This commit is contained in:
2026-06-09 10:05:49 +08:00
5 changed files with 225 additions and 42 deletions
+3 -3
View File
@@ -52,14 +52,14 @@
"mp-weixin" : {
"appid" : "wx79b9a0bfbfe7cbcd",
"setting" : {
"urlCheck" : false
"urlCheck" : false,
"minified" : true
},
"optimization" : {
"subPackages" : true
},
"usingComponents" : true,
"requiredBackgroundModes" : ["audio"],
"requiredBackgroundModes" : [ "audio" ],
"plugins" : {
"WechatSI" : {
"version" : "0.3.5",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+170 -31
View File
@@ -923,6 +923,9 @@ function initBoard() {
commitBoard(next)
// 注入本关唯一高糖(解锁前固定 2 个,无法三连)
ensureHighPresence()
// 注入高糖可能破坏“有解”性:开局静默兜底,避免出现整盘无法消除的死局
// (仅打乱类型、保持高糖数量不变)
if (!boardHasSolution()) shuffleTypesUntilPlayable()
startHintTimer()
}
@@ -1423,6 +1426,38 @@ function startHintTimer() {
hintTimer = setTimeout(showOperationHint, HINT_DELAY_MS)
}
/**
* 就地打乱棋盘上所有方块的“食物类型”(位置/对象不变),直到棋盘“有解”:
* 无现成消除组 + 至少存在一个可用移动。仅交换 type,故高糖数量等多重集不变。
* 不带任何 UI 反馈,供初始化等静默场景使用。返回是否成功。
*/
function shuffleTypesUntilPlayable(maxRetry = 80) {
const tiles = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = board.value[r]?.[c]
if (t && !t.isRemoved) tiles.push(t)
}
}
if (tiles.length < 3) return false
for (let attempt = 0; attempt < maxRetry; attempt++) {
for (let i = tiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tiles[i].type, tiles[j].type] = [tiles[j].type, tiles[i].type]
}
if (findMatches().length === 0 && findPossibleMoves().length > 0) {
bumpLayout()
return true
}
}
return false
}
/** 当前棋盘是否“有解”(无现成消除组且至少有一个可用移动) */
function boardHasSolution() {
return findMatches().length === 0 && findPossibleMoves().length > 0
}
/**
* 棋盘死锁自动被测与修复。
* 使用 Fisher-Yates 打乱食物类型,最多重试 maxRetry 次。
@@ -1431,7 +1466,7 @@ function startHintTimer() {
async function reshuffleIfStuck() {
if (gamePhase.value !== 'playing') return
// 已有解(无现成消除组 + 存在可用移动)则无需重排
if (findMatches().length === 0 && findPossibleMoves().length > 0) return
if (boardHasSolution()) return
// 给玩家反馈
playSfx('reshuffle', 0.75)
@@ -1444,32 +1479,8 @@ async function reshuffleIfStuck() {
setTimeout(() => { floatingVals.value = floatingVals.value.filter(v => v.id !== id) }, 1400)
} catch (_) {}
// 收集棋盘上所有有效方块(保持 tile 对象与位置不变,仅打乱其食物类型)
const tiles = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = board.value[r]?.[c]
if (t && !t.isRemoved) tiles.push(t)
}
}
// 反复打乱类型并就地试写,直到“有解且无现成三连”;
// tiles 已是 board.value 中的同一批对象,故 findMatches/findPossibleMoves 可直接复用
const MAX_RETRY = 60
let ok = false
for (let attempt = 0; attempt < MAX_RETRY; attempt++) {
for (let i = tiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tiles[i].type, tiles[j].type] = [tiles[j].type, tiles[i].type]
}
if (findMatches().length === 0 && findPossibleMoves().length > 0) {
ok = true
break
}
}
if (!ok) {
// 就地洗牌无法洗出可玩布局,回退到全棋盘重新生成
// 就地打乱类型直到有解;失败则回退到全棋盘重新生成
if (!shuffleTypesUntilPlayable(60)) {
initBoard()
return
}
@@ -1754,10 +1765,8 @@ async function processSwap(t1, t2, opts = {}) {
isProcessing.value = false
return
}
// 按解锁进度维持高糖数量(到第 7 步起补到可消除的 3 个
ensureHighPresence()
// 所有连消结束后,检查棋盘是否死锁并自动重排
await reshuffleIfStuck()
// 落子算法已保证“下落即有可消除走法”;此处仅作极端兜底(静默、无横幅
if (!boardHasSolution()) shuffleTypesUntilPlayable()
} else {
await animateSwapOffset(t1, t2)
swapTiles(t1, t2)
@@ -1831,9 +1840,134 @@ async function clearAndRefill() {
return false
}
/* ——— 落子前在 next 网格上做的辅助判定(不触动可见的 board.value ——— */
function gridIsHigh(grid, r, c) {
const t = grid[r]?.[c]
return t && !t.isRemoved && t.type.gi === 'high'
}
function gridWouldFormHighRun(grid, r, c) {
let h = 1
for (let cc = c - 1; cc >= 0 && gridIsHigh(grid, r, cc); cc--) h++
for (let cc = c + 1; cc < COLS && gridIsHigh(grid, r, cc); cc++) h++
if (h >= 3) return true
let v = 1
for (let rr = r - 1; rr >= 0 && gridIsHigh(grid, rr, c); rr--) v++
for (let rr = r + 1; rr < ROWS && gridIsHigh(grid, rr, c); rr++) v++
return v >= 3
}
/** 在 grid 上是否存在“现成可消除组” */
function gridHasCurrentMatch(grid) {
const saved = board.value
board.value = grid
const has = findMatches().length > 0
board.value = saved
return has
}
/** 在 grid 上是否存在“可交换出消除的走法” */
function gridHasMove(grid) {
const saved = board.value
board.value = grid
const ok = findPossibleMoves().length > 0
board.value = saved
return ok
}
/**
* 仅在 next 网格上调整本局高糖数量(只动“新生成的方块”,玩家尚未看到,故无可见突变):
* 解锁前固定 2 个、解锁后 3~HIGH_COUNT_MAX 个;注入时避免直接连成三连。
*/
function balanceHighInRefill(next, newTiles) {
const highFood = sessionHighFood.value
if (!highFood) return
const before = playerMoves.value < HIGH_UNLOCK_MOVES
const minTarget = before ? HIGH_COUNT_LOCKED : HIGH_COUNT_UNLOCKED
const maxCap = before ? HIGH_COUNT_LOCKED : HIGH_COUNT_MAX
let high = 0
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) if (gridIsHigh(next, r, c)) high++
}
const lowMidPool = activeFoods.value.filter((f) => f.gi !== 'high')
if (high > maxCap) {
for (const t of shuffleInPlace([...newTiles])) {
if (high <= maxCap) break
if (t.type.gi !== 'high') continue
const repl = lowMidPool.length ? lowMidPool[Math.floor(Math.random() * lowMidPool.length)] : null
if (repl) { t.type = cloneFoodType(repl); high-- }
}
}
if (high < minTarget) {
const cands = shuffleInPlace([...newTiles])
for (const t of cands) {
if (high >= minTarget) break
if (t.type.gi === 'high') continue
if (gridWouldFormHighRun(next, t.r, t.c)) continue
t.type = cloneFoodType(highFood)
high++
}
for (const t of cands) {
if (high >= minTarget) break
if (t.type.gi === 'high') continue
t.type = cloneFoodType(highFood)
high++
}
}
}
/**
* 保证“落下后必有可消除的走法”:只重 roll 新方块里的非高糖类型(落子前完成,无可见重排)。
* 若 next 已有现成消除组(即将触发连锁),则交给连锁处理,最终落子时再保证。
*/
function ensureRefillPlayable(next, newTiles) {
if (gridHasCurrentMatch(next)) return
if (gridHasMove(next)) return
const nonHighPool = activeFoods.value.filter((f) => f.gi !== 'high')
const rerollable = newTiles.filter((t) => t.type.gi !== 'high')
if (nonHighPool.length && rerollable.length) {
for (let attempt = 0; attempt < 100; attempt++) {
rerollable.forEach((t) => {
t.type = cloneFoodType(nonHighPool[Math.floor(Math.random() * nonHighPool.length)])
})
if (gridHasMove(next)) return
}
}
// 极端兜底:静默打乱整盘类型(保持多重集/高糖数量),无任何横幅提示
silentShuffleGrid(next)
}
/** 在 next 网格上静默打乱类型直至“无现成消除组且有可用走法”,仅作极端兜底 */
function silentShuffleGrid(next) {
const tiles = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = next[r]?.[c]
if (t && !t.isRemoved) tiles.push(t)
}
}
if (tiles.length < 3) return
const saved = board.value
board.value = next
for (let attempt = 0; attempt < 80; attempt++) {
for (let i = tiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tiles[i].type, tiles[j].type] = [tiles[j].type, tiles[i].type]
}
if (findMatches().length === 0 && findPossibleMoves().length > 0) break
}
board.value = saved
}
async function applyGravity() {
const next = Array.from({ length: ROWS }, () => Array(COLS).fill(null))
const dropTiles = []
const newTiles = []
for (let c = 0; c < COLS; c++) {
const surviving = []
@@ -1861,10 +1995,15 @@ async function applyGravity() {
tile.isDropAnimate = false
next[r][c] = tile
dropTiles.push(tile)
newTiles.push(tile)
}
}
ensureBoardFull(next)
// 落子前只调整“新方块”:①按解锁进度维持高糖数量 ②保证落下后必有可消除走法
// 这样棋盘随下落即更新,无需“无解时整盘自动重排”。
balanceHighInRefill(next, newTiles)
ensureRefillPlayable(next, newTiles)
commitBoard(next)
await nextTick()
+24 -4
View File
@@ -193,19 +193,26 @@
<view class="st-float-close" @click="floatAskClosed = true">
<text class="st-float-close-icon">×</text>
</view>
<view class="st-float-inner">
<view class="st-float-inner" :class="{ 'is-expanded': dietAiAskExpanded }">
<view class="st-float-icon">
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
</view>
<input
<textarea
class="st-float-input"
type="text"
:value="dietAiAskText"
placeholder="长按麦克风说话,或输入文字咨询 AI..."
placeholder="输入或语音咨询 AI"
placeholder-class="st-float-placeholder"
:maxlength="-1"
auto-height
:show-confirm-bar="false"
:disable-default-padding="true"
:cursor-spacing="24"
confirm-type="send"
:confirm-hold="false"
@input="onDietAiAskInput"
@confirm="askDietAiFood"
@focus="onDietAiAskFocus"
@blur="onDietAiAskBlur"
/>
<view
class="st-float-mic"
@@ -519,6 +526,19 @@ const {
/** 底部 AI 浮动卡片是否被用户收起 */
const floatAskClosed = ref(false)
/** AI 输入框是否聚焦:聚焦时输入框变大 */
const dietAiAskFocused = ref(false)
function onDietAiAskFocus() {
dietAiAskFocused.value = true
}
function onDietAiAskBlur() {
dietAiAskFocused.value = false
}
/** 聚焦或已有内容时展开为多行输入(内容多则自动增高) */
const dietAiAskExpanded = computed(
() => dietAiAskFocused.value || String(dietAiAskText.value || '').length > 0
)
/** 键盘高度(px):录入弹窗据此整体上移,避免输入框被键盘遮挡 */
const keyboardHeight = ref(0)
function onKeyboardHeightChange(res) {
@@ -740,13 +740,22 @@
.weekly-page .st-float-inner {
display: flex;
align-items: center;
gap: 24rpx;
padding: 16rpx 16rpx 16rpx 40rpx;
gap: 16rpx;
padding: 16rpx 16rpx 16rpx 28rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20rpx);
border: 1rpx solid rgba(0, 108, 73, 0.1);
box-shadow: var(--st-shadow-float);
transition: border-radius 0.22s ease, padding 0.22s ease, border-color 0.22s ease;
}
/* 聚焦/有内容时展开:底部对齐让输入框向上增高,圆角收敛为圆角矩形 */
.weekly-page .st-float-inner.is-expanded {
align-items: flex-end;
border-radius: 32rpx;
padding: 18rpx 16rpx 18rpx 32rpx;
border-color: rgba(0, 108, 73, 0.28);
}
.weekly-page .st-float-icon {
@@ -763,15 +772,30 @@
.weekly-page .st-float-input {
flex: 1;
min-width: 0;
height: 64rpx;
width: 100%;
min-height: 64rpx;
max-height: 240rpx;
font-size: 28rpx;
line-height: 40rpx;
color: var(--on-surface);
background: transparent;
padding: 12rpx 0;
box-sizing: border-box;
transition: min-height 0.2s ease;
}
/* 聚焦时输入框变大;内容超长时由 auto-height 继续向上增高,超过上限可滚动 */
.weekly-page .st-float-inner.is-expanded .st-float-input {
min-height: 104rpx;
}
.weekly-page .st-float-placeholder {
color: rgba(60, 74, 66, 0.6);
font-size: 28rpx;
font-size: 26rpx;
line-height: 40rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.weekly-page .st-float-mic {