feat: scaffold mvp shell and content runtime

This commit is contained in:
manpengan
2026-03-29 00:36:28 +08:00
parent 25a38cbf05
commit c118e24bd1
25 changed files with 2903 additions and 7 deletions

View File

@@ -0,0 +1,45 @@
export function createDefaultPlayerState() {
return {
saveVersion: 2,
unlockedCities: ['beijing'],
levelProgress: {},
collectedCats: [],
collectedMagnets: [],
collectedStamps: [],
cityTeam: {
teamCityId: null,
joinedDate: null,
lastSwitchDate: null,
},
passportStamps: [],
inventory: {
undo: 3,
remove: 1,
shuffle: 1,
},
dailyChallenge: {
date: '',
completed: false,
cityId: 'beijing',
seed: 0,
},
adCooldowns: {
interstitialCount: 0,
lastInterstitialTime: 0,
lastRewardDate: '',
},
settings: {
soundEnabled: true,
musicEnabled: true,
vibrationEnabled: true,
},
stats: {
totalGamesPlayed: 0,
totalGamesWon: 0,
totalShareCount: 0,
firstPlayDate: '',
},
}
}
export default createDefaultPlayerState

257
js/content/index.js Normal file
View File

@@ -0,0 +1,257 @@
import { continents } from './continents/index.js'
import cities from './cities/index.js'
import { createDefaultPlayerState } from './default-player-state.js'
import { getCatalogChildren } from './navigation/world-catalog.js'
import { getEnabledRoots, getNavNode as getRuntimeNavNode } from './navigation/runtime-nav.js'
import { createBundleResolver } from './registry/bundle-resolver.js'
import { createCityRegistry } from './registry/city-registry.js'
import { createContinentRegistry } from './registry/continent-registry.js'
import { projectCityCardView, projectCityProgress } from './registry/progress-projector.js'
import { validateAssets } from './validation/validate-assets.js'
import { validateCity } from './validation/validate-city.js'
import { validateContinent } from './validation/validate-continent.js'
export function validateContent(options = {}) {
const continentIds = new Set(continents.map((continent) => continent.id))
const cityIds = new Set(cities.map((city) => city.id))
const errors = []
const warnings = []
for (const continent of continents) {
const result = validateContinent(continent, cityIds)
errors.push(...result.errors)
warnings.push(...result.warnings)
}
for (const city of cities) {
const result = validateCity(city, continentIds, cityIds)
errors.push(...result.errors)
warnings.push(...result.warnings)
const assetResult = validateAssets(city, options.assetExists)
errors.push(...assetResult.errors)
warnings.push(...assetResult.warnings)
}
return { errors, warnings }
}
export function createContentSystem() {
const continentRegistry = createContinentRegistry(continents)
const cityRegistry = createCityRegistry(cities)
const bundleResolver = createBundleResolver(cityRegistry)
const homeRootNodes = getCatalogChildren(null)
const giftAlbumDefinitions = [
{ id: 'magnets', name: '冰箱贴册' },
{ id: 'stamps', name: '邮票册' },
]
function getCompletedCityCount(playerState) {
return cityRegistry
.getAllCities()
.filter((city) => projectCityProgress(city, playerState).isCompleted)
.length
}
function projectNavNode(node) {
if (!node) {
return null
}
return {
...node,
isUnlocked: node.isUnlockedByDefault,
}
}
function projectHomeTile(node, playerState) {
const completedCityCount = getCompletedCityCount(playerState)
const isMashupTile = node.id === 'mashup'
const isComingSoonTile = node.id === 'coming_soon'
const isUnlocked = isMashupTile
? completedCityCount >= 2
: node.status === 'active' && node.isUnlockedByDefault
const isInteractive = isComingSoonTile || isMashupTile || node.type === 'continent'
return {
...node,
isUnlocked,
isInteractive,
isSpecial: isMashupTile || isComingSoonTile,
}
}
function getMagnetEntries(playerState) {
const magnets = playerState.collectedMagnets ?? []
return cityRegistry.getAllCities().map((city) => {
const collectedCount = magnets.filter((entry) => entry.cityId === city.id).length
return {
cityId: city.id,
cityName: city.display.name,
cityNameEn: city.display.nameEn,
themeColor: city.display.bgColor,
collectedCount,
totalCount: city.levelPresets.length,
}
})
}
function getStampEntries(playerState) {
const stamps = playerState.collectedStamps ?? []
return cityRegistry.getAllCities().map((city) => {
const stampId = city.passport?.stampId ?? `stamp_${city.id}`
return {
cityId: city.id,
cityName: city.display.name,
cityNameEn: city.display.nameEn,
themeColor: city.display.bgColor,
stampId,
isCollected: stamps.some((entry) => entry.stampId === stampId),
}
})
}
function getGiftAlbumEntries(albumId, playerState) {
if (albumId === 'magnets') {
return getMagnetEntries(playerState)
}
if (albumId === 'stamps') {
return getStampEntries(playerState)
}
return []
}
function getGiftAlbums(playerState) {
return giftAlbumDefinitions.map((definition) => {
const entries = getGiftAlbumEntries(definition.id, playerState)
const collectedCount = definition.id === 'magnets'
? entries.reduce((sum, entry) => sum + entry.collectedCount, 0)
: entries.filter((entry) => entry.isCollected).length
const totalCount = definition.id === 'magnets'
? entries.reduce((sum, entry) => sum + entry.totalCount, 0)
: entries.length
return {
...definition,
collectedCount,
totalCount,
}
})
}
function projectCityNavEntry(cityId, parentId, playerState) {
const city = cityRegistry.getCity(cityId)
if (!city) {
return null
}
return {
...projectCityCardView(city, playerState),
id: city.id,
type: 'city',
parentId,
themeColor: city.display.bgColor,
childType: null,
childIds: [],
pageSize: 0,
status: 'active',
isUnlockedByDefault: cityId === 'beijing',
sortOrder: city.sortOrder,
}
}
function getNavChildren(nodeId, playerState) {
const node = getRuntimeNavNode(nodeId)
if (!node || !node.childType) {
return []
}
if (node.childType === 'city') {
return node.childIds
.map((cityId) => projectCityNavEntry(cityId, node.id, playerState))
.filter(Boolean)
}
return node.childIds
.map((childId) => projectNavNode(getRuntimeNavNode(childId)))
.filter(Boolean)
}
return {
getContinentList() {
return continentRegistry.getContinentList()
},
getContinent(continentId) {
return continentRegistry.getContinent(continentId)
},
getCity(cityId) {
return cityRegistry.getCity(cityId)
},
listCitiesByContinent(continentId) {
return cityRegistry.listCitiesByContinent(continentId)
},
getLevelPreset(cityId, levelId) {
return cityRegistry.getLevelPreset(cityId, levelId)
},
getCityProgress(cityId, playerState) {
const city = cityRegistry.getCity(cityId)
return city ? projectCityProgress(city, playerState) : null
},
getCityCardView(cityId, playerState) {
const city = cityRegistry.getCity(cityId)
return city ? projectCityCardView(city, playerState) : null
},
listCityCards(continentId, playerState) {
const navChildren = getNavChildren(continentId, playerState)
if (navChildren.length > 0) {
return navChildren.filter((entry) => entry.type === 'city')
}
return cityRegistry
.listCitiesByContinent(continentId)
.map((city) => projectCityCardView(city, playerState))
},
getRootNavNodes() {
return getEnabledRoots().map((node) => projectNavNode(node))
},
getHomeTiles(playerState) {
return homeRootNodes.map((node) => projectHomeTile(node, playerState))
},
getHomeTile(tileId, playerState) {
const node = homeRootNodes.find((entry) => entry.id === tileId) ?? null
return node ? projectHomeTile(node, playerState) : null
},
getGiftAlbums(playerState) {
return getGiftAlbums(playerState)
},
getGiftAlbumEntries(albumId, playerState) {
return getGiftAlbumEntries(albumId, playerState)
},
getNavNode(nodeId) {
return projectNavNode(getRuntimeNavNode(nodeId))
},
getNavChildren(nodeId, playerState) {
return getNavChildren(nodeId, playerState)
},
ensureCityBundle(cityId) {
return bundleResolver.ensureCityBundle(cityId)
},
validateContent(options) {
return validateContent(options)
},
}
}
export {
continents,
cities,
createDefaultPlayerState,
}
export default createContentSystem

View File

@@ -0,0 +1,24 @@
export function createBundleResolver(cityRegistry) {
return {
resolveCityBundle(cityId) {
const city = cityRegistry.getCity(cityId)
return city ? city.bundle : null
},
ensureCityBundle(cityId) {
const bundle = this.resolveCityBundle(cityId)
if (!bundle) {
return null
}
return {
cityId,
...bundle,
status: 'ready',
}
},
}
}
export default createBundleResolver

View File

@@ -0,0 +1,30 @@
export function createCityRegistry(cities) {
const cityMap = new Map(
[...cities]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((city) => [city.id, city]),
)
return {
getAllCities() {
return [...cityMap.values()]
},
getCity(cityId) {
return cityMap.get(cityId) ?? null
},
listCitiesByContinent(continentId) {
return [...cityMap.values()].filter((city) => city.continentId === continentId)
},
getLevelPreset(cityId, levelId) {
const city = cityMap.get(cityId)
if (!city) {
return null
}
return city.levelPresets.find((preset) => preset.id === levelId) ?? null
},
}
}
export default createCityRegistry

View File

@@ -0,0 +1,21 @@
export function createContinentRegistry(continents) {
const continentMap = new Map(
[...continents]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((continent) => [continent.id, continent]),
)
return {
getContinentList() {
return [...continentMap.values()]
},
getContinent(continentId) {
return continentMap.get(continentId) ?? null
},
hasContinent(continentId) {
return continentMap.has(continentId)
},
}
}
export default createContinentRegistry

View File

@@ -0,0 +1,45 @@
function getLevelEntries(levelProgress = {}) {
return Object.values(levelProgress)
}
export function projectCityProgress(city, playerState) {
const levelProgress = playerState.levelProgress[city.id] ?? {}
const levels = getLevelEntries(levelProgress)
const completedLevels = levels.filter((level) => level.completed).length
const totalStars = levels.reduce((sum, level) => sum + (level.stars ?? 0), 0)
const totalLevels = city.levelPresets.length
const isUnlocked = playerState.unlockedCities.includes(city.id)
const isCompleted = totalLevels > 0 && completedLevels >= totalLevels
return {
cityId: city.id,
completedLevels,
totalLevels,
totalStars,
isUnlocked,
isCompleted,
isCollected: playerState.collectedCats.includes(city.id),
hasPassportStamp: playerState.passportStamps.includes(city.id),
}
}
export function projectCityCardView(city, playerState) {
const progress = projectCityProgress(city, playerState)
return {
cityId: city.id,
name: city.display.name,
nameEn: city.display.nameEn,
bgColor: city.display.bgColor,
catImage: city.cover.catImage,
catThumb: city.cover.catThumb,
isUnlocked: progress.isUnlocked,
isCompleted: progress.isCompleted,
isCollected: progress.isCollected,
completedLevels: progress.completedLevels,
totalLevels: progress.totalLevels,
totalStars: progress.totalStars,
}
}
export default projectCityCardView

View File

@@ -0,0 +1,24 @@
export function validateAssets(city, assetExists) {
const errors = []
const warnings = []
if (typeof assetExists !== 'function') {
return { errors, warnings }
}
const assetPaths = [
city.cover?.catImage,
city.cover?.catThumb,
...(city.elements ?? []).map((element) => element.image),
].filter(Boolean)
for (const assetPath of assetPaths) {
if (!assetExists(assetPath)) {
errors.push(`City "${city.id}" asset is missing: ${assetPath}`)
}
}
return { errors, warnings }
}
export default validateAssets

View File

@@ -0,0 +1,90 @@
const CATEGORY_MINIMUMS = {
landmark: 2,
food: 3,
culture: 2,
item: 2,
nature: 2,
}
function countCategories(elements) {
return elements.reduce((counts, element) => {
counts[element.category] = (counts[element.category] ?? 0) + 1
return counts
}, {})
}
function isMultipleOfThree(value) {
return Number.isInteger(value) && value > 0 && value % 3 === 0
}
export function validateCity(city, continentIds, cityIds) {
const errors = []
const warnings = []
if (!city.id) {
errors.push('City is missing id')
return { errors, warnings }
}
if (!continentIds.has(city.continentId)) {
errors.push(`City "${city.id}" references unknown continent "${city.continentId}"`)
}
if (!city.display?.name || !city.display?.bgColor) {
errors.push(`City "${city.id}" is missing display metadata`)
}
if (!city.cover?.catImage || !city.cover?.catThumb) {
errors.push(`City "${city.id}" is missing cat cover assets`)
}
if (!Array.isArray(city.elements) || city.elements.length < 12 || city.elements.length > 15) {
errors.push(`City "${city.id}" must declare 12-15 elements`)
}
if (!Array.isArray(city.levelPresets) || city.levelPresets.length !== 6) {
errors.push(`City "${city.id}" must declare exactly 6 level presets`)
}
if (city.unlockAfterCityId && !cityIds.has(city.unlockAfterCityId)) {
errors.push(`City "${city.id}" unlockAfterCityId references unknown city "${city.unlockAfterCityId}"`)
}
const seenElementIds = new Set()
const seenElementNames = new Set()
const categoryCounts = countCategories(city.elements ?? [])
for (const element of city.elements ?? []) {
if (seenElementIds.has(element.id)) {
errors.push(`City "${city.id}" has duplicate element id "${element.id}"`)
}
seenElementIds.add(element.id)
if (seenElementNames.has(element.name)) {
warnings.push(`City "${city.id}" has duplicate element name "${element.name}"`)
}
seenElementNames.add(element.name)
}
for (const [category, minimum] of Object.entries(CATEGORY_MINIMUMS)) {
if ((categoryCounts[category] ?? 0) < minimum) {
errors.push(`City "${city.id}" category "${category}" must contain at least ${minimum} elements`)
}
}
for (const preset of city.levelPresets ?? []) {
const values = Array.isArray(preset.piecesPerElement) ? preset.piecesPerElement : [preset.piecesPerElement]
if (Array.isArray(preset.piecesPerElement) && preset.piecesPerElement.length !== preset.elementCount) {
errors.push(`City "${city.id}" level ${preset.id} piecesPerElement array length must match elementCount`)
}
if (!values.every(isMultipleOfThree)) {
errors.push(`City "${city.id}" level ${preset.id} piecesPerElement must contain only multiples of 3`)
}
}
return { errors, warnings }
}
export default validateCity

View File

@@ -0,0 +1,47 @@
function sameMembers(left, right) {
if (left.length !== right.length) {
return false
}
const rightSet = new Set(right)
return left.every((item) => rightSet.has(item))
}
export function validateContinent(continent, cityIds) {
const errors = []
const warnings = []
if (!continent.id) {
errors.push('Continent is missing id')
}
const hasRuntimeCityOrder = Array.isArray(continent.cityIds) && continent.cityIds.length > 0
const hasUnlockOrder = Array.isArray(continent.unlockOrder) && continent.unlockOrder.length > 0
if (hasRuntimeCityOrder && hasUnlockOrder && !sameMembers(continent.cityIds, continent.unlockOrder)) {
errors.push(`Continent "${continent.id}" cityIds and unlockOrder must contain the same cities`)
}
for (const cityId of continent.cityIds ?? []) {
if (!cityIds.has(cityId)) {
errors.push(`Continent "${continent.id}" references unknown city "${cityId}"`)
}
}
if (hasRuntimeCityOrder && !hasUnlockOrder) {
errors.push(`Continent "${continent.id}" must declare unlockOrder when cityIds are present`)
}
if (hasUnlockOrder && !hasRuntimeCityOrder) {
errors.push(`Continent "${continent.id}" must declare cityIds when unlockOrder is present`)
}
if (continent.bundle && !continent.bundle.packId) {
warnings.push(`Continent "${continent.id}" has no bundle packId`)
}
return { errors, warnings }
}
export default validateContinent