Files
zyt/TUICallKit-Vue3/tongji/pages/game.vue
T
2026-06-01 09:34:27 +08:00

2176 lines
70 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="tt in foodTooltips"
:key="tt.id"
class="food-tooltip"
:style="{ left: tt.x + 'px', top: tt.y + 'px' }"
>
<text>{{ tt.name }}</text>
<text class="gi-badge" :style="{ backgroundColor: tt.color }">{{ tt.giLabel }}</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="urgeToast.show" class="gg-urge-toast" :class="'is-' + urgeToast.type">
<text>{{ urgeToast.text }}</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">
{{ gamePhase === 'won' ? '再玩本关' : '再试一次' }}
</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' : '#cbd5e1'" />
</view>
<view v-if="winStreak >= 2" class="gg-hud-streak" hover-class="gg-hud-btn--pressed">
<TongjiIcon name="flame" size="sm" color="#ea580c" />
<text>{{ winStreak }}</text>
</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>
<text v-if="scoreMultiplier > 1" class="gg-hud-mult">×{{ scoreMultiplier }}</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" :class="{ 'is-warn': movesLeft <= 3 && gamePhase === 'playing' }">
<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
class="gg-digest-pill"
:class="{ 'is-warn': digestCountdown <= 3 }"
>
<TongjiIcon name="info" size="sm" color="#ffffff" />
<text class="gg-digest-num">{{ digestCountdown }}</text>
<text class="gg-digest-label">秒后升糖</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="gamePhase === 'playing'"
class="gg-digest-urge-bar"
:class="{ 'is-show': digestUrgent }"
>
<TongjiIcon name="alert-circle" size="sm" color="#ffffff" />
<text class="gg-digest-urge-text">餐后升糖倒计时快消除棋盘运动降糖</text>
</view>
<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>
</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, formatEatLabel, getClearVal } 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, triggerShake, 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 urgeToast = ref({ show: false, text: '', type: 'warm' })
const menuHeight = ref(32)
const menuTop = ref(0)
let urgeTimer = null
let lastUrgeKey = ''
const ROWS = 7
const COLS = 6
const ROW_AXES = Array.from({ length: ROWS }, (_, i) => i)
const COL_AXES = Array.from({ length: COLS }, (_, i) => i)
/** 7 行 × 6 列棋盘:单格像素由可用区域反算 */
const BOARD_INSET_RPX = 4
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 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 digestCountdown = ref(8)
const gamePhase = ref('playing')
const loseReason = ref('')
const resultMessage = ref('')
const foodTooltips = ref([])
const floatingVals = ref([])
const hintUids = ref(new Set())
const hintMove = ref(null) // { t1, t2, isHorizontal }
let hintTimer = null
let digestTimer = 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 winStreak = computed(() => gameProgress.value.winStreak || 0)
const dailyStreak = computed(() => gameProgress.value.dailyStreak || 0)
/** 连胜加成:2 连胜起每多 1 场 +10% 得分 */
const scoreMultiplier = computed(() => {
const s = winStreak.value
if (s < 2) return 1
return 1 + (s - 1) * 0.1
})
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
}
})
)
/** 升糖倒计时 ≤3 秒时在棋盘上方显示告急条 */
const digestUrgent = computed(() => (
gamePhase.value === 'playing' && digestCountdown.value > 0 && digestCountdown.value <= 3
))
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
lastUrgeKey = ''
loseReason.value = ''
tileTouchStart = null
clearDragPreview()
selectSessionFoods()
initBoard()
startDigestTimer()
}
/** 统计棋盘上未消化食物量(高 GI 权重更大) */
function calcBoardDigestRise() {
const loads = { high: 0, mid: 0, low: 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
}
}
}
const mult = levelConfig.value.digestMultiplier ?? 1
const raw = loads.high * 2.5 + loads.mid * 0.9 + loads.low * 0.15
return Math.min(14, Math.max(1, Math.round(raw * 0.07 * mult)))
}
function triggerDigestTick() {
if (gamePhase.value !== 'playing' || isProcessing.value) return
const rise = calcBoardDigestRise()
if (rise <= 0) return
updateMeter(rise)
showMeterDeltaFx(rise, '消化')
playSfx('meterWarn', 0.65, 1.05)
if (glucoseLevel.value >= 95) {
loseReason.value = 'glucose'
endGame('lost')
return
}
}
function resetDigestCountdown() {
digestCountdown.value = levelConfig.value.digestIntervalSec ?? 8
}
function startDigestTimer() {
stopDigestTimer()
resetDigestCountdown()
digestTimer = setInterval(() => {
if (gamePhase.value !== 'playing' || isProcessing.value) return
digestCountdown.value = Math.max(0, digestCountdown.value - 1)
if (digestCountdown.value <= 0) {
triggerDigestTick()
resetDigestCountdown()
}
}, 1000)
}
function stopDigestTimer() {
if (digestTimer) {
clearInterval(digestTimer)
digestTimer = null
}
}
function showUrge(text, type = 'warm') {
if (gamePhase.value !== 'playing') return
if (urgeTimer) clearTimeout(urgeTimer)
urgeToast.value = { show: true, text, type }
urgeTimer = setTimeout(() => {
urgeToast.value = { ...urgeToast.value, show: false }
}, 2200)
}
/** 心理激励:接近胜利 / 步数告急时给正反馈,促继续玩 */
function checkUrge() {
if (gamePhase.value !== 'playing') return
const pct = goalProgressPct.value
let key = ''
let text = ''
let type = 'warm'
if (pct >= 100) return
if (pct >= 88) {
key = 'almost'
text = '就差一点点!再消一组就过关 🎯'
type = 'hot'
} else if (pct >= 65) {
key = 'close'
text = `目标已完成 ${pct}%,继续保持!`
type = 'warm'
} else if (movesLeft.value <= 3 && movesLeft.value > 0) {
key = 'moves'
text = `仅剩 ${movesLeft.value} 步,试试提示箭头交换!`
type = 'warn'
} else if (winStreak.value >= 3) {
key = 'streak'
text = `${winStreak.value} 连胜中!得分 ×${scoreMultiplier.value.toFixed(1)}`
type = 'hot'
}
if (!key || key === lastUrgeKey) return
lastUrgeKey = key
showUrge(text, type)
}
function restartLevel() {
gamePhase.value = 'playing'
resultMessage.value = ''
resultStars.value = 0
resultStats.value = []
isProcessing.value = false
selectedUid.value = null
matchChain.value = 0
screenShaking.value = false
foodTooltips.value = []
floatingVals.value = []
lastUrgeKey = ''
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
foodTooltips.value = []
floatingVals.value = []
lastUrgeKey = ''
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 连:糖果 confetti(彩虹星屑)+ 双层光环 + 轻闪屏 + 轻震屏
* 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.5)
triggerShake(2)
} else if (maxSize >= 4) {
// 4 连:糖果 confetti + 双层光环
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.28)
triggerShake(1)
} 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) {
queryTileCenter(tile).then((center) => {
if (!center) return
const id = `tt-${Date.now()}`
foodTooltips.value.push({
id,
name: tile.type.name,
giLabel: tile.type.giLabel,
color: tile.type.color,
x: center.x,
y: center.y
})
setTimeout(() => {
foodTooltips.value = foodTooltips.value.filter((t) => t.id !== id)
}, 1500)
const valId = `fv-${Date.now()}`
const valText = formatEatLabel(tile.type)
const valColor = tile.type.gi === 'high' ? '#ef5350' : tile.type.gi === 'mid' ? '#fb8c00' : '#43a047'
floatingVals.value.push({
id: valId,
text: valText,
color: valColor,
x: center.x,
y: center.y
})
setTimeout(() => {
floatingVals.value = floatingVals.value.filter((t) => t.id !== valId)
}, 1000)
})
}
function updateGoal(key) {
const g = goals.value[key]
if (g && g.current < g.target) {
g.current += 1
playSfx('goalTick', 0.9)
nextTick(() => checkUrge())
}
}
function updateScore(pts) {
const bonus = Math.round(pts * scoreMultiplier.value)
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) {
const prev = glucoseLevel.value
glucoseLevel.value = Math.max(5, Math.min(95, glucoseLevel.value + totalVal))
playMeterSfx(totalVal, glucoseLevel.value)
if (prev <= 70 && glucoseLevel.value > 70) {
screenShaking.value = true
setTimeout(() => { screenShaking.value = false }, 500)
}
}
function showMeterDeltaFx(delta, label = '血糖') {
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select('.game-grid').boundingClientRect((rect) => {
if (!rect || rect.width <= 0) return
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const sign = delta > 0 ? '+' : ''
const valId = `fv-meter-${Date.now()}`
floatingVals.value.push({
id: valId,
text: `${label} ${sign}${delta}`,
color: delta < 0 ? '#2ecc71' : '#ef5350',
x: cx,
y: cy - 24
})
setTimeout(() => {
floatingVals.value = floatingVals.value.filter((t) => t.id !== valId)
}, 1100)
}).exec()
}
function checkGameEnd() {
if (gamePhase.value !== 'playing') return
if (allGoalsDone.value) {
endGame('won')
return
}
if (movesLeft.value <= 0) {
endGame('lost')
} else {
checkUrge()
}
}
function buildResultStats() {
const rows = [
{ label: '得分', value: scoreText.value },
{ label: '剩余步数', value: String(movesLeft.value) },
{ label: '最高连消', value: `${sessionMaxCombo.value} 连` }
]
if (winStreak.value >= 1) {
rows.push({ label: '连胜', value: `${winStreak.value} 场` })
}
return rows
}
function endGame(phase) {
gamePhase.value = phase
stopDigestTimer()
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 starTip = stars >= 3 ? '完美控糖!' : stars >= 2 ? '表现不错!' : '再接再厉,争取更多星!'
resultMessage.value = newBest
? `${starTip} 新纪录 ${stars} 星,已解锁第 ${levelConfig.value.id + 1} 关`
: `${starTip} 获得 ${stars} 星`
resultStats.value = buildResultStats()
playSfxLayer('win', 'winStinger', 120, 0.7)
} else {
recordLoss()
const pct = goalProgressPct.value
resultStars.value = 0
if (loseReason.value === 'glucose') {
resultMessage.value = '血糖过高!多消除棋盘上的食物,相当于运动降糖'
} else if (pct >= 70) {
resultMessage.value = `目标已完成 ${pct}%,只差一步!再试一次很可能过关`
} else if (pct >= 40) {
resultMessage.value = `已完成 ${pct}%,换种交换顺序试试`
} else {
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)
if (totalVal !== 0) {
showMeterDeltaFx(totalVal, totalVal < 0 ? '运动' : '血糖')
}
// 消除食物 = 运动,升糖倒计时恢复初始
resetDigestCountdown()
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(200))
const footerTop = Math.floor(footer?.top ?? (sys.windowHeight - uni.upx2px(180)))
const inset = uni.upx2px(BOARD_INSET_RPX)
const zoneW = sys.windowWidth
const zoneH = Math.max(0, footerTop - headerBottom)
// 7 行 × 6 列:宽高分别约束,取较小单格像素
cellSizePx.value = calcMaxCellSize(zoneW - inset * 2, zoneH - inset * 2)
const boardDim = calcBoardDimensions(cellSizePx.value)
boardWidthPx.value = boardDim.width
boardHeightPx.value = boardDim.height
mainStyle.value = {
top: `${headerBottom}px`,
bottom: `${Math.max(0, sys.windowHeight - footerTop)}px`
}
pageStyle.value = {
...pageStyle.value,
'--board-area-top': `${headerBottom}px`,
'--board-area-bottom': `${Math.max(0, sys.windowHeight - footerTop)}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()
})
onMounted(() => {
resetHintTimer()
})
onUnmounted(() => {
clearHints()
stopDigestTimer()
if (urgeTimer) clearTimeout(urgeTimer)
})
</script>
<style lang="scss" scoped>
@import '../styles/game-stitch.scss';
</style>