| |
| |
| |
| |
| const { createApp, ref, computed, onMounted, nextTick, watch } = Vue; |
|
|
| |
| |
| |
| |
| 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', |
| }, |
| }; |
|
|
| |
| 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 }, |
| { f: 2093, a: 0.13, d: 1.4 }, |
| { f: 2637, a: 0.08, d: 1.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.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 }; |
| })(); |
|
|
| |
| const HISTORY_KEY = 'fateforge_history_v1'; |
| const LANG_KEY = 'fateforge_lang_v1'; |
|
|
| |
| |
| |
| |
| 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.' }, |
| }; |
|
|
| |
| const LEVEL_EN = { |
| '大吉': 'Great Fortune', '吉': 'Fortune', '中平偏吉': 'Tilt Fortune', |
| '中平': 'Neutral', '中平偏凶': 'Tilt Misfortune', '凶': 'Misfortune', '大凶': 'Great Misfortune', |
| }; |
| |
| 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 {} } |
|
|
| |
| 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); |
| 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); |
|
|
| |
| |
| 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'); |
| const burning = ref(false); |
| const fadingOut = ref(false); |
| const dispersing = ref(false); |
| const previewCleared = ref(false); |
|
|
| |
| const departing = ref(false); |
|
|
| |
| const pomiCardScrolledToBottom = ref(false); |
| const pomiScrollRoot = ref(null); |
|
|
| |
| 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); |
| } |
| |
| 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); |
| } |
|
|
| |
| |
| 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'; |
| }); |
| |
| const displayHexName = computed(() => { |
| if (lang.value === 'en') { |
| return HEX_I18N[hexName.value]?.en || hexEn.value || hexName.value; |
| } |
| return hexName.value; |
| }); |
| |
| const displayHexJudgment = computed(() => { |
| if (lang.value === 'en' && hexName.value) { |
| return HEX_I18N[hexName.value]?.j || hexJudgment.value; |
| } |
| return hexJudgment.value; |
| }); |
| |
| const displayLevel = computed(() => { |
| if (lang.value === 'en') { |
| return LEVEL_EN[auspiciousLevel.value] || auspiciousLevel.value; |
| } |
| return auspiciousLevel.value; |
| }); |
| |
| 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); |
| } |
| |
| function onPomiScroll(e) { |
| const el = e.target; |
| |
| const reached = el.scrollHeight - el.scrollTop - el.clientHeight < 16; |
| pomiCardScrolledToBottom.value = reached; |
| } |
| function openAbout() { aboutOpen.value = true; } |
| function closeAbout() { aboutOpen.value = false; } |
|
|
| |
| 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; |
| |
| preRolledLines.value = rollSixLinesPure(); |
| } |
|
|
| |
| |
| 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() { |
| |
| if (scene.value !== 'kuiji') return; |
|
|
| |
| totalTaps.value += 1; |
|
|
| |
| 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' : '不 知'); |
| |
| |
| if (ans === 'primary') { |
| |
| |
| 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; |
| } |
|
|
| |
| |
| if (phase.value !== 'turtle') return; |
| |
| 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()); |
| |
| const newCoins = splitSumToCoins(lineVal); |
| currentCoins.value = newCoins; |
| |
| nextTick(() => { |
| currentCoins.value = newCoins.map(c => ({ ...c, flipped: true })); |
| }); |
| lastLineValue.value = lineVal; |
| |
| 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) { |
| |
| 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); |
| } |
| |
| 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; } |
|
|
| |
| |
| |
| 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; |
| |
| nextTick(() => { |
| if (pomiScrollRoot.value) pomiScrollRoot.value.scrollTop = 0; |
| }); |
| const lineVals = lines.value.map(l => l.value); |
| await streamReading(lineVals); |
| } |
|
|
| |
| 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) { |
| |
| |
| |
| setTimeout(() => { |
| pomiStage.value = 'obscure'; |
| setTimeout(() => { |
| if (readingDone.value) pomiStage.value = 'reveal_btn'; |
| }, 12000); |
| }, 10000); |
| } |
| |
| } |
| } |
| } |
| 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(/<Reading>([\s\S]*?)(?:<\/Reading>|$)/); |
| const m2 = text.match(/<运在人为[^>]*>([\s\S]*?)(?:<\/运在人为[^>]*>|$)/) |
| || text.match(/<Way Through[^>]*>([\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; |
| |
| setTimeout(() => { |
| fadingOut.value = false; |
| |
| previewCleared.value = true; |
| }, 1200); |
| |
| |
| 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; |
| |
| 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; |
| |
| |
| 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 { |
| |
| 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, |
| |
| ominousClass, pomiBgClass, levelToneClass, ctaLabel, ctaSub, |
| destinyTag, remedyTag, moodTone, |
| auspiciousLevelClass, ctaBtnClass, |
| displayHexName, displayHexJudgment, displayLevel, displayCategory, displayRemedy, |
| |
| swipeTrackStyle, swipeHandleStyle, swipeProgress, |
| |
| 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'); |
|
|