昨天診所做完健檢,報告上列得清清楚楚。不過健檢查的是程式碼的體質,使用者實際走過一遍會遇到什麼,單子上看不出來。所以做完三張檢驗單之後,我換成使用者的身分,從頭把整間診所玩了一輪,挑出三個一定要在上線前處理掉的問題:
三個問題各有各的原因,今天分成三段來講,最後用同一個示範頁把它們串起來。部署的事,留給明天~
症狀
Day 19 做完語言切換、Day 23 補完動態圖例之後,我一直以為多語系已經收尾了。這次實際玩一遍才發現,最明顯的破綻出在測驗本身:整個畫面的按鈕、標題都在換語言,題目卻原封不動地停在中文。
原因
前面處理的都是介面上的「說明文字」,題庫不一樣,它是資料本身。quizData 裡的題目從 Day 10 開始就是一串中文字串,語言表根本沒有機會接上它們。
做法
把每一段文字,從單純的字串換成一個「四語物件」:
const L = (zh, en, ja, ko) => ({ zh, en, ja, ko });
q: L("早上鬧鐘響了,但你昨天熬夜趕專題,現在的你會:",
"Your alarm goes off, but you stayed up late on your project. You:",
"目覚ましが鳴ったけど、昨夜は課題で徹夜…。あなたは:",
"알람이 울렸지만 어젯밤 과제로 밤을 새웠다. 당신은:")
顯示的時候,用一個小函式 t() 依照目前語言取出對應的那一句:
const t = (obj) => (obj && (obj[lang] || obj.zh)) || "";
這一段有三個地方要特別處理:
"A. 直接按掉……" 整串寫死,現在文字只放內容本身,字母由程式依照順序補上。四種語言共用同一套字母,也不用在四個版本裡各寫一次。t() 取出當下語言的版本。語言是在進入測驗之前選好的,測驗畫面上沒有語言按鈕,作答途中不會被切換,所以顯示的當下取對語言就夠了。scores 不管哪種語言都一模一樣,所以不論用哪個語言作答,診斷結果會是同一份。症狀
題目修好之後,我繼續往後玩,發現同樣的問題還藏在三個地方:診斷結果的處方、惡言消滅器的詞庫、發洩牆預設的三句話。
做法
三個地方的處理方式,跟題庫是同一套:
prescriptionDB 的每一筆,從字串換成四語物件,顯示時用 t() 取出對應語言。t() 取出目前語言的詞。氣泡只會出現在測驗畫面,而那裡沒有語言按鈕,所以不用特地追蹤已經飄在半空中的氣泡。另外,語言切換的函式也要多做一件事:依照目前停留在哪個畫面,決定要重畫哪一塊。 在呼吸過場要立刻刷新呼吸的字,在結果頁要重畫圖例、處方跟發洩牆。測驗畫面則不需要,因為那裡不放語言按鈕,語言在進入測驗之前就已經決定了。這是 Day 20 學到的「查表」邏輯,再套用一次。
症狀
呼吸過場的圓圈會一直縮放下去,圓圈中間的文字卻只變了幾次:吸氣、吐氣、吸氣、吐氣、吸氣,然後停在那裡,圓圈還在動,字已經不動了。
原因
我回頭翻計時器的寫法:
if (step >= 4) clearInterval(interval);
這行是 Day 4 的原始寫法,目的是避免計時器無限跑下去。問題是圓圈的 CSS 動畫寫的是 infinite,永遠不會停。一個八秒後收工,一個永遠不收工,兩個時鐘各走各的。 之後我把整段計時器原封不動搬進語言切換的範例,也沒有發現這個落差。
做法
乾脆不要自己再數秒了。圓圈的動畫本身就有一個時鐘,直接問它「你現在跑到幾毫秒」:
const anim = circle.getAnimations()[0];
const ms = Number(anim.currentTime);
const state = (ms % 4000) < 2000 ? "in" : "out";
動畫週期是四秒,前兩秒圓圈放大,對應吸氣;後兩秒圓圈縮小,對應吐氣。文字用同一個時鐘決定,就不可能跟圓圈對不上。計時器也不再有結束的條件,停留在這個畫面多久都會繼續呼吸,離開畫面時才清掉。
這份示範頁從呼吸過場開始,經過整份測驗,到診斷結果與發洩牆。語言按鈕出現在首頁、呼吸過場跟結果頁,作答途中則會收起來,三項修補都在裡面:
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>賽博紓壓診所:上線前三項修補</title>
<style>
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
background: #e3f2fd;
color: #263238;
margin: 0;
padding: 16px;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.lang-switch { display: flex; gap: 6px; margin-bottom: 12px; }
.lang-btn {
background: #fff; border: 2px solid transparent; border-radius: 50px;
padding: 6px 14px; font-size: 0.8rem; cursor: pointer; color: #263238;
touch-action: manipulation;
}
.lang-btn.active { background: #1e88e5; color: #fff; }
.card {
background: #fff; padding: 24px; border-radius: 20px;
box-shadow: 0 12px 30px rgba(0,0,0,0.15);
width: 100%; max-width: 480px; text-align: center;
position: relative; z-index: 10;
}
.hidden { display: none; }
h1 { color: #1e88e5; font-size: 1.4rem; margin: 0 0 8px; }
h2 { color: #263238; font-size: 1.1rem; line-height: 1.5; margin: 8px 0 18px; }
h3 { font-size: 1rem; text-align: left; margin: 18px 0 8px; }
p { color: #546e7a; font-size: 0.9rem; margin: 0 0 16px; }
.progress { font-size: 0.85rem; color: #1e88e5; font-weight: bold; }
.btn {
background: #1e88e5; color: #fff; border: none; border-radius: 50px;
padding: 12px 28px; font-size: 1rem; cursor: pointer; width: 100%;
margin-top: 10px; touch-action: manipulation;
}
.btn:hover { background: #1565c0; }
.option-btn {
background: #f5f5f5; border: 2px solid transparent; border-radius: 14px;
padding: 14px; width: 100%; margin-bottom: 10px; text-align: left;
cursor: pointer; font-size: 0.93rem; color: #263238; touch-action: manipulation;
}
.option-btn:hover { border-color: #1e88e5; background: #e3f2fd; }
.draw-btn {
background: #d32f2f; color: #fff; border: none; border-radius: 50px;
padding: 14px 36px; font-size: 1.1rem; font-weight: bold; cursor: pointer;
width: 100%; box-shadow: 0 4px 15px rgba(211,47,47,0.4); touch-action: manipulation;
}
#drawResult { margin-top: 14px; font-weight: bold; color: #d32f2f; min-height: 48px; white-space: pre-line; }
/* 呼吸圓圈:前 2 秒放大(吸氣),後 2 秒縮小(吐氣) */
.breathe-circle {
width: 130px; height: 130px; border-radius: 50%; margin: 30px auto;
background: #90caf9; color: #fff; font-weight: bold;
display: flex; align-items: center; justify-content: center; text-align: center;
animation: breatheAnimation 4s infinite ease-in-out;
}
@keyframes breatheAnimation {
0% { transform: scale(1); background: #90caf9; }
50% { transform: scale(1.3); background: #42a5f5; }
100% { transform: scale(1); background: #90caf9; }
}
/* 惡言消滅器 */
.rage-target {
position: fixed; background: #ff5252; color: #fff; padding: 6px 12px;
border-radius: 20px; font-size: 0.85rem; cursor: pointer; z-index: 100;
box-shadow: 0 4px 10px rgba(0,0,0,0.2); user-select: none;
animation: floatRandom 8s forwards linear; transition: transform 0.1s, opacity 0.2s;
}
@keyframes floatRandom {
0% { transform: translateY(105vh); opacity: 0.9; }
100% { transform: translateY(-10vh) translateX(30px); opacity: 0; }
}
/* 結果頁 */
.dim-row { text-align: left; margin-bottom: 10px; font-size: 0.85rem; }
.dim-head { display: flex; justify-content: space-between; margin-bottom: 3px; }
.dim-track { height: 10px; background: #eceff1; border-radius: 5px; overflow: hidden; }
.dim-fill { height: 100%; background: linear-gradient(90deg, #64b5f6, #1e88e5); border-radius: 5px; }
.rx {
text-align: left; border: 2px dashed #90caf9; border-radius: 12px;
padding: 12px 14px; font-size: 0.88rem; line-height: 1.6; margin-top: 14px;
}
.rx b { color: #d32f2f; display: block; margin-bottom: 4px; }
.wall-input { display: flex; gap: 6px; margin-bottom: 8px; }
.wall-input input {
flex: 1; padding: 9px 14px; border-radius: 50px;
border: 2px solid #e3f2fd; font-size: 0.85rem;
}
.wall-input button {
background: #1e88e5; color: #fff; border: none; border-radius: 50px;
padding: 9px 16px; cursor: pointer; font-size: 0.85rem;
}
#wallList { max-height: 140px; overflow-y: auto; text-align: left; }
.wall-card {
background: #fdfdfd; border: 2px dashed #90caf9; border-radius: 10px;
padding: 8px 12px; margin-bottom: 6px; font-size: 0.83rem;
}
</style>
</head>
<body>
<div class="lang-switch" id="langSwitch">
<button class="lang-btn" data-lang="zh">中文</button>
<button class="lang-btn" data-lang="en">EN</button>
<button class="lang-btn" data-lang="ja">日本語</button>
<button class="lang-btn" data-lang="ko">한국어</button>
</div>
<!-- 首頁 -->
<div id="welcome-screen" class="card">
<h1 data-i18n="title"></h1>
<p data-i18n="subtitle"></p>
<button class="btn" data-i18n="startBtn" onclick="startBreathe()"></button>
</div>
<!-- 呼吸過場 -->
<div id="breathe-screen" class="card hidden">
<h2 data-i18n="breatheTitle"></h2>
<div class="breathe-circle" id="breatheCircle"></div>
<button class="btn" data-i18n="readyBtn" onclick="startQuiz()"></button>
</div>
<!-- 測驗 -->
<div id="quiz-screen" class="card hidden">
<span class="progress" id="progress"></span>
<h2 id="question"></h2>
<div id="options"></div>
<div id="drawResult"></div>
</div>
<!-- 結果 -->
<div id="result-screen" class="card hidden">
<h2 data-i18n="resultTitle"></h2>
<div id="dimList"></div>
<div class="rx"><b id="rxTitle"></b><span id="rxText"></span></div>
<h3 data-i18n="wallTitle"></h3>
<div class="wall-input">
<input id="wallInput" data-i18n-ph="wallPlaceholder" maxlength="40">
<button data-i18n="wallSend" onclick="submitComplaint()"></button>
</div>
<div id="wallList"></div>
<button class="btn" data-i18n="againBtn" onclick="restart()"></button>
</div>
<script>
const $ = (id) => document.getElementById(id);
let lang = "zh";
// ---------- 第一段:每段文字都是四語物件,顯示時才挑語言 ----------
const L = (zh, en, ja, ko) => ({ zh, en, ja, ko });
const t = (obj) => (obj && (obj[lang] || obj.zh)) || "";
const dimensions = ["日常", "效率", "社交", "心態", "放鬆", "職涯"];
const dimensionNames = {
"日常": L("日常", "Daily", "日常", "일상"),
"效率": L("效率", "Efficiency", "効率", "효율"),
"社交": L("社交", "Social", "社交", "사교"),
"心態": L("心態", "Mindset", "メンタル", "멘탈"),
"放鬆": L("放鬆", "Relax", "リラックス", "휴식"),
"職涯": L("職涯", "Career", "キャリア", "커리어")
};
const UI = {
title: L("賽博紓壓診所", "Cyber Stress Clinic", "サイバー・ストレスクリニック", "사이버 스트레스 클리닉"),
subtitle: L("結合廟宇抽籤、雷達圖與惡搞處方籤的心靈小工具", "Fortune draws, radar charts, and absurd prescriptions in one app", "おみくじ、レーダーチャート、ふざけた処方箋が詰まった癒しツール", "운세뽑기, 레이더 차트, 엉뚱한 처방전이 담긴 힐링 도구"),
startBtn: L("進入紓壓診所", "Enter the Clinic", "クリニックへ入る", "클리닉 입장하기"),
breatheTitle: L("🧘 調整呼吸,放鬆心情", "🧘 Take a Breath, Relax", "🧘 呼吸を整えて、心を落ち着けよう", "🧘 숨을 고르고 마음을 편안하게"),
breathIn: L("吸氣...", "Breathe in...", "吸って...", "들이쉬고..."),
breathOut: L("吐氣...", "Breathe out...", "吐いて...", "내쉬고..."),
readyBtn: L("準備好了,開始解碼", "I'm Ready, Let's Start", "準備完了、始めよう", "준비됐어요, 시작할게요"),
qLabel: L("Question {n} of {total}", "Question {n} of {total}", "第{n}問 / 全{total}問", "{n} / {total}번째 질문"),
drawBtn: L("🎲 搖動籤筒", "🎲 Shake the Stick", "🎲 おみくじを振る", "🎲 운세 통 흔들기"),
drawResult: L("鏘鏘!你抽到了:", "Ta-da! You drew:", "ジャーン!引いたのは:", "짜잔! 뽑은 결과:"),
resultTitle: L("🏥 診斷完成", "🏥 Diagnosis Complete", "🏥 診断完了", "🏥 진단 완료"),
weakest: L("最需要留意:", "Needs attention: ", "要注意:", "주의 필요: "),
wallTitle: L("🧱 匿名發洩牆", "🧱 Anonymous Vent Wall", "🧱 匿名ぼやきの壁", "🧱 익명 하소연 벽"),
wallPlaceholder:L("今天被什麼氣到了?", "What annoyed you today?", "今日は何にイラッとした?", "오늘 뭐 때문에 화났어?"),
wallSend: L("丟出去", "Send", "送信", "보내기"),
againBtn: L("重新掛號", "Start Over", "もう一度", "다시 시작")
};
const quizData = [
{ type: "normal",
q: L("早上鬧鐘響了,但你昨天熬夜趕專題,現在的你會:", "Your alarm goes off, but you stayed up late on your project. You:", "目覚ましが鳴ったけど、昨夜は課題で徹夜…。あなたは:", "알람이 울렸지만 어젯밤 과제로 밤을 새웠다. 당신은:"),
options: [
{ t: L("直接按掉,催眠自己教授今天會請假", "Hit snooze and convince yourself the professor will call in sick", "スヌーズを押して、先生が休講になると自分に言い聞かせる", "알람을 끄고 교수님이 휴강하길 스스로 세뇌한다"), scores: { "日常": -2, "放鬆": 3 } },
{ t: L("痛苦地爬起來,靠三杯黑咖啡續命", "Drag yourself up and survive on three black coffees", "苦しみながら起き上がり、ブラックコーヒー3杯で乗り切る", "괴롭게 일어나 블랙커피 세 잔으로 버틴다"), scores: { "效率": 3, "心態": -2 } },
{ t: L("傳訊息跟同學求救「幫點名」", "Text a classmate to cover your attendance", "クラスメイトに「出席お願い」とメッセージを送る", "친구에게 \"대리 출석 부탁해\"라고 문자한다"), scores: { "社交": 2, "心態": 1 } }
] },
{ type: "draw",
q: L("來到廟宇,面對未知的死線,請誠心搖動籤筒抽出今日運勢:", "At the temple, facing a mysterious deadline, shake the fortune stick sincerely to draw today's luck:", "神社で、見えない締切を前に、心を込めておみくじを引こう:", "사원에서 알 수 없는 마감 앞에, 정성껏 운세 통을 흔들어 오늘의 운세를 뽑아보세요:"),
options: [
{ t: L("【大吉】教授突然宣布延期交件!", "【Great Luck】The professor suddenly extends the deadline!", "【大吉】教授が突然、提出期限を延ばしてくれた!", "【대길】교수님이 갑자기 제출 기한을 연장해 주셨다!"), scores: { "放鬆": 5 } },
{ t: L("【中吉】剛好找到神級開源程式碼,無痛套用。", "【Good Luck】You find perfect open-source code and drop it in painlessly.", "【中吉】神レベルのオープンソースコードを発見、楽々流用できた。", "【중길】완벽한 오픈소스 코드를 발견해 아무 고통 없이 갖다 썼다."), scores: { "效率": 4 } },
{ t: L("【大凶】電腦無預警死機,而且你沒存檔。", "【Terrible Luck】Your computer crashes without warning, and you didn't save.", "【大凶】パソコンが突然フリーズ、しかも保存していない。", "【대흉】컴퓨터가 갑자기 꺼졌는데 저장도 안 했다."), scores: { "心態": -5 } }
] },
{ type: "normal",
q: L("突然多出一個小時空檔,你會怎麼做?", "You suddenly get a free hour. What do you do?", "急に1時間ぽっかり空いた。どうする?", "갑자기 한 시간이 비었다. 무엇을 할까?"),
options: [
{ t: L("趕快把今天的待辦事項處理掉", "Knock out today's to-do list", "今日のToDoを一気に片付ける", "오늘 할 일을 얼른 처리한다"), scores: { "效率": 3, "日常": 1 } },
{ t: L("躺著滑手機,享受難得的空檔", "Lie down and scroll your phone, enjoying the rare break", "寝転んでスマホをいじり、貴重な空き時間を満喫する", "누워서 폰을 보며 귀한 여유를 즐긴다"), scores: { "放鬆": 3 } },
{ t: L("找朋友聊天", "Chat with a friend", "友達とおしゃべりする", "친구와 수다를 떤다"), scores: { "社交": 3, "心態": 1 } }
] },
{ type: "normal",
q: L("遇到完全不會的問題時,你通常會?", "When you hit a problem you totally can't solve, you usually:", "まったく分からない問題にぶつかったら、たいてい:", "전혀 모르는 문제를 만나면 보통:"),
options: [
{ t: L("自己研究到搞懂", "Dig into it alone until you get it", "分かるまで自分で調べ抜く", "이해할 때까지 혼자 파고든다"), scores: { "效率": 2, "職涯": 3 } },
{ t: L("先放著,等心情好一點再處理", "Leave it for now and come back when you feel better", "ひとまず放置して、気分が良くなってから取り組む", "일단 두고 기분이 나아지면 다시 한다"), scores: { "放鬆": 2, "心態": 2 } },
{ t: L("找朋友或同學一起討論", "Discuss it with friends or classmates", "友達やクラスメイトと一緒に相談する", "친구나 동기와 함께 의논한다"), scores: { "社交": 2, "職涯": 1 } }
] },
{ type: "draw",
q: L("最後一關,請再次把命運交給賽博籤筒!", "Final round. Entrust your fate to the cyber fortune stick once more!", "最終関門。もう一度、運命をサイバーおみくじに委ねよう!", "마지막 관문. 다시 한번 사이버 운세 통에 운명을 맡겨보세요!"),
options: [
{ t: L("【神籤】突然獲得神秘力量,今天效率爆表!", "【Divine Luck】A mysterious power surges in. Your efficiency is off the charts today!", "【神籤】謎の力が湧き、今日の効率は爆上がり!", "【신첨】신비한 힘이 솟아 오늘 효율이 폭발한다!"), scores: { "效率": 5, "職涯": 2 } },
{ t: L("【平籤】今天適合好好休息,明天再戰。", "【Plain Luck】Today is for resting. Fight again tomorrow.", "【平籤】今日はしっかり休んで、明日また戦おう。", "【평첨】오늘은 푹 쉬고 내일 다시 싸우자."), scores: { "放鬆": 4, "心態": 2 } },
{ t: L("【凶籤】打開電腦發現昨天的程式全部消失。", "【Bad Luck】You open your laptop and find yesterday's code has vanished.", "【凶籤】パソコンを開くと、昨日のコードが全部消えていた。", "【흉첨】노트북을 켜니 어제 짠 코드가 전부 사라졌다."), scores: { "心態": -5, "日常": -2 } }
] }
];
// ---------- 第二段:處方、氣泡詞庫、發洩牆預設留言,同樣換成四語物件 ----------
const prescriptionDB = {
"日常": L("今天不用早睡了,反正你也不會睡。去買杯手搖飲,全糖去冰,糖分才是良藥。", "Skip the early bedtime, you won't sleep anyway. Grab a bubble tea, full sugar, no ice. Sugar is the real medicine.", "今日は早寝しなくていい、どうせ眠れない。フルシュガー氷なしのタピオカドリンクを買おう。糖分こそ良薬。", "오늘은 일찍 잘 필요 없다, 어차피 못 잔다. 당 100% 얼음 없는 버블티를 사 마시자. 당분이 곧 약이다."),
"效率": L("把手機關機兩小時,看著天花板發呆。這也是一種產出,只是產出是「無」。", "Turn off your phone for two hours and stare at the ceiling. It's a kind of output, the output is \"nothing.\"", "スマホの電源を2時間切って、天井をぼんやり眺めよう。これも立派なアウトプット。ただし成果は「無」。", "휴대폰을 두 시간 꺼 두고 천장을 멍하니 바라보자. 이것도 산출물이다, 결과물이 '무'일 뿐."),
"社交": L("取消今晚所有的局,你需要跟棉被獨處的 me time,誰傳訊息都當作沒看到。", "Cancel every plan tonight. You need me-time alone with your blanket. Pretend you saw no messages.", "今夜の予定は全部キャンセル。必要なのは布団と二人きりのミータイム。誰からのメッセージも見なかったことに。", "오늘 밤 약속은 전부 취소하자. 이불과 단둘이 보내는 me time이 필요하다. 누가 연락해도 못 본 척."),
"心態": L("對著鏡子大喊三聲「我已經很努力了」,然後去睡覺,明天的事明天再說。", "Shout \"I've already tried so hard\" at the mirror three times, then go to bed. Tomorrow's problems can wait.", "鏡に向かって「私はもう十分がんばった」と3回叫んで寝よう。明日のことは明日考える。", "거울을 보고 \"나 충분히 열심히 했어\"를 세 번 외치고 자자. 내일 일은 내일 생각하자."),
"放鬆": L("去便利商店走一圈,什麼都不買,純欣賞架上的零食,療癒效果驚人。", "Walk a lap around a convenience store, buy nothing, just admire the snacks. The healing effect is astonishing.", "コンビニを一周して、何も買わず、棚のお菓子を眺めるだけ。癒やし効果は絶大。", "편의점을 한 바퀴 돌며 아무것도 사지 말고 진열된 과자만 구경하자. 힐링 효과가 놀랍다."),
"職涯": L("打開求職網站逛十分鐘,看看別人的職缺內容,你會突然覺得現在好像還行。", "Browse a job site for ten minutes. Seeing other openings might make your current situation look fine.", "求人サイトを10分眺めてみよう。他の求人を見ると、今の状況も悪くない気がしてくる。", "구직 사이트를 10분 둘러보자. 다른 공고를 보면 지금도 나쁘지 않다는 생각이 든다.")
};
const rageWords = [
L("論文死線", "Thesis deadline", "論文の締切", "논문 마감"),
L("明天要交報告", "Report due tomorrow", "明日提出のレポート", "내일 제출할 보고서"),
L("長輩群組訊息", "Family group-chat messages", "親戚グループのメッセージ", "어른들 단톡방 메시지"),
L("存款餘額不足", "Low bank balance", "残高不足", "잔액 부족"),
L("專案又改需求", "Requirements changed again", "また仕様変更", "프로젝트 요구사항 또 변경")
];
// 發洩牆:預設三句有四語版本,使用者自己輸入的就是單純的字串
let complaints = [
L("教授說報告延期,結果延到明天早上八點", "The professor said the report was postponed, to 8 a.m. tomorrow", "先生がレポート延期と言ったのに、延びたのは明日の朝8時まで", "교수님이 보고서를 연기해 주셨는데 내일 아침 8시로 연기됐다"),
L("咖啡喝到第三杯,手已經開始抖了", "Three coffees in and my hands are already shaking", "コーヒー3杯目で、もう手が震えてきた", "커피 세 잔째인데 벌써 손이 떨린다"),
L("同組的人已經三天沒讀訊息了", "My groupmate hasn't read a message in three days", "グループの人が3日間メッセージを既読にしていない", "조원이 사흘째 메시지를 읽지 않고 있다")
];
// ---------- 畫面切換與語言 ----------
let screen = "welcome";
function showScreen(name) {
screen = name;
["welcome", "breathe", "quiz", "result"].forEach(s => $(s + "-screen").classList.toggle("hidden", s !== name));
$("langSwitch").classList.toggle("hidden", name === "quiz"); // 作答途中不顯示語言按鈕
if (name !== "breathe") stopBreathe();
if (name === "quiz") startRage(); else stopRage();
}
function applyLanguage(l) {
lang = l;
document.documentElement.lang = { zh: "zh-Hant", en: "en", ja: "ja", ko: "ko" }[l];
document.querySelectorAll("[data-i18n]").forEach(el => el.innerText = t(UI[el.dataset.i18n]));
document.querySelectorAll("[data-i18n-ph]").forEach(el => el.placeholder = t(UI[el.dataset.i18nPh]));
document.querySelectorAll(".lang-btn").forEach(b => b.classList.toggle("active", b.dataset.lang === l));
// 有語言按鈕的畫面,依目前停留的位置決定要重畫哪一塊
if (screen === "result") renderResult();
if (screen === "breathe") tickBreath();
}
document.querySelectorAll(".lang-btn").forEach(b => b.addEventListener("click", () => applyLanguage(b.dataset.lang)));
// ---------- 第三段:呼吸文字直接讀動畫自己的時鐘 ----------
let breatheTimer = null;
let breatheStart = 0;
function getBreathState() {
const circle = $("breatheCircle");
const anim = circle.getAnimations ? circle.getAnimations()[0] : null;
let ms;
if (anim && anim.currentTime != null) ms = Number(anim.currentTime);
else ms = performance.now() - breatheStart; // 備援:不支援時退回自己計時
return (ms % 4000) < 2000 ? "in" : "out"; // 前 2 秒放大 = 吸氣,後 2 秒縮小 = 吐氣
}
function tickBreath() {
const text = t(getBreathState() === "in" ? UI.breathIn : UI.breathOut);
const el = $("breatheCircle");
if (el.textContent !== text) el.textContent = text;
}
function startBreathe() {
showScreen("breathe");
breatheStart = performance.now();
clearInterval(breatheTimer);
breatheTimer = setInterval(tickBreath, 120); // 沒有結束條件,離開畫面才清掉
tickBreath();
}
function stopBreathe() { clearInterval(breatheTimer); breatheTimer = null; }
// ---------- 測驗流程 ----------
let current = 0, userScores = {}, locked = false;
function startQuiz() {
current = 0; locked = false;
userScores = {};
dimensions.forEach(d => userScores[d] = 0);
showScreen("quiz");
renderQuestion();
}
function renderQuestion() {
const q = quizData[current];
$("progress").innerText = t(UI.qLabel)
.replace("{n}", current + 1)
.replace("{total}", quizData.length);
$("question").innerText = t(q.q);
$("drawResult").innerText = "";
const box = $("options");
box.innerHTML = "";
if (q.type === "draw") {
const btn = document.createElement("button");
btn.className = "draw-btn";
btn.innerText = t(UI.drawBtn);
btn.onclick = () => drawStick(q);
box.appendChild(btn);
} else {
q.options.forEach((opt, i) => {
const btn = document.createElement("button");
btn.className = "option-btn";
btn.innerText = "ABC"[i] + ". " + t(opt.t); // 字母由程式補上,不寫在文字裡
btn.onclick = () => choose(opt.scores);
box.appendChild(btn);
});
}
}
// 抽到的籤詩,直接取目前語言的那一句顯示
function drawStick(q) {
if (locked) return;
locked = true;
const picked = q.options[Math.floor(Math.random() * q.options.length)];
$("drawResult").innerText = t(UI.drawResult) + "\n" + t(picked.t);
setTimeout(() => { locked = false; choose(picked.scores); }, 1800);
}
function choose(scores) {
for (const dim in scores) userScores[dim] += scores[dim];
current++;
if (current < quizData.length) renderQuestion();
else { showScreen("result"); renderResult(); }
}
// 每個維度的範圍由程式掃題庫算出來
function getDimensionRange(dim) {
let min = 0, max = 0;
quizData.forEach(q => {
const c = q.options.map(o => o.scores[dim] || 0);
min += Math.min(...c);
max += Math.max(...c);
});
return { min, max };
}
function toPercent(v, min, max) {
if (max === min) return 50;
return Math.max(0, Math.min(100, Math.round((v - min) / (max - min) * 100)));
}
// ---------- 結果頁 ----------
function renderResult() {
const scored = dimensions.map(dim => {
const { min, max } = getDimensionRange(dim);
return { dim, pct: toPercent(userScores[dim], min, max) };
});
$("dimList").innerHTML = scored.map(s => `
<div class="dim-row">
<div class="dim-head"><span>${dimensionNames[s.dim][lang]}</span><b>${s.pct}</b></div>
<div class="dim-track"><div class="dim-fill" style="width:${s.pct}%"></div></div>
</div>`).join("");
// 用歸一化後的分數找最弱維度,比原始分數公平
const weakest = scored.reduce((m, c) => c.pct < m.pct ? c : m).dim;
$("rxTitle").innerText = "💊 " + t(UI.weakest) + dimensionNames[weakest][lang];
$("rxText").innerText = t(prescriptionDB[weakest]);
renderWall();
}
function renderWall() {
const list = $("wallList");
list.innerHTML = "";
complaints.forEach(item => {
const div = document.createElement("div");
div.className = "wall-card";
// 物件 = 預設的四語留言;字串 = 使用者自己輸入的,原樣顯示。用 textContent 避免塞進 HTML
div.textContent = "💬 " + (typeof item === "string" ? item : t(item));
list.appendChild(div);
});
}
function submitComplaint() {
const input = $("wallInput");
const text = input.value.trim();
if (!text) return;
complaints.unshift(text);
input.value = "";
renderWall();
}
$("wallInput").addEventListener("keydown", e => { if (e.key === "Enter") submitComplaint(); });
function restart() { showScreen("welcome"); }
// ---------- 惡言消滅器:氣泡產生的當下,取目前語言的詞 ----------
let rageTimer = null;
function spawnRage() {
const idx = Math.floor(Math.random() * rageWords.length);
const el = document.createElement("div");
el.className = "rage-target";
el.textContent = "🔥 " + t(rageWords[idx]);
el.style.left = (Math.random() * 70 + 10) + "%";
el.onclick = () => { el.style.transform = "scale(1.5)"; el.style.opacity = "0"; setTimeout(() => el.remove(), 200); };
document.body.appendChild(el);
setTimeout(() => el.remove(), 8000);
}
function startRage() { if (!rageTimer) rageTimer = setInterval(spawnRage, 2000); }
function stopRage() {
clearInterval(rageTimer); rageTimer = null;
document.querySelectorAll(".rage-target").forEach(el => el.remove());
}
applyLanguage("zh");
</script>
</body>
</html>
存成 .html 打開,先在首頁選好語言,進到呼吸畫面先放著不動,看字跟著圓圈一吸一吐一直輪下去,這裡也可以隨時切換語言;開始作答之後,語言按鈕會收起來,題目、選項、抽籤結果、飄過去的氣泡都用你選好的語言顯示;答完之後在結果頁再切一次語言,處方跟發洩牆也會跟著換。
使用者留言改用 textContent 顯示
Day 25 的發洩牆是用 innerHTML 把留言塞進畫面,使用者打什麼,瀏覽器就當成 HTML 解讀。在自己的小專題裡風險不大,但養成這個習慣比較安心,所以今天順手改成 textContent,使用者輸入的任何字元都只會被當成純文字。
t() 找不到對應語言時,退回中文obj[lang] || obj.zh 這個寫法是留給「某個語言漏翻」的情況。真的漏了,畫面上至少顯示中文,不會出現空白或 undefined。
getAnimations() 取到的 currentTime 有可能是 null
動畫還沒開始、或是元素暫時不在畫面上時就會這樣,所以取值前先判斷,判斷不過就退回用 performance.now() 自己計時。這條備援路線平常用不到,留著是為了不支援這個 API 的瀏覽器。
最弱維度改用歸一化後的分數去找
Day 14 的處方是用原始分數挑最低的維度,現在每個維度已經有站在同一把尺上的 0 到 100 分,用它來比較比較公平,不會有某個維度只是因為題庫給的分數範圍比較窄,就被誤判成最弱。
多語系做得到哪裡,要看資料長什麼樣子
Day 19 的語言表是為了「頁面上寫死的文字」設計的,碰到題庫這種大量資料就不夠用了。今天把每段文字都做成四語物件之後,才算真正搞懂:不論是畫面上的文字、還是資料裡的文字,只要在顯示的那一刻才決定語言,就能隨時切換。
內容跟裝飾要分開放
選項前面的 A、B、C 本來是跟文字黏在一起的,換成四種語言就得抄四次。拆開之後,文字只負責內容,字母由程式依序號補上,之後想改成 1、2、3 也只要動一個地方。這跟前面反覆出現的「資料跟呈現分開」是同一個道理,只是這次輪到一個小小的字母。
把兩個時鐘合併成一個,比校準兩個時鐘容易
呼吸的問題,我原本想過把計時器的間隔調得更精準,後來發現再準也是兩個時鐘,遲早會漂移。直接讀動畫自己的時間,是整個問題最乾脆的解法,因為根本沒有第二個時鐘可以對不上。
今天分三段處理了上線前最後三個破綻:把題目、選項、籤詩全部換成四語物件;處方、氣泡詞庫、發洩牆預設留言一併接上,在呼吸過場與結果頁切換語言,畫面都會立刻跟著換;呼吸過場的文字改成直接讀動畫的時鐘,不會再中途斷氣。
這次的修補完成之後,診所裡能想到的洞,我手上的清單都劃掉了。
剩下的一件事,從 Day 1 就寫在專案初衷裡:點進網址就能用。
明天 Day 29,我們要替診所掛上真正的門牌,把整個專題推上 GitHub Pages,換成一串任何人都能點開的網址。
明天掛牌開業!!