/* ==================================================================== FateForge v2 — 墨韵问天 (Vue 3 SPA) 状态机: yuanqi → kuiji(turtle→coins→hexagon) → pomi → zidu ==================================================================== */ const { createApp, ref, computed, onMounted, nextTick, watch } = Vue; /* ---------------- i18n 字典 ---------------- 中英双语: 中文模式下使用全角标点 (, 。 : ""), 英文模式下使用半角标点 (, . : "") 标点策略: 文案里写中文语境的全角标点;英文翻译时使用半角标点。 额外符号: ","、"·"、"、"、"…" 在英文版保留 (i18n 中手动改写)。 */ const I18N = { zh: { // 顶部菜单 menu_book: '册页', menu_lang: 'EN', menu_about: '关于', // 画面一 缘起 yuanqi_h1: '大衍之数五十,其用四十有九。\n缘主此来,所求何事?', yuanqi_placeholder: '心中默念,轻诉所惑…\n(例: 明日远行、求职面试、心中困惑)', yuanqi_start: '推 演', // 画面二 窥机 yao_unit: '爻', kuiji_turtle_hint_p1: '心中默念所求之事,然后', kuiji_turtle_hint_p2: '连点龟壳六次', kuiji_clicked: '已起', kuiji_touch: '轻 点', kuiji_yao: '第', kuiji_coin_head: '字', kuiji_coin_tail: '幕', kuiji_old_yang: '老阳动爻', kuiji_young_yang: '少阳', kuiji_old_yin: '老阴动爻', kuiji_young_yin: '少阴', // 画面二 卦象展示 hex_done: '六爻已定 · 卦象初成', hex_judgment: '卦辞:', hex_reveal: '窥 天 机', hex_ordinal_prefix: '第', hex_ordinal_suffix: '卦', // 画面三 破迷 pomi_question_prefix: '缘主所问:', pomi_loading: '天机渗透中…', pomi_xintai: '运在人为', // 凶/中平偏凶/大凶 流程 pomi_obscure_l1: '前路晦暗,天机深锁。', pomi_obscure_l2: '定数之中,福祸相依。请缘主轻触,引出"遁其一"以破此局。', pomi_reveal_btn: '遁 其 一', pomi_burning: '破局中…', pomi_breakfree: '乾坤未定,且循其一', pomi_depart: '乾坤已定,且乘势启程', // 画面四 自渡 (兜底, 不常用) zidu_summary: '本卦:', zidu_asked: '缘主所问:', zidu_swipe_hint_p1: '心中默念已毕?', zidu_swipe_hint_p2: '轻拂衣袖', zidu_swipe_hint_p3: ',散去眼前迷障', zidu_swipe_aria: '拂袖散雾', zidu_swipe_glyph: '拂 袖', // 乾坤志 book_title: '乾 坤 志', book_close: '✕ 收册', book_local_only: '此册仅存于本机,不入云端,亦无外传。', book_count_unit: '段墨迹', book_empty: '尚无墨迹 · 待缘主问天', book_collapse: '收起 ▴', book_expand: '展卷 ▾', book_no_reading: '本次未留解读', book_hex_label: '卦 象', book_changed_to: '动而化', book_moving: '动爻', book_destiny_label: '听 天 命 · 窥 天 机', book_remedy_label: '那 一 线 生 机', book_reading_label: '解 读', book_burn: '焚毁此页', book_clear_all: '一把火,焚去前尘', // 关于 about_title: '【关于 FateForge】', about_priv_title: '一、数据安全(Privacy):', about_priv_body: '本应用基于完全本地化的嵌入式大模型(Llama-server/ROCm)运行。您的所有输入、起卦记录仅以墨迹形式存于您的本机浏览器,不上传任何云端服务器,绝对保护您的隐私。', about_disc_title: '二、知命自渡(Disclaimer):', about_disc_body: '《易经》云:"大衍之数五十,其用四十有九。" 本系统旨在通过古老的东方哲学与当代大语言模型结合,为您提供心理疏导与行动启示。卦象不代表绝对的预言,亦非迷信工具。天机流转,唯人自渡。请勿将推演结果作为现实决策的唯一依据。请相信:定数之中,您的每一个积极行动,才是那真正破局的"遁去的一"。', // 终场 finale_restart: '再 问 一 卦', }, en: { // 顶部菜单 menu_book: 'Book', menu_lang: '中', menu_about: 'About', // 画面一 缘起 yuanqi_h1: 'Of the fifty yarrow stalks, forty-nine are used.\nWhat is it that you seek, dear seeker?', yuanqi_placeholder: 'Whisper your question to the void...\n(e.g. travel, career, what troubles the heart)', yuanqi_start: 'Cast', // 画面二 窥机 yao_unit: 'line', kuiji_turtle_hint_p1: 'Hold your question in mind, then', kuiji_turtle_hint_p2: 'tap the tortoise six times', kuiji_clicked: 'Cast', kuiji_touch: 'tap', kuiji_yao: 'Line', kuiji_coin_head: 'head', kuiji_coin_tail: 'tail', kuiji_old_yang: 'old yang (moving)', kuiji_young_yang: 'young yang', kuiji_old_yin: 'old yin (moving)', kuiji_young_yin: 'young yin', // 画面二 卦象展示 hex_done: 'Six lines set · The hexagram takes form', hex_judgment: 'Judgment:', hex_reveal: 'Reveal', hex_ordinal_prefix: 'No.', hex_ordinal_suffix: '', // 画面三 破迷 pomi_question_prefix: 'Your question:', pomi_loading: 'The hexagram seeps through...', pomi_xintai: 'Through human effort', // 凶/中平偏凶/大凶 流程 pomi_obscure_l1: 'The road ahead is dark. The way is locked.', pomi_obscure_l2: 'In fate, fortune and misfortune are entwined. Touch below to draw out the "escaped one" and break this knot.', pomi_reveal_btn: 'Draw the One', pomi_burning: 'Breaking through...', pomi_breakfree: 'The way is still open — walk through', pomi_depart: 'The way is set — step forward', // 画面四 自渡 zidu_summary: 'Original hexagram:', zidu_asked: 'Question:', zidu_swipe_hint_p1: 'Have you finished your meditation?', zidu_swipe_hint_p2: 'Brush your sleeve', zidu_swipe_hint_p3: ', and the mist will fade.', zidu_swipe_aria: 'Brush sleeve to clear the mist', zidu_swipe_glyph: 'Sweep', // 乾坤志 book_title: 'Book of Heaven & Earth', book_close: '✕ Close', book_local_only: 'Stored only on this device. Never uploaded. ', book_count_unit: 'entries', book_empty: 'No traces yet · Awaiting your question', book_collapse: 'collapse ▴', book_expand: 'expand ▾', book_no_reading: 'No reading recorded', book_hex_label: 'Hexagram', book_changed_to: 'transforms into', book_moving: 'moving lines', book_destiny_label: 'Reading', book_remedy_label: 'Way through', book_reading_label: 'Notes', book_burn: 'Burn this page', book_clear_all: 'Burn all — start anew', // 关于 about_title: 'About FateForge', about_priv_title: '1. Privacy', about_priv_body: 'This app runs on a fully local embedded LLM (Llama-server/ROCm). All your inputs and divination records are stored only as ink in your local browser. Nothing is uploaded to any cloud server. Your privacy is absolute.', about_disc_title: '2. Disclaimer', about_disc_body: '"Of the fifty yarrow stalks, forty-nine are used." This system weaves ancient Eastern philosophy with contemporary large language models to offer you perspective and gentle guidance. Hexagrams are not absolute prophecy, nor a tool of superstition. Heaven moves; only you can ferry yourself across. Please do not treat the result as the sole basis for real-world decisions. Believe this: within every fixed fate, your every active step is the true "escaped one" that breaks the knot.', // 终场 finale_restart: 'Ask again', }, }; /* ---------------- 音效模块 (Web Audio API 合成) ---------------- */ const SFX = (() => { let ctx = null; function ensure() { if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)(); if (ctx.state === 'suspended') ctx.resume(); return ctx; } function coinDrop() { const c = ensure(); const t = c.currentTime; const o1 = c.createOscillator(); o1.type = 'triangle'; o1.frequency.setValueAtTime(1800, t); o1.frequency.exponentialRampToValueAtTime(700, t + 0.18); const g1 = c.createGain(); g1.gain.setValueAtTime(0.0001, t); g1.gain.exponentialRampToValueAtTime(0.25, t + 0.005); g1.gain.exponentialRampToValueAtTime(0.0001, t + 0.35); o1.connect(g1).connect(c.destination); o1.start(t); o1.stop(t + 0.4); const buf = c.createBuffer(1, c.sampleRate * 0.08, c.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (c.sampleRate * 0.015)); const n = c.createBufferSource(); n.buffer = buf; const ng = c.createGain(); ng.gain.value = 0.18; n.connect(ng).connect(c.destination); n.start(t); } function tick() { const c = ensure(); const t = c.currentTime; const o = c.createOscillator(); o.type = 'square'; o.frequency.setValueAtTime(160, t); o.frequency.exponentialRampToValueAtTime(80, t + 0.08); const g = c.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.12, t + 0.005); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.15); o.connect(g).connect(c.destination); o.start(t); o.stop(t + 0.18); } function chime() { const c = ensure(); const t = c.currentTime; const harmonics = [ { f: 880, a: 0.30, d: 3.5 }, { f: 1320, a: 0.20, d: 3.0 }, { f: 1760, a: 0.15, d: 2.5 }, { f: 2640, a: 0.08, d: 2.0 }, ]; for (const h of harmonics) { const o = c.createOscillator(); o.type = 'sine'; o.frequency.value = h.f; const g = c.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(h.a, t + 0.01); g.gain.exponentialRampToValueAtTime(0.0001, t + h.d); o.connect(g).connect(c.destination); o.start(t); o.stop(t + h.d + 0.1); } } function swipe() { const c = ensure(); const t = c.currentTime; const buf = c.createBuffer(1, c.sampleRate * 0.6, c.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < data.length; i++) { const env = Math.sin((i / data.length) * Math.PI); data[i] = (Math.random() * 2 - 1) * env * 0.18; } const n = c.createBufferSource(); n.buffer = buf; const f = c.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 800; f.Q.value = 0.8; n.connect(f).connect(c.destination); n.start(t); } function burn() { // 燃烧: 低频隆隆 + 噪声 const c = ensure(); const t = c.currentTime; // 隆隆 (雷鸣) const o = c.createOscillator(); o.type = 'sawtooth'; o.frequency.setValueAtTime(80, t); o.frequency.exponentialRampToValueAtTime(40, t + 1.2); const g = c.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.18, t + 0.05); g.gain.exponentialRampToValueAtTime(0.0001, t + 1.4); o.connect(g).connect(c.destination); o.start(t); o.stop(t + 1.5); // 噪声 (火焰噼啪) const buf = c.createBuffer(1, c.sampleRate * 1.2, c.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < data.length; i++) { const env = Math.pow(1 - i / data.length, 1.5); data[i] = (Math.random() * 2 - 1) * env * 0.32; } const n = c.createBufferSource(); n.buffer = buf; const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 400; n.connect(f).connect(c.destination); n.start(t); } function clearChime() { // 清脆提示音: 高频泛音 + 短促衰减 (类似古寺磬音, 但更清亮、干净) const c = ensure(); const t = c.currentTime; const harmonics = [ { f: 1568, a: 0.20, d: 1.8 }, // G6 { f: 2093, a: 0.13, d: 1.4 }, // C7 { f: 2637, a: 0.08, d: 1.0 }, // E7 ]; for (const h of harmonics) { const o = c.createOscillator(); o.type = 'sine'; o.frequency.value = h.f; const g = c.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(h.a, t + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + h.d); o.connect(g).connect(c.destination); o.start(t); o.stop(t + h.d + 0.1); } } function whoosh() { // 清风: 短促的气流声, 拂面而过 const c = ensure(); const t = c.currentTime; const buf = c.createBuffer(1, c.sampleRate * 0.8, c.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < data.length; i++) { const env = Math.sin((i / data.length) * Math.PI); data[i] = (Math.random() * 2 - 1) * env * 0.18; } const n = c.createBufferSource(); n.buffer = buf; const f = c.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 1100; f.Q.value = 0.6; n.connect(f).connect(c.destination); n.start(t); } return { coinDrop, tick, chime, swipe, ensure, burn, clearChime, whoosh }; })(); /* ---------------- 因果册 localStorage ---------------- */ const HISTORY_KEY = 'fateforge_history_v1'; const LANG_KEY = 'fateforge_lang_v1'; /* ---------------- 64 卦名/卦辞/象传 英译表 (前端兜底) ---------------- 后端 meta 事件只发中文版本 (name, judgment, image, en) —— en 是英文卦名简称. 这里给一个简短的英文卦辞翻译, 用于 pomi (画面三) 英文模式展示. 跟 constants.py HEXAGRAMS 中的 'en' 字段一致. */ const HEX_I18N = { '乾': { en: 'The Creative', j: 'Sublime success. Perseverance furthers.' }, '坤': { en: 'The Receptive', j: 'Sublime success. Perseverance of a mare furthers.' }, '屯': { en: 'Difficulty at Start', j: 'Sublime success. Do not act.' }, '蒙': { en: 'Youthful Folly', j: 'Success. It is not I who seeks the young fool.' }, '需': { en: 'Waiting', j: 'Sincerity. Brilliant success. Perseverance. Cross the great water.' }, '讼': { en: 'Conflict', j: 'Sincerity. Be cautious.' }, '师': { en: 'The Army', j: 'Perseverance. The elder leads; good fortune, no blame.' }, '比': { en: 'Holding Together', j: 'Good fortune. Consult the divination again.' }, '小畜': { en: 'Small Taming', j: 'Success. Dense clouds, no rain.' }, '履': { en: 'Treading', j: 'Treading on the tail of a tiger; it does not bite. Success.' }, '泰': { en: 'Peace', j: 'The small goes, the great comes. Good fortune, success.' }, '否': { en: 'Standstill', j: 'Standstill is not for the noble. Inauspicious.' }, '同人': { en: 'Fellowship', j: 'Fellowship in the open. Success. Cross the great water.' }, '大有': { en: 'Great Possession', j: 'Sublime success.' }, '谦': { en: 'Modesty', j: 'Success. The noble man has the end.' }, '豫': { en: 'Enthusiasm', j: 'Favorable for founding a venture, raising an army.' }, '随': { en: 'Following', j: 'Sublime success, perseverance, no blame.' }, '蛊': { en: 'Work on the Decayed', j: 'Sublime success. Cross the great water.' }, '临': { en: 'Approach', j: 'Sublime success. In the eighth month, misfortune.' }, '观': { en: 'Contemplation', j: 'Wash, but no offering. Sincerity gladdens.' }, '噬嗑': { en: 'Biting Through', j: 'Success. Favorable for legal matters.' }, '贲': { en: 'Grace', j: 'Success. In small matters, favorable to go.' }, '剥': { en: 'Splitting Apart', j: 'Unfavorable to go.' }, '复': { en: 'Return', j: 'Success. Coming and going without illness. Friends come, no blame.' }, '无妄': { en: 'Innocence', j: 'Sublime success, perseverance.' }, '大畜': { en: 'Great Taming', j: 'Perseverance. Not eating at home brings good fortune.' }, '颐': { en: 'Nourishment', j: 'Perseverance brings good fortune. Look to what you eat.' }, '大过': { en: 'Great Excess', j: 'The ridgepole sags. Favorable to go. Success.' }, '坎': { en: 'The Abysmal', j: 'The abyss, again and again. Sincerity.' }, '离': { en: 'The Clinging', j: 'Perseverance brings success. It furthers one to be a herd cow.' }, '咸': { en: 'Influence', j: 'Success. Perseverance. Taking a maiden to wife brings good fortune.' }, '恒': { en: 'Duration', j: 'Success. No blame. Perseverance furthers.' }, '遁': { en: 'Retreat', j: 'Success. In small matters, perseverance furthers.' }, '大壮': { en: 'Great Power', j: 'Perseverance furthers.' }, '晋': { en: 'Progress', j: 'The noble prince is honored with horses in great number.' }, '明夷': { en: 'Darkening of Light', j: 'Perseverance in adversity.' }, '家人': { en: 'The Family', j: 'Furthers the perseverance of the woman.' }, '睽': { en: 'Opposition', j: 'Good fortune in small matters.' }, '蹇': { en: 'Obstruction', j: 'Favorable in the southwest, unfavorable in the northeast.' }, '解': { en: 'Deliverance', j: 'Favorable in the southwest. If nowhere to go, returning brings good fortune.' }, '损': { en: 'Decrease', j: 'Sincerity. Great good fortune. No blame.' }, '益': { en: 'Increase', j: 'Favorable to go. Cross the great water.' }, '夬': { en: 'Breakthrough', j: 'In the royal court, proclaim the truth; danger.' }, '姤': { en: 'Coming to Meet', j: 'A strong woman. Do not marry such a woman.' }, '萃': { en: 'Gathering', j: 'Success. The king approaches the temple.' }, '升': { en: 'Pushing Upward', j: 'Sublime success. See the great man. No anxiety.' }, '困': { en: 'Oppression', j: 'Success. Perseverance. The great man brings good fortune.' }, '井': { en: 'The Well', j: 'The town may be changed, but the well cannot.' }, '革': { en: 'Revolution', j: 'On your own day, you are believed. Sublime success.' }, '鼎': { en: 'The Cauldron', j: 'Great good fortune. Success.' }, '震': { en: 'The Arousing', j: 'Success. Terror, then laughter.' }, '艮': { en: 'Keeping Still', j: 'Keeping his back still. No blame.' }, '渐': { en: 'Development', j: 'The maiden is given in marriage. Perseverance brings good fortune.' }, '归妹': { en: 'Marrying Maiden', j: 'Going brings misfortune. Nothing favorable.' }, '丰': { en: 'Abundance', j: 'Success. The king abounds in riches. No worry.' }, '旅': { en: 'The Wanderer', j: 'Small success. The wanderer perseveres.' }, '巽': { en: 'The Gentle', j: 'Small success. Favorable to go. See the great man.' }, '兑': { en: 'The Joyous', j: 'Success. Perseverance.' }, '涣': { en: 'Dispersion', j: 'Success. The king approaches the temple. Cross the great water.' }, '节': { en: 'Limitation', j: 'Success. Bitter limitation cannot be persevered in.' }, '中孚': { en: 'Inner Truth', j: 'Pig and fish. Good fortune. Cross the great water.' }, '小过': { en: 'Small Excess', j: 'Success. Perseverance. Small matters can be done; great matters cannot.' }, '既济': { en: 'After Completion', j: 'Success. In small matters, perseverance. Beginning good, end disordered.' }, '未济': { en: 'Before Completion', j: 'Success. The small fox nearly crosses. Wetting its tail. Nothing favorable.' }, }; /* 7 档等级英文翻译 (用于英文模式显示等级文字) */ const LEVEL_EN = { '大吉': 'Great Fortune', '吉': 'Fortune', '中平偏吉': 'Tilt Fortune', '中平': 'Neutral', '中平偏凶': 'Tilt Misfortune', '凶': 'Misfortune', '大凶': 'Great Misfortune', }; /* 3 档吉凶英文 */ const CAT_EN = { '吉': 'Fortune', '平': 'Neutral', '凶': 'Misfortune' }; /* 心法标签英文翻译 */ const REMEDY_EN = { '承运心法': 'Riding the wind', '蓄势心法': 'Gathering strength', '锦囊生机': 'Way through', }; function loadHistory() { try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; } } function saveHistory(arr) { localStorage.setItem(HISTORY_KEY, JSON.stringify(arr)); } function loadLang() { try { return localStorage.getItem(LANG_KEY) || 'zh'; } catch { return 'zh'; } } function saveLang(v) { try { localStorage.setItem(LANG_KEY, v); } catch {} } /* ---------------- 主 App ---------------- */ const app = createApp({ setup() { /* ---- 状态 ---- */ const lang = ref(loadLang()); const t = (key) => (I18N[lang.value] && I18N[lang.value][key]) || (I18N.zh[key]) || key; const scene = ref('yuanqi'); const bookOpen = ref(false); const aboutOpen = ref(false); const phase = ref('turtle'); const question = ref(''); const clicks = ref(0); // 铜钱"已落地"次数 (用户看到 +1) const totalTaps = ref(0); // 龟甲累计点击 (含铜钱展示中/回到龟壳前的多余点击) const shaking = ref(false); const currentCoins = ref([ { face: 2, flipped: false }, { face: 2, flipped: false }, { face: 2, flipped: false }, ]); const lastLineValue = ref(null); const lines = ref([]); const hexName = ref(''); const hexEn = ref(''); const hexJudgment = ref(''); const hexOrder = ref(0); const upperTrigram = ref(''); const lowerTrigram = ref(''); const auspicious = ref('平'); const auspiciousLevel = ref('中平'); const levelInfo = ref({ tag: '潜龙勿用', reason: '' }); const uiConfig = ref({ tag: '潜龙勿用', remedy: '蓄势心法', btn_label: '养 精 · 坐 看 风 云', btn_sub: '此时无局可破,唯以静气养神', tone_class: 'tone-paper', mood: '水澜不惊', }); const changedName = ref(''); const readingText = ref(''); const readingError = ref(''); const readingDone = ref(false); const readingSource = ref(''); const movingIndicesCache = ref([]); const destinyText = ref(''); const remedyText = ref(''); const history = ref(loadHistory()); const expandedId = ref(null); // 预摇的 6 爻 (startDivination 时一次性摇出, 然后逐爻显示) // 摇出后用 30% 概率偏向凶卦, 让用户能体验到破局流程 const preRolledLines = ref([]); const mistCleared = ref(false); const finalLines = ref([]); const readingNote = ref(''); const swipeStartX = ref(null); const swipeStartProgress = ref(0); const swipeProgress = ref(0); /* 凶局流程专属状态 */ const pomiStage = ref('loading'); // loading | obscure | reveal_btn | revealing const burning = ref(false); const fadingOut = ref(false); // 遁其一按钮缓慢消失中 const dispersing = ref(false); // 锦囊阶段卡片像被风吹散中 const previewCleared = ref(false); // 心法朦胧 → 清晰(按钮消失后才置 true) /* 吉局启程回归遮罩状态 */ const departing = ref(false); /* 卦象详解卡片是否已滚到底 (显示底部按钮) */ const pomiCardScrolledToBottom = ref(false); const pomiScrollRoot = ref(null); /* 自定义水墨确认弹窗状态 (代替原生 confirm) */ const confirmDialog = ref({ open: false, message: '', confirmLabel: '', cancelLabel: '', _resolve: null, }); function showConfirm(message, confirmLabel = null, cancelLabel = null) { return new Promise((resolve) => { confirmDialog.value = { open: true, message, confirmLabel: confirmLabel || (lang.value === 'en' ? 'Confirm' : '确 定'), cancelLabel: cancelLabel || (lang.value === 'en' ? 'Cancel' : '取 消'), _resolve: resolve, }; }); } function confirmDialogResolve(ans) { const r = confirmDialog.value._resolve; confirmDialog.value.open = false; confirmDialog.value._resolve = null; if (r) r(ans); } /* 自定义水墨"二选一"选择弹窗: 返回 'primary' 或 'secondary' */ const choiceDialog = ref({ open: false, message: '', primaryLabel: '', secondaryLabel: '', _resolve: null, }); function showChoice(message, primaryLabel, secondaryLabel) { return new Promise((resolve) => { choiceDialog.value = { open: true, message, primaryLabel: primaryLabel || (lang.value === 'en' ? 'OK' : '知 晓'), secondaryLabel: secondaryLabel || (lang.value === 'en' ? 'Cancel' : '不 知'), _resolve: resolve, }; }); } function choiceDialogResolve(which) { const r = choiceDialog.value._resolve; choiceDialog.value.open = false; choiceDialog.value._resolve = null; if (r) r(which); } /* ---- 计算属性 ---- */ // 画面三(pomi)背景: 吉/中平偏吉/大吉 → 金, 中平 → 土, 凶/中平偏凶/大凶 → 火 const pomiBgClass = computed(() => { if (scene.value !== 'pomi') return ''; const cat = ['大吉','吉','中平偏吉'].includes(auspiciousLevel.value) ? 'jade' : ['中平偏凶','凶','大凶'].includes(auspiciousLevel.value) ? 'blood' : 'ink'; if (cat === 'jade') return 'bg-element-metal'; if (cat === 'blood') return 'bg-element-fire'; return 'bg-element-earth'; }); const ominousClass = computed(() => { if (scene.value !== 'pomi') return ''; if (auspicious.value === '凶') return 'ominous-bad'; if (auspicious.value === '平') return 'ominous-ice'; return ''; }); const levelToneClass = computed(() => uiConfig.value.tone_class || 'tone-paper'); const ctaLabel = computed(() => uiConfig.value.btn_label); const ctaSub = computed(() => uiConfig.value.btn_sub); const destinyTag = computed(() => `天 定 命 数 · ${levelInfo.value.tag}`); const remedyTag = computed(() => `运 在 人 为 · ${uiConfig.value.remedy}`); const moodTone = computed(() => uiConfig.value.mood); const auspiciousLevelClass = computed(() => { const cat = ['大吉','吉','中平偏吉'].includes(auspiciousLevel.value) ? 'jade' : ['中平偏凶','凶','大凶'].includes(auspiciousLevel.value) ? 'blood' : 'ink'; if (cat === 'jade') return 'text-jade-500 font-shu text-5xl md:text-6xl tracking-widest ink-seep'; if (cat === 'blood') return 'text-bloodred-500 font-shu text-5xl md:text-6xl tracking-widest ink-seep'; return 'text-burntea-500 font-shu text-5xl md:text-6xl tracking-widest ink-seep'; }); // 根据 lang 决定显示中文卦名还是英文 const displayHexName = computed(() => { if (lang.value === 'en') { return HEX_I18N[hexName.value]?.en || hexEn.value || hexName.value; } return hexName.value; }); // 卦辞 (judgment) 在英文模式下的翻译 const displayHexJudgment = computed(() => { if (lang.value === 'en' && hexName.value) { return HEX_I18N[hexName.value]?.j || hexJudgment.value; } return hexJudgment.value; }); // 7 档吉凶等级 英文翻译 const displayLevel = computed(() => { if (lang.value === 'en') { return LEVEL_EN[auspiciousLevel.value] || auspiciousLevel.value; } return auspiciousLevel.value; }); // 3 档吉凶 英文翻译 const displayCategory = computed(() => { if (lang.value === 'en') return CAT_EN[auspicious.value] || auspicious.value; return auspicious.value; }); // 心法标签 英文翻译 const displayRemedy = computed(() => { if (lang.value === 'en') return REMEDY_EN[uiConfig.value.remedy] || uiConfig.value.remedy; return uiConfig.value.remedy; }); const ctaBtnClass = computed(() => { if (auspicious.value === '吉') return 'border-jade-500/40 text-jade-500 hover:bg-jade-500/10'; if (auspicious.value === '凶') return 'border-bloodred-500/40 text-bloodred-500 hover:bg-bloodred-500/5'; return 'border-ink-300/40 text-burntea-500 hover:bg-ink-50/5'; }); // 是否走"凶局 → 晦暗 → 遁其一 → 燃烧"完整流程 const isOminousFlow = computed(() => ['中平偏凶','凶','大凶'].includes(auspiciousLevel.value) && scene.value === 'pomi' ); /* ---- 工具 ---- */ function mistStyle(i) { return { animationDelay: `${i * 1.2}s`, }; } function toggleLang() { lang.value = lang.value === 'zh' ? 'en' : 'zh'; saveLang(lang.value); } /* 监听 pomi 卡片滚动: 滚到底时才显示底部按钮 */ function onPomiScroll(e) { const el = e.target; // 留 16px 余量 (避免 1px 误差) const reached = el.scrollHeight - el.scrollTop - el.clientHeight < 16; pomiCardScrolledToBottom.value = reached; } function openAbout() { aboutOpen.value = true; } function closeAbout() { aboutOpen.value = false; } /* ---------- pomiStage 切换时, 自动把卡片滚动条拉回顶部 ---------- */ watch(pomiStage, (newStage, oldStage) => { if (newStage === oldStage) return; pomiCardScrolledToBottom.value = false; nextTick(() => { if (pomiScrollRoot.value) pomiScrollRoot.value.scrollTop = 0; }); }); /* ============ 画面一 → 画面二 ============ */ function startDivination() { const q = question.value.trim(); if (!q) return; SFX.tick(); scene.value = 'kuiji'; phase.value = 'turtle'; clicks.value = 0; totalTaps.value = 0; lines.value = []; readingText.value = ''; readingError.value = ''; readingDone.value = false; destinyText.value = ''; remedyText.value = ''; mistCleared.value = false; pomiStage.value = 'loading'; burning.value = false; fadingOut.value = false; dispersing.value = false; previewCleared.value = false; // 预摇 6 爻 (完全随机, 缘主起什么卦就是什么卦) preRolledLines.value = rollSixLinesPure(); } /* ============ 画面二 龟壳点击 ============ */ // 前端查表 (6 爻 → 卦象),即时显示在 hexagon 阶段 const HEX_TABLE = { '111111':{name:'乾',judgment:'元亨利贞'}, '000000':{name:'坤',judgment:'元亨,利牝马之贞'}, '100010':{name:'屯',judgment:'元亨利贞,勿用有攸往'}, '010001':{name:'蒙',judgment:'亨,匪我求童蒙'}, '111010':{name:'需',judgment:'有孚,光亨,贞吉,利涉大川'}, '010111':{name:'讼',judgment:'有孚,窒惕'}, '000010':{name:'师',judgment:'贞,丈人吉,无咎'}, '010000':{name:'比',judgment:'吉,原筮,元永贞'}, '111011':{name:'小畜',judgment:'亨,密云不雨'}, '110111':{name:'履',judgment:'履虎尾,不咥人,亨'}, '111000':{name:'泰',judgment:'小往大来,吉亨'}, '000111':{name:'否',judgment:'否之匪人,不利君子贞'}, '101111':{name:'同人',judgment:'同人于野,亨,利涉大川'}, '111101':{name:'大有',judgment:'元亨'}, '001000':{name:'谦',judgment:'亨,君子有终'}, '000100':{name:'豫',judgment:'利建侯行师'}, '100110':{name:'随',judgment:'元亨利贞,无咎'}, '011001':{name:'蛊',judgment:'元亨,利涉大川'}, '110000':{name:'临',judgment:'元亨利贞,至于八月有凶'}, '000011':{name:'观',judgment:'盥而不荐,有孚颙若'}, '100101':{name:'噬嗑',judgment:'亨,利用狱'}, '101001':{name:'贲',judgment:'亨,小利有所往'}, '000001':{name:'剥',judgment:'不利有攸往'}, '100000':{name:'复',judgment:'亨,出入无疾,朋来无咎'}, '100111':{name:'无妄',judgment:'元亨利贞'}, '111001':{name:'大畜',judgment:'利贞,不家食吉,利涉大川'}, '100001':{name:'颐',judgment:'贞吉,观颐,自求口实'}, '011110':{name:'大过',judgment:'栋桡,利有攸往,亨'}, '010010':{name:'坎',judgment:'习坎,有孚,维心亨,行有尚'}, '101101':{name:'离',judgment:'利贞,亨,畜牝牛吉'}, '001110':{name:'咸',judgment:'亨,利贞,取女吉'}, '011100':{name:'恒',judgment:'亨,无咎,利贞,利有攸往'}, '001111':{name:'遁',judgment:'亨,小利贞'}, '111100':{name:'大壮',judgment:'利贞'}, '000101':{name:'晋',judgment:'康侯用锡马蕃庶,昼日三接'}, '101000':{name:'明夷',judgment:'利艰贞'}, '101011':{name:'家人',judgment:'利女贞'}, '110101':{name:'睽',judgment:'小事吉'}, '001010':{name:'蹇',judgment:'利西南,不利东北'}, '010100':{name:'解',judgment:'利西南,无所往,其来复吉'}, '110001':{name:'损',judgment:'有孚,元吉,无咎,可贞'}, '100011':{name:'益',judgment:'利有攸往,利涉大川'}, '111110':{name:'夬',judgment:'扬于王庭,孚号有厉'}, '011111':{name:'姤',judgment:'女壮,勿用取女'}, '000110':{name:'萃',judgment:'亨,王假有庙'}, '011000':{name:'升',judgment:'元亨,利见大人,勿恤'}, '010110':{name:'困',judgment:'亨,贞,大人吉,无咎'}, '011010':{name:'井',judgment:'改邑不改井,无丧无得'}, '101110':{name:'革',judgment:'己日乃孚,元亨利贞'}, '011101':{name:'鼎',judgment:'元吉,亨'}, '100100':{name:'震',judgment:'亨,震来虩虩,笑言哑哑'}, '001001':{name:'艮',judgment:'艮其背,不获其身,行其庭,不见其人,无咎'}, '001011':{name:'渐',judgment:'女归吉,利贞'}, '110100':{name:'归妹',judgment:'征凶,无攸利'}, '101100':{name:'丰',judgment:'亨,王假之,勿忧,宜日中'}, '001101':{name:'旅',judgment:'小亨,旅贞吉'}, '011011':{name:'巽',judgment:'小亨,利有攸往,利见大人'}, '110110':{name:'兑',judgment:'亨,利贞'}, '010011':{name:'涣',judgment:'亨,王假有庙,利涉大川'}, '110010':{name:'节',judgment:'亨,苦节不可贞'}, '110011':{name:'中孚',judgment:'豚鱼吉,利涉大川,利贞'}, '001100':{name:'小过',judgment:'亨,利贞,可小事,不可大事'}, '101010':{name:'既济',judgment:'亨,小利贞,初吉终乱'}, '010101':{name:'未济',judgment:'亨,小狐汔济,濡其尾,无攸利'}, }; function lookupHexLocal(lines) { const key = lines.map(v => (v === 8 || v === 9) ? '1' : '0').join(''); return HEX_TABLE[key] || { name: '?', judgment: '' }; } async function onTurtleClick() { // 仅在 kuiji 场景下累计点击;其他场景直接 return if (scene.value !== 'kuiji') return; // 累计点击 (不论 phase=turtle 还是 phase=coins, 都在累加) totalTaps.value += 1; // 累计超过 6 次: 弹窗, 等待用户选择 if (totalTaps.value > 6) { const msg = lang.value === 'en' ? 'One hexagram takes only six lines.\nYou have already cast six.\nShall we read your hexagram now?' : '缘主,一卦只能摇六下哦。\n您方才已起六爻,可要现在解读?'; const ans = await showChoice(msg, lang.value === 'en' ? 'Read it' : '知 晓', lang.value === 'en' ? 'Reset' : '不 知'); // 重要: 弹窗期间用户可能还在继续点击, 弹窗关闭后需要把 totalTaps 拉回 6 // (如果此时实际只摇了 3 下, 重置为 clicks.value) if (ans === 'primary') { // 知晓 → 立刻进入卦象展示 (用当前已经入卦的 lines) // 若 lines 不足 6 (例如用户疯狂点击但 phase=turtle 没来得及切换), 仍按现有 lines 出卦 const info = lookupHexLocal(lines.value.map(l => l.value)); hexName.value = info.name; hexJudgment.value = info.judgment; phase.value = 'hexagon'; totalTaps.value = clicks.value; // 对齐 } else { // 不知 → 重新添问题 scene.value = 'yuanqi'; question.value = ''; clicks.value = 0; totalTaps.value = 0; lines.value = []; phase.value = 'turtle'; preRolledLines.value = []; lastLineValue.value = null; currentCoins.value = [ { face: 2, flipped: false }, { face: 2, flipped: false }, { face: 2, flipped: false }, ]; } return; } // 总点击 <= 6 时, 仅在 turtle 阶段才"出铜钱 + 入卦" // (phase=coins 时点击只增加 totalTaps, 但不切换 phase, 不入卦) if (phase.value !== 'turtle') return; // 如果已经摇够 6 爻, 也不响应 (防御性) if (clicks.value >= 6) return; shaking.value = true; SFX.tick(); setTimeout(() => { shaking.value = false; }, 600); setTimeout(() => { // 使用预摇的爻值 (保证"摇到什么卦就是什么卦, 完全随机") const lineVal = preRolledLines.value[clicks.value] || (randCoin() + randCoin() + randCoin()); // 三枚铜钱 face 组合:和为 lineVal, 但具体每枚仍随机 (视觉上每点都不同) const newCoins = splitSumToCoins(lineVal); currentCoins.value = newCoins; // 翻转动画 —— 触发 .flipped class nextTick(() => { currentCoins.value = newCoins.map(c => ({ ...c, flipped: true })); }); lastLineValue.value = lineVal; // 数字立即 +1 (在钱币出现的瞬间), 这样钱币不动时数字已经显示 clicks.value += 1; const glyph = { value: lineVal, yang: lineVal === 8 || lineVal === 9, moving: lineVal === 6 || lineVal === 9, mark: lineVal === 9 ? '○' : lineVal === 6 ? '×' : '', }; lines.value = [...lines.value, glyph]; phase.value = 'coins'; SFX.coinDrop(); // 铜钱展示时长 —— 用户要看清自己的钱 const COIN_DISPLAY_MS = 2400; setTimeout(() => { if (clicks.value >= 6) { // 6 爻完成 const info = lookupHexLocal(lines.value.map(l => l.value)); hexName.value = info.name; hexJudgment.value = info.judgment; setTimeout(() => { phase.value = 'hexagon'; }, 800); } else { // 回到龟壳 setTimeout(() => { phase.value = 'turtle'; lastLineValue.value = null; }, 600); } }, COIN_DISPLAY_MS); }, 700); } // 给定 lineVal (6~9), 随机返回一组 (c1, c2, c3) 三枚铜钱 face, 和为 lineVal function splitSumToCoins(lineVal) { const triples = []; for (let a = 2; a <= 3; a++) { for (let b = 2; b <= 3; b++) { for (let c = 2; c <= 3; c++) { if (a + b + c === lineVal) triples.push([a, b, c]); } } } const pick = triples[Math.floor(Math.random() * triples.length)] || [2, 2, lineVal - 4]; return pick.map(face => ({ face, flipped: false })); } function randCoin() { return Math.random() < 0.5 ? 2 : 3; } /* ---------------- 6 爻完全随机 ---------------- 用户明确要求"完全随机, 摇到什么卦就是什么卦, 主打缘分". 不再做凶卦偏向 —— 让每一次起卦都是真正的天意. */ function rollSixLinesPure() { const out = []; for (let i = 0; i < 6; i++) { out.push(randCoin() + randCoin() + randCoin()); } return out; } /* ============ 画面二 → 画面三 ============ */ async function goScene3() { scene.value = 'pomi'; pomiStage.value = 'loading'; burning.value = false; fadingOut.value = false; dispersing.value = false; previewCleared.value = false; pomiCardScrolledToBottom.value = false; // 等下一帧, 确保 DOM 已经挂载好, 再把滚动条拉回顶部 nextTick(() => { if (pomiScrollRoot.value) pomiScrollRoot.value.scrollTop = 0; }); const lineVals = lines.value.map(l => l.value); await streamReading(lineVals); } /* ============ SSE 流式解读 ============ */ async function streamReading(lineVals) { readingError.value = ''; readingText.value = ''; readingDone.value = false; readingSource.value = ''; readingNote.value = ''; destinyText.value = ''; remedyText.value = ''; pomiStage.value = 'loading'; movingIndicesCache.value = lineVals .map((v, i) => ({ v, i })) .filter(o => o.v === 6 || o.v === 9) .map(o => o.i + 1); let acc = ''; let ctrl = null; const timeout = setTimeout(() => { if (ctrl) try { ctrl.abort(); } catch {} }, 60000); ctrl = new AbortController(); try { const resp = await fetch('/api/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question: question.value, lines: lineVals, lang: lang.value }), signal: ctrl.signal, }); if (!resp.ok || !resp.body) { readingError.value = `天机不通 (HTTP ${resp.status})`; clearTimeout(timeout); // 错误时仍要走凶局流程, 让用户能看到按钮 if (isOminousFlow.value) { destinyText.value = ''; setTimeout(() => { pomiStage.value = 'obscure'; }, 1200); } return; } const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); let buf = ''; let firstHex = true; while (true) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf('\n\n')) !== -1) { const frame = buf.slice(0, idx); buf = buf.slice(idx + 2); const line = frame.split('\n').find(l => l.startsWith('data: ')); if (!line) continue; const payload = line.slice(6).trim(); if (!payload) continue; let evt; try { evt = JSON.parse(payload); } catch { continue; } if (evt.type === 'meta') { if (firstHex) { firstHex = false; hexName.value = evt.hex.name; hexEn.value = evt.hex.en; hexJudgment.value = evt.hex.judgment; hexOrder.value = evt.hex.order; upperTrigram.value = evt.hex.upper; lowerTrigram.value = evt.hex.lower; auspicious.value = evt.auspicious; if (evt.level) auspiciousLevel.value = evt.level; if (evt.levelInfo) levelInfo.value = evt.levelInfo; if (evt.ui) uiConfig.value = evt.ui; changedName.value = evt.changed ? evt.changed.name : ''; } } else if (evt.type === 'delta') { acc += evt.text; if (evt.source) readingSource.value = evt.source; splitReading(acc); readingText.value = acc; } else if (evt.type === 'warn') { readingNote.value = evt.message || ''; } else if (evt.type === 'error') { readingError.value = evt.message || '天机暂不可闻'; } else if (evt.type === 'done') { if (evt.source) readingSource.value = evt.source; readingDone.value = true; clearTimeout(timeout); saveCurrentToHistory(acc); // 解读完成后, 按吉凶决定下一步: if (isOminousFlow.value) { // 凶局: 先让用户充分看完解读 (10s) // 然后切到 obscure (模糊) 再 12s // 最后才显示遁其一按钮 setTimeout(() => { pomiStage.value = 'obscure'; setTimeout(() => { if (readingDone.value) pomiStage.value = 'reveal_btn'; }, 12000); }, 10000); } // 吉/中平/中平偏吉/大吉 不切换 stage, 直接显示正文 + 心法 (无需按钮) } } } clearTimeout(timeout); if (!readingDone.value) readingDone.value = true; } catch (e) { clearTimeout(timeout); if (e.name !== 'AbortError') { readingError.value = `请求异常: ${e.message || e}`; } } } function splitReading(text) { // 同时支持中文 (<听天命/运在人为>) 和英文 () 两种标签 const m1 = text.match(/<听天命[^>]*>([\s\S]*?)(?:<\/听天命[^>]*>|$)/) || text.match(/([\s\S]*?)(?:<\/Reading>|$)/); const m2 = text.match(/<运在人为[^>]*>([\s\S]*?)(?:<\/运在人为[^>]*>|$)/) || text.match(/]*>([\s\S]*?)(?:<\/Way Through[^>]*>|$)/); if (m1) destinyText.value = m1[1].trim(); if (m2) remedyText.value = m2[1].trim(); } /* ============ 凶局 → "遁其一" 触发 → 缓慢消失 → 蒙版揭开 → 心法清晰 ============ */ function onRevealOne() { if (pomiStage.value !== 'reveal_btn') return; if (fadingOut.value) return; SFX.clearChime(); // 清脆的声音, 不吓人 fadingOut.value = true; // 按钮慢慢消失 (1.2s). 期间心法依然被蒙版盖着, 看不清. setTimeout(() => { fadingOut.value = false; // 按钮完全消失后, 揭开蒙版 (previewCleared=true 触发 .reveal-veil.is-lifted 的 1s 过渡) previewCleared.value = true; }, 1200); // 再延迟 (按钮消失 + 蒙版揭开动画结束) → 切到 revealing 完整版 // 1200ms 按钮消失, +1000ms 蒙版揭开 → 2200ms 切场景 setTimeout(() => { pomiStage.value = 'revealing'; burning.value = false; previewCleared.value = false; nextTick(() => { if (pomiScrollRoot.value) pomiScrollRoot.value.scrollTop = 0; pomiCardScrolledToBottom.value = false; }); }, 1200 + 1100); } /* ============ 凶局 → 锦囊 → "乾坤未定且循其一" 触发 → 风吹散卡片 → 返回首页 ============ */ function onBreakFree() { if (pomiStage.value !== 'revealing') return; if (dispersing.value) return; SFX.whoosh(); // 清风 dispersing.value = true; // 1.6s 后切回首页 (卡片已经吹散了) setTimeout(() => { scene.value = 'yuanqi'; question.value = ''; clicks.value = 0; lines.value = []; phase.value = 'turtle'; preRolledLines.value = []; pomiStage.value = 'loading'; burning.value = false; fadingOut.value = false; dispersing.value = false; previewCleared.value = false; pomiCardScrolledToBottom.value = false; destinyText.value = ''; remedyText.value = ''; mistCleared.value = false; }, 1600); } /* ============ 吉局 → 启程按钮 → 模糊遮罩覆盖 → 返回首页 ============ */ function departToHome() { if (departing.value) return; SFX.tick(); departing.value = true; // 1.6s 后遮罩已完全覆盖, 此时切换 scene 到 yuanqi // 然后给一点时间让 yuanqi 渲染, 再淡出遮罩 setTimeout(() => { scene.value = 'yuanqi'; question.value = ''; clicks.value = 0; lines.value = []; phase.value = 'turtle'; preRolledLines.value = []; pomiStage.value = 'loading'; burning.value = false; fadingOut.value = false; dispersing.value = false; previewCleared.value = false; pomiCardScrolledToBottom.value = false; destinyText.value = ''; remedyText.value = ''; setTimeout(() => { departing.value = false; }, 100); }, 1400); } /* ============ 重置 / 乾坤志 / 关于 ============ */ function reset() { scene.value = 'yuanqi'; question.value = ''; clicks.value = 0; totalTaps.value = 0; lines.value = []; phase.value = 'turtle'; readingText.value = ''; readingDone.value = false; readingError.value = ''; destinyText.value = ''; remedyText.value = ''; mistCleared.value = false; finalLines.value = []; swipeProgress.value = 0; swipeStartX.value = null; pomiStage.value = 'loading'; burning.value = false; fadingOut.value = false; dispersing.value = false; previewCleared.value = false; preRolledLines.value = []; } function openBook() { bookOpen.value = true; } function closeBook() { expandedId.value = null; bookOpen.value = false; } function toggleHistory(id) { expandedId.value = expandedId.value === id ? null : id; } function saveCurrentToHistory(text) { const item = { id: Date.now(), date: new Date().toLocaleString(lang.value === 'en' ? 'en-US' : 'zh-CN', { hour12: false }), question: question.value, hexName: hexName.value, hexEn: hexEn.value, hexOrder: hexOrder.value, upper: upperTrigram.value, lower: lowerTrigram.value, judgment: hexJudgment.value, changedName: changedName.value, moving: movingIndicesCache.value, auspicious: auspicious.value, destinyText: destinyText.value, remedyText: remedyText.value, text: text, }; const arr = [item, ...history.value].slice(0, 50); history.value = arr; saveHistory(arr); } async function deleteHistory(id) { const msg = lang.value === 'en' ? 'Burn this page?' : '焚毁此页?'; const ok = lang.value === 'en' ? 'Burn' : '焚 毁'; const ans = await showConfirm(msg, ok); if (!ans) return; history.value = history.value.filter(h => h.id !== id); saveHistory(history.value); } async function clearHistory() { const msg = lang.value === 'en' ? 'Burn all traces?' : '真要焚去前尘?'; const ok = lang.value === 'en' ? 'Burn all' : '一 焚 了 之'; const ans = await showConfirm(msg, ok); if (!ans) return; history.value = []; saveHistory([]); } /* ============ 拂袖交互 (兼容旧自渡页) ============ */ const HANDLE_COLORS = [ { core: 'rgba(170, 215, 175, 0.72)', mid: 'rgba(170, 215, 175, 0.45)', edge: 'rgba(170, 215, 175, 0.10)', glow: 'rgba(140, 200, 150, 0.30)' }, { core: 'rgba(225, 165, 150, 0.72)', mid: 'rgba(225, 165, 150, 0.45)', edge: 'rgba(225, 165, 150, 0.10)', glow: 'rgba(220, 140, 130, 0.30)' }, { core: 'rgba(230, 210, 155, 0.72)', mid: 'rgba(230, 210, 155, 0.45)', edge: 'rgba(230, 210, 155, 0.10)', glow: 'rgba(220, 200, 140, 0.30)' }, { core: 'rgba(235, 215, 145, 0.72)', mid: 'rgba(235, 215, 145, 0.45)', edge: 'rgba(235, 215, 145, 0.10)', glow: 'rgba(220, 195, 110, 0.30)' }, { core: 'rgba(170, 200, 220, 0.72)', mid: 'rgba(170, 200, 220, 0.45)', edge: 'rgba(170, 200, 220, 0.10)', glow: 'rgba(140, 180, 200, 0.30)' }, ]; const SWIPE_THRESHOLD_RATIO = 0.85; const swipeTrackStyle = computed(() => { return { '--swipe-pct': `${(swipeProgress.value * 100).toFixed(1)}%` }; }); const swipeHandleStyle = computed(() => { const p = Math.max(0, Math.min(1, swipeProgress.value)); const seg = p * (HANDLE_COLORS.length - 1); const i = Math.min(HANDLE_COLORS.length - 2, Math.floor(seg)); const t = seg - i; const a = HANDLE_COLORS[i]; const b = HANDLE_COLORS[i + 1]; const lerpRGBA = (aStr, bStr, t) => { const pa = aStr.match(/[\d.]+/g).map(Number); const pb = bStr.match(/[\d.]+/g).map(Number); const r = pa.map((v, k) => v * (1 - t) + pb[k] * t); return `rgba(${r[0].toFixed(0)}, ${r[1].toFixed(0)}, ${r[2].toFixed(0)}, ${r[3].toFixed(2)})`; }; const core = lerpRGBA(a.core, b.core, t); const mid = lerpRGBA(a.mid, b.mid, t); const edge = lerpRGBA(a.edge, b.edge, t); const glow = lerpRGBA(a.glow, b.glow, t); const leftPct = (p * 100).toFixed(1); return { left: `${leftPct}%`, background: `radial-gradient(circle at 50% 50%, ${core} 0%, ${mid} 35%, ${edge} 65%, rgba(${core.match(/\d+/g).slice(0,3).join(', ')}, 0) 100%)`, boxShadow: `inset 0 0 12px ${glow}, 0 0 18px ${glow}`, }; }); function onSwipeStart(e) { const x = (e.clientX ?? e.touches?.[0]?.clientX ?? 0); swipeStartX.value = x; swipeStartProgress.value = swipeProgress.value; } function onSwipeMove(e) { if (swipeStartX.value === null) return; const x = (e.clientX ?? e.touches?.[0]?.clientX ?? 0); const zone = e.currentTarget; const w = zone ? zone.clientWidth : 320; const dx = x - swipeStartX.value; const delta = dx / w; const next = Math.max(swipeProgress.value, Math.min(1, swipeStartProgress.value + delta)); swipeProgress.value = next; } function onSwipeEnd() { if (swipeStartX.value === null) return; const triggered = swipeProgress.value >= SWIPE_THRESHOLD_RATIO; swipeStartX.value = null; swipeStartProgress.value = 0; if (triggered) { swipeProgress.value = 1; triggerFinale(); } } function onSwipeClick() { if (swipeStartX.value !== null) return; swipeProgress.value = 1; triggerFinale(); } function triggerFinale() { SFX.swipe(); setTimeout(() => SFX.chime(), 600); mistCleared.value = true; finalLines.value = lang.value === 'en' ? [ 'Though heaven is fixed,', 'the heart can turn the world.', 'In every knot, a thread.', 'Walk on — your hand holds it.', ] : [ '命 虽 天 定', '心 能 转 境', '天 留 一 线', '缘 主 待 如 何', '— 拂袖而去,前路自开 —', ]; } onMounted(() => { const wake = () => SFX.ensure(); document.addEventListener('pointerdown', wake, { once: true }); }); return { // state lang, t, scene, phase, bookOpen, aboutOpen, question, clicks, totalTaps, shaking, currentCoins, lastLineValue, lines, hexName, hexEn, hexJudgment, hexOrder, upperTrigram, lowerTrigram, auspicious, auspiciousLevel, levelInfo, uiConfig, changedName, readingText, readingError, readingDone, readingNote, destinyText, remedyText, history, expandedId, mistCleared, finalLines, pomiStage, burning, fadingOut, dispersing, previewCleared, isOminousFlow, departing, pomiCardScrolledToBottom, pomiScrollRoot, onPomiScroll, confirmDialog, showConfirm, confirmDialogResolve, choiceDialog, showChoice, choiceDialogResolve, // computed ominousClass, pomiBgClass, levelToneClass, ctaLabel, ctaSub, destinyTag, remedyTag, moodTone, auspiciousLevelClass, ctaBtnClass, displayHexName, displayHexJudgment, displayLevel, displayCategory, displayRemedy, // swipe swipeTrackStyle, swipeHandleStyle, swipeProgress, // methods mistStyle, toggleLang, openAbout, closeAbout, startDivination, onTurtleClick, goScene3, onRevealOne, onBreakFree, departToHome, onSwipeStart, onSwipeMove, onSwipeEnd, onSwipeClick, reset, openBook, closeBook, deleteHistory, clearHistory, toggleHistory, }; } }); app.config.globalProperties.assets = { turtle: '/static/assets/turtle.png', coinHead: '/static/assets/coin-head.png', coinTail: '/static/assets/coin-tail.png', }; app.mount('#app');