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,