This commit is contained in:
Your Name
2026-06-03 11:39:36 +08:00
parent a3bae1555a
commit ec4e0b9f29
22 changed files with 1353 additions and 646 deletions
+211 -125
View File
@@ -67,11 +67,31 @@
<text class="gg-food-card-tip">{{ foodCard.tip }}</text>
</view>
<!-- 消除高糖红色警示弹窗短暂强调后进入结算 -->
<view v-if="highClearAlertVisible" class="gg-high-alert-overlay" @tap.stop>
<view class="gg-high-alert-card">
<view class="gg-high-alert-icon-wrap" aria-hidden="true">
<TongjiIcon name="alert-triangle" size="lg" color="#ffffff" />
</view>
<view class="gg-high-alert-badge-row">
<text class="gg-high-alert-badge">高糖</text>
<text class="gg-high-alert-badge-sub">红色标识 · 升糖快</text>
</view>
<text class="gg-high-alert-title">已消除高糖食物</text>
<text class="gg-high-alert-name">{{ clearedHighFoodName }}</text>
<text class="gg-high-alert-tip">请记住这类食物要少吃</text>
</view>
</view>
<!-- 学习结果 -->
<view v-if="gamePhase !== 'playing'" class="gg-result-overlay" @tap.stop>
<view class="gg-result-card">
<text class="gg-result-title">{{ gamePhase === 'won' ? '学会啦!' : '再认一认' }}</text>
<view v-if="gamePhase === 'won'" class="gg-result-stars">
<view class="gg-result-card" :class="{ 'is-high-clear': winReason === 'high_cleared' }">
<view v-if="winReason === 'high_cleared'" class="gg-result-high-head">
<text class="gg-result-high-badge">高糖</text>
<text class="gg-result-high-tag">已消除</text>
</view>
<text class="gg-result-title">{{ resultTitle }}</text>
<view v-if="gamePhase === 'won' && winReason !== 'high_cleared'" class="gg-result-stars">
<text
v-for="i in 3"
:key="'star-' + i"
@@ -80,16 +100,20 @@
></text>
</view>
<text class="gg-result-sub">{{ resultMessage }}</text>
<view v-if="resultStats.length" class="gg-result-stats">
<view v-if="resultStats.length" class="gg-result-stats" :class="{ 'is-high-clear': winReason === 'high_cleared' }">
<view v-for="(row, idx) in resultStats" :key="'rs-' + idx" class="gg-result-stat">
<text class="gg-result-stat-label">{{ row.label }}</text>
<text class="gg-result-stat-val">{{ row.value }}</text>
<text class="gg-result-stat-val" :class="{ 'is-high-val': winReason === 'high_cleared' && idx === 0 }">
<text v-if="winReason === 'high_cleared' && idx === 0" class="gg-result-inline-badge">高糖</text>
{{ row.value }}
</text>
</view>
</view>
<view class="gg-result-actions">
<view
v-if="gamePhase === 'won'"
class="gg-result-btn is-primary"
:class="{ 'is-high-primary': winReason === 'high_cleared' }"
@tap="goNextLevel"
>认下一组</view>
<view class="gg-result-btn" :class="{ 'is-primary': gamePhase !== 'won' }" @tap="restartLevel">
@@ -128,104 +152,49 @@
</view>
</view>
</view>
<!-- 核心数据面板三栏网格布局 -->
<view class="gg-hud-dashboard">
<!-- 左侧血糖监控指示器 -->
<view class="gg-dash-glucose">
<view class="gg-glucose-ring" :class="meterFillClass" :style="glucoseRingStyle">
<view class="gg-glucose-ring-inner">
<TongjiIcon name="glucose" size="sm" :color="glucoseAccentColor" />
<text class="gg-glucose-value">{{ glucoseLevel }}</text>
</view>
</view>
<view class="gg-glucose-detail">
<view class="gg-glucose-detail-head">
<text class="gg-glucose-title">血糖</text>
<text class="gg-glucose-status" :class="statusLabelClass">{{ glucoseShortStatus }}</text>
</view>
<view class="gg-meter-bar">
<view class="gg-meter-track">
<view class="gg-meter-zone is-low" />
<view class="gg-meter-zone is-ok" />
<view class="gg-meter-zone is-high" />
</view>
<view class="gg-meter-fill" :class="meterFillClass" :style="{ width: glucoseLevel + '%' }" />
<view class="gg-meter-pointer" :class="meterFillClass" :style="{ left: glucoseLevel + '%' }" />
</view>
<view class="gg-meter-legend">
<text class="is-low-text">偏低</text>
<text class="is-ok-text">稳定</text>
<text class="is-high-text">偏高</text>
</view>
</view>
</view>
<!-- 垂直分割线 1 -->
<view class="gg-dash-divider" />
<!-- 中间步数看板仅展示无失败惩罚 -->
<view class="gg-dash-moves">
<text class="gg-moves-label">步数</text>
<text class="gg-moves-val">{{ movesLeft }}</text>
</view>
<!-- 垂直分割线 2 -->
<view class="gg-dash-divider" />
<!-- 右侧关卡任务目标 -->
<view class="gg-dash-mission">
<view class="gg-mission-head">
<view class="gg-mission-title-wrap">
<text class="gg-mission-title">认识这些食物</text>
<text class="gg-mission-pct">{{ goalProgressPct }}%</text>
</view>
<view v-if="dailyStreak >= 2" class="gg-mission-streak">
<TongjiIcon name="flame" size="sm" color="#059669" />
<text>{{ dailyStreak }} </text>
</view>
</view>
<view class="gg-mission-goals">
<view
v-for="g in goalList"
:key="g.key"
class="gg-goal-chip"
:class="{ 'is-done': g.done }"
>
<view class="gg-goal-icon-wrap">
<FoodTileIcon :food="g.food" size="goal" />
</view>
<view class="gg-goal-chip-body">
<view class="gg-goal-chip-bar">
<view
class="gg-goal-chip-fill"
:style="{ width: Math.min(100, Math.round((g.current / g.target) * 100)) + '%' }"
/>
</view>
<text class="gg-goal-chip-text">{{ g.current }}/{{ g.target }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 底部棋盘说明认知导向无倒计时 -->
<view v-if="gamePhase === 'playing'" class="gg-board-hint-bar">
<view class="gg-board-hint-left">
<view class="gg-hint-tag">认糖</view>
<text class="gg-board-hint-text">点一下食物看它含糖高不高</text>
</view>
</view>
</view>
<!-- 棋盘 -->
<!-- 主区棋盘为主辅助信息弱化 -->
<view class="gg-main" :style="mainStyle">
<view class="gg-board-stack" :style="boardStackStyle">
<!-- 仅展示上次消除的食物与含糖量 -->
<view v-if="gamePhase === 'playing'" class="gg-hud-last-food">
<text class="gg-hud-last-food-kicker">上次消除</text>
<view
v-if="lastClearedFood"
class="gg-hud-last-food-card"
:class="'is-' + lastClearedFood.gi"
>
<view class="gg-hud-last-food-icon">
<FoodTileIcon :food="lastClearedFood.food" size="goal" />
</view>
<view class="gg-hud-last-food-body">
<text class="gg-hud-last-food-name">{{ lastClearedFood.name }}</text>
<view class="gg-hud-last-food-sugar-row">
<text class="gg-hud-last-food-sugar-label">含糖量</text>
<text
class="gg-hud-last-food-gi"
:class="'is-' + lastClearedFood.gi"
:style="{ backgroundColor: lastClearedFood.color }"
>{{ lastClearedFood.giShort }}</text>
<text class="gg-hud-last-food-sugar-hint">{{ lastClearedFood.eatHint }}</text>
</view>
</view>
</view>
<view v-else class="gg-hud-last-food-placeholder">
<text>三连消除后这里显示食物名称与含糖量</text>
</view>
</view>
<!-- 棋盘 -->
<view class="gg-board-hero">
<view v-if="gamePhase === 'playing'" class="gg-board-hint-inline">
<text class="gg-hint-tag">认糖</text>
<text class="gg-board-hint-text">点食物看含糖 · 消掉红色即过关</text>
</view>
<view class="gg-board-stack" :style="boardStackStyle">
<view class="gg-board-zone">
<view v-if="hintTipVisible" class="gg-no-move-tip">
<TongjiIcon name="alert-circle" size="sm" color="#ea580c" />
<TongjiIcon name="alert-triangle" size="sm" color="#ea580c" />
<text class="gg-no-move-tip-text">{{ hintTipText }}</text>
</view>
<view class="gg-board-wrap" :style="boardWrapStyle">
@@ -284,11 +253,12 @@
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 底部道具 -->
<view class="gg-footer gg-glass" :style="footerStyle">
<!-- 道具栏弱化 -->
<view class="gg-footer gg-glass gg-footer--sub" :style="footerStyle">
<view class="gg-boosters">
<view
v-for="booster in boosters"
@@ -314,7 +284,15 @@ import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, ref } f
import { onLoad, onReady } from '@dcloudio/uni-app'
import TongjiIcon from '../components/TongjiIcon.vue'
import FoodTileIcon from '../components/FoodTileIcon.vue'
import { FOOD_LIBRARY, cloneFoodType, getClearVal, getFoodTip, GI_SHORT, GI_COLOR } from '../data/gameFoodLibrary.js'
import {
FOOD_LIBRARY,
cloneFoodType,
getClearVal,
getFoodTip,
formatEatLabel,
GI_SHORT,
GI_COLOR
} from '../data/gameFoodLibrary.js'
import { warmUpFoodImgs } from '../data/gameFoodImg.js'
import { useGameSfx } from '../composables/useGameSfx.js'
import { useGameFx } from '../composables/useGameFx.js'
@@ -341,11 +319,11 @@ const sessionMaxCombo = ref(0)
const menuHeight = ref(32)
const menuTop = ref(0)
const ROWS = 6
const ROWS = 5
const COLS = 4
const ROW_AXES = Array.from({ length: ROWS }, (_, i) => i)
const COL_AXES = Array.from({ length: COLS }, (_, i) => i)
/** 6 行 × 4 列棋盘:单格像素由可用区域反算 */
/** 5 行 × 4 列棋盘:单格像素由可用区域反算 */
const BOARD_INSET_RPX = 4
/** 棋盘与底部道具栏之间的安全间隙,避免最后一行被遮挡 */
const BOARD_FOOTER_GAP_RPX = 28
@@ -366,9 +344,11 @@ const dragPreview = ref(null)
let tileTouchStart = null
let touchHandledInteraction = false
// 每局选中的食材子集(保证消除可玩,并让每次进入素材不同
const SESSION_FOODS = { low: 3, mid: 2, high: 2 }
// 每局选中的食材子集;高糖仅 1 种(消除该种即通关
const SESSION_FOODS = { low: 3, mid: 2, high: 1 }
const activeFoods = ref([])
/** 本局唯一的高糖食物类型 */
const sessionHighFood = ref(null)
const goalDefs = ref([])
function pickRandom(pool, n) {
@@ -382,17 +362,20 @@ function pickRandom(pool, n) {
/**
* 随机挑选本局食材与收集目标。每次调用结果不同:
* 低/中/高 各取若干,组成 7 种棋子;再从中挑 2 种作为收集目标。
* 低/中各若干 + 仅 1 种高糖;再从中挑 2 种作为收集目标。
*/
function selectSessionFoods() {
const lows = FOOD_LIBRARY.filter((f) => f.gi === 'low')
const mids = FOOD_LIBRARY.filter((f) => f.gi === 'mid')
const highs = FOOD_LIBRARY.filter((f) => f.gi === 'high')
const highPick = pickRandom(highs, 1)
sessionHighFood.value = highPick[0] ? cloneFoodType(highPick[0]) : null
const selected = [
...pickRandom(lows, SESSION_FOODS.low),
...pickRandom(mids, SESSION_FOODS.mid),
...pickRandom(highs, SESSION_FOODS.high)
...highPick
]
activeFoods.value = selected
warmUpFoodImgs(selected)
@@ -507,8 +490,21 @@ const selectedUid = ref(null)
const matchChain = ref(0)
const movesLeft = ref(25)
const gamePhase = ref('playing')
/** goals | high_cleared */
const winReason = ref('')
const loseReason = ref('')
/** 消除高糖时的红色警示弹窗 */
const highClearAlertVisible = ref(false)
const clearedHighFoodName = ref('')
const resultTitle = computed(() => {
if (gamePhase.value !== 'won') return '再认一认'
if (winReason.value === 'high_cleared') return '高糖已消除!'
return '学会啦!'
})
const resultMessage = ref('')
/** 上一次三连消除的食物(摘要条展示) */
const lastClearedFood = ref(null)
const foodCard = ref(null)
let foodCardTimer = null
const floatingVals = ref([])
@@ -666,14 +662,15 @@ const glucoseShortStatus = computed(() => {
const glucoseAccentColor = computed(() => {
if (glucoseLevel.value < 30) return '#3b82f6'
if (glucoseLevel.value > 70) return '#dc2626'
return '#006c49'
return '#ea580c'
})
const glucoseRingStyle = computed(() => {
const pct = glucoseLevel.value
const color = glucoseAccentColor.value
const track = glucoseLevel.value < 30 || glucoseLevel.value > 70 ? '#e2ece6' : '#ffedd5'
return {
background: `conic-gradient(${color} 0 ${pct}%, #e2ece6 ${pct}% 100%)`
background: `conic-gradient(${color} 0 ${pct}%, ${track} ${pct}% 100%)`
}
})
@@ -723,6 +720,17 @@ function randomFood() {
return cloneFoodType(pick)
}
/** 本局棋盘上是否还存在高糖食物 */
function boardHasHighGiFood() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && !tile.isRemoved && tile.type.gi === 'high') return true
}
}
return false
}
function wouldCreateInitialMatch(r, c, type, currentBoard, currentRow) {
const key = type.key
if (r >= 2 && currentBoard[r - 1][c]?.type.key === key && currentBoard[r - 2][c]?.type.key === key) {
@@ -833,7 +841,11 @@ function setupLevel(levelId) {
const cfg = getLevelConfig(levelId)
applyLevelConfig(cfg)
sessionMaxCombo.value = 0
winReason.value = ''
loseReason.value = ''
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
lastClearedFood.value = null
tileTouchStart = null
clearDragPreview()
selectSessionFoods()
@@ -869,6 +881,10 @@ function recomputeGlucoseFromBoard() {
function restartLevel() {
gamePhase.value = 'playing'
winReason.value = ''
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
lastClearedFood.value = null
resultMessage.value = ''
resultStars.value = 0
resultStats.value = []
@@ -884,6 +900,9 @@ function restartLevel() {
function goNextLevel() {
gamePhase.value = 'playing'
winReason.value = ''
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
resultMessage.value = ''
resultStars.value = 0
resultStats.value = []
@@ -1407,6 +1426,24 @@ function updateGoal(key) {
}
}
/** 记录本批消除中代表性食物(取最大匹配组) */
function recordLastClearedFood(groups) {
if (!Array.isArray(groups) || !groups.length) return
const main = groups.reduce((a, b) => ((b.size || 0) > (a.size || 0) ? b : a))
const rep = main?.tiles?.[Math.floor((main.tiles.length || 1) / 2)]
const t = rep?.type
if (!t) return
const gi = t.gi || 'low'
lastClearedFood.value = {
name: t.name || '食物',
gi,
giShort: GI_SHORT[gi] || '低糖',
eatHint: formatEatLabel(t),
color: GI_COLOR[gi] || '#64748b',
food: cloneFoodType(t)
}
}
function updateScore(pts) {
const bonus = Math.round(pts)
score.value += bonus
@@ -1448,10 +1485,28 @@ function showLearnFx(groups) {
function checkGameEnd() {
if (gamePhase.value !== 'playing') return
// 无失败惩罚:仅在认全目标食物通关,步数耗尽不判负
// 无失败惩罚:认全目标或已消除场上全部高糖食物通关
if (allGoalsDone.value) {
endGame('won')
endGame('won', { reason: 'goals' })
return
}
if (sessionHighFood.value && !boardHasHighGiFood()) {
endGame('won', {
reason: 'high_cleared',
foodName: sessionHighFood.value.name
})
}
}
/** 三连消除含高糖:先红色警示弹窗,再进入结算 */
async function finishHighSugarClear(foodType) {
const name = foodType?.name || sessionHighFood.value?.name || '高糖食物'
clearedHighFoodName.value = name
highClearAlertVisible.value = true
triggerFlash('rgba(220, 38, 38, 0.22)', 0.35)
await delay(2000)
highClearAlertVisible.value = false
endGame('won', { reason: 'high_cleared', foodName: name })
}
function buildResultStats() {
@@ -1463,11 +1518,12 @@ function buildResultStats() {
]
}
function endGame(phase) {
function endGame(phase, opts = {}) {
gamePhase.value = phase
clearHints()
if (phase === 'won') {
winReason.value = opts.reason || 'goals'
const { stars, newBest } = recordWin({
levelId: levelConfig.value.id,
movesLeft: movesLeft.value,
@@ -1477,14 +1533,25 @@ function endGame(phase) {
maxCombo: sessionMaxCombo.value
})
resultStars.value = stars
const learnedNames = goalList.value.map((g) => g.food?.name).filter(Boolean).join('、')
resultMessage.value = learnedNames
? `这一组您认识了:${learnedNames}。记住它们的含糖高低啦!`
: '又认识了几种食物的含糖高低,真棒!'
if (newBest && gameProgress.value.maxUnlocked > levelConfig.value.id) {
resultMessage.value += ` 已解锁第 ${levelConfig.value.id + 1} 组。`
if (winReason.value === 'high_cleared') {
const name = opts.foodName || sessionHighFood.value?.name || '高糖食物'
resultMessage.value =
`您已消除高糖食物「${name}」!这类食物升糖快,日常要少吃。`
resultStats.value = [
{ label: '本局高糖食物', value: name },
{ label: '消除练习', value: '达标' },
{ label: '得分', value: scoreText.value }
]
} else {
const learnedNames = goalList.value.map((g) => g.food?.name).filter(Boolean).join('、')
resultMessage.value = learnedNames
? `这一组您认识了:${learnedNames}。记住它们的含糖高低啦!`
: '又认识了几种食物的含糖高低,真棒!'
if (newBest && gameProgress.value.maxUnlocked > levelConfig.value.id) {
resultMessage.value += ` 已解锁第 ${levelConfig.value.id + 1} 组。`
}
resultStats.value = buildResultStats()
}
resultStats.value = buildResultStats()
playSfxLayer('win', 'winStinger', 120, 0.7)
} else {
recordLoss()
@@ -1545,7 +1612,11 @@ async function processSwap(t1, t2, opts = {}) {
const matches = findMatches()
if (matches.length > 0) {
movesLeft.value = Math.max(0, movesLeft.value - 1)
await clearAndRefill()
const endedByHigh = await clearAndRefill()
if (endedByHigh) {
isProcessing.value = false
return
}
// 所有连消结束后,检查棋盘是否死锁并自动重排
await reshuffleIfStuck()
} else {
@@ -1561,7 +1632,10 @@ async function processSwap(t1, t2, opts = {}) {
async function clearAndRefill() {
const { groups, tiles } = findMatchGroups()
if (!tiles.length) return
if (!tiles.length) return false
const clearedHighTiles = tiles.filter((t) => t.type.gi === 'high')
recordLastClearedFood(groups)
let totalVal = 0
let maxSize = 3
@@ -1600,14 +1674,22 @@ async function clearAndRefill() {
await delay(MATCH_MS)
removeTilesFromBoard(tiles)
if (clearedHighTiles.length > 0) {
await finishHighSugarClear(clearedHighTiles[0].type)
return true
}
await applyGravity()
const next = findMatchGroups()
if (next.tiles.length > 0) {
matchChain.value += 1
await delay(200)
await clearAndRefill()
const ended = await clearAndRefill()
if (ended) return true
}
return false
}
async function applyGravity() {
@@ -1771,7 +1853,11 @@ async function onBooster(booster) {
const matches = findMatches()
if (matches.length > 0) {
await clearAndRefill()
const endedByHigh = await clearAndRefill()
if (endedByHigh) {
isProcessing.value = false
return
}
}
// fiber 改变了棋盘,保险检查是否死锁
await reshuffleIfStuck()
@@ -1937,7 +2023,7 @@ function initLayout() {
paddingTop: `${paddingTop}px`,
paddingLeft: '32rpx',
paddingRight: '32rpx',
paddingBottom: '16rpx'
paddingBottom: '10rpx'
}
navStyle.value = {
paddingRight: `${Math.max(0, headerPaddingRight - uni.upx2px(32))}px`
@@ -1971,7 +2057,7 @@ function recalcBoardSize() {
// 预留底部安全间隙,棋盘不贴到道具栏
const zoneH = Math.max(0, footerTop - headerBottom - footerGap)
// 6 行 × 4 列:宽高分别约束,取较小单格像素
// 5 行 × 4 列:宽高分别约束,取较小单格像素
cellSizePx.value = calcMaxCellSize(zoneW - inset * 2, zoneH - inset * 2)
const boardDim = calcBoardDimensions(cellSizePx.value)
boardWidthPx.value = boardDim.width