feat(tongji): 接入小游戏真实周榜与微信分享

This commit is contained in:
大哥大哥的大哥哥
2026-07-17 18:02:00 +08:00
parent 62467dc01c
commit 7192c4db8c
8 changed files with 1014 additions and 32 deletions
+3 -1
View File
@@ -230,7 +230,9 @@
"navigationStyle": "custom",
"navigationBarTitleText": "控糖消消乐",
"backgroundColor": "#eefbf4",
"disableScroll": false
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
]
@@ -0,0 +1,36 @@
# 控糖消消乐平台接入说明
## 已接入能力
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
- 按周一日期、性别自动分配最多 7 人的同行组。
- 以真实平台昵称、头像、认糖数和本周最高分排序。
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
- 待同步成绩最多本地保留 8 局,重新联网后自动补传。
- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。
- 受邀进入只记录一次轻量分享访问,不自动建立家庭或好友绑定。
## 接口
- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。
- `POST /api/tcm/gameSubmitProgress`:上报 `session_key``learned_count``score``ended`
- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。
- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`
四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`
## 部署顺序
1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`
2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php``TcmController.php`
3. 重新构建并上传小程序前端。
如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。
## 上线前检查
- 用男女各两个测试账号进入,确认被分入对应性别组。
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
- 周一验证新周重新分组,旧周成绩不带入新周。
@@ -0,0 +1,244 @@
import { ref } from 'vue'
const EMPTY_BOARD = {
week_start: '',
week_end: '',
sex_label: '同行',
group_size: 7,
member_count: 0,
players: [],
me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true },
invite_code: ''
}
const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1'
function createSessionKey() {
const random = Math.random().toString(36).slice(2, 12)
return `game_${Date.now().toString(36)}_${random}`
}
function readPendingPayloads() {
try {
const stored = uni.getStorageSync(PENDING_SYNC_KEY)
if (!Array.isArray(stored)) return []
return stored.filter(item => (
item
&& /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || ''))
&& Number(item.learned_count) >= 0
)).slice(-8)
} catch (_) {
return []
}
}
/**
* 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。
* 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。
*/
export function useGamePlatform(proxy, ensureLoggedIn) {
const connected = ref(false)
const loading = ref(false)
const syncStatus = ref('idle')
const leaderboard = ref({ ...EMPTY_BOARD })
const confirmedSessionLearned = ref(0)
let sessionKey = createSessionKey()
let syncTimer = null
let syncing = false
let queuedPayloads = readPendingPayloads()
let connectPromise = null
function persistPendingPayloads() {
try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {}
}
async function api(request) {
if (!proxy?.apiUrl) throw new Error('平台接口未初始化')
return proxy.apiUrl(request, false)
}
function applyLeaderboard(data, confirmedForSession = '') {
if (!data || !Array.isArray(data.players)) return
const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey
if (!inactiveSessionResponse) {
leaderboard.value = {
...EMPTY_BOARD,
...data,
me: { ...EMPTY_BOARD.me, ...(data.me || {}) },
players: data.players
}
}
if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) {
confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0
}
connected.value = true
syncStatus.value = 'synced'
}
async function refreshLeaderboard() {
const res = await api({
url: '/api/tcm/gameWeeklyLeaderboard',
method: 'GET'
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '同行榜加载失败')
}
applyLeaderboard(res.data)
return res.data
}
async function acceptShare(inviteCode) {
const code = String(inviteCode || '').trim().toUpperCase()
if (!code) return
try {
await api({
url: '/api/tcm/gameAcceptShare',
method: 'POST',
data: { invite_code: code }
})
} catch (_) {
// 分享关系是辅助能力,不阻断进入游戏。
}
}
async function connect(inviteCode = '') {
if (connectPromise) return connectPromise
connectPromise = (async () => {
loading.value = true
try {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('登录失败')
await acceptShare(inviteCode)
await refreshLeaderboard()
if (queuedPayloads.length) flushProgress()
return true
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
return false
} finally {
loading.value = false
connectPromise = null
}
})()
return connectPromise
}
function beginSession() {
if (syncTimer) clearTimeout(syncTimer)
sessionKey = createSessionKey()
confirmedSessionLearned.value = 0
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
return sessionKey
}
function queueProgress(learnedCount, score, ended = false) {
const nextPayload = {
session_key: sessionKey,
learned_count: Math.max(0, Number(learnedCount) || 0),
score: Math.max(0, Number(score) || 0),
ended: ended ? 1 : 0
}
const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey)
if (existingIndex >= 0) {
const existing = queuedPayloads[existingIndex]
queuedPayloads[existingIndex] = {
...nextPayload,
learned_count: Math.max(existing.learned_count, nextPayload.learned_count),
score: Math.max(existing.score, nextPayload.score),
ended: Math.max(existing.ended, nextPayload.ended)
}
} else {
queuedPayloads.push(nextPayload)
}
persistPendingPayloads()
syncStatus.value = 'pending'
if (syncTimer) clearTimeout(syncTimer)
if (ended) {
flushProgress()
} else {
syncTimer = setTimeout(flushProgress, 1000)
}
}
async function flushProgress() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
if (syncing || !queuedPayloads.length) return
const payload = queuedPayloads[0]
syncing = true
syncStatus.value = 'syncing'
try {
if (!connected.value) {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('未登录')
}
const res = await api({
url: '/api/tcm/gameSubmitProgress',
method: 'POST',
data: payload
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '成绩保存失败')
}
applyLeaderboard(res.data, payload.session_key)
queuedPayloads = queuedPayloads.filter(item => item.session_key !== payload.session_key)
persistPendingPayloads()
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
persistPendingPayloads()
} finally {
syncing = false
if (queuedPayloads.length && connected.value) {
setTimeout(flushProgress, 80)
}
}
}
async function syncAndRefresh(learnedCount, score) {
queueProgress(learnedCount, score, false)
await flushProgress()
if (!connected.value) {
await connect()
if (queuedPayloads.length) await flushProgress()
} else {
try { await refreshLeaderboard() } catch (_) {}
}
}
async function recordShare() {
try {
if (!connected.value && !(await connect())) return
const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' })
if (res?.code === 1 && res.data?.invite_code) {
leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code }
}
} catch (_) {
// 分享本身仍可进行,统计失败不影响用户。
}
}
function dispose() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
persistPendingPayloads()
}
return {
connected,
loading,
syncStatus,
leaderboard,
confirmedSessionLearned,
connect,
beginSession,
queueProgress,
flushProgress,
syncAndRefresh,
refreshLeaderboard,
recordShare,
dispose
}
}
@@ -321,6 +321,7 @@
.eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; }
.eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; }
.eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; }
.eg-weekly-preview.is-offline { color: #69766f; background: #e8eeeb; }
.eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; }
.eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; }
.eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); }
@@ -334,6 +335,7 @@
.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; }
.eg-weekly-avatar-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
.eg-weekly-avatar.is-tone-2 { background: #e39932; }
.eg-weekly-avatar.is-tone-3 { background: #6c83c8; }
.eg-weekly-avatar.is-tone-4 { background: #e5683e; }
@@ -353,6 +355,8 @@
.eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; }
.eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; }
.eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; }
.eg-weekly-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-weekly-share::after { border: 0; }
.eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; }
.eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; }
@@ -389,6 +393,8 @@
.eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; }
.eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; }
.eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); }
.eg-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-share-btn::after { border: 0; }
.eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; }
@keyframes egPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); }
+131 -31
View File
@@ -197,9 +197,9 @@
<view>
<view class="eg-weekly-title-line">
<text class="eg-weekly-title">本周7人同行榜</text>
<text class="eg-weekly-preview">界面预览</text>
<text class="eg-weekly-preview" :class="{ 'is-offline': !platformConnected }">{{ weeklyStatusLabel }}</text>
</view>
<text class="eg-weekly-sub">{{ weeklyRangeLabel }} · 每周一更新</text>
<text class="eg-weekly-sub">{{ weeklyRangeLabel }} · {{ weeklySexLabel }} · {{ weeklyMemberCount }}/7</text>
</view>
<view class="eg-weekly-medal">{{ weeklyMyRank }}</view>
</view>
@@ -225,7 +225,10 @@
:class="{ 'is-me': player.isMe }"
>
<text class="eg-weekly-place">{{ player.rank }}</text>
<view class="eg-weekly-avatar" :class="`is-tone-${player.tone}`">{{ player.avatar }}</view>
<view class="eg-weekly-avatar" :class="`is-tone-${player.tone}`">
<image v-if="player.avatarUrl" class="eg-weekly-avatar-image" :src="player.avatarUrl" mode="aspectFill" />
<text v-else>{{ player.avatar }}</text>
</view>
<text class="eg-weekly-name">{{ player.name }}</text>
<text v-if="player.isMe" class="eg-weekly-me"></text>
<text class="eg-weekly-count">{{ player.count }}</text>
@@ -242,7 +245,8 @@
</view>
<view class="eg-weekly-start" @tap="closeWeeklyRank">开始消除</view>
<text class="eg-weekly-footnote">当前为排行效果预览真实好友数据接入后替换</text>
<button class="eg-weekly-share" open-type="share">邀请亲友一起认糖</button>
<text class="eg-weekly-footnote">使用平台昵称参加真实周榜每周一重新分组分享不会自动绑定家庭关系</text>
</view>
</view>
@@ -284,6 +288,7 @@
<view class="eg-result-score"><text>本局得分</text><text>{{ score }}</text></view>
<view class="eg-result-best"><text>历史最高</text><text>{{ bestScore }}</text></view>
<view class="eg-primary-btn" @tap="startGame">再玩一局</view>
<button class="eg-share-btn" open-type="share">分享本局认糖成绩</button>
<view class="eg-secondary-btn" @tap="goBack">返回血糖周报</view>
</view>
</view>
@@ -292,10 +297,12 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { computed, getCurrentInstance, ref } from 'vue'
import { 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'
import { useTongjiAuth } from '../composables/useTongjiAuth.js'
const FOOD_IMAGE_MODULES = import.meta.glob('./assets/food/*.jpg', {
eager: true,
@@ -318,20 +325,6 @@ const COMBO_PITY_TURNS = 6
const MEGA_COMBO_SCORE = 300
const DANGER_DROP_CHANCE = 0.05 + GAME_DIFFICULTY * 0.01
const COLUMN_GROUP_BIAS = 0.86 - GAME_DIFFICULTY * 0.028
const WEEKLY_PREVIEW_BASE = 208
const WEEKLY_PREVIEW_PLAYERS = [
{ id: 'li', name: '李阿姨', avatar: '李', count: 286, tone: 1 },
{ id: 'health', name: '健康每一天', avatar: '健', count: 248, tone: 2 },
{ id: 'wang', name: '王师傅', avatar: '王', count: 226, tone: 3 },
{ id: 'me', name: '我', avatar: '我', count: 0, tone: 4, isMe: true },
{ id: 'happy', name: '快乐生活', avatar: '乐', count: 191, tone: 5 },
{ id: 'chen', name: '陈叔', avatar: '陈', count: 174, tone: 6 },
{ id: 'sunny', name: '天天好心情', avatar: '晴', count: 152, tone: 7 }
]
const WEEKLY_START_RANK = WEEKLY_PREVIEW_PLAYERS
.map(player => ({ ...player, count: player.isMe ? WEEKLY_PREVIEW_BASE : player.count }))
.sort((a, b) => b.count - a.count)
.findIndex(player => player.isMe) + 1
const CHEER_MESSAGES = [
{ source: '同行鼓励', text: '你需要加油哦!再向前一点,就能超过上一名。', tone: 'warm' },
{ source: '同行鼓励', text: '你太棒了!今天也在认真认识食物。', tone: 'bright' },
@@ -404,6 +397,23 @@ const {
warmUp: warmUpSfx
} = useGameSfx()
const { proxy } = getCurrentInstance()
const { ensureLoggedIn } = useTongjiAuth(proxy)
const {
connected: platformConnected,
loading: platformLoading,
syncStatus: platformSyncStatus,
leaderboard: platformLeaderboard,
confirmedSessionLearned,
connect: connectPlatform,
beginSession: beginPlatformSession,
queueProgress: queuePlatformProgress,
flushProgress: flushPlatformProgress,
syncAndRefresh: syncAndRefreshPlatform,
recordShare: recordPlatformShare,
dispose: disposePlatform
} = useGamePlatform(proxy, ensureLoggedIn)
const board = ref([])
const selected = ref(-1)
const score = ref(0)
@@ -441,7 +451,6 @@ const reshuffleDone = ref(false)
const reshuffling = ref(false)
const confettiVisible = ref(false)
const weeklyRankVisible = ref(false)
const weeklyPreviewProgress = ref(0)
const cheerIndex = ref(0)
const CONFETTI_COLORS = ['#ff3154', '#ffd928', '#28c76f', '#4d8dff', '#a855f7', '#ff7a1a']
const confettiPieces = Array.from({ length: 34 }, (_, index) => ({
@@ -466,6 +475,7 @@ let taskRewardTimer = null
let pendingThemeMatchCount = 0
let taskRewardStatusUntil = 0
let sfxGesturePrimed = false
let incomingInviteCode = ''
const currentTheme = computed(() => FOOD_GROUPS[themeIndex.value % FOOD_GROUPS.length])
const currentTask = computed(() => {
@@ -483,13 +493,38 @@ const highCount = computed(() => board.value.filter(tile => tile && foodOf(tile.
const boardRows = computed(() => Array.from({ length: ROWS }, (_, row) => (
board.value.slice(row * COLS, row * COLS + COLS).map((tile, col) => ({ ...tile, index: row * COLS + col }))
)))
const weeklyMyCount = computed(() => WEEKLY_PREVIEW_BASE + weeklyPreviewProgress.value)
const weeklyRankRows = computed(() => WEEKLY_PREVIEW_PLAYERS
.map(player => ({ ...player, count: player.isMe ? weeklyMyCount.value : player.count }))
.sort((a, b) => b.count - a.count)
.map((player, index) => ({ ...player, rank: index + 1 })))
const weeklyMyRank = computed(() => weeklyRankRows.value.find(player => player.isMe)?.rank || 7)
const weeklyRankRise = computed(() => Math.max(0, WEEKLY_START_RANK - weeklyMyRank.value))
const pendingWeeklyLearned = computed(() => Math.max(0, learned.value - confirmedSessionLearned.value))
const weeklyMyCount = computed(() => Number(platformLeaderboard.value.me?.count || 0) + pendingWeeklyLearned.value)
const weeklyRankRows = computed(() => {
const remote = Array.isArray(platformLeaderboard.value.players)
? platformLeaderboard.value.players
: []
const userData = uni.getStorageSync('userData') || {}
const fallbackName = String(userData.nickname || '我')
const source = remote.length
? remote.map((player, index) => ({
...player,
isMe: !!(player.is_me || player.isMe),
avatarUrl: player.avatar || '',
avatar: String(player.name || '同行').slice(0, 1),
tone: index % 7 + 1,
count: player.is_me || player.isMe ? weeklyMyCount.value : Number(player.count || 0)
}))
: [{
id: 'local-me',
name: fallbackName,
avatar: fallbackName.slice(0, 1) || '我',
avatarUrl: userData.avatar || '',
count: weeklyMyCount.value,
tone: 1,
isMe: true
}]
return 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 }))
})
const weeklyMyRank = computed(() => weeklyRankRows.value.find(player => player.isMe)?.rank || 1)
const weeklyRankRise = computed(() => 0)
const weeklyIsFirst = computed(() => weeklyMyRank.value === 1)
const weeklyDistance = computed(() => {
const mineIndex = weeklyRankRows.value.findIndex(player => player.isMe)
@@ -497,17 +532,60 @@ const weeklyDistance = computed(() => {
return Math.max(1, weeklyRankRows.value[mineIndex - 1].count - weeklyMyCount.value + 1)
})
const currentCheer = computed(() => CHEER_MESSAGES[cheerIndex.value % CHEER_MESSAGES.length])
const weeklyRangeLabel = computed(() => getWeekRangeLabel(new Date()))
const weeklyRangeLabel = computed(() => {
const start = platformLeaderboard.value.week_start
const end = platformLeaderboard.value.week_end
if (!start || !end) return getWeekRangeLabel(new Date())
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 weeklyStatusLabel = computed(() => {
if (platformLoading.value) return '连接中'
if (!platformConnected.value) return '离线记录中'
if (platformSyncStatus.value === 'pending' || platformSyncStatus.value === 'syncing') return '保存中'
return '真实周榜'
})
onLoad(() => {
onShareAppMessage(() => {
recordPlatformShare()
return {
title: gameShareTitle(),
path: gameSharePath()
}
})
onShareTimeline(() => {
recordPlatformShare()
const inviteCode = String(platformLeaderboard.value.invite_code || '')
return {
title: gameShareTitle(),
query: inviteCode ? `from=share&invite_code=${encodeURIComponent(inviteCode)}` : 'from=share'
}
})
onLoad((options = {}) => {
const info = uni.getSystemInfoSync()
safeTop.value = info.safeAreaInsets?.top || info.statusBarHeight || 20
bestScore.value = Number(uni.getStorageSync('tongji_endless_best') || 0)
incomingInviteCode = String(options.invite_code || '').trim()
startGame()
weeklyRankVisible.value = true
connectPlatform(incomingInviteCode)
})
onShow(() => {
connectPlatform(incomingInviteCode).then((ok) => {
if (ok) flushPlatformProgress()
})
})
onUnload(() => {
if (learned.value > 0 || gameOver.value) {
queuePlatformProgress(learned.value, score.value, gameOver.value)
flushPlatformProgress()
}
disposePlatform()
if (hintTimer) clearTimeout(hintTimer)
if (jackpotTimer) clearTimeout(jackpotTimer)
if (confettiTimer) clearTimeout(confettiTimer)
@@ -534,6 +612,25 @@ function getWeekRangeLabel(now) {
return `${label(monday)}${label(sunday)}`
}
function formatWeekDate(value) {
const parts = String(value).split('-').map(Number)
if (parts.length !== 3) return String(value)
return `${parts[1]}${parts[2]}`
}
function gameShareTitle() {
if (learned.value > 0) {
return `我在控糖消消乐认识了${learned.value}份食物,一起来挑战吧`
}
return '控糖消消乐:边玩边认识常见食物'
}
function gameSharePath() {
const inviteCode = String(platformLeaderboard.value.invite_code || '')
const query = inviteCode ? `?from=share&invite_code=${encodeURIComponent(inviteCode)}` : '?from=share'
return `/tongji/endless-game/index${query}`
}
function openWeeklyRank() {
primeSfxFromGesture()
if (gameOver.value) return
@@ -542,6 +639,7 @@ function openWeeklyRank() {
return
}
weeklyRankVisible.value = true
syncAndRefreshPlatform(learned.value, score.value)
playSfx('select', .72)
}
@@ -560,6 +658,7 @@ function cycleCheer() {
function startGame() {
if (hintTimer) clearTimeout(hintTimer)
beginPlatformSession()
themeIndex.value = 0
board.value = buildPlayableBoard(openingKeysForTheme(currentTheme.value))
selected.value = -1
@@ -798,6 +897,7 @@ async function trySwap(a, b, soundPlayed = false) {
endedFood.value = foodOf(highTile.key)
saveBest()
gameOver.value = true
queuePlatformProgress(learned.value, score.value, true)
busy.value = false
return
}
@@ -872,10 +972,10 @@ async function resolveMatches(initialGroups, movedIndex) {
vibrate(largestMatch >= 5 || wave >= 3 ? 'medium' : 'light')
turnConsumed += removedNormal.length
learned.value += removedNormal.length
weeklyPreviewProgress.value += removedNormal.length
updateFoodTask(removedNormal)
const multiplier = feastMoves.value > 0 ? 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)
const next = [...board.value]
remove.forEach(index => { next[index] = null })