From c118e24bd1e6c6b4227d9ebb16f3974b9d3e3329 Mon Sep 17 00:00:00 2001 From: manpengan Date: Sun, 29 Mar 2026 00:36:28 +0800 Subject: [PATCH] feat: scaffold mvp shell and content runtime --- docs/art-production-guide.md | 161 ++++ js/content/default-player-state.js | 45 ++ js/content/index.js | 257 +++++++ js/content/registry/bundle-resolver.js | 24 + js/content/registry/city-registry.js | 30 + js/content/registry/continent-registry.js | 21 + js/content/registry/progress-projector.js | 45 ++ js/content/validation/validate-assets.js | 24 + js/content/validation/validate-city.js | 90 +++ js/content/validation/validate-continent.js | 47 ++ js/gameplay/difficulty/anchors.js | 55 ++ js/gameplay/difficulty/classify-deadlock.js | 47 ++ js/gameplay/difficulty/evaluate-board.js | 29 + js/gameplay/difficulty/generate-board.js | 138 ++++ js/gameplay/difficulty/index.js | 9 + js/gameplay/difficulty/overlap-graph.js | 74 ++ js/gameplay/difficulty/random.js | 36 + js/gameplay/session/index.js | 189 +++++ js/main.js | 813 +++++++++++++++++++- js/ui/scene-store.js | 284 +++++++ package.json | 8 + tests/content-system.test.js | 146 ++++ tests/difficulty-generator.test.js | 74 ++ tests/game-session.test.js | 140 ++++ tests/scene-store.test.js | 124 +++ 25 files changed, 2903 insertions(+), 7 deletions(-) create mode 100644 docs/art-production-guide.md create mode 100644 js/content/default-player-state.js create mode 100644 js/content/index.js create mode 100644 js/content/registry/bundle-resolver.js create mode 100644 js/content/registry/city-registry.js create mode 100644 js/content/registry/continent-registry.js create mode 100644 js/content/registry/progress-projector.js create mode 100644 js/content/validation/validate-assets.js create mode 100644 js/content/validation/validate-city.js create mode 100644 js/content/validation/validate-continent.js create mode 100644 js/gameplay/difficulty/anchors.js create mode 100644 js/gameplay/difficulty/classify-deadlock.js create mode 100644 js/gameplay/difficulty/evaluate-board.js create mode 100644 js/gameplay/difficulty/generate-board.js create mode 100644 js/gameplay/difficulty/index.js create mode 100644 js/gameplay/difficulty/overlap-graph.js create mode 100644 js/gameplay/difficulty/random.js create mode 100644 js/gameplay/session/index.js create mode 100644 js/ui/scene-store.js create mode 100644 package.json create mode 100644 tests/content-system.test.js create mode 100644 tests/difficulty-generator.test.js create mode 100644 tests/game-session.test.js create mode 100644 tests/scene-store.test.js diff --git a/docs/art-production-guide.md b/docs/art-production-guide.md new file mode 100644 index 0000000..6462c71 --- /dev/null +++ b/docs/art-production-guide.md @@ -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+ | diff --git a/js/content/default-player-state.js b/js/content/default-player-state.js new file mode 100644 index 0000000..3a9cc31 --- /dev/null +++ b/js/content/default-player-state.js @@ -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 diff --git a/js/content/index.js b/js/content/index.js new file mode 100644 index 0000000..ce47eff --- /dev/null +++ b/js/content/index.js @@ -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 diff --git a/js/content/registry/bundle-resolver.js b/js/content/registry/bundle-resolver.js new file mode 100644 index 0000000..23bd4c7 --- /dev/null +++ b/js/content/registry/bundle-resolver.js @@ -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 diff --git a/js/content/registry/city-registry.js b/js/content/registry/city-registry.js new file mode 100644 index 0000000..090ae3f --- /dev/null +++ b/js/content/registry/city-registry.js @@ -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 diff --git a/js/content/registry/continent-registry.js b/js/content/registry/continent-registry.js new file mode 100644 index 0000000..230dde6 --- /dev/null +++ b/js/content/registry/continent-registry.js @@ -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 diff --git a/js/content/registry/progress-projector.js b/js/content/registry/progress-projector.js new file mode 100644 index 0000000..bdd3089 --- /dev/null +++ b/js/content/registry/progress-projector.js @@ -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 diff --git a/js/content/validation/validate-assets.js b/js/content/validation/validate-assets.js new file mode 100644 index 0000000..9e6785d --- /dev/null +++ b/js/content/validation/validate-assets.js @@ -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 diff --git a/js/content/validation/validate-city.js b/js/content/validation/validate-city.js new file mode 100644 index 0000000..69137be --- /dev/null +++ b/js/content/validation/validate-city.js @@ -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 diff --git a/js/content/validation/validate-continent.js b/js/content/validation/validate-continent.js new file mode 100644 index 0000000..9d972e8 --- /dev/null +++ b/js/content/validation/validate-continent.js @@ -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 diff --git a/js/gameplay/difficulty/anchors.js b/js/gameplay/difficulty/anchors.js new file mode 100644 index 0000000..fd8c0d8 --- /dev/null +++ b/js/gameplay/difficulty/anchors.js @@ -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 +} diff --git a/js/gameplay/difficulty/classify-deadlock.js b/js/gameplay/difficulty/classify-deadlock.js new file mode 100644 index 0000000..bb61a16 --- /dev/null +++ b/js/gameplay/difficulty/classify-deadlock.js @@ -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 diff --git a/js/gameplay/difficulty/evaluate-board.js b/js/gameplay/difficulty/evaluate-board.js new file mode 100644 index 0000000..cbf525b --- /dev/null +++ b/js/gameplay/difficulty/evaluate-board.js @@ -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 diff --git a/js/gameplay/difficulty/generate-board.js b/js/gameplay/difficulty/generate-board.js new file mode 100644 index 0000000..1f36b5d --- /dev/null +++ b/js/gameplay/difficulty/generate-board.js @@ -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 diff --git a/js/gameplay/difficulty/index.js b/js/gameplay/difficulty/index.js new file mode 100644 index 0000000..7446f59 --- /dev/null +++ b/js/gameplay/difficulty/index.js @@ -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' diff --git a/js/gameplay/difficulty/overlap-graph.js b/js/gameplay/difficulty/overlap-graph.js new file mode 100644 index 0000000..153b304 --- /dev/null +++ b/js/gameplay/difficulty/overlap-graph.js @@ -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 ?? []) +} diff --git a/js/gameplay/difficulty/random.js b/js/gameplay/difficulty/random.js new file mode 100644 index 0000000..1f2c11d --- /dev/null +++ b/js/gameplay/difficulty/random.js @@ -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 +} diff --git a/js/gameplay/session/index.js b/js/gameplay/session/index.js new file mode 100644 index 0000000..1dcf2c2 --- /dev/null +++ b/js/gameplay/session/index.js @@ -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 diff --git a/js/main.js b/js/main.js index 28f0e24..9c773bc 100644 --- a/js/main.js +++ b/js/main.js @@ -1,12 +1,811 @@ -const { windowWidth, windowHeight } = wx.getSystemInfoSync() +import { createContentSystem, createDefaultPlayerState } from './content/index.js' +import { createSceneStore } from './ui/scene-store.js' + +const { + windowWidth, + windowHeight, + pixelRatio = 1, +} = wx.getSystemInfoSync() const canvas = wx.createCanvas() +canvas.width = windowWidth * pixelRatio +canvas.height = windowHeight * pixelRatio + const ctx = canvas.getContext('2d') +ctx.scale(pixelRatio, pixelRatio) +ctx.textBaseline = 'middle' -ctx.fillStyle = '#ffffff' -ctx.fillRect(0, 0, windowWidth, windowHeight) +const STORAGE_KEY = 'player_state' +const contentSystem = createContentSystem() +const playerState = loadPlayerState() +const sceneStore = createSceneStore({ contentSystem, playerState }) +const hitTargets = [] +let transientMessage = '' +let transientMessageUntil = 0 -ctx.fillStyle = '#333333' -ctx.font = '24px sans-serif' -ctx.textAlign = 'center' -ctx.fillText('微信小游戏', windowWidth / 2, windowHeight / 2) +function loadPlayerState() { + try { + const stored = wx.getStorageSync(STORAGE_KEY) + if (!stored) { + return createDefaultPlayerState() + } + + return { + ...createDefaultPlayerState(), + ...stored, + inventory: { + ...createDefaultPlayerState().inventory, + ...(stored.inventory ?? {}), + }, + settings: { + ...createDefaultPlayerState().settings, + ...(stored.settings ?? {}), + }, + stats: { + ...createDefaultPlayerState().stats, + ...(stored.stats ?? {}), + }, + cityTeam: { + ...createDefaultPlayerState().cityTeam, + ...(stored.cityTeam ?? {}), + }, + } + } catch { + return createDefaultPlayerState() + } +} + +function savePlayerState() { + try { + wx.setStorageSync(STORAGE_KEY, playerState) + } catch { + // Ignore storage write failures in the MVP shell. + } +} + +function triggerInviteShare() { + playerState.stats.totalShareCount += 1 + savePlayerState() + + if (typeof wx.shareAppMessage === 'function') { + try { + wx.shareAppMessage({ + title: '一起来抓猫猫!', + query: 'from=invite', + }) + showTransientMessage('已打开分享卡片') + return + } catch { + // Fall through to local feedback. + } + } + + showTransientMessage('分享入口已触发,真机再校验分享卡片') +} + +function drawRoundedRect(x, y, width, height, radius) { + const clampedRadius = Math.min(radius, width / 2, height / 2) + + ctx.beginPath() + ctx.moveTo(x + clampedRadius, y) + ctx.lineTo(x + width - clampedRadius, y) + ctx.arcTo(x + width, y, x + width, y + clampedRadius, clampedRadius) + ctx.lineTo(x + width, y + height - clampedRadius) + ctx.arcTo(x + width, y + height, x + width - clampedRadius, y + height, clampedRadius) + ctx.lineTo(x + clampedRadius, y + height) + ctx.arcTo(x, y + height, x, y + height - clampedRadius, clampedRadius) + ctx.lineTo(x, y + clampedRadius) + ctx.arcTo(x, y, x + clampedRadius, y, clampedRadius) + ctx.closePath() +} + +function drawBackground() { + const gradient = ctx.createLinearGradient(0, 0, windowWidth, windowHeight) + gradient.addColorStop(0, '#fff7ef') + gradient.addColorStop(1, '#ffe1d6') + ctx.fillStyle = gradient + ctx.fillRect(0, 0, windowWidth, windowHeight) +} + +function registerHitTarget(target) { + hitTargets.push(target) +} + +function resetHitTargets() { + hitTargets.length = 0 +} + +function showTransientMessage(message, durationMs = 1800) { + transientMessage = message + transientMessageUntil = Date.now() + durationMs +} + +function hashStringToHue(value) { + let hash = 0 + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) % 360 + } + + return hash +} + +function getElementStyle(elementId) { + const hue = hashStringToHue(elementId) + + return { + fill: `hsl(${hue} 82% 87%)`, + border: `hsl(${hue} 58% 52%)`, + } +} + +function hitTest(x, y) { + for (let index = hitTargets.length - 1; index >= 0; index -= 1) { + const target = hitTargets[index] + if ( + x >= target.x + && x <= target.x + target.width + && y >= target.y + && y <= target.y + target.height + ) { + return target + } + } + + return null +} + +function drawHeader(title, subtitle) { + ctx.fillStyle = '#231f2a' + ctx.font = 'bold 30px sans-serif' + ctx.textAlign = 'left' + ctx.fillText(title, 24, 36) + + if (subtitle) { + ctx.fillStyle = '#6b6474' + ctx.font = '15px sans-serif' + ctx.fillText(subtitle, 24, 64) + } +} + +function drawButton({ x, y, width, height, label, onTap, fillStyle = '#231f2a', textColor = '#ffffff' }) { + ctx.fillStyle = fillStyle + drawRoundedRect(x, y, width, height, 14) + ctx.fill() + + ctx.fillStyle = textColor + ctx.font = 'bold 16px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(label, x + width / 2, y + height / 2) + + if (onTap) { + registerHitTarget({ x, y, width, height, onTap }) + } +} + +function drawBackButton(onTap) { + drawButton({ + x: 24, + y: windowHeight - 58, + width: 88, + height: 38, + label: '返回', + onTap, + fillStyle: '#ffffff', + textColor: '#231f2a', + }) +} + +function drawSidebarCard({ x, y, width, height, titleLines, subtitle, onTap, accentColor = '#231f2a' }) { + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = accentColor + ctx.lineWidth = 2 + drawRoundedRect(x, y, width, height, 16) + ctx.fill() + ctx.stroke() + + ctx.fillStyle = accentColor + ctx.font = 'bold 14px sans-serif' + ctx.textAlign = 'center' + titleLines.forEach((line, index) => { + ctx.fillText(line, x + width / 2, y + 34 + index * 18) + }) + + if (subtitle) { + ctx.fillStyle = '#6b6474' + ctx.font = '11px sans-serif' + ctx.fillText(subtitle, x + width / 2, y + height - 20) + } + + if (onTap) { + registerHitTarget({ x, y, width, height, onTap }) + } +} + +function drawHomeScene() { + drawHeader('城市抓猫猫', '世界主页面 · 3×3 入口') + + const homeTiles = contentSystem.getHomeTiles(sceneStore.getPlayerState()) + const playerGiftAlbums = contentSystem.getGiftAlbums(sceneStore.getPlayerState()) + const sidebarWidth = Math.max(58, Math.min(70, Math.floor(windowWidth * 0.18))) + const sidebarGap = 8 + const centerX = 24 + sidebarWidth + sidebarGap + const centerWidth = windowWidth - 48 - sidebarWidth * 2 - sidebarGap * 2 + const columns = 3 + const cardWidth = (centerWidth - 12 * (columns - 1)) / columns + const cardHeight = Math.min(118, Math.max(96, Math.floor(windowHeight * 0.15))) + const startY = 96 + + drawSidebarCard({ + x: 24, + y: startY, + width: sidebarWidth, + height: cardHeight, + titleLines: ['邀请', '好友'], + subtitle: '分享卡片', + accentColor: '#FF8A65', + onTap() { + triggerInviteShare() + render() + }, + }) + + drawSidebarCard({ + x: 24, + y: startY + cardHeight + 12, + width: sidebarWidth, + height: cardHeight, + titleLines: ['开房', 'PK'], + subtitle: 'V1.1+', + accentColor: '#7E57C2', + onTap() { + showTransientMessage('开房间 PK 需要实时同步,V1.1+ 接入') + render() + }, + }) + + drawSidebarCard({ + x: windowWidth - 24 - sidebarWidth, + y: startY, + width: sidebarWidth, + height: cardHeight, + titleLines: ['礼物', '区'], + subtitle: `${playerGiftAlbums[0].collectedCount + playerGiftAlbums[1].collectedCount} 个收藏`, + accentColor: '#26A69A', + onTap() { + sceneStore.openGiftZone() + render() + }, + }) + + drawSidebarCard({ + x: windowWidth - 24 - sidebarWidth, + y: startY + cardHeight + 12, + width: sidebarWidth, + height: cardHeight, + titleLines: ['排行', '榜'], + subtitle: '城市战队', + accentColor: '#42A5F5', + onTap() { + showTransientMessage('城市排行榜依赖服务端,V1.1+ 接入') + render() + }, + }) + + homeTiles.forEach((tile, index) => { + const row = Math.floor(index / columns) + const column = index % columns + const x = centerX + column * (cardWidth + 12) + const y = startY + row * (cardHeight + 12) + const isLocked = !tile.isUnlocked && tile.id !== 'coming_soon' + + ctx.fillStyle = tile.isUnlocked ? '#ffffff' : '#f0ebe5' + ctx.strokeStyle = tile.themeColor + ctx.lineWidth = 2 + drawRoundedRect(x, y, cardWidth, cardHeight, 16) + ctx.fill() + ctx.stroke() + + ctx.fillStyle = tile.themeColor + ctx.beginPath() + ctx.arc(x + cardWidth / 2, y + 34, 22, 0, Math.PI * 2) + ctx.fill() + + ctx.fillStyle = '#ffffff' + ctx.font = 'bold 16px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(String(index + 1), x + cardWidth / 2, y + 34) + + ctx.fillStyle = '#231f2a' + ctx.font = tile.name.length >= 5 ? 'bold 13px sans-serif' : 'bold 16px sans-serif' + ctx.fillText(tile.name, x + cardWidth / 2, y + 76) + + ctx.fillStyle = '#6b6474' + ctx.font = '12px sans-serif' + let subtitle = '后续开放' + + if (tile.id === 'asia') { + subtitle = 'MVP 已开放' + } else if (tile.id === 'mashup') { + subtitle = tile.isUnlocked ? '随机混搭' : '通关 2 城解锁' + } else if (tile.id === 'coming_soon') { + subtitle = '新玩法预告' + } else if (!isLocked) { + subtitle = '可进入' + } + + ctx.fillText(subtitle, x + cardWidth / 2, y + 100) + + if (isLocked) { + ctx.fillStyle = '#8d8694' + ctx.font = 'bold 12px sans-serif' + ctx.fillText('LOCK', x + cardWidth / 2, y + 116) + } + + registerHitTarget({ + x, + y, + width: cardWidth, + height: cardHeight, + onTap() { + const result = sceneStore.openHomeTile(tile.id) + + if (!result.opened) { + if (result.reason === 'locked') { + showTransientMessage(tile.id === 'mashup' ? '通关 2 个城市后解锁主题大混战' : `${tile.name} 后续开放`) + } else if (result.reason === 'coming-soon') { + showTransientMessage('新玩法即将上线,敬请期待!') + } else if (result.reason === 'mode-unavailable') { + showTransientMessage('主题大混战入口已预留,玩法实现下一步接入') + } else { + showTransientMessage('该入口暂不可用') + } + } + + render() + }, + }) + }) +} + +function drawCitySelectScene(scene) { + drawHeader('城市抓猫猫', '亚洲城市页 · 点击已解锁城市') + + const cityCards = contentSystem.listCityCards(scene.continentId, sceneStore.getPlayerState()) + const columns = 3 + const cardWidth = (windowWidth - 24 * 2 - 12 * (columns - 1)) / columns + const cardHeight = 128 + const startY = 96 + + cityCards.forEach((card, index) => { + const row = Math.floor(index / columns) + const column = index % columns + const x = 24 + column * (cardWidth + 12) + const y = startY + row * (cardHeight + 12) + + ctx.fillStyle = card.isUnlocked ? '#ffffff' : '#ebe5df' + ctx.strokeStyle = card.isUnlocked ? card.bgColor : '#d3ccc4' + ctx.lineWidth = 2 + drawRoundedRect(x, y, cardWidth, cardHeight, 16) + ctx.fill() + ctx.stroke() + + ctx.fillStyle = card.bgColor + ctx.beginPath() + ctx.arc(x + cardWidth / 2, y + 34, 22, 0, Math.PI * 2) + ctx.fill() + + ctx.fillStyle = '#231f2a' + ctx.font = 'bold 18px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(card.name, x + cardWidth / 2, y + 76) + + ctx.fillStyle = '#6b6474' + ctx.font = '13px sans-serif' + ctx.fillText( + card.isUnlocked ? `${card.completedLevels}/${card.totalLevels} 关` : '未解锁', + x + cardWidth / 2, + y + 100, + ) + + if (card.isUnlocked) { + registerHitTarget({ + x, + y, + width: cardWidth, + height: cardHeight, + onTap() { + sceneStore.openCity(card.cityId) + render() + }, + }) + } + }) +} + +function drawLevelSelectScene(scene) { + const city = contentSystem.getCity(scene.cityId) + + drawHeader(city.display.name, `${city.display.tagline} · 选择关卡`) + + const columns = 2 + const cardWidth = (windowWidth - 24 * 2 - 14) / columns + const cardHeight = 100 + const startY = 104 + + scene.levels.forEach((level, index) => { + const row = Math.floor(index / columns) + const column = index % columns + const x = 24 + column * (cardWidth + 14) + const y = startY + row * (cardHeight + 14) + + ctx.fillStyle = level.isUnlocked ? '#ffffff' : '#ebe5df' + ctx.strokeStyle = level.isCompleted ? city.display.bgColor : '#d7d0c8' + ctx.lineWidth = 2 + drawRoundedRect(x, y, cardWidth, cardHeight, 16) + ctx.fill() + ctx.stroke() + + ctx.fillStyle = level.isCompleted ? city.display.bgColor : '#231f2a' + ctx.font = 'bold 22px sans-serif' + ctx.textAlign = 'left' + ctx.fillText(`关卡 ${level.levelId}`, x + 18, y + 28) + + ctx.fillStyle = '#6b6474' + ctx.font = '14px sans-serif' + ctx.fillText( + level.isUnlocked ? (level.isCompleted ? `${'★'.repeat(level.stars || 3)}` : '点击开始') : '未解锁', + x + 18, + y + 64, + ) + + if (level.isUnlocked) { + registerHitTarget({ + x, + y, + width: cardWidth, + height: cardHeight, + onTap() { + sceneStore.openLevel(scene.cityId, level.levelId) + render() + }, + }) + } + }) + + drawBackButton(() => { + sceneStore.goBack() + render() + }) +} + +function drawGiftZoneScene(scene) { + const playerStateSnapshot = sceneStore.getPlayerState() + const albums = contentSystem.getGiftAlbums(playerStateSnapshot) + const entries = contentSystem.getGiftAlbumEntries(scene.selectedTab, playerStateSnapshot) + + drawHeader('城市主题礼物区', 'MVP 收集册 · 冰箱贴 / 邮票') + + const summaryX = 24 + const summaryY = 92 + const summaryWidth = windowWidth - 48 + const summaryHeight = 52 + + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = '#d7d0c8' + ctx.lineWidth = 2 + drawRoundedRect(summaryX, summaryY, summaryWidth, summaryHeight, 16) + ctx.fill() + ctx.stroke() + + ctx.fillStyle = '#231f2a' + ctx.font = 'bold 14px sans-serif' + ctx.textAlign = 'center' + ctx.fillText( + albums.map((album) => `${album.name} ${album.collectedCount}/${album.totalCount}`).join(' · '), + summaryX + summaryWidth / 2, + summaryY + summaryHeight / 2, + ) + + const tabWidth = (windowWidth - 24 * 2 - 12) / 2 + const tabsY = 158 + + albums.forEach((album, index) => { + drawButton({ + x: 24 + index * (tabWidth + 12), + y: tabsY, + width: tabWidth, + height: 42, + label: album.name, + fillStyle: scene.selectedTab === album.id ? '#231f2a' : '#ffffff', + textColor: scene.selectedTab === album.id ? '#ffffff' : '#231f2a', + onTap() { + sceneStore.selectGiftTab(album.id) + render() + }, + }) + }) + + const columns = 2 + const cardWidth = (windowWidth - 24 * 2 - 14) / columns + const cardHeight = 82 + const startY = 216 + + entries.forEach((entry, index) => { + const row = Math.floor(index / columns) + const column = index % columns + const x = 24 + column * (cardWidth + 14) + const y = startY + row * (cardHeight + 12) + + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = entry.themeColor + ctx.lineWidth = 2 + drawRoundedRect(x, y, cardWidth, cardHeight, 16) + ctx.fill() + ctx.stroke() + + ctx.fillStyle = entry.themeColor + ctx.font = 'bold 17px sans-serif' + ctx.textAlign = 'left' + ctx.fillText(entry.cityName, x + 16, y + 24) + + ctx.fillStyle = '#6b6474' + ctx.font = '13px sans-serif' + if (scene.selectedTab === 'magnets') { + ctx.fillText(`冰箱贴 ${entry.collectedCount}/${entry.totalCount}`, x + 16, y + 52) + } else { + ctx.fillText(entry.isCollected ? '邮票已收集' : '邮票未收集', x + 16, y + 52) + } + }) + + drawBackButton(() => { + sceneStore.goBack() + render() + }) +} + +function drawGameplayScene(scene) { + const city = contentSystem.getCity(scene.cityId) + const session = scene.session + const boardState = session.getBoardState() + const state = session.getState() + const clickableIds = new Set(session.getClickablePieces().map((piece) => piece.id)) + const activePieces = boardState.pieces + .filter((piece) => !piece.removed) + .sort((left, right) => left.layer - right.layer) + + const elementNameMap = new Map(city.elements.map((element) => [element.id, element.name])) + + drawHeader(`${city.display.name} · 关卡 ${scene.levelId}`, `${state.slot.length}/7 槽位`) + + drawButton({ + x: windowWidth - 112, + y: 22, + width: 88, + height: 34, + label: '重开', + onTap() { + sceneStore.restartCurrentLevel() + render() + }, + fillStyle: '#ffffff', + textColor: '#231f2a', + }) + + activePieces.forEach((piece) => { + const style = getElementStyle(piece.elementId) + const isClickable = clickableIds.has(piece.id) + + ctx.fillStyle = style.fill + ctx.strokeStyle = isClickable ? style.border : '#c9c1ba' + ctx.lineWidth = isClickable ? 2 : 1 + ctx.globalAlpha = isClickable ? 1 : 0.5 + drawRoundedRect(piece.x, piece.y, piece.width, piece.height, 12) + ctx.fill() + ctx.stroke() + ctx.globalAlpha = 1 + + ctx.fillStyle = '#231f2a' + ctx.font = 'bold 12px sans-serif' + ctx.textAlign = 'center' + ctx.fillText( + (elementNameMap.get(piece.elementId) ?? '').slice(0, 2), + piece.x + piece.width / 2, + piece.y + piece.height / 2, + ) + + if (isClickable && state.status === 'playing') { + registerHitTarget({ + x: piece.x, + y: piece.y, + width: piece.width, + height: piece.height, + onTap() { + const result = session.pickPiece(piece.id) + + if (result.status === 'won') { + sceneStore.completeLevel({ + cityId: scene.cityId, + levelId: scene.levelId, + stars: 3, + }) + savePlayerState() + } + + render() + }, + }) + } + }) + + const slotY = windowHeight - 126 + const slotWidth = (windowWidth - 24 * 2 - 6 * 8) / 7 + + for (let index = 0; index < 7; index += 1) { + const x = 24 + index * (slotWidth + 8) + const slotEntry = state.slot[index] + + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = '#d5cec7' + ctx.lineWidth = 2 + drawRoundedRect(x, slotY, slotWidth, 54, 12) + ctx.fill() + ctx.stroke() + + if (slotEntry) { + const label = elementNameMap.get(slotEntry.elementId) ?? '' + const style = getElementStyle(slotEntry.elementId) + ctx.fillStyle = style.border + ctx.font = 'bold 12px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(label.slice(0, 2), x + slotWidth / 2, slotY + 27) + } + } + + drawBackButton(() => { + sceneStore.goBack() + render() + }) + + if (state.status === 'won' || state.status === 'failed') { + drawResultOverlay(state.status, city.display.bgColor) + } +} + +function drawResultOverlay(status, accentColor) { + ctx.fillStyle = 'rgba(35, 31, 42, 0.45)' + ctx.fillRect(0, 0, windowWidth, windowHeight) + + const boxWidth = windowWidth - 48 + const boxHeight = 180 + const x = 24 + const y = (windowHeight - boxHeight) / 2 + + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = accentColor + ctx.lineWidth = 2 + drawRoundedRect(x, y, boxWidth, boxHeight, 20) + ctx.fill() + ctx.stroke() + + ctx.fillStyle = '#231f2a' + ctx.font = 'bold 28px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(status === 'won' ? '通关成功' : '挑战失败', x + boxWidth / 2, y + 48) + + ctx.fillStyle = '#6b6474' + ctx.font = '15px sans-serif' + ctx.fillText( + status === 'won' ? '已记录进度,返回关卡页继续' : '可以重开当前关卡,继续测试核心循环', + x + boxWidth / 2, + y + 86, + ) + + drawButton({ + x: x + 20, + y: y + 120, + width: (boxWidth - 50) / 2, + height: 42, + label: status === 'won' ? '返回关卡' : '返回', + onTap() { + sceneStore.goBack() + render() + }, + fillStyle: '#ffffff', + textColor: '#231f2a', + }) + + drawButton({ + x: x + 30 + (boxWidth - 50) / 2, + y: y + 120, + width: (boxWidth - 50) / 2, + height: 42, + label: status === 'won' ? '下一步再做' : '重新挑战', + onTap() { + if (status === 'won') { + sceneStore.goBack() + } else { + sceneStore.restartCurrentLevel() + } + render() + }, + fillStyle: '#231f2a', + textColor: '#ffffff', + }) +} + +function drawTransientMessage() { + if (!transientMessage || transientMessageUntil <= Date.now()) { + transientMessage = '' + transientMessageUntil = 0 + return + } + + const width = windowWidth - 48 + const height = 44 + const x = 24 + const y = windowHeight - 116 + + ctx.fillStyle = 'rgba(35, 31, 42, 0.88)' + drawRoundedRect(x, y, width, height, 14) + ctx.fill() + + ctx.fillStyle = '#ffffff' + ctx.font = '14px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(transientMessage, x + width / 2, y + height / 2) +} + +function render() { + resetHitTargets() + drawBackground() + + const scene = sceneStore.getScene() + + if (scene.type === 'home-select') { + drawHomeScene() + drawTransientMessage() + return + } + + if (scene.type === 'city-select') { + drawCitySelectScene(scene) + drawTransientMessage() + return + } + + if (scene.type === 'level-select') { + drawLevelSelectScene(scene) + drawTransientMessage() + return + } + + if (scene.type === 'gift-zone') { + drawGiftZoneScene(scene) + drawTransientMessage() + return + } + + if (scene.type === 'gameplay') { + drawGameplayScene(scene) + drawTransientMessage() + } +} + +function handleTouchStart(event) { + const touch = event.touches?.[0] + if (!touch) { + return + } + + const target = hitTest(touch.clientX, touch.clientY) + if (target) { + target.onTap() + } +} + +if (typeof wx.onTouchStart === 'function') { + wx.onTouchStart(handleTouchStart) +} + +render() diff --git a/js/ui/scene-store.js b/js/ui/scene-store.js new file mode 100644 index 0000000..98bcbce --- /dev/null +++ b/js/ui/scene-store.js @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..863dd0c --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "wechat-minigame", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + } +} diff --git a/tests/content-system.test.js b/tests/content-system.test.js new file mode 100644 index 0000000..38871ac --- /dev/null +++ b/tests/content-system.test.js @@ -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) +}) diff --git a/tests/difficulty-generator.test.js b/tests/difficulty-generator.test.js new file mode 100644 index 0000000..9451cde --- /dev/null +++ b/tests/difficulty-generator.test.js @@ -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') +}) diff --git a/tests/game-session.test.js b/tests/game-session.test.js new file mode 100644 index 0000000..be3829e --- /dev/null +++ b/tests/game-session.test.js @@ -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') +}) diff --git a/tests/scene-store.test.js b/tests/scene-store.test.js new file mode 100644 index 0000000..62342be --- /dev/null +++ b/tests/scene-store.test.js @@ -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') +})