From 68b5b12b5994f4cf066274b773862aceaec02ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E5=93=A5=E5=A4=A7=E5=93=A5=E7=9A=84=E5=A4=A7?= =?UTF-8?q?=E5=93=A5=E5=93=A5?= Date: Tue, 21 Jul 2026 15:19:06 +0800 Subject: [PATCH] feat(tongji): add hidden flow scoring mode --- .../tongji/endless-game/game-endless.scss | 18 ++ TUICallKit-Vue3/tongji/endless-game/index.vue | 235 ++++++++++++++++-- 2 files changed, 234 insertions(+), 19 deletions(-) diff --git a/TUICallKit-Vue3/tongji/endless-game/game-endless.scss b/TUICallKit-Vue3/tongji/endless-game/game-endless.scss index 19f07c6e..0ca4b509 100644 --- a/TUICallKit-Vue3/tongji/endless-game/game-endless.scss +++ b/TUICallKit-Vue3/tongji/endless-game/game-endless.scss @@ -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); } diff --git a/TUICallKit-Vue3/tongji/endless-game/index.vue b/TUICallKit-Vue3/tongji/endless-game/index.vue index 6e487031..2781099b 100644 --- a/TUICallKit-Vue3/tongji/endless-game/index.vue +++ b/TUICallKit-Vue3/tongji/endless-game/index.vue @@ -70,11 +70,19 @@ - + 连消 ×{{ Math.max(combo, 1) }} - 双倍得分 ×{{ feastMoves }} + + {{ flowPaused ? '选择暂停' : '心流 ×2' }} + {{ flowCountdownLabel }}秒 + + 双倍得分 ×{{ feastMoves }} 高糖食物 {{ highCount }}/3 @@ -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() }