1491 lines
64 KiB
Vue
1491 lines
64 KiB
Vue
<template>
|
||
<view
|
||
class="eg-page"
|
||
@touchstart.capture="primeSfxFromGesture"
|
||
@mousedown.capture="primeSfxFromGesture"
|
||
>
|
||
<view v-if="confettiVisible" class="eg-confetti-layer" aria-hidden="true">
|
||
<text
|
||
v-for="piece in confettiPieces"
|
||
:key="piece.id"
|
||
class="eg-confetti-piece"
|
||
:class="{ 'is-emoji': piece.emoji }"
|
||
:style="piece.style"
|
||
>{{ piece.emoji }}</text>
|
||
</view>
|
||
<view class="eg-nav" :style="navStyle">
|
||
<view class="eg-nav-leading">
|
||
<view class="eg-nav-btn" @tap="goBack">
|
||
<TongjiIcon name="chevron-left" size="md" color="#155e4b" />
|
||
</view>
|
||
</view>
|
||
<view class="eg-title-wrap">
|
||
<text class="eg-title">控糖消消乐</text>
|
||
<text class="eg-subtitle">第{{ themeIndex + 1 }}站 · {{ currentTheme.title }}</text>
|
||
</view>
|
||
<view class="eg-nav-actions">
|
||
<view class="eg-nav-btn eg-nav-btn--small" @tap="toggleSfx">
|
||
<TongjiIcon name="volume" size="sm" :color="sfxEnabled ? '#155e4b' : '#94a3b8'" />
|
||
</view>
|
||
<view class="eg-nav-btn eg-nav-btn--small" @tap="showRules">
|
||
<TongjiIcon name="info" size="sm" color="#155e4b" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="eg-content">
|
||
<view class="eg-score-card">
|
||
<view class="eg-stat">
|
||
<text class="eg-stat-label">本局认糖</text>
|
||
<text class="eg-stat-value">{{ learned }}</text>
|
||
</view>
|
||
<view class="eg-stat eg-stat--main">
|
||
<text class="eg-stat-label">得分</text>
|
||
<text class="eg-stat-score">{{ score }}</text>
|
||
</view>
|
||
<view
|
||
class="eg-stat eg-stat--rank"
|
||
:class="{ 'is-locked': busy || reshuffling || stageVisible || camelJackpot.visible }"
|
||
@tap="openWeeklyRank"
|
||
>
|
||
<text class="eg-stat-label">本周同行</text>
|
||
<text class="eg-stat-rank-value">第{{ weeklyMyRank }}名</text>
|
||
<text class="eg-stat-rank-hint">点击查看</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="eg-task-card">
|
||
<view class="eg-task-head">
|
||
<view class="eg-task-name">
|
||
<TongjiIcon name="trophy" size="sm" color="#8a5a10" />
|
||
<text>小目标:{{ currentTask.label }}</text>
|
||
</view>
|
||
<text class="eg-task-count">{{ Math.min(taskProgress, currentTask.target) }}/{{ currentTask.target }}</text>
|
||
</view>
|
||
<view class="eg-progress"><view class="eg-progress-fill" :style="{ width: taskPercent + '%' }" /></view>
|
||
<view v-if="taskRewardVisible" :key="taskRewardKey" class="eg-task-reward-flight">
|
||
<view class="eg-task-reward-glow" />
|
||
<image :src="foodOf('camel').img" mode="aspectFit" />
|
||
<text>+1</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="eg-board-shell">
|
||
<view class="eg-board-top">
|
||
<view class="eg-board-bonuses">
|
||
<view class="eg-combo" :class="{ 'is-hot': combo >= 3 }">连消 ×{{ Math.max(combo, 1) }}</view>
|
||
<view v-if="feastMoves > 0" class="eg-double-state">双倍得分 ×{{ feastMoves }}</view>
|
||
</view>
|
||
<view class="eg-risk">
|
||
<text>高糖食物 {{ highCount }}/3</text>
|
||
<view class="eg-risk-dots">
|
||
<view v-for="n in 3" :key="n" class="eg-risk-dot" :class="{ on: n <= highCount }" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="eg-board" :class="{ 'is-busy': busy }">
|
||
<view v-for="(row, rowIndex) in boardRows" :key="rowIndex" class="eg-row">
|
||
<view
|
||
v-for="tile in row"
|
||
:key="tile.id"
|
||
class="eg-cell"
|
||
:class="cellClasses(tile)"
|
||
:style="cellMotionStyle(tile.index)"
|
||
@tap="tapTile(tile.index)"
|
||
@touchstart="touchStart($event, tile.index)"
|
||
@touchmove.stop.prevent="touchMove($event, tile.index)"
|
||
@touchend="touchEnd($event, tile.index)"
|
||
>
|
||
<view v-if="tile.special" class="eg-special-mark" :class="`is-${tile.special}`">
|
||
<view v-if="tile.special === 'row' || tile.special === 'col'" class="eg-mini-flame">
|
||
<view class="eg-mini-flame-core" />
|
||
</view>
|
||
<view v-else-if="tile.special === 'burst'" class="eg-l-special" aria-label="L形范围消除">
|
||
<view class="eg-l-arm is-vertical" />
|
||
<view class="eg-l-arm is-horizontal" />
|
||
<view class="eg-l-core" />
|
||
</view>
|
||
<text v-else>✦</text>
|
||
</view>
|
||
<view v-if="fireClearingIndices.includes(tile.index)" class="eg-fire-clear" aria-hidden="true">
|
||
<view class="eg-fire-glow" />
|
||
<view class="eg-fire-sweep" />
|
||
<view class="eg-fire-flame is-left"><view /></view>
|
||
<view class="eg-fire-flame is-center"><view /></view>
|
||
<view class="eg-fire-flame is-right"><view /></view>
|
||
<view class="eg-fire-spark is-one" />
|
||
<view class="eg-fire-spark is-two" />
|
||
<view class="eg-fire-spark is-three" />
|
||
</view>
|
||
<image class="eg-food-image" :src="foodOf(tile.key).img" mode="aspectFit" />
|
||
<text class="eg-food-name">{{ foodOf(tile.key).short }}</text>
|
||
<view class="eg-sugar-tag" :class="`is-${foodOf(tile.key).level || 'low'}`">
|
||
{{ foodOf(tile.key).levelLabel || '低糖' }}
|
||
</view>
|
||
<view v-if="camelMode && foodOf(tile.key).replaceable" class="eg-replace-target">可替换</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="impactText" class="eg-impact-text" :class="`is-level-${impactLevel}`">{{ impactText }}</view>
|
||
|
||
<view v-if="camelJackpot.visible" class="eg-jackpot" :class="`is-match-${camelJackpot.count}`">
|
||
<view class="eg-jackpot-lights" />
|
||
<text class="eg-jackpot-kicker">{{ camelJackpot.kicker }}</text>
|
||
<view v-if="camelJackpot.count === 3" class="eg-triple-cans">
|
||
<view v-for="n in 3" :key="n" class="eg-triple-can">
|
||
<image :src="foodOf('camel').img" mode="aspectFit" />
|
||
</view>
|
||
</view>
|
||
<view v-else-if="camelJackpot.count === 4" class="eg-four-teaser">
|
||
<view class="eg-four-cans">
|
||
<view v-for="n in 5" :key="n" class="eg-four-can" :class="{ 'is-locked': n === 5 }">
|
||
<image v-if="n < 5" :src="foodOf('camel').img" mode="aspectFit" />
|
||
<text v-else>?</text>
|
||
</view>
|
||
</view>
|
||
<text class="eg-four-promise">还差 1 罐,777 大奖即将开启</text>
|
||
</view>
|
||
<view v-else class="eg-five-celebration">
|
||
<view class="eg-five-cans">
|
||
<view v-for="n in 5" :key="n" class="eg-five-can">
|
||
<image :src="foodOf('camel').img" mode="aspectFit" />
|
||
</view>
|
||
</view>
|
||
<view class="eg-five-sevens">
|
||
<text v-for="n in 3" :key="n">7</text>
|
||
</view>
|
||
</view>
|
||
<text class="eg-jackpot-title">{{ camelJackpot.title }}</text>
|
||
<text v-if="camelJackpot.count >= 5" class="eg-jackpot-points">777 ×2</text>
|
||
<text class="eg-jackpot-reward">{{ camelJackpot.reward }}</text>
|
||
<text class="eg-jackpot-note">仅为游戏内娱乐奖励</text>
|
||
</view>
|
||
|
||
<view class="eg-board-tip">
|
||
<TongjiIcon name="bulb" size="sm" color="#9a6718" />
|
||
<text>{{ statusText }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="eg-tools">
|
||
<view class="eg-tool" :class="{ active: camelMode, disabled: camelCount <= 0 || busy }" @tap="toggleCamel">
|
||
<view class="eg-tool-icon eg-tool-icon--milk">
|
||
<image class="eg-tool-food-image" :src="foodOf('camel').img" mode="aspectFit" />
|
||
<view class="eg-replace-badge">
|
||
<TongjiIcon name="refresh" size="sm" color="#ffffff" />
|
||
<text>替换</text>
|
||
</view>
|
||
</view>
|
||
<view class="eg-tool-copy">
|
||
<text class="eg-tool-name">新疆纯驼乳粉</text>
|
||
<text class="eg-tool-desc">替换{{ foodOf(currentTheme.danger).short }} · 三杯连消换主题</text>
|
||
</view>
|
||
<view class="eg-tool-count">×{{ camelCount }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="eg-legend">
|
||
<text><text class="eg-dot eg-dot--safe">●</text> 低糖</text>
|
||
<text><text class="eg-dot eg-dot--mid">●</text> 中糖</text>
|
||
<text><text class="eg-dot eg-dot--high">●</text> 主动三消高糖会结束</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="weeklyRankVisible" class="eg-weekly-overlay" @tap.stop>
|
||
<view class="eg-weekly-card">
|
||
<view class="eg-weekly-head">
|
||
<view>
|
||
<view class="eg-weekly-title-line">
|
||
<text class="eg-weekly-title">本周7人同行榜</text>
|
||
<text class="eg-weekly-preview">界面预览</text>
|
||
</view>
|
||
<text class="eg-weekly-sub">{{ weeklyRangeLabel }} · 每周一更新</text>
|
||
</view>
|
||
<view class="eg-weekly-medal">{{ weeklyMyRank }}</view>
|
||
</view>
|
||
|
||
<view class="eg-weekly-progress-card">
|
||
<view>
|
||
<text class="eg-weekly-progress-label">我的本周认糖</text>
|
||
<text class="eg-weekly-progress-value">{{ weeklyMyCount }}</text>
|
||
</view>
|
||
<view class="eg-weekly-progress-copy">
|
||
<text v-if="weeklyRankRise > 0">当前第 {{ weeklyMyRank }} 名 · 本周上升 {{ weeklyRankRise }} 名</text>
|
||
<text v-else>当前第 {{ weeklyMyRank }} 名 · 继续积累认糖</text>
|
||
<text v-if="weeklyIsFirst">太棒了,本周暂列第一!</text>
|
||
<text v-else>再认 {{ weeklyDistance }} 个超过上一名</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="eg-weekly-list">
|
||
<view
|
||
v-for="player in weeklyRankRows"
|
||
:key="player.id"
|
||
class="eg-weekly-row"
|
||
:class="{ 'is-me': player.isMe }"
|
||
>
|
||
<text class="eg-weekly-place">{{ player.rank }}</text>
|
||
<view class="eg-weekly-avatar" :class="`is-tone-${player.tone}`">{{ player.avatar }}</view>
|
||
<text class="eg-weekly-name">{{ player.name }}</text>
|
||
<text v-if="player.isMe" class="eg-weekly-me">我</text>
|
||
<text class="eg-weekly-count">{{ player.count }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="eg-cheer-card" :class="`is-${currentCheer.tone}`">
|
||
<view class="eg-cheer-top">
|
||
<text class="eg-cheer-source">{{ currentCheer.source }}</text>
|
||
<text class="eg-cheer-change" @tap.stop="cycleCheer">换一句</text>
|
||
</view>
|
||
<text class="eg-cheer-copy">“{{ currentCheer.text }}”</text>
|
||
<text v-if="currentCheer.userGenerated" class="eg-cheer-note">亲友自定义内容,不作为医学判断</text>
|
||
</view>
|
||
|
||
<view class="eg-weekly-start" @tap="closeWeeklyRank">开始消除</view>
|
||
<text class="eg-weekly-footnote">当前为排行效果预览,真实好友数据接入后替换</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="reshuffleVisible" class="eg-reshuffle-overlay" @tap.stop>
|
||
<view class="eg-reshuffle-card">
|
||
<view class="eg-reshuffle-icon">
|
||
<TongjiIcon name="refresh" size="lg" color="#ffffff" />
|
||
</view>
|
||
<text class="eg-reshuffle-title">{{ reshuffleDone ? '洗牌完成' : '没有可消除组合' }}</text>
|
||
<text class="eg-reshuffle-desc">{{ reshuffleDone ? '新的消除机会已经出现' : '正在为您重新洗牌,请稍候' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="stageVisible" class="eg-stage-overlay" @tap.stop>
|
||
<view class="eg-stage-card">
|
||
<text class="eg-stage-kicker">新菜单已解锁</text>
|
||
<text class="eg-stage-number">第 {{ stageMeta.number }} 站</text>
|
||
<text class="eg-stage-title">{{ stageMeta.title }}</text>
|
||
<view class="eg-stage-foods">
|
||
<view v-for="food in stageMeta.foods" :key="food.key" class="eg-stage-food" :class="{ 'is-danger': food.high }">
|
||
<image :src="food.img" mode="aspectFit" />
|
||
<text>{{ food.short }}</text>
|
||
</view>
|
||
</view>
|
||
<text class="eg-stage-tip">本组橙框食物可用驼乳粉替换</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="gameOver" class="eg-overlay" @tap.stop>
|
||
<view class="eg-modal eg-result-modal">
|
||
<view class="eg-result-warning">!</view>
|
||
<text class="eg-modal-title">本局结束</text>
|
||
<text class="eg-result-food">你消除了:{{ endedFood.name }}</text>
|
||
<view class="eg-knowledge-card">
|
||
<text class="eg-knowledge-title">为什么要注意?</text>
|
||
<text class="eg-knowledge-copy">{{ endedFood.tip }}</text>
|
||
<text class="eg-knowledge-review">科普内容上线前由甄养堂医生审核</text>
|
||
</view>
|
||
<view class="eg-result-score"><text>本局得分</text><text>{{ score }}</text></view>
|
||
<view class="eg-result-best"><text>历史最高</text><text>{{ bestScore }}</text></view>
|
||
<view class="eg-primary-btn" @tap="startGame">再玩一局</view>
|
||
<view class="eg-secondary-btn" @tap="goBack">返回血糖周报</view>
|
||
</view>
|
||
</view>
|
||
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, ref } from 'vue'
|
||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||
import TongjiIcon from './components/TongjiIcon.vue'
|
||
import { useGameSfx } from './composables/useGameSfx.js'
|
||
|
||
const FOOD_IMAGE_BASE_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/'
|
||
|
||
const ROWS = 5
|
||
const COLS = 4
|
||
const GAME_DIFFICULTY = 5
|
||
const HIGH_CAP = 3
|
||
const MAX_CAMEL_COUNT = 5
|
||
const OVERFLOW_CAMEL_SCORE = 100
|
||
const MAX_CASCADE_WAVES = 3
|
||
const COMBO_TASK_WAVE = 2
|
||
const COMBO_TASK_TARGET = 2
|
||
const COMBO_PITY_TURNS = 6
|
||
const MEGA_COMBO_SCORE = 300
|
||
const DANGER_DROP_CHANCE = 0.05 + GAME_DIFFICULTY * 0.01
|
||
const COLUMN_GROUP_BIAS = 0.86 - GAME_DIFFICULTY * 0.028
|
||
const WEEKLY_PREVIEW_BASE = 208
|
||
const WEEKLY_PREVIEW_PLAYERS = [
|
||
{ id: 'li', name: '李阿姨', avatar: '李', count: 286, tone: 1 },
|
||
{ id: 'health', name: '健康每一天', avatar: '健', count: 248, tone: 2 },
|
||
{ id: 'wang', name: '王师傅', avatar: '王', count: 226, tone: 3 },
|
||
{ id: 'me', name: '我', avatar: '我', count: 0, tone: 4, isMe: true },
|
||
{ id: 'happy', name: '快乐生活', avatar: '乐', count: 191, tone: 5 },
|
||
{ id: 'chen', name: '陈叔', avatar: '陈', count: 174, tone: 6 },
|
||
{ id: 'sunny', name: '天天好心情', avatar: '晴', count: 152, tone: 7 }
|
||
]
|
||
const WEEKLY_START_RANK = WEEKLY_PREVIEW_PLAYERS
|
||
.map(player => ({ ...player, count: player.isMe ? WEEKLY_PREVIEW_BASE : player.count }))
|
||
.sort((a, b) => b.count - a.count)
|
||
.findIndex(player => player.isMe) + 1
|
||
const CHEER_MESSAGES = [
|
||
{ source: '同行鼓励', text: '你需要加油哦!再向前一点,就能超过上一名。', tone: 'warm' },
|
||
{ source: '同行鼓励', text: '你太棒了!今天也在认真认识食物。', tone: 'bright' },
|
||
{ source: '开心鼓励', text: '你肯定是个大美女(大帅哥)!保持好心情。', tone: 'bright' },
|
||
{ source: '亲友留言示例', text: '游戏虽好,可不要贪吃哦!', tone: 'family' },
|
||
{ source: '亲友留言示例', text: '差不多行了,赶快洗洗睡吧!', tone: 'family' },
|
||
{ source: '亲友留言示例', text: '你太努力了,你是我的榜样!', tone: 'warm' },
|
||
{ source: '亲友留言示例', text: '好样的!', tone: 'bright' },
|
||
{ source: '亲友自定义留言示例', text: '我血糖已经正常了,你们玩吧。', tone: 'family', userGenerated: true }
|
||
]
|
||
const FOODS = {
|
||
corn: { name: '玉米', short: '玉米', emoji: '🌽', color: 'yellow', level: 'mid', levelLabel: '中糖' },
|
||
egg: { name: '鸡蛋', short: '鸡蛋', emoji: '🥚', color: 'cream', level: 'low', levelLabel: '低糖' },
|
||
tomato: { name: '番茄', short: '番茄', emoji: '🍅', color: 'red', level: 'low', levelLabel: '低糖' },
|
||
potato: { name: '红薯', short: '红薯', emoji: '🍠', color: 'purple', level: 'mid', levelLabel: '中糖' },
|
||
cucumber: { name: '黄瓜', short: '黄瓜', emoji: '🥒', color: 'yellow', level: 'low', levelLabel: '低糖' },
|
||
lettuce: { name: '生菜', short: '生菜', emoji: '🥬', color: 'cream', level: 'low', levelLabel: '低糖' },
|
||
grainMantou: { name: '杂粮馒头', short: '杂粮馒头', emoji: '🫓', color: 'red', level: 'mid', levelLabel: '中糖' },
|
||
wonton: { name: '馄饨', short: '馄饨', emoji: '🥟', color: 'purple', level: 'mid', levelLabel: '中糖' },
|
||
celery: { name: '芹菜', short: '芹菜', emoji: '🌿', color: 'yellow', level: 'low', levelLabel: '低糖' },
|
||
mushroom: { name: '香菇', short: '香菇', emoji: '🍄', color: 'cream', level: 'low', levelLabel: '低糖' },
|
||
chicken: { name: '鸡肉', short: '鸡肉', emoji: '🍗', color: 'red', level: 'mid', levelLabel: '中糖' },
|
||
taro: { name: '芋头', short: '芋头', emoji: '🥔', color: 'purple', level: 'mid', levelLabel: '中糖' },
|
||
broccoli: { name: '西兰花', short: '西兰花', emoji: '🥦', color: 'yellow', level: 'low', levelLabel: '低糖' },
|
||
milk: { name: '牛奶', short: '牛奶', emoji: '🥛', color: 'cream', level: 'low', levelLabel: '低糖' },
|
||
brownRice: { name: '糙米饭', short: '糙米饭', emoji: '🍙', color: 'red', level: 'mid', levelLabel: '中糖' },
|
||
apple: { name: '苹果', short: '苹果', emoji: '🍎', color: 'yellow', level: 'low', levelLabel: '低糖' },
|
||
strawberry: { name: '草莓', short: '草莓', emoji: '🍓', color: 'cream', level: 'low', levelLabel: '低糖' },
|
||
banana: { name: '香蕉', short: '香蕉', emoji: '🍌', color: 'red', level: 'mid', levelLabel: '中糖' },
|
||
mango: { name: '芒果', short: '芒果', emoji: '🥭', color: 'purple', level: 'mid', levelLabel: '中糖' },
|
||
tea: { name: '红茶', short: '红茶', emoji: '🍵', color: 'yellow', level: 'low', levelLabel: '低糖' },
|
||
blackCoffee: { name: '黑咖啡', short: '黑咖啡', emoji: '☕', color: 'cream', level: 'low', levelLabel: '低糖' },
|
||
coffee: { name: '咖啡', short: '咖啡', emoji: '☕', color: 'red', level: 'mid', levelLabel: '中糖' },
|
||
pineapple: { name: '菠萝', short: '菠萝', emoji: '🍍', color: 'purple', level: 'mid', levelLabel: '中糖' },
|
||
pepper: { name: '青椒', short: '青椒', emoji: '🫑', color: 'yellow', level: 'low', levelLabel: '低糖' },
|
||
greenBean: { name: '四季豆', short: '四季豆', emoji: '🫛', color: 'cream', level: 'low', levelLabel: '低糖' },
|
||
shrimp: { name: '虾仁', short: '虾仁', emoji: '🦐', color: 'red', level: 'mid', levelLabel: '中糖' },
|
||
udon: { name: '乌冬面', short: '乌冬面', emoji: '🍲', color: 'purple', level: 'mid', levelLabel: '中糖' },
|
||
onion: { name: '洋葱', short: '洋葱', emoji: '🧅', color: 'yellow', level: 'low', levelLabel: '低糖' },
|
||
congee: {
|
||
name: '白米粥', short: '白粥', emoji: '🥣', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true,
|
||
tip: '白米粥煮得软烂,消化吸收通常较快。糖尿病患者应结合食用量、搭配和个人血糖情况,在医生或营养师指导下合理选择。'
|
||
},
|
||
eightCongee: { name: '八宝粥', short: '八宝粥', emoji: '🥣', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '八宝粥常含糯米、豆类或添加糖,实际影响与配方和食用量有关,请留意营养成分。' },
|
||
centuryCongee: { name: '皮蛋瘦肉粥', short: '瘦肉粥', emoji: '🥣', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '粥类煮得软烂后通常吸收较快,搭配和食用量需要结合个人情况。' },
|
||
milletCongee: { name: '小米粥', short: '小米粥', emoji: '🥣', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '小米粥的升糖影响与浓稠度和食用量有关,游戏等级需由医生最终审核。' },
|
||
orangeJuice: { name: '柳橙汁', short: '橙汁', emoji: '🧃', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '果汁缺少完整水果的部分膳食纤维,饮用量和是否添加糖都需要注意。' },
|
||
soda: { name: '含糖汽水', short: '汽水', emoji: '🥤', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '含糖饮料中的糖吸收较快,日常应优先选择不加糖的饮品。' },
|
||
riceNoodleSoup: { name: '汤粉', short: '汤粉', emoji: '🍜', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '汤粉以精制米粉为主,实际影响与分量和配菜有关,应注意搭配。' },
|
||
noodleSoup: { name: '汤面', short: '汤面', emoji: '🍜', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '汤面以精细面食为主,汤汁和分量需要注意,具体建议以医生审核为准。' },
|
||
milkTea: { name: '含糖奶茶', short: '奶茶', emoji: '🧋', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '含糖奶茶可能同时含有较多添加糖和能量,应关注配方与饮用量。' },
|
||
camel: { name: '驼乳', short: '驼乳', emoji: '🥛', color: 'milk', level: 'prop', levelLabel: '道具', product: true }
|
||
}
|
||
Object.keys(FOODS).forEach((key) => { FOODS[key].img = `${FOOD_IMAGE_BASE_URL}${key}.jpg` })
|
||
const FOOD_GROUPS = [
|
||
{ title: '中式早餐', safe: ['egg', 'cucumber', 'corn', 'grainMantou'], danger: 'congee' },
|
||
{ title: '家常粥餐', safe: ['celery', 'mushroom', 'chicken', 'taro'], danger: 'centuryCongee' },
|
||
{ title: '粗粮早餐', safe: ['broccoli', 'milk', 'brownRice', 'potato'], danger: 'milletCongee' },
|
||
{ title: '水果饮品', safe: ['apple', 'strawberry', 'banana', 'mango'], danger: 'orangeJuice' },
|
||
{ title: '日常饮品', safe: ['milk', 'tea', 'blackCoffee', 'coffee'], danger: 'soda' },
|
||
{ title: '南方汤粉', safe: ['pepper', 'greenBean', 'shrimp', 'udon'], danger: 'riceNoodleSoup' },
|
||
{ title: '北方面食', safe: ['onion', 'celery', 'corn', 'grainMantou'], danger: 'noodleSoup' },
|
||
]
|
||
|
||
const {
|
||
enabled: sfxEnabled,
|
||
play: playSfx,
|
||
playLayer: playSfxLayer,
|
||
playDrop: playDropSfx,
|
||
warmUp: warmUpSfx
|
||
} = useGameSfx()
|
||
|
||
const board = ref([])
|
||
const selected = ref(-1)
|
||
const score = ref(0)
|
||
const bestScore = ref(0)
|
||
const learned = ref(0)
|
||
const combo = ref(0)
|
||
const feastMoves = ref(0)
|
||
const camelCount = ref(3)
|
||
const camelMode = ref(false)
|
||
const busy = ref(false)
|
||
const gameOver = ref(false)
|
||
const themeIndex = ref(0)
|
||
const endedFood = ref(FOODS.congee)
|
||
const taskIndex = ref(0)
|
||
const taskProgress = ref(0)
|
||
const comboDryTurns = ref(0)
|
||
const taskRewardVisible = ref(false)
|
||
const taskRewardKey = ref(0)
|
||
const statusText = ref('交换相邻食物,三个相同即可消除')
|
||
const navStyle = ref({ paddingTop: '20px' })
|
||
const touch = { x: 0, y: 0, index: -1 }
|
||
const dragIndex = ref(-1)
|
||
const dragTarget = ref(-1)
|
||
const dragX = ref(0)
|
||
const dragY = ref(0)
|
||
const clearingIndices = ref([])
|
||
const fireClearingIndices = ref([])
|
||
const impactText = ref('')
|
||
const impactLevel = ref(1)
|
||
const camelJackpot = ref({ visible: false, count: 0, kicker: '', title: '', reward: '' })
|
||
const stageVisible = ref(false)
|
||
const stageMeta = ref({ number: 1, title: '', foods: [] })
|
||
const reshuffleVisible = ref(false)
|
||
const reshuffleDone = ref(false)
|
||
const reshuffling = ref(false)
|
||
const confettiVisible = ref(false)
|
||
const weeklyRankVisible = ref(false)
|
||
const weeklyPreviewProgress = ref(0)
|
||
const cheerIndex = ref(0)
|
||
const CONFETTI_COLORS = ['#ff3154', '#ffd928', '#28c76f', '#4d8dff', '#a855f7', '#ff7a1a']
|
||
const confettiPieces = Array.from({ length: 34 }, (_, index) => ({
|
||
id: index,
|
||
emoji: index % 8 === 0 ? '🎉' : (index % 11 === 0 ? '★' : ''),
|
||
style: {
|
||
left: `${(index * 37 + 7) % 100}%`,
|
||
backgroundColor: index % 8 === 0 ? 'transparent' : CONFETTI_COLORS[index % CONFETTI_COLORS.length],
|
||
animationDelay: `${(index % 9) * .065}s`,
|
||
animationDuration: `${3.7 + (index % 5) * .36}s`,
|
||
width: `${12 + (index % 3) * 5}rpx`,
|
||
height: `${22 + (index % 4) * 6}rpx`
|
||
}
|
||
}))
|
||
let suppressTapUntil = 0
|
||
let tileId = 1
|
||
let hintTimer = null
|
||
let jackpotTimer = null
|
||
let confettiTimer = null
|
||
let stageTimer = null
|
||
let taskRewardTimer = null
|
||
let pendingThemeMatchCount = 0
|
||
let taskRewardStatusUntil = 0
|
||
let sfxGesturePrimed = false
|
||
|
||
const currentTheme = computed(() => FOOD_GROUPS[themeIndex.value % FOOD_GROUPS.length])
|
||
const currentTask = computed(() => {
|
||
const safe = currentTheme.value.safe
|
||
const templates = [
|
||
{ key: safe[0], label: `消除 7 个${foodOf(safe[0]).short}`, target: 7, kind: 'food' },
|
||
{ key: 'special', label: '制造 1 个惊喜棋子', target: 1, kind: 'special' },
|
||
{ key: 'combo', label: '完成连消 ×2', target: COMBO_TASK_TARGET, kind: 'combo' },
|
||
{ key: safe[1], label: `消除 8 个${foodOf(safe[1]).short}`, target: 8, kind: 'food' }
|
||
]
|
||
return templates[taskIndex.value % templates.length]
|
||
})
|
||
const taskPercent = computed(() => Math.min(100, taskProgress.value / currentTask.value.target * 100))
|
||
const highCount = computed(() => board.value.filter(tile => tile && foodOf(tile.key).high).length)
|
||
const boardRows = computed(() => Array.from({ length: ROWS }, (_, row) => (
|
||
board.value.slice(row * COLS, row * COLS + COLS).map((tile, col) => ({ ...tile, index: row * COLS + col }))
|
||
)))
|
||
const weeklyMyCount = computed(() => WEEKLY_PREVIEW_BASE + weeklyPreviewProgress.value)
|
||
const weeklyRankRows = computed(() => WEEKLY_PREVIEW_PLAYERS
|
||
.map(player => ({ ...player, count: player.isMe ? weeklyMyCount.value : player.count }))
|
||
.sort((a, b) => b.count - a.count)
|
||
.map((player, index) => ({ ...player, rank: index + 1 })))
|
||
const weeklyMyRank = computed(() => weeklyRankRows.value.find(player => player.isMe)?.rank || 7)
|
||
const weeklyRankRise = computed(() => Math.max(0, WEEKLY_START_RANK - weeklyMyRank.value))
|
||
const weeklyIsFirst = computed(() => weeklyMyRank.value === 1)
|
||
const weeklyDistance = computed(() => {
|
||
const mineIndex = weeklyRankRows.value.findIndex(player => player.isMe)
|
||
if (mineIndex <= 0) return 0
|
||
return Math.max(1, weeklyRankRows.value[mineIndex - 1].count - weeklyMyCount.value + 1)
|
||
})
|
||
const currentCheer = computed(() => CHEER_MESSAGES[cheerIndex.value % CHEER_MESSAGES.length])
|
||
const weeklyRangeLabel = computed(() => getWeekRangeLabel(new Date()))
|
||
|
||
function initNavLayout() {
|
||
let paddingTop = 20
|
||
let paddingRight = uni.upx2px(28)
|
||
|
||
try {
|
||
const info = uni.getSystemInfoSync()
|
||
paddingTop = Number(info.safeAreaInsets?.top || info.statusBarHeight) || 20
|
||
|
||
// #ifdef MP-WEIXIN
|
||
const menu = typeof uni.getMenuButtonBoundingClientRect === 'function'
|
||
? uni.getMenuButtonBoundingClientRect()
|
||
: null
|
||
if (menu && menu.width > 0) {
|
||
paddingTop = Math.max(paddingTop, Number(menu.top) || 0)
|
||
paddingRight = Math.max(
|
||
paddingRight,
|
||
(Number(info.windowWidth) || 0) - Number(menu.left) + uni.upx2px(8)
|
||
)
|
||
}
|
||
// #endif
|
||
} catch (_) {}
|
||
|
||
navStyle.value = {
|
||
paddingTop: `${paddingTop}px`,
|
||
paddingRight: `${paddingRight}px`
|
||
}
|
||
}
|
||
|
||
onLoad(() => {
|
||
initNavLayout()
|
||
bestScore.value = Number(uni.getStorageSync('tongji_endless_best') || 0)
|
||
startGame()
|
||
weeklyRankVisible.value = true
|
||
})
|
||
|
||
onUnload(() => {
|
||
if (hintTimer) clearTimeout(hintTimer)
|
||
if (jackpotTimer) clearTimeout(jackpotTimer)
|
||
if (confettiTimer) clearTimeout(confettiTimer)
|
||
if (stageTimer) clearTimeout(stageTimer)
|
||
if (taskRewardTimer) clearTimeout(taskRewardTimer)
|
||
})
|
||
|
||
function makeTile(key, special = '') {
|
||
return { id: tileId++, key, special }
|
||
}
|
||
|
||
function foodOf(key) {
|
||
return FOODS[key] || FOODS.corn
|
||
}
|
||
|
||
function getWeekRangeLabel(now) {
|
||
const date = new Date(now)
|
||
const day = date.getDay() || 7
|
||
const monday = new Date(date)
|
||
monday.setDate(date.getDate() - day + 1)
|
||
const sunday = new Date(monday)
|
||
sunday.setDate(monday.getDate() + 6)
|
||
const label = value => `${value.getMonth() + 1}月${value.getDate()}日`
|
||
return `${label(monday)}—${label(sunday)}`
|
||
}
|
||
|
||
function openWeeklyRank() {
|
||
primeSfxFromGesture()
|
||
if (gameOver.value) return
|
||
if (busy.value || reshuffling.value || stageVisible.value || camelJackpot.value.visible) {
|
||
statusText.value = '本次消除完成后即可查看同行榜'
|
||
return
|
||
}
|
||
weeklyRankVisible.value = true
|
||
playSfx('select', .72)
|
||
}
|
||
|
||
function closeWeeklyRank() {
|
||
primeSfxFromGesture()
|
||
weeklyRankVisible.value = false
|
||
playSfx('select', .86)
|
||
scheduleHint()
|
||
}
|
||
|
||
function cycleCheer() {
|
||
primeSfxFromGesture()
|
||
cheerIndex.value = (cheerIndex.value + 1) % CHEER_MESSAGES.length
|
||
playSfx('select', .68)
|
||
}
|
||
|
||
function startGame() {
|
||
if (hintTimer) clearTimeout(hintTimer)
|
||
themeIndex.value = 0
|
||
board.value = buildPlayableBoard(openingKeysForTheme(currentTheme.value))
|
||
selected.value = -1
|
||
score.value = 0
|
||
learned.value = 0
|
||
combo.value = 0
|
||
feastMoves.value = 0
|
||
camelCount.value = 3
|
||
camelMode.value = false
|
||
busy.value = false
|
||
gameOver.value = false
|
||
taskIndex.value = 0
|
||
taskProgress.value = 0
|
||
comboDryTurns.value = 0
|
||
taskRewardVisible.value = false
|
||
clearingIndices.value = []
|
||
fireClearingIndices.value = []
|
||
impactText.value = ''
|
||
camelJackpot.value.visible = false
|
||
stageVisible.value = false
|
||
reshuffleVisible.value = false
|
||
reshuffleDone.value = false
|
||
reshuffling.value = false
|
||
confettiVisible.value = false
|
||
pendingThemeMatchCount = 0
|
||
taskRewardStatusUntil = 0
|
||
resetDrag()
|
||
statusText.value = '交换相邻食物,三个相同即可消除'
|
||
scheduleHint()
|
||
}
|
||
|
||
function goBack() {
|
||
const pages = getCurrentPages()
|
||
if (pages.length > 1) {
|
||
uni.navigateBack({
|
||
delta: 1,
|
||
fail: () => uni.redirectTo({ url: '/tongji/pages/weekly' })
|
||
})
|
||
return
|
||
}
|
||
uni.redirectTo({ url: '/tongji/pages/weekly' })
|
||
}
|
||
|
||
function showRules() {
|
||
uni.showModal({
|
||
title: '玩法说明',
|
||
content: `当前难度:${GAME_DIFFICULTY}/10\n\n交换相邻食物,三个相同即可消除。四连、直线五连会生成火焰惊喜棋子;横向三个与竖向三个相交的 L/T 形五消,会生成范围爆破棋子,再次消除可清除周围九格。连消×2会计入连消小目标,连续普通消除后会出现一次连消机会;连消×3可获得额外惊喜奖励。完成小目标直接获得 1 罐驼乳粉;驼乳粉可替换本组指定稀食或饮品,三个驼乳连消后进入下一组食品。主动三消“高糖”食物代表吃掉,会结束本局;火焰特效波及高糖食物代表将它移出餐盘,不算吃掉。没有可走步时会自动洗牌。`,
|
||
showCancel: false,
|
||
confirmText: '知道了'
|
||
})
|
||
}
|
||
|
||
function toggleSfx() {
|
||
sfxEnabled.value = !sfxEnabled.value
|
||
try { uni.setStorageSync('tongji_game_sfx_enabled', sfxEnabled.value ? '1' : '0') } catch (_) {}
|
||
if (sfxEnabled.value) {
|
||
primeSfxFromGesture()
|
||
playSfx('select', .8)
|
||
}
|
||
uni.showToast({ title: sfxEnabled.value ? '音效已开启' : '音效已关闭', icon: 'none' })
|
||
}
|
||
|
||
function primeSfxFromGesture() {
|
||
if (sfxGesturePrimed || !sfxEnabled.value) return
|
||
sfxGesturePrimed = true
|
||
warmUpSfx()
|
||
}
|
||
|
||
function cellClasses(tile) {
|
||
const food = foodOf(tile.key)
|
||
return [
|
||
`is-${food.color}`,
|
||
`is-level-${food.level}`,
|
||
{
|
||
'is-selected': selected.value === tile.index,
|
||
'is-high': food.high,
|
||
'is-camel-target': camelMode.value && food.replaceable,
|
||
'is-clearing': clearingIndices.value.includes(tile.index),
|
||
'is-fire-clearing': fireClearingIndices.value.includes(tile.index),
|
||
'is-dragging': dragIndex.value === tile.index || dragTarget.value === tile.index
|
||
}
|
||
]
|
||
}
|
||
|
||
function cellMotionStyle(index) {
|
||
if (dragIndex.value === index) return { transform: `translate(${dragX.value}px, ${dragY.value}px) scale(1.05)`, zIndex: 6 }
|
||
if (dragTarget.value === index) return { transform: `translate(${-dragX.value * .72}px, ${-dragY.value * .72}px) scale(.96)`, zIndex: 4 }
|
||
return {}
|
||
}
|
||
|
||
function touchStart(event, index) {
|
||
primeSfxFromGesture()
|
||
const point = event.touches?.[0]
|
||
if (!point) return
|
||
touch.x = point.clientX
|
||
touch.y = point.clientY
|
||
touch.index = index
|
||
dragIndex.value = index
|
||
dragTarget.value = -1
|
||
dragX.value = 0
|
||
dragY.value = 0
|
||
}
|
||
|
||
function touchMove(event, index) {
|
||
const point = event.touches?.[0]
|
||
if (!point || touch.index !== index || busy.value) return
|
||
const rawX = point.clientX - touch.x
|
||
const rawY = point.clientY - touch.y
|
||
const horizontal = Math.abs(rawX) > Math.abs(rawY)
|
||
dragX.value = horizontal ? Math.max(-82, Math.min(82, rawX)) : 0
|
||
dragY.value = horizontal ? 0 : Math.max(-82, Math.min(82, rawY))
|
||
let target = index
|
||
if (horizontal && Math.abs(rawX) > 10) target += rawX > 0 ? 1 : -1
|
||
if (!horizontal && Math.abs(rawY) > 10) target += rawY > 0 ? COLS : -COLS
|
||
dragTarget.value = target >= 0 && target < ROWS * COLS && isAdjacent(index, target) ? target : -1
|
||
}
|
||
|
||
async function touchEnd(event, index) {
|
||
const point = event.changedTouches?.[0]
|
||
if (!point || touch.index !== index) {
|
||
resetDrag()
|
||
return
|
||
}
|
||
const dx = point.clientX - touch.x
|
||
const dy = point.clientY - touch.y
|
||
if (Math.max(Math.abs(dx), Math.abs(dy)) < 32) {
|
||
resetDrag()
|
||
return
|
||
}
|
||
let target = index
|
||
if (Math.abs(dx) > Math.abs(dy)) target += dx > 0 ? 1 : -1
|
||
else target += dy > 0 ? COLS : -COLS
|
||
suppressTapUntil = Date.now() + 350
|
||
if (target >= 0 && target < ROWS * COLS && isAdjacent(index, target)) {
|
||
const horizontal = Math.abs(dx) > Math.abs(dy)
|
||
dragX.value = horizontal ? (dx > 0 ? 78 : -78) : 0
|
||
dragY.value = horizontal ? 0 : (dy > 0 ? 78 : -78)
|
||
dragTarget.value = target
|
||
playSfx('swap', .85)
|
||
await wait(85)
|
||
resetDrag()
|
||
trySwap(index, target, true)
|
||
} else {
|
||
resetDrag()
|
||
playSfx('swapFail', .65)
|
||
}
|
||
}
|
||
|
||
function resetDrag() {
|
||
dragIndex.value = -1
|
||
dragTarget.value = -1
|
||
dragX.value = 0
|
||
dragY.value = 0
|
||
}
|
||
|
||
async function tapTile(index) {
|
||
primeSfxFromGesture()
|
||
if (Date.now() < suppressTapUntil) return
|
||
if (busy.value || gameOver.value) return
|
||
const tile = board.value[index]
|
||
if (camelMode.value) {
|
||
if (!foodOf(tile.key).replaceable) {
|
||
statusText.value = `本组驼乳粉用于替换${foodOf(currentTheme.value.danger).short}`
|
||
return
|
||
}
|
||
board.value[index] = makeTile('camel')
|
||
board.value = [...board.value]
|
||
camelCount.value -= 1
|
||
camelMode.value = false
|
||
selected.value = -1
|
||
playSfxLayer('insulin', 'sparkle', 55, .5)
|
||
vibrate('light')
|
||
const groups = findMatches(board.value)
|
||
if (groups.length) {
|
||
busy.value = true
|
||
statusText.value = '驼乳棋子连起来了!'
|
||
await resolveMatches(groups, index)
|
||
busy.value = false
|
||
finishTurn()
|
||
} else {
|
||
statusText.value = `已将${foodOf(currentTheme.value.danger).short}移出餐盘,凑齐三个驼乳可换主题`
|
||
if (!checkBoardHealth()) scheduleHint()
|
||
}
|
||
return
|
||
}
|
||
if (selected.value < 0) {
|
||
selected.value = index
|
||
playSfx('select', .75)
|
||
statusText.value = '再选择一个相邻食物进行交换'
|
||
return
|
||
}
|
||
if (selected.value === index) {
|
||
selected.value = -1
|
||
playSfx('deselect', .65)
|
||
return
|
||
}
|
||
if (!isAdjacent(selected.value, index)) {
|
||
selected.value = index
|
||
statusText.value = '只能交换上下左右相邻的食物'
|
||
return
|
||
}
|
||
const from = selected.value
|
||
selected.value = -1
|
||
trySwap(from, index)
|
||
}
|
||
|
||
function toggleCamel() {
|
||
primeSfxFromGesture()
|
||
if (busy.value || camelCount.value <= 0 || gameOver.value) return
|
||
camelMode.value = !camelMode.value
|
||
selected.value = -1
|
||
statusText.value = camelMode.value ? `请选择橙色边框的${foodOf(currentTheme.value.danger).short}进行替换` : '已取消使用驼乳粉'
|
||
}
|
||
|
||
function isAdjacent(a, b) {
|
||
const ar = Math.floor(a / COLS)
|
||
const ac = a % COLS
|
||
const br = Math.floor(b / COLS)
|
||
const bc = b % COLS
|
||
return Math.abs(ar - br) + Math.abs(ac - bc) === 1
|
||
}
|
||
|
||
async function trySwap(a, b, soundPlayed = false) {
|
||
if (busy.value || gameOver.value) return
|
||
busy.value = true
|
||
if (!soundPlayed) playSfx('swap', .85)
|
||
swap(a, b)
|
||
await wait(130)
|
||
const groups = findMatches(board.value)
|
||
if (!groups.length) {
|
||
swap(a, b)
|
||
playSfx('swapFail', .8)
|
||
vibrate('light')
|
||
statusText.value = '这一步没有形成三连,再试一次'
|
||
busy.value = false
|
||
if (!checkBoardHealth()) scheduleHint()
|
||
return
|
||
}
|
||
const directIndices = new Set(groups.flatMap(group => group.indices))
|
||
const highTile = [...directIndices].map(i => board.value[i]).find(tile => foodOf(tile.key).high)
|
||
if (highTile) {
|
||
playSfx('lose', .9)
|
||
vibrate('heavy')
|
||
endedFood.value = foodOf(highTile.key)
|
||
saveBest()
|
||
gameOver.value = true
|
||
busy.value = false
|
||
return
|
||
}
|
||
await resolveMatches(groups, b)
|
||
busy.value = false
|
||
if (!gameOver.value) finishTurn()
|
||
}
|
||
|
||
function swap(a, b) {
|
||
const next = [...board.value]
|
||
;[next[a], next[b]] = [next[b], next[a]]
|
||
board.value = next
|
||
}
|
||
|
||
async function resolveMatches(initialGroups, movedIndex) {
|
||
const comboTaskAtStart = currentTask.value.kind === 'combo'
|
||
let groups = initialGroups
|
||
let wave = 0
|
||
let turnConsumed = 0
|
||
let madeSpecial = 0
|
||
let firstWave = true
|
||
while (groups.length && wave < MAX_CASCADE_WAVES) {
|
||
wave += 1
|
||
combo.value = wave
|
||
const remove = new Set(groups.flatMap(group => group.indices))
|
||
let keeper = -1
|
||
let keeperSpecial = ''
|
||
const bentMatch = firstWave ? findBentFiveMatch(groups) : null
|
||
if (firstWave) {
|
||
const large = groups.find(group => group.indices.length >= 4)
|
||
if (bentMatch) {
|
||
keeper = bentMatch.intersection
|
||
keeperSpecial = 'burst'
|
||
remove.delete(keeper)
|
||
madeSpecial = 1
|
||
} else if (large) {
|
||
keeper = large.indices.includes(movedIndex) ? movedIndex : large.indices[0]
|
||
keeperSpecial = large.indices.length >= 5 ? 'all' : large.direction
|
||
remove.delete(keeper)
|
||
madeSpecial = 1
|
||
}
|
||
}
|
||
fireClearingIndices.value = []
|
||
const specialEffects = expandSpecials(remove)
|
||
const camelGroup = groups
|
||
.filter(group => group.indices.every(index => board.value[index]?.key === 'camel'))
|
||
.sort((a, b) => b.indices.length - a.indices.length)[0]
|
||
const bentIndices = bentMatch ? [...new Set([...bentMatch.row.indices, ...bentMatch.col.indices])] : []
|
||
const bentCamelCount = bentIndices.length === 5
|
||
&& bentIndices.every(index => board.value[index]?.key === 'camel')
|
||
? 5
|
||
: 0
|
||
const camelMatchCount = Math.max(bentCamelCount, camelGroup?.indices.length || 0)
|
||
if (camelMatchCount >= 3) applyCamelJackpot(camelMatchCount, remove)
|
||
const removedTiles = [...remove].map(i => board.value[i]).filter(Boolean)
|
||
const removedNormal = removedTiles.filter(tile => !foodOf(tile.key).high)
|
||
const fireRemovedHigh = fireClearingIndices.value.some(index => foodOf(board.value[index]?.key).high)
|
||
const largestMatch = Math.max(
|
||
...groups.map(group => group.indices.length),
|
||
keeperSpecial === 'burst' || specialEffects.hasBurst ? 5 : 3
|
||
)
|
||
clearingIndices.value = [...remove]
|
||
const triggeredSpecial = fireRemovedHigh
|
||
? 'fire-high'
|
||
: specialEffects.hasBurst
|
||
? 'burst-triggered'
|
||
: specialEffects.hasFire
|
||
? 'fire-triggered'
|
||
: keeperSpecial
|
||
showImpact(largestMatch, wave, triggeredSpecial)
|
||
playMatchImpact(largestMatch, wave)
|
||
vibrate(largestMatch >= 5 || wave >= 3 ? 'medium' : 'light')
|
||
turnConsumed += removedNormal.length
|
||
learned.value += removedNormal.length
|
||
weeklyPreviewProgress.value += removedNormal.length
|
||
updateFoodTask(removedNormal)
|
||
const multiplier = feastMoves.value > 0 ? 2 : 1
|
||
score.value += (removedNormal.length * 12 + Math.max(0, wave - 1) * 20 + madeSpecial * 35) * multiplier
|
||
await wait(specialEffects.hasFire ? 330 : 210)
|
||
const next = [...board.value]
|
||
remove.forEach(index => { next[index] = null })
|
||
if (keeper >= 0 && next[keeper]) next[keeper] = { ...next[keeper], special: keeperSpecial }
|
||
board.value = next
|
||
clearingIndices.value = []
|
||
fireClearingIndices.value = []
|
||
if (madeSpecial && currentTask.value.kind === 'special') advanceTask(madeSpecial)
|
||
await wait(70)
|
||
const shouldGuaranteeCombo = firstWave
|
||
&& comboTaskAtStart
|
||
&& comboDryTurns.value >= COMBO_PITY_TURNS - 1
|
||
collapseAndFill(shouldGuaranteeCombo)
|
||
playDropSfx(remove.size)
|
||
await wait(190)
|
||
groups = findMatches(board.value)
|
||
const automaticHighMatch = groups.some(group => (
|
||
group.indices.some(index => foodOf(board.value[index]?.key).high)
|
||
))
|
||
if (automaticHighMatch) {
|
||
board.value = makeStablePlayableBoard(board.value)
|
||
groups = []
|
||
statusText.value = '自动掉落避开了高糖连消,棋盘已整理'
|
||
} else if (wave >= MAX_CASCADE_WAVES && groups.length) {
|
||
board.value = makeStablePlayableBoard(board.value)
|
||
groups = []
|
||
statusText.value = '连消奖励已结算,棋盘已稳定'
|
||
}
|
||
firstWave = false
|
||
madeSpecial = 0
|
||
}
|
||
if (wave >= 3) rewardMegaCombo(wave)
|
||
if (comboTaskAtStart) {
|
||
if (wave >= COMBO_TASK_WAVE) {
|
||
comboDryTurns.value = 0
|
||
advanceTask(1)
|
||
} else {
|
||
comboDryTurns.value += 1
|
||
}
|
||
}
|
||
if (turnConsumed > 0 && feastMoves.value > 0) feastMoves.value -= 1
|
||
if (pendingThemeMatchCount >= 3) {
|
||
const matchedCount = pendingThemeMatchCount
|
||
pendingThemeMatchCount = 0
|
||
const celebrationDelay = matchedCount >= 5 ? 2300 : matchedCount === 4 ? 2000 : 1250
|
||
await wait(celebrationDelay)
|
||
advanceFoodTheme()
|
||
}
|
||
}
|
||
|
||
function playMatchImpact(matchSize, wave) {
|
||
if (wave >= 3) playSfxLayer('comboMega', 'blast', 70, .55)
|
||
else if (wave === 2) playSfxLayer('combo3', 'pop', 60, .5)
|
||
else if (matchSize >= 5) playSfxLayer('match5', 'blast', 65, .55)
|
||
else if (matchSize >= 4) playSfxLayer('match4', 'sparkle', 55, .5)
|
||
else playSfxLayer('match3', 'pop', 45, .45)
|
||
}
|
||
|
||
function planCamelReward(requested) {
|
||
const available = Math.max(0, MAX_CAMEL_COUNT - camelCount.value)
|
||
const granted = Math.min(requested, available)
|
||
const overflow = requested - granted
|
||
return { granted, overflow, overflowScore: overflow * OVERFLOW_CAMEL_SCORE }
|
||
}
|
||
|
||
function grantCamelReward(requested) {
|
||
const reward = planCamelReward(requested)
|
||
camelCount.value += reward.granted
|
||
score.value += reward.overflowScore
|
||
return reward
|
||
}
|
||
|
||
function rewardMegaCombo(wave) {
|
||
score.value += MEGA_COMBO_SCORE
|
||
const camelReward = grantCamelReward(1)
|
||
const rewardText = camelRewardText(camelReward)
|
||
statusText.value = `${wave} 连消惊喜:+${MEGA_COMBO_SCORE} 分${rewardText ? ` · ${rewardText}` : ''}`
|
||
taskRewardStatusUntil = Date.now() + 1800
|
||
if (camelReward.granted > 0) showTaskCamelReward()
|
||
startConfetti()
|
||
playSfxLayer('comboMega', 'fanfare', 100, .82)
|
||
vibrate('medium')
|
||
uni.showToast({
|
||
title: `${wave} 连消惊喜:+${MEGA_COMBO_SCORE} 分${camelReward.granted > 0 ? '、驼乳粉 +1' : ''}`,
|
||
icon: 'none'
|
||
})
|
||
}
|
||
|
||
function camelRewardText(reward) {
|
||
const parts = []
|
||
if (reward.granted > 0) parts.push(`驼乳粉 ×${reward.granted}`)
|
||
if (reward.overflow > 0) parts.push(`满仓多余奖励换 ${reward.overflowScore} 分`)
|
||
return parts.join(' · ')
|
||
}
|
||
|
||
function applyCamelJackpot(count, remove) {
|
||
pendingThemeMatchCount = Math.max(pendingThemeMatchCount, count)
|
||
if (count >= 5) {
|
||
score.value += 1554
|
||
const camelReward = grantCamelReward(4)
|
||
feastMoves.value = Math.max(feastMoves.value, 6)
|
||
board.value.forEach((tile, index) => { if (foodOf(tile?.key).high) remove.add(index) })
|
||
showCamelJackpot(5, '驼乳五连双倍大奖', `+1554 分 · ${camelRewardText(camelReward)} · 双倍得分 ×6`)
|
||
startConfetti()
|
||
playJackpotSequence()
|
||
return
|
||
}
|
||
if (count === 4) {
|
||
score.value += 288
|
||
const camelReward = grantCamelReward(1)
|
||
const highIndex = board.value.findIndex((tile, index) => foodOf(tile?.key).high && !remove.has(index))
|
||
if (highIndex >= 0) remove.add(highIndex)
|
||
showCamelJackpot(4, '驼乳超级四连', `+288 分 · ${camelRewardText(camelReward)} · 移出一份高糖食物`)
|
||
playSfxLayer('match5', 'fanfare', 90, .7)
|
||
setTimeout(() => playSfx('tap', 1.16, .58), 260)
|
||
vibrate('medium')
|
||
return
|
||
}
|
||
score.value += 77
|
||
showCamelJackpot(3, '驼乳幸运三连', '+77 分 · 轻量连消奖励')
|
||
playSfxLayer('match3', 'sparkle', 44, .38)
|
||
vibrate('light')
|
||
}
|
||
|
||
function advanceFoodTheme() {
|
||
const previous = currentTheme.value
|
||
themeIndex.value = (themeIndex.value + 1) % FOOD_GROUPS.length
|
||
const nextTheme = currentTheme.value
|
||
const mapping = {}
|
||
previous.safe.forEach((key, index) => { mapping[key] = nextTheme.safe[index] })
|
||
mapping[previous.danger] = nextTheme.danger
|
||
const mappedBoard = board.value.map(tile => {
|
||
if (!tile || !mapping[tile.key]) return tile
|
||
return { ...tile, key: mapping[tile.key] }
|
||
})
|
||
board.value = mappedBoard
|
||
if (findMatches(mappedBoard).length || !hasSafeMoveIn(mappedBoard)) {
|
||
board.value = buildPlayableBoard(openingKeysForTheme(nextTheme))
|
||
}
|
||
taskIndex.value = 0
|
||
taskProgress.value = 0
|
||
comboDryTurns.value = 0
|
||
selected.value = -1
|
||
camelMode.value = false
|
||
endedFood.value = foodOf(nextTheme.danger)
|
||
stageMeta.value = {
|
||
number: themeIndex.value + 1,
|
||
title: nextTheme.title,
|
||
foods: [...nextTheme.safe, nextTheme.danger].map(key => ({ key, ...foodOf(key) }))
|
||
}
|
||
stageVisible.value = true
|
||
statusText.value = `已进入${nextTheme.title},橙框${foodOf(nextTheme.danger).short}可替换`
|
||
playSfxLayer('fanfare', 'sparkle', 100, .65)
|
||
if (stageTimer) clearTimeout(stageTimer)
|
||
stageTimer = setTimeout(() => { stageVisible.value = false }, 1800)
|
||
}
|
||
|
||
function showCamelJackpot(count, title, reward) {
|
||
if (jackpotTimer) clearTimeout(jackpotTimer)
|
||
camelJackpot.value = {
|
||
visible: true,
|
||
count,
|
||
kicker: count >= 5 ? 'JACKPOT · 777' : count === 4 ? 'ONE MORE · 再来一罐' : 'LUCKY · 小惊喜',
|
||
title,
|
||
reward
|
||
}
|
||
const duration = count >= 5 ? 2200 : count === 4 ? 1900 : 1150
|
||
jackpotTimer = setTimeout(() => { camelJackpot.value.visible = false }, duration)
|
||
}
|
||
|
||
function startConfetti() {
|
||
if (confettiTimer) clearTimeout(confettiTimer)
|
||
confettiVisible.value = false
|
||
setTimeout(() => { confettiVisible.value = true }, 20)
|
||
confettiTimer = setTimeout(() => { confettiVisible.value = false }, 6000)
|
||
}
|
||
|
||
function playJackpotSequence() {
|
||
playSfx('tap', .9, .82)
|
||
setTimeout(() => playSfx('tap', 1, 1.05), 150)
|
||
setTimeout(() => playSfx('tap', 1.08, 1.35), 300)
|
||
setTimeout(() => playSfxLayer('comboMega', 'fanfare', 100, .85), 430)
|
||
setTimeout(() => playSfxLayer('blast', 'sparkle', 90, .75), 690)
|
||
vibrate('medium')
|
||
setTimeout(() => vibrate('medium'), 170)
|
||
setTimeout(() => vibrate('heavy'), 430)
|
||
}
|
||
|
||
function showImpact(matchSize, wave, specialType = '') {
|
||
impactLevel.value = Math.min(3, wave >= 3 ? 3 : matchSize >= 5 ? 3 : matchSize >= 4 ? 2 : 1)
|
||
if (specialType === 'fire-high') impactText.value = '火焰移出高糖!'
|
||
else if (wave >= 3) impactText.value = `${wave} 连消!`
|
||
else if (specialType === 'fire-triggered') impactText.value = '火焰清除!'
|
||
else if (specialType === 'burst-triggered') impactText.value = '范围爆破!'
|
||
else if (specialType === 'burst') impactText.value = 'L形惊喜!'
|
||
else if (matchSize >= 5) impactText.value = '五连惊喜!'
|
||
else if (matchSize >= 4) impactText.value = '四连!'
|
||
else impactText.value = '消除!'
|
||
setTimeout(() => { impactText.value = '' }, 520)
|
||
}
|
||
|
||
function vibrate(type = 'light') {
|
||
try { uni.vibrateShort({ type }) } catch (_) {}
|
||
}
|
||
|
||
function expandSpecials(remove) {
|
||
const queue = [...remove]
|
||
const seen = new Set()
|
||
const fire = new Set()
|
||
let hasBurst = false
|
||
let hasFire = false
|
||
while (queue.length) {
|
||
const index = queue.shift()
|
||
if (seen.has(index)) continue
|
||
seen.add(index)
|
||
const tile = board.value[index]
|
||
if (!tile?.special) continue
|
||
hasFire = true
|
||
const extra = []
|
||
if (tile.special === 'row') {
|
||
const row = Math.floor(index / COLS)
|
||
for (let col = 0; col < COLS; col += 1) extra.push(row * COLS + col)
|
||
} else if (tile.special === 'col') {
|
||
const col = index % COLS
|
||
for (let row = 0; row < ROWS; row += 1) extra.push(row * COLS + col)
|
||
} else if (tile.special === 'burst') {
|
||
hasBurst = true
|
||
const centerRow = Math.floor(index / COLS)
|
||
const centerCol = index % COLS
|
||
for (let row = Math.max(0, centerRow - 1); row <= Math.min(ROWS - 1, centerRow + 1); row += 1) {
|
||
for (let col = Math.max(0, centerCol - 1); col <= Math.min(COLS - 1, centerCol + 1); col += 1) {
|
||
extra.push(row * COLS + col)
|
||
}
|
||
}
|
||
} else {
|
||
board.value.forEach((item, i) => { if (item?.key === tile.key) extra.push(i) })
|
||
}
|
||
extra.forEach(i => fire.add(i))
|
||
extra.forEach(i => {
|
||
if (!remove.has(i)) { remove.add(i); queue.push(i) }
|
||
})
|
||
}
|
||
fireClearingIndices.value = [...fire]
|
||
return { hasBurst, hasFire }
|
||
}
|
||
|
||
function collapseAndFill(forceSafeMatch = false) {
|
||
const next = Array(ROWS * COLS).fill(null)
|
||
for (let col = 0; col < COLS; col += 1) {
|
||
const existing = []
|
||
for (let row = ROWS - 1; row >= 0; row -= 1) {
|
||
const tile = board.value[row * COLS + col]
|
||
if (tile) existing.push(tile)
|
||
}
|
||
existing.forEach((tile, offset) => {
|
||
next[(ROWS - 1 - offset) * COLS + col] = tile
|
||
})
|
||
}
|
||
const forcedIndices = forceSafeMatch ? findForcedDropMatch(next) : []
|
||
const forcedSet = new Set(forcedIndices)
|
||
const forcedKey = forcedIndices.length ? pickForcedDropKey(next, forcedIndices) : ''
|
||
for (let row = 0; row < ROWS; row += 1) {
|
||
for (let col = 0; col < COLS; col += 1) {
|
||
const index = row * COLS + col
|
||
if (!next[index]) {
|
||
const key = forcedSet.has(index) ? forcedKey : pickDropFoodKey(next, index, col)
|
||
next[index] = makeTile(key)
|
||
}
|
||
}
|
||
}
|
||
board.value = next
|
||
return forcedIndices.length === 3
|
||
}
|
||
|
||
function findForcedDropMatch(snapshot) {
|
||
const empty = new Set(snapshot.map((tile, index) => tile ? -1 : index).filter(index => index >= 0))
|
||
for (let row = 0; row < ROWS; row += 1) {
|
||
for (let col = 0; col <= COLS - 3; col += 1) {
|
||
const indices = [0, 1, 2].map(offset => row * COLS + col + offset)
|
||
if (indices.every(index => empty.has(index))) return indices
|
||
}
|
||
}
|
||
for (let col = 0; col < COLS; col += 1) {
|
||
for (let row = 0; row <= ROWS - 3; row += 1) {
|
||
const indices = [0, 1, 2].map(offset => (row + offset) * COLS + col)
|
||
if (indices.every(index => empty.has(index))) return indices
|
||
}
|
||
}
|
||
return []
|
||
}
|
||
|
||
function pickForcedDropKey(snapshot, indices) {
|
||
return currentTheme.value.safe
|
||
.map(key => ({
|
||
key,
|
||
nearby: snapshot.reduce((count, tile, index) => (
|
||
tile?.key === key && indices.some(target => isAdjacent(target, index)) ? count + 1 : count
|
||
), 0)
|
||
}))
|
||
.sort((a, b) => a.nearby - b.nearby)[0]?.key || currentTheme.value.safe[0]
|
||
}
|
||
|
||
function pickDropFoodKey(snapshot, index, col) {
|
||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||
const key = randomFoodKey(snapshot, col)
|
||
if (!wouldCreateMatchAt(snapshot, index, key)) return key
|
||
}
|
||
const fallback = [...currentTheme.value.safe, currentTheme.value.danger]
|
||
.find(key => !wouldCreateMatchAt(snapshot, index, key))
|
||
return fallback || currentTheme.value.safe[(index + col) % currentTheme.value.safe.length]
|
||
}
|
||
|
||
function wouldCreateMatchAt(source, index, key) {
|
||
const row = Math.floor(index / COLS)
|
||
const col = index % COLS
|
||
const keyAt = target => target === index ? key : source[target]?.key
|
||
let horizontal = 1
|
||
for (let c = col - 1; c >= 0 && keyAt(row * COLS + c) === key; c -= 1) horizontal += 1
|
||
for (let c = col + 1; c < COLS && keyAt(row * COLS + c) === key; c += 1) horizontal += 1
|
||
if (horizontal >= 3) return true
|
||
let vertical = 1
|
||
for (let r = row - 1; r >= 0 && keyAt(r * COLS + col) === key; r -= 1) vertical += 1
|
||
for (let r = row + 1; r < ROWS && keyAt(r * COLS + col) === key; r += 1) vertical += 1
|
||
return vertical >= 3
|
||
}
|
||
|
||
function randomFoodKey(snapshot, col = -1) {
|
||
const highs = snapshot.filter(tile => tile && foodOf(tile.key).high).length
|
||
if (highs < HIGH_CAP && Math.random() < DANGER_DROP_CHANCE) return currentTheme.value.danger
|
||
const keys = currentTheme.value.safe
|
||
if (col >= 0 && Math.random() < COLUMN_GROUP_BIAS) return keys[col % keys.length]
|
||
return keys[Math.floor(Math.random() * keys.length)]
|
||
}
|
||
|
||
function findBentFiveMatch(groups) {
|
||
const rows = groups.filter(group => group.direction === 'row' && group.indices.length === 3)
|
||
const cols = groups.filter(group => group.direction === 'col' && group.indices.length === 3)
|
||
for (const row of rows) {
|
||
for (const col of cols) {
|
||
const intersection = row.indices.find(index => col.indices.includes(index))
|
||
if (intersection !== undefined) return { intersection, row, col }
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function findMatches(source) {
|
||
const groups = []
|
||
for (let row = 0; row < ROWS; row += 1) {
|
||
let start = 0
|
||
while (start < COLS) {
|
||
let end = start + 1
|
||
while (end < COLS && source[row * COLS + end]?.key === source[row * COLS + start]?.key) end += 1
|
||
if (source[row * COLS + start] && end - start >= 3) {
|
||
groups.push({ direction: 'row', indices: Array.from({ length: end - start }, (_, n) => row * COLS + start + n) })
|
||
}
|
||
start = end
|
||
}
|
||
}
|
||
for (let col = 0; col < COLS; col += 1) {
|
||
let start = 0
|
||
while (start < ROWS) {
|
||
let end = start + 1
|
||
while (end < ROWS && source[end * COLS + col]?.key === source[start * COLS + col]?.key) end += 1
|
||
if (source[start * COLS + col] && end - start >= 3) {
|
||
groups.push({ direction: 'col', indices: Array.from({ length: end - start }, (_, n) => (start + n) * COLS + col) })
|
||
}
|
||
start = end
|
||
}
|
||
}
|
||
return groups
|
||
}
|
||
|
||
function updateFoodTask(tiles) {
|
||
if (currentTask.value.kind !== 'food') return
|
||
advanceTask(tiles.filter(tile => tile.key === currentTask.value.key).length)
|
||
}
|
||
|
||
function advanceTask(amount) {
|
||
if (!amount) return
|
||
taskProgress.value += amount
|
||
if (taskProgress.value < currentTask.value.target) return
|
||
score.value += 120
|
||
const camelReward = grantCamelReward(1)
|
||
if (camelReward.granted > 0) {
|
||
showTaskCamelReward()
|
||
statusText.value = '小目标完成,驼乳粉已放入道具栏'
|
||
} else {
|
||
statusText.value = `驼乳粉已满,多余奖励换 ${camelReward.overflowScore} 分`
|
||
}
|
||
uni.showToast({
|
||
title: camelReward.granted > 0 ? '小目标完成:驼乳粉 +1' : `驼乳粉已满:奖励换 ${camelReward.overflowScore} 分`,
|
||
icon: 'none'
|
||
})
|
||
taskIndex.value = (taskIndex.value + 1) % 4
|
||
taskProgress.value = 0
|
||
comboDryTurns.value = 0
|
||
}
|
||
|
||
function showTaskCamelReward() {
|
||
if (taskRewardTimer) clearTimeout(taskRewardTimer)
|
||
taskRewardKey.value += 1
|
||
taskRewardVisible.value = true
|
||
taskRewardStatusUntil = Date.now() + 1600
|
||
playSfxLayer('boost', 'sparkle', 75, .55)
|
||
vibrate('light')
|
||
taskRewardTimer = setTimeout(() => { taskRewardVisible.value = false }, 1150)
|
||
}
|
||
|
||
function finishTurn() {
|
||
saveBest()
|
||
if (checkBoardHealth()) return
|
||
if (stageVisible.value) {
|
||
scheduleHint()
|
||
return
|
||
}
|
||
if (Date.now() < taskRewardStatusUntil) {
|
||
scheduleHint()
|
||
return
|
||
}
|
||
statusText.value = combo.value >= 2 ? `漂亮!完成 ${combo.value} 连消` : '食物已从上方补充,继续挑战'
|
||
scheduleHint()
|
||
}
|
||
|
||
function checkBoardHealth() {
|
||
if (hasSafeMove() || reshuffling.value) return false
|
||
performReshuffle()
|
||
return true
|
||
}
|
||
|
||
function hasSafeMove() {
|
||
return hasSafeMoveIn(board.value)
|
||
}
|
||
|
||
function hasSafeMoveIn(source) {
|
||
return Boolean(findSafeMoveIn(source))
|
||
}
|
||
|
||
function findSafeMoveIn(source) {
|
||
for (let i = 0; i < source.length; i += 1) {
|
||
const candidates = []
|
||
if (i % COLS < COLS - 1) candidates.push(i + 1)
|
||
if (i + COLS < source.length) candidates.push(i + COLS)
|
||
for (const j of candidates) {
|
||
const copy = source.map(tile => ({ ...tile }))
|
||
;[copy[i], copy[j]] = [copy[j], copy[i]]
|
||
const groups = findMatches(copy)
|
||
if (groups.length && !groups.flatMap(group => group.indices).some(index => foodOf(copy[index].key).high)) {
|
||
return { from: i, to: j }
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
async function performReshuffle() {
|
||
if (reshuffling.value || gameOver.value) return
|
||
reshuffling.value = true
|
||
busy.value = true
|
||
reshuffleDone.value = false
|
||
reshuffleVisible.value = true
|
||
selected.value = -1
|
||
camelMode.value = false
|
||
statusText.value = '没有可消除组合,正在重新洗牌'
|
||
playSfx('reshuffle', .9)
|
||
vibrate('medium')
|
||
await wait(760)
|
||
|
||
const source = board.value.map(tile => ({ ...tile }))
|
||
let candidate = null
|
||
for (let attempt = 0; attempt < 240; attempt += 1) {
|
||
const shuffled = shuffleTiles(source)
|
||
if (findMatches(shuffled).length) continue
|
||
if (!hasSafeMoveIn(shuffled)) continue
|
||
candidate = shuffled
|
||
break
|
||
}
|
||
board.value = candidate || buildGuaranteedBoard()
|
||
reshuffleDone.value = true
|
||
statusText.value = '洗牌完成,新的消除机会已经出现'
|
||
playSfxLayer('drop', 'sparkle', 70, .55)
|
||
vibrate('light')
|
||
await wait(520)
|
||
reshuffleVisible.value = false
|
||
reshuffling.value = false
|
||
busy.value = false
|
||
scheduleHint()
|
||
}
|
||
|
||
function shuffleTiles(source) {
|
||
const result = source.map(tile => ({ ...tile }))
|
||
for (let i = result.length - 1; i > 0; i -= 1) {
|
||
const j = Math.floor(Math.random() * (i + 1))
|
||
;[result[i], result[j]] = [result[j], result[i]]
|
||
}
|
||
return result
|
||
}
|
||
|
||
function buildGuaranteedBoard() {
|
||
return buildPlayableBoard(openingKeysForTheme(currentTheme.value))
|
||
}
|
||
|
||
function openingKeysForTheme(theme) {
|
||
const [a, b, c, d] = theme.safe
|
||
const danger = theme.danger
|
||
return [
|
||
a, b, c, d,
|
||
b, c, d, a,
|
||
c, d, a, danger,
|
||
d, a, danger, b,
|
||
a, b, c, d
|
||
]
|
||
}
|
||
|
||
function buildPlayableBoard(keys) {
|
||
let candidate = keys.map(key => makeTile(key))
|
||
for (let attempt = 0; attempt < 240; attempt += 1) {
|
||
if (!findMatches(candidate).length && hasSafeMoveIn(candidate)) return candidate
|
||
candidate = shuffleTiles(candidate)
|
||
}
|
||
return forcedPlayableKeysForTheme(currentTheme.value).map(key => makeTile(key))
|
||
}
|
||
|
||
function forcedPlayableKeysForTheme(theme) {
|
||
const [a, b, c, d] = theme.safe
|
||
const danger = theme.danger
|
||
return [
|
||
d, a, danger, c,
|
||
d, c, b, danger,
|
||
c, a, b, a,
|
||
d, d, a, a,
|
||
b, d, c, b
|
||
]
|
||
}
|
||
|
||
function makeStablePlayableBoard(source) {
|
||
const tiles = source.filter(Boolean).map(tile => ({ ...tile }))
|
||
if (tiles.length === ROWS * COLS) {
|
||
for (let attempt = 0; attempt < 300; attempt += 1) {
|
||
const candidate = shuffleTiles(tiles)
|
||
if (!findMatches(candidate).length && hasSafeMoveIn(candidate)) return candidate
|
||
}
|
||
}
|
||
return buildGuaranteedBoard()
|
||
}
|
||
|
||
function safeMoveHint(move) {
|
||
if (!move) return ''
|
||
const from = foodOf(board.value[move.from]?.key).short
|
||
const to = foodOf(board.value[move.to]?.key).short
|
||
const direction = move.to === move.from + 1 ? '向右' : '向下'
|
||
return `提示:把${from}${direction}与${to}交换`
|
||
}
|
||
|
||
function scheduleHint() {
|
||
if (hintTimer) clearTimeout(hintTimer)
|
||
hintTimer = setTimeout(() => {
|
||
if (busy.value || gameOver.value || stageVisible.value) return
|
||
const move = findSafeMoveIn(board.value)
|
||
if (!move) {
|
||
performReshuffle()
|
||
return
|
||
}
|
||
statusText.value = safeMoveHint(move)
|
||
}, 5000)
|
||
}
|
||
|
||
function saveBest() {
|
||
if (score.value <= bestScore.value) return
|
||
bestScore.value = score.value
|
||
uni.setStorageSync('tongji_endless_best', bestScore.value)
|
||
}
|
||
|
||
function wait(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms))
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import './game-endless.scss';
|
||
</style>
|