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