Files
zyt/TUICallKit-Vue3/tongji/pages/game.vue
T
2026-06-03 11:39:36 +08:00

2119 lines
69 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="game-page" :class="{ 'is-screen-shake': screenShaking }" :style="pageStyle">
<!-- 消除特效层 -->
<view class="gg-fx-layer" aria-hidden="true">
<!-- 大消除闪屏 -->
<view
v-if="flash.active"
class="gg-screen-flash"
:style="{ backgroundColor: flash.color, opacity: flash.opacity }"
/>
<view
v-for="rp in ripples"
:key="rp.id"
class="gg-match-ripple"
:style="{
left: rp.left + 'px',
top: rp.top + 'px',
width: rp.size + 'px',
height: rp.size + 'px',
borderColor: rp.color,
borderWidth: rp.thickness + 'px',
boxShadow: '0 0 ' + (rp.thickness * 4) + 'px ' + rp.color + ', inset 0 0 ' + (rp.thickness * 3) + 'px ' + rp.color
}"
/>
<view
v-for="p in burstParticles"
:key="p.id"
class="gg-particle"
:class="{ 'is-star': p.char }"
:style="{
width: p.size + 'px',
height: p.size + 'px',
left: p.left + 'px',
top: p.top + 'px',
backgroundColor: p.char ? 'transparent' : p.color,
color: p.color,
fontSize: p.size + 'px',
opacity: p.opacity,
transform: p.transform,
transition: p.transition
}"
>
<text v-if="p.char">{{ p.char }}</text>
</view>
<view
v-for="ft in floatingVals"
:key="ft.id"
class="gg-floating-val"
:style="{ left: ft.x + 'px', top: ft.y + 'px', color: ft.color }"
>{{ ft.text }}</view>
<!-- 连消横幅 -->
<view
v-if="comboBanner.show"
class="gg-combo-banner"
:class="'is-tier-' + comboBanner.tier"
>
<text>{{ comboBanner.text }}</text>
</view>
</view>
<!-- 点击食物的大字科普卡名称 + 含糖等级 + 一句话说明 -->
<view v-if="foodCard" class="gg-food-card" :class="'is-' + foodCard.gi">
<view class="gg-food-card-head">
<text class="gg-food-card-name">{{ foodCard.name }}</text>
<text class="gg-food-card-gi" :style="{ backgroundColor: foodCard.color }">{{ foodCard.giShort }}</text>
</view>
<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" :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"
class="gg-result-star"
:class="{ 'is-lit': i <= resultStars }"
></text>
</view>
<text class="gg-result-sub">{{ resultMessage }}</text>
<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" :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">
再认一遍
</view>
<view class="gg-result-btn" @tap="goBack">返回</view>
</view>
</view>
</view>
<!-- Header HUD -->
<view class="gg-header gg-glass" :style="headerStyle">
<!-- 顶部导航与全局状态 -->
<view class="gg-hud-nav" :style="navStyle">
<!-- 左侧控制组 -->
<view class="gg-hud-control-group">
<view class="gg-hud-btn" hover-class="gg-hud-btn--pressed" @tap="goBack">
<TongjiIcon name="chevron-left" size="md" color="#006c49" />
</view>
<view
class="gg-hud-btn"
:class="{ 'is-muted': !sfxEnabled }"
hover-class="gg-hud-btn--pressed"
@tap="onSoundToggle"
>
<TongjiIcon name="volume" size="md" :color="sfxEnabled ? '#006c49' : '#94a3b8'" />
</view>
</view>
<!-- 绝对居中的关卡与得分 -->
<view class="gg-hud-level-center">
<text class="gg-hud-level-text"> {{ levelConfig.id }} </text>
<view class="gg-hud-score" :class="{ 'is-pop': scoreAnimating }">
<text class="gg-hud-score-val">{{ scoreText }}</text>
<text class="gg-hud-score-unit"></text>
</view>
</view>
</view>
</view>
<!-- 主区棋盘为主辅助信息弱化 -->
<view class="gg-main" :style="mainStyle">
<!-- 仅展示上次消除的食物与含糖量 -->
<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-triangle" size="sm" color="#ea580c" />
<text class="gg-no-move-tip-text">{{ hintTipText }}</text>
</view>
<view class="gg-board-wrap" :style="boardWrapStyle">
<!-- 提示交换箭头悬浮在两个提示食物正中间 -->
<view
v-if="hintArrowInfo"
class="gg-hint-arrow"
:style="{ left: hintArrowInfo.midX + 'px', top: hintArrowInfo.midY + 'px' }"
>
{{ hintArrowInfo.char }}
</view>
<view class="game-grid" id="game-board" :style="gridStyle" @touchmove.stop.prevent="onBoardTouchMove">
<view
v-for="row in gridRows"
:key="'row-' + row.r"
class="game-row"
:style="rowStyle"
>
<view
v-for="cell in row.cols"
:key="'cell-' + cell.r + '-' + cell.c"
:id="'cell-' + cell.r + '-' + cell.c"
class="game-cell"
:style="cellStyle"
>
<view
v-if="cell.tile"
:key="cell.tile.uid"
:id="'tile-' + cell.tile.uid"
class="game-tile"
hover-class="game-tile--pressed"
:style="[tileStyle(cell.tile.type), tileDragStyle(cell.tile)]"
:class="{
'is-selected': selectedUid === cell.tile.uid,
'is-matched': cell.tile.isMatched,
'is-hint': hintUids.has(cell.tile.uid),
'is-match-fx-3': cell.tile.matchFx === 3,
'is-match-fx-4': cell.tile.matchFx === 4,
'is-match-fx-5': cell.tile.matchFx >= 5,
'is-drop-start': cell.tile.isDropStart,
'is-drop-animate': cell.tile.isDropAnimate,
'is-dragging': dragPreview?.tileUid === cell.tile.uid,
'is-drag-neighbor': dragPreview?.neighborUid === cell.tile.uid
}"
@tap.stop="onTileTap(cell.tile)"
@touchstart.stop="onTileTouchStart($event, cell.tile)"
@touchmove.stop.prevent="onTileTouchMove($event, cell.tile)"
@touchend.stop="onTileTouchEnd($event, cell.tile)"
@touchcancel.stop="onTileTouchCancel"
>
<FoodTileIcon :food="cell.tile.type" size="tile" />
<view class="gg-tile-gi" :class="'is-' + cell.tile.type.gi">{{ giChar(cell.tile.type.gi) }}</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 道具栏弱化 -->
<view class="gg-footer gg-glass gg-footer--sub" :style="footerStyle">
<view class="gg-boosters">
<view
v-for="booster in boosters"
:key="booster.id"
class="gg-booster-btn"
hover-class="gg-booster-btn--pressed"
:class="{ 'is-disabled': booster.count <= 0 }"
@tap="onBooster(booster)"
>
<view class="gg-booster-count" :class="booster.countClass">{{ booster.count }}</view>
<view class="gg-booster-icon">
<TongjiIcon :name="booster.icon" size="xl" :color="booster.color" />
</view>
<text class="gg-booster-label">{{ booster.label }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, ref } from 'vue'
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,
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'
import { useGameProgress } from '../composables/useGameProgress.js'
import { getLevelConfig } from '../data/gameLevels.js'
const {
enabled: sfxEnabled,
play: playSfx,
playLayer: playSfxLayer,
playMatch: playMatchSfx,
playDrop: playDropSfx,
playMeter: playMeterSfx,
warmUp: warmUpSfx,
toggleEnabled: toggleSfx
} = useGameSfx()
const { burstParticles, ripples, flash, comboBanner, spawnBurst, spawnRipple, triggerFlash, showComboBanner } = useGameFx()
const { progress: gameProgress, touchDailyPlay, recordWin, recordLoss } = useGameProgress()
const levelConfig = ref(getLevelConfig(1))
const resultStars = ref(0)
const resultStats = ref([])
const sessionMaxCombo = ref(0)
const menuHeight = ref(32)
const menuTop = ref(0)
const ROWS = 5
const COLS = 4
const ROW_AXES = Array.from({ length: ROWS }, (_, i) => i)
const COL_AXES = Array.from({ length: COLS }, (_, i) => i)
/** 5 行 × 4 列棋盘:单格像素由可用区域反算 */
const BOARD_INSET_RPX = 4
/** 棋盘与底部道具栏之间的安全间隙,避免最后一行被遮挡 */
const BOARD_FOOTER_GAP_RPX = 28
const GRID_PAD_RPX = 8
const GRID_GAP_RPX = 8
const SWAP_MS = 280
const MATCH_MS = 400
const GRAVITY_MS = 300
const HINT_DELAY_MS = 5000
const TAP_SLOP_PX = 10
const SWIPE_AXIS_LOCK_PX = 8
const SWIPE_COMMIT_RATIO = 0.22
/** 滑动跟手预览:主格 + 相邻格联动位移 */
const dragPreview = ref(null)
/** 触摸滑动:记录起点,避免与 @tap 重复触发 */
let tileTouchStart = null
let touchHandledInteraction = false
// 每局选中的食材子集;高糖仅 1 种(消除该种即通关)
const SESSION_FOODS = { low: 3, mid: 2, high: 1 }
const activeFoods = ref([])
/** 本局唯一的高糖食物类型 */
const sessionHighFood = ref(null)
const goalDefs = ref([])
function pickRandom(pool, n) {
const arr = [...pool]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr.slice(0, Math.min(n, arr.length))
}
/**
* 随机挑选本局食材与收集目标。每次调用结果不同:
* 低/中各若干 + 仅 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),
...highPick
]
activeFoods.value = selected
warmUpFoodImgs(selected)
// 从本局食材中挑 2 种作为收集目标;数量随关卡递增
const goalFoods = pickRandom(selected, 2)
const targets = levelConfig.value.goalTargets || [10, 12]
goalDefs.value = goalFoods.map((f, i) => ({ key: f.key, food: cloneFoodType(f), target: targets[i] ?? 10 }))
rebuildGoals()
}
function rebuildGoals() {
goals.value = goalDefs.value.reduce((acc, g) => {
acc[g.key] = { key: g.key, current: 0, target: g.target }
return acc
}, {})
}
let uidSeq = 0
function nextUid() {
uidSeq += 1
return `t-${uidSeq}`
}
// 格子无底色,仅保留食物图标
function tileStyle() {
return { background: 'transparent' }
}
/** 棋子角标用:低 / 中 / 高(含糖等级) */
function giChar(gi) {
return gi === 'high' ? '高' : gi === 'mid' ? '中' : '低'
}
function gridPadGapPx() {
return {
padPx: Math.round(uni.upx2px(GRID_PAD_RPX)),
gapPx: Math.round(uni.upx2px(GRID_GAP_RPX))
}
}
function calcBoardDimensions(cellPx) {
const { padPx, gapPx } = gridPadGapPx()
return {
width: COLS * cellPx + 2 * padPx + (COLS - 1) * gapPx,
height: ROWS * cellPx + 2 * padPx + (ROWS - 1) * gapPx
}
}
function calcMaxCellSize(zoneW, zoneH) {
const { padPx, gapPx } = gridPadGapPx()
const fromW = (zoneW - 2 * padPx - (COLS - 1) * gapPx) / COLS
const fromH = (zoneH - 2 * padPx - (ROWS - 1) * gapPx) / ROWS
return Math.max(24, Math.floor(Math.min(fromW, fromH)))
}
function getCellMetrics() {
const cell = cellSizePx.value
const { gapPx } = gridPadGapPx()
if (cell <= 0) {
return { cell: 40, stride: 46 }
}
return { cell, stride: cell + gapPx }
}
function tileDragStyle(tile) {
void layoutTick.value
const d = dragPreview.value
if (!d || !tile) return {}
const easing = d.useTransition
? `transform ${SWAP_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`
: 'none'
if (d.tileUid === tile.uid) {
const scale = d.active ? 1.07 : 1
return {
transform: `translate3d(${d.offsetX}px, ${d.offsetY}px, 0) scale(${scale})`,
transition: easing,
zIndex: 20
}
}
if (d.neighborUid === tile.uid) {
return {
transform: `translate3d(${d.nOffsetX}px, ${d.nOffsetY}px, 0)`,
transition: easing,
zIndex: 15
}
}
return {}
}
const instance = getCurrentInstance()
const headerStyle = ref({})
const navStyle = ref({})
const mainStyle = ref({})
const footerStyle = ref({})
const pageStyle = ref({})
const boardWidthPx = ref(0)
const boardHeightPx = ref(0)
const cellSizePx = ref(0)
const layoutTick = ref(0)
const hintTipVisible = ref(false)
const hintTipText = ref('')
const board = ref([])
const score = ref(0)
const scoreAnimating = ref(false)
const glucoseLevel = ref(50)
const screenShaking = ref(false)
const isProcessing = ref(false)
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([])
const hintUids = ref(new Set())
const hintMove = ref(null) // { t1, t2, isHorizontal }
let hintTimer = null
let startedSound = false
// 目标列表在 selectSessionFoods() 中按本局食材动态生成
const goals = ref({})
const boosters = ref([
{ id: 'insulin', count: 3, label: '胰岛素', icon: 'syringe', color: '#2ecc71', countClass: 'is-green' },
{ id: 'fiber', count: 1, label: '纤维', icon: 'leaf', color: '#f97316', countClass: 'is-orange' },
{ id: 'meal', count: 5, label: '食谱', icon: 'utensils', color: '#f43f5e', countClass: 'is-rose' }
])
const boardWrapStyle = computed(() => {
const w = boardWidthPx.value
const h = boardHeightPx.value
if (w > 0 && h > 0) {
return { width: `${w}px`, height: `${h}px`, flexShrink: '0' }
}
return { width: '100%', maxWidth: '100%' }
})
/** 提示条 / 棋盘共用宽度,保证对齐居中 */
const boardStackStyle = computed(() => {
const w = boardWidthPx.value
if (w > 0) {
return { width: `${w}px`, maxWidth: '100%', flexShrink: '0', boxSizing: 'border-box' }
}
return { width: '100%', maxWidth: '100%', boxSizing: 'border-box' }
})
/**
* 根据棋盘尺寸与单格像素,计算两个提示方块的中心坐标
* 返回 { midX, midY, isHorizontal, char } 供模板渲染箭头
*/
const gridStyle = computed(() => {
const { padPx, gapPx } = gridPadGapPx()
const w = boardWidthPx.value
const h = boardHeightPx.value
const style = {
padding: `${padPx}px`,
gap: `${gapPx}px`
}
if (w > 0 && h > 0) {
style.width = `${w}px`
style.height = `${h}px`
}
return style
})
const rowStyle = computed(() => {
const { gapPx } = gridPadGapPx()
return { gap: `${gapPx}px` }
})
const cellStyle = computed(() => {
const cell = cellSizePx.value
if (cell <= 0) return {}
return {
width: `${cell}px`,
height: `${cell}px`
}
})
const hintArrowInfo = computed(() => {
if (!hintMove.value || boardWidthPx.value <= 0 || cellSizePx.value <= 0) return null
const { t1, t2, isHorizontal } = hintMove.value
const { padPx, gapPx } = gridPadGapPx()
const cellSize = cellSizePx.value
// 计算两个方块的中心坐标
const cx1 = padPx + t1.c * (cellSize + gapPx) + cellSize / 2
const cy1 = padPx + t1.r * (cellSize + gapPx) + cellSize / 2
const cx2 = padPx + t2.c * (cellSize + gapPx) + cellSize / 2
const cy2 = padPx + t2.r * (cellSize + gapPx) + cellSize / 2
return {
midX: (cx1 + cx2) / 2,
midY: (cy1 + cy2) / 2,
isHorizontal,
char: isHorizontal ? '⇄' : '⇅'
}
})
const gridRows = computed(() => {
void layoutTick.value
return ROW_AXES.map((r) => ({
r,
cols: COL_AXES.map((c) => {
const tile = board.value[r]?.[c]
return {
r,
c,
tile: tile && !tile.isRemoved ? tile : null
}
})
}))
})
const scoreText = computed(() => score.value.toLocaleString('zh-CN'))
const dailyStreak = computed(() => gameProgress.value.dailyStreak || 0)
const goalProgressPct = computed(() => {
const list = goalList.value
if (!list.length) return 0
const total = list.reduce((s, g) => s + g.target, 0)
const cur = list.reduce((s, g) => s + g.current, 0)
return Math.min(100, Math.round((cur / total) * 100))
})
const goalList = computed(() =>
goalDefs.value.map((g) => {
const cur = goals.value[g.key]?.current ?? 0
return {
key: g.key,
food: g.food,
current: cur,
target: g.target,
done: cur >= g.target
}
})
)
const allGoalsDone = computed(() =>
goalList.value.length > 0 && goalList.value.every((g) => g.done)
)
const glucoseStatusText = computed(() => {
if (glucoseLevel.value < 30) return '血糖偏低'
if (glucoseLevel.value > 70) return '血糖过高'
return '血糖稳定'
})
const meterFillClass = computed(() => {
if (glucoseLevel.value < 30) return 'is-low'
if (glucoseLevel.value > 70) return 'is-high'
return 'is-stable'
})
const statusLabelClass = computed(() => {
if (glucoseLevel.value < 30) return 'is-warn-low'
if (glucoseLevel.value > 70) return 'is-warn-high'
return ''
})
const glucoseShortStatus = computed(() => {
if (glucoseLevel.value < 30) return '偏低'
if (glucoseLevel.value > 70) return '偏高'
return '稳定'
})
const glucoseAccentColor = computed(() => {
if (glucoseLevel.value < 30) return '#3b82f6'
if (glucoseLevel.value > 70) return '#dc2626'
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}%, ${track} ${pct}% 100%)`
}
})
function bumpLayout() {
layoutTick.value += 1
}
function syncTilePos(tile, r, c) {
tile.r = r
tile.c = c
}
function getBoardTile(r, c) {
return board.value[r]?.[c] || null
}
function setBoardTile(r, c, tile) {
if (!board.value[r]) {
board.value[r] = Array(COLS).fill(null)
}
board.value[r][c] = tile || null
if (tile) syncTilePos(tile, r, c)
}
function removeTilesFromBoard(tiles) {
const removed = new Set(tiles)
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && removed.has(tile)) {
tile.isRemoved = true
tile.isMatched = false
tile.matchFx = 0
}
}
}
bumpLayout()
}
function updateGridMetrics() {
bumpLayout()
}
function randomFood() {
const pool = activeFoods.value.length ? activeFoods.value : FOOD_LIBRARY
const pick = pool[Math.floor(Math.random() * pool.length)]
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) {
return true
}
if (c >= 2 && currentRow[c - 1]?.type.key === key && currentRow[c - 2]?.type.key === key) {
return true
}
return false
}
function createTile(r, c, type) {
return {
uid: nextUid(),
r,
c,
type,
isMatched: false,
isRemoved: false,
matchFx: 0,
isDropStart: false,
isDropAnimate: false
}
}
function commitBoard(next) {
board.value = next
bumpLayout()
}
function generateBoard() {
const next = Array.from({ length: ROWS }, () => Array(COLS).fill(null))
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
let type
do {
type = randomFood()
} while (wouldCreateInitialMatch(r, c, type, next, next[r]))
next[r][c] = createTile(r, c, type)
}
}
return next
}
function initBoard() {
clearHints()
// 反复生成,直到棋盘“有解”(无现成消除组且至少有一个可用移动),避免开局即死局
const MAX_GEN = 40
let next = generateBoard()
for (let i = 0; i < MAX_GEN && !isBoardPlayable(next); i++) {
next = generateBoard()
}
ensureBoardFull(next)
commitBoard(next)
startHintTimer()
}
function ensureBoardFull(targetBoard) {
const grid = targetBoard || board.value
let changed = false
for (let r = 0; r < ROWS; r++) {
if (!grid[r]) grid[r] = Array(COLS).fill(null)
for (let c = 0; c < COLS; c++) {
const tile = grid[r][c]
if (!tile || tile.isRemoved) {
grid[r][c] = createTile(r, c, randomFood())
changed = true
} else {
syncTilePos(tile, r, c)
tile.isRemoved = false
tile.isDropStart = false
tile.isDropAnimate = false
}
}
}
if (changed && !targetBoard) {
commitBoard(grid.map((row) => [...row]))
}
return grid
}
function clearDropFlags() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile) {
tile.isDropStart = false
tile.isDropAnimate = false
}
}
}
bumpLayout()
}
function applyLevelConfig(cfg) {
levelConfig.value = cfg
movesLeft.value = cfg.moves
score.value = 0
glucoseLevel.value = cfg.startGlucose ?? 50
boosters.value.forEach((b) => {
if (b.id === 'insulin') b.count = cfg.boosters.insulin
else if (b.id === 'fiber') b.count = cfg.boosters.fiber
else if (b.id === 'meal') b.count = cfg.boosters.meal
})
}
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()
initBoard()
recomputeGlucoseFromBoard()
}
/** 统计棋盘上当前含糖负荷(高 GI 权重更大),用于血糖表信息性反算 */
function calcBoardSugarLoad() {
const loads = { high: 0, mid: 0, low: 0 }
let total = 0
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && !tile.isRemoved) {
loads[tile.type.gi] = (loads[tile.type.gi] || 0) + 1
total++
}
}
}
if (total <= 0) return 50
// 高糖占比越大,血糖表越高;纯信息展示,不判负、不随时间变化
const weighted = loads.high * 1 + loads.mid * 0.5 + loads.low * 0.1
const ratio = weighted / total
return Math.max(5, Math.min(95, Math.round(ratio * 100)))
}
/** 棋盘稳定后,把血糖表同步为当前含糖负荷(消除高糖食物后会变好) */
function recomputeGlucoseFromBoard() {
const target = calcBoardSugarLoad()
glucoseLevel.value = target
}
function restartLevel() {
gamePhase.value = 'playing'
winReason.value = ''
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
lastClearedFood.value = null
resultMessage.value = ''
resultStars.value = 0
resultStats.value = []
isProcessing.value = false
selectedUid.value = null
matchChain.value = 0
screenShaking.value = false
foodCard.value = null
floatingVals.value = []
setupLevel(levelConfig.value.id)
nextTick(() => recalcBoardSize())
}
function goNextLevel() {
gamePhase.value = 'playing'
winReason.value = ''
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
resultMessage.value = ''
resultStars.value = 0
resultStats.value = []
isProcessing.value = false
selectedUid.value = null
matchChain.value = 0
screenShaking.value = false
foodCard.value = null
floatingVals.value = []
setupLevel(gameProgress.value.currentLevel || levelConfig.value.id + 1)
nextTick(() => recalcBoardSize())
}
function getTileByUid(uid) {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && tile.uid === uid) return tile
}
}
return null
}
function isAdjacent(a, b) {
return Math.abs(a.r - b.r) + Math.abs(a.c - b.c) === 1
}
function getSwipeNeighbor(tile, dx, dy) {
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return null
let nr = tile.r
let nc = tile.c
if (Math.abs(dx) >= Math.abs(dy)) {
nc += dx > 0 ? 1 : -1
} else {
nr += dy > 0 ? 1 : -1
}
if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) return null
const neighbor = board.value[nr]?.[nc]
return isActiveTile(neighbor) ? neighbor : null
}
function clampDragDelta(tile, dx, dy, axis) {
const { stride } = getCellMetrics()
const max = stride * 0.95
let cx = axis === 'v' ? 0 : dx
let cy = axis === 'h' ? 0 : dy
const neighbor = getSwipeNeighbor(tile, cx, cy)
if (!neighbor) {
const damp = 0.28
cx *= damp
cy *= damp
} else {
cx = Math.max(-max, Math.min(max, cx))
cy = Math.max(-max, Math.min(max, cy))
}
return { dx: cx, dy: cy, neighbor }
}
function setDragPreview(tile, dx, dy, neighbor, useTransition = false, active = true) {
dragPreview.value = {
tileUid: tile.uid,
neighborUid: neighbor?.uid || null,
offsetX: dx,
offsetY: dy,
nOffsetX: neighbor ? -dx : 0,
nOffsetY: neighbor ? -dy : 0,
useTransition,
active
}
}
async function snapDragBack() {
const d = dragPreview.value
if (!d) return
dragPreview.value = {
...d,
offsetX: 0,
offsetY: 0,
nOffsetX: 0,
nOffsetY: 0,
useTransition: true,
active: false
}
await delay(180)
dragPreview.value = null
}
function clearDragPreview() {
dragPreview.value = null
}
async function animateSwapOffset(t1, t2) {
const { stride } = getCellMetrics()
const fullDx = (t2.c - t1.c) * stride
const fullDy = (t2.r - t1.r) * stride
setDragPreview(t1, fullDx, fullDy, t2, true, false)
await delay(SWAP_MS)
dragPreview.value = null
}
function markTouchHandled() {
touchHandledInteraction = true
setTimeout(() => {
touchHandledInteraction = false
}, 350)
}
function onBoardTouchMove() {
// 阻止棋盘区域滑动带动页面滚动
}
function onTileTouchStart(e, tile) {
if (gamePhase.value !== 'playing') return
if (isProcessing.value || tile.isMatched || tile.isRemoved) return
const pt = e.touches?.[0]
if (!pt) return
clearDragPreview()
tileTouchStart = {
tile,
x: pt.clientX,
y: pt.clientY,
axis: null
}
}
function onTileTouchMove(e, tile) {
if (!tileTouchStart || tileTouchStart.tile?.uid !== tile.uid) return
if (gamePhase.value !== 'playing' || isProcessing.value) return
const pt = e.touches?.[0]
if (!pt) return
let dx = pt.clientX - tileTouchStart.x
let dy = pt.clientY - tileTouchStart.y
if (!tileTouchStart.axis) {
const dist = Math.max(Math.abs(dx), Math.abs(dy))
if (dist >= SWIPE_AXIS_LOCK_PX) {
tileTouchStart.axis = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v'
}
}
if (tileTouchStart.axis === 'h') dy = 0
else if (tileTouchStart.axis === 'v') dx = 0
const { dx: cx, dy: cy, neighbor } = clampDragDelta(tile, dx, dy, tileTouchStart.axis)
setDragPreview(tile, cx, cy, neighbor, false, true)
}
async function onTileTouchEnd(e, tile) {
if (!tileTouchStart || tileTouchStart.tile?.uid !== tile.uid) {
tileTouchStart = null
await snapDragBack()
return
}
if (gamePhase.value !== 'playing' || isProcessing.value || tile.isMatched || tile.isRemoved) {
tileTouchStart = null
await snapDragBack()
return
}
const pt = e.changedTouches?.[0]
if (!pt) {
tileTouchStart = null
await snapDragBack()
return
}
const dx = pt.clientX - tileTouchStart.x
const dy = pt.clientY - tileTouchStart.y
const axis = tileTouchStart.axis
tileTouchStart = null
const adx = Math.abs(dx)
const ady = Math.abs(dy)
ensureStartSound()
resetHintTimer()
markTouchHandled()
if (adx < TAP_SLOP_PX && ady < TAP_SLOP_PX) {
clearDragPreview()
await onTileTap(tile, { fromTouch: true })
return
}
const { stride } = getCellMetrics()
const commitDist = stride * SWIPE_COMMIT_RATIO
const cx = axis === 'v' ? 0 : dx
const cy = axis === 'h' ? 0 : dy
if (Math.max(Math.abs(cx), Math.abs(cy)) < commitDist) {
await snapDragBack()
return
}
const neighbor = getSwipeNeighbor(tile, cx, cy)
if (!neighbor || !isAdjacent(tile, neighbor)) {
await snapDragBack()
return
}
selectedUid.value = null
const fullDx = (neighbor.c - tile.c) * stride
const fullDy = (neighbor.r - tile.r) * stride
setDragPreview(tile, fullDx, fullDy, neighbor, true, false)
await delay(SWAP_MS)
dragPreview.value = null
await processSwap(tile, neighbor, { skipAnimate: true })
}
async function onTileTouchCancel() {
tileTouchStart = null
await snapDragBack()
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function swapTiles(t1, t2) {
const r1 = t1.r
const c1 = t1.c
const r2 = t2.r
const c2 = t2.c
setBoardTile(r1, c1, t2)
setBoardTile(r2, c2, t1)
bumpLayout()
}
function isActiveTile(tile) {
return tile && !tile.isRemoved && !tile.isMatched
}
function findMatchGroups() {
const groups = []
const allTiles = new Set()
function addRun(run) {
if (run.length >= 3) {
groups.push({ tiles: run, size: run.length })
run.forEach((t) => allTiles.add(t))
}
}
for (let r = 0; r < ROWS; r++) {
let c = 0
while (c < COLS) {
const t0 = getBoardTile(r, c)
if (!isActiveTile(t0)) {
c++
continue
}
const key = t0.type.key
const run = [t0]
let k = c + 1
while (k < COLS) {
const t = getBoardTile(r, k)
if (!isActiveTile(t) || t.type.key !== key) break
run.push(t)
k++
}
addRun(run)
c = run.length > 1 ? k : c + 1
}
}
for (let c = 0; c < COLS; c++) {
let r = 0
while (r < ROWS) {
const t0 = getBoardTile(r, c)
if (!isActiveTile(t0)) {
r++
continue
}
const key = t0.type.key
const run = [t0]
let k = r + 1
while (k < ROWS) {
const t = getBoardTile(k, c)
if (!isActiveTile(t) || t.type.key !== key) break
run.push(t)
k++
}
addRun(run)
r = run.length > 1 ? k : r + 1
}
}
return { groups, tiles: Array.from(allTiles) }
}
function findMatches() {
return findMatchGroups().tiles
}
function testMatchAfterSwap(r1, c1, r2, c2) {
const t1 = getBoardTile(r1, c1)
const t2 = getBoardTile(r2, c2)
if (!t1 || !t2) return false
setBoardTile(r1, c1, t2)
setBoardTile(r2, c2, t1)
const has = findMatches().length > 0
setBoardTile(r1, c1, t1)
setBoardTile(r2, c2, t2)
return has
}
function findPossibleMoves() {
const moves = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (c < COLS - 1 && testMatchAfterSwap(r, c, r, c + 1)) {
moves.push({ t1: getBoardTile(r, c), t2: getBoardTile(r, c + 1) })
}
if (r < ROWS - 1 && testMatchAfterSwap(r, c, r + 1, c)) {
moves.push({ t1: getBoardTile(r, c), t2: getBoardTile(r + 1, c) })
}
}
}
return moves
}
/**
* 在不提交到响应式 board 的前提下,检测给定棋盘是否“有解”:
* 既无现成可消除组,且至少存在一个可交换出消除的移动。
*/
function isBoardPlayable(grid) {
const saved = board.value
board.value = grid
const playable = findMatches().length === 0 && findPossibleMoves().length > 0
board.value = saved
return playable
}
function clearHints() {
if (hintTimer) {
clearTimeout(hintTimer)
hintTimer = null
}
hintUids.value = new Set()
hintMove.value = null
hintTipVisible.value = false
}
function resetHintTimer() {
if (hintTimer) {
clearTimeout(hintTimer)
hintTimer = null
}
hintUids.value = new Set()
hintMove.value = null
hintTipVisible.value = false
startHintTimer()
}
function showOperationHint() {
if (isProcessing.value || gamePhase.value !== 'playing') return
const possible = findPossibleMoves()
if (possible.length > 0) {
const move = possible[Math.floor(Math.random() * possible.length)]
hintUids.value = new Set([move.t1.uid, move.t2.uid])
// 保存移动信息,用于渲染食物间的交换箭头
hintMove.value = {
t1: move.t1,
t2: move.t2,
isHorizontal: move.t1.r === move.t2.r
}
hintTipVisible.value = false // 不再显示 banner,箭头已足够
} else {
hintMove.value = null
hintTipText.value = '棋盘暂无可用移动,可点击底部道具重新排列'
hintTipVisible.value = true
}
playSfx('hint', 0.55)
}
function startHintTimer() {
if (gamePhase.value !== 'playing') return
if (hintTimer) clearTimeout(hintTimer)
hintTimer = setTimeout(showOperationHint, HINT_DELAY_MS)
}
/**
* 棋盘死锁自动被测与修复。
* 使用 Fisher-Yates 打乱食物类型,最多重试 maxRetry 次。
* 调用时机:processSwap / onBooster 结束后
*/
async function reshuffleIfStuck() {
if (gamePhase.value !== 'playing') return
// 已有解(无现成消除组 + 存在可用移动)则无需重排
if (findMatches().length === 0 && findPossibleMoves().length > 0) return
// 给玩家反馈
playSfx('reshuffle', 0.75)
try {
const sys = uni.getSystemInfoSync()
const cx = sys.windowWidth / 2
const cy = sys.windowHeight / 2
const id = `rs-${Date.now()}`
floatingVals.value.push({ id, text: '🔀 棋盘自动重排!', color: '#006c49', x: cx, y: cy })
setTimeout(() => { floatingVals.value = floatingVals.value.filter(v => v.id !== id) }, 1400)
} catch (_) {}
// 收集棋盘上所有有效方块(保持 tile 对象与位置不变,仅打乱其食物类型)
const tiles = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = board.value[r]?.[c]
if (t && !t.isRemoved) tiles.push(t)
}
}
// 反复打乱类型并就地试写,直到“有解且无现成三连”;
// tiles 已是 board.value 中的同一批对象,故 findMatches/findPossibleMoves 可直接复用
const MAX_RETRY = 60
let ok = false
for (let attempt = 0; attempt < MAX_RETRY; attempt++) {
for (let i = tiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tiles[i].type, tiles[j].type] = [tiles[j].type, tiles[i].type]
}
if (findMatches().length === 0 && findPossibleMoves().length > 0) {
ok = true
break
}
}
if (!ok) {
// 就地洗牌无法洗出可玩布局,回退到全棋盘重新生成
initBoard()
return
}
bumpLayout()
await delay(180)
}
/**
* 消除组视觉特效(适老化:柔和为主,去掉震屏与强闪屏):
* 3 连:柔光圈 + 少量星屑
* 4 连:彩虹星屑 + 双层光环 + 极轻闪屏
* 5 连+:彩虹光环 + 轻闪屏(不震屏)
* 支持对每一组同时触发(并行查询中心坐标)。
*/
async function spawnGroupFx(groups, maxSize) {
if (maxSize < 3 || !groups.length) return
// 并行查询所有层组的中心坐标
const centers = (await Promise.all(
groups.map(async (g) => {
const ct = g.tiles[Math.floor(g.tiles.length / 2)]
const c = await queryTileCenter(ct)
return c ? { center: c, color: ct.type.color, size: g.size } : null
})
)).filter(Boolean)
if (!centers.length) return
const main = centers.reduce((a, b) => (b.size > a.size ? b : a))
if (maxSize >= 5) {
// 5 连+:彩虹光环(柔和,不震屏)
centers.forEach(({ center, color }) => {
spawnRipple(center.x, center.y, color, 4)
spawnBurst(center.x, center.y, color, 16, { rainbow: true, distMax: 130, sizeMax: 18 })
})
spawnRipple(main.center.x, main.center.y, '#FFD54A', 6)
spawnRipple(main.center.x, main.center.y, '#ffffff', 2, { life: 520 })
spawnBurst(main.center.x, main.center.y, '#FFD54A', 18, { rainbow: true, sizeMax: 22, distMax: 160 })
triggerFlash('#fff7d6', 0.18)
} else if (maxSize >= 4) {
// 4 连:彩虹星屑 + 双层光环(不震屏)
centers.forEach(({ center, color }) => {
spawnRipple(center.x, center.y, color, 3)
spawnBurst(center.x, center.y, color, 12, { rainbow: true })
})
spawnRipple(main.center.x, main.center.y, '#ffffff', 1, { life: 480 })
spawnBurst(main.center.x, main.center.y, '#FFE06B', 6, { sizeMax: 18 })
triggerFlash('#ffffff', 0.12)
} else {
// 3 连:轻量星屑 + 柔光圈
centers.forEach(({ center, color }) => {
spawnRipple(center.x, center.y, color, 1, { life: 460 })
spawnBurst(center.x, center.y, color, 6, { sizeMax: 12, distMax: 68, life: 640 })
})
}
}
async function queryTileCenter(tile) {
return new Promise((resolve) => {
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select(`#tile-${tile.uid}`).boundingClientRect((rect) => {
if (rect && rect.width > 0) {
resolve({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 })
} else {
resolve(null)
}
}).exec()
})
}
/** 点击食物:弹出大字科普卡,强化「这是高/中/低糖食物」的认知 */
function showFoodTooltip(tile) {
const t = tile.type
if (foodCardTimer) clearTimeout(foodCardTimer)
foodCard.value = {
name: t.name,
gi: t.gi,
giShort: GI_SHORT[t.gi] || '',
color: GI_COLOR[t.gi] || '#16a34a',
tip: getFoodTip(t)
}
foodCardTimer = setTimeout(() => {
foodCard.value = null
}, 3200)
}
function updateGoal(key) {
const g = goals.value[key]
if (g && g.current < g.target) {
g.current += 1
playSfx('goalTick', 0.9)
}
}
/** 记录本批消除中代表性食物(取最大匹配组) */
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
if (bonus >= 40) playSfx('score', Math.min(1.15, 0.85 + bonus / 200))
scoreAnimating.value = false
nextTick(() => {
scoreAnimating.value = true
setTimeout(() => { scoreAnimating.value = false }, 400)
})
}
function updateMeter(totalVal) {
glucoseLevel.value = Math.max(5, Math.min(95, glucoseLevel.value + totalVal))
playMeterSfx(totalVal, glucoseLevel.value)
}
/** 消除时给认知正反馈:在最大一组中心浮出「认识了 X·高糖」 */
function showLearnFx(groups) {
if (!groups || !groups.length) return
const g = groups.reduce((a, b) => (b.size > a.size ? b : a))
const rep = g.tiles[Math.floor(g.tiles.length / 2)]
if (!rep) return
const t = rep.type
queryTileCenter(rep).then((center) => {
if (!center) return
const id = `learn-${Date.now()}`
floatingVals.value.push({
id,
text: `认识了 ${t.name}·${GI_SHORT[t.gi] || ''}`,
color: GI_COLOR[t.gi] || '#16a34a',
x: center.x,
y: center.y - 30
})
setTimeout(() => {
floatingVals.value = floatingVals.value.filter((v) => v.id !== id)
}, 1400)
})
}
function checkGameEnd() {
if (gamePhase.value !== 'playing') return
// 无失败惩罚:认全目标或已消除场上全部高糖食物即通关
if (allGoalsDone.value) {
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() {
const learned = goalList.value.reduce((s, g) => s + g.current, 0)
return [
{ label: '本组认识食物', value: `${goalList.value.length} 种` },
{ label: '消除练习', value: `${learned} 次` },
{ label: '得分', value: scoreText.value }
]
}
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,
glucoseLevel: glucoseLevel.value,
score: score.value,
levelConfig: levelConfig.value,
maxCombo: sessionMaxCombo.value
})
resultStars.value = stars
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()
}
playSfxLayer('win', 'winStinger', 120, 0.7)
} else {
recordLoss()
resultStars.value = 0
resultMessage.value = '再认一认这些食物,下次更熟练。'
resultStats.value = buildResultStats()
playSfx('lose')
}
}
async function onTileTap(tile, opts = {}) {
if (!opts.fromTouch && touchHandledInteraction) return
if (gamePhase.value !== 'playing') return
if (isProcessing.value || tile.isMatched || tile.isRemoved) return
ensureStartSound()
resetHintTimer()
showFoodTooltip(tile)
if (!selectedUid.value) {
selectedUid.value = tile.uid
playSfx('select')
return
}
const selected = getTileByUid(selectedUid.value)
if (!selected || selected.isMatched || selected.isRemoved) {
selectedUid.value = tile.uid
return
}
if (selected.uid === tile.uid) {
selectedUid.value = null
playSfx('deselect')
return
}
if (!isAdjacent(selected, tile)) {
selectedUid.value = tile.uid
playSfx('select')
return
}
selectedUid.value = null
await processSwap(selected, tile)
}
async function processSwap(t1, t2, opts = {}) {
isProcessing.value = true
matchChain.value = 0
playSfx('swap')
if (!opts.skipAnimate) {
await animateSwapOffset(t1, t2)
}
swapTiles(t1, t2)
const matches = findMatches()
if (matches.length > 0) {
movesLeft.value = Math.max(0, movesLeft.value - 1)
const endedByHigh = await clearAndRefill()
if (endedByHigh) {
isProcessing.value = false
return
}
// 所有连消结束后,检查棋盘是否死锁并自动重排
await reshuffleIfStuck()
} else {
await animateSwapOffset(t1, t2)
swapTiles(t1, t2)
playSfx('swapFail')
}
isProcessing.value = false
resetHintTimer()
checkGameEnd()
}
async function clearAndRefill() {
const { groups, tiles } = findMatchGroups()
if (!tiles.length) return false
const clearedHighTiles = tiles.filter((t) => t.type.gi === 'high')
recordLastClearedFood(groups)
let totalVal = 0
let maxSize = 3
tiles.forEach((t) => { t.matchFx = 0 })
groups.forEach((g) => {
maxSize = Math.max(maxSize, g.size)
const fx = g.size >= 5 ? 5 : g.size >= 4 ? 4 : 3
g.tiles.forEach((t) => {
t.isMatched = true
t.matchFx = Math.max(t.matchFx || 0, fx)
})
})
// 用去重后的 tiles 累加控糖效果/目标,避免 L/T 交叉点被横竖两组重复计分
tiles.forEach((t) => {
totalVal += getClearVal(t.type)
updateGoal(t.type.key)
})
playMatchSfx({ matchCount: maxSize, chain: matchChain.value, tiles })
if (matchChain.value >= 1) {
const chainNum = matchChain.value + 1
const pts = tiles.length * 20 * chainNum
showComboBanner(chainNum, pts)
if (chainNum > sessionMaxCombo.value) sessionMaxCombo.value = chainNum
}
// 震屏由 spawnGroupFx 内部统一管理,此处不重复调用
await spawnGroupFx(groups, maxSize)
updateScore(tiles.length * 20 * (matchChain.value + 1))
updateMeter(totalVal)
showLearnFx(groups)
bumpLayout()
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)
const ended = await clearAndRefill()
if (ended) return true
}
return false
}
async function applyGravity() {
const next = Array.from({ length: ROWS }, () => Array(COLS).fill(null))
const dropTiles = []
for (let c = 0; c < COLS; c++) {
const surviving = []
for (let r = 0; r < ROWS; r++) {
const tile = board.value[r]?.[c]
if (tile && !tile.isRemoved) surviving.push({ tile, fromR: r })
}
const gap = ROWS - surviving.length
surviving.forEach(({ tile, fromR }, i) => {
const nr = gap + i
tile.isMatched = false
tile.isRemoved = false
tile.matchFx = 0
tile.isDropStart = fromR !== nr
tile.isDropAnimate = false
syncTilePos(tile, nr, c)
next[nr][c] = tile
if (tile.isDropStart) dropTiles.push(tile)
})
for (let r = 0; r < gap; r++) {
const tile = createTile(r, c, randomFood())
tile.isDropStart = true
tile.isDropAnimate = false
next[r][c] = tile
dropTiles.push(tile)
}
}
ensureBoardFull(next)
commitBoard(next)
await nextTick()
if (dropTiles.length > 0) {
playDropSfx(dropTiles.length)
dropTiles.forEach((t) => {
t.isDropStart = false
t.isDropAnimate = true
})
bumpLayout()
await delay(GRAVITY_MS)
}
clearDropFlags()
}
async function onBooster(booster) {
if (gamePhase.value !== 'playing' || isProcessing.value) return
if (booster.count <= 0) {
uni.showToast({ title: '该道具已用尽', icon: 'none' })
return
}
ensureStartSound()
if (booster.id === 'insulin') {
if (glucoseLevel.value <= 5) {
uni.showToast({ title: '当前血糖正常,无需使用胰岛素', icon: 'none' })
return
}
isProcessing.value = true
booster.count--
playSfx('insulin')
const oldVal = glucoseLevel.value
glucoseLevel.value = Math.max(5, glucoseLevel.value - 25)
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select('.game-grid').boundingClientRect((rect) => {
if (rect && rect.width > 0) {
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const valId = `fv-${Date.now()}`
floatingVals.value.push({
id: valId,
text: `血糖 -${oldVal - glucoseLevel.value}`,
color: '#2ecc71',
x: cx,
y: cy - 20
})
setTimeout(() => {
floatingVals.value = floatingVals.value.filter((t) => t.id !== valId)
}, 1200)
spawnRipple(cx, cy, '#2ecc71', 3)
spawnBurst(cx, cy, '#2ecc71', 20)
}
}).exec()
await delay(500)
isProcessing.value = false
resetHintTimer()
} else if (booster.id === 'fiber') {
const highGiTiles = []
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') {
highGiTiles.push(tile)
}
}
}
if (highGiTiles.length === 0) {
uni.showToast({ title: '场上暂无高GI食物,无需使用膳食纤维', icon: 'none' })
return
}
isProcessing.value = true
booster.count--
playSfx('fiber')
// 只能替换成“本局在用”的低GI食材,否则会引入无法消除的新图标
let lowGiFoods = activeFoods.value.filter(f => f.gi === 'low')
if (!lowGiFoods.length) lowGiFoods = activeFoods.value.length ? activeFoods.value : FOOD_LIBRARY
highGiTiles.forEach((tile) => {
const randomLowFood = cloneFoodType(lowGiFoods[Math.floor(Math.random() * lowGiFoods.length)])
tile.type = randomLowFood
queryTileCenter(tile).then((center) => {
if (center) {
spawnRipple(center.x, center.y, '#f97316', 1)
spawnBurst(center.x, center.y, '#f97316', 6)
}
})
})
const dropVal = highGiTiles.length * 3
const oldVal = glucoseLevel.value
glucoseLevel.value = Math.max(5, glucoseLevel.value - dropVal)
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select('.game-grid').boundingClientRect((rect) => {
if (rect && rect.width > 0) {
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const valId = `fv-${Date.now()}`
floatingVals.value.push({
id: valId,
text: `膳食纤维:血糖 -${oldVal - glucoseLevel.value}`,
color: '#f97316',
x: cx,
y: cy - 20
})
setTimeout(() => {
floatingVals.value = floatingVals.value.filter((t) => t.id !== valId)
}, 1500)
}
}).exec()
await delay(600)
const matches = findMatches()
if (matches.length > 0) {
const endedByHigh = await clearAndRefill()
if (endedByHigh) {
isProcessing.value = false
return
}
}
// fiber 改变了棋盘,保险检查是否死锁
await reshuffleIfStuck()
isProcessing.value = false
resetHintTimer()
checkGameEnd()
} else if (booster.id === 'meal') {
isProcessing.value = true
booster.count--
playSfx('meal')
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select('.game-grid').boundingClientRect((rect) => {
if (rect && rect.width > 0) {
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
spawnRipple(cx, cy, '#f43f5e', 4)
spawnBurst(cx, cy, '#f43f5e', 24)
}
}).exec()
const currentTypes = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && !tile.isRemoved) {
currentTypes.push(tile.type)
}
}
}
let next = null
let attempts = 0
let success = false
while (attempts < 100) {
attempts++
const shuffledTypes = [...currentTypes]
for (let i = shuffledTypes.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffledTypes[i], shuffledTypes[j]] = [shuffledTypes[j], shuffledTypes[i]]
}
next = Array.from({ length: ROWS }, () => Array(COLS).fill(null))
let ptr = 0
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const type = shuffledTypes[ptr++] || randomFood()
next[r][c] = createTile(r, c, type)
}
}
let hasInitialMatch = false
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const key = next[r][c].type.key
if (r >= 2 && next[r - 1][c].type.key === key && next[r - 2][c].type.key === key) {
hasInitialMatch = true
break
}
if (c >= 2 && next[r][c - 1].type.key === key && next[r][c - 2].type.key === key) {
hasInitialMatch = true
break
}
}
if (hasInitialMatch) break
}
if (hasInitialMatch) continue
const savedBoard = board.value
board.value = next
const possible = findPossibleMoves()
board.value = savedBoard
if (possible.length > 0) {
success = true
break
}
}
if (!success) {
next = Array.from({ length: ROWS }, () => Array(COLS).fill(null))
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
let type
do {
type = randomFood()
} while (wouldCreateInitialMatch(r, c, type, next, next[r]))
next[r][c] = createTile(r, c, type)
}
}
}
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = next[r][c]
t.isDropStart = true
t.isDropAnimate = false
}
}
commitBoard(next)
await nextTick()
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = next[r][c]
t.isDropStart = false
t.isDropAnimate = true
}
}
bumpLayout()
await delay(GRAVITY_MS)
clearDropFlags()
// meal 自带洗牌已保证有解,但再做一次轻量检查保险
await reshuffleIfStuck()
isProcessing.value = false
resetHintTimer()
}
}
function onSoundToggle() {
toggleSfx()
}
function ensureStartSound() {
if (!startedSound) {
startedSound = true
warmUpSfx()
}
}
function initLayout() {
let paddingTop = uni.upx2px(24)
let safeBottom = uni.upx2px(24)
let headerPaddingRight = uni.upx2px(32)
try {
const sys = uni.getSystemInfoSync()
const statusBar = Number(sys.statusBarHeight) || 0
paddingTop = statusBar + uni.upx2px(8)
safeBottom = sys.safeAreaInsets?.bottom || uni.upx2px(24)
// #ifdef MP-WEIXIN
const menu = typeof uni.getMenuButtonBoundingClientRect === 'function'
? uni.getMenuButtonBoundingClientRect()
: null
if (menu && menu.width > 0) {
paddingTop = Math.max(paddingTop, menu.top)
// 仅顶栏避让胶囊,棋盘区保持左右对称居中
headerPaddingRight = sys.windowWidth - menu.left + uni.upx2px(8)
menuHeight.value = menu.height
menuTop.value = menu.top
}
// #endif
} catch (e) {}
headerStyle.value = {
paddingTop: `${paddingTop}px`,
paddingLeft: '32rpx',
paddingRight: '32rpx',
paddingBottom: '10rpx'
}
navStyle.value = {
paddingRight: `${Math.max(0, headerPaddingRight - uni.upx2px(32))}px`
}
footerStyle.value = {
paddingBottom: `${safeBottom + uni.upx2px(8)}px`
}
pageStyle.value = {
'--menu-height': `${menuHeight.value}px`,
'--menu-top': `${menuTop.value}px`
}
nextTick(() => recalcBoardSize())
}
function recalcBoardSize() {
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select('.gg-header').boundingClientRect()
query.select('.gg-footer').boundingClientRect()
query.exec((res) => {
try {
const sys = uni.getSystemInfoSync()
const header = res?.[0]
const footer = res?.[1]
const headerBottom = Math.ceil(header?.bottom ?? header?.height ?? uni.upx2px(360))
// 底部道具栏较高,测量失败时用更保守的兜底高度,避免最后一行被遮挡
const footerTop = Math.floor(footer?.top ?? (sys.windowHeight - uni.upx2px(300)))
const inset = uni.upx2px(BOARD_INSET_RPX)
const footerGap = uni.upx2px(BOARD_FOOTER_GAP_RPX)
const zoneW = sys.windowWidth
// 预留底部安全间隙,棋盘不贴到道具栏
const zoneH = Math.max(0, footerTop - headerBottom - footerGap)
// 5 行 × 4 列:宽高分别约束,取较小单格像素
cellSizePx.value = calcMaxCellSize(zoneW - inset * 2, zoneH - inset * 2)
const boardDim = calcBoardDimensions(cellSizePx.value)
boardWidthPx.value = boardDim.width
boardHeightPx.value = boardDim.height
const areaBottom = Math.max(0, sys.windowHeight - footerTop + footerGap)
mainStyle.value = {
top: `${headerBottom}px`,
bottom: `${areaBottom}px`
}
pageStyle.value = {
...pageStyle.value,
'--board-area-top': `${headerBottom}px`,
'--board-area-bottom': `${areaBottom}px`,
'--cell-size': `${cellSizePx.value}px`,
'--tile-cell-px': `${cellSizePx.value}px`,
'--tile-emoji-size': `${Math.floor(cellSizePx.value * 0.82)}px`
}
updateGridMetrics()
} catch (e) {}
})
}
function goBack() {
uni.navigateBack({
fail() {
uni.redirectTo({ url: '/tongji/pages/more' })
}
})
}
onLoad(() => {
initLayout()
touchDailyPlay()
const lv = gameProgress.value.currentLevel || 1
setupLevel(Math.min(lv, gameProgress.value.maxUnlocked || lv))
})
onReady(() => {
recalcBoardSize()
// 等头/底栏(含安全区、道具栏)完全布局后再量一次,确保最后一行不被遮挡
setTimeout(() => recalcBoardSize(), 180)
})
onMounted(() => {
resetHintTimer()
})
onUnmounted(() => {
clearHints()
if (foodCardTimer) clearTimeout(foodCardTimer)
})
</script>
<style lang="scss" scoped>
@import '../styles/game-stitch.scss';
</style>