diff --git a/TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md b/TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md
index a194febd..94def35f 100644
--- a/TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md
+++ b/TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md
@@ -3,7 +3,8 @@
## 已接入能力
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
-- 按周一日期、性别自动分配最多 7 人的同行组。
+- 按周一日期自动分配最多 7 人的混合同行组,前期不区分男女。
+- 本周已经进入旧男女组的账号,下次读取榜单时保留成绩并自动迁入混合组。
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
- 以真实平台昵称、头像、认糖数和本周最高分排序。
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
@@ -33,7 +34,7 @@
## 上线前检查
-- 用男女各两个测试账号进入,确认被分入对应性别组。
+- 用不同账号进入,确认按进入顺序加入同一个最多 7 人的混合同行组。
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
diff --git a/TUICallKit-Vue3/tongji/endless-game/assets/food/carrot.jpg b/TUICallKit-Vue3/tongji/endless-game/assets/food/carrot.jpg
new file mode 100644
index 00000000..2ccec723
Binary files /dev/null and b/TUICallKit-Vue3/tongji/endless-game/assets/food/carrot.jpg differ
diff --git a/TUICallKit-Vue3/tongji/endless-game/assets/food/fish.jpg b/TUICallKit-Vue3/tongji/endless-game/assets/food/fish.jpg
new file mode 100644
index 00000000..a80acc93
Binary files /dev/null and b/TUICallKit-Vue3/tongji/endless-game/assets/food/fish.jpg differ
diff --git a/TUICallKit-Vue3/tongji/endless-game/assets/food/redBeanCongee.jpg b/TUICallKit-Vue3/tongji/endless-game/assets/food/redBeanCongee.jpg
new file mode 100644
index 00000000..6d605f80
Binary files /dev/null and b/TUICallKit-Vue3/tongji/endless-game/assets/food/redBeanCongee.jpg differ
diff --git a/TUICallKit-Vue3/tongji/endless-game/assets/food/spinach.jpg b/TUICallKit-Vue3/tongji/endless-game/assets/food/spinach.jpg
new file mode 100644
index 00000000..5fa81d5a
Binary files /dev/null and b/TUICallKit-Vue3/tongji/endless-game/assets/food/spinach.jpg differ
diff --git a/TUICallKit-Vue3/tongji/endless-game/assets/food/tofu.jpg b/TUICallKit-Vue3/tongji/endless-game/assets/food/tofu.jpg
new file mode 100644
index 00000000..0b10dbc2
Binary files /dev/null and b/TUICallKit-Vue3/tongji/endless-game/assets/food/tofu.jpg differ
diff --git a/TUICallKit-Vue3/tongji/endless-game/build/stress-game.mjs b/TUICallKit-Vue3/tongji/endless-game/build/stress-game.mjs
new file mode 100644
index 00000000..a09c8319
--- /dev/null
+++ b/TUICallKit-Vue3/tongji/endless-game/build/stress-game.mjs
@@ -0,0 +1,570 @@
+#!/usr/bin/env node
+
+// 识糖小课堂棋盘压力测试:复刻生产版的 5x4 棋盘、掉落、洗牌和渐进换组规则。
+// 运行:node tongji/endless-game/build/stress-game.mjs --runs 10000
+
+const ROWS = 5
+const COLS = 4
+const HIGH_CAP = 3
+const MAX_CASCADE_WAVES = 3
+const DANGER_DROP_CHANCE = 0.1
+const COLUMN_GROUP_BIAS = 0.72
+
+const FOOD_GROUPS = [
+ { title: '中式早餐', safe: ['egg', 'cucumber', 'corn', 'grainMantou'], danger: 'congee' },
+ { title: '家常粥餐', safe: ['celery', 'mushroom', 'chicken', 'taro'], danger: 'centuryCongee' },
+ { title: '粗粮早餐', safe: ['broccoli', 'milk', 'brownRice', 'potato'], danger: 'milletCongee' },
+ { title: '水果饮品', safe: ['apple', 'strawberry', 'banana', 'mango'], danger: 'orangeJuice' },
+ { title: '日常饮品', safe: ['milk', 'tea', 'blackCoffee', 'coffee'], danger: 'soda' },
+ { title: '南方汤粉', safe: ['pepper', 'greenBean', 'shrimp', 'udon'], danger: 'riceNoodleSoup' },
+ { title: '北方面食', safe: ['onion', 'celery', 'corn', 'grainMantou'], danger: 'noodleSoup' },
+ { title: '家常餐桌', safe: ['tofu', 'spinach', 'carrot', 'fish'], danger: 'redBeanCongee' }
+]
+
+const HIGH_KEYS = new Set(FOOD_GROUPS.map(group => group.danger))
+const SAFE_KEYS = new Set(FOOD_GROUPS.flatMap(group => group.safe))
+
+function readNumberArg(name, fallback) {
+ const index = process.argv.indexOf(name)
+ if (index < 0) return fallback
+ const value = Number(process.argv[index + 1])
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
+}
+
+const RUNS = readNumberArg('--runs', 10000)
+const TURNS = readNumberArg('--turns', 80)
+const BASE_SEED = readNumberArg('--seed', 20260722)
+
+function createRng(seed) {
+ let state = seed >>> 0 || 1
+ return () => {
+ state ^= state << 13
+ state ^= state >>> 17
+ state ^= state << 5
+ return (state >>> 0) / 4294967296
+ }
+}
+
+function cloneBoard(source) {
+ return source.map(tile => tile ? { ...tile } : null)
+}
+
+function tile(key) {
+ return { key }
+}
+
+function isAdjacent(a, b) {
+ const ar = Math.floor(a / COLS)
+ const ac = a % COLS
+ const br = Math.floor(b / COLS)
+ const bc = b % COLS
+ return Math.abs(ar - br) + Math.abs(ac - bc) === 1
+}
+
+function swap(source, a, b) {
+ const next = cloneBoard(source)
+ ;[next[a], next[b]] = [next[b], next[a]]
+ return next
+}
+
+function shuffle(source, rng) {
+ const result = cloneBoard(source)
+ for (let i = result.length - 1; i > 0; i -= 1) {
+ const j = Math.floor(rng() * (i + 1))
+ ;[result[i], result[j]] = [result[j], result[i]]
+ }
+ return result
+}
+
+function findMatches(source) {
+ const groups = []
+ for (let row = 0; row < ROWS; row += 1) {
+ let start = 0
+ while (start < COLS) {
+ let end = start + 1
+ while (end < COLS && source[row * COLS + end]?.key === source[row * COLS + start]?.key) end += 1
+ if (source[row * COLS + start] && end - start >= 3) {
+ groups.push({ direction: 'row', indices: Array.from({ length: end - start }, (_, n) => row * COLS + start + n) })
+ }
+ start = end
+ }
+ }
+ for (let col = 0; col < COLS; col += 1) {
+ let start = 0
+ while (start < ROWS) {
+ let end = start + 1
+ while (end < ROWS && source[end * COLS + col]?.key === source[start * COLS + col]?.key) end += 1
+ if (source[start * COLS + col] && end - start >= 3) {
+ groups.push({ direction: 'col', indices: Array.from({ length: end - start }, (_, n) => (start + n) * COLS + col) })
+ }
+ start = end
+ }
+ }
+ return groups
+}
+
+function enumerateMatchingMoves(source) {
+ const moves = []
+ for (let i = 0; i < source.length; i += 1) {
+ const candidates = []
+ if (i % COLS < COLS - 1) candidates.push(i + 1)
+ if (i + COLS < source.length) candidates.push(i + COLS)
+ for (const j of candidates) {
+ const copy = swap(source, i, j)
+ const groups = findMatches(copy)
+ if (!groups.length) continue
+ const indices = new Set(groups.flatMap(group => group.indices))
+ const hitsHigh = [...indices].some(index => HIGH_KEYS.has(copy[index]?.key))
+ moves.push({ from: i, to: j, hitsHigh, groups })
+ }
+ }
+ return moves
+}
+
+function safeMoves(source) {
+ return enumerateMatchingMoves(source).filter(move => !move.hitsHigh)
+}
+
+function wouldCreateMatchAt(source, index, key) {
+ const row = Math.floor(index / COLS)
+ const col = index % COLS
+ const keyAt = target => target === index ? key : source[target]?.key
+ let horizontal = 1
+ for (let c = col - 1; c >= 0 && keyAt(row * COLS + c) === key; c -= 1) horizontal += 1
+ for (let c = col + 1; c < COLS && keyAt(row * COLS + c) === key; c += 1) horizontal += 1
+ if (horizontal >= 3) return true
+ let vertical = 1
+ for (let r = row - 1; r >= 0 && keyAt(r * COLS + col) === key; r -= 1) vertical += 1
+ for (let r = row + 1; r < ROWS && keyAt(r * COLS + col) === key; r += 1) vertical += 1
+ return vertical >= 3
+}
+
+function openingKeys(state) {
+ const [a, b, c, d] = state.activeSafeKeys
+ const danger = FOOD_GROUPS[state.themeIndex].danger
+ return [
+ a, b, c, d,
+ b, c, d, a,
+ c, d, a, danger,
+ d, a, danger, b,
+ a, b, c, d
+ ]
+}
+
+function forcedPlayableKeys(state) {
+ const [a, b, c, d] = state.activeSafeKeys
+ const danger = FOOD_GROUPS[state.themeIndex].danger
+ return [
+ d, a, danger, c,
+ d, c, b, danger,
+ c, a, b, a,
+ d, d, a, a,
+ b, d, c, b
+ ]
+}
+
+function buildPlayableBoard(state, keys = openingKeys(state)) {
+ let candidate = keys.map(tile)
+ for (let attempt = 0; attempt < 240; attempt += 1) {
+ if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
+ candidate = shuffle(candidate, state.rng)
+ }
+ state.metrics.guaranteedFallbacks += 1
+ return forcedPlayableKeys(state).map(tile)
+}
+
+function makeStablePlayableBoard(state, source) {
+ const tiles = source.filter(Boolean).map(item => ({ ...item }))
+ if (tiles.length === ROWS * COLS) {
+ for (let attempt = 0; attempt < 300; attempt += 1) {
+ const candidate = shuffle(tiles, state.rng)
+ if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
+ }
+ }
+ state.metrics.stableFallbacks += 1
+ return buildPlayableBoard(state)
+}
+
+function queueSafeGroupTransition(state, keys) {
+ const target = [...keys]
+ const last = state.queuedSafeGroups[state.queuedSafeGroups.length - 1]
+ if (!last || !last.every((key, index) => key === target[index])) {
+ state.queuedSafeGroups.push(target)
+ }
+ startNextTransition(state)
+}
+
+function startNextTransition(state) {
+ if (state.transition) return false
+ while (state.queuedSafeGroups.length) {
+ const target = state.queuedSafeGroups[0]
+ const slot = target.findIndex((key, index) => state.activeSafeKeys[index] !== key)
+ if (slot < 0) {
+ state.queuedSafeGroups.shift()
+ continue
+ }
+ const from = state.activeSafeKeys[slot]
+ const to = target[slot]
+ state.activeSafeKeys[slot] = to
+ state.transition = { from, to, slot, age: 0 }
+ return true
+ }
+ return false
+}
+
+function finishTransitionIfReady(state) {
+ let changed = false
+ while (state.transition) {
+ const { from, to } = state.transition
+ const remaining = state.board.filter(item => item?.key === from).length
+ if (remaining > 2) break
+ if (remaining) {
+ state.board = state.board.map(item => item?.key === from ? { ...item, key: to } : item)
+ state.metrics.tailConversions += remaining
+ }
+ state.transition = null
+ changed = true
+ startNextTransition(state)
+ }
+ return changed
+}
+
+function prepareTransitionForReshuffle(state) {
+ const activeSet = new Set(state.activeSafeKeys)
+ const counts = Object.fromEntries(state.activeSafeKeys.map(key => [key, 0]))
+ state.board.forEach(item => {
+ if (item && activeSet.has(item.key)) counts[item.key] += 1
+ })
+ let imported = 0
+ const next = state.board.map(item => {
+ if (!item || item.key === 'camel' || HIGH_KEYS.has(item.key)) return item ? { ...item } : item
+ if (state.transition && item.key === state.transition.from) {
+ imported += 1
+ counts[state.transition.to] += 1
+ return { ...item, key: state.transition.to }
+ }
+ if (activeSet.has(item.key)) return { ...item }
+ const key = [...state.activeSafeKeys].sort((a, b) => counts[a] - counts[b])[0]
+ counts[key] += 1
+ imported += 1
+ return { ...item, key }
+ })
+ state.board = next
+ state.metrics.reshuffleImports += imported
+}
+
+function randomFoodKey(state, snapshot, col) {
+ const highs = snapshot.filter(item => item && HIGH_KEYS.has(item.key)).length
+ if (highs < HIGH_CAP && state.rng() < DANGER_DROP_CHANCE) {
+ return FOOD_GROUPS[state.themeIndex].danger
+ }
+ if (state.rng() < COLUMN_GROUP_BIAS) return state.activeSafeKeys[col % state.activeSafeKeys.length]
+ return state.activeSafeKeys[Math.floor(state.rng() * state.activeSafeKeys.length)]
+}
+
+function pickDropKey(state, snapshot, index, col) {
+ for (let attempt = 0; attempt < 16; attempt += 1) {
+ const key = randomFoodKey(state, snapshot, col)
+ if (!wouldCreateMatchAt(snapshot, index, key)) return key
+ }
+ const fallback = [...state.activeSafeKeys, FOOD_GROUPS[state.themeIndex].danger]
+ .find(key => !wouldCreateMatchAt(snapshot, index, key))
+ return fallback || state.activeSafeKeys[(index + col) % state.activeSafeKeys.length]
+}
+
+function collapseAndFill(state) {
+ const next = Array(ROWS * COLS).fill(null)
+ for (let col = 0; col < COLS; col += 1) {
+ const existing = []
+ for (let row = ROWS - 1; row >= 0; row -= 1) {
+ const item = state.board[row * COLS + col]
+ if (item) existing.push(item)
+ }
+ existing.forEach((item, offset) => {
+ next[(ROWS - 1 - offset) * COLS + col] = item
+ })
+ }
+ for (let row = 0; row < ROWS; row += 1) {
+ for (let col = 0; col < COLS; col += 1) {
+ const index = row * COLS + col
+ if (!next[index]) next[index] = tile(pickDropKey(state, next, index, col))
+ }
+ }
+ state.board = next
+}
+
+function reshuffle(state) {
+ prepareTransitionForReshuffle(state)
+ let candidate = null
+ for (let attempt = 0; attempt < 240; attempt += 1) {
+ const shuffled = shuffle(state.board, state.rng)
+ if (findMatches(shuffled).length || !safeMoves(shuffled).length) continue
+ candidate = shuffled
+ break
+ }
+ state.board = candidate || buildPlayableBoard(state, forcedPlayableKeys(state))
+ finishTransitionIfReady(state)
+ state.metrics.reshuffles += 1
+}
+
+function advanceTheme(state) {
+ const previous = FOOD_GROUPS[state.themeIndex]
+ state.themeIndex = (state.themeIndex + 1) % FOOD_GROUPS.length
+ const next = FOOD_GROUPS[state.themeIndex]
+ state.board = state.board.map(item => item?.key === previous.danger ? { ...item, key: next.danger } : item)
+ queueSafeGroupTransition(state, next.safe)
+ state.metrics.themeChanges += 1
+}
+
+function resolveMatches(state, initialGroups) {
+ let groups = initialGroups
+ let wave = 0
+ let camelMatch = 0
+ while (groups.length && wave < MAX_CASCADE_WAVES) {
+ wave += 1
+ const remove = new Set(groups.flatMap(group => group.indices))
+ for (const group of groups) {
+ if (group.indices.every(index => state.board[index]?.key === 'camel')) {
+ camelMatch = Math.max(camelMatch, group.indices.length)
+ }
+ }
+ remove.forEach(index => { state.board[index] = null })
+ collapseAndFill(state)
+ finishTransitionIfReady(state)
+ groups = findMatches(state.board)
+ const autoHigh = groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))
+ if (autoHigh) {
+ state.metrics.automaticHighAvoided += 1
+ state.board = makeStablePlayableBoard(state, state.board)
+ groups = []
+ } else if (wave >= MAX_CASCADE_WAVES && groups.length) {
+ state.metrics.cascadeCaps += 1
+ state.board = makeStablePlayableBoard(state, state.board)
+ groups = []
+ }
+ }
+ state.metrics.maxCascade = Math.max(state.metrics.maxCascade, wave)
+ if (camelMatch >= 3) advanceTheme(state)
+}
+
+function useCamelTool(state) {
+ const danger = FOOD_GROUPS[state.themeIndex].danger
+ const targets = state.board
+ .map((item, index) => item?.key === danger ? index : -1)
+ .filter(index => index >= 0)
+ if (!targets.length) return false
+ const index = targets[Math.floor(state.rng() * targets.length)]
+ state.board[index] = tile('camel')
+ state.metrics.toolUses += 1
+ const groups = findMatches(state.board)
+ if (groups.length) resolveMatches(state, groups)
+ return true
+}
+
+function recordFailure(state, type, detail = '') {
+ state.metrics.failures[type] = (state.metrics.failures[type] || 0) + 1
+ if (state.metrics.samples.length < 20) {
+ state.metrics.samples.push({ run: state.run, turn: state.turn, type, detail, board: state.board.map(item => item?.key || '-') })
+ }
+}
+
+function validateState(state, expectStable = true) {
+ if (state.board.length !== ROWS * COLS || state.board.some(item => !item?.key)) {
+ recordFailure(state, 'invalid_board_size')
+ return false
+ }
+ const currentDanger = FOOD_GROUPS[state.themeIndex].danger
+ const highKeys = state.board.filter(item => HIGH_KEYS.has(item.key)).map(item => item.key)
+ if (highKeys.some(key => key !== currentDanger)) recordFailure(state, 'wrong_theme_danger', highKeys.join(','))
+ if (highKeys.length > HIGH_CAP) recordFailure(state, 'danger_cap_exceeded', String(highKeys.length))
+
+ const allowed = new Set([...state.activeSafeKeys, 'camel'])
+ if (state.transition) allowed.add(state.transition.from)
+ const stale = state.board.filter(item => !HIGH_KEYS.has(item.key) && !allowed.has(item.key)).map(item => item.key)
+ if (stale.length) recordFailure(state, 'stale_food', stale.join(','))
+
+ const normalTypes = new Set(state.board.filter(item => SAFE_KEYS.has(item.key)).map(item => item.key))
+ if (normalTypes.size > 5) recordFailure(state, 'too_many_normal_types', String(normalTypes.size))
+ if (new Set(state.activeSafeKeys).size !== 4) recordFailure(state, 'duplicate_active_food')
+ if (state.transition) {
+ const remaining = state.board.filter(item => item.key === state.transition.from).length
+ if (remaining <= 2) recordFailure(state, 'orphan_transition_tail', `${state.transition.from}:${remaining}`)
+ }
+ if (expectStable && findMatches(state.board).length) recordFailure(state, 'unresolved_match')
+ return true
+}
+
+function createMetrics() {
+ return {
+ runs: RUNS,
+ turnsPerRun: TURNS,
+ totalTurns: 0,
+ validMoves: 0,
+ reshuffles: 0,
+ reshuffleImports: 0,
+ guaranteedFallbacks: 0,
+ stableFallbacks: 0,
+ themeChanges: 0,
+ toolUses: 0,
+ tailConversions: 0,
+ automaticHighAvoided: 0,
+ cascadeCaps: 0,
+ maxCascade: 0,
+ unsafeHighMoveProbes: 0,
+ maxTransitionAge: 0,
+ maxQueuedThemes: 0,
+ transitionDrainTurns: 0,
+ unfinishedTransitions: 0,
+ failures: {},
+ samples: []
+ }
+}
+
+function runTargetedTailTests(metrics) {
+ for (let groupIndex = 0; groupIndex < FOOD_GROUPS.length; groupIndex += 1) {
+ const nextIndex = (groupIndex + 1) % FOOD_GROUPS.length
+ for (const remaining of [0, 1, 2]) {
+ const state = {
+ run: -1,
+ turn: remaining,
+ rng: createRng(BASE_SEED + groupIndex * 17 + remaining),
+ metrics,
+ themeIndex: nextIndex,
+ activeSafeKeys: [...FOOD_GROUPS[nextIndex].safe],
+ transition: {
+ from: FOOD_GROUPS[groupIndex].safe[0],
+ to: FOOD_GROUPS[nextIndex].safe[0],
+ slot: 0,
+ age: 0
+ },
+ queuedSafeGroups: [[...FOOD_GROUPS[nextIndex].safe]],
+ board: []
+ }
+ const fill = FOOD_GROUPS[nextIndex].safe
+ state.board = Array.from({ length: ROWS * COLS }, (_, index) => tile(fill[index % fill.length]))
+ for (let index = 0; index < remaining; index += 1) state.board[index] = tile(state.transition.from)
+ finishTransitionIfReady(state)
+ if (state.board.some(item => item.key === FOOD_GROUPS[groupIndex].safe[0])) {
+ recordFailure(state, 'targeted_tail_not_converted', `${groupIndex}:${remaining}`)
+ }
+ }
+ }
+}
+
+const metrics = createMetrics()
+runTargetedTailTests(metrics)
+
+for (let run = 0; run < RUNS; run += 1) {
+ const state = {
+ run,
+ turn: 0,
+ rng: createRng(BASE_SEED + run * 2654435761),
+ metrics,
+ themeIndex: run % FOOD_GROUPS.length,
+ activeSafeKeys: [...FOOD_GROUPS[run % FOOD_GROUPS.length].safe],
+ transition: null,
+ queuedSafeGroups: [],
+ board: []
+ }
+ state.board = buildPlayableBoard(state)
+ validateState(state)
+
+ let toolsUsed = 0
+ for (let turn = 0; turn < TURNS; turn += 1) {
+ state.turn = turn
+ metrics.totalTurns += 1
+ if (state.transition) {
+ state.transition.age += 1
+ metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
+ }
+ metrics.maxQueuedThemes = Math.max(metrics.maxQueuedThemes, state.queuedSafeGroups.length)
+
+ // 模拟玩家积攒到驼乳后替换高糖,但每局限制5次,避免测试人为填满棋盘。
+ if (toolsUsed < 5 && turn > 0 && turn % 13 === 0 && useCamelTool(state)) {
+ toolsUsed += 1
+ finishTransitionIfReady(state)
+ if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
+ }
+
+ let moves = safeMoves(state.board)
+ if (!moves.length) {
+ reshuffle(state)
+ moves = safeMoves(state.board)
+ }
+ if (!moves.length) {
+ recordFailure(state, 'dead_board_after_reshuffle')
+ break
+ }
+
+ // 额外探测“主动三消高糖会结束”的判定,不改变主模拟棋盘。
+ const unsafe = enumerateMatchingMoves(state.board).find(move => move.hitsHigh)
+ if (unsafe) {
+ const probe = swap(state.board, unsafe.from, unsafe.to)
+ const direct = findMatches(probe)
+ if (!direct.some(group => group.indices.some(index => HIGH_KEYS.has(probe[index]?.key)))) {
+ recordFailure(state, 'unsafe_high_probe_missed')
+ }
+ metrics.unsafeHighMoveProbes += 1
+ }
+
+ const move = moves[Math.floor(state.rng() * moves.length)]
+ state.board = swap(state.board, move.from, move.to)
+ const groups = findMatches(state.board)
+ if (!groups.length) {
+ recordFailure(state, 'safe_move_without_match')
+ break
+ }
+ if (groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))) {
+ recordFailure(state, 'safe_move_hit_high')
+ break
+ }
+ resolveMatches(state, groups)
+ metrics.validMoves += 1
+
+ // 用较快节奏覆盖连续换组和排队逻辑,强度高于正常玩家。
+ if ((turn + 1) % 16 === 0 && turn + 1 < TURNS) advanceTheme(state)
+ finishTransitionIfReady(state)
+ if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
+ if (!safeMoves(state.board).length) reshuffle(state)
+ validateState(state)
+ }
+
+ // 停止新增主题后继续正常消除,验证排队中的旧食物能否全部退出,避免队列永久积压。
+ let drainTurn = 0
+ while ((state.transition || state.queuedSafeGroups.length) && drainTurn < 80) {
+ state.turn = TURNS + drainTurn
+ if (state.transition) {
+ state.transition.age += 1
+ metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
+ }
+ let moves = safeMoves(state.board)
+ if (!moves.length) {
+ reshuffle(state)
+ moves = safeMoves(state.board)
+ }
+ if (!moves.length) {
+ recordFailure(state, 'dead_board_while_draining')
+ break
+ }
+ const move = moves[Math.floor(state.rng() * moves.length)]
+ state.board = swap(state.board, move.from, move.to)
+ resolveMatches(state, findMatches(state.board))
+ metrics.validMoves += 1
+ finishTransitionIfReady(state)
+ if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
+ if (!safeMoves(state.board).length) reshuffle(state)
+ validateState(state)
+ drainTurn += 1
+ }
+ metrics.transitionDrainTurns += drainTurn
+ if (state.transition || state.queuedSafeGroups.length) metrics.unfinishedTransitions += 1
+}
+
+const failureCount = Object.values(metrics.failures).reduce((sum, count) => sum + count, 0)
+const result = {
+ ok: failureCount === 0,
+ ...metrics,
+ failureCount
+}
+process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
+if (!result.ok) process.exitCode = 1
diff --git a/TUICallKit-Vue3/tongji/endless-game/build/tongjiFoodAssetsPlugin.ts b/TUICallKit-Vue3/tongji/endless-game/build/tongjiFoodAssetsPlugin.ts
new file mode 100644
index 00000000..92286517
--- /dev/null
+++ b/TUICallKit-Vue3/tongji/endless-game/build/tongjiFoodAssetsPlugin.ts
@@ -0,0 +1,31 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import type { Plugin } from 'vite'
+
+const FOOD_ICON_FILES = [
+ 'tofu.jpg',
+ 'spinach.jpg',
+ 'carrot.jpg',
+ 'fish.jpg',
+ 'redBeanCongee.jpg'
+]
+
+/**
+ * Keep endless-game food icons inside the tongji subpackage.
+ * Importing them from Vue would make Vite emit them into the main-package assets folder.
+ */
+export function tongjiFoodAssetsPlugin(): Plugin {
+ return {
+ name: 'tongji-food-assets',
+ apply: 'build',
+ generateBundle() {
+ FOOD_ICON_FILES.forEach((fileName) => {
+ this.emitFile({
+ type: 'asset',
+ fileName: `tongji/endless-game/assets/food/${fileName}`,
+ source: readFileSync(resolve(process.cwd(), 'tongji/endless-game/assets/food', fileName))
+ })
+ })
+ }
+ }
+}
diff --git a/TUICallKit-Vue3/tongji/endless-game/game-endless.scss b/TUICallKit-Vue3/tongji/endless-game/game-endless.scss
index 0ca4b509..a87d6dcd 100644
--- a/TUICallKit-Vue3/tongji/endless-game/game-endless.scss
+++ b/TUICallKit-Vue3/tongji/endless-game/game-endless.scss
@@ -46,7 +46,44 @@
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
-.eg-title-wrap { display: flex; min-width: 0; flex-direction: column; align-items: center; }
+.eg-title-wrap { position: relative; display: flex; min-width: 0; flex-direction: column; align-items: center; }
+.eg-title-wrap > .eg-title,
+.eg-title-wrap > .eg-subtitle { transition: opacity .08s ease; }
+.eg-title-wrap.is-noticing > .eg-title,
+.eg-title-wrap.is-noticing > .eg-subtitle { opacity: .16; }
+.eg-nav-notice {
+ position: absolute;
+ z-index: 1;
+ top: 50%;
+ left: 50%;
+ display: flex;
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ flex-direction: column;
+ align-items: center;
+ padding: 5rpx 8rpx;
+ border: 1rpx solid rgba(21,94,75,.09);
+ border-radius: 16rpx;
+ color: rgba(38,86,72,.82);
+ background: rgba(255,255,255,.58);
+ box-shadow: 0 4rpx 12rpx rgba(13,64,51,.06);
+ text-align: center;
+ transform: translate(-50%, -50%);
+ animation: egNavNotice 1.05s ease-out both;
+ pointer-events: none;
+}
+.eg-nav-notice-title,
+.eg-nav-notice-detail {
+ display: block;
+ overflow: hidden;
+ max-width: 100%;
+ line-height: 1.15;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.eg-nav-notice-title { font-size: 23rpx; font-weight: 800; }
+.eg-nav-notice-detail { margin-top: 2rpx; color: rgba(154,106,42,.78); font-size: 20rpx; font-weight: 700; }
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
@@ -176,6 +213,7 @@
.eg-cell.is-milk { background: #edf7ff; }
.eg-cell.is-high { border-color: #ef7b45; }
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
+.eg-food-image.is-large-square { width: 136rpx; height: 102rpx; margin-top: 2rpx; filter: saturate(1.1) contrast(1.06); }
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
@@ -438,6 +476,13 @@
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
}
+@keyframes egNavNotice {
+ 0% { opacity: 0; transform: translate(-50%, -42%); }
+ 12% { opacity: .9; transform: translate(-50%, -50%); }
+ 42% { opacity: .82; transform: translate(-50%, -50%); }
+ 100% { opacity: 0; transform: translate(-50%, -58%); }
+}
+
@keyframes egRewardGlow {
from { opacity: .4; transform: scale(.86); }
to { opacity: .9; transform: scale(1.12); }
diff --git a/TUICallKit-Vue3/tongji/endless-game/index.vue b/TUICallKit-Vue3/tongji/endless-game/index.vue
index 2781099b..e15ddade 100644
--- a/TUICallKit-Vue3/tongji/endless-game/index.vue
+++ b/TUICallKit-Vue3/tongji/endless-game/index.vue
@@ -19,9 +19,20 @@
-
+
识糖小课堂
第{{ themeIndex + 1 }}站 · {{ currentTheme.title }}
+
+ {{ centerNoticeTitle }}
+ {{ centerNoticeDetail }}
+
@@ -126,7 +137,12 @@
-
+
{{ foodOf(tile.key).short }}
{{ foodOf(tile.key).levelLabel || '低糖' }}
@@ -271,13 +287,13 @@
{{ reshuffleDone ? '洗牌完成' : '没有可消除组合' }}
- {{ reshuffleDone ? '新的消除机会已经出现' : '正在为您重新洗牌,请稍候' }}
+ {{ reshuffleDone ? (reshuffleImportedFoods ? '洗牌完成,新食物已经加入' : '新的消除机会已经出现') : '正在为您重新洗牌,请稍候' }}
- 新菜单已解锁
+ 新食物排队加入
第 {{ stageMeta.number }} 站
{{ stageMeta.title }}
@@ -286,7 +302,7 @@
{{ food.short }}
- 本组橙框食物可用驼乳粉替换
+ 橙框食物已更新,普通食物会随掉落逐步加入
@@ -320,6 +336,14 @@ import { useGamePlatform } from './composables/useGamePlatform.js'
import { useGameAuth } from './composables/useGameAuth.js'
const FOOD_IMAGE_BASE_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/'
+const LOCAL_FOOD_IMAGE_BASE_URL = '/tongji/endless-game/assets/food/'
+const LOCAL_FOOD_IMAGES = {
+ tofu: `${LOCAL_FOOD_IMAGE_BASE_URL}tofu.jpg`,
+ spinach: `${LOCAL_FOOD_IMAGE_BASE_URL}spinach.jpg`,
+ carrot: `${LOCAL_FOOD_IMAGE_BASE_URL}carrot.jpg`,
+ fish: `${LOCAL_FOOD_IMAGE_BASE_URL}fish.jpg`,
+ redBeanCongee: `${LOCAL_FOOD_IMAGE_BASE_URL}redBeanCongee.jpg`
+}
const ROWS = 5
const COLS = 4
@@ -395,9 +419,20 @@ const FOODS = {
riceNoodleSoup: { name: '汤粉', short: '汤粉', emoji: '🍜', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '汤粉以精制米粉为主,实际影响与分量和配菜有关,应注意搭配。' },
noodleSoup: { name: '汤面', short: '汤面', emoji: '🍜', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '汤面以精细面食为主,汤汁和分量需要注意,具体建议以医生审核为准。' },
milkTea: { name: '含糖奶茶', short: '奶茶', emoji: '🧋', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '含糖奶茶可能同时含有较多添加糖和能量,应关注配方与饮用量。' },
+ tofu: { name: '豆腐', short: '豆腐', emoji: '◻️', color: 'cream', category: '豆制品', level: 'low', levelLabel: '低糖', largeIcon: true },
+ spinach: { name: '菠菜', short: '菠菜', emoji: '🥬', color: 'yellow', category: '蔬菜', level: 'low', levelLabel: '低糖', largeIcon: true },
+ carrot: { name: '胡萝卜', short: '胡萝卜', emoji: '🥕', color: 'red', category: '蔬菜', level: 'low', levelLabel: '低糖', largeIcon: true },
+ fish: { name: '鱼', short: '鱼', emoji: '🐟', color: 'purple', category: '鱼肉蛋类', level: 'low', levelLabel: '低糖', largeIcon: true },
+ redBeanCongee: {
+ name: '软烂红豆粥', short: '红豆粥', emoji: '🥣', color: 'orange', category: '粥类',
+ level: 'high', levelLabel: '高糖', high: true, replaceable: true, largeIcon: true,
+ tip: '本局图标表示长时间熬煮、豆粒开花的红豆粥。食物对血糖的影响与熬煮程度、食用量和搭配有关,糖尿病患者应结合个人情况合理选择。'
+ },
camel: { name: '驼乳', short: '驼乳', emoji: '🥛', color: 'milk', level: 'prop', levelLabel: '道具', product: true }
}
-Object.keys(FOODS).forEach((key) => { FOODS[key].img = `${FOOD_IMAGE_BASE_URL}${key}.jpg` })
+Object.keys(FOODS).forEach((key) => {
+ FOODS[key].img = LOCAL_FOOD_IMAGES[key] || `${FOOD_IMAGE_BASE_URL}${key}.jpg`
+})
const FOOD_GROUPS = [
{ title: '中式早餐', safe: ['egg', 'cucumber', 'corn', 'grainMantou'], danger: 'congee' },
{ title: '家常粥餐', safe: ['celery', 'mushroom', 'chicken', 'taro'], danger: 'centuryCongee' },
@@ -406,6 +441,7 @@ const FOOD_GROUPS = [
{ title: '日常饮品', safe: ['milk', 'tea', 'blackCoffee', 'coffee'], danger: 'soda' },
{ title: '南方汤粉', safe: ['pepper', 'greenBean', 'shrimp', 'udon'], danger: 'riceNoodleSoup' },
{ title: '北方面食', safe: ['onion', 'celery', 'corn', 'grainMantou'], danger: 'noodleSoup' },
+ { title: '家常餐桌', safe: ['tofu', 'spinach', 'carrot', 'fish'], danger: 'redBeanCongee' },
]
const {
@@ -451,12 +487,19 @@ const camelMode = ref(false)
const busy = ref(false)
const gameOver = ref(false)
const themeIndex = ref(0)
+const activeSafeKeys = ref([...FOOD_GROUPS[0].safe])
+const foodTransition = ref(null)
+const queuedSafeGroups = ref([])
const endedFood = ref(FOODS.congee)
const taskIndex = ref(0)
const taskProgress = ref(0)
const comboDryTurns = ref(0)
const taskRewardVisible = ref(false)
const taskRewardKey = ref(0)
+const centerNoticeVisible = ref(false)
+const centerNoticeKey = ref(0)
+const centerNoticeTitle = ref('')
+const centerNoticeDetail = ref('')
const statusText = ref('交换相邻食物,三个相同即可消除')
const navStyle = ref({ paddingTop: '20px' })
const touch = { x: 0, y: 0, index: -1 }
@@ -474,6 +517,7 @@ const stageMeta = ref({ number: 1, title: '', foods: [] })
const reshuffleVisible = ref(false)
const reshuffleDone = ref(false)
const reshuffling = ref(false)
+const reshuffleImportedFoods = ref(false)
const confettiVisible = ref(false)
const weeklyRankVisible = ref(false)
const restoredGame = ref(false)
@@ -498,6 +542,7 @@ let jackpotTimer = null
let confettiTimer = null
let stageTimer = null
let taskRewardTimer = null
+let centerNoticeTimer = null
let flowTimer = null
let flowPauseTimer = null
let flowPausedRemainingMs = 0
@@ -511,7 +556,7 @@ let incomingInviteCode = ''
const currentTheme = computed(() => FOOD_GROUPS[themeIndex.value % FOOD_GROUPS.length])
const currentTask = computed(() => {
- const safe = currentTheme.value.safe
+ const safe = activeSafeKeys.value.length === 4 ? activeSafeKeys.value : currentTheme.value.safe
const templates = [
{ key: safe[0], label: `消除 7 个${foodOf(safe[0]).short}`, target: 7, kind: 'food' },
{ key: 'special', label: '制造 1 个惊喜棋子', target: 1, kind: 'special' },
@@ -586,7 +631,8 @@ const weeklyRangeLabel = computed(() => {
if (!start || !end) return getWeekRangeLabel(new Date())
return `${formatWeekDate(start)}—${formatWeekDate(end)}`
})
-const weeklySexLabel = computed(() => platformLeaderboard.value.sex_label || '同行')
+// 前期人数较少,七人榜统一混排,不向玩家展示男女分组。
+const weeklySexLabel = computed(() => '同行')
const weeklyMemberCount = computed(() => {
const reported = Number(platformLeaderboard.value.member_count || 0)
const remoteCount = Array.isArray(platformLeaderboard.value.players)
@@ -686,6 +732,7 @@ onUnload(() => {
if (confettiTimer) clearTimeout(confettiTimer)
if (stageTimer) clearTimeout(stageTimer)
if (taskRewardTimer) clearTimeout(taskRewardTimer)
+ if (centerNoticeTimer) clearTimeout(centerNoticeTimer)
clearFlowTimers()
})
@@ -697,6 +744,101 @@ function foodOf(key) {
return FOODS[key] || FOODS.corn
}
+function isValidSafeGroup(keys) {
+ return Array.isArray(keys)
+ && keys.length === 4
+ && new Set(keys).size === 4
+ && keys.every(key => FOODS[key] && !foodOf(key).high && !foodOf(key).product)
+}
+
+function resetFoodTransitions(keys = currentTheme.value.safe) {
+ activeSafeKeys.value = isValidSafeGroup(keys) ? [...keys] : [...currentTheme.value.safe]
+ foodTransition.value = null
+ queuedSafeGroups.value = []
+}
+
+function queueSafeGroupTransition(keys) {
+ if (!isValidSafeGroup(keys)) return false
+ const target = [...keys]
+ const lastQueued = queuedSafeGroups.value[queuedSafeGroups.value.length - 1]
+ if (lastQueued?.every((key, index) => key === target[index])) return false
+ queuedSafeGroups.value = [...queuedSafeGroups.value, target]
+ startNextFoodTransition()
+ return true
+}
+
+function startNextFoodTransition() {
+ if (foodTransition.value) return false
+ while (queuedSafeGroups.value.length) {
+ const target = queuedSafeGroups.value[0]
+ const slot = target.findIndex((key, index) => activeSafeKeys.value[index] !== key)
+ if (slot < 0) {
+ queuedSafeGroups.value = queuedSafeGroups.value.slice(1)
+ continue
+ }
+ const beforeTask = currentTask.value
+ const from = activeSafeKeys.value[slot]
+ const to = target[slot]
+ const nextActive = [...activeSafeKeys.value]
+ nextActive[slot] = to
+ activeSafeKeys.value = nextActive
+ foodTransition.value = { from, to, slot }
+ if (beforeTask.kind === 'food' && beforeTask.key === from) taskProgress.value = 0
+ return true
+ }
+ return false
+}
+
+function finishFoodTransitionIfReady(silent = false) {
+ let changed = false
+ let lastAdded = ''
+ while (foodTransition.value) {
+ const transition = foodTransition.value
+ const remaining = board.value.filter(tile => tile?.key === transition.from).length
+ // 旧食物停止掉落后,最后 1~2 个已经无法再自然三消。
+ // 直接把这点“尾巴”换成新食物,避免下一组里长期孤零零留着一杯茶等旧图标。
+ if (remaining > 2) break
+ if (remaining > 0) {
+ board.value = board.value.map(tile => (
+ tile?.key === transition.from ? { ...tile, key: transition.to } : tile
+ ))
+ }
+ lastAdded = transition.to
+ foodTransition.value = null
+ changed = true
+ startNextFoodTransition()
+ }
+ if (changed && !silent) statusText.value = `${foodOf(lastAdded).short}已加入,下一种食物正在排队`
+ return changed
+}
+
+function prepareFoodTransitionForReshuffle(source) {
+ const active = activeSafeKeys.value
+ const activeSet = new Set(active)
+ const counts = Object.fromEntries(active.map(key => [key, 0]))
+ source.forEach(tile => {
+ if (tile && activeSet.has(tile.key)) counts[tile.key] += 1
+ })
+ let imported = 0
+ const transition = foodTransition.value
+ const next = source.map(tile => {
+ if (!tile) return tile
+ const food = foodOf(tile.key)
+ if (food.high || food.product) return { ...tile }
+ if (transition && tile.key === transition.from) {
+ imported += 1
+ counts[transition.to] += 1
+ return { ...tile, key: transition.to }
+ }
+ if (activeSet.has(tile.key)) return { ...tile }
+ const key = [...active].sort((a, b) => counts[a] - counts[b])[0]
+ counts[key] += 1
+ imported += 1
+ return { ...tile, key }
+ })
+ return { board: next, imported }
+}
+
function getWeekRangeLabel(now) {
const date = new Date(now)
const day = date.getDay() || 7
@@ -929,6 +1071,9 @@ function saveActiveGame() {
confirmed_session_learned: Math.max(0, Number(confirmedSessionLearned.value) || 0),
saved_at: Date.now(),
theme_index: themeIndex.value,
+ active_safe_keys: [...activeSafeKeys.value],
+ food_transition: foodTransition.value ? { ...foodTransition.value } : null,
+ queued_safe_groups: queuedSafeGroups.value.map(keys => [...keys]),
board: board.value.map(tile => ({ id: tile.id, key: tile.key, special: tile.special || '' })),
score: score.value,
learned: learned.value,
@@ -966,6 +1111,28 @@ function restoreActiveGame() {
}
themeIndex.value = Math.max(0, Math.min(FOOD_GROUPS.length - 1, Number(saved.theme_index) || 0))
+ activeSafeKeys.value = isValidSafeGroup(saved.active_safe_keys)
+ ? [...saved.active_safe_keys]
+ : [...currentTheme.value.safe]
+ const savedTransition = saved.food_transition
+ foodTransition.value = savedTransition
+ && FOODS[savedTransition.from]
+ && FOODS[savedTransition.to]
+ && !foodOf(savedTransition.from).high
+ && !foodOf(savedTransition.from).product
+ && !foodOf(savedTransition.to).high
+ && !foodOf(savedTransition.to).product
+ && Number.isInteger(Number(savedTransition.slot))
+ && activeSafeKeys.value[Math.max(0, Math.min(3, Number(savedTransition.slot)))] === savedTransition.to
+ ? {
+ from: savedTransition.from,
+ to: savedTransition.to,
+ slot: Math.max(0, Math.min(3, Number(savedTransition.slot)))
+ }
+ : null
+ queuedSafeGroups.value = Array.isArray(saved.queued_safe_groups)
+ ? saved.queued_safe_groups.filter(isValidSafeGroup).map(keys => [...keys])
+ : []
board.value = saved.board.map(tile => ({
id: Math.max(1, Number(tile.id) || tileId++),
key: tile.key,
@@ -988,6 +1155,7 @@ function restoreActiveGame() {
busy.value = false
gameOver.value = false
taskRewardVisible.value = false
+ centerNoticeVisible.value = false
clearingIndices.value = []
fireClearingIndices.value = []
impactText.value = ''
@@ -996,12 +1164,14 @@ function restoreActiveGame() {
reshuffleVisible.value = false
reshuffleDone.value = false
reshuffling.value = false
+ reshuffleImportedFoods.value = false
confettiVisible.value = false
pendingThemeMatchCount = 0
taskRewardStatusUntil = 0
endedFood.value = foodOf(currentTheme.value.danger)
restoredGame.value = true
resetDrag()
+ finishFoodTransitionIfReady(true)
statusText.value = '已恢复上次未完成的游戏和得分'
scheduleHint()
return true
@@ -1012,6 +1182,7 @@ function startGame() {
clearActiveGame()
beginPlatformSession()
themeIndex.value = 0
+ resetFoodTransitions(currentTheme.value.safe)
board.value = buildPlayableBoard(openingKeysForTheme(currentTheme.value))
selected.value = -1
score.value = 0
@@ -1027,6 +1198,7 @@ function startGame() {
taskProgress.value = 0
comboDryTurns.value = 0
taskRewardVisible.value = false
+ centerNoticeVisible.value = false
clearingIndices.value = []
fireClearingIndices.value = []
impactText.value = ''
@@ -1035,6 +1207,7 @@ function startGame() {
reshuffleVisible.value = false
reshuffleDone.value = false
reshuffling.value = false
+ reshuffleImportedFoods.value = false
confettiVisible.value = false
pendingThemeMatchCount = 0
taskRewardStatusUntil = 0
@@ -1060,7 +1233,7 @@ function goBack() {
function showRules() {
uni.showModal({
title: '玩法说明',
- content: `当前难度:${GAME_DIFFICULTY}/10\n\n交换相邻食物,三个相同即可消除。四连、直线五连会生成火焰惊喜棋子;横向三个与竖向三个相交的 L/T 形五消,会生成范围爆破棋子,再次消除可清除周围九格。连消×2会计入连消小目标,连续普通消除后会出现一次连消机会;连消×3可获得额外惊喜奖励。完成小目标直接获得 1 罐驼乳粉;驼乳粉可替换本组指定稀食或饮品,三个驼乳连消后进入下一组食品。主动三消“高糖”食物代表吃掉,会结束本局;火焰特效波及高糖食物代表将它移出餐盘,不算吃掉。没有可走步时会自动洗牌。`,
+ content: `当前难度:${GAME_DIFFICULTY}/10\n\n交换相邻食物,三个相同即可消除。四连、直线五连会生成火焰惊喜棋子;横向三个与竖向三个相交的 L/T 形五消,会生成范围爆破棋子,再次消除可清除周围九格。连消×2会计入连消小目标,连续普通消除后会出现一次连消机会;连消×3可获得额外惊喜奖励。完成小目标直接获得 1 罐驼乳粉;驼乳粉可替换本组指定稀食或饮品,三个驼乳连消后,高糖食物立即更新,普通食物会随掉落逐步换入。主动三消“高糖”食物代表吃掉,会结束本局;火焰特效波及高糖食物代表将它移出餐盘,不算吃掉。没有可走步时会自动洗牌,并加快当前新食物换入。`,
showCancel: false,
confirmText: '知道了'
})
@@ -1371,6 +1544,7 @@ async function resolveMatches(initialGroups, movedIndex, options = {}) {
&& comboTaskAtStart
&& comboDryTurns.value >= COMBO_PITY_TURNS - 1
collapseAndFill(shouldGuaranteeCombo)
+ finishFoodTransitionIfReady()
playDropSfx(remove.size)
await wait(190)
groups = findMatches(board.value)
@@ -1430,6 +1604,18 @@ function grantCamelReward(requested) {
return reward
}
+function showCamelOverflowNotice(overflowScore) {
+ if (centerNoticeTimer) clearTimeout(centerNoticeTimer)
+ centerNoticeKey.value += 1
+ centerNoticeTitle.value = '驼乳粉已满'
+ centerNoticeDetail.value = `多余奖励换 ${overflowScore} 分`
+ centerNoticeVisible.value = true
+ centerNoticeTimer = setTimeout(() => {
+ centerNoticeVisible.value = false
+ centerNoticeTimer = null
+ }, 1050)
+}
+
function rewardMegaCombo(wave) {
score.value += MEGA_COMBO_SCORE
const camelReward = grantCamelReward(1)
@@ -1437,6 +1623,7 @@ function rewardMegaCombo(wave) {
statusText.value = `${wave} 连消惊喜:+${MEGA_COMBO_SCORE} 分${rewardText ? ` · ${rewardText}` : ''}`
taskRewardStatusUntil = Date.now() + 1800
if (camelReward.granted > 0) showTaskCamelReward()
+ if (camelReward.overflow > 0) showCamelOverflowNotice(camelReward.overflowScore)
startConfetti()
playSfxLayer('comboMega', 'fanfare', 100, .82)
vibrate('medium')
@@ -1449,7 +1636,6 @@ function rewardMegaCombo(wave) {
function camelRewardText(reward) {
const parts = []
if (reward.granted > 0) parts.push(`驼乳粉 ×${reward.granted}`)
- if (reward.overflow > 0) parts.push(`满仓多余奖励换 ${reward.overflowScore} 分`)
return parts.join(' · ')
}
@@ -1488,17 +1674,10 @@ function advanceFoodTheme() {
const previous = currentTheme.value
themeIndex.value = (themeIndex.value + 1) % FOOD_GROUPS.length
const nextTheme = currentTheme.value
- const mapping = {}
- previous.safe.forEach((key, index) => { mapping[key] = nextTheme.safe[index] })
- mapping[previous.danger] = nextTheme.danger
- const mappedBoard = board.value.map(tile => {
- if (!tile || !mapping[tile.key]) return tile
- return { ...tile, key: mapping[tile.key] }
- })
- board.value = mappedBoard
- if (findMatches(mappedBoard).length || !hasSafeMoveIn(mappedBoard)) {
- board.value = buildPlayableBoard(openingKeysForTheme(nextTheme))
- }
+ board.value = board.value.map(tile => (
+ tile?.key === previous.danger ? { ...tile, key: nextTheme.danger } : tile
+ ))
+ queueSafeGroupTransition(nextTheme.safe)
taskIndex.value = 0
taskProgress.value = 0
comboDryTurns.value = 0
@@ -1511,7 +1690,7 @@ function advanceFoodTheme() {
foods: [...nextTheme.safe, nextTheme.danger].map(key => ({ key, ...foodOf(key) }))
}
stageVisible.value = true
- statusText.value = `已进入${nextTheme.title},橙框${foodOf(nextTheme.danger).short}可替换`
+ statusText.value = `已进入${nextTheme.title},新食物会随掉落逐步加入`
playSfxLayer('fanfare', 'sparkle', 100, .65)
if (stageTimer) clearTimeout(stageTimer)
stageTimer = setTimeout(() => { stageVisible.value = false }, 1800)
@@ -1652,14 +1831,14 @@ function findForcedDropMatch(snapshot) {
}
function pickForcedDropKey(snapshot, indices) {
- return currentTheme.value.safe
+ return activeSafeKeys.value
.map(key => ({
key,
nearby: snapshot.reduce((count, tile, index) => (
tile?.key === key && indices.some(target => isAdjacent(target, index)) ? count + 1 : count
), 0)
}))
- .sort((a, b) => a.nearby - b.nearby)[0]?.key || currentTheme.value.safe[0]
+ .sort((a, b) => a.nearby - b.nearby)[0]?.key || activeSafeKeys.value[0]
}
function pickDropFoodKey(snapshot, index, col) {
@@ -1667,9 +1846,9 @@ function pickDropFoodKey(snapshot, index, col) {
const key = randomFoodKey(snapshot, col)
if (!wouldCreateMatchAt(snapshot, index, key)) return key
}
- const fallback = [...currentTheme.value.safe, currentTheme.value.danger]
+ const fallback = [...activeSafeKeys.value, currentTheme.value.danger]
.find(key => !wouldCreateMatchAt(snapshot, index, key))
- return fallback || currentTheme.value.safe[(index + col) % currentTheme.value.safe.length]
+ return fallback || activeSafeKeys.value[(index + col) % activeSafeKeys.value.length]
}
function wouldCreateMatchAt(source, index, key) {
@@ -1689,7 +1868,7 @@ function wouldCreateMatchAt(source, index, key) {
function randomFoodKey(snapshot, col = -1) {
const highs = snapshot.filter(tile => tile && foodOf(tile.key).high).length
if (highs < HIGH_CAP && Math.random() < DANGER_DROP_CHANCE) return currentTheme.value.danger
- const keys = currentTheme.value.safe
+ const keys = activeSafeKeys.value
if (col >= 0 && Math.random() < COLUMN_GROUP_BIAS) return keys[col % keys.length]
return keys[Math.floor(Math.random() * keys.length)]
}
@@ -1747,13 +1926,11 @@ function advanceTask(amount) {
if (camelReward.granted > 0) {
showTaskCamelReward()
statusText.value = '小目标完成,驼乳粉已放入道具栏'
+ uni.showToast({ title: '小目标完成:驼乳粉 +1', icon: 'none' })
} else {
- statusText.value = `驼乳粉已满,多余奖励换 ${camelReward.overflowScore} 分`
+ statusText.value = '小目标完成,继续挑战'
+ showCamelOverflowNotice(camelReward.overflowScore)
}
- uni.showToast({
- title: camelReward.granted > 0 ? '小目标完成:驼乳粉 +1' : `驼乳粉已满:奖励换 ${camelReward.overflowScore} 分`,
- icon: 'none'
- })
taskIndex.value = (taskIndex.value + 1) % 4
taskProgress.value = 0
comboDryTurns.value = 0
@@ -1823,6 +2000,7 @@ async function performReshuffle() {
reshuffling.value = true
busy.value = true
reshuffleDone.value = false
+ reshuffleImportedFoods.value = false
reshuffleVisible.value = true
selected.value = -1
camelMode.value = false
@@ -1831,7 +2009,9 @@ async function performReshuffle() {
vibrate('medium')
await wait(760)
- const source = board.value.map(tile => ({ ...tile }))
+ const prepared = prepareFoodTransitionForReshuffle(board.value)
+ const source = prepared.board
+ reshuffleImportedFoods.value = prepared.imported > 0
let candidate = null
for (let attempt = 0; attempt < 240; attempt += 1) {
const shuffled = shuffleTiles(source)
@@ -1841,8 +2021,11 @@ async function performReshuffle() {
break
}
board.value = candidate || buildGuaranteedBoard()
+ finishFoodTransitionIfReady(true)
reshuffleDone.value = true
- statusText.value = '洗牌完成,新的消除机会已经出现'
+ statusText.value = reshuffleImportedFoods.value
+ ? '洗牌完成,新食物已经加入'
+ : '洗牌完成,新的消除机会已经出现'
playSfxLayer('drop', 'sparkle', 70, .55)
vibrate('light')
await wait(520)
@@ -1867,7 +2050,9 @@ function buildGuaranteedBoard() {
}
function openingKeysForTheme(theme) {
- const [a, b, c, d] = theme.safe
+ const [a, b, c, d] = isValidSafeGroup(activeSafeKeys.value)
+ ? activeSafeKeys.value
+ : theme.safe
const danger = theme.danger
return [
a, b, c, d,
@@ -1888,7 +2073,9 @@ function buildPlayableBoard(keys) {
}
function forcedPlayableKeysForTheme(theme) {
- const [a, b, c, d] = theme.safe
+ const [a, b, c, d] = isValidSafeGroup(activeSafeKeys.value)
+ ? activeSafeKeys.value
+ : theme.safe
const danger = theme.danger
return [
d, a, danger, c,
diff --git a/TUICallKit-Vue3/vite.config.ts b/TUICallKit-Vue3/vite.config.ts
index d77e038f..edc355a5 100644
--- a/TUICallKit-Vue3/vite.config.ts
+++ b/TUICallKit-Vue3/vite.config.ts
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import Optimization from '@uni-ku/bundle-optimizer'
+import { tongjiFoodAssetsPlugin } from './tongji/endless-game/build/tongjiFoodAssetsPlugin'
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
// 编译产物默认输出到 ./dist//
@@ -16,6 +17,7 @@ export default defineConfig({
dts: false,
logger: false,
}),
+ tongjiFoodAssetsPlugin(),
],
css: {
preprocessorOptions: {
diff --git a/server/app/api/logic/tcm/GamePlatformLogic.php b/server/app/api/logic/tcm/GamePlatformLogic.php
index 535fa105..de7c0117 100644
--- a/server/app/api/logic/tcm/GamePlatformLogic.php
+++ b/server/app/api/logic/tcm/GamePlatformLogic.php
@@ -8,6 +8,7 @@ use think\facade\Log;
/**
* 控糖消消乐平台能力:真实用户、每周7人同行榜、幂等成绩上报和微信分享。
+ * 前期榜单统一混排;sex 字段只保留为用户资料快照,不参与分组。
*/
class GamePlatformLogic
{
@@ -29,7 +30,7 @@ class GamePlatformLogic
}
/**
- * 获取当前用户所在的真实周榜;首次进入会分配到当周同性别7人组。
+ * 获取当前用户所在的真实周榜;首次进入会分配到当周混合7人组。
*/
public static function leaderboard(int $userId, string $sessionKey = ''): array|false
{
@@ -258,27 +259,27 @@ class GamePlatformLogic
$existing['nickname'] = $profile['nickname'];
$existing['avatar'] = $profile['avatar'];
}
+
+ // 已经进入本周旧“男女榜”的用户也在下次访问时平滑迁入混合榜,
+ // 保留原认糖数、最高分和局数,不要求等到下周重新开始。
+ $oldGroupId = (int) $existing['group_id'];
+ $oldGroupSex = (int) Db::name('tcm_game_weekly_group')
+ ->where('id', $oldGroupId)
+ ->value('sex');
+ if ($oldGroupSex !== 0) {
+ $mixedGroup = self::lockAvailableMixedGroup($weekStart, $now);
+ Db::name('tcm_game_weekly_score')->where('id', (int) $existing['id'])->update([
+ 'group_id' => (int) $mixedGroup['id'],
+ 'update_time'=> $now,
+ ]);
+ $existing['group_id'] = (int) $mixedGroup['id'];
+ self::refreshGroupMemberCount($oldGroupId, $now);
+ self::refreshGroupMemberCount((int) $mixedGroup['id'], $now);
+ }
return $existing;
}
- // 每周、每个性别使用一行分配锁串行化首次入组。直接 upsert 锁行,
- // 避免空分组上的间隙锁导致首批并发请求互相等待或偶发死锁。
- Db::name('tcm_game_weekly_allocator')->duplicate([
- 'update_time',
- ])->insert([
- 'week_start' => $weekStart,
- 'sex' => $profile['sex'],
- 'create_time'=> $now,
- 'update_time'=> $now,
- ]);
- $allocator = Db::name('tcm_game_weekly_allocator')
- ->where('week_start', $weekStart)
- ->where('sex', $profile['sex'])
- ->lock(true)
- ->find();
- if (!$allocator) {
- throw new \RuntimeException('同行分配锁创建失败');
- }
+ $group = self::lockAvailableMixedGroup($weekStart, $now);
// 等待分配锁期间,同一用户的另一个请求可能已经完成分配。
$existing = Db::name('tcm_game_weekly_score')
@@ -290,43 +291,6 @@ class GamePlatformLogic
return $existing;
}
- $group = Db::name('tcm_game_weekly_group')
- ->where('week_start', $weekStart)
- ->where('sex', $profile['sex'])
- ->where('member_count', '<', self::GROUP_SIZE)
- ->order('group_no', 'asc')
- ->lock(true)
- ->find();
-
- if (!$group) {
- $maxGroupNo = (int) Db::name('tcm_game_weekly_group')
- ->where('week_start', $weekStart)
- ->where('sex', $profile['sex'])
- ->max('group_no');
- $groupNo = $maxGroupNo + 1;
- // 首批用户并发进入时可能同时算出相同 group_no。利用唯一键 upsert,
- // 让请求汇合到同一组,再锁定该组继续分配,避免偶发 1062/死锁。
- Db::name('tcm_game_weekly_group')->duplicate([
- 'update_time',
- ])->insert([
- 'week_start' => $weekStart,
- 'sex' => $profile['sex'],
- 'group_no' => $groupNo,
- 'member_count'=> 0,
- 'create_time' => $now,
- 'update_time' => $now,
- ]);
- $group = Db::name('tcm_game_weekly_group')
- ->where('week_start', $weekStart)
- ->where('sex', $profile['sex'])
- ->where('group_no', $groupNo)
- ->lock(true)
- ->find();
- if (!$group) {
- throw new \RuntimeException('同行分组创建失败');
- }
- }
-
$scoreRow = [
'group_id' => (int) $group['id'],
'week_start' => $weekStart,
@@ -358,18 +322,84 @@ class GamePlatformLogic
// 仅新插入时刷新人数;使用实际成绩行数纠正历史并发造成的计数漂移。
if ($inserted === 1) {
- $memberCount = (int) Db::name('tcm_game_weekly_score')
- ->where('group_id', (int) $group['id'])
- ->count();
- Db::name('tcm_game_weekly_group')->where('id', (int) $group['id'])->update([
- 'member_count' => min(self::GROUP_SIZE, $memberCount),
- 'update_time' => $now,
- ]);
+ self::refreshGroupMemberCount((int) $group['id'], $now);
}
return $score;
}
+ /** 锁定一个可加入的混合7人组;调用方需处在数据库事务中。 */
+ private static function lockAvailableMixedGroup(string $weekStart, int $now): array
+ {
+ // sex=0 是当前统一混合榜。分配锁避免首批并发请求重复建组或超过7人。
+ Db::name('tcm_game_weekly_allocator')->duplicate([
+ 'update_time',
+ ])->insert([
+ 'week_start' => $weekStart,
+ 'sex' => 0,
+ 'create_time'=> $now,
+ 'update_time'=> $now,
+ ]);
+ $allocator = Db::name('tcm_game_weekly_allocator')
+ ->where('week_start', $weekStart)
+ ->where('sex', 0)
+ ->lock(true)
+ ->find();
+ if (!$allocator) {
+ throw new \RuntimeException('同行分配锁创建失败');
+ }
+
+ $group = Db::name('tcm_game_weekly_group')
+ ->where('week_start', $weekStart)
+ ->where('sex', 0)
+ ->where('member_count', '<', self::GROUP_SIZE)
+ ->order('group_no', 'asc')
+ ->lock(true)
+ ->find();
+ if ($group) {
+ return $group;
+ }
+
+ $groupNo = (int) Db::name('tcm_game_weekly_group')
+ ->where('week_start', $weekStart)
+ ->where('sex', 0)
+ ->max('group_no') + 1;
+ Db::name('tcm_game_weekly_group')->duplicate([
+ 'update_time',
+ ])->insert([
+ 'week_start' => $weekStart,
+ 'sex' => 0,
+ 'group_no' => $groupNo,
+ 'member_count'=> 0,
+ 'create_time' => $now,
+ 'update_time' => $now,
+ ]);
+ $group = Db::name('tcm_game_weekly_group')
+ ->where('week_start', $weekStart)
+ ->where('sex', 0)
+ ->where('group_no', $groupNo)
+ ->lock(true)
+ ->find();
+ if (!$group) {
+ throw new \RuntimeException('同行分组创建失败');
+ }
+ return $group;
+ }
+
+ private static function refreshGroupMemberCount(int $groupId, int $now): void
+ {
+ if ($groupId <= 0) {
+ return;
+ }
+ $memberCount = (int) Db::name('tcm_game_weekly_score')
+ ->where('group_id', $groupId)
+ ->count();
+ Db::name('tcm_game_weekly_group')->where('id', $groupId)->update([
+ 'member_count' => min(self::GROUP_SIZE, $memberCount),
+ 'update_time' => $now,
+ ]);
+ }
+
private static function buildLeaderboard(
int $groupId,
int $userId,
@@ -414,14 +444,11 @@ class GamePlatformLogic
$distance = $myIndex > 0
? max(1, (int) $players[$myIndex - 1]['count'] - (int) $me['count'] + 1)
: 0;
- $group = Db::name('tcm_game_weekly_group')->where('id', $groupId)->find();
- $sex = (int) ($group['sex'] ?? 0);
-
return [
'week_start' => $weekStart,
'week_end' => date('Y-m-d', strtotime($weekStart . ' +6 days')),
- 'sex' => $sex,
- 'sex_label' => $sex === 1 ? '男士同行' : ($sex === 2 ? '女士同行' : '同行'),
+ 'sex' => 0,
+ 'sex_label' => '同行',
'group_size' => self::GROUP_SIZE,
'member_count'=> count($players),
'players' => $players,