Files
zyt/TUICallKit-Vue3/tongji/pages/game.vue
T
2026-06-02 18:33:25 +08:00

2033 lines
66 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="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">
<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">
<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>
</view>
</view>
<view class="gg-result-actions">
<view
v-if="gamePhase === 'won'"
class="gg-result-btn is-primary"
@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 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 class="gg-board-zone">
<view v-if="hintTipVisible" class="gg-no-move-tip">
<TongjiIcon name="alert-circle" 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 class="gg-footer gg-glass" :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, 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 = 6
const COLS = 4
const ROW_AXES = Array.from({ length: ROWS }, (_, i) => i)
const COL_AXES = Array.from({ length: COLS }, (_, i) => i)
/** 6 行 × 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
// 每局选中的食材子集(保证消除可玩,并让每次进入素材不同)
const SESSION_FOODS = { low: 3, mid: 2, high: 2 }
const activeFoods = ref([])
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))
}
/**
* 随机挑选本局食材与收集目标。每次调用结果不同:
* 低/中/高 各取若干,组成 7 种棋子;再从中挑 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 selected = [
...pickRandom(lows, SESSION_FOODS.low),
...pickRandom(mids, SESSION_FOODS.mid),
...pickRandom(highs, SESSION_FOODS.high)
]
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')
const loseReason = ref('')
const resultMessage = ref('')
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 '#006c49'
})
const glucoseRingStyle = computed(() => {
const pct = glucoseLevel.value
const color = glucoseAccentColor.value
return {
background: `conic-gradient(${color} 0 ${pct}%, #e2ece6 ${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 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
loseReason.value = ''
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'
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'
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 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')
}
}
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) {
gamePhase.value = phase
clearHints()
if (phase === 'won') {
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
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)
await clearAndRefill()
// 所有连消结束后,检查棋盘是否死锁并自动重排
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
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)
await applyGravity()
const next = findMatchGroups()
if (next.tiles.length > 0) {
matchChain.value += 1
await delay(200)
await clearAndRefill()
}
}
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) {
await clearAndRefill()
}
// 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: '16rpx'
}
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)
// 6 行 × 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>