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
|
||||
55
js/gameplay/difficulty/anchors.js
Normal file
55
js/gameplay/difficulty/anchors.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const BOUNDS = {
|
||||
left: 32,
|
||||
right: 358,
|
||||
top: 130,
|
||||
bottom: 560,
|
||||
}
|
||||
|
||||
export function createAnchorGrid(columns = 6, rows = 8) {
|
||||
const anchors = []
|
||||
const width = BOUNDS.right - BOUNDS.left
|
||||
const height = BOUNDS.bottom - BOUNDS.top
|
||||
const stepX = width / (columns - 1)
|
||||
const stepY = height / (rows - 1)
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
anchors.push({
|
||||
id: `anchor_${row}_${column}`,
|
||||
x: BOUNDS.left + column * stepX,
|
||||
y: BOUNDS.top + row * stepY,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return anchors
|
||||
}
|
||||
|
||||
export function getLayerDistribution(density, layers) {
|
||||
const presets = {
|
||||
low: [0.4, 0.35, 0.25],
|
||||
medium: [0.3, 0.3, 0.25, 0.15],
|
||||
medium_high: [0.28, 0.27, 0.25, 0.2],
|
||||
high: [0.25, 0.25, 0.25, 0.25],
|
||||
}
|
||||
const base = presets[density] ?? presets.medium
|
||||
const sliced = base.slice(0, layers)
|
||||
const total = sliced.reduce((sum, value) => sum + value, 0)
|
||||
|
||||
return sliced.map((value) => value / total)
|
||||
}
|
||||
|
||||
export function allocateLayerCounts(totalPieces, density, layers) {
|
||||
const distribution = getLayerDistribution(density, layers)
|
||||
const counts = distribution.map((value) => Math.floor(totalPieces * value))
|
||||
let assigned = counts.reduce((sum, value) => sum + value, 0)
|
||||
|
||||
while (assigned < totalPieces) {
|
||||
for (let index = 0; index < counts.length && assigned < totalPieces; index += 1) {
|
||||
counts[index] += 1
|
||||
assigned += 1
|
||||
}
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
47
js/gameplay/difficulty/classify-deadlock.js
Normal file
47
js/gameplay/difficulty/classify-deadlock.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getClickablePieces } from './overlap-graph.js'
|
||||
|
||||
function getPotentialMatches(slot, clickablePieces) {
|
||||
return clickablePieces.filter((piece) => {
|
||||
const sameCount = slot.filter((slottedPiece) => slottedPiece.elementId === piece.elementId).length
|
||||
return sameCount >= 2
|
||||
})
|
||||
}
|
||||
|
||||
export function classifyDeadlock(runtimeState, boardState) {
|
||||
const slot = runtimeState.slot ?? []
|
||||
const bypass = runtimeState.bypass ?? []
|
||||
const clickablePieces = getClickablePieces(boardState)
|
||||
|
||||
if (slot.length < 7) {
|
||||
return {
|
||||
type: 'none',
|
||||
clickablePieces,
|
||||
}
|
||||
}
|
||||
|
||||
const matchablePieces = getPotentialMatches(slot, clickablePieces)
|
||||
|
||||
if (matchablePieces.length > 0) {
|
||||
return {
|
||||
type: 'none',
|
||||
clickablePieces,
|
||||
matchablePieces,
|
||||
}
|
||||
}
|
||||
|
||||
if (bypass.length > 0) {
|
||||
return {
|
||||
type: 'soft',
|
||||
clickablePieces,
|
||||
matchablePieces: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'hard',
|
||||
clickablePieces,
|
||||
matchablePieces: [],
|
||||
}
|
||||
}
|
||||
|
||||
export default classifyDeadlock
|
||||
29
js/gameplay/difficulty/evaluate-board.js
Normal file
29
js/gameplay/difficulty/evaluate-board.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getClickablePieces } from './overlap-graph.js'
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function evaluateBoard(boardState, levelPreset) {
|
||||
const pieces = (boardState.pieces ?? []).filter((piece) => !piece.removed)
|
||||
const clickablePieces = getClickablePieces(boardState)
|
||||
const totalPieces = pieces.length
|
||||
const initialClickableRatio = totalPieces === 0 ? 0 : clickablePieces.length / totalPieces
|
||||
const targetPassRate = levelPreset?.targetPassRate ?? 0.75
|
||||
const visibilityBonus = (initialClickableRatio - 0.3) * 0.35
|
||||
const densityPenalty = levelPreset?.density === 'high' ? 0.05 : levelPreset?.density === 'medium_high' ? 0.03 : 0
|
||||
const toolFreePassRate = clamp(targetPassRate + visibilityBonus - densityPenalty, 0.45, 0.99)
|
||||
const toolAssistPassRate = clamp(toolFreePassRate + 0.12, toolFreePassRate, 1)
|
||||
const avgTurnsToFinish = totalPieces / 1.2
|
||||
|
||||
return {
|
||||
totalPieces,
|
||||
initialClickableRatio,
|
||||
simulatedPassRate: toolFreePassRate,
|
||||
avgTurnsToFinish,
|
||||
toolFreePassRate,
|
||||
toolAssistPassRate,
|
||||
}
|
||||
}
|
||||
|
||||
export default evaluateBoard
|
||||
138
js/gameplay/difficulty/generate-board.js
Normal file
138
js/gameplay/difficulty/generate-board.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createAnchorGrid, allocateLayerCounts } from './anchors.js'
|
||||
import { evaluateBoard } from './evaluate-board.js'
|
||||
import { buildOverlapGraph } from './overlap-graph.js'
|
||||
import { createRng, shuffleWithRng } from './random.js'
|
||||
|
||||
function normalizePiecesPerElement(piecesPerElement, elementCount) {
|
||||
if (Array.isArray(piecesPerElement)) {
|
||||
return piecesPerElement.slice(0, elementCount)
|
||||
}
|
||||
|
||||
return Array.from({ length: elementCount }, () => piecesPerElement)
|
||||
}
|
||||
|
||||
function pickElements(city, levelPreset, rng) {
|
||||
const shuffled = shuffleWithRng(city.elements, rng)
|
||||
const categorySeen = new Set()
|
||||
const selected = []
|
||||
|
||||
for (const element of shuffled) {
|
||||
if (!categorySeen.has(element.category)) {
|
||||
selected.push(element)
|
||||
categorySeen.add(element.category)
|
||||
}
|
||||
if (selected.length === levelPreset.elementCount) {
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of shuffled) {
|
||||
if (!selected.includes(element)) {
|
||||
selected.push(element)
|
||||
}
|
||||
if (selected.length === levelPreset.elementCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
function createPiecePool(selectedElements, counts) {
|
||||
const pieces = []
|
||||
let sequence = 1
|
||||
|
||||
selectedElements.forEach((element, elementIndex) => {
|
||||
const count = counts[elementIndex]
|
||||
for (let offset = 0; offset < count; offset += 1) {
|
||||
pieces.push({
|
||||
id: `piece_${String(sequence).padStart(4, '0')}`,
|
||||
elementId: element.id,
|
||||
})
|
||||
sequence += 1
|
||||
}
|
||||
})
|
||||
|
||||
return pieces
|
||||
}
|
||||
|
||||
function createLayerAnchors(anchorGrid, rng) {
|
||||
return shuffleWithRng(anchorGrid, rng)
|
||||
}
|
||||
|
||||
function placePieces(piecePool, levelPreset, rng) {
|
||||
const anchors = createAnchorGrid()
|
||||
const layerCounts = allocateLayerCounts(piecePool.length, levelPreset.density, levelPreset.layers)
|
||||
const placedPieces = []
|
||||
let poolIndex = 0
|
||||
|
||||
for (let layer = 0; layer < layerCounts.length; layer += 1) {
|
||||
const layerAnchors = createLayerAnchors(anchors, rng)
|
||||
const previousLayerPieces = placedPieces.filter((piece) => piece.layer === layer - 1)
|
||||
|
||||
for (let index = 0; index < layerCounts[layer]; index += 1) {
|
||||
const source = piecePool[poolIndex]
|
||||
const overlapChance = levelPreset.density === 'high'
|
||||
? 0.85
|
||||
: levelPreset.density === 'medium_high'
|
||||
? 0.7
|
||||
: levelPreset.density === 'medium'
|
||||
? 0.55
|
||||
: 0.35
|
||||
|
||||
let anchor = layerAnchors[index % layerAnchors.length]
|
||||
|
||||
if (layer > 0 && previousLayerPieces.length > 0 && rng() < overlapChance) {
|
||||
const target = previousLayerPieces[Math.floor(rng() * previousLayerPieces.length)]
|
||||
anchor = { x: target.x + 6, y: target.y + 6 }
|
||||
}
|
||||
|
||||
placedPieces.push({
|
||||
...source,
|
||||
layer,
|
||||
x: Math.round(anchor.x + (rng() - 0.5) * 12),
|
||||
y: Math.round(anchor.y + (rng() - 0.5) * 12),
|
||||
width: 64,
|
||||
height: 64,
|
||||
rotation: Math.round((rng() - 0.5) * 12),
|
||||
removed: false,
|
||||
})
|
||||
|
||||
poolIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
return placedPieces
|
||||
}
|
||||
|
||||
export function generateBoard({ cityId, levelId, seed, contentSystem }) {
|
||||
const city = contentSystem.getCity(cityId)
|
||||
const levelPreset = contentSystem.getLevelPreset(cityId, levelId)
|
||||
|
||||
if (!city || !levelPreset) {
|
||||
throw new Error(`Unknown board target: ${cityId}#${levelId}`)
|
||||
}
|
||||
|
||||
const effectiveSeed = seed ?? levelPreset.seedBase
|
||||
const rng = createRng(effectiveSeed)
|
||||
const selectedElements = pickElements(city, levelPreset, rng)
|
||||
const counts = normalizePiecesPerElement(levelPreset.piecesPerElement, levelPreset.elementCount)
|
||||
const piecePool = createPiecePool(selectedElements, counts)
|
||||
const pieces = placePieces(shuffleWithRng(piecePool, rng), levelPreset, rng)
|
||||
const overlapGraph = buildOverlapGraph(pieces)
|
||||
const boardState = {
|
||||
boardId: `${cityId}-${levelId}-${effectiveSeed}`,
|
||||
cityId,
|
||||
levelId,
|
||||
seed: effectiveSeed,
|
||||
pieces,
|
||||
overlapGraph,
|
||||
metrics: {},
|
||||
}
|
||||
|
||||
boardState.metrics = evaluateBoard(boardState, levelPreset)
|
||||
|
||||
return boardState
|
||||
}
|
||||
|
||||
export default generateBoard
|
||||
9
js/gameplay/difficulty/index.js
Normal file
9
js/gameplay/difficulty/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export { classifyDeadlock } from './classify-deadlock.js'
|
||||
export { evaluateBoard } from './evaluate-board.js'
|
||||
export { generateBoard } from './generate-board.js'
|
||||
export {
|
||||
buildOverlapGraph,
|
||||
getClickablePieces,
|
||||
rebuildGraphAfterShuffle,
|
||||
rebuildGraphAfterUndo,
|
||||
} from './overlap-graph.js'
|
||||
74
js/gameplay/difficulty/overlap-graph.js
Normal file
74
js/gameplay/difficulty/overlap-graph.js
Normal file
@@ -0,0 +1,74 @@
|
||||
function getPieceArea(piece) {
|
||||
return piece.width * piece.height
|
||||
}
|
||||
|
||||
function overlapArea(left, right) {
|
||||
const overlapWidth = Math.max(0, Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x))
|
||||
const overlapHeight = Math.max(0, Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y))
|
||||
|
||||
return overlapWidth * overlapHeight
|
||||
}
|
||||
|
||||
function overlapsCenterZone(lower, upper) {
|
||||
const centerWidth = lower.width * 0.5
|
||||
const centerHeight = lower.height * 0.5
|
||||
const centerBox = {
|
||||
x: lower.x + lower.width * 0.25,
|
||||
y: lower.y + lower.height * 0.25,
|
||||
width: centerWidth,
|
||||
height: centerHeight,
|
||||
}
|
||||
|
||||
return overlapArea(centerBox, upper) > 0
|
||||
}
|
||||
|
||||
export function buildOverlapGraph(pieces) {
|
||||
const graph = Object.fromEntries(pieces.map((piece) => [piece.id, []]))
|
||||
|
||||
for (let leftIndex = 0; leftIndex < pieces.length; leftIndex += 1) {
|
||||
for (let rightIndex = 0; rightIndex < pieces.length; rightIndex += 1) {
|
||||
if (leftIndex === rightIndex) {
|
||||
continue
|
||||
}
|
||||
|
||||
const blocker = pieces[leftIndex]
|
||||
const blocked = pieces[rightIndex]
|
||||
|
||||
if (blocker.layer <= blocked.layer) {
|
||||
continue
|
||||
}
|
||||
|
||||
const ratio = overlapArea(blocker, blocked) / getPieceArea(blocked)
|
||||
|
||||
if (ratio >= 0.2 && overlapsCenterZone(blocked, blocker)) {
|
||||
graph[blocker.id].push(blocked.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return graph
|
||||
}
|
||||
|
||||
export function getClickablePieces(boardState) {
|
||||
const pieces = (boardState.pieces ?? []).filter((piece) => !piece.removed)
|
||||
const indegree = Object.fromEntries(pieces.map((piece) => [piece.id, 0]))
|
||||
const graph = boardState.overlapGraph ?? {}
|
||||
|
||||
for (const blockedIds of Object.values(graph)) {
|
||||
for (const blockedId of blockedIds) {
|
||||
if (blockedId in indegree) {
|
||||
indegree[blockedId] += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pieces.filter((piece) => indegree[piece.id] === 0)
|
||||
}
|
||||
|
||||
export function rebuildGraphAfterShuffle(boardState) {
|
||||
return buildOverlapGraph(boardState.pieces ?? [])
|
||||
}
|
||||
|
||||
export function rebuildGraphAfterUndo(boardState) {
|
||||
return buildOverlapGraph(boardState.pieces ?? [])
|
||||
}
|
||||
36
js/gameplay/difficulty/random.js
Normal file
36
js/gameplay/difficulty/random.js
Normal file
@@ -0,0 +1,36 @@
|
||||
export function hashSeed(input) {
|
||||
const value = String(input)
|
||||
let hash = 2166136261
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
export function createRng(seed) {
|
||||
let state = typeof seed === 'number' ? seed >>> 0 : hashSeed(seed)
|
||||
|
||||
return function next() {
|
||||
state += 0x6D2B79F5
|
||||
let value = state
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1)
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
export function shuffleWithRng(items, rng) {
|
||||
const result = [...items]
|
||||
|
||||
for (let index = result.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(rng() * (index + 1))
|
||||
const temp = result[index]
|
||||
result[index] = result[swapIndex]
|
||||
result[swapIndex] = temp
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
189
js/gameplay/session/index.js
Normal file
189
js/gameplay/session/index.js
Normal file
@@ -0,0 +1,189 @@
|
||||
import { generateBoard, getClickablePieces } from '../difficulty/index.js'
|
||||
|
||||
function cloneBoardState(boardState) {
|
||||
return {
|
||||
...boardState,
|
||||
pieces: boardState.pieces.map((piece) => ({ ...piece })),
|
||||
overlapGraph: Object.fromEntries(
|
||||
Object.entries(boardState.overlapGraph ?? {}).map(([pieceId, blockedIds]) => [pieceId, [...blockedIds]]),
|
||||
),
|
||||
metrics: { ...(boardState.metrics ?? {}) },
|
||||
}
|
||||
}
|
||||
|
||||
function getPieceById(boardState, pieceId) {
|
||||
return boardState.pieces.find((piece) => piece.id === pieceId) ?? null
|
||||
}
|
||||
|
||||
function createSlotEntry(piece) {
|
||||
return {
|
||||
pieceId: piece.id,
|
||||
elementId: piece.elementId,
|
||||
}
|
||||
}
|
||||
|
||||
function removeMatchedEntries(slot, elementId) {
|
||||
let remaining = 3
|
||||
|
||||
return slot.filter((entry) => {
|
||||
if (entry.elementId === elementId && remaining > 0) {
|
||||
remaining -= 1
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function getMatchingTriples(slot) {
|
||||
const counts = slot.reduce((map, entry) => {
|
||||
map.set(entry.elementId, (map.get(entry.elementId) ?? 0) + 1)
|
||||
return map
|
||||
}, new Map())
|
||||
|
||||
const matchedElementId = [...counts.entries()].find(([, count]) => count >= 3)?.[0] ?? null
|
||||
|
||||
return matchedElementId
|
||||
}
|
||||
|
||||
function getStateSnapshot(state, boardState) {
|
||||
return {
|
||||
cityId: state.cityId,
|
||||
levelId: state.levelId,
|
||||
seed: state.seed,
|
||||
status: state.status,
|
||||
slot: [...state.slot],
|
||||
bypass: [...state.bypass],
|
||||
removedPieceIds: [...state.removedPieceIds],
|
||||
boardState,
|
||||
}
|
||||
}
|
||||
|
||||
export function createGameSession({
|
||||
cityId,
|
||||
levelId,
|
||||
seed,
|
||||
contentSystem,
|
||||
boardState,
|
||||
}) {
|
||||
const resolvedBoard = boardState ? cloneBoardState(boardState) : generateBoard({ cityId, levelId, seed, contentSystem })
|
||||
const state = {
|
||||
cityId,
|
||||
levelId,
|
||||
seed: resolvedBoard.seed,
|
||||
status: 'playing',
|
||||
slot: [],
|
||||
bypass: [],
|
||||
removedPieceIds: [],
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return getStateSnapshot(state, resolvedBoard)
|
||||
}
|
||||
|
||||
function isBoardCleared() {
|
||||
return resolvedBoard.pieces.every((piece) => piece.removed) && state.slot.length === 0
|
||||
}
|
||||
|
||||
function getClickable() {
|
||||
return getClickablePieces(resolvedBoard)
|
||||
}
|
||||
|
||||
function pickPiece(pieceId) {
|
||||
if (state.status !== 'playing') {
|
||||
return {
|
||||
status: state.status,
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
const piece = getPieceById(resolvedBoard, pieceId)
|
||||
|
||||
if (!piece || piece.removed) {
|
||||
return {
|
||||
status: 'invalid',
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
const clickableIds = new Set(getClickable().map((clickablePiece) => clickablePiece.id))
|
||||
if (!clickableIds.has(pieceId)) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
piece.removed = true
|
||||
state.slot.push(createSlotEntry(piece))
|
||||
|
||||
const matchedElementId = getMatchingTriples(state.slot)
|
||||
if (matchedElementId) {
|
||||
const matchedIds = state.slot
|
||||
.filter((entry) => entry.elementId === matchedElementId)
|
||||
.slice(0, 3)
|
||||
.map((entry) => entry.pieceId)
|
||||
|
||||
state.slot = removeMatchedEntries(state.slot, matchedElementId)
|
||||
state.removedPieceIds.push(...matchedIds)
|
||||
|
||||
if (isBoardCleared()) {
|
||||
state.status = 'won'
|
||||
return {
|
||||
status: 'won',
|
||||
matchedElementId,
|
||||
matchedIds,
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'matched',
|
||||
matchedElementId,
|
||||
matchedIds,
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
if (state.slot.length >= 7) {
|
||||
state.status = 'failed'
|
||||
return {
|
||||
status: 'failed',
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'picked',
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
function restart(nextSeed = seed ?? resolvedBoard.seed) {
|
||||
const freshSession = createGameSession({
|
||||
cityId,
|
||||
levelId,
|
||||
seed: nextSeed,
|
||||
contentSystem,
|
||||
})
|
||||
|
||||
return freshSession
|
||||
}
|
||||
|
||||
return {
|
||||
cityId,
|
||||
levelId,
|
||||
seed: resolvedBoard.seed,
|
||||
getBoardState() {
|
||||
return resolvedBoard
|
||||
},
|
||||
getState,
|
||||
getClickablePieces() {
|
||||
return getClickable()
|
||||
},
|
||||
pickPiece,
|
||||
restart,
|
||||
}
|
||||
}
|
||||
|
||||
export default createGameSession
|
||||
813
js/main.js
813
js/main.js
@@ -1,12 +1,811 @@
|
||||
const { windowWidth, windowHeight } = wx.getSystemInfoSync()
|
||||
import { createContentSystem, createDefaultPlayerState } from './content/index.js'
|
||||
import { createSceneStore } from './ui/scene-store.js'
|
||||
|
||||
const {
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
pixelRatio = 1,
|
||||
} = wx.getSystemInfoSync()
|
||||
|
||||
const canvas = wx.createCanvas()
|
||||
canvas.width = windowWidth * pixelRatio
|
||||
canvas.height = windowHeight * pixelRatio
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(pixelRatio, pixelRatio)
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, windowWidth, windowHeight)
|
||||
const STORAGE_KEY = 'player_state'
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = loadPlayerState()
|
||||
const sceneStore = createSceneStore({ contentSystem, playerState })
|
||||
const hitTargets = []
|
||||
let transientMessage = ''
|
||||
let transientMessageUntil = 0
|
||||
|
||||
ctx.fillStyle = '#333333'
|
||||
ctx.font = '24px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('微信小游戏', windowWidth / 2, windowHeight / 2)
|
||||
function loadPlayerState() {
|
||||
try {
|
||||
const stored = wx.getStorageSync(STORAGE_KEY)
|
||||
if (!stored) {
|
||||
return createDefaultPlayerState()
|
||||
}
|
||||
|
||||
return {
|
||||
...createDefaultPlayerState(),
|
||||
...stored,
|
||||
inventory: {
|
||||
...createDefaultPlayerState().inventory,
|
||||
...(stored.inventory ?? {}),
|
||||
},
|
||||
settings: {
|
||||
...createDefaultPlayerState().settings,
|
||||
...(stored.settings ?? {}),
|
||||
},
|
||||
stats: {
|
||||
...createDefaultPlayerState().stats,
|
||||
...(stored.stats ?? {}),
|
||||
},
|
||||
cityTeam: {
|
||||
...createDefaultPlayerState().cityTeam,
|
||||
...(stored.cityTeam ?? {}),
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return createDefaultPlayerState()
|
||||
}
|
||||
}
|
||||
|
||||
function savePlayerState() {
|
||||
try {
|
||||
wx.setStorageSync(STORAGE_KEY, playerState)
|
||||
} catch {
|
||||
// Ignore storage write failures in the MVP shell.
|
||||
}
|
||||
}
|
||||
|
||||
function triggerInviteShare() {
|
||||
playerState.stats.totalShareCount += 1
|
||||
savePlayerState()
|
||||
|
||||
if (typeof wx.shareAppMessage === 'function') {
|
||||
try {
|
||||
wx.shareAppMessage({
|
||||
title: '一起来抓猫猫!',
|
||||
query: 'from=invite',
|
||||
})
|
||||
showTransientMessage('已打开分享卡片')
|
||||
return
|
||||
} catch {
|
||||
// Fall through to local feedback.
|
||||
}
|
||||
}
|
||||
|
||||
showTransientMessage('分享入口已触发,真机再校验分享卡片')
|
||||
}
|
||||
|
||||
function drawRoundedRect(x, y, width, height, radius) {
|
||||
const clampedRadius = Math.min(radius, width / 2, height / 2)
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + clampedRadius, y)
|
||||
ctx.lineTo(x + width - clampedRadius, y)
|
||||
ctx.arcTo(x + width, y, x + width, y + clampedRadius, clampedRadius)
|
||||
ctx.lineTo(x + width, y + height - clampedRadius)
|
||||
ctx.arcTo(x + width, y + height, x + width - clampedRadius, y + height, clampedRadius)
|
||||
ctx.lineTo(x + clampedRadius, y + height)
|
||||
ctx.arcTo(x, y + height, x, y + height - clampedRadius, clampedRadius)
|
||||
ctx.lineTo(x, y + clampedRadius)
|
||||
ctx.arcTo(x, y, x + clampedRadius, y, clampedRadius)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function drawBackground() {
|
||||
const gradient = ctx.createLinearGradient(0, 0, windowWidth, windowHeight)
|
||||
gradient.addColorStop(0, '#fff7ef')
|
||||
gradient.addColorStop(1, '#ffe1d6')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, windowWidth, windowHeight)
|
||||
}
|
||||
|
||||
function registerHitTarget(target) {
|
||||
hitTargets.push(target)
|
||||
}
|
||||
|
||||
function resetHitTargets() {
|
||||
hitTargets.length = 0
|
||||
}
|
||||
|
||||
function showTransientMessage(message, durationMs = 1800) {
|
||||
transientMessage = message
|
||||
transientMessageUntil = Date.now() + durationMs
|
||||
}
|
||||
|
||||
function hashStringToHue(value) {
|
||||
let hash = 0
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) % 360
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
function getElementStyle(elementId) {
|
||||
const hue = hashStringToHue(elementId)
|
||||
|
||||
return {
|
||||
fill: `hsl(${hue} 82% 87%)`,
|
||||
border: `hsl(${hue} 58% 52%)`,
|
||||
}
|
||||
}
|
||||
|
||||
function hitTest(x, y) {
|
||||
for (let index = hitTargets.length - 1; index >= 0; index -= 1) {
|
||||
const target = hitTargets[index]
|
||||
if (
|
||||
x >= target.x
|
||||
&& x <= target.x + target.width
|
||||
&& y >= target.y
|
||||
&& y <= target.y + target.height
|
||||
) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function drawHeader(title, subtitle) {
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 30px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(title, 24, 36)
|
||||
|
||||
if (subtitle) {
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '15px sans-serif'
|
||||
ctx.fillText(subtitle, 24, 64)
|
||||
}
|
||||
}
|
||||
|
||||
function drawButton({ x, y, width, height, label, onTap, fillStyle = '#231f2a', textColor = '#ffffff' }) {
|
||||
ctx.fillStyle = fillStyle
|
||||
drawRoundedRect(x, y, width, height, 14)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = textColor
|
||||
ctx.font = 'bold 16px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label, x + width / 2, y + height / 2)
|
||||
|
||||
if (onTap) {
|
||||
registerHitTarget({ x, y, width, height, onTap })
|
||||
}
|
||||
}
|
||||
|
||||
function drawBackButton(onTap) {
|
||||
drawButton({
|
||||
x: 24,
|
||||
y: windowHeight - 58,
|
||||
width: 88,
|
||||
height: 38,
|
||||
label: '返回',
|
||||
onTap,
|
||||
fillStyle: '#ffffff',
|
||||
textColor: '#231f2a',
|
||||
})
|
||||
}
|
||||
|
||||
function drawSidebarCard({ x, y, width, height, titleLines, subtitle, onTap, accentColor = '#231f2a' }) {
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = accentColor
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, width, height, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = accentColor
|
||||
ctx.font = 'bold 14px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
titleLines.forEach((line, index) => {
|
||||
ctx.fillText(line, x + width / 2, y + 34 + index * 18)
|
||||
})
|
||||
|
||||
if (subtitle) {
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.fillText(subtitle, x + width / 2, y + height - 20)
|
||||
}
|
||||
|
||||
if (onTap) {
|
||||
registerHitTarget({ x, y, width, height, onTap })
|
||||
}
|
||||
}
|
||||
|
||||
function drawHomeScene() {
|
||||
drawHeader('城市抓猫猫', '世界主页面 · 3×3 入口')
|
||||
|
||||
const homeTiles = contentSystem.getHomeTiles(sceneStore.getPlayerState())
|
||||
const playerGiftAlbums = contentSystem.getGiftAlbums(sceneStore.getPlayerState())
|
||||
const sidebarWidth = Math.max(58, Math.min(70, Math.floor(windowWidth * 0.18)))
|
||||
const sidebarGap = 8
|
||||
const centerX = 24 + sidebarWidth + sidebarGap
|
||||
const centerWidth = windowWidth - 48 - sidebarWidth * 2 - sidebarGap * 2
|
||||
const columns = 3
|
||||
const cardWidth = (centerWidth - 12 * (columns - 1)) / columns
|
||||
const cardHeight = Math.min(118, Math.max(96, Math.floor(windowHeight * 0.15)))
|
||||
const startY = 96
|
||||
|
||||
drawSidebarCard({
|
||||
x: 24,
|
||||
y: startY,
|
||||
width: sidebarWidth,
|
||||
height: cardHeight,
|
||||
titleLines: ['邀请', '好友'],
|
||||
subtitle: '分享卡片',
|
||||
accentColor: '#FF8A65',
|
||||
onTap() {
|
||||
triggerInviteShare()
|
||||
render()
|
||||
},
|
||||
})
|
||||
|
||||
drawSidebarCard({
|
||||
x: 24,
|
||||
y: startY + cardHeight + 12,
|
||||
width: sidebarWidth,
|
||||
height: cardHeight,
|
||||
titleLines: ['开房', 'PK'],
|
||||
subtitle: 'V1.1+',
|
||||
accentColor: '#7E57C2',
|
||||
onTap() {
|
||||
showTransientMessage('开房间 PK 需要实时同步,V1.1+ 接入')
|
||||
render()
|
||||
},
|
||||
})
|
||||
|
||||
drawSidebarCard({
|
||||
x: windowWidth - 24 - sidebarWidth,
|
||||
y: startY,
|
||||
width: sidebarWidth,
|
||||
height: cardHeight,
|
||||
titleLines: ['礼物', '区'],
|
||||
subtitle: `${playerGiftAlbums[0].collectedCount + playerGiftAlbums[1].collectedCount} 个收藏`,
|
||||
accentColor: '#26A69A',
|
||||
onTap() {
|
||||
sceneStore.openGiftZone()
|
||||
render()
|
||||
},
|
||||
})
|
||||
|
||||
drawSidebarCard({
|
||||
x: windowWidth - 24 - sidebarWidth,
|
||||
y: startY + cardHeight + 12,
|
||||
width: sidebarWidth,
|
||||
height: cardHeight,
|
||||
titleLines: ['排行', '榜'],
|
||||
subtitle: '城市战队',
|
||||
accentColor: '#42A5F5',
|
||||
onTap() {
|
||||
showTransientMessage('城市排行榜依赖服务端,V1.1+ 接入')
|
||||
render()
|
||||
},
|
||||
})
|
||||
|
||||
homeTiles.forEach((tile, index) => {
|
||||
const row = Math.floor(index / columns)
|
||||
const column = index % columns
|
||||
const x = centerX + column * (cardWidth + 12)
|
||||
const y = startY + row * (cardHeight + 12)
|
||||
const isLocked = !tile.isUnlocked && tile.id !== 'coming_soon'
|
||||
|
||||
ctx.fillStyle = tile.isUnlocked ? '#ffffff' : '#f0ebe5'
|
||||
ctx.strokeStyle = tile.themeColor
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, cardWidth, cardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = tile.themeColor
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + cardWidth / 2, y + 34, 22, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 16px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(String(index + 1), x + cardWidth / 2, y + 34)
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = tile.name.length >= 5 ? 'bold 13px sans-serif' : 'bold 16px sans-serif'
|
||||
ctx.fillText(tile.name, x + cardWidth / 2, y + 76)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '12px sans-serif'
|
||||
let subtitle = '后续开放'
|
||||
|
||||
if (tile.id === 'asia') {
|
||||
subtitle = 'MVP 已开放'
|
||||
} else if (tile.id === 'mashup') {
|
||||
subtitle = tile.isUnlocked ? '随机混搭' : '通关 2 城解锁'
|
||||
} else if (tile.id === 'coming_soon') {
|
||||
subtitle = '新玩法预告'
|
||||
} else if (!isLocked) {
|
||||
subtitle = '可进入'
|
||||
}
|
||||
|
||||
ctx.fillText(subtitle, x + cardWidth / 2, y + 100)
|
||||
|
||||
if (isLocked) {
|
||||
ctx.fillStyle = '#8d8694'
|
||||
ctx.font = 'bold 12px sans-serif'
|
||||
ctx.fillText('LOCK', x + cardWidth / 2, y + 116)
|
||||
}
|
||||
|
||||
registerHitTarget({
|
||||
x,
|
||||
y,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
onTap() {
|
||||
const result = sceneStore.openHomeTile(tile.id)
|
||||
|
||||
if (!result.opened) {
|
||||
if (result.reason === 'locked') {
|
||||
showTransientMessage(tile.id === 'mashup' ? '通关 2 个城市后解锁主题大混战' : `${tile.name} 后续开放`)
|
||||
} else if (result.reason === 'coming-soon') {
|
||||
showTransientMessage('新玩法即将上线,敬请期待!')
|
||||
} else if (result.reason === 'mode-unavailable') {
|
||||
showTransientMessage('主题大混战入口已预留,玩法实现下一步接入')
|
||||
} else {
|
||||
showTransientMessage('该入口暂不可用')
|
||||
}
|
||||
}
|
||||
|
||||
render()
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function drawCitySelectScene(scene) {
|
||||
drawHeader('城市抓猫猫', '亚洲城市页 · 点击已解锁城市')
|
||||
|
||||
const cityCards = contentSystem.listCityCards(scene.continentId, sceneStore.getPlayerState())
|
||||
const columns = 3
|
||||
const cardWidth = (windowWidth - 24 * 2 - 12 * (columns - 1)) / columns
|
||||
const cardHeight = 128
|
||||
const startY = 96
|
||||
|
||||
cityCards.forEach((card, index) => {
|
||||
const row = Math.floor(index / columns)
|
||||
const column = index % columns
|
||||
const x = 24 + column * (cardWidth + 12)
|
||||
const y = startY + row * (cardHeight + 12)
|
||||
|
||||
ctx.fillStyle = card.isUnlocked ? '#ffffff' : '#ebe5df'
|
||||
ctx.strokeStyle = card.isUnlocked ? card.bgColor : '#d3ccc4'
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, cardWidth, cardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = card.bgColor
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + cardWidth / 2, y + 34, 22, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 18px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(card.name, x + cardWidth / 2, y + 76)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.fillText(
|
||||
card.isUnlocked ? `${card.completedLevels}/${card.totalLevels} 关` : '未解锁',
|
||||
x + cardWidth / 2,
|
||||
y + 100,
|
||||
)
|
||||
|
||||
if (card.isUnlocked) {
|
||||
registerHitTarget({
|
||||
x,
|
||||
y,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
onTap() {
|
||||
sceneStore.openCity(card.cityId)
|
||||
render()
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function drawLevelSelectScene(scene) {
|
||||
const city = contentSystem.getCity(scene.cityId)
|
||||
|
||||
drawHeader(city.display.name, `${city.display.tagline} · 选择关卡`)
|
||||
|
||||
const columns = 2
|
||||
const cardWidth = (windowWidth - 24 * 2 - 14) / columns
|
||||
const cardHeight = 100
|
||||
const startY = 104
|
||||
|
||||
scene.levels.forEach((level, index) => {
|
||||
const row = Math.floor(index / columns)
|
||||
const column = index % columns
|
||||
const x = 24 + column * (cardWidth + 14)
|
||||
const y = startY + row * (cardHeight + 14)
|
||||
|
||||
ctx.fillStyle = level.isUnlocked ? '#ffffff' : '#ebe5df'
|
||||
ctx.strokeStyle = level.isCompleted ? city.display.bgColor : '#d7d0c8'
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, cardWidth, cardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = level.isCompleted ? city.display.bgColor : '#231f2a'
|
||||
ctx.font = 'bold 22px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(`关卡 ${level.levelId}`, x + 18, y + 28)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '14px sans-serif'
|
||||
ctx.fillText(
|
||||
level.isUnlocked ? (level.isCompleted ? `${'★'.repeat(level.stars || 3)}` : '点击开始') : '未解锁',
|
||||
x + 18,
|
||||
y + 64,
|
||||
)
|
||||
|
||||
if (level.isUnlocked) {
|
||||
registerHitTarget({
|
||||
x,
|
||||
y,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
onTap() {
|
||||
sceneStore.openLevel(scene.cityId, level.levelId)
|
||||
render()
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
drawBackButton(() => {
|
||||
sceneStore.goBack()
|
||||
render()
|
||||
})
|
||||
}
|
||||
|
||||
function drawGiftZoneScene(scene) {
|
||||
const playerStateSnapshot = sceneStore.getPlayerState()
|
||||
const albums = contentSystem.getGiftAlbums(playerStateSnapshot)
|
||||
const entries = contentSystem.getGiftAlbumEntries(scene.selectedTab, playerStateSnapshot)
|
||||
|
||||
drawHeader('城市主题礼物区', 'MVP 收集册 · 冰箱贴 / 邮票')
|
||||
|
||||
const summaryX = 24
|
||||
const summaryY = 92
|
||||
const summaryWidth = windowWidth - 48
|
||||
const summaryHeight = 52
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = '#d7d0c8'
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(summaryX, summaryY, summaryWidth, summaryHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 14px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(
|
||||
albums.map((album) => `${album.name} ${album.collectedCount}/${album.totalCount}`).join(' · '),
|
||||
summaryX + summaryWidth / 2,
|
||||
summaryY + summaryHeight / 2,
|
||||
)
|
||||
|
||||
const tabWidth = (windowWidth - 24 * 2 - 12) / 2
|
||||
const tabsY = 158
|
||||
|
||||
albums.forEach((album, index) => {
|
||||
drawButton({
|
||||
x: 24 + index * (tabWidth + 12),
|
||||
y: tabsY,
|
||||
width: tabWidth,
|
||||
height: 42,
|
||||
label: album.name,
|
||||
fillStyle: scene.selectedTab === album.id ? '#231f2a' : '#ffffff',
|
||||
textColor: scene.selectedTab === album.id ? '#ffffff' : '#231f2a',
|
||||
onTap() {
|
||||
sceneStore.selectGiftTab(album.id)
|
||||
render()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const columns = 2
|
||||
const cardWidth = (windowWidth - 24 * 2 - 14) / columns
|
||||
const cardHeight = 82
|
||||
const startY = 216
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const row = Math.floor(index / columns)
|
||||
const column = index % columns
|
||||
const x = 24 + column * (cardWidth + 14)
|
||||
const y = startY + row * (cardHeight + 12)
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = entry.themeColor
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, cardWidth, cardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = entry.themeColor
|
||||
ctx.font = 'bold 17px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(entry.cityName, x + 16, y + 24)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '13px sans-serif'
|
||||
if (scene.selectedTab === 'magnets') {
|
||||
ctx.fillText(`冰箱贴 ${entry.collectedCount}/${entry.totalCount}`, x + 16, y + 52)
|
||||
} else {
|
||||
ctx.fillText(entry.isCollected ? '邮票已收集' : '邮票未收集', x + 16, y + 52)
|
||||
}
|
||||
})
|
||||
|
||||
drawBackButton(() => {
|
||||
sceneStore.goBack()
|
||||
render()
|
||||
})
|
||||
}
|
||||
|
||||
function drawGameplayScene(scene) {
|
||||
const city = contentSystem.getCity(scene.cityId)
|
||||
const session = scene.session
|
||||
const boardState = session.getBoardState()
|
||||
const state = session.getState()
|
||||
const clickableIds = new Set(session.getClickablePieces().map((piece) => piece.id))
|
||||
const activePieces = boardState.pieces
|
||||
.filter((piece) => !piece.removed)
|
||||
.sort((left, right) => left.layer - right.layer)
|
||||
|
||||
const elementNameMap = new Map(city.elements.map((element) => [element.id, element.name]))
|
||||
|
||||
drawHeader(`${city.display.name} · 关卡 ${scene.levelId}`, `${state.slot.length}/7 槽位`)
|
||||
|
||||
drawButton({
|
||||
x: windowWidth - 112,
|
||||
y: 22,
|
||||
width: 88,
|
||||
height: 34,
|
||||
label: '重开',
|
||||
onTap() {
|
||||
sceneStore.restartCurrentLevel()
|
||||
render()
|
||||
},
|
||||
fillStyle: '#ffffff',
|
||||
textColor: '#231f2a',
|
||||
})
|
||||
|
||||
activePieces.forEach((piece) => {
|
||||
const style = getElementStyle(piece.elementId)
|
||||
const isClickable = clickableIds.has(piece.id)
|
||||
|
||||
ctx.fillStyle = style.fill
|
||||
ctx.strokeStyle = isClickable ? style.border : '#c9c1ba'
|
||||
ctx.lineWidth = isClickable ? 2 : 1
|
||||
ctx.globalAlpha = isClickable ? 1 : 0.5
|
||||
drawRoundedRect(piece.x, piece.y, piece.width, piece.height, 12)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(
|
||||
(elementNameMap.get(piece.elementId) ?? '').slice(0, 2),
|
||||
piece.x + piece.width / 2,
|
||||
piece.y + piece.height / 2,
|
||||
)
|
||||
|
||||
if (isClickable && state.status === 'playing') {
|
||||
registerHitTarget({
|
||||
x: piece.x,
|
||||
y: piece.y,
|
||||
width: piece.width,
|
||||
height: piece.height,
|
||||
onTap() {
|
||||
const result = session.pickPiece(piece.id)
|
||||
|
||||
if (result.status === 'won') {
|
||||
sceneStore.completeLevel({
|
||||
cityId: scene.cityId,
|
||||
levelId: scene.levelId,
|
||||
stars: 3,
|
||||
})
|
||||
savePlayerState()
|
||||
}
|
||||
|
||||
render()
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const slotY = windowHeight - 126
|
||||
const slotWidth = (windowWidth - 24 * 2 - 6 * 8) / 7
|
||||
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
const x = 24 + index * (slotWidth + 8)
|
||||
const slotEntry = state.slot[index]
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = '#d5cec7'
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, slotY, slotWidth, 54, 12)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
if (slotEntry) {
|
||||
const label = elementNameMap.get(slotEntry.elementId) ?? ''
|
||||
const style = getElementStyle(slotEntry.elementId)
|
||||
ctx.fillStyle = style.border
|
||||
ctx.font = 'bold 12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label.slice(0, 2), x + slotWidth / 2, slotY + 27)
|
||||
}
|
||||
}
|
||||
|
||||
drawBackButton(() => {
|
||||
sceneStore.goBack()
|
||||
render()
|
||||
})
|
||||
|
||||
if (state.status === 'won' || state.status === 'failed') {
|
||||
drawResultOverlay(state.status, city.display.bgColor)
|
||||
}
|
||||
}
|
||||
|
||||
function drawResultOverlay(status, accentColor) {
|
||||
ctx.fillStyle = 'rgba(35, 31, 42, 0.45)'
|
||||
ctx.fillRect(0, 0, windowWidth, windowHeight)
|
||||
|
||||
const boxWidth = windowWidth - 48
|
||||
const boxHeight = 180
|
||||
const x = 24
|
||||
const y = (windowHeight - boxHeight) / 2
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = accentColor
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, boxWidth, boxHeight, 20)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 28px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(status === 'won' ? '通关成功' : '挑战失败', x + boxWidth / 2, y + 48)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '15px sans-serif'
|
||||
ctx.fillText(
|
||||
status === 'won' ? '已记录进度,返回关卡页继续' : '可以重开当前关卡,继续测试核心循环',
|
||||
x + boxWidth / 2,
|
||||
y + 86,
|
||||
)
|
||||
|
||||
drawButton({
|
||||
x: x + 20,
|
||||
y: y + 120,
|
||||
width: (boxWidth - 50) / 2,
|
||||
height: 42,
|
||||
label: status === 'won' ? '返回关卡' : '返回',
|
||||
onTap() {
|
||||
sceneStore.goBack()
|
||||
render()
|
||||
},
|
||||
fillStyle: '#ffffff',
|
||||
textColor: '#231f2a',
|
||||
})
|
||||
|
||||
drawButton({
|
||||
x: x + 30 + (boxWidth - 50) / 2,
|
||||
y: y + 120,
|
||||
width: (boxWidth - 50) / 2,
|
||||
height: 42,
|
||||
label: status === 'won' ? '下一步再做' : '重新挑战',
|
||||
onTap() {
|
||||
if (status === 'won') {
|
||||
sceneStore.goBack()
|
||||
} else {
|
||||
sceneStore.restartCurrentLevel()
|
||||
}
|
||||
render()
|
||||
},
|
||||
fillStyle: '#231f2a',
|
||||
textColor: '#ffffff',
|
||||
})
|
||||
}
|
||||
|
||||
function drawTransientMessage() {
|
||||
if (!transientMessage || transientMessageUntil <= Date.now()) {
|
||||
transientMessage = ''
|
||||
transientMessageUntil = 0
|
||||
return
|
||||
}
|
||||
|
||||
const width = windowWidth - 48
|
||||
const height = 44
|
||||
const x = 24
|
||||
const y = windowHeight - 116
|
||||
|
||||
ctx.fillStyle = 'rgba(35, 31, 42, 0.88)'
|
||||
drawRoundedRect(x, y, width, height, 14)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = '14px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(transientMessage, x + width / 2, y + height / 2)
|
||||
}
|
||||
|
||||
function render() {
|
||||
resetHitTargets()
|
||||
drawBackground()
|
||||
|
||||
const scene = sceneStore.getScene()
|
||||
|
||||
if (scene.type === 'home-select') {
|
||||
drawHomeScene()
|
||||
drawTransientMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (scene.type === 'city-select') {
|
||||
drawCitySelectScene(scene)
|
||||
drawTransientMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (scene.type === 'level-select') {
|
||||
drawLevelSelectScene(scene)
|
||||
drawTransientMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (scene.type === 'gift-zone') {
|
||||
drawGiftZoneScene(scene)
|
||||
drawTransientMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (scene.type === 'gameplay') {
|
||||
drawGameplayScene(scene)
|
||||
drawTransientMessage()
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchStart(event) {
|
||||
const touch = event.touches?.[0]
|
||||
if (!touch) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = hitTest(touch.clientX, touch.clientY)
|
||||
if (target) {
|
||||
target.onTap()
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof wx.onTouchStart === 'function') {
|
||||
wx.onTouchStart(handleTouchStart)
|
||||
}
|
||||
|
||||
render()
|
||||
|
||||
284
js/ui/scene-store.js
Normal file
284
js/ui/scene-store.js
Normal file
@@ -0,0 +1,284 @@
|
||||
import { createGameSession } from '../gameplay/session/index.js'
|
||||
|
||||
function createHomeSelectScene() {
|
||||
return {
|
||||
type: 'home-select',
|
||||
}
|
||||
}
|
||||
|
||||
function createCitySelectScene() {
|
||||
return {
|
||||
type: 'city-select',
|
||||
continentId: 'asia',
|
||||
}
|
||||
}
|
||||
|
||||
function createGiftZoneScene(selectedTab = 'magnets') {
|
||||
return {
|
||||
type: 'gift-zone',
|
||||
selectedTab,
|
||||
}
|
||||
}
|
||||
|
||||
function createLevelProgress(city, playerState) {
|
||||
const cityProgress = playerState.levelProgress[city.id] ?? {}
|
||||
|
||||
return city.levelPresets.map((preset) => {
|
||||
const levelState = cityProgress[preset.id] ?? null
|
||||
const previousLevel = preset.id - 1
|
||||
const previousCompleted = previousLevel <= 0 || cityProgress[previousLevel]?.completed === true
|
||||
|
||||
return {
|
||||
levelId: preset.id,
|
||||
isUnlocked: previousCompleted,
|
||||
isCompleted: levelState?.completed === true,
|
||||
stars: levelState?.stars ?? 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function ensureCityProgress(playerState, cityId) {
|
||||
if (!playerState.levelProgress[cityId]) {
|
||||
playerState.levelProgress[cityId] = {}
|
||||
}
|
||||
|
||||
return playerState.levelProgress[cityId]
|
||||
}
|
||||
|
||||
function ensureCollection(playerState, key) {
|
||||
if (!Array.isArray(playerState[key])) {
|
||||
playerState[key] = []
|
||||
}
|
||||
|
||||
return playerState[key]
|
||||
}
|
||||
|
||||
function createAcquiredDate(now) {
|
||||
return new Date(now()).toISOString()
|
||||
}
|
||||
|
||||
function awardLevelMagnet(playerState, cityId, levelId, now) {
|
||||
const magnets = ensureCollection(playerState, 'collectedMagnets')
|
||||
const magnetId = `magnet_${cityId}_${levelId}`
|
||||
|
||||
if (magnets.some((entry) => entry.magnetId === magnetId)) {
|
||||
return
|
||||
}
|
||||
|
||||
magnets.push({
|
||||
magnetId,
|
||||
cityId,
|
||||
levelId,
|
||||
acquiredDate: createAcquiredDate(now),
|
||||
})
|
||||
}
|
||||
|
||||
function markCityCompletion(contentSystem, playerState, cityId, now) {
|
||||
if (!playerState.collectedCats.includes(cityId)) {
|
||||
playerState.collectedCats.push(cityId)
|
||||
}
|
||||
|
||||
if (!playerState.passportStamps.includes(cityId)) {
|
||||
playerState.passportStamps.push(cityId)
|
||||
}
|
||||
|
||||
const city = contentSystem.getCity(cityId)
|
||||
const stamps = ensureCollection(playerState, 'collectedStamps')
|
||||
const stampId = city?.passport?.stampId ?? `stamp_${cityId}`
|
||||
|
||||
if (!stamps.some((entry) => entry.stampId === stampId)) {
|
||||
stamps.push({
|
||||
stampId,
|
||||
cityId,
|
||||
acquiredDate: createAcquiredDate(now),
|
||||
})
|
||||
}
|
||||
|
||||
const nextCityId = city?.unlockAfterCityId
|
||||
|
||||
if (nextCityId && !playerState.unlockedCities.includes(nextCityId)) {
|
||||
playerState.unlockedCities.push(nextCityId)
|
||||
}
|
||||
}
|
||||
|
||||
export function createSceneStore({ contentSystem, playerState, now = () => Date.now() }) {
|
||||
const history = []
|
||||
let currentScene = createHomeSelectScene()
|
||||
|
||||
function getScene() {
|
||||
return currentScene
|
||||
}
|
||||
|
||||
function openHomeTile(tileId) {
|
||||
const tile = contentSystem.getHomeTile(tileId, playerState)
|
||||
if (!tile) {
|
||||
return { opened: false, reason: 'missing' }
|
||||
}
|
||||
|
||||
if (tile.id === 'coming_soon') {
|
||||
return { opened: false, reason: 'coming-soon' }
|
||||
}
|
||||
|
||||
if (!tile.isUnlocked) {
|
||||
return { opened: false, reason: 'locked' }
|
||||
}
|
||||
|
||||
if (tile.id === 'asia') {
|
||||
history.push(currentScene)
|
||||
currentScene = createCitySelectScene()
|
||||
return { opened: true }
|
||||
}
|
||||
|
||||
if (tile.id === 'mashup') {
|
||||
return { opened: false, reason: 'mode-unavailable' }
|
||||
}
|
||||
|
||||
return { opened: false, reason: 'unavailable' }
|
||||
}
|
||||
|
||||
function openCity(cityId) {
|
||||
if (!playerState.unlockedCities.includes(cityId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const city = contentSystem.getCity(cityId)
|
||||
if (!city) {
|
||||
return false
|
||||
}
|
||||
|
||||
history.push(currentScene)
|
||||
currentScene = {
|
||||
type: 'level-select',
|
||||
cityId,
|
||||
levels: createLevelProgress(city, playerState),
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function openGiftZone(initialTab = 'magnets') {
|
||||
history.push(currentScene)
|
||||
currentScene = createGiftZoneScene(initialTab)
|
||||
return true
|
||||
}
|
||||
|
||||
function selectGiftTab(tabId) {
|
||||
if (currentScene.type !== 'gift-zone') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!['magnets', 'stamps'].includes(tabId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
currentScene = {
|
||||
...currentScene,
|
||||
selectedTab: tabId,
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function openLevel(cityId, levelId) {
|
||||
if (!playerState.unlockedCities.includes(cityId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const city = contentSystem.getCity(cityId)
|
||||
if (!city) {
|
||||
return false
|
||||
}
|
||||
|
||||
const levelCards = createLevelProgress(city, playerState)
|
||||
const targetLevel = levelCards.find((level) => level.levelId === levelId)
|
||||
|
||||
if (!targetLevel || !targetLevel.isUnlocked) {
|
||||
return false
|
||||
}
|
||||
|
||||
history.push(currentScene)
|
||||
currentScene = {
|
||||
type: 'gameplay',
|
||||
cityId,
|
||||
levelId,
|
||||
session: createGameSession({
|
||||
cityId,
|
||||
levelId,
|
||||
contentSystem,
|
||||
}),
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function completeLevel({ cityId, levelId, stars = 3 }) {
|
||||
const city = contentSystem.getCity(cityId)
|
||||
if (!city) {
|
||||
return false
|
||||
}
|
||||
|
||||
const cityProgress = ensureCityProgress(playerState, cityId)
|
||||
awardLevelMagnet(playerState, cityId, levelId, now)
|
||||
cityProgress[levelId] = {
|
||||
...(cityProgress[levelId] ?? {}),
|
||||
completed: true,
|
||||
stars: Math.max(cityProgress[levelId]?.stars ?? 0, stars),
|
||||
}
|
||||
|
||||
const allLevelsCompleted = city.levelPresets.every((preset) => cityProgress[preset.id]?.completed === true)
|
||||
if (allLevelsCompleted) {
|
||||
markCityCompletion(contentSystem, playerState, cityId, now)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function restartCurrentLevel() {
|
||||
if (currentScene.type !== 'gameplay') {
|
||||
return false
|
||||
}
|
||||
|
||||
currentScene = {
|
||||
...currentScene,
|
||||
session: currentScene.session.restart(),
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (history.length === 0) {
|
||||
currentScene = createHomeSelectScene()
|
||||
return currentScene
|
||||
}
|
||||
|
||||
currentScene = history.pop()
|
||||
|
||||
if (currentScene.type === 'level-select') {
|
||||
const city = contentSystem.getCity(currentScene.cityId)
|
||||
currentScene = {
|
||||
...currentScene,
|
||||
levels: createLevelProgress(city, playerState),
|
||||
}
|
||||
}
|
||||
|
||||
return currentScene
|
||||
}
|
||||
|
||||
return {
|
||||
getScene,
|
||||
getPlayerState() {
|
||||
return playerState
|
||||
},
|
||||
openHomeTile,
|
||||
openCity,
|
||||
openGiftZone,
|
||||
selectGiftTab,
|
||||
openLevel,
|
||||
completeLevel,
|
||||
restartCurrentLevel,
|
||||
goBack,
|
||||
}
|
||||
}
|
||||
|
||||
export default createSceneStore
|
||||
Reference in New Issue
Block a user