fix(tongji): 恢复游戏进度并完善周榜

This commit is contained in:
大哥大哥的大哥哥
2026-07-20 19:04:45 +08:00
parent 8a6890767e
commit 9e684f0afb
7 changed files with 206 additions and 15 deletions
+2
View File
@@ -22,3 +22,5 @@ unpackage/dist
*.sw?
TUICallKit
# 本工程的通话源码复用仓库内 ../wx/TUICallKit;保留相对软链接以便干净克隆后可直接构建。
!TUICallKit
+1
View File
@@ -0,0 +1 @@
../wx/TUICallKit
@@ -39,6 +39,7 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
const connected = ref(false)
const loading = ref(false)
const syncStatus = ref('idle')
const lastError = ref('')
const leaderboard = ref({ ...EMPTY_BOARD })
const confirmedSessionLearned = ref(0)
@@ -73,17 +74,19 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
}
connected.value = true
syncStatus.value = 'synced'
lastError.value = ''
}
async function refreshLeaderboard() {
const res = await api({
url: '/api/tcm/gameWeeklyLeaderboard',
method: 'GET'
method: 'GET',
data: { session_key: sessionKey }
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '同行榜加载失败')
}
applyLeaderboard(res.data)
applyLeaderboard(res.data, sessionKey)
return res.data
}
@@ -112,9 +115,10 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
await refreshLeaderboard()
if (queuedPayloads.length) flushProgress()
return true
} catch (_) {
} catch (error) {
connected.value = false
syncStatus.value = 'offline'
lastError.value = String(error?.message || '平台连接失败,请点击重试')
return false
} finally {
loading.value = false
@@ -124,14 +128,21 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
return connectPromise
}
function beginSession() {
function beginSession(existingSessionKey = '') {
if (syncTimer) clearTimeout(syncTimer)
sessionKey = createSessionKey()
const restoredKey = String(existingSessionKey || '').trim()
sessionKey = /^[A-Za-z0-9_-]{16,64}$/.test(restoredKey)
? restoredKey
: createSessionKey()
confirmedSessionLearned.value = 0
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
return sessionKey
}
function getSessionKey() {
return sessionKey
}
function queueProgress(learnedCount, score, ended = false) {
const nextPayload = {
session_key: sessionKey,
@@ -191,9 +202,10 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|| Number(item.ended) > Number(payload.ended)
))
persistPendingPayloads()
} catch (_) {
} catch (error) {
connected.value = false
syncStatus.value = 'offline'
lastError.value = String(error?.message || '成绩暂未保存,联网后会自动重试')
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
persistPendingPayloads()
} finally {
@@ -237,10 +249,12 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
connected,
loading,
syncStatus,
lastError,
leaderboard,
confirmedSessionLearned,
connect,
beginSession,
getSessionKey,
queueProgress,
flushProgress,
syncAndRefresh,
@@ -337,6 +337,9 @@
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
.eg-weekly-row.is-waiting { color: #8a9b94; border-style: dashed; border-color: #dbe8e2; background: rgba(247,251,249,.72); }
.eg-weekly-row.is-waiting .eg-weekly-avatar { color: #6e887e; border-color: #dce9e3; background: #edf4f1; box-shadow: none; }
.eg-weekly-row.is-waiting .eg-weekly-count { color: #9baba4; }
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
@@ -350,6 +353,9 @@
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
.eg-weekly-connection { display: flex; align-items: center; justify-content: space-between; gap: 12rpx; margin-top: 13rpx; padding: 12rpx 15rpx; border: 2rpx solid #e4d7b2; border-radius: 18rpx; color: #6b6655; background: #fff9e8; font-size: 21rpx; font-weight: 700; }
.eg-weekly-connection > text:first-child { flex: 1; }
.eg-weekly-retry { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 999rpx; color: #fff; background: #23765c; font-weight: 900; }
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
+162 -6
View File
@@ -224,7 +224,7 @@
v-for="player in weeklyRankRows"
:key="player.id"
class="eg-weekly-row"
:class="{ 'is-me': player.isMe }"
:class="{ 'is-me': player.isMe, 'is-waiting': player.isWaiting }"
>
<text class="eg-weekly-place">{{ player.rank }}</text>
<view class="eg-weekly-avatar" :class="`is-tone-${player.tone}`">
@@ -237,6 +237,11 @@
</view>
</view>
<view v-if="!platformConnected" class="eg-weekly-connection" @tap.stop="retryPlatformConnection">
<text>{{ platformConnectionHint }}</text>
<text class="eg-weekly-retry">点击重试</text>
</view>
<view class="eg-cheer-card" :class="`is-${currentCheer.tone}`">
<view class="eg-cheer-top">
<text class="eg-cheer-source">{{ currentCheer.source }}</text>
@@ -246,7 +251,7 @@
<text v-if="currentCheer.userGenerated" class="eg-cheer-note">亲友自定义内容不作为医学判断</text>
</view>
<view class="eg-weekly-start" @tap="closeWeeklyRank">开始消除</view>
<view class="eg-weekly-start" @tap="closeWeeklyRank">{{ restoredGame ? '继续上次游戏' : '开始消除' }}</view>
<button class="eg-weekly-share" open-type="share">邀请亲友一起认糖</button>
<text class="eg-weekly-footnote">使用平台昵称参加真实周榜每周一重新分组分享不会自动绑定家庭关系</text>
</view>
@@ -300,7 +305,7 @@
<script setup>
import { computed, getCurrentInstance, ref } from 'vue'
import { onLoad, onShareAppMessage, onShareTimeline, onShow, onUnload } from '@dcloudio/uni-app'
import { onHide, onLoad, onShareAppMessage, onShareTimeline, onShow, onUnload } from '@dcloudio/uni-app'
import TongjiIcon from '../components/TongjiIcon.vue'
import { useGameSfx } from './composables/useGameSfx.js'
import { useGamePlatform } from './composables/useGamePlatform.js'
@@ -319,6 +324,8 @@ 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 DANGER_DROP_CHANCE = 0.05 + GAME_DIFFICULTY * 0.01
const COLUMN_GROUP_BIAS = 0.86 - GAME_DIFFICULTY * 0.028
const CHEER_MESSAGES = [
@@ -399,10 +406,12 @@ const {
connected: platformConnected,
loading: platformLoading,
syncStatus: platformSyncStatus,
lastError: platformLastError,
leaderboard: platformLeaderboard,
confirmedSessionLearned,
connect: connectPlatform,
beginSession: beginPlatformSession,
getSessionKey: getPlatformSessionKey,
queueProgress: queuePlatformProgress,
flushProgress: flushPlatformProgress,
syncAndRefresh: syncAndRefreshPlatform,
@@ -447,6 +456,7 @@ const reshuffleDone = ref(false)
const reshuffling = ref(false)
const confettiVisible = ref(false)
const weeklyRankVisible = ref(false)
const restoredGame = ref(false)
const cheerIndex = ref(0)
const CONFETTI_COLORS = ['#ff3154', '#ffd928', '#28c76f', '#4d8dff', '#a855f7', '#ff7a1a']
const confettiPieces = Array.from({ length: 34 }, (_, index) => ({
@@ -515,9 +525,24 @@ const weeklyRankRows = computed(() => {
tone: 1,
isMe: true
}]
return source
const ranked = source
.sort((a, b) => b.count - a.count || Number(b.best_score || 0) - Number(a.best_score || 0))
.map((player, index) => ({ ...player, rank: index + 1 }))
while (ranked.length < 7) {
const rank = ranked.length + 1
ranked.push({
id: `waiting-${rank}`,
rank,
name: '等待同行加入',
avatar: '+',
avatarUrl: '',
count: '—',
tone: rank,
isMe: false,
isWaiting: true
})
}
return ranked.slice(0, 7)
})
const weeklyMyRank = computed(() => weeklyRankRows.value.find(player => player.isMe)?.rank || 1)
const weeklyRankRise = computed(() => 0)
@@ -535,13 +560,24 @@ const weeklyRangeLabel = computed(() => {
return `${formatWeekDate(start)}${formatWeekDate(end)}`
})
const weeklySexLabel = computed(() => platformLeaderboard.value.sex_label || '同行')
const weeklyMemberCount = computed(() => Math.max(1, Number(platformLeaderboard.value.member_count || weeklyRankRows.value.length)))
const weeklyMemberCount = computed(() => {
const reported = Number(platformLeaderboard.value.member_count || 0)
const remoteCount = Array.isArray(platformLeaderboard.value.players)
? platformLeaderboard.value.players.length
: 0
return Math.max(1, Math.min(7, reported || remoteCount || 1))
})
const weeklyStatusLabel = computed(() => {
if (platformLoading.value) return '连接中'
if (!platformConnected.value) return '离线记录中'
if (platformSyncStatus.value === 'pending' || platformSyncStatus.value === 'syncing') return '保存中'
return '真实周榜'
})
const platformConnectionHint = computed(() => (
platformLoading.value
? '正在连接甄养堂账号和本周同行榜'
: (platformLastError.value || '当前成绩暂存在本机,联网登录后会自动补传')
))
onShareAppMessage(() => {
recordPlatformShare()
@@ -592,7 +628,7 @@ onLoad((options = {}) => {
initNavLayout()
bestScore.value = Number(uni.getStorageSync('tongji_endless_best') || 0)
incomingInviteCode = String(options.invite_code || '').trim()
startGame()
if (!restoreActiveGame()) startGame()
weeklyRankVisible.value = true
connectPlatform(incomingInviteCode)
})
@@ -603,7 +639,12 @@ onShow(() => {
})
})
onHide(() => {
saveActiveGame()
})
onUnload(() => {
saveActiveGame()
if (learned.value > 0 || gameOver.value) {
queuePlatformProgress(learned.value, score.value, gameOver.value)
flushPlatformProgress()
@@ -679,8 +720,117 @@ function cycleCheer() {
playSfx('select', .68)
}
async function retryPlatformConnection() {
if (platformLoading.value) return
const connected = await connectPlatform(incomingInviteCode)
if (connected) {
await syncAndRefreshPlatform(learned.value, score.value)
uni.showToast({ title: '周榜已更新', icon: 'none' })
return
}
uni.showToast({ title: platformConnectionHint.value, icon: 'none' })
}
function currentWeekStartKey(now = new Date()) {
const date = new Date(now)
const day = date.getDay() || 7
date.setHours(0, 0, 0, 0)
date.setDate(date.getDate() - day + 1)
const pad = value => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function clearActiveGame() {
try { uni.removeStorageSync(ACTIVE_GAME_KEY) } catch (_) {}
}
function saveActiveGame() {
if (gameOver.value) {
clearActiveGame()
return false
}
if (busy.value || board.value.length !== ROWS * COLS) return false
const stableBoard = board.value.every(tile => tile && FOODS[tile.key])
if (!stableBoard) return false
try {
uni.setStorageSync(ACTIVE_GAME_KEY, {
version: ACTIVE_GAME_VERSION,
week_start: currentWeekStartKey(),
session_key: getPlatformSessionKey(),
confirmed_session_learned: Math.max(0, Number(confirmedSessionLearned.value) || 0),
saved_at: Date.now(),
theme_index: themeIndex.value,
board: board.value.map(tile => ({ id: tile.id, key: tile.key, special: tile.special || '' })),
score: score.value,
learned: learned.value,
combo: combo.value,
feast_moves: feastMoves.value,
camel_count: camelCount.value,
task_index: taskIndex.value,
task_progress: taskProgress.value,
combo_dry_turns: comboDryTurns.value
})
return true
} catch (_) {
return false
}
}
function restoreActiveGame() {
let saved
try { saved = uni.getStorageSync(ACTIVE_GAME_KEY) } catch (_) { return false }
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) {
clearActiveGame()
return false
}
themeIndex.value = Math.max(0, Math.min(FOOD_GROUPS.length - 1, Number(saved.theme_index) || 0))
board.value = saved.board.map(tile => ({
id: Math.max(1, Number(tile.id) || tileId++),
key: tile.key,
special: ['row', 'col', 'all', 'burst'].includes(tile.special) ? tile.special : ''
}))
tileId = Math.max(tileId, ...board.value.map(tile => tile.id + 1))
score.value = Math.max(0, Number(saved.score) || 0)
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)
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)
comboDryTurns.value = Math.max(0, Number(saved.combo_dry_turns) || 0)
beginPlatformSession(saved.session_key)
confirmedSessionLearned.value = Math.max(0, Number(saved.confirmed_session_learned) || 0)
selected.value = -1
camelMode.value = false
busy.value = false
gameOver.value = false
taskRewardVisible.value = false
clearingIndices.value = []
fireClearingIndices.value = []
impactText.value = ''
camelJackpot.value.visible = false
stageVisible.value = false
reshuffleVisible.value = false
reshuffleDone.value = false
reshuffling.value = false
confettiVisible.value = false
pendingThemeMatchCount = 0
taskRewardStatusUntil = 0
endedFood.value = foodOf(currentTheme.value.danger)
restoredGame.value = true
resetDrag()
statusText.value = '已恢复上次未完成的游戏和得分'
scheduleHint()
return true
}
function startGame() {
if (hintTimer) clearTimeout(hintTimer)
clearActiveGame()
beginPlatformSession()
themeIndex.value = 0
board.value = buildPlayableBoard(openingKeysForTheme(currentTheme.value))
@@ -708,8 +858,10 @@ function startGame() {
confettiVisible.value = false
pendingThemeMatchCount = 0
taskRewardStatusUntil = 0
restoredGame.value = false
resetDrag()
statusText.value = '交换相邻食物,三个相同即可消除'
saveActiveGame()
scheduleHint()
}
@@ -863,6 +1015,7 @@ async function tapTile(index) {
finishTurn()
} else {
statusText.value = `已将${foodOf(currentTheme.value.danger).short}移出餐盘,凑齐三个驼乳可换主题`
saveActiveGame()
if (!checkBoardHealth()) scheduleHint()
}
return
@@ -928,6 +1081,7 @@ async function trySwap(a, b, soundPlayed = false) {
endedFood.value = foodOf(highTile.key)
saveBest()
gameOver.value = true
clearActiveGame()
queuePlatformProgress(learned.value, score.value, true)
busy.value = false
return
@@ -1422,6 +1576,7 @@ function showTaskCamelReward() {
function finishTurn() {
saveBest()
saveActiveGame()
if (checkBoardHealth()) return
if (stageVisible.value) {
scheduleHint()
@@ -1497,6 +1652,7 @@ async function performReshuffle() {
reshuffleVisible.value = false
reshuffling.value = false
busy.value = false
saveActiveGame()
scheduleHint()
}
+4 -1
View File
@@ -44,7 +44,10 @@ class TcmController extends BaseApiController
/** 获取当前登录用户的控糖消消乐每周7人同行榜。 */
public function gameWeeklyLeaderboard()
{
$result = GamePlatformLogic::leaderboard((int) $this->userId);
$result = GamePlatformLogic::leaderboard(
(int) $this->userId,
(string) $this->request->get('session_key', '')
);
if ($result === false) {
return $this->fail(GamePlatformLogic::getError());
}
+11 -2
View File
@@ -31,7 +31,7 @@ class GamePlatformLogic
/**
* 获取当前用户所在的真实周榜;首次进入会分配到当周同性别7人组。
*/
public static function leaderboard(int $userId): array|false
public static function leaderboard(int $userId, string $sessionKey = ''): array|false
{
self::$error = '';
if ($userId <= 0) {
@@ -43,7 +43,16 @@ class GamePlatformLogic
$score = self::ensureWeeklyScore($userId, self::weekStart());
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
Db::commit();
return self::buildLeaderboard((int) $score['group_id'], $userId, $inviteCode);
$result = self::buildLeaderboard((int) $score['group_id'], $userId, $inviteCode);
$sessionKey = trim($sessionKey);
if (preg_match('/^[A-Za-z0-9_-]{16,64}$/', $sessionKey)) {
$confirmed = Db::name('tcm_game_session')
->where('session_key', $sessionKey)
->where('user_id', $userId)
->value('learned_count');
$result['confirmed_session_learned'] = max(0, (int) $confirmed);
}
return $result;
} catch (\Throwable $e) {
Db::rollback();
self::logException('leaderboard', $e);