diff --git a/docs/superpowers/plans/2026-03-29-gift-zone-cityteam-mashup.md b/docs/superpowers/plans/2026-03-29-gift-zone-cityteam-mashup.md new file mode 100644 index 0000000..ad78726 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-gift-zone-cityteam-mashup.md @@ -0,0 +1,67 @@ +# Gift Zone CityTeam Mashup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the MVP cat album and local cityTeam entry flow, then wire the mashup home tile into a playable standalone round with local rewards. + +**Architecture:** Extend the existing `contentSystem` and `scene-store` contracts instead of refactoring core navigation. Reuse the current gameplay session for mashup by feeding it generated board metadata and handling mashup completion in a separate local reward path. + +**Tech Stack:** ES modules, Node test runner, WeChat mini-game canvas shell + +--- + +### Task 1: Docs And Data Contracts + +**Files:** +- Create: `docs/superpowers/specs/2026-03-29-gift-zone-cityteam-mashup-design.md` +- Create: `docs/superpowers/plans/2026-03-29-gift-zone-cityteam-mashup.md` +- Modify: `js/content/index.js` + +- [ ] Define the MVP cat album contract in `contentSystem` +- [ ] Keep `animals` and `maps` out of scope +- [ ] Add any helper projections needed for cityTeam and mashup + +### Task 2: Gift Zone And CityTeam + +**Files:** +- Modify: `tests/content-system.test.js` +- Modify: `tests/scene-store.test.js` +- Modify: `js/content/index.js` +- Modify: `js/ui/scene-store.js` +- Modify: `js/main.js` + +- [ ] Write failing tests for `cats` album visibility and counts +- [ ] Write failing tests for cityTeam selection flow +- [ ] Implement `cats` album projections with existing city cat data +- [ ] Implement `city-team-select` scene and local selection writeback +- [ ] Render cityTeam status and entry buttons in the canvas UI +- [ ] Re-run targeted tests until green + +### Task 3: Mashup Gameplay + +**Files:** +- Modify: `tests/difficulty-generator.test.js` +- Modify: `tests/scene-store.test.js` +- Modify: `js/gameplay/difficulty/generate-board.js` +- Create: `js/gameplay/difficulty/generate-mashup-board.js` +- Modify: `js/gameplay/difficulty/index.js` +- Modify: `js/gameplay/session/index.js` +- Modify: `js/ui/scene-store.js` +- Modify: `js/main.js` + +- [ ] Write failing tests for mashup board generation +- [ ] Write failing tests for mashup scene entry and reward writeback +- [ ] Implement mashup board builder from unlocked city content +- [ ] Implement mashup scene/session creation in `scene-store` +- [ ] Implement mashup win reward path and result messaging +- [ ] Re-run targeted tests until green + +### Task 4: Full Verification + +**Files:** +- Modify: `tests/*` as needed +- Modify: `js/*` as needed + +- [ ] Run the full test suite with `npm test` +- [ ] Verify no unrelated placeholders were implemented +- [ ] Summarize remaining placeholder areas in the final report diff --git a/docs/superpowers/specs/2026-03-29-gift-zone-cityteam-mashup-design.md b/docs/superpowers/specs/2026-03-29-gift-zone-cityteam-mashup-design.md new file mode 100644 index 0000000..4d9a9a9 --- /dev/null +++ b/docs/superpowers/specs/2026-03-29-gift-zone-cityteam-mashup-design.md @@ -0,0 +1,92 @@ +# Gift Zone CityTeam Mashup Design + +## Goal + +在现有 MVP 壳子上补齐三个紧邻切片: + +- 礼物区从 2 册扩到 3 册,先把 `猫猫册` 做成可浏览的 MVP 版本 +- 接通本地 `cityTeam` 选队入口,只做本地选择和展示,不做服务端排行 +- 把 `主题大混战` 做成可进入的独立对局,并提供本地奖励回写 + +## Scope + +### In + +- `gift-zone` 增加 `cats` tab 和汇总计数 +- `cityTeam` 选择页、本地入队状态、入口按钮 +- `mashup` 入口从首页可进入独立对局 +- `mashup` 使用已解锁城市元素混搭生成棋盘 +- `mashup` 通关奖励写回本地存档 + +### Out + +- 真正的分享卡片视觉内容 +- 开房 PK +- 城市战队排行榜服务端逻辑 +- 礼物区 `动物册 / 地图册` +- 地区级猫猫收集规则 + +## Design Decisions + +### 1. 猫猫册口径 + +先用“已通关城市猫”作为 `猫猫册` MVP 口径,而不是文档里的“地区猫”正式口径。 + +原因: + +- 现有存档里已经有 `collectedCats[]` 城市完成态 +- 现有 CityManifest 已经有 `cat` 和 `cover.catThumb` +- 这样可以不引入新资源、不改通关发奖链路 + +后续若升级为“地区猫”,只需要替换 `cats` album 的数据投影层,不必重写礼物区 UI 框架。 + +### 2. cityTeam 入口 + +`cityTeam` 入口同时挂在两个地方: + +- 礼物区顶部信息卡 +- 主页右侧“排行榜”卡片 + +如果未选队,进入 `city-team-select` 选择页;如果已选队,则展示当前所属战队的本地状态说明。选择仅允许已解锁城市,首次选择后本地锁定。 + +### 3. 主题大混战实现方式 + +不把 `mashup` 硬塞进现有城市关卡合同,而是新增一条轻量 mode 路径: + +- `scene-store` 增加 `mashup` scene +- 生成器增加 mashup board 构建辅助函数 +- `gameplay` 复用现有 `GameSession` +- `main.js` 根据 scene metadata 渲染 mashup 标题、结果和奖励文案 + +这样不需要把 `contentSystem.getCity()` 改成兼容“虚拟城市”,能控制改动面。 + +### 4. 混战奖励 + +混战不计入城市关卡进度。通关奖励规则: + +- 优先随机发一个当前已解锁城市的冰箱贴 +- 如果随机命中的冰箱贴已拥有,则发 `shuffle +1` + +奖励完全本地化,只做存档回写和结果展示。 + +## Data Flow + +### Gift Zone + +`playerState` -> `contentSystem.getGiftAlbums()` / `getGiftAlbumEntries()` -> `scene-store gift-zone` -> `main.js` 礼物区页面 + +### CityTeam + +`playerState.cityTeam` -> `scene-store` 入口判断 / 选择写回 -> `main.js` 选择页与状态卡 + +### Mashup + +首页 `mashup` tile -> `scene-store.openHomeTile('mashup')` -> 构建 mashup board + session -> `main.js` gameplay -> 通关后 `scene-store.completeMashupRun()` + +## Testing + +- 内容系统测试:礼物区 album 列表包含 `cats`,计数与条目投影正确 +- scene-store 测试:cityTeam 选择页可打开、可选择、选后锁定 +- scene-store 测试:`mashup` 在通关 2 城后可进入 +- 对局/难度测试:mashup board 由多个城市元素组成且可复现 +- scene-store 测试:mashup 通关可写入奖励 diff --git a/js/content/index.js b/js/content/index.js index ce47eff..ad9c12b 100644 --- a/js/content/index.js +++ b/js/content/index.js @@ -44,6 +44,7 @@ export function createContentSystem() { const giftAlbumDefinitions = [ { id: 'magnets', name: '冰箱贴册' }, { id: 'stamps', name: '邮票册' }, + { id: 'cats', name: '猫猫册' }, ] function getCompletedCityCount(playerState) { @@ -115,6 +116,35 @@ export function createContentSystem() { }) } + function getCatEntries(playerState) { + const collectedCats = new Set(playerState.collectedCats ?? []) + + return cityRegistry.getAllCities().map((city) => ({ + cityId: city.id, + cityName: city.display.name, + cityNameEn: city.display.nameEn, + themeColor: city.display.bgColor, + catId: city.cat.id, + catName: city.cat.name, + catThumb: city.cover.catThumb, + isCollected: collectedCats.has(city.id), + })) + } + + function summarizeAlbumEntries(albumId, entries) { + if (albumId === 'magnets') { + return { + collectedCount: entries.reduce((sum, entry) => sum + entry.collectedCount, 0), + totalCount: entries.reduce((sum, entry) => sum + entry.totalCount, 0), + } + } + + return { + collectedCount: entries.filter((entry) => entry.isCollected).length, + totalCount: entries.length, + } + } + function getGiftAlbumEntries(albumId, playerState) { if (albumId === 'magnets') { return getMagnetEntries(playerState) @@ -124,23 +154,22 @@ export function createContentSystem() { return getStampEntries(playerState) } + if (albumId === 'cats') { + return getCatEntries(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 + const summary = summarizeAlbumEntries(definition.id, entries) return { ...definition, - collectedCount, - totalCount, + collectedCount: summary.collectedCount, + totalCount: summary.totalCount, } }) } diff --git a/js/gameplay/difficulty/generate-board.js b/js/gameplay/difficulty/generate-board.js index 1f36b5d..3b83b9b 100644 --- a/js/gameplay/difficulty/generate-board.js +++ b/js/gameplay/difficulty/generate-board.js @@ -105,6 +105,38 @@ function placePieces(piecePool, levelPreset, rng) { return placedPieces } +export function generateBoardFromDefinition({ + boardId, + cityId, + levelId, + seed, + elements, + levelPreset, + extraState = {}, +}) { + const effectiveSeed = seed ?? levelPreset.seedBase + const rng = createRng(effectiveSeed) + const selectedElements = [...elements] + 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: boardId ?? `${cityId}-${levelId}-${effectiveSeed}`, + cityId, + levelId, + seed: effectiveSeed, + pieces, + overlapGraph, + metrics: {}, + ...extraState, + } + + boardState.metrics = evaluateBoard(boardState, levelPreset) + + return boardState +} + export function generateBoard({ cityId, levelId, seed, contentSystem }) { const city = contentSystem.getCity(cityId) const levelPreset = contentSystem.getLevelPreset(cityId, levelId) @@ -116,23 +148,14 @@ export function generateBoard({ cityId, levelId, seed, contentSystem }) { 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}`, + + return generateBoardFromDefinition({ cityId, levelId, seed: effectiveSeed, - pieces, - overlapGraph, - metrics: {}, - } - - boardState.metrics = evaluateBoard(boardState, levelPreset) - - return boardState + elements: selectedElements, + levelPreset, + }) } export default generateBoard diff --git a/js/gameplay/difficulty/generate-mashup-board.js b/js/gameplay/difficulty/generate-mashup-board.js new file mode 100644 index 0000000..0ad6a8b --- /dev/null +++ b/js/gameplay/difficulty/generate-mashup-board.js @@ -0,0 +1,99 @@ +import { generateBoardFromDefinition } from './generate-board.js' +import { createRng, shuffleWithRng } from './random.js' + +function createMashupLevelPreset(elementCount) { + return { + id: 1, + seedBase: 31001, + elementCount, + piecesPerElement: Array.from({ length: elementCount }, () => 3), + layers: 4, + density: 'medium_high', + } +} + +function getSourceCities(cityIds, contentSystem) { + return cityIds + .map((cityId) => contentSystem.getCity(cityId)) + .filter(Boolean) +} + +function createCityElementPools(cities, rng) { + return cities.map((city) => ({ + cityId: city.id, + cursor: 0, + elements: shuffleWithRng( + city.elements.map((element) => ({ + ...element, + sourceCityId: city.id, + sourceCityName: city.display.name, + })), + rng, + ), + })) +} + +function pickMashupElements(cities, targetCount, rng) { + const pools = createCityElementPools(cities, rng) + const selected = [] + + while (selected.length < targetCount) { + let didAdd = false + + for (const pool of pools) { + const element = pool.elements[pool.cursor] + if (!element) { + continue + } + + selected.push(element) + pool.cursor += 1 + didAdd = true + + if (selected.length === targetCount) { + break + } + } + + if (!didAdd) { + break + } + } + + return selected +} + +export function generateMashupBoard({ cityIds, seed, contentSystem }) { + const cities = getSourceCities(cityIds, contentSystem) + + if (cities.length === 0) { + throw new Error('Mashup mode requires at least one source city') + } + + const effectiveSeed = seed ?? 31001 + const rng = createRng(effectiveSeed) + const maxElementCount = cities.reduce((sum, city) => sum + city.elements.length, 0) + const elementCount = Math.min(8, maxElementCount) + const levelPreset = createMashupLevelPreset(elementCount) + const selectedElements = pickMashupElements(cities, elementCount, rng) + + return generateBoardFromDefinition({ + boardId: `mashup-${effectiveSeed}`, + cityId: 'mashup', + levelId: 1, + seed: effectiveSeed, + elements: selectedElements, + levelPreset, + extraState: { + sourceCityIds: cities.map((city) => city.id), + elementDefinitions: selectedElements.map((element) => ({ + elementId: element.id, + name: element.name, + sourceCityId: element.sourceCityId, + sourceCityName: element.sourceCityName, + })), + }, + }) +} + +export default generateMashupBoard diff --git a/js/gameplay/difficulty/index.js b/js/gameplay/difficulty/index.js index 7446f59..d3f76fd 100644 --- a/js/gameplay/difficulty/index.js +++ b/js/gameplay/difficulty/index.js @@ -1,6 +1,7 @@ export { classifyDeadlock } from './classify-deadlock.js' export { evaluateBoard } from './evaluate-board.js' export { generateBoard } from './generate-board.js' +export { generateMashupBoard } from './generate-mashup-board.js' export { buildOverlapGraph, getClickablePieces, diff --git a/js/gameplay/session/index.js b/js/gameplay/session/index.js index 1dcf2c2..3c198e2 100644 --- a/js/gameplay/session/index.js +++ b/js/gameplay/session/index.js @@ -65,6 +65,7 @@ export function createGameSession({ seed, contentSystem, boardState, + restartFactory, }) { const resolvedBoard = boardState ? cloneBoardState(boardState) : generateBoard({ cityId, levelId, seed, contentSystem }) const state = { @@ -160,6 +161,10 @@ export function createGameSession({ } function restart(nextSeed = seed ?? resolvedBoard.seed) { + if (restartFactory) { + return restartFactory(nextSeed) + } + const freshSession = createGameSession({ cityId, levelId, diff --git a/js/main.js b/js/main.js index 9c773bc..0e6946c 100644 --- a/js/main.js +++ b/js/main.js @@ -223,10 +223,15 @@ function drawSidebarCard({ x, y, width, height, titleLines, subtitle, onTap, acc } function drawHomeScene() { + const playerStateSnapshot = sceneStore.getPlayerState() drawHeader('城市抓猫猫', '世界主页面 · 3×3 入口') - const homeTiles = contentSystem.getHomeTiles(sceneStore.getPlayerState()) - const playerGiftAlbums = contentSystem.getGiftAlbums(sceneStore.getPlayerState()) + const homeTiles = contentSystem.getHomeTiles(playerStateSnapshot) + const playerGiftAlbums = contentSystem.getGiftAlbums(playerStateSnapshot) + const teamCity = playerStateSnapshot.cityTeam?.teamCityId + ? contentSystem.getCity(playerStateSnapshot.cityTeam.teamCityId) + : null + const totalGiftCount = playerGiftAlbums.reduce((sum, album) => sum + album.collectedCount, 0) const sidebarWidth = Math.max(58, Math.min(70, Math.floor(windowWidth * 0.18))) const sidebarGap = 8 const centerX = 24 + sidebarWidth + sidebarGap @@ -270,7 +275,7 @@ function drawHomeScene() { width: sidebarWidth, height: cardHeight, titleLines: ['礼物', '区'], - subtitle: `${playerGiftAlbums[0].collectedCount + playerGiftAlbums[1].collectedCount} 个收藏`, + subtitle: `${totalGiftCount} 个收藏`, accentColor: '#26A69A', onTap() { sceneStore.openGiftZone() @@ -284,10 +289,10 @@ function drawHomeScene() { width: sidebarWidth, height: cardHeight, titleLines: ['排行', '榜'], - subtitle: '城市战队', + subtitle: teamCity ? `${teamCity.display.name} 战队` : '选择战队', accentColor: '#42A5F5', onTap() { - showTransientMessage('城市排行榜依赖服务端,V1.1+ 接入') + sceneStore.openCityTeamSelect() render() }, }) @@ -483,8 +488,11 @@ function drawGiftZoneScene(scene) { const playerStateSnapshot = sceneStore.getPlayerState() const albums = contentSystem.getGiftAlbums(playerStateSnapshot) const entries = contentSystem.getGiftAlbumEntries(scene.selectedTab, playerStateSnapshot) + const teamCity = playerStateSnapshot.cityTeam?.teamCityId + ? contentSystem.getCity(playerStateSnapshot.cityTeam.teamCityId) + : null - drawHeader('城市主题礼物区', 'MVP 收集册 · 冰箱贴 / 邮票') + drawHeader('城市主题礼物区', 'MVP 收集册 · 冰箱贴 / 邮票 / 猫猫') const summaryX = 24 const summaryY = 92 @@ -507,8 +515,50 @@ function drawGiftZoneScene(scene) { summaryY + summaryHeight / 2, ) - const tabWidth = (windowWidth - 24 * 2 - 12) / 2 - const tabsY = 158 + const teamCardY = 156 + const teamCardHeight = 58 + + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = teamCity ? teamCity.display.bgColor : '#8aa3ff' + ctx.lineWidth = 2 + drawRoundedRect(summaryX, teamCardY, summaryWidth, teamCardHeight, 16) + ctx.fill() + ctx.stroke() + + ctx.fillStyle = '#231f2a' + ctx.font = 'bold 15px sans-serif' + ctx.textAlign = 'left' + ctx.fillText( + teamCity ? `${teamCity.display.name} 战队` : '还没加入城市战队', + summaryX + 16, + teamCardY + 22, + ) + + ctx.fillStyle = '#6b6474' + ctx.font = '12px sans-serif' + ctx.fillText( + teamCity ? '本地已锁定,排行榜继续占位' : '先选已解锁城市,本地状态先走通', + summaryX + 16, + teamCardY + 42, + ) + + drawButton({ + x: summaryX + summaryWidth - 116, + y: teamCardY + 12, + width: 100, + height: 34, + label: teamCity ? '查看战队' : '选择战队', + onTap() { + sceneStore.openCityTeamSelect() + render() + }, + fillStyle: teamCity ? teamCity.display.bgColor : '#231f2a', + textColor: '#ffffff', + }) + + const tabGap = 12 + const tabWidth = (windowWidth - 24 * 2 - tabGap * (albums.length - 1)) / albums.length + const tabsY = 228 albums.forEach((album, index) => { drawButton({ @@ -529,7 +579,7 @@ function drawGiftZoneScene(scene) { const columns = 2 const cardWidth = (windowWidth - 24 * 2 - 14) / columns const cardHeight = 82 - const startY = 216 + const startY = 286 entries.forEach((entry, index) => { const row = Math.floor(index / columns) @@ -553,8 +603,10 @@ function drawGiftZoneScene(scene) { ctx.font = '13px sans-serif' if (scene.selectedTab === 'magnets') { ctx.fillText(`冰箱贴 ${entry.collectedCount}/${entry.totalCount}`, x + 16, y + 52) - } else { + } else if (scene.selectedTab === 'stamps') { ctx.fillText(entry.isCollected ? '邮票已收集' : '邮票未收集', x + 16, y + 52) + } else { + ctx.fillText(entry.isCollected ? `${entry.catName} 已收集` : '通关城市后收集', x + 16, y + 52) } }) @@ -564,8 +616,73 @@ function drawGiftZoneScene(scene) { }) } +function drawCityTeamSelectScene(scene) { + const isLocked = Boolean(scene.selectedTeamCityId) + const selectedCity = isLocked ? contentSystem.getCity(scene.selectedTeamCityId) : null + + drawHeader( + '选择城市战队', + selectedCity ? `当前所属 ${selectedCity.display.name} 战队 · 本地先锁定` : '从已解锁城市里选择一个战队', + ) + + const columns = 2 + const cardWidth = (windowWidth - 24 * 2 - 14) / columns + const cardHeight = 94 + const startY = 104 + + scene.options.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) + const isSelected = scene.selectedTeamCityId === entry.cityId + + 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 18px sans-serif' + ctx.textAlign = 'left' + ctx.fillText(entry.cityName, x + 16, y + 24) + + ctx.fillStyle = '#6b6474' + ctx.font = '13px sans-serif' + ctx.fillText(`猫猫 ${entry.catName}`, x + 16, y + 48) + ctx.fillText(isSelected ? '已加入,当前不可更换' : '可加入本地战队', x + 16, y + 72) + + registerHitTarget({ + x, + y, + width: cardWidth, + height: cardHeight, + onTap() { + const selected = sceneStore.chooseCityTeam(entry.cityId) + if (selected) { + savePlayerState() + showTransientMessage(`已加入 ${entry.cityName} 战队`) + } else if (isLocked) { + showTransientMessage('当前版本先锁定首次选队') + } else { + showTransientMessage('只能选择已解锁城市') + } + render() + }, + }) + }) + + drawBackButton(() => { + sceneStore.goBack() + render() + }) +} + function drawGameplayScene(scene) { - const city = contentSystem.getCity(scene.cityId) + const isMashup = scene.mode === 'mashup' + const city = isMashup ? null : contentSystem.getCity(scene.cityId) const session = scene.session const boardState = session.getBoardState() const state = session.getState() @@ -573,10 +690,15 @@ function drawGameplayScene(scene) { const activePieces = boardState.pieces .filter((piece) => !piece.removed) .sort((left, right) => left.layer - right.layer) + const elementNameMap = isMashup + ? new Map(scene.elementDefinitions.map((element) => [element.elementId, element.name])) + : new Map(city.elements.map((element) => [element.id, element.name])) + const title = isMashup ? scene.title : `${city.display.name} · 关卡 ${scene.levelId}` + const subtitle = isMashup + ? `${scene.subtitle ?? '随机混搭'} · ${state.slot.length}/7 槽位` + : `${state.slot.length}/7 槽位` - const elementNameMap = new Map(city.elements.map((element) => [element.id, element.name])) - - drawHeader(`${city.display.name} · 关卡 ${scene.levelId}`, `${state.slot.length}/7 槽位`) + drawHeader(title, subtitle) drawButton({ x: windowWidth - 112, @@ -624,11 +746,7 @@ function drawGameplayScene(scene) { const result = session.pickPiece(piece.id) if (result.status === 'won') { - sceneStore.completeLevel({ - cityId: scene.cityId, - levelId: scene.levelId, - stars: 3, - }) + sceneStore.completeGameplayVictory(3) savePlayerState() } @@ -668,11 +786,29 @@ function drawGameplayScene(scene) { }) if (state.status === 'won' || state.status === 'failed') { - drawResultOverlay(state.status, city.display.bgColor) + drawResultOverlay(scene, state.status, isMashup ? scene.accentColor : city.display.bgColor) } } -function drawResultOverlay(status, accentColor) { +function drawResultOverlay(scene, status, accentColor) { + const isMashup = scene.mode === 'mashup' + const rewardSummary = scene.rewardSummary + const title = status === 'won' ? '通关成功' : '挑战失败' + let detail = '可以重开当前关卡,继续测试核心循环' + + if (status === 'won' && isMashup) { + if (rewardSummary?.type === 'magnet') { + const rewardCity = contentSystem.getCity(rewardSummary.cityId) + detail = `奖励:${rewardCity?.display.name ?? rewardSummary.cityId} 冰箱贴 ${rewardSummary.levelId}` + } else if (rewardSummary?.type === 'inventory') { + detail = '奖励:Shuffle +1' + } else { + detail = '已结算本地混战奖励' + } + } else if (status === 'won') { + detail = '已记录进度,返回关卡页继续' + } + ctx.fillStyle = 'rgba(35, 31, 42, 0.45)' ctx.fillRect(0, 0, windowWidth, windowHeight) @@ -691,22 +827,18 @@ function drawResultOverlay(status, accentColor) { ctx.fillStyle = '#231f2a' ctx.font = 'bold 28px sans-serif' ctx.textAlign = 'center' - ctx.fillText(status === 'won' ? '通关成功' : '挑战失败', x + boxWidth / 2, y + 48) + ctx.fillText(title, x + boxWidth / 2, y + 48) ctx.fillStyle = '#6b6474' ctx.font = '15px sans-serif' - ctx.fillText( - status === 'won' ? '已记录进度,返回关卡页继续' : '可以重开当前关卡,继续测试核心循环', - x + boxWidth / 2, - y + 86, - ) + ctx.fillText(detail, x + boxWidth / 2, y + 86) drawButton({ x: x + 20, y: y + 120, width: (boxWidth - 50) / 2, height: 42, - label: status === 'won' ? '返回关卡' : '返回', + label: status === 'won' ? (isMashup ? '返回主页' : '返回关卡') : '返回', onTap() { sceneStore.goBack() render() @@ -720,10 +852,14 @@ function drawResultOverlay(status, accentColor) { y: y + 120, width: (boxWidth - 50) / 2, height: 42, - label: status === 'won' ? '下一步再做' : '重新挑战', + label: status === 'won' ? (isMashup ? '再来一局' : '下一步再做') : '重新挑战', onTap() { if (status === 'won') { - sceneStore.goBack() + if (isMashup) { + sceneStore.restartCurrentLevel() + } else { + sceneStore.goBack() + } } else { sceneStore.restartCurrentLevel() } @@ -786,6 +922,12 @@ function render() { return } + if (scene.type === 'city-team-select') { + drawCityTeamSelectScene(scene) + drawTransientMessage() + return + } + if (scene.type === 'gameplay') { drawGameplayScene(scene) drawTransientMessage() diff --git a/js/ui/scene-store.js b/js/ui/scene-store.js index 98bcbce..2a012d2 100644 --- a/js/ui/scene-store.js +++ b/js/ui/scene-store.js @@ -1,4 +1,5 @@ import { createGameSession } from '../gameplay/session/index.js' +import { generateMashupBoard } from '../gameplay/difficulty/index.js' function createHomeSelectScene() { return { @@ -20,6 +21,41 @@ function createGiftZoneScene(selectedTab = 'magnets') { } } +function createCityTeamSelectScene(options, selectedTeamCityId) { + return { + type: 'city-team-select', + options, + selectedTeamCityId, + } +} + +function createGameplayScene({ + cityId, + levelId, + session, + mode = 'city', + title = null, + subtitle = null, + accentColor = null, + sourceCityIds = [], + elementDefinitions = [], + rewardSummary = null, +}) { + return { + type: 'gameplay', + cityId, + levelId, + session, + mode, + title, + subtitle, + accentColor, + sourceCityIds, + elementDefinitions, + rewardSummary, + } +} + function createLevelProgress(city, playerState) { const cityProgress = playerState.levelProgress[city.id] ?? {} @@ -57,6 +93,35 @@ function createAcquiredDate(now) { return new Date(now()).toISOString() } +function createCityTeamOptions(contentSystem, playerState) { + return playerState.unlockedCities + .map((cityId) => { + const city = contentSystem.getCity(cityId) + if (!city) { + return null + } + + return { + cityId: city.id, + cityName: city.display.name, + catName: city.cat.name, + themeColor: city.display.bgColor, + } + }) + .filter(Boolean) +} + +function createMashupRewardCandidates(contentSystem, playerState) { + return playerState.unlockedCities + .map((cityId) => contentSystem.getCity(cityId)) + .filter(Boolean) + .flatMap((city) => city.levelPresets.map((preset) => ({ + magnetId: `magnet_${city.id}_${preset.id}`, + cityId: city.id, + levelId: preset.id, + }))) +} + function awardLevelMagnet(playerState, cityId, levelId, now) { const magnets = ensureCollection(playerState, 'collectedMagnets') const magnetId = `magnet_${cityId}_${levelId}` @@ -73,6 +138,41 @@ function awardLevelMagnet(playerState, cityId, levelId, now) { }) } +function awardMashupReward(contentSystem, playerState, seed, now) { + const rewardCandidates = createMashupRewardCandidates(contentSystem, playerState) + const magnets = ensureCollection(playerState, 'collectedMagnets') + + if (rewardCandidates.length === 0) { + playerState.inventory.shuffle = (playerState.inventory.shuffle ?? 0) + 1 + return { + type: 'inventory', + itemId: 'shuffle', + amount: 1, + } + } + + const reward = rewardCandidates[seed % rewardCandidates.length] + + if (magnets.some((entry) => entry.magnetId === reward.magnetId)) { + playerState.inventory.shuffle = (playerState.inventory.shuffle ?? 0) + 1 + return { + type: 'inventory', + itemId: 'shuffle', + amount: 1, + } + } + + magnets.push({ + ...reward, + acquiredDate: createAcquiredDate(now), + }) + + return { + type: 'magnet', + ...reward, + } +} + function markCityCompletion(contentSystem, playerState, cityId, now) { if (!playerState.collectedCats.includes(cityId)) { playerState.collectedCats.push(cityId) @@ -105,6 +205,40 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date. const history = [] let currentScene = createHomeSelectScene() + function createMashupSession(seed = now()) { + const boardState = generateMashupBoard({ + cityIds: playerState.unlockedCities, + seed, + contentSystem, + }) + + return createGameSession({ + cityId: 'mashup', + levelId: 1, + seed, + contentSystem, + boardState, + restartFactory: (nextSeed) => createMashupSession(nextSeed), + }) + } + + function createMashupGameplayScene(seed = now()) { + const session = createMashupSession(seed) + const boardState = session.getBoardState() + + return createGameplayScene({ + cityId: 'mashup', + levelId: 1, + session, + mode: 'mashup', + title: '主题大混战', + subtitle: `${boardState.sourceCityIds.length} 城混搭`, + accentColor: '#E74C3C', + sourceCityIds: boardState.sourceCityIds ?? [], + elementDefinitions: boardState.elementDefinitions ?? [], + }) + } + function getScene() { return currentScene } @@ -130,7 +264,9 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date. } if (tile.id === 'mashup') { - return { opened: false, reason: 'mode-unavailable' } + history.push(currentScene) + currentScene = createMashupGameplayScene() + return { opened: true } } return { opened: false, reason: 'unavailable' } @@ -167,7 +303,7 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date. return false } - if (!['magnets', 'stamps'].includes(tabId)) { + if (!['magnets', 'stamps', 'cats'].includes(tabId)) { return false } @@ -179,6 +315,40 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date. return true } + function openCityTeamSelect() { + history.push(currentScene) + currentScene = createCityTeamSelectScene( + createCityTeamOptions(contentSystem, playerState), + playerState.cityTeam?.teamCityId ?? null, + ) + + return true + } + + function chooseCityTeam(cityId) { + if (currentScene.type !== 'city-team-select') { + return false + } + + if (playerState.cityTeam?.teamCityId) { + return false + } + + if (!playerState.unlockedCities.includes(cityId)) { + return false + } + + playerState.cityTeam = { + ...playerState.cityTeam, + teamCityId: cityId, + joinedDate: createAcquiredDate(now), + lastSwitchDate: null, + } + + currentScene = history.pop() ?? createHomeSelectScene() + return true + } + function openLevel(cityId, levelId) { if (!playerState.unlockedCities.includes(cityId)) { return false @@ -197,8 +367,7 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date. } history.push(currentScene) - currentScene = { - type: 'gameplay', + currentScene = createGameplayScene({ cityId, levelId, session: createGameSession({ @@ -206,7 +375,7 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date. levelId, contentSystem, }), - } + }) return true } @@ -246,6 +415,33 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date. return true } + function completeGameplayVictory(stars = 3) { + if (currentScene.type !== 'gameplay') { + return null + } + + if (currentScene.mode === 'mashup') { + const reward = awardMashupReward(contentSystem, playerState, currentScene.session.seed, now) + currentScene = { + ...currentScene, + rewardSummary: reward, + } + return reward + } + + completeLevel({ + cityId: currentScene.cityId, + levelId: currentScene.levelId, + stars, + }) + + return { + type: 'city', + cityId: currentScene.cityId, + levelId: currentScene.levelId, + } + } + function goBack() { if (history.length === 0) { currentScene = createHomeSelectScene() @@ -274,8 +470,11 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date. openCity, openGiftZone, selectGiftTab, + openCityTeamSelect, + chooseCityTeam, openLevel, completeLevel, + completeGameplayVictory, restartCurrentLevel, goBack, } diff --git a/tests/content-system.test.js b/tests/content-system.test.js index 38871ac..7e88356 100644 --- a/tests/content-system.test.js +++ b/tests/content-system.test.js @@ -129,18 +129,24 @@ test('content system summarizes MVP gift albums and per-city progress', () => { cityId: 'beijing', acquiredDate: '2026-03-29T00:00:00.000Z', }) + playerState.collectedCats.push('beijing') const albums = contentSystem.getGiftAlbums(playerState) const magnetEntries = contentSystem.getGiftAlbumEntries('magnets', playerState) const stampEntries = contentSystem.getGiftAlbumEntries('stamps', playerState) + const catEntries = contentSystem.getGiftAlbumEntries('cats', 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.deepEqual(albums.map((album) => album.id), ['magnets', 'stamps', 'cats']) + assert.deepEqual(albums.map((album) => album.collectedCount), [1, 1, 1]) + assert.deepEqual(albums.map((album) => album.totalCount), [36, 6, 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) + assert.equal(catEntries[0].cityId, 'beijing') + assert.equal(catEntries[0].catName, '京京') + assert.equal(catEntries[0].isCollected, true) + assert.equal(catEntries[1].isCollected, false) }) diff --git a/tests/difficulty-generator.test.js b/tests/difficulty-generator.test.js index 9451cde..05677af 100644 --- a/tests/difficulty-generator.test.js +++ b/tests/difficulty-generator.test.js @@ -6,6 +6,7 @@ import { classifyDeadlock, evaluateBoard, generateBoard, + generateMashupBoard, } from '../js/gameplay/difficulty/index.js' test('generateBoard is deterministic for the same city, level, and seed', () => { @@ -72,3 +73,23 @@ test('classifyDeadlock identifies a hard deadlock when slot is full and no match assert.equal(result.type, 'hard') }) + +test('generateMashupBoard is deterministic and mixes unlocked city content', () => { + const contentSystem = createContentSystem() + + const first = generateMashupBoard({ + cityIds: ['beijing', 'tokyo'], + seed: 33001, + contentSystem, + }) + const second = generateMashupBoard({ + cityIds: ['beijing', 'tokyo'], + seed: 33001, + contentSystem, + }) + + assert.deepEqual(first.pieces, second.pieces) + assert.deepEqual(first.overlapGraph, second.overlapGraph) + assert.deepEqual(first.sourceCityIds, ['beijing', 'tokyo']) + assert.ok(new Set(first.elementDefinitions.map((entry) => entry.sourceCityId)).size >= 2) +}) diff --git a/tests/scene-store.test.js b/tests/scene-store.test.js index 62342be..555e1ef 100644 --- a/tests/scene-store.test.js +++ b/tests/scene-store.test.js @@ -118,7 +118,125 @@ test('scene store opens the gift zone and switches between MVP tabs', () => { assert.equal(switched, true) assert.equal(sceneStore.getScene().selectedTab, 'stamps') + const switchedToCats = sceneStore.selectGiftTab('cats') + + assert.equal(switchedToCats, true) + assert.equal(sceneStore.getScene().selectedTab, 'cats') + sceneStore.goBack() assert.equal(sceneStore.getScene().type, 'home-select') }) + +test('scene store opens city team selection and locks the first chosen team', () => { + const contentSystem = createContentSystem() + const playerState = createDefaultPlayerState() + const sceneStore = createSceneStore({ + contentSystem, + playerState, + now: () => new Date('2026-03-29T08:00:00.000Z').getTime(), + }) + + const opened = sceneStore.openCityTeamSelect() + + assert.equal(opened, true) + assert.equal(sceneStore.getScene().type, 'city-team-select') + assert.deepEqual(sceneStore.getScene().options.map((entry) => entry.cityId), ['beijing']) + + const selected = sceneStore.chooseCityTeam('beijing') + + assert.equal(selected, true) + assert.deepEqual(playerState.cityTeam, { + teamCityId: 'beijing', + joinedDate: '2026-03-29T08:00:00.000Z', + lastSwitchDate: null, + }) + assert.equal(sceneStore.getScene().type, 'home-select') + + sceneStore.openCityTeamSelect() + const reselected = sceneStore.chooseCityTeam('beijing') + + assert.equal(reselected, false) + assert.equal(playerState.cityTeam.teamCityId, 'beijing') +}) + +test('scene store opens mashup gameplay after two completed cities', () => { + const contentSystem = createContentSystem() + const playerState = createDefaultPlayerState() + const sceneStore = createSceneStore({ + contentSystem, + playerState, + now: () => 33001, + }) + + playerState.unlockedCities.push('tokyo') + 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 opened = sceneStore.openHomeTile('mashup') + + assert.deepEqual(opened, { opened: true }) + assert.equal(sceneStore.getScene().type, 'gameplay') + assert.equal(sceneStore.getScene().mode, 'mashup') + assert.deepEqual(sceneStore.getScene().sourceCityIds, ['beijing', 'tokyo']) +}) + +test('scene store awards a deterministic local mashup reward', () => { + const contentSystem = createContentSystem() + const playerState = createDefaultPlayerState() + const sceneStore = createSceneStore({ + contentSystem, + playerState, + now: () => 33001, + }) + + playerState.unlockedCities.push('tokyo') + 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 }, + } + + sceneStore.openHomeTile('mashup') + const reward = sceneStore.completeGameplayVictory() + + assert.deepEqual(reward, { + type: 'magnet', + magnetId: 'magnet_beijing_2', + cityId: 'beijing', + levelId: 2, + }) + assert.deepEqual(playerState.collectedMagnets, [ + { + magnetId: 'magnet_beijing_2', + cityId: 'beijing', + levelId: 2, + acquiredDate: '1970-01-01T00:00:33.001Z', + }, + ]) +})