This commit is contained in:
大哥大哥的大哥哥
2026-09-09 14:47:29 +08:00
parent 4d9da40abd
commit 65755c9e96
832 changed files with 412085 additions and 0 deletions
@@ -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,
}
@@ -0,0 +1,25 @@
const SHARED_GAME_CHAPTERS = new Set([1])
function normalizeChapterNumber(value) {
const number = Number(value)
if (!Number.isFinite(number)) return 1
return Math.min(15, Math.max(1, Math.floor(number)))
}
function chapterPackageRoot(value) {
const chapter = normalizeChapterNumber(value)
if (SHARED_GAME_CHAPTERS.has(chapter)) return 'package-game'
return `package-chapter-${String(chapter).padStart(2, '0')}`
}
function chapterRoute(value, options = {}) {
const chapter = normalizeChapterNumber(value)
const replay = options && options.replay === true ? '&replay=1' : ''
return `/${chapterPackageRoot(chapter)}/pages/chapter/chapter?chapter=${chapter}${replay}`
}
module.exports = {
normalizeChapterNumber,
chapterPackageRoot,
chapterRoute,
}
@@ -0,0 +1,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,
}
@@ -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,
}
@@ -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,
}
@@ -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,
}
@@ -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,
}