網站能用了,但沒有理由每天回來。純進度條只回答「我到哪了」,不回答「為什麼要再多讀一課」。
今天加三個東西:遊戲化層(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。
為什麼不合併?因為它們的生命週期不同:
分開存的直接好處:「重置遊戲化」是一個安全操作,不會碰到學習進度。如果混在一起,任何調整規則的想法都會變成「可能弄壞使用者進度」的高風險改動。
/* 等級曲線:門檻二次成長,前期升級快、後期變慢 */
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.lessons 與 s.quizzes 是去重集合,不是計數器。使用者重讀一課、重整頁面再答一次,XP 不會增加。這種防刷分在單機網站上看起來多餘(反正只騙自己),但一個能被輕易刷爆的計分板會立刻失去意義——激勵機制的價值來自它不能作假。
課文裡放一個佔位 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 */ },
};
三個設計點:
data-game="名稱",邏輯註冊在 Interactive.games。 課文保持是純內容(不含 script),Day 12 建搜尋索引、Day 15 抽名詞表時才能安全地掃它。dataset.ready 保證幂等。 Day 6 的換課會重複呼叫 activate(),沒有這個守衛就會綁到重複的事件。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 動畫。)
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 讀來,直接進 innerHTML。localStorage 是使用者完全可控的:
// 攻擊者(或好奇的使用者)在 console 執行
localStorage.setItem("learnpath-gamify-v1",
JSON.stringify({ xp: '<img src=x onerror="alert(document.cookie)">' }));
重整之後,那段 HTML 就會被 innerHTML 執行。
「自己騙自己有什麼關係?」——有兩個真實情境有關係:
而且第 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。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 要避開 quiz 與 quiz-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
今天三件事:
if (window.Xxx)、dataset.ready 幂等、try/catch 隔離。load() 守住整個模組,勝過在每個使用點記得跳脫。明天是第一階段的收尾,也是我認為整個系列最重要的一篇:雙層驗證。第一層零依賴驗結構,第二層真的開瀏覽器驗渲染——因為 Day 7 那兩個 bug 只有第二層抓得到。順便把 CSP、假分鐘數與 git 一起收乾淨。