iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
自我挑戰組

賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁系列 第 11 篇

Day 11:維度計分機制:如何將零碎選項匯聚成數據

  • 分享至 

  • xImage
  •  

前言

昨天我們把 10 道題目的動態渲染框架與求運氣的「廟宇籤筒」機制搞定了。
看著畫面隨著使用者的點擊,一題一題往下切換,整個心理測驗的骨架已經相當完整。
不過,你有沒有發現少了一個最重要的靈魂?

那就是——

分數到底跑哪去了?

在前幾天的範例中,我們雖然已經在每個選項的資料結構裡偷偷塞入了像:

scores: {
    "日常": -2,
    "放鬆": 3
}

這樣的分數物件。

但問題是,使用者點下選項之後,程式其實只負責跳到下一題,這些分數根本沒有真正被記錄下來。

也就是說,不管使用者一路選了什麼,最後的結果都可能還是一片:

日常:0
效率:0
社交:0
心態:0
放鬆:0
職涯:0

這就像我們辛辛苦苦開了一間診所,病人填完病歷之後,櫃台卻跟你說:

「嗯……我們好像沒有記錄你的病歷。」

那當然不行。

所以今天 Day 11 的任務,就是替這個心理測驗裝上一顆真正的「計分大腦」。


核心觀念:全域計分物件與分數累加

這次我們需要統計的不是單一分數,而是六個不同的心理維度:

  1. 日常
  2. 效率
  3. 社交
  4. 心態
  5. 放鬆
  6. 職涯

如果每個維度都另外建立一個變數,例如:

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 真正接進測驗流程。
為了讓整個流程更容易理解,這次同樣做成一個可以獨立執行的小型示範。

裡面會包含:

  • 一般選擇題
  • 求籤題
  • 六大心理維度
  • 點擊後自動累加分數
  • 測驗結束後顯示最終分數
  • 重新測驗功能

1. CSS:建立題目與結果畫面的樣式

首先延續昨天的賽博紓壓診所風格,先把題目、選項與結果區域設計好。

* {
    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;
}

2. HTML:保留一個乾淨的畫面空殼

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 處理。


3. 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
                }
            }

        ]
    }

];

這裡就可以看到昨天埋下的伏筆真正派上用場了。

每一個選項都不只是文字。

它其實還偷偷攜帶了一組「分數資料」。


4. 建立玩家的計分板

接下來建立今天的主角:

let userScores = {

    "日常": 0,
    "效率": 0,
    "社交": 0,
    "心態": 0,
    "放鬆": 0,
    "職涯": 0

};

再建立目前題目索引:

let currentQuestionIndex = 0;

這樣我們就可以知道:

目前答到第幾題
+
目前六個維度各有幾分

5. 建立真正的「計分函式」

接下來就是整個 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

每一題都會持續累加。

這就是我們今天真正建立起來的「計分大腦」。


6. 動態渲染題目

接著把昨天的動態渲染邏輯拿回來。

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
↓
跳下一題

7. 求籤也要加入計分

昨天的求籤機制也不能漏掉。

因此 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,而是交給命運決定,抽到的結果一樣會正確進入計分系統。


8. 處理選項與分數

接下來修改昨天的 handleSelectOption()。

function handleSelectOption(scores) {

    // 先累加本題分數

    addScores(scores);


    // 前往下一題

    if (
        currentQuestionIndex
        < quizData.length - 1
    ) {

        currentQuestionIndex++;

        loadQuestion(
            currentQuestionIndex
        );

    }

    else {

        // 所有題目完成

        showResult();

    }

}

這裡就是今天最重要的串接。

完整流程正式變成:

使用者選擇答案
        ↓
取得 scores
        ↓
addScores()
        ↓
userScores 更新
        ↓
進入下一題

如果是求籤:

搖籤筒
  ↓
Math.random()
  ↓
抽出選項
  ↓
取得 scores
  ↓
addScores()
  ↓
進入下一題

所以不管是「自己選」還是「命運幫你選」,最後都會進入同一套計分系統。


9. 測驗結束後顯示六大維度

最後建立 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 分

這次的數字終於不是假的了。

而是使用者剛剛一路做出的選擇真正累積出來的結果。


10. 加入重新測驗功能

為了讓使用者可以重新挑戰一次,最後加入:

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!

把今天辛苦累積出來的六大維度分數,轉換成座標、角度與多邊形,最後親手畫出一張真正屬於使用者的六角雷達圖。

從今天的「有數據」,進化成明天的「看得見的數據」。

我們明天見!


上一篇
Day 10:心理測驗核心題庫與動態生成(feat. 廟宇求籤系統)
下一篇
Day 12:Canvas 畫布實戰:把六大維度真的畫出來
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言