iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 9

Day 9 — 讓人想回來:遊戲化與互動元件

  • 分享至 

  • xImage
  •  

今天要解的問題

網站能用了,但沒有理由每天回來。純進度條只回答「我到哪了」,不回答「為什麼要再多讀一課」。

今天加三個東西:遊戲化層(XP/等級/連續天數/徽章)、互動元件(猜一下再揭曉、小遊戲),還有一個非做不可的資安修補——因為遊戲化的狀態存在 localStorage,而使用者可以隨意編輯它。

挑槓桿:八角框架的三個便宜選項

Octalysis(八角行為分析框架)列了八種核心驅動力。全做是不可能的,我挑投報率最高的三個:

驅動力 實作 成本
成就感 XP + 等級 低(一個計數器)
擁有感/逃避損失 連續學習天數 streak 低(存最後學習日)
即時回饋 慶祝 toast + 徽章 低(CSS 動畫)

刻意不做的:排行榜(沒有多人)、稀缺性倒數(會製造焦慮,跟學習網站的調性不合)、社交壓力(沒有社交)。

狀態分離:一個關鍵決定

const Gamify = {
  KEY: "learnpath-gamify-v1",     // ← 與學習進度不同的 key
  XP_LESSON: 20,
  XP_QUIZ: 10,

學習進度在 statmaster-progress-v1,遊戲化在 learnpath-gamify-v1兩個獨立的 key。

為什麼不合併?因為它們的生命週期不同

  • 學習進度是事實(我讀過這課),要永久保存、絕不能弄丟。
  • XP 與徽章是激勵機制,規則可能會調(XP 給多少、等級曲線),甚至可能想重置。

分開存的直接好處:「重置遊戲化」是一個安全操作,不會碰到學習進度。如果混在一起,任何調整規則的想法都會變成「可能弄壞使用者進度」的高風險改動。

XP、等級與 streak

  /* 等級曲線:門檻二次成長,前期升級快、後期變慢 */
  levelOf(xp) { return Math.floor(Math.sqrt(xp / 60)) + 1; },
  levelFloor(lvl) { return Math.ceil(Math.pow(lvl - 1, 2) * 60); },

  today() { return new Date().toISOString().slice(0, 10); },

  touchStreak(s) {
    const d = this.today();
    if (s.lastDay === d) return;                                  // 今天已算過
    const yesterday = new Date(Date.now() - 864e5).toISOString().slice(0, 10);
    s.streakDays = (s.lastDay === yesterday) ? s.streakDays + 1 : 1;
    s.lastDay = d;
  },

levelOf 用平方根:Lv.2 需要 60 XP,Lv.3 需要 240 XP,Lv.4 需要 540 XP。前幾級升得快(新手立刻有回饋),後面自然變慢。線性曲線會讓中期覺得升級不值錢,指數曲線會讓後期絕望。

streak 的邏輯只有三行,但涵蓋三種情況:今天已記過(不重複)、昨天有讀(+1)、中斷了(歸 1)。

864e5 是一天的毫秒數(86400000)。用 toISOString().slice(0,10)YYYY-MM-DD是 UTC 日期——這在跨時區時會有誤差(台灣早上 8 點前算前一天)。我接受這個誤差:streak 是激勵機制不是計費系統,差一天不影響學習。要精準就得處理時區,複雜度不值得。

徽章:測試函式即規則

  BADGES: [
    { id: "first",   icon: "🌱", label: "啟程",     test: p => p >= 1 },
    { id: "ten",     icon: "📚", label: "十課達成", test: p => p >= 10 },
    { id: "streak3", icon: "🔥", label: "三日連續", test: (p, s) => s.streakDays >= 3 },
    { id: "half",    icon: "🚀", label: "半程英雄", test: p => p >= 128 },
    { id: "scholar", icon: "🎓", label: "博學者",   test: p => p >= 253 },
  ],

  checkBadges(s) {
    const done = (typeof Progress !== "undefined" && Progress.doneCount) ? Progress.doneCount() : 0;
    const newly = [];
    this.BADGES.forEach(b => {
      if (!s.badges[b.id] && b.test(done, s)) { s.badges[b.id] = this.today(); newly.push(b); }
    });
    return newly;
  },

徽章條件是函式而不是設定值,所以「連續三天」與「完成十課」可以用同一套機制表達。新增徽章只要加一列。

s.badges[b.id] = this.today() 存的是取得日期而不是 true——資料量一樣,但之後做學習儀表板(Day 17)時就有時間軸可用。能順手存的資訊就存,別存 boolean。

給分:同一題只能拿一次

  onLessonComplete(key) {
    const s = this.load();
    if (key) { if (s.lessons[key]) return; s.lessons[key] = true; }   // 重複完成不重複給分
    this.touchStreak(s);
    s.xp += this.XP_LESSON;
    const badges = this.checkBadges(s);
    this.save(s);
    this.toast(`+${this.XP_LESSON} XP · 完成一課!`, "✅");
    badges.forEach(b => this.toast(`解鎖徽章:${b.label}`, b.icon));
    this.renderHUD();
  },

  onQuizCorrect(key) {
    const s = this.load();
    if (s.quizzes[key]) return;        // 同題只給一次,避免重整刷分
    s.quizzes[key] = true;
    …
  },

s.lessonss.quizzes去重集合,不是計數器。使用者重讀一課、重整頁面再答一次,XP 不會增加。這種防刷分在單機網站上看起來多餘(反正只騙自己),但一個能被輕易刷爆的計分板會立刻失去意義——激勵機制的價值來自它不能作假

互動元件:把好奇心做成 UI

課文裡放一個佔位 div,邏輯寫在 View:

<div class="reveal">
  <button class="reveal-q">🤔 猜一下:樣本數變兩倍,信賴區間會縮小多少?(點我揭曉)</button>
  <div class="reveal-a">縮小到約 \(1/\sqrt{2} \approx 71\%\),不是一半——因為標準誤是 \(\sigma/\sqrt{n}\)。</div>
</div>

<div class="minigame" data-game="noisy-or"></div>
const Interactive = {
  activate(root) {
    if (!root) return;
    root.querySelectorAll(".reveal").forEach(el => this.bindReveal(el));
    root.querySelectorAll("[data-game]").forEach(el => {
      const g = this.games[el.dataset.game];
      if (g && !el.dataset.ready) { el.dataset.ready = "1"; try { g(el); } catch (e) {} }
    });
  },

  bindReveal(el) {
    if (el.dataset.ready) return;         // 幂等:重複 activate 不會綁兩次
    el.dataset.ready = "1";
    const q = el.querySelector(".reveal-q");
    if (q) q.addEventListener("click", () => el.classList.toggle("open"));
  },

  games: { /* 每個遊戲一個 (el) => void */ },
};

三個設計點:

  1. 課文只放 data-game="名稱",邏輯註冊在 Interactive.games 課文保持是純內容(不含 script),Day 12 建搜尋索引、Day 15 抽名詞表時才能安全地掃它。
  2. dataset.ready 保證幂等。 Day 6 的換課會重複呼叫 activate(),沒有這個守衛就會綁到重複的事件。
  3. try/catch 包住遊戲初始化。 一個小遊戲寫壞不該讓整課讀不了。附加功能一律用防禦式接入。

.reveal 這個元件成本極低(一個 toggle class),但學習效果意外地好:先猜再看答案,會讓錯誤的直覺被明確地打破,比直接讀正確答案記得牢。

小遊戲的實作以 noisy-OR 機率為例(拉三個滑桿,即時算 π = 1 − ∏(1−pⱼ)):

"noisy-or": function (el) {
  el.innerHTML = /* 三個 range input + 一個結果條 */;
  const recompute = () => {
    let prod = 1;
    el.querySelectorAll("input[type=range]").forEach(r => { prod *= (1 - parseFloat(r.value)); });
    const pi = 1 - prod;
    el.querySelector(".mg-pi").textContent = pi.toFixed(3);
    el.querySelector(".mg-fill").style.width = (pi * 100).toFixed(1) + "%";
  };
  el.querySelectorAll("input[type=range]").forEach(r => r.addEventListener("input", recompute));
  recompute();
}

重點不是這個公式,是互動比文字更能傳達「至少一個為正」的直覺:把任一滑桿拉到 1,π 立刻變 1。這句話寫十遍都不如拉一次滑桿。(Day 18 會把這個模式擴大到 canvas 動畫。)


資安:localStorage 是使用者可控輸入

HUD 是這樣畫的:

hud.innerHTML =
  `<span class="lvl"><b>Lv.${lvl}</b> ${s.xp} XP</span>` +
  `<span class="xpbar"><i style="width:${pct}%"></i></span>` +
  (s.streakDays > 0 ? `<span class="streak">🔥 ${s.streakDays}</span>` : "");

s.xp 從 localStorage 讀來,直接進 innerHTMLlocalStorage 是使用者完全可控的

// 攻擊者(或好奇的使用者)在 console 執行
localStorage.setItem("learnpath-gamify-v1",
  JSON.stringify({ xp: '<img src=x onerror="alert(document.cookie)">' }));

重整之後,那段 HTML 就會被 innerHTML 執行。

「自己騙自己有什麼關係?」——有兩個真實情境有關係:

  1. 共用電腦:前一個人留下的 payload 會在下一個人的瀏覽器執行。
  2. 社交工程:「在 console 貼這段就能解鎖全部徽章」這種貼文,是實際存在的攻擊模式。

而且第 27 天我要做雲端同步,那時候 localStorage 的內容會被送到後端、再同步到其他裝置——同一個洞的破壞範圍會從「自己」變成「所有裝置」。現在修最便宜。

解法:在讀取邊界淨化

  load() {
    let raw = {};
    try { raw = JSON.parse(localStorage.getItem(this.KEY)) || {}; } catch { raw = {}; }
    const obj = v => (v && typeof v === "object" && !Array.isArray(v)) ? v : {};
    const num = v => Number.isFinite(+v) ? +v : 0;
    return {
      xp:         Math.max(0, num(raw.xp)),
      streakDays: Math.max(0, Math.floor(num(raw.streakDays))),
      lastDay:    (typeof raw.lastDay === "string" && /^\d{4}-\d{2}-\d{2}$/.test(raw.lastDay))
                    ? raw.lastDay : null,
      quizzes: obj(raw.quizzes), lessons: obj(raw.lessons), badges: obj(raw.badges),
    };
  },

原則:在信任邊界上做型別強制轉換,而不是在每個使用點記得跳脫。

  • 數字欄位 → num() 強制轉數字,非數字變 0。字串 payload 進來會變成 0,不可能是 HTML。
  • 日期 → 用 regex 驗格式,不合就 null
  • 物件欄位 → 確認真的是物件(Array.isArray 要排除,因為陣列的 typeof 也是 "object")。

load()唯一的讀取入口,所有 Gamify 方法都經過它。一個地方守住,全部安全。這比「每個 innerHTML 都記得 escape」可靠得多——後者總會有一處忘記。

掛鉤點

// lesson-view.js:課文渲染後
if (window.Interactive) Interactive.activate(body);
// 測驗答對時
if (String(i) === correct && window.Gamify) Gamify.onQuizCorrect(key);

// chapter.js:完成本課時
Progress.setDone(chId, lessonId, true);
if (window.Gamify) Gamify.onLessonComplete(lessonKey(chId, lessonId));

全部用 if (window.Xxx) 包起來——這兩個檔案缺席時,網站必須照常運作。附加功能不能變成必要依賴。

一個容易忽略的契約:新元件的 class 要避開 quizquiz-opt。Day 8 的驗證器會數這兩個 class 的出現次數,如果小遊戲的 HTML 裡不小心有個 class="quiz-opt",就會打壞「每課恰一題」的檢查。

驗證

node --check js/views/gamify.js
node --check js/views/interactive.js
python3 -m http.server 8901

功能:完成一課 → toast 跳出 + HUD 的 XP 上升;答對測驗 → +10 XP;重整後再答同一題 → XP 不變;連兩天使用 → 🔥 出現 2。

資安(一定要實測):

localStorage.setItem("learnpath-gamify-v1", JSON.stringify({xp:'<img src=x onerror=alert(1)>'}));
location.reload();
// 預期:HUD 顯示「0 XP」,沒有 alert

小結與明天預告

今天三件事:

  1. 狀態分離:遊戲化與學習進度用不同 key,讓「調整規則」變成低風險操作。
  2. 附加功能防禦式接入if (window.Xxx)dataset.ready 幂等、try/catch 隔離。
  3. 在讀取邊界淨化 localStorage:一個 load() 守住整個模組,勝過在每個使用點記得跳脫。

明天是第一階段的收尾,也是我認為整個系列最重要的一篇:雙層驗證。第一層零依賴驗結構,第二層真的開瀏覽器驗渲染——因為 Day 7 那兩個 bug 只有第二層抓得到。順便把 CSP、假分鐘數與 git 一起收乾淨。


上一篇
Day 8 — 隨堂測驗元件 + 課文寫作規格
系列文
知識圖譜 : 技能樹式學習歷程9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言