前言
昨天我們把 10 道題目的動態渲染框架與求運氣的「廟宇籤筒」機制搞定了。
看著畫面隨著使用者的點擊,一題一題往下切換,整個心理測驗的骨架已經相當完整。
不過,你有沒有發現少了一個最重要的靈魂?
那就是——
分數到底跑哪去了?
在前幾天的範例中,我們雖然已經在每個選項的資料結構裡偷偷塞入了像:
scores: {
"日常": -2,
"放鬆": 3
}
這樣的分數物件。
但問題是,使用者點下選項之後,程式其實只負責跳到下一題,這些分數根本沒有真正被記錄下來。
也就是說,不管使用者一路選了什麼,最後的結果都可能還是一片:
日常:0
效率:0
社交:0
心態:0
放鬆:0
職涯:0
這就像我們辛辛苦苦開了一間診所,病人填完病歷之後,櫃台卻跟你說:
「嗯……我們好像沒有記錄你的病歷。」
那當然不行。
所以今天 Day 11 的任務,就是替這個心理測驗裝上一顆真正的「計分大腦」。
核心觀念:全域計分物件與分數累加
這次我們需要統計的不是單一分數,而是六個不同的心理維度:
如果每個維度都另外建立一個變數,例如:
let dailyScore = 0;
let efficiencyScore = 0;
let socialScore = 0;
let mindsetScore = 0;
一開始可能還可以接受。
但如果未來增加到 10 個甚至 20 個維度,程式碼很快就會變得難以維護。
因此這次我們改用一個物件統一管理所有分數:
let userScores = {
"日常": 0,
"效率": 0,
"社交": 0,
"心態": 0,
"放鬆": 0,
"職涯": 0
};
這個 userScores 就可以想像成玩家專屬的「計分板」。
一開始所有分數都是 0。
當玩家選擇一個選項時,就把該選項背後的 scores 加進對應的維度。
例如:
scores: {
"日常": -2,
"放鬆": 3
}
就代表:
日常 -2 分
放鬆 +3 分
而且不管是加分還是扣分,都可以直接透過同一套邏輯處理。
動手寫 Code:建立維度計分與測驗流程
延續昨天的動態題庫,我們這次會把 scores 真正接進測驗流程。
為了讓整個流程更容易理解,這次同樣做成一個可以獨立執行的小型示範。
裡面會包含:
首先延續昨天的賽博紓壓診所風格,先把題目、選項與結果區域設計好。
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family:
Arial,
"Microsoft JhengHei",
sans-serif;
background: #111827;
color: white;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.card {
width: 90%;
max-width: 650px;
background: #1f2937;
padding: 40px;
border-radius: 20px;
box-shadow:
0 0 20px rgba(0, 255, 255, 0.2);
}
h1 {
text-align: center;
color: #67e8f9;
margin-bottom: 5px;
}
.subtitle {
text-align: center;
color: #9ca3af;
margin-bottom: 30px;
}
.progress {
color: #67e8f9;
font-size: 14px;
}
#question-text {
line-height: 1.6;
margin: 20px 0;
}
/* 一般選項按鈕 */
.option-btn {
width: 100%;
padding: 15px;
margin-bottom: 12px;
border: 1px solid #374151;
border-radius: 12px;
background: #111827;
color: white;
cursor: pointer;
text-align: left;
font-size: 16px;
transition: 0.2s;
}
.option-btn:hover {
background: #164e63;
border-color: #67e8f9;
transform: translateY(-2px);
}
/* 搖籤筒按鈕 */
.draw-btn {
background-color: #d32f2f;
color: white;
border: none;
border-radius: 50px;
padding: 15px 40px;
font-size: 1.2rem;
cursor: pointer;
transition: 0.2s;
font-weight: bold;
box-shadow:
0 4px 15px rgba(211, 47, 47, 0.4);
}
.draw-btn:hover {
background-color: #b71c1c;
transform: translateY(-2px);
}
/* 抽籤結果 */
#draw-result {
margin-top: 20px;
font-size: 1.1rem;
font-weight: bold;
color: #ff5252;
min-height: 30px;
white-space: pre-line;
}
/* 結果區 */
.result-item {
padding: 15px;
margin: 10px 0;
background: #111827;
border-radius: 10px;
border-left: 4px solid #67e8f9;
display: flex;
justify-content: space-between;
}
.restart-btn {
width: 100%;
padding: 14px;
margin-top: 20px;
border: none;
border-radius: 10px;
background: #0891b2;
color: white;
font-size: 16px;
cursor: pointer;
}
.restart-btn:hover {
background: #0e7490;
}
.hidden {
display: none;
}
HTML 的部分依然不需要寫死 10 題。
我們只需要準備一個容器,讓 JavaScript 負責把題目與選項塞進去。
<div class="card" id="quiz-screen">
<h1>🧠 賽博紓壓診所</h1>
<p class="subtitle">
心理維度診斷系統
</p>
<!-- 題目區 -->
<div id="question-area">
<span id="progress-text" class="progress">
Question 1 of 5
</span>
<h2 id="question-text">
題目載入中...
</h2>
<div id="options-container"></div>
<div id="draw-result"></div>
</div>
<!-- 結果區 -->
<div id="result-area" class="hidden">
<h2>🎉 診斷完成!</h2>
<p>
以下是本次測驗的六大維度分數:
</p>
<div id="result-container"></div>
<button
class="restart-btn"
onclick="restartTest()"
>
再測一次
</button>
</div>
</div>
這樣 HTML 依然只負責「提供位置」。
至於到底要顯示哪一題、哪幾個選項,以及最後的分數是多少,都交給 JavaScript 處理。
接下來就是今天最重要的部分。
首先建立題庫。
延續 Day 10 的設計,每個選項都可以擁有自己的 scores。
const quizData = [
{
type: "normal",
question:
"早上鬧鐘響了,但你昨天熬夜趕專題,現在的你會:",
options: [
{
text:
"A. 直接按掉,催眠自己教授今天會請假",
scores: {
"日常": -2,
"放鬆": 3
}
},
{
text:
"B. 痛苦地爬起來,靠三杯黑咖啡續命",
scores: {
"效率": 3,
"心態": -2
}
},
{
text:
"C. 傳訊息跟同學求救「幫點名」",
scores: {
"社交": 2,
"心態": 1
}
}
]
},
{
type: "draw",
question:
"來到廟宇,面對未知的死線,請誠心搖動籤筒抽出今日運勢:",
options: [
{
text:
"【大吉】教授突然宣布延期交件!",
scores: {
"放鬆": 5
}
},
{
text:
"【中吉】剛好找到神級開源程式碼,無痛套用。",
scores: {
"效率": 4
}
},
{
text:
"【大凶】電腦無預警死機,而且你沒存檔。",
scores: {
"心態": -5
}
}
]
},
{
type: "normal",
question:
"突然多出一個小時空檔,你會怎麼做?",
options: [
{
text:
"A. 趕快把今天的待辦事項處理掉",
scores: {
"效率": 3,
"日常": 1
}
},
{
text:
"B. 躺著滑手機,享受難得的空檔",
scores: {
"放鬆": 3
}
},
{
text:
"C. 找朋友聊天",
scores: {
"社交": 3,
"心態": 1
}
}
]
},
{
type: "normal",
question:
"遇到完全不會的問題時,你通常會?",
options: [
{
text:
"A. 自己研究到搞懂",
scores: {
"效率": 2,
"職涯": 3
}
},
{
text:
"B. 先放著,等心情好一點再處理",
scores: {
"放鬆": 2,
"心態": 2
}
},
{
text:
"C. 找朋友或同學一起討論",
scores: {
"社交": 2,
"職涯": 1
}
}
]
},
{
type: "draw",
question:
"最後一關,請再次把命運交給賽博籤筒!",
options: [
{
text:
"【神籤】突然獲得神秘力量,今天效率爆表!",
scores: {
"效率": 5,
"職涯": 2
}
},
{
text:
"【平籤】今天適合好好休息,明天再戰。",
scores: {
"放鬆": 4,
"心態": 2
}
},
{
text:
"【凶籤】打開電腦發現昨天的程式全部消失。",
scores: {
"心態": -5,
"日常": -2
}
}
]
}
];
這裡就可以看到昨天埋下的伏筆真正派上用場了。
每一個選項都不只是文字。
它其實還偷偷攜帶了一組「分數資料」。
接下來建立今天的主角:
let userScores = {
"日常": 0,
"效率": 0,
"社交": 0,
"心態": 0,
"放鬆": 0,
"職涯": 0
};
再建立目前題目索引:
let currentQuestionIndex = 0;
這樣我們就可以知道:
目前答到第幾題
+
目前六個維度各有幾分
接下來就是整個 Day 11 的核心:
function addScores(scores) {
for (let dimension in scores) {
userScores[dimension] += scores[dimension];
}
}
這裡的:
for (let dimension in scores)
會把這次選項提供的所有維度一個一個拿出來。
例如使用者選到:
{
"日常": -2,
"放鬆": 3
}
程式就會自動執行:
userScores["日常"] += -2;
userScores["放鬆"] += 3;
如果下一題又拿到:
{
"效率": 4
}
就會變成:
日常:-2
放鬆:3
效率:4
每一題都會持續累加。
這就是我們今天真正建立起來的「計分大腦」。
接著把昨天的動態渲染邏輯拿回來。
function loadQuestion(index) {
const qData = quizData[index];
const optionsContainer =
document.getElementById("options-container");
const drawResult =
document.getElementById("draw-result");
// 更新題目進度
document.getElementById("progress-text")
.innerText =
`Question ${index + 1} of ${quizData.length}`;
// 更新題目文字
document.getElementById("question-text")
.innerText =
qData.question;
// 清空上一題
optionsContainer.innerHTML = "";
drawResult.innerText = "";
// 判斷題型
if (qData.type === "normal") {
qData.options.forEach((opt) => {
const btn =
document.createElement("button");
btn.className = "option-btn";
btn.innerText = opt.text;
btn.onclick = () => {
handleSelectOption(opt.scores);
};
optionsContainer.appendChild(btn);
});
}
else if (qData.type === "draw") {
const drawBtn =
document.createElement("button");
drawBtn.className = "draw-btn";
drawBtn.innerText =
"🎲 搖動籤筒";
drawBtn.onclick = () => {
handleDraw(qData.options);
};
optionsContainer.appendChild(drawBtn);
}
}
這裡最重要的變化是:
昨天我們點擊答案之後,只會:
跳下一題
今天則會變成:
點擊答案
↓
取得 scores
↓
加入 userScores
↓
跳下一題
昨天的求籤機制也不能漏掉。
因此 handleDraw() 需要把抽到的籤傳給計分函式。
function handleDraw(options) {
const drawResult =
document.getElementById("draw-result");
// 隨機抽出一個選項
const randomOption =
options[
Math.floor(
Math.random() * options.length
)
];
// 顯示抽籤結果
drawResult.innerText =
`鏘鏘!你抽到了:\n${randomOption.text}`;
// 稍微停頓,再進入下一題
setTimeout(() => {
handleSelectOption(
randomOption.scores
);
}, 2000);
}
這樣就算使用者沒有自己選 A、B、C,而是交給命運決定,抽到的結果一樣會正確進入計分系統。
接下來修改昨天的 handleSelectOption()。
function handleSelectOption(scores) {
// 先累加本題分數
addScores(scores);
// 前往下一題
if (
currentQuestionIndex
< quizData.length - 1
) {
currentQuestionIndex++;
loadQuestion(
currentQuestionIndex
);
}
else {
// 所有題目完成
showResult();
}
}
這裡就是今天最重要的串接。
完整流程正式變成:
使用者選擇答案
↓
取得 scores
↓
addScores()
↓
userScores 更新
↓
進入下一題
如果是求籤:
搖籤筒
↓
Math.random()
↓
抽出選項
↓
取得 scores
↓
addScores()
↓
進入下一題
所以不管是「自己選」還是「命運幫你選」,最後都會進入同一套計分系統。
最後建立 showResult():
function showResult() {
document
.getElementById("question-area")
.classList.add("hidden");
document
.getElementById("result-area")
.classList.remove("hidden");
const resultContainer =
document.getElementById("result-container");
resultContainer.innerHTML = "";
for (let dimension in userScores) {
const resultItem =
document.createElement("div");
resultItem.className =
"result-item";
resultItem.innerHTML = `
<span>${dimension}</span>
<strong>
${userScores[dimension]} 分
</strong>
`;
resultContainer.appendChild(
resultItem
);
}
}
最後就會得到類似這樣的結果:
🎉 診斷完成!
日常 -1 分
效率 7 分
社交 5 分
心態 -2 分
放鬆 12 分
職涯 4 分
這次的數字終於不是假的了。
而是使用者剛剛一路做出的選擇真正累積出來的結果。
為了讓使用者可以重新挑戰一次,最後加入:
function restartTest() {
// 回到第一題
currentQuestionIndex = 0;
// 所有分數歸零
userScores = {
"日常": 0,
"效率": 0,
"社交": 0,
"心態": 0,
"放鬆": 0,
"職涯": 0
};
// 顯示題目
document
.getElementById("question-area")
.classList.remove("hidden");
// 隱藏結果
document
.getElementById("result-area")
.classList.add("hidden");
// 載入第一題
loadQuestion(
currentQuestionIndex
);
}
最後,在 JavaScript 最下面讓第一題自動載入:
window.onload = () => {
loadQuestion(
currentQuestionIndex
);
};
這樣整個測驗流程就完成了。
完整 JavaScript
如果把今天的內容整合起來,完整的 JavaScript 大致會是:
const quizData = [
{
type: "normal",
question:
"早上鬧鐘響了,但你昨天熬夜趕專題,現在的你會:",
options: [
{
text:
"A. 直接按掉,催眠自己教授今天會請假",
scores: {
"日常": -2,
"放鬆": 3
}
},
{
text:
"B. 痛苦地爬起來,靠三杯黑咖啡續命",
scores: {
"效率": 3,
"心態": -2
}
},
{
text:
"C. 傳訊息跟同學求救「幫點名」",
scores: {
"社交": 2,
"心態": 1
}
}
]
},
{
type: "draw",
question:
"來到廟宇,面對未知的死線,請誠心搖動籤筒抽出今日運勢:",
options: [
{
text:
"【大吉】教授突然宣布延期交件!",
scores: {
"放鬆": 5
}
},
{
text:
"【中吉】剛好找到神級開源程式碼,無痛套用。",
scores: {
"效率": 4
}
},
{
text:
"【大凶】電腦無預警死機,而且你沒存檔。",
scores: {
"心態": -5
}
}
]
},
{
type: "normal",
question:
"突然多出一個小時空檔,你會怎麼做?",
options: [
{
text:
"A. 趕快把今天的待辦事項處理掉",
scores: {
"效率": 3,
"日常": 1
}
},
{
text:
"B. 躺著滑手機,享受難得的空檔",
scores: {
"放鬆": 3
}
},
{
text:
"C. 找朋友聊天",
scores: {
"社交": 3,
"心態": 1
}
}
]
},
{
type: "normal",
question:
"遇到完全不會的問題時,你通常會?",
options: [
{
text:
"A. 自己研究到搞懂",
scores: {
"效率": 2,
"職涯": 3
}
},
{
text:
"B. 先放著,等心情好一點再處理",
scores: {
"放鬆": 2,
"心態": 2
}
},
{
text:
"C. 找朋友或同學一起討論",
scores: {
"社交": 2,
"職涯": 1
}
}
]
},
{
type: "draw",
question:
"最後一關,請再次把命運交給賽博籤筒!",
options: [
{
text:
"【神籤】突然獲得神秘力量,今天效率爆表!",
scores: {
"效率": 5,
"職涯": 2
}
},
{
text:
"【平籤】今天適合好好休息,明天再戰。",
scores: {
"放鬆": 4,
"心態": 2
}
},
{
text:
"【凶籤】打開電腦發現昨天的程式全部消失。",
scores: {
"心態": -5,
"日常": -2
}
}
]
}
];
let userScores = {
"日常": 0,
"效率": 0,
"社交": 0,
"心態": 0,
"放鬆": 0,
"職涯": 0
};
let currentQuestionIndex = 0;
function addScores(scores) {
for (let dimension in scores) {
userScores[dimension] += scores[dimension];
}
}
function loadQuestion(index) {
const qData = quizData[index];
const optionsContainer =
document.getElementById("options-container");
const drawResult =
document.getElementById("draw-result");
document.getElementById("progress-text")
.innerText =
`Question ${index + 1} of ${quizData.length}`;
document.getElementById("question-text")
.innerText =
qData.question;
optionsContainer.innerHTML = "";
drawResult.innerText = "";
if (qData.type === "normal") {
qData.options.forEach((opt) => {
const btn =
document.createElement("button");
btn.className =
"option-btn";
btn.innerText =
opt.text;
btn.onclick = () => {
handleSelectOption(
opt.scores
);
};
optionsContainer.appendChild(
btn
);
});
}
else if (qData.type === "draw") {
const drawBtn =
document.createElement("button");
drawBtn.className =
"draw-btn";
drawBtn.innerText =
"🎲 搖動籤筒";
drawBtn.onclick = () => {
handleDraw(
qData.options
);
};
optionsContainer.appendChild(
drawBtn
);
}
}
function handleDraw(options) {
const drawResult =
document.getElementById("draw-result");
const randomOption =
options[
Math.floor(
Math.random() *
options.length
)
];
drawResult.innerText =
`鏘鏘!你抽到了:\n${randomOption.text}`;
setTimeout(() => {
handleSelectOption(
randomOption.scores
);
}, 2000);
}
function handleSelectOption(scores) {
addScores(scores);
if (
currentQuestionIndex
< quizData.length - 1
) {
currentQuestionIndex++;
loadQuestion(
currentQuestionIndex
);
}
else {
showResult();
}
}
function showResult() {
document
.getElementById("question-area")
.classList.add("hidden");
document
.getElementById("result-area")
.classList.remove("hidden");
const resultContainer =
document.getElementById(
"result-container"
);
resultContainer.innerHTML = "";
for (let dimension in userScores) {
const resultItem =
document.createElement("div");
resultItem.className =
"result-item";
resultItem.innerHTML = `
<span>${dimension}</span>
<strong>
${userScores[dimension]} 分
</strong>
`;
resultContainer.appendChild(
resultItem
);
}
}
function restartTest() {
currentQuestionIndex = 0;
userScores = {
"日常": 0,
"效率": 0,
"社交": 0,
"心態": 0,
"放鬆": 0,
"職涯": 0
};
document
.getElementById("question-area")
.classList.remove("hidden");
document
.getElementById("result-area")
.classList.add("hidden");
loadQuestion(
currentQuestionIndex
);
}
window.onload = () => {
loadQuestion(
currentQuestionIndex
);
};
開發心得與設計考量
在寫這段計分邏輯的時候,我一直在思考一件事情:
到底要怎麼讓這個系統未來比較好擴充?
如果只是用 score1、score2、score3 這種方式來管理,剛開始可能覺得很方便,但當題目越來越多、心理維度越來越多之後,維護起來絕對會開始崩潰。
因此這次改用:
userScores[dimension] += scores[dimension];
這種以「維度名稱」作為 Key 的方式。
最大的好處就是彈性非常高。
例如現在只有:
日常
效率
社交
心態
放鬆
職涯
但未來如果想增加:
健康
創意
財務
人際
只需要在 userScores 裡增加新的維度,原本的 addScores() 根本不用修改。
這種「資料決定程式怎麼運作」的設計,也讓整個專題比把所有東西寫死在 HTML 裡更加容易維護。
而且昨天埋下的 scores 終於正式派上用場了。
現在每一個選項背後都可以偷偷藏著不同的權重:
scores: {
"效率": 3,
"心態": -2
}
玩家表面上只是在回答一個很鬧的問題,但程式的另一邊其實正在默默幫他累積六大維度的分數。
最有趣的是,就連「求籤」也不例外。
使用者以為自己把人生交給命運,實際上 JavaScript 正在:
Math.random()
↓
抽出結果
↓
取得 scores
↓
累加到 userScores
所以某種程度上來說——
命運是假的,JavaScript 的 Math.random() 才是真的。
今日結語與明日預告
今天,我們終於讓這個心理測驗真正擁有了「記憶」!
昨天使用者點擊選項之後,程式只知道:
「好,你答完了,下一題。」
今天則變成:
「好,你答完了,而且我知道你剛剛為哪個維度加了幾分。」
從原本單純的:
題目
↓
選答案
↓
下一題
正式進化成:
題目
↓
選擇 / 求籤
↓
取得 scores
↓
累加 userScores
↓
下一題
↓
全部完成
↓
產生六大維度結果
而這些累積下來的分數,就是下一個功能最重要的原料。
因為光把:
日常:5
效率:8
社交:3
心態:6
放鬆:9
職涯:4
一行一行印在畫面上,好像還是有點太普通了。
既然都已經進入「紓壓診所」了,當然要讓它看起來更炫一點。
下一篇 Day 12:Canvas 畫布實戰:從零刻出專屬的六角形能力雷達圖,我們就要正式挑戰 HTML5 Canvas!
把今天辛苦累積出來的六大維度分數,轉換成座標、角度與多邊形,最後親手畫出一張真正屬於使用者的六角雷達圖。
從今天的「有數據」,進化成明天的「看得見的數據」。
我們明天見!