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,161 @@
# 美术生产指南 — 城市抓猫猫
## 1. 总体风格定义
风格关键词扁平冰箱贴风Flat fridge magnet style
- 圆角矩形白底卡片
- 高饱和度彩色图标
- 1px 浅灰描边
- 微投影(轻微立体感)
- 简洁线条,无复杂细节
- 可爱、清新、适合小尺寸显示
参考风格旅行冰箱贴、emoji sticker pack、Notion 风格图标
## 2. 素材类型与规格
| 素材类型 | 尺寸 | 格式 | 数量/城市 | AI Prompt 后缀 |
|---------|------|------|----------|---------------|
| 元素图标 | 128×128px @2x | PNG 透明底 | 12-15 个 | 见模板 A |
| 猫猫封面 | 256×256px @2x | PNG 透明底 | 1 个 | 见模板 B |
| 猫猫缩略图 | 128×128px @2x | PNG 透明底 | 1 个 | 同封面缩小 |
| 冰箱贴(收集品版) | 128×128px @2x | PNG 透明底 | 6 个/城市 | 同元素图标 |
| 城市邮票 | 192×256px @2x | PNG 透明底 | 1 个/城市 | 见模板 C |
| 地区猫猫 | 256×256px @2x | PNG 透明底 | 8 个(中国) | 见模板 D |
| 国家动物 | 256×256px @2x | PNG 透明底 | 按国家 | 见模板 E |
| 洲地图 | 512×512px @2x | PNG 透明底 | 6 张 | 见模板 F |
## 3. Prompt 模板
### 模板 A — 城市元素图标
```
Base prompt:
flat design icon of [物品名], [物品描述], cute sticker style,
rounded rectangle white background with thin light gray border,
subtle drop shadow, high saturation colors, simple clean lines,
no text, isolated on white background, 128x128px
Color guidance: [城市主色调] accent
Negative prompt:
realistic, 3D render, photograph, complex details, text, watermark, blurry, dark
```
示例 — 北京糖葫芦:
```
flat design icon of Chinese tanghulu (candied hawthorn on stick), red glossy berries on wooden stick, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on white background
```
### 模板 B — 城市猫猫封面
```
Base prompt:
flat design cat head icon, front-facing [毛色] cat with [花纹] pattern,
wearing [装饰], [表情] expression, cute kawaii style,
simple clean lines, sticker style, white background, no text,
high saturation colors, 256x256px
Negative prompt:
realistic, photograph, full body, complex background, text, watermark
```
示例 — 北京猫猫(京京):
```
flat design cat head icon, front-facing orange tabby cat with darker orange stripes, wearing a traditional Chinese tiger hat (虎头帽), cute smiling expression, kawaii style, simple clean lines, sticker style, white background, no text, high saturation colors
```
### 模板 C — 城市邮票
```
flat design postage stamp of [城市名], vintage stamp border with perforated edges,
[城市标志元素] in center, city name "[英文名]" at bottom,
[城市主色调] color scheme, retro travel poster style,
simple clean illustration, no photograph, white background
```
### 模板 D — 地区猫猫(中国)
```
flat design cat head icon, front-facing [品种] cat,
[毛色和花纹描述], wearing [地区特色装饰],
cute kawaii style, simple clean lines, sticker style,
white background, no text, high saturation colors
```
### 模板 E — 国家代表动物
```
flat design icon of [动物名], cute cartoon style,
wearing [国家特色小装饰], [国旗配色 accent],
simple clean lines, sticker style, rounded rectangle white background,
thin light gray border, subtle drop shadow, no text, isolated on white
```
### 模板 F — 洲地图
```
flat design illustrated map of [洲名], [风格描述],
showing continent outline with cute landmark icons,
[色调] color scheme, hand-drawn travel map style,
simple clean illustration, white background, no text labels
```
## 4. 色彩参照
| 城市 | 主色 | HEX | 应用 |
|------|------|-----|------|
| 北京 | 中国红 | #CC2936 | 元素边框高亮、邮票底色 |
| 东京 | 樱花粉红 | #E84057 | — |
| 曼谷 | 泰国金 | #FFB347 | — |
| 首尔 | 太极蓝 | #4A90D9 | — |
| 新加坡 | 热带绿 | #00A896 | — |
| 伊斯坦布尔 | 奥斯曼蓝 | #1A5276 | — |
## 5. AI 工具推荐
| 工具 | 优势 | 推荐用途 |
|------|------|---------|
| Midjourney V6 | 风格一致性最好 | 元素图标、猫猫 |
| DALL-E 3 | Prompt 理解力强 | 邮票、地图 |
| Stable Diffusion + ControlNet | 可控性最高 | 批量统一风格 |
## 6. 后处理 Checklist
每张图 AI 出图后需要人工修正:
- [ ] 去除多余背景(确保透明底)
- [ ] 统一尺寸裁切(元素 128px / 猫猫 256px
- [ ] 检查线条粗细一致性
- [ ] 调整饱和度到统一水平
- [ ] 添加白底圆角矩形卡片背景(如果 AI 没生成)
- [ ] 添加 1px 浅灰描边 + 微投影
- [ ] 导出 @2x PNG
- [ ] 按命名规范重命名
## 7. 命名规范
```
元素图标: images/elements/{cityId}/{cityId}_{序号}.png
猫猫封面: images/cats/cat_{cityId}.png
猫猫缩略图images/cats/cat_{cityId}_thumb.png
冰箱贴: images/magnets/{cityId}/magnet_{cityId}_{levelId}.png
邮票: images/stamps/stamp_{cityId}.png
地区猫猫: images/cats/region/cat_{regionId}.png
国家动物: images/animals/animal_{countryId}.png
洲地图: images/maps/map_{continentId}.png
```
## 8. 生产优先级
| 优先级 | 素材 | 数量 | 说明 |
|--------|------|------|------|
| P0 | 北京 14 元素 + 猫猫 | 15 张 | 首城,开发验证用 |
| P0 | 通用 UI按钮、槽位、背景 | ~10 张 | 游戏核心界面 |
| P1 | 其他 5 城市元素 + 猫猫 | ~75 张 | MVP 完整内容 |
| P1 | 6 城市邮票 | 6 张 | 收集系统 |
| P2 | 音效 | 6-8 个 | 通用交互音 |
| P3 | 地区猫猫、国家动物、洲地图 | ~20 张 | V1.1+ |

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() const canvas = wx.createCanvas()
canvas.width = windowWidth * pixelRatio
canvas.height = windowHeight * pixelRatio
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
ctx.scale(pixelRatio, pixelRatio)
ctx.textBaseline = 'middle'
const STORAGE_KEY = 'player_state'
const contentSystem = createContentSystem()
const playerState = loadPlayerState()
const sceneStore = createSceneStore({ contentSystem, playerState })
const hitTargets = []
let transientMessage = ''
let transientMessageUntil = 0
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.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) ctx.fillRect(0, 0, windowWidth, windowHeight)
ctx.fillStyle = '#333333' const boxWidth = windowWidth - 48
ctx.font = '24px sans-serif' 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.textAlign = 'center'
ctx.fillText('微信小游戏', windowWidth / 2, windowHeight / 2) 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

8
package.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "wechat-minigame",
"private": true,
"type": "module",
"scripts": {
"test": "node --test"
}
}

View File

@@ -0,0 +1,146 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
createContentSystem,
createDefaultPlayerState,
validateContent,
} from '../js/content/index.js'
test('built-in content validates without errors', () => {
const result = validateContent()
assert.equal(result.errors.length, 0)
})
test('content system projects city cards from player state', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const beijingCard = contentSystem.getCityCardView('beijing', playerState)
const tokyoCardBefore = contentSystem.getCityCardView('tokyo', playerState)
assert.equal(beijingCard.isUnlocked, true)
assert.equal(beijingCard.isCollected, false)
assert.equal(tokyoCardBefore.isUnlocked, false)
playerState.levelProgress.beijing = {
1: { completed: true, stars: 3 },
2: { completed: true, stars: 3 },
3: { completed: true, stars: 3 },
4: { completed: true, stars: 3 },
5: { completed: true, stars: 3 },
6: { completed: true, stars: 3 },
}
playerState.unlockedCities.push('tokyo')
playerState.collectedCats.push('beijing')
playerState.passportStamps.push('beijing')
const beijingAfter = contentSystem.getCityCardView('beijing', playerState)
const tokyoCardAfter = contentSystem.getCityCardView('tokyo', playerState)
assert.equal(beijingAfter.isCompleted, true)
assert.equal(beijingAfter.isCollected, true)
assert.equal(tokyoCardAfter.isUnlocked, true)
})
test('content system exposes runtime navigation roots and city children for the MVP map', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const roots = contentSystem.getRootNavNodes()
const asia = contentSystem.getNavNode('asia')
const children = contentSystem.getNavChildren('asia', playerState)
assert.deepEqual(roots.map((node) => node.id), ['asia', 'mashup', 'coming_soon'])
assert.equal(asia.childType, 'city')
assert.deepEqual(
children.map((entry) => entry.id),
['beijing', 'tokyo', 'bangkok', 'seoul', 'singapore', 'istanbul'],
)
assert.equal(children[0].type, 'city')
assert.equal(children[0].parentId, 'asia')
assert.equal(children[0].isUnlocked, true)
assert.equal(children[1].isUnlocked, false)
})
test('content system exposes nine home tiles and gates mashup behind two completed cities', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const initialTiles = contentSystem.getHomeTiles(playerState)
assert.deepEqual(
initialTiles.map((tile) => tile.id),
['asia', 'europe', 'north_america', 'south_america', 'africa', 'oceania', 'antarctica', 'mashup', 'coming_soon'],
)
assert.equal(initialTiles[0].isUnlocked, true)
assert.equal(initialTiles[1].isUnlocked, false)
assert.equal(initialTiles[7].isUnlocked, false)
assert.equal(initialTiles[8].isInteractive, true)
playerState.levelProgress.beijing = {
1: { completed: true, stars: 3 },
2: { completed: true, stars: 3 },
3: { completed: true, stars: 3 },
4: { completed: true, stars: 3 },
5: { completed: true, stars: 3 },
6: { completed: true, stars: 3 },
}
playerState.levelProgress.tokyo = {
1: { completed: true, stars: 3 },
2: { completed: true, stars: 3 },
3: { completed: true, stars: 3 },
4: { completed: true, stars: 3 },
5: { completed: true, stars: 3 },
6: { completed: true, stars: 3 },
}
const unlockedTiles = contentSystem.getHomeTiles(playerState)
const mashupTile = unlockedTiles.find((tile) => tile.id === 'mashup')
assert.equal(mashupTile.isUnlocked, true)
})
test('default player state includes city team and empty collection albums', () => {
const playerState = createDefaultPlayerState()
assert.deepEqual(playerState.cityTeam, {
teamCityId: null,
joinedDate: null,
lastSwitchDate: null,
})
assert.deepEqual(playerState.collectedMagnets, [])
assert.deepEqual(playerState.collectedStamps, [])
})
test('content system summarizes MVP gift albums and per-city progress', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
playerState.collectedMagnets.push({
magnetId: 'magnet_beijing_1',
cityId: 'beijing',
levelId: 1,
acquiredDate: '2026-03-29T00:00:00.000Z',
})
playerState.collectedStamps.push({
stampId: 'stamp_beijing',
cityId: 'beijing',
acquiredDate: '2026-03-29T00:00:00.000Z',
})
const albums = contentSystem.getGiftAlbums(playerState)
const magnetEntries = contentSystem.getGiftAlbumEntries('magnets', playerState)
const stampEntries = contentSystem.getGiftAlbumEntries('stamps', playerState)
assert.deepEqual(albums.map((album) => album.id), ['magnets', 'stamps'])
assert.deepEqual(albums.map((album) => album.collectedCount), [1, 1])
assert.deepEqual(albums.map((album) => album.totalCount), [36, 6])
assert.equal(magnetEntries[0].cityId, 'beijing')
assert.equal(magnetEntries[0].collectedCount, 1)
assert.equal(magnetEntries[0].totalCount, 6)
assert.equal(stampEntries[0].cityId, 'beijing')
assert.equal(stampEntries[0].isCollected, true)
assert.equal(stampEntries[1].isCollected, false)
})

View File

@@ -0,0 +1,74 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createContentSystem } from '../js/content/index.js'
import {
classifyDeadlock,
evaluateBoard,
generateBoard,
} from '../js/gameplay/difficulty/index.js'
test('generateBoard is deterministic for the same city, level, and seed', () => {
const contentSystem = createContentSystem()
const first = generateBoard({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem,
})
const second = generateBoard({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem,
})
assert.deepEqual(first.pieces, second.pieces)
assert.deepEqual(first.overlapGraph, second.overlapGraph)
})
test('evaluateBoard exposes baseline metrics for intro level boards', () => {
const contentSystem = createContentSystem()
const board = generateBoard({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem,
})
const levelPreset = contentSystem.getLevelPreset('beijing', 1)
const metrics = evaluateBoard(board, levelPreset)
assert.equal(metrics.totalPieces, 18)
assert.ok(metrics.initialClickableRatio >= 0.3)
})
test('classifyDeadlock identifies a hard deadlock when slot is full and no match path exists', () => {
const runtimeState = {
slot: [
{ elementId: 'a' },
{ elementId: 'b' },
{ elementId: 'c' },
{ elementId: 'd' },
{ elementId: 'e' },
{ elementId: 'f' },
{ elementId: 'g' },
],
bypass: [],
}
const boardState = {
pieces: [
{ id: 'p1', elementId: 'x', removed: false },
{ id: 'p2', elementId: 'y', removed: false },
],
overlapGraph: {
p1: [],
p2: [],
},
}
const result = classifyDeadlock(runtimeState, boardState)
assert.equal(result.type, 'hard')
})

140
tests/game-session.test.js Normal file
View File

@@ -0,0 +1,140 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createContentSystem } from '../js/content/index.js'
import { createGameSession } from '../js/gameplay/session/index.js'
test('pickPiece adds a clickable piece to the slot', () => {
const session = createGameSession({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem: createContentSystem(),
})
const clickablePiece = session.getClickablePieces()[0]
const result = session.pickPiece(clickablePiece.id)
assert.equal(result.status, 'picked')
assert.equal(session.getState().slot.length, 1)
assert.equal(session.getState().slot[0].pieceId, clickablePiece.id)
})
test('pickPiece auto clears triples from the slot', () => {
const contentSystem = createContentSystem()
const session = createGameSession({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem,
boardState: {
boardId: 'test',
cityId: 'beijing',
levelId: 1,
seed: 1,
pieces: [
{ id: 'p1', elementId: 'beijing_01', layer: 0, x: 0, y: 0, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p2', elementId: 'beijing_01', layer: 0, x: 70, y: 0, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p3', elementId: 'beijing_01', layer: 0, x: 140, y: 0, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p4', elementId: 'beijing_02', layer: 0, x: 0, y: 70, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p5', elementId: 'beijing_02', layer: 0, x: 70, y: 70, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p6', elementId: 'beijing_02', layer: 0, x: 140, y: 70, width: 64, height: 64, rotation: 0, removed: false },
],
overlapGraph: {
p1: [],
p2: [],
p3: [],
p4: [],
p5: [],
p6: [],
},
metrics: {},
},
})
session.pickPiece('p1')
session.pickPiece('p2')
const third = session.pickPiece('p3')
assert.equal(third.status, 'matched')
assert.equal(session.getState().slot.length, 0)
assert.equal(session.getState().removedPieceIds.length, 3)
})
test('session fails when slot fills without a match', () => {
const session = createGameSession({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem: createContentSystem(),
boardState: {
boardId: 'full-slot',
cityId: 'beijing',
levelId: 1,
seed: 2,
pieces: ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map((key, index) => ({
id: `p${index + 1}`,
elementId: key,
layer: 0,
x: index * 70,
y: 0,
width: 64,
height: 64,
rotation: 0,
removed: false,
})),
overlapGraph: {
p1: [],
p2: [],
p3: [],
p4: [],
p5: [],
p6: [],
p7: [],
},
metrics: {},
},
})
for (let index = 1; index <= 6; index += 1) {
session.pickPiece(`p${index}`)
}
const last = session.pickPiece('p7')
assert.equal(last.status, 'failed')
assert.equal(session.getState().status, 'failed')
})
test('session wins when the final triple clears the board', () => {
const session = createGameSession({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem: createContentSystem(),
boardState: {
boardId: 'win-state',
cityId: 'beijing',
levelId: 1,
seed: 3,
pieces: [
{ id: 'p1', elementId: 'beijing_01', layer: 0, x: 0, y: 0, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p2', elementId: 'beijing_01', layer: 0, x: 70, y: 0, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p3', elementId: 'beijing_01', layer: 0, x: 140, y: 0, width: 64, height: 64, rotation: 0, removed: false },
],
overlapGraph: {
p1: [],
p2: [],
p3: [],
},
metrics: {},
},
})
session.pickPiece('p1')
session.pickPiece('p2')
const third = session.pickPiece('p3')
assert.equal(third.status, 'won')
assert.equal(session.getState().status, 'won')
})

124
tests/scene-store.test.js Normal file
View File

@@ -0,0 +1,124 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createContentSystem, createDefaultPlayerState } from '../js/content/index.js'
import { createSceneStore } from '../js/ui/scene-store.js'
test('scene store starts on the home selection page', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({ contentSystem, playerState })
assert.equal(sceneStore.getScene().type, 'home-select')
})
test('scene store opens level selection for unlocked city and returns back', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({ contentSystem, playerState })
const openedHome = sceneStore.openHomeTile('asia')
const opened = sceneStore.openCity('beijing')
assert.equal(openedHome.opened, true)
assert.equal(opened, true)
assert.equal(sceneStore.getScene().type, 'level-select')
assert.equal(sceneStore.getScene().cityId, 'beijing')
sceneStore.goBack()
assert.equal(sceneStore.getScene().type, 'city-select')
})
test('scene store opens gameplay scene from a valid level', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({ contentSystem, playerState })
sceneStore.openHomeTile('asia')
sceneStore.openCity('beijing')
const opened = sceneStore.openLevel('beijing', 1)
assert.equal(opened, true)
assert.equal(sceneStore.getScene().type, 'gameplay')
assert.equal(sceneStore.getScene().session.cityId, 'beijing')
assert.equal(sceneStore.getScene().session.levelId, 1)
})
test('scene store awards magnets, stamps, and unlocks the next city on completion', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({
contentSystem,
playerState,
now: () => new Date('2026-03-28T12:00:00.000Z').getTime(),
})
sceneStore.completeLevel({
cityId: 'beijing',
levelId: 1,
stars: 3,
})
assert.deepEqual(playerState.collectedMagnets, [
{
magnetId: 'magnet_beijing_1',
cityId: 'beijing',
levelId: 1,
acquiredDate: '2026-03-28T12:00:00.000Z',
},
])
for (let levelId = 2; levelId <= 6; levelId += 1) {
sceneStore.completeLevel({
cityId: 'beijing',
levelId,
stars: 3,
})
}
assert.equal(playerState.collectedCats.includes('beijing'), true)
assert.equal(playerState.passportStamps.includes('beijing'), true)
assert.equal(playerState.unlockedCities.includes('tokyo'), true)
assert.deepEqual(playerState.collectedStamps, [
{
stampId: 'stamp_beijing',
cityId: 'beijing',
acquiredDate: '2026-03-28T12:00:00.000Z',
},
])
})
test('scene store reports locked and placeholder home tiles without leaving the home scene', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({ contentSystem, playerState })
const locked = sceneStore.openHomeTile('europe')
const soon = sceneStore.openHomeTile('coming_soon')
assert.deepEqual(locked, { opened: false, reason: 'locked' })
assert.deepEqual(soon, { opened: false, reason: 'coming-soon' })
assert.equal(sceneStore.getScene().type, 'home-select')
})
test('scene store opens the gift zone and switches between MVP tabs', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({ contentSystem, playerState })
const opened = sceneStore.openGiftZone()
assert.equal(opened, true)
assert.equal(sceneStore.getScene().type, 'gift-zone')
assert.equal(sceneStore.getScene().selectedTab, 'magnets')
const switched = sceneStore.selectGiftTab('stamps')
assert.equal(switched, true)
assert.equal(sceneStore.getScene().selectedTab, 'stamps')
sceneStore.goBack()
assert.equal(sceneStore.getScene().type, 'home-select')
})