gengxin
This commit is contained in:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user