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

View 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
}

View 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

View 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

View 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

View 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'

View 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 ?? [])
}

View 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
}

View 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

View File

@@ -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
View 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