<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()
    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)
}

/**
 * 棋盘死锁自动被测与修复。
 * 使用 Fisher-Yates 打乱食物类型，最多重试 maxRetry 次。
 * 调用时机：processSwap / onBooster 结束后
 */
async function reshuffleIfStuck() {
    if (gamePhase.value !== 'playing') return
    // 已有解（无现成消除组 + 存在可用移动）则无需重排
    if (findMatches().length === 0 && findPossibleMoves().length > 0) return

    // 给玩家反馈
    playSfx('reshuffle', 0.75)
    try {
        const sys = uni.getSystemInfoSync()
        const cx = sys.windowWidth / 2
        const cy = sys.windowHeight / 2
        const id = `rs-${Date.now()}`
        floatingVals.value.push({ id, text: '🔀 棋盘自动重排!', color: '#006c49', x: cx, y: cy })
        setTimeout(() => { floatingVals.value = floatingVals.value.filter(v => v.id !== id) }, 1400)
    } catch (_) {}

    // 收集棋盘上所有有效方块（保持 tile 对象与位置不变，仅打乱其食物类型）
    const tiles = []
    for (let r = 0; r < ROWS; r++) {
        for (let c = 0; c < COLS; c++) {
            const t = board.value[r]?.[c]
            if (t && !t.isRemoved) tiles.push(t)
        }
    }

    // 反复打乱类型并就地试写，直到“有解且无现成三连”；
    // tiles 已是 board.value 中的同一批对象，故 findMatches/findPossibleMoves 可直接复用
    const MAX_RETRY = 60
    let ok = false
    for (let attempt = 0; attempt < MAX_RETRY; attempt++) {
        for (let i = tiles.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [tiles[i].type, tiles[j].type] = [tiles[j].type, tiles[i].type]
        }
        if (findMatches().length === 0 && findPossibleMoves().length > 0) {
            ok = true
            break
        }
    }

    if (!ok) {
        // 就地洗牌无法洗出可玩布局，回退到全棋盘重新生成
        initBoard()
        return
    }

    bumpLayout()
    await delay(180)
}

/**
 * 消除组视觉特效（适老化：柔和为主，去掉震屏与强闪屏）：
 *   3 连：柔光圈 + 少量星屑
 *   4 连：彩虹星屑 + 双层光环 + 极轻闪屏
 *   5 连+：彩虹光环 + 轻闪屏（不震屏）
 * 支持对每一组同时触发（并行查询中心坐标）。
 */
async function spawnGroupFx(groups, maxSize) {
    if (maxSize < 3 || !groups.length) return

    // 并行查询所有层组的中心坐标
    const centers = (await Promise.all(
        groups.map(async (g) => {
            const ct = g.tiles[Math.floor(g.tiles.length / 2)]
            const c = await queryTileCenter(ct)
            return c ? { center: c, color: ct.type.color, size: g.size } : null
        })
    )).filter(Boolean)

    if (!centers.length) return
    const main = centers.reduce((a, b) => (b.size > a.size ? b : a))

    if (maxSize >= 5) {
        // 5 连+：彩虹光环（柔和，不震屏）
        centers.forEach(({ center, color }) => {
            spawnRipple(center.x, center.y, color, 4)
            spawnBurst(center.x, center.y, color, 16, { rainbow: true, distMax: 130, sizeMax: 18 })
        })
        spawnRipple(main.center.x, main.center.y, '#FFD54A', 6)
        spawnRipple(main.center.x, main.center.y, '#ffffff', 2, { life: 520 })
        spawnBurst(main.center.x, main.center.y, '#FFD54A', 18, { rainbow: true, sizeMax: 22, distMax: 160 })
        triggerFlash('#fff7d6', 0.18)
    } else if (maxSize >= 4) {
        // 4 连：彩虹星屑 + 双层光环（不震屏）
        centers.forEach(({ center, color }) => {
            spawnRipple(center.x, center.y, color, 3)
            spawnBurst(center.x, center.y, color, 12, { rainbow: true })
        })
        spawnRipple(main.center.x, main.center.y, '#ffffff', 1, { life: 480 })
        spawnBurst(main.center.x, main.center.y, '#FFE06B', 6, { sizeMax: 18 })
        triggerFlash('#ffffff', 0.12)
    } else {
        // 3 连：轻量星屑 + 柔光圈
        centers.forEach(({ center, color }) => {
            spawnRipple(center.x, center.y, color, 1, { life: 460 })
            spawnBurst(center.x, center.y, color, 6, { sizeMax: 12, distMax: 68, life: 640 })
        })
    }
}

async function queryTileCenter(tile) {
    return new Promise((resolve) => {
        const query = uni.createSelectorQuery().in(instance?.proxy)
        query.select(`#tile-${tile.uid}`).boundingClientRect((rect) => {
            if (rect && rect.width > 0) {
                resolve({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 })
            } else {
                resolve(null)
            }
        }).exec()
    })
}

/** 点击食物：弹出大字科普卡，强化「这是高/中/低糖食物」的认知 */
function showFoodTooltip(tile) {
    const t = tile.type
    if (foodCardTimer) clearTimeout(foodCardTimer)
    foodCard.value = {
        name: t.name,
        gi: t.gi,
        giShort: GI_SHORT[t.gi] || '',
        color: GI_COLOR[t.gi] || '#16a34a',
        tip: getFoodTip(t)
    }
    foodCardTimer = setTimeout(() => {
        foodCard.value = null
    }, 3200)
}

function updateGoal(key) {
    const g = goals.value[key]
    if (g && g.current < g.target) {
        g.current += 1
        playSfx('goalTick', 0.9)
    }
}

/** 记录本批消除中代表性食物（取最大匹配组） */
function recordLastClearedFood(groups) {
    if (!Array.isArray(groups) || !groups.length) return
    const main = groups.reduce((a, b) => ((b.size || 0) > (a.size || 0) ? b : a))
    const rep = main?.tiles?.[Math.floor((main.tiles.length || 1) / 2)]
    const t = rep?.type
    if (!t) return
    const gi = t.gi || 'low'
    lastClearedFood.value = {
        name: t.name || '食物',
        gi,
        giShort: GI_SHORT[gi] || '低糖',
        eatHint: formatEatLabel(t),
        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
        }
        // 按解锁进度维持高糖数量（到第 7 步起补到可消除的 3 个）
        ensureHighPresence()
        // 所有连消结束后，检查棋盘是否死锁并自动重排
        await reshuffleIfStuck()
    } else {
        await animateSwapOffset(t1, t2)
        swapTiles(t1, t2)
        playSfx('swapFail')
    }

    isProcessing.value = false
    resetHintTimer()
    checkGameEnd()
}

async function clearAndRefill() {
    const { groups, tiles } = findMatchGroups()
    if (!tiles.length) return false

    const clearedHighTiles = tiles.filter((t) => t.type.gi === 'high')
    recordLastClearedFood(groups)

    let totalVal = 0
    let maxSize = 3
    tiles.forEach((t) => { t.matchFx = 0 })

    groups.forEach((g) => {
        maxSize = Math.max(maxSize, g.size)
        const fx = g.size >= 5 ? 5 : g.size >= 4 ? 4 : 3
        g.tiles.forEach((t) => {
            t.isMatched = true
            t.matchFx = Math.max(t.matchFx || 0, fx)
        })
    })

    // 用去重后的 tiles 累加控糖效果/目标，避免 L/T 交叉点被横竖两组重复计分
    tiles.forEach((t) => {
        totalVal += getClearVal(t.type)
        updateGoal(t.type.key)
    })

    playMatchSfx({ matchCount: maxSize, chain: matchChain.value, tiles })
    if (matchChain.value >= 1) {
        const chainNum = matchChain.value + 1
        const pts = tiles.length * 20 * chainNum
        showComboBanner(chainNum, pts)
        if (chainNum > sessionMaxCombo.value) sessionMaxCombo.value = chainNum
    }
    // 震屏由 spawnGroupFx 内部统一管理，此处不重复调用
    await spawnGroupFx(groups, maxSize)

    updateScore(tiles.length * 20 * (matchChain.value + 1))
    updateMeter(totalVal)
    showLearnFx(groups)

    bumpLayout()
    await delay(MATCH_MS)

    removeTilesFromBoard(tiles)

    if (clearedHighTiles.length > 0) {
        await finishHighSugarClear(clearedHighTiles[0].type)
        return true
    }

    await applyGravity()

    const next = findMatchGroups()
    if (next.tiles.length > 0) {
        matchChain.value += 1
        await delay(200)
        const ended = await clearAndRefill()
        if (ended) return true
    }
    return false
}

async function applyGravity() {
    const next = Array.from({ length: ROWS }, () => Array(COLS).fill(null))
    const dropTiles = []

    for (let c = 0; c < COLS; c++) {
        const surviving = []
        for (let r = 0; r < ROWS; r++) {
            const tile = board.value[r]?.[c]
            if (tile && !tile.isRemoved) surviving.push({ tile, fromR: r })
        }
        const gap = ROWS - surviving.length

        surviving.forEach(({ tile, fromR }, i) => {
            const nr = gap + i
            tile.isMatched = false
            tile.isRemoved = false
            tile.matchFx = 0
            tile.isDropStart = fromR !== nr
            tile.isDropAnimate = false
            syncTilePos(tile, nr, c)
            next[nr][c] = tile
            if (tile.isDropStart) dropTiles.push(tile)
        })

        for (let r = 0; r < gap; r++) {
            const tile = createTile(r, c, randomFood())
            tile.isDropStart = true
            tile.isDropAnimate = false
            next[r][c] = tile
            dropTiles.push(tile)
        }
    }

    ensureBoardFull(next)
    commitBoard(next)
    await nextTick()

    if (dropTiles.length > 0) {
        playDropSfx(dropTiles.length)
        dropTiles.forEach((t) => {
            t.isDropStart = false
            t.isDropAnimate = true
        })
        bumpLayout()
        await delay(GRAVITY_MS)
    }
    clearDropFlags()
}

async function onBooster(booster) {
    if (gamePhase.value !== 'playing' || isProcessing.value) return
    if (booster.count <= 0) {
        uni.showToast({ title: '该道具已用尽', icon: 'none' })
        return
    }

    ensureStartSound()

    if (booster.id === 'insulin') {
        if (glucoseLevel.value <= 5) {
            uni.showToast({ title: '当前血糖正常，无需使用胰岛素', icon: 'none' })
            return
        }

        isProcessing.value = true
        booster.count--
        playSfx('insulin')

        const oldVal = glucoseLevel.value
        glucoseLevel.value = Math.max(5, glucoseLevel.value - 25)

        const query = uni.createSelectorQuery().in(instance?.proxy)
        query.select('.game-grid').boundingClientRect((rect) => {
            if (rect && rect.width > 0) {
                const cx = rect.left + rect.width / 2
                const cy = rect.top + rect.height / 2

                const valId = `fv-${Date.now()}`
                floatingVals.value.push({
                    id: valId,
                    text: `血糖 -${oldVal - glucoseLevel.value}`,
                    color: '#2ecc71',
                    x: cx,
                    y: cy - 20
                })
                setTimeout(() => {
                    floatingVals.value = floatingVals.value.filter((t) => t.id !== valId)
                }, 1200)

                spawnRipple(cx, cy, '#2ecc71', 3)
                spawnBurst(cx, cy, '#2ecc71', 20)
            }
        }).exec()

        await delay(500)
        isProcessing.value = false
        resetHintTimer()
    } else if (booster.id === 'fiber') {
        const highGiTiles = []
        for (let r = 0; r < ROWS; r++) {
            for (let c = 0; c < COLS; c++) {
                const tile = board.value[r]?.[c]
                if (tile && !tile.isRemoved && tile.type.gi === 'high') {
                    highGiTiles.push(tile)
                }
            }
        }

        if (highGiTiles.length === 0) {
            uni.showToast({ title: '场上暂无高GI食物，无需使用膳食纤维', icon: 'none' })
            return
        }

        isProcessing.value = true
        booster.count--
        playSfx('fiber')

        // 只能替换成“本局在用”的低GI食材，否则会引入无法消除的新图标
        let lowGiFoods = activeFoods.value.filter(f => f.gi === 'low')
        if (!lowGiFoods.length) lowGiFoods = activeFoods.value.length ? activeFoods.value : FOOD_LIBRARY

        highGiTiles.forEach((tile) => {
            const randomLowFood = cloneFoodType(lowGiFoods[Math.floor(Math.random() * lowGiFoods.length)])
            tile.type = randomLowFood

            queryTileCenter(tile).then((center) => {
                if (center) {
                    spawnRipple(center.x, center.y, '#f97316', 1)
                    spawnBurst(center.x, center.y, '#f97316', 6)
                }
            })
        })

        const dropVal = highGiTiles.length * 3
        const oldVal = glucoseLevel.value
        glucoseLevel.value = Math.max(5, glucoseLevel.value - dropVal)

        const query = uni.createSelectorQuery().in(instance?.proxy)
        query.select('.game-grid').boundingClientRect((rect) => {
            if (rect && rect.width > 0) {
                const cx = rect.left + rect.width / 2
                const cy = rect.top + rect.height / 2

                const valId = `fv-${Date.now()}`
                floatingVals.value.push({
                    id: valId,
                    text: `膳食纤维：血糖 -${oldVal - glucoseLevel.value}`,
                    color: '#f97316',
                    x: cx,
                    y: cy - 20
                })
                setTimeout(() => {
                    floatingVals.value = floatingVals.value.filter((t) => t.id !== valId)
                }, 1500)
            }
        }).exec()

        await delay(600)

        const matches = findMatches()
        if (matches.length > 0) {
            const endedByHigh = await clearAndRefill()
            if (endedByHigh) {
                isProcessing.value = false
                return
            }
        }
        // 恢复本关高糖数量，避免纤维清空高糖直接跳过“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>
