Người chơi tắt trình duyệt, một tuần sau mở lại, và mong thấy đủ xu, đủ sao, đúng âm lượng đã chỉnh. Trong lúc đó game có thể đã lên phiên bản mới với nhiều field mới. Bài này giải thích cách
OG.Savegiữ dữ liệu an toàn qua mọi thay đổi đó.
🎯 Mục tiêu #
- Dùng
localStorageđúng cách: luôn bọctry/catch, lưu dạng JSON, biết khi nào nó không dùng được. - Hiểu kỹ thuật giá trị mặc định + deep merge (gộp sâu) để nâng cấp save cũ mà không viết code riêng cho từng field.
- Hiểu migrate (chuyển đổi dữ liệu từ định dạng cũ sang mới) và khi nào thực sự cần nó.
- Dùng namespace dữ liệu (
OG.Save.ns) để mỗi module có "ngăn" riêng trong save. - Viết code chịu được dữ liệu hỏng: JSON sai cú pháp, field thiếu, field sai kiểu.
Đọc trước: Bài 06 — Event bus, vì Save phát 'coins:change', 'settings:change' và 'save:reset'.
🧠 Khái niệm #
1. localStorage: tiện nhưng không đáng tin tuyệt đối #
localStorage lưu cặp chuỗi → chuỗi theo từng origin (với file://, mỗi trình duyệt xử lý một kiểu). Nó có thể:
- Bị chặn (chế độ riêng tư, chính sách bảo mật,
file://trên vài trình duyệt): mọi lệnh truy cập đều có thể ném lỗi. - Đầy (thường khoảng 5 MB mỗi origin):
setItemnémQuotaExceededError. - Bị sửa tay trong DevTools, hoặc chứa dữ liệu từ phiên bản game cũ.
Vì vậy mọi truy cập phải được bọc lại. Nếu không dùng được thì game vẫn chạy, chỉ là dữ liệu sống trong bộ nhớ.
2. Mặc định + deep merge #
Thay vì kiểm tra từng field ("save có tapJump chưa? chưa thì gán 'auto'"), ta làm ngược lại:
- Tạo một bản mặc định đầy đủ của phiên bản hiện tại.
- Gộp sâu dữ liệu đã lưu đè lên: field nào save có thì lấy của save, field nào save thiếu (field mới) thì giữ giá trị mặc định.
flowchart LR
D["defaults() v hiện tại<br/>{coins:0, settings:{musicVolume:0.6, tapJump:'auto', ...}}"] --> M(("merge"))
S["save cũ trong localStorage<br/>{coins:120, settings:{musicVolume:0.3}}"] --> M
M --> R["kết quả<br/>{coins:120, settings:{musicVolume:0.3, tapJump:'auto', ...}}"]Với cách này, thêm một field mới chỉ cần sửa defaults().
3. Khi nào cần migrate thật sự? #
Merge chỉ xử lý được việc thêm field. Các thay đổi sau thì cần code migrate riêng:
| Thay đổi định dạng | Merge có tự lo được không? |
|---|---|
| Thêm field mới | ✅ có |
Đổi tên field (best → bestScore) |
❌ không, field cũ nằm "mồ côi" còn field mới nhận giá trị mặc định |
| Đổi kiểu (số → object), đổi ý nghĩa giá trị | ❌ không |
Chuyển dữ liệu sang key localStorage khác |
❌ không |
Cách làm chuẩn là lưu số phiên bản (v) trong save và viết các bước migrate nối tiếp: v1 → v2 → v3. Mỗi bước chỉ cần biết hai phiên bản liền nhau.
4. Namespace dữ liệu #
Nếu mọi module cùng ghi vào gốc Save.data, sớm muộn sẽ có hai module trùng tên field. OG.Save.ns('quests') trả về Save.data.ext.quests, một "ngăn" riêng của module Quests. Thêm module mới thì không phải sửa defaults().
5. Thứ tự khởi động #
Save.load() thay OG.Save.data bằng một object mới. Mọi module đọc hoặc ghi save phải làm việc sau lúc đó:
sequenceDiagram participant H as index.html (LOAD) participant M as main.js boot() participant S as OG.Save participant P as OG.Progress H->>S: save.js chạy: data = defaults() (TẠM) Note over H: KHÔNG module nào được đọc/ghi Save.data lúc load M->>S: load(): đọc localStorage, merge, migrate → data MỚI M->>P: Progress.init() (đọc tiến độ thật) M->>M: init các module tính năng
🔍 Đọc code #
File chính: src/core/save.js. Ví dụ dùng: src/game/progress.js, src/world/quests.js, src/story/learn.js, src/social/leaderboard.js.
4.1 Khoá lưu và key cũ #
var KEY = 'oggyAdventure.save.v1';
var LEGACY_BEST_KEY = 'oggyBest'; // key của bản MVP (chỉ lưu một con số kỷ lục)
var PROBE_KEY = 'oggyAdventure.probe'; // key tạm để thử localStorage có ghi được khôngTên key có hậu tố .v1. Nếu một ngày định dạng đổi quá nhiều, ta có thể chuyển sang key .v2 và giữ nguyên key cũ làm bản sao lưu.
4.2 defaults(): một hàm, không phải một hằng #
function defaults() {
return {
v: 1,
coins: 0,⚠️ Bẫy: nếu
defaultslà một object hằng dùng chung, thì saureset()mọi chỉnh sửa trênSave.datacũng sửa luôn "bản mặc định", và lần reset sau sẽ không còn sạch. Hàm tạo một object mới mỗi lần gọi.
4.3 merge(): gộp sâu, nhưng mảng được thay nguyên #
function merge(base, saved) {
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return base;
for (var k in saved) {
if (!Object.prototype.hasOwnProperty.call(saved, k)) continue;
var b = base[k], s = saved[k];
if (b && typeof b === 'object' && !Array.isArray(b) && s && typeof s === 'object' && !Array.isArray(s)) {
base[k] = merge(b, s);
} else {
base[k] = s;
}
}
return base;
}- Chỉ đệ quy khi cả hai bên đều là object thường. Nhờ vậy, nếu save hỏng có
settings: 5thì kết quả làsettings: 5(sai kiểu); các module đọc settings đều kiểm tra kiểu (xemsettings()trong audio.js). - Mảng được thay nguyên. Nếu gộp
unlockedCharstheo chỉ số,['oggy'](mặc định) gộp với['jack', 'olivia'](đã lưu) sẽ ra kết quả sai ngay khi độ dài hai mảng khác nhau. hasOwnPropertyđể không chép thuộc tính kế thừa từ prototype.
4.4 load(): ba lớp bảo vệ #
load: function () {
// Thử ghi một key tạm: cách chắc chắn nhất để biết localStorage có dùng được không.
Save.available = storageSet(PROBE_KEY, '1');
storageRemove(PROBE_KEY);
var d = defaults();
var raw = storageGet(KEY);
if (raw) {
// 💡 Khái niệm: bảo vệ dữ liệu hỏng — JSON sửa tay sai cú pháp thì JSON.parse ném lỗi; ta bắt lại và
// dùng mặc định thay vì để game trắng màn hình ngay lúc khởi động.
try { d = merge(d, JSON.parse(raw)); } catch (e) { console.warn('[OG.Save] dữ liệu hỏng, dùng mặc định'); }
}
// 💡 Khái niệm: migrate — bản MVP chỉ lưu kỷ lục ở key 'oggyBest'. Đọc nó và giữ số lớn hơn,
// nên người chơi bản cũ không mất kỷ lục khi lên bản mới.
var legacy = +(storageGet(LEGACY_BEST_KEY) || 0);
if (legacy > d.bestScore) d.bestScore = legacy;
Save.data = d;
return d;
},- Probe (thăm dò): thử ghi một key tạm để biết
localStoragecó dùng được không. - try/catch quanh
JSON.parse: chuỗi hỏng thì dùng bản mặc định. - Migrate từ bản MVP: đọc key cũ
oggyBestvà giữ số lớn hơn. Đây là một migrate thật, chỉ một dòng, vì dữ liệu cũ rất đơn giản.
Các hàm storageGet, storageSet, storageRemove bọc try/catch ở mức thấp nhất, nên mọi chỗ khác dùng chúng mà không phải lo bị ném lỗi.
4.5 commit(): ghi xuống, kèm key cũ #
commit: function () {
var ok = false;
try { ok = storageSet(KEY, JSON.stringify(Save.data)); } catch (e) { ok = false; }
// Vẫn ghi key cũ để bản MVP (nếu người chơi mở lại) thấy kỷ lục mới.
if (Save.data.bestScore) storageSet(LEGACY_BEST_KEY, String(Save.data.bestScore));
return ok;
},Mỗi commit chạy JSON.stringify cho toàn bộ save. Rẻ nếu chỉ làm thỉnh thoảng (xong màn, mua đồ), nhưng phí nếu làm mỗi frame hay mỗi cú nhảy. Bài tập ⭐⭐ sẽ cho bạn thực hành chọn thời điểm commit.
4.6 ns(): namespace cho module #
ns: function (name) {
if (!Save.data.ext[name] || typeof Save.data.ext[name] !== 'object') Save.data.ext[name] = {};
return Save.data.ext[name];
},Module dùng ns rồi tự chữa field thiếu. Ví dụ hàm store() trong src/world/quests.js:
function store() {
var s = OG.Save.ns('quests');
if (!s.levels || typeof s.levels !== 'object') s.levels = {};
if (!s.global || typeof s.global !== 'object') s.global = {};
if (!s.daily || typeof s.daily !== 'object') s.daily = { date: '', ids: [], prog: {} };
if (!Array.isArray(s.daily.ids)) s.daily.ids = [];
if (!s.daily.prog || typeof s.daily.prog !== 'object') s.daily.prog = {};
return s;
}Hàm này được gọi mỗi lần cần, không lưu thành biến dài hạn. Lý do: Save.reset() thay cả Save.data, nên một tham chiếu cũ sẽ trỏ vào object không còn được lưu.
Cùng file có một migrate một lần bằng cờ: seedFromStats lấy giá trị ban đầu cho thành tựu từ Save.data.stats (dữ liệu có trước khi module Quests ra đời), và cờ seeded đảm bảo việc đó chỉ chạy một lần.
function seedFromStats() {
var s = store();
if (s.seeded) return;
s.seeded = true;4.7 "Đừng tin dữ liệu bên ngoài": sanitize của bảng xếp hạng #
src/social/leaderboard.js chuẩn hoá mọi entry (dữ liệu cục bộ lẫn dữ liệu từ máy chủ) qua sanitize: chuỗi bị cắt độ dài và bỏ ký tự điều khiển, số bị kẹp khoảng, id chỉ nhận ký tự an toàn. local.submitSync còn sanitize lại dữ liệu đã lưu mỗi lần đọc, nên save bị sửa tay vẫn tự chữa được:
// Sanitize lại dữ liệu đã lưu mỗi lần đọc → save cũ / bị sửa tay vẫn dùng được (tự chữa).
var res = insertSorted(ns.leaderboard.map(sanitize), entry, MAX_ENTRIES);4.8 Progress: commit trước, emit sau #
Trong complete của src/game/progress.js:
// ⚠️ Bẫy: commit TRƯỚC khi phát event — listener (vd Menus) có thể đọc lại Save ngay trong lúc nghe.
OG.Save.commit();
chars.forEach(function (c) { OG.Events.emit('char:unlock', { charId: c, reason: 'level' }); });maxStars() cũng đáng chú ý: tổng sao tối đa là dữ liệu dẫn xuất, được tính lại mỗi lần (levels().length * STARS_PER_LEVEL) chứ không lưu. Thêm màn mới thì không phải sửa save.
4.9 Thứ tự trong boot() #
OG.DOM.init();
OG.Save.load();
OG.Input.init(canvas);
OG.Progress.init();⚠️ Bẫy: đảo
Save.load()xuống dướiProgress.init()thì Progress làm việc trên dữ liệu mặc định rồiSave.commit()ghi đè lên bản lưu thật. Người chơi mất tiến độ thật sự.
🧪 Thử ngay #
Mở game, DevTools → Console.
Đọc dữ liệu:
jsOG.Save.available; // true nếu localStorage dùng được OG.Save.data; // toàn bộ save OG.Save.get('settings.musicVolume', 0.6); // đọc theo đường dẫn chấm OG.Save.get('khong.co.dau', 'mặc định'); // trả fallbackXem bản lưu thật: DevTools → Application → Local Storage → key
oggyAdventure.save.v1.Sao lưu trước khi phá (bắt buộc làm trước bước 3 và 4):
jslocalStorage.setItem('oggy.bak', localStorage.getItem('oggyAdventure.save.v1'));Save hỏng:
jslocalStorage.setItem('oggyAdventure.save.v1', '{hỏng'); location.reload();Console in
[OG.Save] dữ liệu hỏng, dùng mặc định, và game vẫn chạy với dữ liệu mới tinh.Save cũ thiếu field:
jslocalStorage.setItem('oggyAdventure.save.v1', JSON.stringify({ coins: 999, settings: { musicVolume: 0.1 } })); location.reload(); // sau khi tải lại: OG.Save.data.coins; // 999 (lấy từ save) OG.Save.data.settings.musicVolume; // 0.1 (lấy từ save) OG.Save.data.settings.tapJump; // 'auto' (save thiếu → merge lấy mặc định)Migrate từ bản MVP:
jslocalStorage.setItem('oggyBest', '123456'); location.reload(); // sau khi tải lại: OG.Save.data.bestScore; // 123456 nếu lớn hơn kỷ lục hiện cóKhôi phục bản sao lưu:
jslocalStorage.setItem('oggyAdventure.save.v1', localStorage.getItem('oggy.bak')); location.reload();Namespace:
jsvar my = OG.Save.ns('demo'); my.x = (my.x || 0) + 1; OG.Save.commit(); OG.Save.data.ext.demo; // { x: 1 }, tải lại trang vẫn cònDữ liệu dẫn xuất (chỉ đọc):
OG.Progress.totalStars() + '/' + OG.Progress.maxStars().
✍️ Bài tập #
⭐ Dễ — Thêm một cài đặt mới mà không phá save cũ #
Thêm cài đặt settings.showFps (mặc định false) vào defaults(). Chứng minh bằng console rằng:
- Người chơi có save cũ (không có
showFps) sau khi tải lại nhận đượcfalse. OG.Save.setSetting('showFps', true)rồi tải lại trang thì giá trị vẫn làtrue.- Event
'settings:change'được phát với đúng{key, value}. - Kiểm tra kiểu vẫn sạch:
src/core/save.jscó// @ts-checkở dòng 1, nên chạynpx -y -p [email protected] tsc -p jsconfig.json --noEmit(không in gì, mã thoát 0). Rồinode --check src/core/save.jsvànode tests/smoke.jsphải XANH.
💡 Gợi ý
- Chỉ cần sửa object
settingstrongdefaults(). Nhớ cập nhật@typedef SaveSettingsở đầu file: quên bước này thì tsc báo lỗi'showFps' does not exist in type 'SaveSettings'(bước 4 sẽ bắt được). - Nghe event trong console:
OG.Events.on('settings:change', function (e) { console.log(e); });. - Mô phỏng save cũ bằng bước 4 của "Thử ngay".
⭐⭐ Vừa — Module thống kê số lần nhảy #
Viết file mới src/game/jump-stats.js (nhớ thêm <script> vào index.html) tạo OG.JumpStats với:
total(): tổng số lần nhảy từ trước tới nay (lưu bền vững).fewest(levelId): số lần nhảy ít nhất khi qua màn đó (nullnếu chưa qua).
Yêu cầu: dữ liệu nằm trong OG.Save.ns('jumpStats'); không commit mỗi cú nhảy; tự chữa nếu save bị sửa tay thành dữ liệu sai kiểu; vẫn đúng sau khi người chơi bấm "Xoá dữ liệu" (Save.reset()).
💡 Gợi ý
- Event có sẵn:
'player:jump','level:start','level:complete'(payload cólevelId),'game:over'. - Đăng ký listener lúc load thì được (
OG.Eventsđã có), nhưng không gọiOG.Save.ns()lúc load. Hãy suy nghĩ: lúc đóSave.datalà object nào, vàSave.load()sẽ làm gì với nó? - Viết hàm
store()giốngquests.jsvà gọi nó mỗi lần cần. - Nên commit ở mốc quan trọng (xong màn, thua). Hãy cân nhắc đánh đổi: tắt tab giữa màn thì mất bao nhiêu dữ liệu?
- Đặt
<script>sausrc/game/progress.jslà hợp lý. Smoke test tự đọc danh sách script từindex.htmlnên sẽ chạy cả file mới.
⭐⭐⭐ Khó — Migrate có phiên bản #
Giả sử phiên bản tới của game (save v: 2) có hai quy tắc mới:
settings.difficultychỉ được là'kid'hoặc'challenge'; giá trị khác (save sửa tay, bản thử nghiệm) phải đổi thành'kid'.unlockedCharsvàprogress.unlockedkhông được có phần tử trùng.
Viết hệ thống migrate trong save.js: hằng SAVE_VERSION = 2, bảng MIGRATIONS (mỗi bước nâng v lên 1), hàm migrate(d) chạy nối tiếp các bước, gọi trong load(). Xử lý được: save v: 1, save rất cũ không có v, save đã là v: 2, và save từ phiên bản mới hơn (v: 3). Thêm một bản sao lưu chuỗi gốc trước khi migrate.
💡 Gợi ý
defaults()phải trảv: SAVE_VERSION, vì save mới tạo thì không cần migrate.- ⚠️
mergechépsaved.vđè lênvmặc định. Nhưng nếu save không cóvthì kết quả vẫn mangvmặc định (= 2) và bị bỏ qua migrate. Hãy đọcvtừ dữ liệu đã lưu, trước khi merge. - Save từ phiên bản mới hơn: đừng "hạ cấp" nó, chỉ
console.warnvà giữ nguyên. - Đừng để một bug trong migrate xoá sạch dữ liệu: bọc riêng bước migrate bằng
try/catch, và sao lưurawvào key khác (vdKEY + '.bak.v1') trước khi biến đổi. - Kiểm thử bằng console (bước 2 → 4 của "Thử ngay" với dữ liệu
v: 1códifficulty: 'easy'), hoặc bằng một script Node nhỏ nạpsave.jsvàovmvớimakeStorage()củatests/fake-dom.js.
Lời giải: solutions/11-luu-tru-migrate.md. Hãy tự làm trước khi xem.
Ghi chú: CODE_STYLE.md cấm đổi định dạng save khi refactor code của dự án. Bài tập ⭐⭐⭐ là bài luyện tập trên bản sao (fork/nhánh) của bạn. Trong dự án thật, đổi
SAVE_VERSIONlà một quyết định cần cả nhóm đồng ý và cập nhật tài liệu hợp đồng (docs/ARCHITECTURE.md mục 7, docs/STORY.md mục 8).
📚 Đọc thêm #
- MDN: Window.localStorage và Using the Web Storage API (có ví dụ kiểm tra storage dùng được bằng
try/catch) - MDN: Storage quotas and eviction criteria
- MDN: JSON.parse(), lỗi
SyntaxErrorkhi chuỗi hỏng - Martin Fowler: Evolutionary Database Design, ý tưởng migrate từng bước có đánh số phiên bản (áp dụng cho cả save game)
- Hợp đồng định dạng save: docs/ARCHITECTURE.md mục 7 (OG.Save) và docs/STORY.md mục 8
➡️ Bài tiếp theo #
Bài 12 — Data-driven: thêm màn, câu đố, hành trình mới: tiến độ của màn hành trình cũng nằm trong progress.levels, vì vậy id màn phải có tiền tố. Bài sau giải thích vì sao.