feat(tongji): 接入小游戏真实周榜与微信分享
This commit is contained in:
@@ -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); }
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user