Files
zyt/TUICallKit-Vue3/tongji/endless-game/index.vue
T

1491 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view
class="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>