fix(tongji): stabilize food transitions and mixed weekly rank

This commit is contained in:
大哥大哥的大哥哥
2026-07-22 15:21:51 +08:00
parent 68b5b12b59
commit 1626b6f498
12 changed files with 970 additions and 107 deletions
@@ -3,7 +3,8 @@
## 已接入能力
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
- 按周一日期、性别自动分配最多 7 人的同行组。
- 按周一日期自动分配最多 7 人的混合同行组,前期不区分男女
- 本周已经进入旧男女组的账号,下次读取榜单时保留成绩并自动迁入混合组。
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
- 以真实平台昵称、头像、认糖数和本周最高分排序。
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
@@ -33,7 +34,7 @@
## 上线前检查
-男女各两个测试账号进入,确认被分入对应性别组。
-不同账号进入,确认按进入顺序加入同一个最多 7 人的混合同行组。
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

@@ -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
@@ -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))
})
})
}
}
}
@@ -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); }
+223 -36
View File
@@ -19,9 +19,20 @@
<TongjiIcon name="chevron-left" size="md" color="#155e4b" />
</view>
</view>
<view class="eg-title-wrap">
<view class="eg-title-wrap" :class="{ 'is-noticing': centerNoticeVisible }">
<text class="eg-title">识糖小课堂</text>
<text class="eg-subtitle">{{ themeIndex + 1 }} · {{ currentTheme.title }}</text>
<view
v-if="centerNoticeVisible"
:key="centerNoticeKey"
class="eg-nav-notice"
role="status"
aria-live="polite"
aria-atomic="true"
>
<text class="eg-nav-notice-title">{{ centerNoticeTitle }}</text>
<text class="eg-nav-notice-detail">{{ centerNoticeDetail }}</text>
</view>
</view>
<view class="eg-nav-actions">
<view class="eg-nav-btn eg-nav-btn--small" @tap="toggleSfx">
@@ -126,7 +137,12 @@
<view class="eg-fire-spark is-two" />
<view class="eg-fire-spark is-three" />
</view>
<image class="eg-food-image" :src="foodOf(tile.key).img" mode="aspectFit" />
<image
class="eg-food-image"
:class="{ 'is-large-square': foodOf(tile.key).largeIcon }"
:src="foodOf(tile.key).img"
mode="aspectFit"
/>
<text class="eg-food-name">{{ foodOf(tile.key).short }}</text>
<view class="eg-sugar-tag" :class="`is-${foodOf(tile.key).level || 'low'}`">
{{ foodOf(tile.key).levelLabel || '低糖' }}
@@ -271,13 +287,13 @@
<TongjiIcon name="refresh" size="lg" color="#ffffff" />
</view>
<text class="eg-reshuffle-title">{{ reshuffleDone ? '洗牌完成' : '没有可消除组合' }}</text>
<text class="eg-reshuffle-desc">{{ reshuffleDone ? '新的消除机会已经出现' : '正在为您重新洗牌,请稍候' }}</text>
<text class="eg-reshuffle-desc">{{ reshuffleDone ? (reshuffleImportedFoods ? '洗牌完成,新食物已经加入' : '新的消除机会已经出现') : '正在为您重新洗牌,请稍候' }}</text>
</view>
</view>
<view v-if="stageVisible" class="eg-stage-overlay" @tap.stop>
<view class="eg-stage-card">
<text class="eg-stage-kicker">菜单已解锁</text>
<text class="eg-stage-kicker">食物排队加入</text>
<text class="eg-stage-number"> {{ stageMeta.number }} </text>
<text class="eg-stage-title">{{ stageMeta.title }}</text>
<view class="eg-stage-foods">
@@ -286,7 +302,7 @@
<text>{{ food.short }}</text>
</view>
</view>
<text class="eg-stage-tip">本组橙框食物可用驼乳粉替换</text>
<text class="eg-stage-tip">橙框食物已更新普通食物会随掉落逐步加入</text>
</view>
</view>
@@ -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,
+2
View File
@@ -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/<mode>/<platform>
@@ -16,6 +17,7 @@ export default defineConfig({
dts: false,
logger: false,
}),
tongjiFoodAssetsPlugin(),
],
css: {
preprocessorOptions: {
+95 -68
View File
@@ -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,