feat: scaffold mvp shell and content runtime
This commit is contained in:
45
js/content/default-player-state.js
Normal file
45
js/content/default-player-state.js
Normal 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
257
js/content/index.js
Normal 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
|
||||
24
js/content/registry/bundle-resolver.js
Normal file
24
js/content/registry/bundle-resolver.js
Normal 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
|
||||
30
js/content/registry/city-registry.js
Normal file
30
js/content/registry/city-registry.js
Normal 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
|
||||
21
js/content/registry/continent-registry.js
Normal file
21
js/content/registry/continent-registry.js
Normal 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
|
||||
45
js/content/registry/progress-projector.js
Normal file
45
js/content/registry/progress-projector.js
Normal 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
|
||||
24
js/content/validation/validate-assets.js
Normal file
24
js/content/validation/validate-assets.js
Normal 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
|
||||
90
js/content/validation/validate-city.js
Normal file
90
js/content/validation/validate-city.js
Normal 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
|
||||
47
js/content/validation/validate-continent.js
Normal file
47
js/content/validation/validate-continent.js
Normal 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
|
||||
Reference in New Issue
Block a user