refine heart breath preparation and contraction

This commit is contained in:
leefer
2026-07-23 20:49:17 +08:00
parent fc3e7499d2
commit d520749252
4 changed files with 58 additions and 28 deletions
+13 -6
View File
@@ -13,8 +13,10 @@ const {
const HEART_BREATH_INHALE_MS = 3_000; const HEART_BREATH_INHALE_MS = 3_000;
const HEART_BREATH_HOLD_MS = 2_000; const HEART_BREATH_HOLD_MS = 2_000;
const HEART_BREATH_EXHALE_MS = 4_000; const HEART_BREATH_EXHALE_MS = 4_000;
const HEART_BREATH_PREPARE_MS = 1_000;
const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS; const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS;
const HEART_BREATH_TOTAL_MS = HEART_BREATH_CYCLE_MS * 5; const HEART_BREATH_ACTIVE_MS = HEART_BREATH_CYCLE_MS * 5;
const HEART_BREATH_TOTAL_MS = HEART_BREATH_PREPARE_MS + HEART_BREATH_ACTIVE_MS;
const state = { const state = {
user: null, user: null,
@@ -3831,8 +3833,11 @@ function updateBreathingDisplay() {
? Math.max(0, state.heartBreathingEndsAt - Date.now()) ? Math.max(0, state.heartBreathingEndsAt - Date.now())
: Math.max(0, state.heartSeconds * 1000); : Math.max(0, state.heartSeconds * 1000);
const elapsedMs = HEART_BREATH_TOTAL_MS - remainingMs; const elapsedMs = HEART_BREATH_TOTAL_MS - remainingMs;
const cycleElapsedMs = elapsedMs % HEART_BREATH_CYCLE_MS; const activeElapsedMs = Math.max(0, elapsedMs - HEART_BREATH_PREPARE_MS);
const breathPhase = cycleElapsedMs < HEART_BREATH_INHALE_MS const cycleElapsedMs = activeElapsedMs % HEART_BREATH_CYCLE_MS;
const breathPhase = elapsedMs < HEART_BREATH_PREPARE_MS
? "prepare"
: cycleElapsedMs < HEART_BREATH_INHALE_MS
? "inhale" ? "inhale"
: cycleElapsedMs < HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS : cycleElapsedMs < HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS
? "hold" ? "hold"
@@ -3840,14 +3845,16 @@ function updateBreathingDisplay() {
const phase = state.heartSeconds <= 0 ? "settled" : breathPhase; const phase = state.heartSeconds <= 0 ? "settled" : breathPhase;
const scene = document.querySelector("#breathingScene"); const scene = document.querySelector("#breathingScene");
scene.dataset.phase = phase; scene.dataset.phase = phase;
setText("breathingPhase", phase === "settled" ? "静" : phase === "inhale" ? "吸" : phase === "hold" ? "停驻" : "呼"); setText("breathingPhase", phase === "settled" ? "静" : phase === "prepare" ? "静" : phase === "inhale" ? "吸" : phase === "hold" ? "" : "呼");
const prompt = state.heartSeconds <= 0 const prompt = state.heartSeconds <= 0
? "静心已成,可以起卦" ? "静心已成,可以起卦"
: phase === "prepare"
? "放松片刻,准备呼吸"
: phase === "hold" : phase === "hold"
? "停驻片刻,让念头自然沉下" ? "停驻片刻,让念头自然沉下"
: elapsedMs < 18_000 : activeElapsedMs < 18_000
? phase === "inhale" ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开" ? phase === "inhale" ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开"
: elapsedMs < 36_000 : activeElapsedMs < 36_000
? phase === "inhale" ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案" ? phase === "inhale" ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案"
: phase === "inhale" ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头"; : phase === "inhale" ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头";
setText("breathingPrompt", prompt); setText("breathingPrompt", prompt);
+3 -3
View File
@@ -597,11 +597,11 @@
<button class="button heart-return-button" type="button" data-heart-return><i data-lucide="arrow-left"></i><span>返回</span></button> <button class="button heart-return-button" type="button" data-heart-return><i data-lucide="arrow-left"></i><span>返回</span></button>
<div class="heart-stage-inner breathing-stage"> <div class="heart-stage-inner breathing-stage">
<span class="heart-stage-index">观心 · 二</span> <span class="heart-stage-index">观心 · 二</span>
<div id="breathingScene" class="breathing-scene" data-phase="inhale"> <div id="breathingScene" class="breathing-scene" data-phase="prepare">
<div class="heart-breath-ripple" aria-hidden="true"><span></span><span></span><span></span><i></i></div> <div class="heart-breath-ripple" aria-hidden="true"><span></span><span></span><span></span><i></i></div>
<b id="breathingPhase" class="breathing-phase" role="status" aria-live="polite">吸气</b> <b id="breathingPhase" class="breathing-phase" role="status" aria-live="polite"></b>
</div> </div>
<h3 id="breathingPrompt">缓慢吸气,放下对答案的预设</h3> <h3 id="breathingPrompt">放松片刻,准备呼吸</h3>
<div class="heart-incense" aria-hidden="true"><i id="heartIncenseEmber"></i></div> <div class="heart-incense" aria-hidden="true"><i id="heartIncenseEmber"></i></div>
<button id="beginCastingButton" class="button primary" type="button" disabled>静心完成,开始起卦</button> <button id="beginCastingButton" class="button primary" type="button" disabled>静心完成,开始起卦</button>
</div> </div>
+16 -8
View File
@@ -10392,7 +10392,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
box-shadow: 0 0 14px 4px rgba(255, 180, 90, 0.35); box-shadow: 0 0 14px 4px rgba(255, 180, 90, 0.35);
} }
.heart-incense i.is-burning { animation: heart-incense-burn 45s linear forwards; } .heart-incense i.is-burning { animation: heart-incense-burn 45s linear 1s forwards; }
#heavenHeartPanel #beginCastingButton { #heavenHeartPanel #beginCastingButton {
opacity: 0.35; opacity: 0.35;
@@ -11001,7 +11001,9 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
border: 1px solid rgba(126, 77, 52, 0.5); border: 1px solid rgba(126, 77, 52, 0.5);
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 18px rgba(194, 159, 90, 0.06); box-shadow: 0 0 18px rgba(194, 159, 90, 0.06);
animation: heart-breath-ripple 9s cubic-bezier(0.37, 0.01, 0.22, 1) infinite; opacity: 0.12;
transform: scale(0.7);
animation: heart-breath-ripple 9s cubic-bezier(0.37, 0.01, 0.22, 1) 1s infinite both;
will-change: transform, opacity; will-change: transform, opacity;
} }
@@ -11031,25 +11033,26 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
background: rgba(253, 252, 248, 0.92); background: rgba(253, 252, 248, 0.92);
box-shadow: 0 0 0 6px rgba(194, 159, 90, 0.06), 0 0 20px rgba(154, 91, 69, 0.12); box-shadow: 0 0 0 6px rgba(194, 159, 90, 0.06), 0 0 20px rgba(154, 91, 69, 0.12);
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
animation: heart-breath-ripple-core 9s ease-in-out infinite; animation: heart-breath-ripple-core 9s ease-in-out 1s infinite both;
} }
@keyframes heart-breath-ripple { @keyframes heart-breath-ripple {
0% { opacity: 0.12; transform: scale(0.7); } 0% { opacity: 0.12; transform: scale(0.7); }
33.333% { opacity: 0.82; transform: scale(0.94); } 33.333% { opacity: 0.82; transform: scale(0.94); }
55.556% { opacity: 0.82; transform: scale(0.94); } 55.556% { opacity: 0.82; transform: scale(0.94); }
100% { opacity: 0; transform: scale(1.22); } 100% { opacity: 0.1; transform: scale(0.62); }
} }
@keyframes heart-breath-ripple-core { @keyframes heart-breath-ripple-core {
0% { opacity: 0.38; box-shadow: 0 0 0 3px rgba(194, 159, 90, 0.04), 0 0 8px rgba(154, 91, 69, 0.06); } 0% { opacity: 0.38; box-shadow: 0 0 0 3px rgba(194, 159, 90, 0.04), 0 0 8px rgba(154, 91, 69, 0.06); transform: translate(-50%, -50%) scale(0.76); }
33.333%, 55.556% { opacity: 1; box-shadow: 0 0 0 9px rgba(194, 159, 90, 0.09), 0 0 26px rgba(154, 91, 69, 0.18); } 33.333%, 55.556% { opacity: 1; box-shadow: 0 0 0 9px rgba(194, 159, 90, 0.09), 0 0 26px rgba(154, 91, 69, 0.18); transform: translate(-50%, -50%) scale(1); }
100% { opacity: 0.3; box-shadow: 0 0 0 14px rgba(194, 159, 90, 0), 0 0 5px rgba(154, 91, 69, 0.03); } 100% { opacity: 0.3; box-shadow: 0 0 0 2px rgba(194, 159, 90, 0), 0 0 5px rgba(154, 91, 69, 0.03); transform: translate(-50%, -50%) scale(0.64); }
} }
@keyframes heart-breath-ripple-soft { @keyframes heart-breath-ripple-soft {
0%, 100% { opacity: 0.24; transform: scale(0.94); } 0% { opacity: 0.24; transform: scale(0.86); }
33.333%, 55.556% { opacity: 0.68; transform: scale(0.94); } 33.333%, 55.556% { opacity: 0.68; transform: scale(0.94); }
100% { opacity: 0.18; transform: scale(0.82); }
} }
#heavenHeartPanel .breathing-phase { #heavenHeartPanel .breathing-phase {
@@ -11064,6 +11067,11 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
transition: color 480ms ease, opacity 480ms ease; transition: color 480ms ease, opacity 480ms ease;
} }
#heavenHeartPanel .breathing-scene[data-phase="prepare"] .breathing-phase {
color: #858980;
opacity: 0.62;
}
#heavenHeartPanel .breathing-scene[data-phase="hold"] .breathing-phase { #heavenHeartPanel .breathing-scene[data-phase="hold"] .breathing-phase {
color: #8a6b3e; color: #8a6b3e;
opacity: 0.9; opacity: 0.9;
+21 -6
View File
@@ -340,7 +340,7 @@ test("saved daily fortune opens in the reading dialog without regenerating", asy
await expect(page.locator("#heavenReadingHistoryList .heaven-reading-history-item")).toHaveCount(1); await expect(page.locator("#heavenReadingHistoryList .heaven-reading-history-item")).toHaveCount(1);
}); });
test("heart breathing uses five silent nine-second ripple cycles", async ({ page }) => { test("heart breathing prepares once then contracts on each exhale", async ({ page }) => {
await mockApplication(page, session("user", true)); await mockApplication(page, session("user", true));
await page.goto("/index.html"); await page.goto("/index.html");
await page.locator('[data-view="heavenView"]').first().click(); await page.locator('[data-view="heavenView"]').first().click();
@@ -350,26 +350,41 @@ test("heart breathing uses five silent nine-second ripple cycles", async ({ page
const timing = await page.evaluate(() => ({ const timing = await page.evaluate(() => ({
remaining: state.heartBreathingEndsAt - Date.now(), remaining: state.heartBreathingEndsAt - Date.now(),
incenseDuration: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration, incenseDuration: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration,
incenseDelay: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDelay,
rippleDuration: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDuration, rippleDuration: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDuration,
rippleDelay: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDelay,
rippleAnimation: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationName, rippleAnimation: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationName,
rippleKeyframes: document.querySelector(".heart-breath-ripple span").getAnimations()[0].effect.getKeyframes(),
})); }));
expect(timing.remaining).toBeGreaterThan(44_000); expect(timing.remaining).toBeGreaterThan(45_000);
expect(timing.remaining).toBeLessThanOrEqual(45_000); expect(timing.remaining).toBeLessThanOrEqual(46_000);
expect(timing.incenseDuration).toBe("45s"); expect(timing.incenseDuration).toBe("45s");
expect(timing.incenseDelay).toBe("1s");
expect(timing.rippleDuration).toBe("9s"); expect(timing.rippleDuration).toBe("9s");
expect(timing.rippleDelay).toBe("1s");
expect(timing.rippleAnimation).toContain("heart-breath-ripple"); expect(timing.rippleAnimation).toContain("heart-breath-ripple");
await expect(page.locator("#breathingPhase")).toHaveText("吸气"); expect(timing.rippleKeyframes.at(-1).transform).toContain("0.62");
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "prepare");
await expect(page.locator("#breathingPhase")).toHaveText("静");
await expect(page.locator("#breathingSeconds, #breathingProgress, .breathing-orbit")).toHaveCount(0); await expect(page.locator("#breathingSeconds, #breathingProgress, .breathing-orbit")).toHaveCount(0);
await page.evaluate(() => { await page.evaluate(() => {
clearInterval(state.heartTimer); clearInterval(state.heartTimer);
state.heartTimer = null; state.heartTimer = null;
state.heartSeconds = 45;
state.heartBreathingEndsAt = Date.now() + 44_500;
updateBreathingDisplay();
});
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "inhale");
await expect(page.locator("#breathingPhase")).toHaveText("吸");
await page.evaluate(() => {
state.heartSeconds = 42; state.heartSeconds = 42;
state.heartBreathingEndsAt = Date.now() + 41_500; state.heartBreathingEndsAt = Date.now() + 41_500;
updateBreathingDisplay(); updateBreathingDisplay();
}); });
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "hold"); await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "hold");
await expect(page.locator("#breathingPhase")).toHaveText("停驻"); await expect(page.locator("#breathingPhase")).toHaveText("");
await page.evaluate(() => { await page.evaluate(() => {
state.heartSeconds = 39; state.heartSeconds = 39;
@@ -377,7 +392,7 @@ test("heart breathing uses five silent nine-second ripple cycles", async ({ page
updateBreathingDisplay(); updateBreathingDisplay();
}); });
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "exhale"); await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "exhale");
await expect(page.locator("#breathingPhase")).toHaveText("呼"); await expect(page.locator("#breathingPhase")).toHaveText("呼");
}); });
test("mobile shell stays within the viewport", async ({ page }) => { test("mobile shell stays within the viewport", async ({ page }) => {