Files
zyt/TUICallKit-Vue3/tongji/pages/game.vue
T
2026-06-09 09:31:03 +08:00

2407 lines
80 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="game-page" :class="{ 'is-screen-shake': screenShaking }" :style="pageStyle">
<!-- 消除特效层 -->
<view class="gg-fx-layer" aria-hidden="true">
<!-- 大消除闪屏 -->
<view
v-if="flash.active"
class="gg-screen-flash"
:style="{ backgroundColor: flash.color, opacity: flash.opacity }"
/>
<view
v-for="rp in ripples"
:key="rp.id"
class="gg-match-ripple"
:style="{
left: rp.left + 'px',
top: rp.top + 'px',
width: rp.size + 'px',
height: rp.size + 'px',
borderColor: rp.color,
borderWidth: rp.thickness + 'px',
boxShadow: '0 0 ' + (rp.thickness * 4) + 'px ' + rp.color + ', inset 0 0 ' + (rp.thickness * 3) + 'px ' + rp.color
}"
/>
<view
v-for="p in burstParticles"
:key="p.id"
class="gg-particle"
:class="{ 'is-star': p.char }"
:style="{
width: p.size + 'px',
height: p.size + 'px',
left: p.left + 'px',
top: p.top + 'px',
backgroundColor: p.char ? 'transparent' : p.color,
color: p.color,
fontSize: p.size + 'px',
opacity: p.opacity,
transform: p.transform,
transition: p.transition
}"
>
<text v-if="p.char">{{ p.char }}</text>
</view>
<view
v-for="ft in floatingVals"
:key="ft.id"
class="gg-floating-val"
:style="{ left: ft.x + 'px', top: ft.y + 'px', color: ft.color }"
>{{ ft.text }}</view>
<!-- 连消横幅 -->
<view
v-if="comboBanner.show"
class="gg-combo-banner"
:class="'is-tier-' + comboBanner.tier"
>
<text>{{ comboBanner.text }}</text>
</view>
</view>
<!-- 点击食物的大字科普卡名称 + 含糖等级 + 一句话说明 -->
<view v-if="foodCard" class="gg-food-card" :class="'is-' + foodCard.gi">
<view class="gg-food-card-head">
<text class="gg-food-card-name">{{ foodCard.name }}</text>
<text class="gg-food-card-gi" :style="{ backgroundColor: foodCard.color }">{{ foodCard.giShort }}</text>
</view>
<text class="gg-food-card-tip">{{ foodCard.tip }}</text>
</view>
<!-- 消除高糖红色警示弹窗短暂强调后进入结算 -->
<view v-if="highClearAlertVisible" class="gg-high-alert-overlay" @tap.stop>
<view class="gg-high-alert-card">
<view class="gg-high-alert-icon-wrap" aria-hidden="true">
<TongjiIcon name="alert-triangle" size="lg" color="#ffffff" />
</view>
<view class="gg-high-alert-badge-row">
<text class="gg-high-alert-badge">高糖</text>
<text class="gg-high-alert-badge-sub">红色标识 · 升糖快</text>
</view>
<text class="gg-high-alert-title">已消除高糖食物</text>
<text class="gg-high-alert-name">{{ clearedHighFoodName }}</text>
<text class="gg-high-alert-tip">请记住这类食物要少吃</text>
</view>
</view>
<!-- 学习结果 -->
<view v-if="gamePhase !== 'playing'" class="gg-result-overlay" @tap.stop>
<view class="gg-result-card" :class="{ 'is-high-clear': winReason === 'high_cleared' }">
<view v-if="winReason === 'high_cleared'" class="gg-result-high-head">
<text class="gg-result-high-badge">高糖</text>
<text class="gg-result-high-tag">已消除</text>
</view>
<text class="gg-result-title">{{ resultTitle }}</text>
<view v-if="gamePhase === 'won' && winReason !== 'high_cleared'" class="gg-result-stars">
<text
v-for="i in 3"
:key="'star-' + i"
class="gg-result-star"
:class="{ 'is-lit': i <= resultStars }"
></text>
</view>
<text class="gg-result-sub">{{ resultMessage }}</text>
<view v-if="resultStats.length" class="gg-result-stats" :class="{ 'is-high-clear': winReason === 'high_cleared' }">
<view v-for="(row, idx) in resultStats" :key="'rs-' + idx" class="gg-result-stat">
<text class="gg-result-stat-label">{{ row.label }}</text>
<text class="gg-result-stat-val" :class="{ 'is-high-val': winReason === 'high_cleared' && idx === 0 }">
<text v-if="winReason === 'high_cleared' && idx === 0" class="gg-result-inline-badge">高糖</text>
{{ row.value }}
</text>
</view>
</view>
<view class="gg-result-actions">
<view
v-if="gamePhase === 'won'"
class="gg-result-btn is-primary"
:class="{ 'is-high-primary': winReason === 'high_cleared' }"
@tap="goNextLevel"
>认下一组</view>
<view class="gg-result-btn" :class="{ 'is-primary': gamePhase !== 'won' }" @tap="restartLevel">
再认一遍
</view>
<view class="gg-result-btn" @tap="goBack">返回</view>
</view>
</view>
</view>
<!-- Header HUD -->
<view class="gg-header" :style="headerStyle">
<!-- 顶部导航与全局状态 -->
<view class="gg-hud-nav" :style="navStyle">
<!-- 返回 -->
<view class="gg-hud-btn" hover-class="gg-hud-btn--pressed" @tap="goBack">
<TongjiIcon name="chevron-left" size="md" color="#006c4b" />
</view>
<!-- 绝对居中的关卡 -->
<view class="gg-hud-level-center">
<text class="gg-hud-level-text"> {{ levelConfig.id }} </text>
</view>
<!-- 声音 -->
<view class="gg-hud-control-group">
<view
class="gg-hud-btn"
:class="{ 'is-muted': !sfxEnabled }"
hover-class="gg-hud-btn--pressed"
@tap="onSoundToggle"
>
<TongjiIcon name="volume" size="md" :color="sfxEnabled ? '#006c4b' : '#94a3b8'" />
</view>
</view>
</view>
</view>
<!-- 主区棋盘为主辅助信息弱化 -->
<view class="gg-main" :style="mainStyle">
<!-- 信息横幅默认为玩法提示消除后展示该食物的含糖与建议 -->
<view
v-if="gamePhase === 'playing'"
class="gg-info-banner"
:class="lastClearedFood ? 'is-food is-' + lastClearedFood.gi : ''"
>
<!-- 已消除食物含糖 + 建议 -->
<template v-if="lastClearedFood">
<view class="gg-info-banner-icon is-food" :class="'is-' + lastClearedFood.gi">
<FoodTileIcon :food="lastClearedFood.food" size="goal" />
</view>
<view class="gg-info-banner-body">
<view class="gg-info-food-head">
<text class="gg-info-food-name">{{ lastClearedFood.name }}</text>
<view class="gg-info-food-gi" :class="'is-' + lastClearedFood.gi">
<text class="gg-info-food-gi-text">含糖{{ lastClearedFood.giShort }}</text>
</view>
</view>
<text class="gg-info-food-tip" :class="'is-' + lastClearedFood.gi">{{ lastClearedFood.tip }}</text>
</view>
</template>
<!-- 默认玩法提示 -->
<template v-else>
<view class="gg-info-banner-icon">
<TongjiIcon name="info" size="md" color="#ffffff" />
</view>
<view class="gg-info-banner-body">
<text class="gg-info-banner-text"><text class="gg-info-banner-kw">认糖</text>点击食物看含糖量 · 消除红色即过关</text>
</view>
</template>
</view>
<!-- 棋盘 -->
<view class="gg-board-hero">
<view class="gg-board-stack" :style="boardStackStyle">
<view class="gg-board-zone">
<view v-if="hintTipVisible" class="gg-no-move-tip">
<TongjiIcon name="alert-triangle" size="sm" color="#ea580c" />
<text class="gg-no-move-tip-text">{{ hintTipText }}</text>
</view>
<view class="gg-board-wrap" :style="boardWrapStyle">
<!-- 提示交换箭头悬浮在两个提示食物正中间 -->
<view
v-if="hintArrowInfo"
class="gg-hint-arrow"
:style="{ left: hintArrowInfo.midX + 'px', top: hintArrowInfo.midY + 'px' }"
>
{{ hintArrowInfo.char }}
</view>
<view class="game-grid" id="game-board" :style="gridStyle" @touchmove.stop.prevent="onBoardTouchMove">
<view
v-for="row in gridRows"
:key="'row-' + row.r"
class="game-row"
:style="rowStyle"
>
<view
v-for="cell in row.cols"
:key="'cell-' + cell.r + '-' + cell.c"
:id="'cell-' + cell.r + '-' + cell.c"
class="game-cell"
:style="cellStyle"
>
<view
v-if="cell.tile"
:key="cell.tile.uid"
:id="'tile-' + cell.tile.uid"
class="game-tile"
hover-class="game-tile--pressed"
:style="[tileStyle(cell.tile.type), tileDragStyle(cell.tile)]"
:class="{
'is-selected': selectedUid === cell.tile.uid,
'is-matched': cell.tile.isMatched,
'is-hint': hintUids.has(cell.tile.uid),
'is-match-fx-3': cell.tile.matchFx === 3,
'is-match-fx-4': cell.tile.matchFx === 4,
'is-match-fx-5': cell.tile.matchFx >= 5,
'is-drop-start': cell.tile.isDropStart,
'is-drop-animate': cell.tile.isDropAnimate,
'is-dragging': dragPreview?.tileUid === cell.tile.uid,
'is-drag-neighbor': dragPreview?.neighborUid === cell.tile.uid
}"
@tap.stop="onTileTap(cell.tile)"
@touchstart.stop="onTileTouchStart($event, cell.tile)"
@touchmove.stop.prevent="onTileTouchMove($event, cell.tile)"
@touchend.stop="onTileTouchEnd($event, cell.tile)"
@touchcancel.stop="onTileTouchCancel"
>
<FoodTileIcon :food="cell.tile.type" size="tile" />
<view class="gg-tile-gi" :class="'is-' + cell.tile.type.gi">{{ giChar(cell.tile.type.gi) }}</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 底部道具栏 -->
<view class="gg-footer" :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-tile">
<view class="gg-booster-icon">
<TongjiIcon :name="booster.icon" size="xl" :color="booster.color" />
</view>
<view class="gg-booster-count" :class="booster.countClass">{{ booster.count }}</view>
</view>
<text class="gg-booster-label">{{ booster.label }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { onLoad, onReady } from '@dcloudio/uni-app'
import TongjiIcon from '../components/TongjiIcon.vue'
import FoodTileIcon from '../components/FoodTileIcon.vue'
import {
FOOD_LIBRARY,
cloneFoodType,
getClearVal,
getFoodTip,
formatEatLabel,
getFoodByKey,
GI_SHORT,
GI_COLOR
} from '../data/gameFoodLibrary.js'
import { warmUpFoodImgs } from '../data/gameFoodImg.js'
import { useGameSfx } from '../composables/useGameSfx.js'
import { useGameFx } from '../composables/useGameFx.js'
import { useGameProgress } from '../composables/useGameProgress.js'
import { getLevelConfig } from '../data/gameLevels.js'
const {
enabled: sfxEnabled,
play: playSfx,
playLayer: playSfxLayer,
playMatch: playMatchSfx,
playDrop: playDropSfx,
playMeter: playMeterSfx,
warmUp: warmUpSfx,
toggleEnabled: toggleSfx
} = useGameSfx()
const { burstParticles, ripples, flash, comboBanner, spawnBurst, spawnRipple, triggerFlash, showComboBanner } = useGameFx()
const { progress: gameProgress, touchDailyPlay, recordWin, recordLoss } = useGameProgress()
const levelConfig = ref(getLevelConfig(1))
const resultStars = ref(0)
const resultStats = ref([])
const sessionMaxCombo = ref(0)
const menuHeight = ref(32)
const menuTop = ref(0)
const ROWS = 5
const COLS = 4
const ROW_AXES = Array.from({ length: ROWS }, (_, i) => i)
const COL_AXES = Array.from({ length: COLS }, (_, i) => i)
/** 5 行 × 4 列棋盘:单格像素由可用区域反算 */
const BOARD_INSET_RPX = 4
/** 棋盘与底部道具栏之间的安全间隙,避免最后一行被遮挡 */
const BOARD_FOOTER_GAP_RPX = 28
const GRID_PAD_RPX = 18
const GRID_GAP_RPX = 14
const SWAP_MS = 280
const MATCH_MS = 400
const GRAVITY_MS = 300
const HINT_DELAY_MS = 5000
const TAP_SLOP_PX = 10
const SWIPE_AXIS_LOCK_PX = 8
const SWIPE_COMMIT_RATIO = 0.22
/** 滑动跟手预览:主格 + 相邻格联动位移 */
const dragPreview = ref(null)
/** 触摸滑动:记录起点,避免与 @tap 重复触发 */
let tileTouchStart = null
let touchHandledInteraction = false
// 每局选中的食材子集;高糖仅 1 种(消除该种即通关)
const SESSION_FOODS = { low: 3, mid: 2, high: 1 }
const activeFoods = ref([])
/** 本局唯一的高糖食物类型 */
const sessionHighFood = ref(null)
const goalDefs = ref([])
/** 解锁高糖三连所需的有效操作次数(约 8 步内才有机会消除红色) */
const HIGH_UNLOCK_MOVES = 7
/** 解锁前棋盘固定保留的高糖数量(<3 无法三连,故无法消除) */
const HIGH_COUNT_LOCKED = 2
/** 解锁后棋盘至少保留的高糖数量(够凑三连) */
const HIGH_COUNT_UNLOCKED = 3
/** 解锁后高糖数量上限,避免刷屏 */
const HIGH_COUNT_MAX = 6
/** 解锁后每次补格生成高糖的概率 */
const HIGH_SPAWN_P = 0.16
/** 非高糖棋子的最小种类数,保证棋盘有足够变化 */
const MIN_NON_HIGH_TYPES = 4
/** 本局玩家有效操作次数(成功消除的交换计为一次) */
const playerMoves = ref(0)
function pickRandom(pool, n) {
const arr = [...pool]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr.slice(0, Math.min(n, arr.length))
}
/**
* 随机挑选本局食材与收集目标。每次调用结果不同:
* 低/中各若干 + 仅 1 种高糖;再从中挑 2 种作为收集目标。
*/
function selectSessionFoods() {
// 固定食材关卡(如第一关):只用配置里的这些食物
const fixedKeys = levelConfig.value.foods
if (Array.isArray(fixedKeys) && fixedKeys.length) {
const fixed = fixedKeys.map((k) => getFoodByKey(k)).filter(Boolean)
const fixedHighs = fixed.filter((f) => f.gi === 'high')
const fixedNonHighs = fixed.filter((f) => f.gi !== 'high')
// 每关只允许一种高糖:从固定高糖里随机取 1 种
const fixedHighPick = pickRandom(fixedHighs, 1)
sessionHighFood.value = fixedHighPick[0] ? cloneFoodType(fixedHighPick[0]) : null
// 非高糖种类不足时,用随机低糖补足,保证棋盘有变化
const nonHigh = [...fixedNonHighs]
if (nonHigh.length < MIN_NON_HIGH_TYPES) {
const used = new Set([...nonHigh, ...fixedHighPick].map((f) => f.key))
const extraLows = FOOD_LIBRARY.filter((f) => f.gi === 'low' && !used.has(f.key))
nonHigh.push(...pickRandom(extraLows, MIN_NON_HIGH_TYPES - nonHigh.length))
}
const active = [...nonHigh, ...fixedHighPick]
activeFoods.value = active
warmUpFoodImgs(active)
// 收集目标从非高糖里挑(高糖以“消除即过关”驱动)
const goalPool = nonHigh.length ? nonHigh : active
const goalFoods = pickRandom(goalPool, Math.min(2, goalPool.length))
const fixedTargets = levelConfig.value.goalTargets || [10, 12]
goalDefs.value = goalFoods.map((f, i) => ({ key: f.key, food: cloneFoodType(f), target: fixedTargets[i] ?? 10 }))
rebuildGoals()
return
}
const lows = FOOD_LIBRARY.filter((f) => f.gi === 'low')
const mids = FOOD_LIBRARY.filter((f) => f.gi === 'mid')
const highs = FOOD_LIBRARY.filter((f) => f.gi === 'high')
const highPick = pickRandom(highs, 1)
sessionHighFood.value = highPick[0] ? cloneFoodType(highPick[0]) : null
const selected = [
...pickRandom(lows, SESSION_FOODS.low),
...pickRandom(mids, SESSION_FOODS.mid),
...highPick
]
activeFoods.value = selected
warmUpFoodImgs(selected)
// 从本局食材中挑 2 种作为收集目标;数量随关卡递增
const goalFoods = pickRandom(selected, 2)
const targets = levelConfig.value.goalTargets || [10, 12]
goalDefs.value = goalFoods.map((f, i) => ({ key: f.key, food: cloneFoodType(f), target: targets[i] ?? 10 }))
rebuildGoals()
}
function rebuildGoals() {
goals.value = goalDefs.value.reduce((acc, g) => {
acc[g.key] = { key: g.key, current: 0, target: g.target }
return acc
}, {})
}
let uidSeq = 0
function nextUid() {
uidSeq += 1
return `t-${uidSeq}`
}
// 格子无底色,仅保留食物图标
function tileStyle() {
return { background: 'transparent' }
}
/** 棋子角标用:低 / 中 / 高(含糖等级) */
function giChar(gi) {
return gi === 'high' ? '高' : gi === 'mid' ? '中' : '低'
}
function gridPadGapPx() {
return {
padPx: Math.round(uni.upx2px(GRID_PAD_RPX)),
gapPx: Math.round(uni.upx2px(GRID_GAP_RPX))
}
}
function calcBoardDimensions(cellPx) {
const { padPx, gapPx } = gridPadGapPx()
return {
width: COLS * cellPx + 2 * padPx + (COLS - 1) * gapPx,
height: ROWS * cellPx + 2 * padPx + (ROWS - 1) * gapPx
}
}
function calcMaxCellSize(zoneW, zoneH) {
const { padPx, gapPx } = gridPadGapPx()
const fromW = (zoneW - 2 * padPx - (COLS - 1) * gapPx) / COLS
const fromH = (zoneH - 2 * padPx - (ROWS - 1) * gapPx) / ROWS
return Math.max(24, Math.floor(Math.min(fromW, fromH)))
}
function getCellMetrics() {
const cell = cellSizePx.value
const { gapPx } = gridPadGapPx()
if (cell <= 0) {
return { cell: 40, stride: 46 }
}
return { cell, stride: cell + gapPx }
}
function tileDragStyle(tile) {
void layoutTick.value
const d = dragPreview.value
if (!d || !tile) return {}
const easing = d.useTransition
? `transform ${SWAP_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`
: 'none'
if (d.tileUid === tile.uid) {
const scale = d.active ? 1.07 : 1
return {
transform: `translate3d(${d.offsetX}px, ${d.offsetY}px, 0) scale(${scale})`,
transition: easing,
zIndex: 20
}
}
if (d.neighborUid === tile.uid) {
return {
transform: `translate3d(${d.nOffsetX}px, ${d.nOffsetY}px, 0)`,
transition: easing,
zIndex: 15
}
}
return {}
}
const instance = getCurrentInstance()
const headerStyle = ref({})
const navStyle = ref({})
const mainStyle = ref({})
const footerStyle = ref({})
const pageStyle = ref({})
const boardWidthPx = ref(0)
const boardHeightPx = ref(0)
const cellSizePx = ref(0)
const layoutTick = ref(0)
const hintTipVisible = ref(false)
const hintTipText = ref('')
const board = ref([])
const score = ref(0)
const scoreAnimating = ref(false)
const glucoseLevel = ref(50)
const screenShaking = ref(false)
const isProcessing = ref(false)
const selectedUid = ref(null)
const matchChain = ref(0)
const movesLeft = ref(25)
const gamePhase = ref('playing')
/** goals | high_cleared */
const winReason = ref('')
const loseReason = ref('')
/** 消除高糖时的红色警示弹窗 */
const highClearAlertVisible = ref(false)
const clearedHighFoodName = ref('')
const resultTitle = computed(() => {
if (gamePhase.value !== 'won') return '再认一认'
if (winReason.value === 'high_cleared') return '高糖已消除!'
return '学会啦!'
})
const resultMessage = ref('')
/** 上一次三连消除的食物(摘要条展示) */
const lastClearedFood = ref(null)
const foodCard = ref(null)
let foodCardTimer = null
const floatingVals = ref([])
const hintUids = ref(new Set())
const hintMove = ref(null) // { t1, t2, isHorizontal }
let hintTimer = null
let startedSound = false
// 目标列表在 selectSessionFoods() 中按本局食材动态生成
const goals = ref({})
const boosters = ref([
{ id: 'insulin', count: 3, label: '胰岛素', icon: 'syringe', color: '#2ecc71', countClass: 'is-green' },
{ id: 'fiber', count: 1, label: '纤维', icon: 'leaf', color: '#f97316', countClass: 'is-orange' },
{ id: 'meal', count: 5, label: '食谱', icon: 'utensils', color: '#f43f5e', countClass: 'is-rose' }
])
const boardWrapStyle = computed(() => {
const w = boardWidthPx.value
const h = boardHeightPx.value
if (w > 0 && h > 0) {
return { width: `${w}px`, height: `${h}px`, flexShrink: '0' }
}
return { width: '100%', maxWidth: '100%' }
})
/** 提示条 / 棋盘共用宽度,保证对齐居中 */
const boardStackStyle = computed(() => {
const w = boardWidthPx.value
if (w > 0) {
return { width: `${w}px`, maxWidth: '100%', flexShrink: '0', boxSizing: 'border-box' }
}
return { width: '100%', maxWidth: '100%', boxSizing: 'border-box' }
})
/**
* 根据棋盘尺寸与单格像素,计算两个提示方块的中心坐标
* 返回 { midX, midY, isHorizontal, char } 供模板渲染箭头
*/
const gridStyle = computed(() => {
const { padPx, gapPx } = gridPadGapPx()
const w = boardWidthPx.value
const h = boardHeightPx.value
const style = {
padding: `${padPx}px`,
gap: `${gapPx}px`
}
if (w > 0 && h > 0) {
style.width = `${w}px`
style.height = `${h+10}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 glucoseStatusText = computed(() => {
if (glucoseLevel.value < 30) return '血糖偏低'
if (glucoseLevel.value > 70) return '血糖过高'
return '血糖稳定'
})
const meterFillClass = computed(() => {
if (glucoseLevel.value < 30) return 'is-low'
if (glucoseLevel.value > 70) return 'is-high'
return 'is-stable'
})
const statusLabelClass = computed(() => {
if (glucoseLevel.value < 30) return 'is-warn-low'
if (glucoseLevel.value > 70) return 'is-warn-high'
return ''
})
const glucoseShortStatus = computed(() => {
if (glucoseLevel.value < 30) return '偏低'
if (glucoseLevel.value > 70) return '偏高'
return '稳定'
})
const glucoseAccentColor = computed(() => {
if (glucoseLevel.value < 30) return '#3b82f6'
if (glucoseLevel.value > 70) return '#dc2626'
return '#ea580c'
})
const glucoseRingStyle = computed(() => {
const pct = glucoseLevel.value
const color = glucoseAccentColor.value
const track = glucoseLevel.value < 30 || glucoseLevel.value > 70 ? '#e2ece6' : '#ffedd5'
return {
background: `conic-gradient(${color} 0 ${pct}%, ${track} ${pct}% 100%)`
}
})
function bumpLayout() {
layoutTick.value += 1
}
function syncTilePos(tile, r, c) {
tile.r = r
tile.c = c
}
function getBoardTile(r, c) {
return board.value[r]?.[c] || null
}
function setBoardTile(r, c, tile) {
if (!board.value[r]) {
board.value[r] = Array(COLS).fill(null)
}
board.value[r][c] = tile || null
if (tile) syncTilePos(tile, r, c)
}
function removeTilesFromBoard(tiles) {
const removed = new Set(tiles)
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && removed.has(tile)) {
tile.isRemoved = true
tile.isMatched = false
tile.matchFx = 0
}
}
}
bumpLayout()
}
function updateGridMetrics() {
bumpLayout()
}
function randomFood() {
const pool = activeFoods.value.length ? activeFoods.value : FOOD_LIBRARY
const highFood = sessionHighFood.value
// 解锁后高糖才会随补格生成;解锁前一律生成非高糖,由 ensureHighPresence 控制高糖数量
const afterUnlock = highFood && playerMoves.value >= HIGH_UNLOCK_MOVES
if (afterUnlock && Math.random() < HIGH_SPAWN_P) {
return cloneFoodType(highFood)
}
const nonHigh = pool.filter((f) => f.gi !== 'high')
const src = nonHigh.length ? nonHigh : pool
const pick = src[Math.floor(Math.random() * src.length)]
return cloneFoodType(pick)
}
/** 本局棋盘上是否还存在高糖食物 */
function boardHasHighGiFood() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && !tile.isRemoved && tile.type.gi === 'high') return true
}
}
return false
}
/** 该位置若变为高糖,是否会与现有高糖形成三连(注入时尽量避免,让玩家自己凑) */
function wouldFormHighRun(r, c) {
const isHigh = (rr, cc) => {
const t = board.value[rr]?.[cc]
return t && !t.isRemoved && t.type.gi === 'high'
}
let h = 1
for (let cc = c - 1; cc >= 0 && isHigh(r, cc); cc--) h++
for (let cc = c + 1; cc < COLS && isHigh(r, cc); cc++) h++
if (h >= 3) return true
let v = 1
for (let rr = r - 1; rr >= 0 && isHigh(rr, c); rr--) v++
for (let rr = r + 1; rr < ROWS && isHigh(rr, c); rr++) v++
return v >= 3
}
function shuffleInPlace(arr) {
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
}
/**
* 维持棋盘上高糖(红色)食物的数量,是“约 8 步才能消除红色”的核心:
* - 解锁前(playerMoves < HIGH_UNLOCK_MOVES)固定保留 2 个,永远无法三连 → 无法消除;
* - 解锁后至少保留 3 个(散开、避免直接成行),玩家凑三连即可消除过关;上限 HIGH_COUNT_MAX。
* 高糖只在此注入/回收,randomFood 解锁前不生成高糖。
*/
function ensureHighPresence() {
const highFood = sessionHighFood.value
if (!highFood) return
const before = playerMoves.value < HIGH_UNLOCK_MOVES
const minTarget = before ? HIGH_COUNT_LOCKED : HIGH_COUNT_UNLOCKED
const maxCap = before ? HIGH_COUNT_LOCKED : HIGH_COUNT_MAX
const highTiles = []
const nonHighTiles = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = board.value[r]?.[c]
if (!t || t.isRemoved) continue
if (t.type.gi === 'high') highTiles.push(t)
else nonHighTiles.push(t)
}
}
if (highTiles.length < minTarget) {
let need = minTarget - highTiles.length
const candidates = shuffleInPlace([...nonHighTiles])
// 优先选不会立即成三连的位置
for (const t of candidates) {
if (need <= 0) break
if (t.type.gi === 'high') continue
if (wouldFormHighRun(t.r, t.c)) continue
t.type = cloneFoodType(highFood)
need--
}
// 仍不够再放宽限制
for (const t of candidates) {
if (need <= 0) break
if (t.type.gi === 'high') continue
t.type = cloneFoodType(highFood)
need--
}
bumpLayout()
} else if (highTiles.length > maxCap) {
const lowMidPool = activeFoods.value.filter((f) => f.gi !== 'high')
let remove = highTiles.length - maxCap
const shuffled = shuffleInPlace([...highTiles])
for (const t of shuffled) {
if (remove <= 0) break
const repl = lowMidPool.length
? lowMidPool[Math.floor(Math.random() * lowMidPool.length)]
: null
if (repl) {
t.type = cloneFoodType(repl)
remove--
}
}
bumpLayout()
}
}
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)
// 注入本关唯一高糖(解锁前固定 2 个,无法三连)
ensureHighPresence()
// 注入高糖可能破坏“有解”性:开局静默兜底,避免出现整盘无法消除的死局
// (仅打乱类型、保持高糖数量不变)
if (!boardHasSolution()) shuffleTypesUntilPlayable()
startHintTimer()
}
function ensureBoardFull(targetBoard) {
const grid = targetBoard || board.value
let changed = false
for (let r = 0; r < ROWS; r++) {
if (!grid[r]) grid[r] = Array(COLS).fill(null)
for (let c = 0; c < COLS; c++) {
const tile = grid[r][c]
if (!tile || tile.isRemoved) {
grid[r][c] = createTile(r, c, randomFood())
changed = true
} else {
syncTilePos(tile, r, c)
tile.isRemoved = false
tile.isDropStart = false
tile.isDropAnimate = false
}
}
}
if (changed && !targetBoard) {
commitBoard(grid.map((row) => [...row]))
}
return grid
}
function clearDropFlags() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile) {
tile.isDropStart = false
tile.isDropAnimate = false
}
}
}
bumpLayout()
}
function applyLevelConfig(cfg) {
levelConfig.value = cfg
movesLeft.value = cfg.moves
score.value = 0
glucoseLevel.value = cfg.startGlucose ?? 50
boosters.value.forEach((b) => {
if (b.id === 'insulin') b.count = cfg.boosters.insulin
else if (b.id === 'fiber') b.count = cfg.boosters.fiber
else if (b.id === 'meal') b.count = cfg.boosters.meal
})
}
function setupLevel(levelId) {
const cfg = getLevelConfig(levelId)
applyLevelConfig(cfg)
sessionMaxCombo.value = 0
winReason.value = ''
loseReason.value = ''
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
lastClearedFood.value = null
playerMoves.value = 0
tileTouchStart = null
clearDragPreview()
selectSessionFoods()
initBoard()
recomputeGlucoseFromBoard()
}
/** 统计棋盘上当前含糖负荷(高 GI 权重更大),用于血糖表信息性反算 */
function calcBoardSugarLoad() {
const loads = { high: 0, mid: 0, low: 0 }
let total = 0
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && !tile.isRemoved) {
loads[tile.type.gi] = (loads[tile.type.gi] || 0) + 1
total++
}
}
}
if (total <= 0) return 50
// 高糖占比越大,血糖表越高;纯信息展示,不判负、不随时间变化
const weighted = loads.high * 1 + loads.mid * 0.5 + loads.low * 0.1
const ratio = weighted / total
return Math.max(5, Math.min(95, Math.round(ratio * 100)))
}
/** 棋盘稳定后,把血糖表同步为当前含糖负荷(消除高糖食物后会变好) */
function recomputeGlucoseFromBoard() {
const target = calcBoardSugarLoad()
glucoseLevel.value = target
}
function restartLevel() {
gamePhase.value = 'playing'
winReason.value = ''
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
lastClearedFood.value = null
resultMessage.value = ''
resultStars.value = 0
resultStats.value = []
isProcessing.value = false
selectedUid.value = null
matchChain.value = 0
screenShaking.value = false
foodCard.value = null
floatingVals.value = []
setupLevel(levelConfig.value.id)
nextTick(() => recalcBoardSize())
}
function goNextLevel() {
gamePhase.value = 'playing'
winReason.value = ''
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
resultMessage.value = ''
resultStars.value = 0
resultStats.value = []
isProcessing.value = false
selectedUid.value = null
matchChain.value = 0
screenShaking.value = false
foodCard.value = null
floatingVals.value = []
setupLevel(gameProgress.value.currentLevel || levelConfig.value.id + 1)
nextTick(() => recalcBoardSize())
}
function getTileByUid(uid) {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && tile.uid === uid) return tile
}
}
return null
}
function isAdjacent(a, b) {
return Math.abs(a.r - b.r) + Math.abs(a.c - b.c) === 1
}
function getSwipeNeighbor(tile, dx, dy) {
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return null
let nr = tile.r
let nc = tile.c
if (Math.abs(dx) >= Math.abs(dy)) {
nc += dx > 0 ? 1 : -1
} else {
nr += dy > 0 ? 1 : -1
}
if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) return null
const neighbor = board.value[nr]?.[nc]
return isActiveTile(neighbor) ? neighbor : null
}
function clampDragDelta(tile, dx, dy, axis) {
const { stride } = getCellMetrics()
const max = stride * 0.95
let cx = axis === 'v' ? 0 : dx
let cy = axis === 'h' ? 0 : dy
const neighbor = getSwipeNeighbor(tile, cx, cy)
if (!neighbor) {
const damp = 0.28
cx *= damp
cy *= damp
} else {
cx = Math.max(-max, Math.min(max, cx))
cy = Math.max(-max, Math.min(max, cy))
}
return { dx: cx, dy: cy, neighbor }
}
function setDragPreview(tile, dx, dy, neighbor, useTransition = false, active = true) {
dragPreview.value = {
tileUid: tile.uid,
neighborUid: neighbor?.uid || null,
offsetX: dx,
offsetY: dy,
nOffsetX: neighbor ? -dx : 0,
nOffsetY: neighbor ? -dy : 0,
useTransition,
active
}
}
async function snapDragBack() {
const d = dragPreview.value
if (!d) return
dragPreview.value = {
...d,
offsetX: 0,
offsetY: 0,
nOffsetX: 0,
nOffsetY: 0,
useTransition: true,
active: false
}
await delay(180)
dragPreview.value = null
}
function clearDragPreview() {
dragPreview.value = null
}
async function animateSwapOffset(t1, t2) {
const { stride } = getCellMetrics()
const fullDx = (t2.c - t1.c) * stride
const fullDy = (t2.r - t1.r) * stride
setDragPreview(t1, fullDx, fullDy, t2, true, false)
await delay(SWAP_MS)
dragPreview.value = null
}
function markTouchHandled() {
touchHandledInteraction = true
setTimeout(() => {
touchHandledInteraction = false
}, 350)
}
function onBoardTouchMove() {
// 阻止棋盘区域滑动带动页面滚动
}
function onTileTouchStart(e, tile) {
if (gamePhase.value !== 'playing') return
if (isProcessing.value || tile.isMatched || tile.isRemoved) return
const pt = e.touches?.[0]
if (!pt) return
clearDragPreview()
tileTouchStart = {
tile,
x: pt.clientX,
y: pt.clientY,
axis: null
}
}
function onTileTouchMove(e, tile) {
if (!tileTouchStart || tileTouchStart.tile?.uid !== tile.uid) return
if (gamePhase.value !== 'playing' || isProcessing.value) return
const pt = e.touches?.[0]
if (!pt) return
let dx = pt.clientX - tileTouchStart.x
let dy = pt.clientY - tileTouchStart.y
if (!tileTouchStart.axis) {
const dist = Math.max(Math.abs(dx), Math.abs(dy))
if (dist >= SWIPE_AXIS_LOCK_PX) {
tileTouchStart.axis = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v'
}
}
if (tileTouchStart.axis === 'h') dy = 0
else if (tileTouchStart.axis === 'v') dx = 0
const { dx: cx, dy: cy, neighbor } = clampDragDelta(tile, dx, dy, tileTouchStart.axis)
setDragPreview(tile, cx, cy, neighbor, false, true)
}
async function onTileTouchEnd(e, tile) {
if (!tileTouchStart || tileTouchStart.tile?.uid !== tile.uid) {
tileTouchStart = null
await snapDragBack()
return
}
if (gamePhase.value !== 'playing' || isProcessing.value || tile.isMatched || tile.isRemoved) {
tileTouchStart = null
await snapDragBack()
return
}
const pt = e.changedTouches?.[0]
if (!pt) {
tileTouchStart = null
await snapDragBack()
return
}
const dx = pt.clientX - tileTouchStart.x
const dy = pt.clientY - tileTouchStart.y
const axis = tileTouchStart.axis
tileTouchStart = null
const adx = Math.abs(dx)
const ady = Math.abs(dy)
ensureStartSound()
resetHintTimer()
markTouchHandled()
if (adx < TAP_SLOP_PX && ady < TAP_SLOP_PX) {
clearDragPreview()
await onTileTap(tile, { fromTouch: true })
return
}
const { stride } = getCellMetrics()
const commitDist = stride * SWIPE_COMMIT_RATIO
const cx = axis === 'v' ? 0 : dx
const cy = axis === 'h' ? 0 : dy
if (Math.max(Math.abs(cx), Math.abs(cy)) < commitDist) {
await snapDragBack()
return
}
const neighbor = getSwipeNeighbor(tile, cx, cy)
if (!neighbor || !isAdjacent(tile, neighbor)) {
await snapDragBack()
return
}
selectedUid.value = null
const fullDx = (neighbor.c - tile.c) * stride
const fullDy = (neighbor.r - tile.r) * stride
setDragPreview(tile, fullDx, fullDy, neighbor, true, false)
await delay(SWAP_MS)
dragPreview.value = null
await processSwap(tile, neighbor, { skipAnimate: true })
}
async function onTileTouchCancel() {
tileTouchStart = null
await snapDragBack()
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function swapTiles(t1, t2) {
const r1 = t1.r
const c1 = t1.c
const r2 = t2.r
const c2 = t2.c
setBoardTile(r1, c1, t2)
setBoardTile(r2, c2, t1)
bumpLayout()
}
function isActiveTile(tile) {
return tile && !tile.isRemoved && !tile.isMatched
}
function findMatchGroups() {
const groups = []
const allTiles = new Set()
function addRun(run) {
if (run.length >= 3) {
groups.push({ tiles: run, size: run.length })
run.forEach((t) => allTiles.add(t))
}
}
for (let r = 0; r < ROWS; r++) {
let c = 0
while (c < COLS) {
const t0 = getBoardTile(r, c)
if (!isActiveTile(t0)) {
c++
continue
}
const key = t0.type.key
const run = [t0]
let k = c + 1
while (k < COLS) {
const t = getBoardTile(r, k)
if (!isActiveTile(t) || t.type.key !== key) break
run.push(t)
k++
}
addRun(run)
c = run.length > 1 ? k : c + 1
}
}
for (let c = 0; c < COLS; c++) {
let r = 0
while (r < ROWS) {
const t0 = getBoardTile(r, c)
if (!isActiveTile(t0)) {
r++
continue
}
const key = t0.type.key
const run = [t0]
let k = r + 1
while (k < ROWS) {
const t = getBoardTile(k, c)
if (!isActiveTile(t) || t.type.key !== key) break
run.push(t)
k++
}
addRun(run)
r = run.length > 1 ? k : r + 1
}
}
return { groups, tiles: Array.from(allTiles) }
}
function findMatches() {
return findMatchGroups().tiles
}
function testMatchAfterSwap(r1, c1, r2, c2) {
const t1 = getBoardTile(r1, c1)
const t2 = getBoardTile(r2, c2)
if (!t1 || !t2) return false
setBoardTile(r1, c1, t2)
setBoardTile(r2, c2, t1)
const has = findMatches().length > 0
setBoardTile(r1, c1, t1)
setBoardTile(r2, c2, t2)
return has
}
function findPossibleMoves() {
const moves = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (c < COLS - 1 && testMatchAfterSwap(r, c, r, c + 1)) {
moves.push({ t1: getBoardTile(r, c), t2: getBoardTile(r, c + 1) })
}
if (r < ROWS - 1 && testMatchAfterSwap(r, c, r + 1, c)) {
moves.push({ t1: getBoardTile(r, c), t2: getBoardTile(r + 1, c) })
}
}
}
return moves
}
/**
* 在不提交到响应式 board 的前提下,检测给定棋盘是否“有解”:
* 既无现成可消除组,且至少存在一个可交换出消除的移动。
*/
function isBoardPlayable(grid) {
const saved = board.value
board.value = grid
const playable = findMatches().length === 0 && findPossibleMoves().length > 0
board.value = saved
return playable
}
function clearHints() {
if (hintTimer) {
clearTimeout(hintTimer)
hintTimer = null
}
hintUids.value = new Set()
hintMove.value = null
hintTipVisible.value = false
}
function resetHintTimer() {
if (hintTimer) {
clearTimeout(hintTimer)
hintTimer = null
}
hintUids.value = new Set()
hintMove.value = null
hintTipVisible.value = false
startHintTimer()
}
function showOperationHint() {
if (isProcessing.value || gamePhase.value !== 'playing') return
const possible = findPossibleMoves()
if (possible.length > 0) {
const move = possible[Math.floor(Math.random() * possible.length)]
hintUids.value = new Set([move.t1.uid, move.t2.uid])
// 保存移动信息,用于渲染食物间的交换箭头
hintMove.value = {
t1: move.t1,
t2: move.t2,
isHorizontal: move.t1.r === move.t2.r
}
hintTipVisible.value = false // 不再显示 banner,箭头已足够
} else {
hintMove.value = null
hintTipText.value = '棋盘暂无可用移动,可点击底部道具重新排列'
hintTipVisible.value = true
}
playSfx('hint', 0.55)
}
function startHintTimer() {
if (gamePhase.value !== 'playing') return
if (hintTimer) clearTimeout(hintTimer)
hintTimer = setTimeout(showOperationHint, HINT_DELAY_MS)
}
/**
* 就地打乱棋盘上所有方块的“食物类型”(位置/对象不变),直到棋盘“有解”:
* 无现成消除组 + 至少存在一个可用移动。仅交换 type,故高糖数量等多重集不变。
* 不带任何 UI 反馈,供初始化等静默场景使用。返回是否成功。
*/
function shuffleTypesUntilPlayable(maxRetry = 80) {
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)
}
}
if (tiles.length < 3) return false
for (let attempt = 0; attempt < maxRetry; 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) {
bumpLayout()
return true
}
}
return false
}
/** 当前棋盘是否“有解”(无现成消除组且至少有一个可用移动) */
function boardHasSolution() {
return findMatches().length === 0 && findPossibleMoves().length > 0
}
/**
* 棋盘死锁自动被测与修复。
* 使用 Fisher-Yates 打乱食物类型,最多重试 maxRetry 次。
* 调用时机:processSwap / onBooster 结束后
*/
async function reshuffleIfStuck() {
if (gamePhase.value !== 'playing') return
// 已有解(无现成消除组 + 存在可用移动)则无需重排
if (boardHasSolution()) 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 (_) {}
// 就地打乱类型直到有解;失败则回退到全棋盘重新生成
if (!shuffleTypesUntilPlayable(60)) {
initBoard()
return
}
bumpLayout()
await delay(180)
}
/**
* 消除组视觉特效(适老化:柔和为主,去掉震屏与强闪屏):
* 3 连:柔光圈 + 少量星屑
* 4 连:彩虹星屑 + 双层光环 + 极轻闪屏
* 5 连+:彩虹光环 + 轻闪屏(不震屏)
* 支持对每一组同时触发(并行查询中心坐标)。
*/
async function spawnGroupFx(groups, maxSize) {
if (maxSize < 3 || !groups.length) return
// 并行查询所有层组的中心坐标
const centers = (await Promise.all(
groups.map(async (g) => {
const ct = g.tiles[Math.floor(g.tiles.length / 2)]
const c = await queryTileCenter(ct)
return c ? { center: c, color: ct.type.color, size: g.size } : null
})
)).filter(Boolean)
if (!centers.length) return
const main = centers.reduce((a, b) => (b.size > a.size ? b : a))
if (maxSize >= 5) {
// 5 连+:彩虹光环(柔和,不震屏)
centers.forEach(({ center, color }) => {
spawnRipple(center.x, center.y, color, 4)
spawnBurst(center.x, center.y, color, 16, { rainbow: true, distMax: 130, sizeMax: 18 })
})
spawnRipple(main.center.x, main.center.y, '#FFD54A', 6)
spawnRipple(main.center.x, main.center.y, '#ffffff', 2, { life: 520 })
spawnBurst(main.center.x, main.center.y, '#FFD54A', 18, { rainbow: true, sizeMax: 22, distMax: 160 })
triggerFlash('#fff7d6', 0.18)
} else if (maxSize >= 4) {
// 4 连:彩虹星屑 + 双层光环(不震屏)
centers.forEach(({ center, color }) => {
spawnRipple(center.x, center.y, color, 3)
spawnBurst(center.x, center.y, color, 12, { rainbow: true })
})
spawnRipple(main.center.x, main.center.y, '#ffffff', 1, { life: 480 })
spawnBurst(main.center.x, main.center.y, '#FFE06B', 6, { sizeMax: 18 })
triggerFlash('#ffffff', 0.12)
} else {
// 3 连:轻量星屑 + 柔光圈
centers.forEach(({ center, color }) => {
spawnRipple(center.x, center.y, color, 1, { life: 460 })
spawnBurst(center.x, center.y, color, 6, { sizeMax: 12, distMax: 68, life: 640 })
})
}
}
async function queryTileCenter(tile) {
return new Promise((resolve) => {
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select(`#tile-${tile.uid}`).boundingClientRect((rect) => {
if (rect && rect.width > 0) {
resolve({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 })
} else {
resolve(null)
}
}).exec()
})
}
/** 点击食物:弹出大字科普卡,强化「这是高/中/低糖食物」的认知 */
function showFoodTooltip(tile) {
const t = tile.type
if (foodCardTimer) clearTimeout(foodCardTimer)
foodCard.value = {
name: t.name,
gi: t.gi,
giShort: GI_SHORT[t.gi] || '',
color: GI_COLOR[t.gi] || '#16a34a',
tip: getFoodTip(t)
}
foodCardTimer = setTimeout(() => {
foodCard.value = null
}, 3200)
}
function updateGoal(key) {
const g = goals.value[key]
if (g && g.current < g.target) {
g.current += 1
playSfx('goalTick', 0.9)
}
}
/** 记录本批消除中代表性食物(取最大匹配组) */
function recordLastClearedFood(groups) {
if (!Array.isArray(groups) || !groups.length) return
const main = groups.reduce((a, b) => ((b.size || 0) > (a.size || 0) ? b : a))
const rep = main?.tiles?.[Math.floor((main.tiles.length || 1) / 2)]
const t = rep?.type
if (!t) return
const gi = t.gi || 'low'
lastClearedFood.value = {
name: t.name || '食物',
gi,
giShort: GI_SHORT[gi] || '低糖',
eatHint: formatEatLabel(t),
tip: getFoodTip(t),
color: GI_COLOR[gi] || '#64748b',
food: cloneFoodType(t)
}
}
function updateScore(pts) {
const bonus = Math.round(pts)
score.value += bonus
if (bonus >= 40) playSfx('score', Math.min(1.15, 0.85 + bonus / 200))
scoreAnimating.value = false
nextTick(() => {
scoreAnimating.value = true
setTimeout(() => { scoreAnimating.value = false }, 400)
})
}
function updateMeter(totalVal) {
glucoseLevel.value = Math.max(5, Math.min(95, glucoseLevel.value + totalVal))
playMeterSfx(totalVal, glucoseLevel.value)
}
/** 消除时给认知正反馈:在最大一组中心浮出「认识了 X·高糖」 */
function showLearnFx(groups) {
if (!groups || !groups.length) return
const g = groups.reduce((a, b) => (b.size > a.size ? b : a))
const rep = g.tiles[Math.floor(g.tiles.length / 2)]
if (!rep) return
const t = rep.type
queryTileCenter(rep).then((center) => {
if (!center) return
const id = `learn-${Date.now()}`
floatingVals.value.push({
id,
text: `认识了 ${t.name}·${GI_SHORT[t.gi] || ''}`,
color: GI_COLOR[t.gi] || '#16a34a',
x: center.x,
y: center.y - 30
})
setTimeout(() => {
floatingVals.value = floatingVals.value.filter((v) => v.id !== id)
}, 1400)
})
}
function checkGameEnd() {
if (gamePhase.value !== 'playing') return
// 唯一通关条件:消除本关唯一的高糖(红色)食物
if (sessionHighFood.value && !boardHasHighGiFood()) {
endGame('won', {
reason: 'high_cleared',
foodName: sessionHighFood.value.name
})
}
}
/** 三连消除含高糖:先红色警示弹窗,再进入结算 */
async function finishHighSugarClear(foodType) {
const name = foodType?.name || sessionHighFood.value?.name || '高糖食物'
clearedHighFoodName.value = name
highClearAlertVisible.value = true
triggerFlash('rgba(220, 38, 38, 0.22)', 0.35)
await delay(2000)
highClearAlertVisible.value = false
endGame('won', { reason: 'high_cleared', foodName: name })
}
function buildResultStats() {
const learned = goalList.value.reduce((s, g) => s + g.current, 0)
return [
{ label: '本组认识食物', value: `${goalList.value.length} 种` },
{ label: '消除练习', value: `${learned} 次` },
{ label: '得分', value: scoreText.value }
]
}
function endGame(phase, opts = {}) {
gamePhase.value = phase
clearHints()
if (phase === 'won') {
winReason.value = opts.reason || 'goals'
const { stars, newBest } = recordWin({
levelId: levelConfig.value.id,
movesLeft: movesLeft.value,
glucoseLevel: glucoseLevel.value,
score: score.value,
levelConfig: levelConfig.value,
maxCombo: sessionMaxCombo.value
})
resultStars.value = stars
if (winReason.value === 'high_cleared') {
const name = opts.foodName || sessionHighFood.value?.name || '高糖食物'
resultMessage.value =
`您已消除高糖食物「${name}」!这类食物升糖快,日常要少吃。`
resultStats.value = [
{ label: '本局高糖食物', value: name },
{ label: '消除练习', value: '达标' },
{ label: '得分', value: scoreText.value }
]
} else {
const learnedNames = goalList.value.map((g) => g.food?.name).filter(Boolean).join('、')
resultMessage.value = learnedNames
? `这一组您认识了:${learnedNames}。记住它们的含糖高低啦!`
: '又认识了几种食物的含糖高低,真棒!'
if (newBest && gameProgress.value.maxUnlocked > levelConfig.value.id) {
resultMessage.value += ` 已解锁第 ${levelConfig.value.id + 1} 组。`
}
resultStats.value = buildResultStats()
}
playSfxLayer('win', 'winStinger', 120, 0.7)
} else {
recordLoss()
resultStars.value = 0
resultMessage.value = '再认一认这些食物,下次更熟练。'
resultStats.value = buildResultStats()
playSfx('lose')
}
}
async function onTileTap(tile, opts = {}) {
if (!opts.fromTouch && touchHandledInteraction) return
if (gamePhase.value !== 'playing') return
if (isProcessing.value || tile.isMatched || tile.isRemoved) return
ensureStartSound()
resetHintTimer()
showFoodTooltip(tile)
if (!selectedUid.value) {
selectedUid.value = tile.uid
playSfx('select')
return
}
const selected = getTileByUid(selectedUid.value)
if (!selected || selected.isMatched || selected.isRemoved) {
selectedUid.value = tile.uid
return
}
if (selected.uid === tile.uid) {
selectedUid.value = null
playSfx('deselect')
return
}
if (!isAdjacent(selected, tile)) {
selectedUid.value = tile.uid
playSfx('select')
return
}
selectedUid.value = null
await processSwap(selected, tile)
}
async function processSwap(t1, t2, opts = {}) {
isProcessing.value = true
matchChain.value = 0
playSfx('swap')
if (!opts.skipAnimate) {
await animateSwapOffset(t1, t2)
}
swapTiles(t1, t2)
const matches = findMatches()
if (matches.length > 0) {
movesLeft.value = Math.max(0, movesLeft.value - 1)
playerMoves.value += 1
const endedByHigh = await clearAndRefill()
if (endedByHigh) {
isProcessing.value = false
return
}
// 落子算法已保证“下落即有可消除走法”;此处仅作极端兜底(静默、无横幅)
if (!boardHasSolution()) shuffleTypesUntilPlayable()
} else {
await animateSwapOffset(t1, t2)
swapTiles(t1, t2)
playSfx('swapFail')
}
isProcessing.value = false
resetHintTimer()
checkGameEnd()
}
async function clearAndRefill() {
const { groups, tiles } = findMatchGroups()
if (!tiles.length) return false
const clearedHighTiles = tiles.filter((t) => t.type.gi === 'high')
recordLastClearedFood(groups)
let totalVal = 0
let maxSize = 3
tiles.forEach((t) => { t.matchFx = 0 })
groups.forEach((g) => {
maxSize = Math.max(maxSize, g.size)
const fx = g.size >= 5 ? 5 : g.size >= 4 ? 4 : 3
g.tiles.forEach((t) => {
t.isMatched = true
t.matchFx = Math.max(t.matchFx || 0, fx)
})
})
// 用去重后的 tiles 累加控糖效果/目标,避免 L/T 交叉点被横竖两组重复计分
tiles.forEach((t) => {
totalVal += getClearVal(t.type)
updateGoal(t.type.key)
})
playMatchSfx({ matchCount: maxSize, chain: matchChain.value, tiles })
if (matchChain.value >= 1) {
const chainNum = matchChain.value + 1
const pts = tiles.length * 20 * chainNum
showComboBanner(chainNum, pts)
if (chainNum > sessionMaxCombo.value) sessionMaxCombo.value = chainNum
}
// 震屏由 spawnGroupFx 内部统一管理,此处不重复调用
await spawnGroupFx(groups, maxSize)
updateScore(tiles.length * 20 * (matchChain.value + 1))
updateMeter(totalVal)
showLearnFx(groups)
bumpLayout()
await delay(MATCH_MS)
removeTilesFromBoard(tiles)
if (clearedHighTiles.length > 0) {
await finishHighSugarClear(clearedHighTiles[0].type)
return true
}
await applyGravity()
const next = findMatchGroups()
if (next.tiles.length > 0) {
matchChain.value += 1
await delay(200)
const ended = await clearAndRefill()
if (ended) return true
}
return false
}
/* ——— 落子前在 next 网格上做的辅助判定(不触动可见的 board.value ——— */
function gridIsHigh(grid, r, c) {
const t = grid[r]?.[c]
return t && !t.isRemoved && t.type.gi === 'high'
}
function gridWouldFormHighRun(grid, r, c) {
let h = 1
for (let cc = c - 1; cc >= 0 && gridIsHigh(grid, r, cc); cc--) h++
for (let cc = c + 1; cc < COLS && gridIsHigh(grid, r, cc); cc++) h++
if (h >= 3) return true
let v = 1
for (let rr = r - 1; rr >= 0 && gridIsHigh(grid, rr, c); rr--) v++
for (let rr = r + 1; rr < ROWS && gridIsHigh(grid, rr, c); rr++) v++
return v >= 3
}
/** 在 grid 上是否存在“现成可消除组” */
function gridHasCurrentMatch(grid) {
const saved = board.value
board.value = grid
const has = findMatches().length > 0
board.value = saved
return has
}
/** 在 grid 上是否存在“可交换出消除的走法” */
function gridHasMove(grid) {
const saved = board.value
board.value = grid
const ok = findPossibleMoves().length > 0
board.value = saved
return ok
}
/**
* 仅在 next 网格上调整本局高糖数量(只动“新生成的方块”,玩家尚未看到,故无可见突变):
* 解锁前固定 2 个、解锁后 3~HIGH_COUNT_MAX 个;注入时避免直接连成三连。
*/
function balanceHighInRefill(next, newTiles) {
const highFood = sessionHighFood.value
if (!highFood) return
const before = playerMoves.value < HIGH_UNLOCK_MOVES
const minTarget = before ? HIGH_COUNT_LOCKED : HIGH_COUNT_UNLOCKED
const maxCap = before ? HIGH_COUNT_LOCKED : HIGH_COUNT_MAX
let high = 0
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) if (gridIsHigh(next, r, c)) high++
}
const lowMidPool = activeFoods.value.filter((f) => f.gi !== 'high')
if (high > maxCap) {
for (const t of shuffleInPlace([...newTiles])) {
if (high <= maxCap) break
if (t.type.gi !== 'high') continue
const repl = lowMidPool.length ? lowMidPool[Math.floor(Math.random() * lowMidPool.length)] : null
if (repl) { t.type = cloneFoodType(repl); high-- }
}
}
if (high < minTarget) {
const cands = shuffleInPlace([...newTiles])
for (const t of cands) {
if (high >= minTarget) break
if (t.type.gi === 'high') continue
if (gridWouldFormHighRun(next, t.r, t.c)) continue
t.type = cloneFoodType(highFood)
high++
}
for (const t of cands) {
if (high >= minTarget) break
if (t.type.gi === 'high') continue
t.type = cloneFoodType(highFood)
high++
}
}
}
/**
* 保证“落下后必有可消除的走法”:只重 roll 新方块里的非高糖类型(落子前完成,无可见重排)。
* 若 next 已有现成消除组(即将触发连锁),则交给连锁处理,最终落子时再保证。
*/
function ensureRefillPlayable(next, newTiles) {
if (gridHasCurrentMatch(next)) return
if (gridHasMove(next)) return
const nonHighPool = activeFoods.value.filter((f) => f.gi !== 'high')
const rerollable = newTiles.filter((t) => t.type.gi !== 'high')
if (nonHighPool.length && rerollable.length) {
for (let attempt = 0; attempt < 100; attempt++) {
rerollable.forEach((t) => {
t.type = cloneFoodType(nonHighPool[Math.floor(Math.random() * nonHighPool.length)])
})
if (gridHasMove(next)) return
}
}
// 极端兜底:静默打乱整盘类型(保持多重集/高糖数量),无任何横幅提示
silentShuffleGrid(next)
}
/** 在 next 网格上静默打乱类型直至“无现成消除组且有可用走法”,仅作极端兜底 */
function silentShuffleGrid(next) {
const tiles = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = next[r]?.[c]
if (t && !t.isRemoved) tiles.push(t)
}
}
if (tiles.length < 3) return
const saved = board.value
board.value = next
for (let attempt = 0; attempt < 80; 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) break
}
board.value = saved
}
async function applyGravity() {
const next = Array.from({ length: ROWS }, () => Array(COLS).fill(null))
const dropTiles = []
const newTiles = []
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)
newTiles.push(tile)
}
}
ensureBoardFull(next)
// 落子前只调整“新方块”:①按解锁进度维持高糖数量 ②保证落下后必有可消除走法
// 这样棋盘随下落即更新,无需“无解时整盘自动重排”。
balanceHighInRefill(next, newTiles)
ensureRefillPlayable(next, newTiles)
commitBoard(next)
await nextTick()
if (dropTiles.length > 0) {
playDropSfx(dropTiles.length)
dropTiles.forEach((t) => {
t.isDropStart = false
t.isDropAnimate = true
})
bumpLayout()
await delay(GRAVITY_MS)
}
clearDropFlags()
}
async function onBooster(booster) {
if (gamePhase.value !== 'playing' || isProcessing.value) return
if (booster.count <= 0) {
uni.showToast({ title: '该道具已用尽', icon: 'none' })
return
}
ensureStartSound()
if (booster.id === 'insulin') {
if (glucoseLevel.value <= 5) {
uni.showToast({ title: '当前血糖正常,无需使用胰岛素', icon: 'none' })
return
}
isProcessing.value = true
booster.count--
playSfx('insulin')
const oldVal = glucoseLevel.value
glucoseLevel.value = Math.max(5, glucoseLevel.value - 25)
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select('.game-grid').boundingClientRect((rect) => {
if (rect && rect.width > 0) {
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const valId = `fv-${Date.now()}`
floatingVals.value.push({
id: valId,
text: `血糖 -${oldVal - glucoseLevel.value}`,
color: '#2ecc71',
x: cx,
y: cy - 20
})
setTimeout(() => {
floatingVals.value = floatingVals.value.filter((t) => t.id !== valId)
}, 1200)
spawnRipple(cx, cy, '#2ecc71', 3)
spawnBurst(cx, cy, '#2ecc71', 20)
}
}).exec()
await delay(500)
isProcessing.value = false
resetHintTimer()
} else if (booster.id === 'fiber') {
const highGiTiles = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && !tile.isRemoved && tile.type.gi === 'high') {
highGiTiles.push(tile)
}
}
}
if (highGiTiles.length === 0) {
uni.showToast({ title: '场上暂无高GI食物,无需使用膳食纤维', icon: 'none' })
return
}
isProcessing.value = true
booster.count--
playSfx('fiber')
// 只能替换成“本局在用”的低GI食材,否则会引入无法消除的新图标
let lowGiFoods = activeFoods.value.filter(f => f.gi === 'low')
if (!lowGiFoods.length) lowGiFoods = activeFoods.value.length ? activeFoods.value : FOOD_LIBRARY
highGiTiles.forEach((tile) => {
const randomLowFood = cloneFoodType(lowGiFoods[Math.floor(Math.random() * lowGiFoods.length)])
tile.type = randomLowFood
queryTileCenter(tile).then((center) => {
if (center) {
spawnRipple(center.x, center.y, '#f97316', 1)
spawnBurst(center.x, center.y, '#f97316', 6)
}
})
})
const dropVal = highGiTiles.length * 3
const oldVal = glucoseLevel.value
glucoseLevel.value = Math.max(5, glucoseLevel.value - dropVal)
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select('.game-grid').boundingClientRect((rect) => {
if (rect && rect.width > 0) {
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const valId = `fv-${Date.now()}`
floatingVals.value.push({
id: valId,
text: `膳食纤维:血糖 -${oldVal - glucoseLevel.value}`,
color: '#f97316',
x: cx,
y: cy - 20
})
setTimeout(() => {
floatingVals.value = floatingVals.value.filter((t) => t.id !== valId)
}, 1500)
}
}).exec()
await delay(600)
const matches = findMatches()
if (matches.length > 0) {
const endedByHigh = await clearAndRefill()
if (endedByHigh) {
isProcessing.value = false
return
}
}
// 恢复本关高糖数量,避免纤维清空高糖直接跳过“8 步”门槛
ensureHighPresence()
// 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()
// 维持本关高糖数量
ensureHighPresence()
// 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: '18rpx'
}
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.select('.gg-board-hero').boundingClientRect()
query.exec((res) => {
try {
const sys = uni.getSystemInfoSync()
const header = res?.[0]
const footer = res?.[1]
const hero = res?.[2]
const headerBottom = Math.ceil(header?.bottom ?? header?.height ?? uni.upx2px(360))
// 棋盘真正可用区从进度条/信息横幅之下(board-hero 顶部)算起
const boardAreaTop = Math.ceil(hero?.top ?? headerBottom)
// 底部道具栏较高,测量失败时用更保守的兜底高度,避免最后一行被遮挡
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)
// gg-main 左右内边距各 20rpx,棋盘可用宽度需相应收窄
const sidePad = uni.upx2px(20)
const zoneW = sys.windowWidth - sidePad * 2
// 预留底部安全间隙,棋盘不贴到道具栏
const zoneH = Math.max(0, footerTop - boardAreaTop - footerGap)
// 5 行 × 4 列:宽高分别约束,取较小单格像素
cellSizePx.value = calcMaxCellSize(zoneW - inset * 2, zoneH - inset * 2)
const boardDim = calcBoardDimensions(cellSizePx.value)
boardWidthPx.value = boardDim.width
boardHeightPx.value = boardDim.height
const areaBottom = Math.max(0, sys.windowHeight - footerTop + footerGap)
mainStyle.value = {
top: `${headerBottom}px`,
bottom: `${areaBottom}px`
}
pageStyle.value = {
...pageStyle.value,
'--board-area-top': `${headerBottom}px`,
'--board-area-bottom': `${areaBottom}px`,
'--cell-size': `${cellSizePx.value}px`,
'--tile-cell-px': `${cellSizePx.value}px`,
'--tile-emoji-size': `${Math.floor(cellSizePx.value * 0.82)}px`
}
updateGridMetrics()
} catch (e) {}
})
}
function goBack() {
uni.navigateBack({
fail() {
uni.redirectTo({ url: '/tongji/pages/more' })
}
})
}
onLoad(() => {
initLayout()
touchDailyPlay()
const lv = gameProgress.value.currentLevel || 1
setupLevel(Math.min(lv, gameProgress.value.maxUnlocked || lv))
})
onReady(() => {
recalcBoardSize()
// 等头/底栏(含安全区、道具栏)完全布局后再量一次,确保最后一行不被遮挡
setTimeout(() => recalcBoardSize(), 180)
})
onMounted(() => {
resetHintTimer()
})
onUnmounted(() => {
clearHints()
if (foodCardTimer) clearTimeout(foodCardTimer)
})
</script>
<style lang="scss" scoped>
@import '../styles/game-stitch.scss';
</style>