feat(tongji): add hidden flow scoring mode

This commit is contained in:
大哥大哥的大哥哥
2026-07-21 15:19:06 +08:00
parent 9e684f0afb
commit 68b5b12b59
2 changed files with 234 additions and 19 deletions
@@ -117,12 +117,20 @@
background: rgba(255,255,255,.92);
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
}
.eg-board-shell.is-flowing {
border-color: rgba(245, 158, 11, .72);
box-shadow: 0 0 0 5rpx rgba(255, 221, 89, .2), 0 18rpx 46rpx rgba(234, 88, 12, .2);
animation: egFlowShell 1.05s ease-in-out infinite alternate;
}
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
.eg-flow-state { display: flex; align-items: center; gap: 8rpx; padding: 8rpx 14rpx; border: 3rpx solid #fff3a3; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #f97316, #dc2626); box-shadow: 0 5rpx 16rpx rgba(220,38,38,.3); font-size: 25rpx; font-weight: 1000; white-space: nowrap; animation: egFlowBadge .62s ease-in-out infinite alternate; }
.eg-flow-state > text:last-child { min-width: 62rpx; color: #fff7a8; text-align: right; }
.eg-flow-state.is-paused { border-color: #d9f6ff; background: linear-gradient(135deg, #1687b4, #3155a6); box-shadow: 0 5rpx 16rpx rgba(34,109,166,.28); animation: none; }
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
.eg-risk-dots { display: flex; gap: 5rpx; }
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
@@ -559,6 +567,16 @@
100% { opacity: 1; transform: scale(1); }
}
@keyframes egFlowShell {
from { filter: saturate(1); }
to { filter: saturate(1.08); }
}
@keyframes egFlowBadge {
from { transform: scale(.98); filter: brightness(.96); }
to { transform: scale(1.035); filter: brightness(1.12); }
}
@keyframes egStageIn {
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
18% { opacity: 1; transform: scale(1.06) translateY(0); }
+216 -19
View File
@@ -70,11 +70,19 @@
</view>
</view>
<view class="eg-board-shell">
<view class="eg-board-shell" :class="{ 'is-flowing': flowActive }">
<view class="eg-board-top">
<view class="eg-board-bonuses">
<view class="eg-combo" :class="{ 'is-hot': combo >= 3 }">连消 ×{{ Math.max(combo, 1) }}</view>
<view v-if="feastMoves > 0" class="eg-double-state">双倍得分 ×{{ feastMoves }}</view>
<view
v-if="flowActive"
class="eg-flow-state"
:class="{ 'is-paused': flowPaused }"
>
<text>{{ flowPaused ? '选择暂停' : '心流 ×2' }}</text>
<text>{{ flowCountdownLabel }}</text>
</view>
<view v-else-if="feastMoves > 0" class="eg-double-state">双倍得分 ×{{ feastMoves }}</view>
</view>
<view class="eg-risk">
<text>高糖食物 {{ highCount }}/3</text>
@@ -324,8 +332,16 @@ const COMBO_TASK_WAVE = 2
const COMBO_TASK_TARGET = 2
const COMBO_PITY_TURNS = 6
const MEGA_COMBO_SCORE = 300
const ACTIVE_GAME_KEY = 'tongji_endless_active_game_v1'
const ACTIVE_GAME_VERSION = 1
const FLOW_TRIGGER_GROUPS = 10
const FLOW_WINDOW_MS = 3000
const FLOW_DURATION_MS = 3000
const FLOW_CAMEL_PAUSE_MS = 3000
const FLOW_JACKPOT_DURATION_MS = 6000
const FLOW_TICK_MS = 100
const ACTIVE_GAME_KEY = 'tongji_endless_active_game_v2'
const LEGACY_ACTIVE_GAME_KEY = 'tongji_endless_active_game_v1'
const ENDED_GAME_KEY = 'tongji_endless_ended_game_v1'
const ACTIVE_GAME_VERSION = 2
const DANGER_DROP_CHANCE = 0.05 + GAME_DIFFICULTY * 0.01
const COLUMN_GROUP_BIAS = 0.86 - GAME_DIFFICULTY * 0.028
const CHEER_MESSAGES = [
@@ -426,6 +442,10 @@ const bestScore = ref(0)
const learned = ref(0)
const combo = ref(0)
const feastMoves = ref(0)
const flowActive = ref(false)
const flowPaused = ref(false)
const flowRemainingMs = ref(0)
const flowChainCount = ref(0)
const camelCount = ref(3)
const camelMode = ref(false)
const busy = ref(false)
@@ -478,6 +498,12 @@ let jackpotTimer = null
let confettiTimer = null
let stageTimer = null
let taskRewardTimer = null
let flowTimer = null
let flowPauseTimer = null
let flowPausedRemainingMs = 0
let lastFlowGroupAt = 0
let flowLastTickAt = 0
let flowPageHidden = false
let pendingThemeMatchCount = 0
let taskRewardStatusUntil = 0
let sfxGesturePrimed = false
@@ -495,6 +521,7 @@ const currentTask = computed(() => {
return templates[taskIndex.value % templates.length]
})
const taskPercent = computed(() => Math.min(100, taskProgress.value / currentTask.value.target * 100))
const flowCountdownLabel = computed(() => Math.max(0, flowRemainingMs.value / 1000).toFixed(1))
const highCount = computed(() => board.value.filter(tile => tile && foodOf(tile.key).high).length)
const boardRows = computed(() => Array.from({ length: ROWS }, (_, row) => (
board.value.slice(row * COLS, row * COLS + COLS).map((tile, col) => ({ ...tile, index: row * COLS + col }))
@@ -634,12 +661,16 @@ onLoad((options = {}) => {
})
onShow(() => {
flowPageHidden = false
syncFlowClock()
connectPlatform(incomingInviteCode).then((ok) => {
if (ok) flushPlatformProgress()
})
})
onHide(() => {
flowPageHidden = true
if (flowPaused.value) resumeFlowFromCamel()
saveActiveGame()
})
@@ -655,6 +686,7 @@ onUnload(() => {
if (confettiTimer) clearTimeout(confettiTimer)
if (stageTimer) clearTimeout(stageTimer)
if (taskRewardTimer) clearTimeout(taskRewardTimer)
clearFlowTimers()
})
function makeTile(key, special = '') {
@@ -742,6 +774,142 @@ function currentWeekStartKey(now = new Date()) {
function clearActiveGame() {
try { uni.removeStorageSync(ACTIVE_GAME_KEY) } catch (_) {}
try { uni.removeStorageSync(LEGACY_ACTIVE_GAME_KEY) } catch (_) {}
}
function readEndedGame() {
try { return uni.getStorageSync(ENDED_GAME_KEY) || null } catch (_) { return null }
}
function markGameEnded() {
try {
uni.setStorageSync(ENDED_GAME_KEY, {
session_key: getPlatformSessionKey(),
ended_at: Date.now()
})
} catch (_) {}
}
function clearFlowTimers() {
if (flowTimer) clearInterval(flowTimer)
if (flowPauseTimer) clearTimeout(flowPauseTimer)
flowTimer = null
flowPauseTimer = null
}
function resetFlowState() {
clearFlowTimers()
flowActive.value = false
flowPaused.value = false
flowRemainingMs.value = 0
flowChainCount.value = 0
flowPausedRemainingMs = 0
lastFlowGroupAt = 0
flowLastTickAt = 0
}
function flowClockBlocked() {
return flowPageHidden
|| flowPaused.value
|| busy.value
|| reshuffling.value
|| stageVisible.value
|| camelJackpot.value.visible
|| weeklyRankVisible.value
|| gameOver.value
}
function syncFlowClock() {
if (!flowActive.value) return
const now = Date.now()
if (!flowLastTickAt) flowLastTickAt = now
const elapsed = Math.max(0, now - flowLastTickAt)
flowLastTickAt = now
if (flowClockBlocked()) return
flowRemainingMs.value = Math.max(0, flowRemainingMs.value - elapsed)
if (flowRemainingMs.value <= 0) endFlow()
}
function ensureFlowTimer() {
if (flowTimer) clearInterval(flowTimer)
flowLastTickAt = Date.now()
flowTimer = setInterval(syncFlowClock, FLOW_TICK_MS)
}
function activateFlow(duration = FLOW_DURATION_MS, source = 'streak') {
const wasActive = flowActive.value
flowActive.value = true
flowPaused.value = false
flowPausedRemainingMs = 0
flowRemainingMs.value = Math.max(wasActive ? flowRemainingMs.value : 0, duration)
flowChainCount.value = Math.max(flowChainCount.value, FLOW_TRIGGER_GROUPS)
ensureFlowTimer()
if (wasActive) return
statusText.value = source === 'camel-five'
? '五罐驼乳开启 6 秒心流,继续消除得分翻倍'
: '手感来了!3 秒内继续消除,得分翻倍'
playSfxLayer('comboMega', 'sparkle', 90, .72)
vibrate('medium')
uni.showToast({
title: source === 'camel-five' ? '五连大奖:心流双倍 6 秒' : '隐藏心流已触发:得分 ×2',
icon: 'none'
})
}
function endFlow() {
if (flowTimer) clearInterval(flowTimer)
if (flowPauseTimer) clearTimeout(flowPauseTimer)
flowTimer = null
flowPauseTimer = null
flowActive.value = false
flowPaused.value = false
flowRemainingMs.value = 0
flowChainCount.value = 0
flowPausedRemainingMs = 0
lastFlowGroupAt = 0
flowLastTickAt = 0
if (!busy.value && !gameOver.value) statusText.value = '心流结束,继续寻找下一次手感'
}
function pauseFlowForCamel() {
if (!flowActive.value || flowPaused.value) return
syncFlowClock()
if (!flowActive.value) return
flowPaused.value = true
flowPausedRemainingMs = flowRemainingMs.value
if (flowPauseTimer) clearTimeout(flowPauseTimer)
flowPauseTimer = setTimeout(() => {
resumeFlowFromCamel()
if (camelMode.value && !busy.value) statusText.value = '3 秒选择暂停已结束,请尽快选择替换食物'
}, FLOW_CAMEL_PAUSE_MS)
}
function resumeFlowFromCamel() {
if (!flowPaused.value) return
if (flowPauseTimer) clearTimeout(flowPauseTimer)
flowPauseTimer = null
flowPaused.value = false
flowRemainingMs.value = Math.max(0, flowPausedRemainingMs)
flowPausedRemainingMs = 0
flowLastTickAt = Date.now()
if (flowRemainingMs.value <= 0) endFlow()
}
function registerFlowGroups(groupCount, countsTowardTrigger = true) {
const count = Math.max(0, Number(groupCount) || 0)
if (!count) return
const now = Date.now()
if (flowActive.value) {
flowRemainingMs.value = Math.max(flowRemainingMs.value, FLOW_DURATION_MS)
flowLastTickAt = now
return
}
if (!countsTowardTrigger) return
flowChainCount.value = now - lastFlowGroupAt <= FLOW_WINDOW_MS
? flowChainCount.value + count
: count
lastFlowGroupAt = now
if (flowChainCount.value >= FLOW_TRIGGER_GROUPS) activateFlow(FLOW_DURATION_MS)
}
function saveActiveGame() {
@@ -755,6 +923,7 @@ function saveActiveGame() {
try {
uni.setStorageSync(ACTIVE_GAME_KEY, {
version: ACTIVE_GAME_VERSION,
status: 'active',
week_start: currentWeekStartKey(),
session_key: getPlatformSessionKey(),
confirmed_session_learned: Math.max(0, Number(confirmedSessionLearned.value) || 0),
@@ -782,7 +951,16 @@ function restoreActiveGame() {
const validBoard = Array.isArray(saved?.board)
&& saved.board.length === ROWS * COLS
&& saved.board.every(tile => tile && FOODS[tile.key])
if (saved?.version !== ACTIVE_GAME_VERSION || saved?.week_start !== currentWeekStartKey() || !validBoard) {
const ended = readEndedGame()
const savedSessionKey = String(saved?.session_key || '')
const endedSessionKey = String(ended?.session_key || '')
if (
saved?.version !== ACTIVE_GAME_VERSION
|| saved?.status !== 'active'
|| saved?.week_start !== currentWeekStartKey()
|| !validBoard
|| (savedSessionKey && savedSessionKey === endedSessionKey)
) {
clearActiveGame()
return false
}
@@ -798,6 +976,7 @@ function restoreActiveGame() {
learned.value = Math.max(0, Number(saved.learned) || 0)
combo.value = Math.max(0, Number(saved.combo) || 0)
feastMoves.value = Math.max(0, Number(saved.feast_moves) || 0)
resetFlowState()
camelCount.value = Math.max(0, Math.min(MAX_CAMEL_COUNT, Number(saved.camel_count) || 0))
taskIndex.value = Math.max(0, Number(saved.task_index) || 0) % 4
taskProgress.value = Math.max(0, Number(saved.task_progress) || 0)
@@ -839,6 +1018,7 @@ function startGame() {
learned.value = 0
combo.value = 0
feastMoves.value = 0
resetFlowState()
camelCount.value = 3
camelMode.value = false
busy.value = false
@@ -1003,6 +1183,7 @@ async function tapTile(index) {
board.value = [...board.value]
camelCount.value -= 1
camelMode.value = false
resumeFlowFromCamel()
selected.value = -1
playSfxLayer('insulin', 'sparkle', 55, .5)
vibrate('light')
@@ -1010,7 +1191,7 @@ async function tapTile(index) {
if (groups.length) {
busy.value = true
statusText.value = '驼乳棋子连起来了!'
await resolveMatches(groups, index)
await resolveMatches(groups, index, { toolTriggered: true })
busy.value = false
finishTurn()
} else {
@@ -1046,7 +1227,15 @@ function toggleCamel() {
if (busy.value || camelCount.value <= 0 || gameOver.value) return
camelMode.value = !camelMode.value
selected.value = -1
statusText.value = camelMode.value ? `请选择橙色边框的${foodOf(currentTheme.value.danger).short}进行替换` : '已取消使用驼乳粉'
if (camelMode.value) {
pauseFlowForCamel()
statusText.value = flowPaused.value
? `心流暂停 3 秒,请选择橙色边框的${foodOf(currentTheme.value.danger).short}`
: `请选择橙色边框的${foodOf(currentTheme.value.danger).short}进行替换`
} else {
resumeFlowFromCamel()
statusText.value = '已取消使用驼乳粉'
}
}
function isAdjacent(a, b) {
@@ -1081,6 +1270,8 @@ async function trySwap(a, b, soundPlayed = false) {
endedFood.value = foodOf(highTile.key)
saveBest()
gameOver.value = true
resetFlowState()
markGameEnded()
clearActiveGame()
queuePlatformProgress(learned.value, score.value, true)
busy.value = false
@@ -1097,7 +1288,8 @@ function swap(a, b) {
board.value = next
}
async function resolveMatches(initialGroups, movedIndex) {
async function resolveMatches(initialGroups, movedIndex, options = {}) {
const toolTriggered = !!options.toolTriggered
const comboTaskAtStart = currentTask.value.kind === 'combo'
let groups = initialGroups
let wave = 0
@@ -1136,6 +1328,7 @@ async function resolveMatches(initialGroups, movedIndex) {
? 5
: 0
const camelMatchCount = Math.max(bentCamelCount, camelGroup?.indices.length || 0)
registerFlowGroups(groups.length, !toolTriggered)
if (camelMatchCount >= 3) applyCamelJackpot(camelMatchCount, remove)
const removedTiles = [...remove].map(i => board.value[i]).filter(Boolean)
// 排名统计的是正确认识并消除的普通食物;驼乳是道具,不计入认糖数。
@@ -1162,7 +1355,7 @@ async function resolveMatches(initialGroups, movedIndex) {
turnConsumed += removedNormal.length
learned.value += removedNormal.length
updateFoodTask(removedNormal)
const multiplier = feastMoves.value > 0 ? 2 : 1
const multiplier = feastMoves.value > 0 || flowActive.value ? 2 : 1
score.value += (removedNormal.length * 12 + Math.max(0, wave - 1) * 20 + madeSpecial * 35) * multiplier
queuePlatformProgress(learned.value, score.value, false)
await wait(specialEffects.hasFire ? 330 : 210)
@@ -1262,29 +1455,31 @@ function camelRewardText(reward) {
function applyCamelJackpot(count, remove) {
pendingThemeMatchCount = Math.max(pendingThemeMatchCount, count)
const bonusMultiplier = flowActive.value ? 2 : 1
if (count >= 5) {
score.value += 1554
const camelReward = grantCamelReward(4)
feastMoves.value = Math.max(feastMoves.value, 6)
const bonus = 1554 * bonusMultiplier
score.value += bonus
activateFlow(FLOW_JACKPOT_DURATION_MS, 'camel-five')
board.value.forEach((tile, index) => { if (foodOf(tile?.key).high) remove.add(index) })
showCamelJackpot(5, '驼乳五连双倍大奖', `+1554 分 · ${camelRewardText(camelReward)} · 双倍得分 ×6`)
showCamelJackpot(5, '驼乳五连双倍大奖', `+${bonus} 分 · 移出全部高糖 · 心流双倍 6 秒`)
startConfetti()
playJackpotSequence()
return
}
if (count === 4) {
score.value += 288
const camelReward = grantCamelReward(1)
const bonus = 388 * bonusMultiplier
score.value += bonus
const highIndex = board.value.findIndex((tile, index) => foodOf(tile?.key).high && !remove.has(index))
if (highIndex >= 0) remove.add(highIndex)
showCamelJackpot(4, '驼乳超级四连', `+288 分 · ${camelRewardText(camelReward)} · 移出一份高糖食物`)
showCamelJackpot(4, '驼乳超级四连', `+${bonus} · 移出一份高糖食物 · 再来一罐冲击大奖`)
playSfxLayer('match5', 'fanfare', 90, .7)
setTimeout(() => playSfx('tap', 1.16, .58), 260)
vibrate('medium')
return
}
score.value += 77
showCamelJackpot(3, '驼乳幸运三连', '+77 分 · 轻量连消奖励')
const bonus = 77 * bonusMultiplier
score.value += bonus
showCamelJackpot(3, '驼乳幸运三连', `+${bonus} 分 · 轻量连消奖励`)
playSfxLayer('match3', 'sparkle', 44, .38)
vibrate('light')
}
@@ -1586,7 +1781,9 @@ function finishTurn() {
scheduleHint()
return
}
statusText.value = combo.value >= 2 ? `漂亮!完成 ${combo.value} 连消` : '食物已从上方补充,继续挑战'
statusText.value = flowActive.value
? '心流双倍进行中,3 秒内继续消除可续时'
: (combo.value >= 2 ? `漂亮!完成 ${combo.value} 连消` : '食物已从上方补充,继续挑战')
scheduleHint()
}