✋ Hãy tự làm trước. Lời giải chỉ là một cách làm. Nếu bài của bạn khác nhưng chạy đúng và giải thích được vì sao, bài đó vẫn đạt. Quay lại bài học: 12-data-driven-man-cau-do-hanh-trinh.md
Ba lời giải dưới đây áp dụng được cùng lúc. Tự kiểm tra sau mỗi bài: node tests/smoke.js phải XANH, npx -y -p [email protected] tsc -p jsconfig.json --noEmit không in gì, và trong Console OG.Journeys.validate('home') (cùng OG.Journeys.validate('ocean') sau bài ⭐⭐⭐) không có lỗi lẫn cảnh báo.
⭐ Thẻ k_teeth và câu đố q_teeth #
1. Nội dung trong src/journeys/home/content.js.
Thêm vào mảng cards, sau thẻ k_hot:
{ id: 'k_teeth', areaId: 'kitchen', title: 'Răng trắng tinh', icon: '🪥',
fact: 'Đánh răng hai lần mỗi ngày, buổi sáng và trước khi đi ngủ, giúp răng chắc khoẻ và không bị sâu.' },Thêm vào mảng quizzes, sau câu q_safe:
{ id: 'q_teeth', areaId: 'kitchen', cardId: 'k_teeth',
question: 'Mỗi ngày em nên đánh răng mấy lần?',
choices: [{ text: 'Một tuần một lần', icon: '📅' }, { text: 'Hai lần: sáng và tối', icon: '🪥' }],
answer: 1, hint: 'Gợi ý: buổi sáng thức dậy và buổi tối trước khi ngủ.',
explain: 'Đúng rồi! Đánh răng sáng và tối giúp răng luôn khoẻ.' },2. Đặt vào màn home-kitchen-2 trong src/journeys/home/journey.js:
pages: [
{ card: 'k_fruit', x: 625, y: 300 }, // trên bục cao
{ card: 'k_hot', x: 1180, y: 440 },
{ card: 'k_teeth', x: 1250, y: 440 } // MỚI
],
quizGates: [{ quiz: 'q_vitc', x: 960 }, { quiz: 'q_teeth', x: 1600 }, { quiz: 'q_safe' }], // MỚI: q_teeth sau trang k_teethGiải thích vị trí
| Mốc (x, px thế giới) | Vật |
|---|---|
| 960 | cổng q_vitc (có sẵn) |
| 1180, 1250 | trang k_hot, trang k_teeth |
| 1600 | cổng q_teeth: sau trang chứa đáp án, trước cửa ra |
| 1780 | cửa EXIT (mượn từ màn kitchen) + cổng q_safe |
validateLevel chỉ kiểm tra x < exit.x. Còn quy tắc "cổng đặt sau trang sách chứa đáp án" là quy tắc thiết kế (ghi trong ⚠️ của journey.js), validate không tự bắt được. Người thiết kế phải tự giữ.
Vì sao không đặt vào
home-kitchen-1? Lần thử đầu tiên của lời giải đặt trang ở x = 1560 và cổng ở x = 1650 tronghome-kitchen-1. Game chơi được, nhưng smoke test đỏ. Lý do: màn đầu tiên của mỗi hành trình đượcdetailedLevelCheckskiểm tra kỹ hơn (dịch chuyển người chơi tới từng trang sách, rồi cho bị thương liên tục để thử hồi sinh), và lực đẩy lùi khi bị thương hất người chơi vào cổng mới. Câu đố bật lên giữa chừng làm kịch bản test lệch. Bài học rút ra: test đầu-cuối cũng mang giả định về dữ liệu. Khi test đỏ, hãy đọc kịch bản test để biết đó là lỗi game hay giả định của test (xem Bài 13).
⭐⭐ Màn home-kitchen-3 #
Thêm vào cuối mảng levels của khu kitchen (sau home-kitchen-2):
{
// Màn tự vẽ hình học. Là màn CUỐI khu → trận Dee Dee chuyển sang màn này.
id: 'home-kitchen-3', name: 'Tráng miệng trái cây', icon: '🍉',
width: 1800, spawn: { x: 70, y: 420 }, goal: 4,
platforms: [[0, 480, 1800, 60], [300, 400, 160, 18], [560, 340, 160, 18], [840, 400, 170, 18],
[1120, 340, 160, 18], [1400, 400, 170, 18]],
pizzas: [[200, 440], [380, 360], [640, 300], [925, 360], [1200, 300], [1485, 360]],
coins: [[1000, 440], [1600, 440]],
enemies: [roach(760), roach(1300)],
exit: { x: 1680 },
pages: [{ card: 'k_water', x: 1050, y: 440 }],
quizGate: { quiz: 'q_wash' },
guideSpots: [{ x: 230, lines: ['Món tráng miệng ngon nhất là trái cây tươi!'] }],
checkpoints: [900]
}Kiểm tra thiết kế
- Sàn
[0, 480, 1800, 60]làplatforms[0](bất biến mà level.js dựa vào). - Độ cao: sàn (480) → bục 400 là 80 px; bục 400 → bục 340 là 60 px. Cả hai đều dưới đỉnh nhảy ≈ 116 px.
- Pizza trên bục y = 400 đặt ở y = 360, trên bục y = 340 đặt ở y = 300 (cách mặt bục 40 px, là tâm pizza).
- 6 pizza,
goal: 4: trẻ được bỏ sót 2 miếng mà vẫn qua màn. exit.x = 1680 ≤ width − 40(luậtEXIT_EDGE_MARGINtrong validate).roach(760)dùng factory có sẵn của file:rangemặc định 70,speedmặc định 45.
Câu 1: trận Dee Dee ở đâu? Ở home-kitchen-3. compileAll truyền li === lvls.length - 1 làm tham số isLastOfArea, và compileLevel gắn area.boss.bossId vào màn cuối khu. Dữ liệu trùm nằm ở khu, không nằm ở màn, nên thêm màn vào cuối khu thì trùm tự "dời" theo. OG.Journeys.getLevel('home-kitchen-2').boss giờ là null.
Câu 2: save của người chơi cũ. isLevelUnlocked mở một màn khi màn liền trước theo thứ tự hành trình đã xong:
var ids = getCompiled().order[L.journey], i = ids.indexOf(levelId);
return i <= 0 || isDone(ids[i - 1]);Sau cập nhật, màn liền trước home-garden-1 là home-kitchen-3 (chưa chơi), nên home-garden-1 bị khoá lại dù người chơi đã qua nó, và "Chơi tiếp" (firstIncomplete) đưa họ về home-kitchen-3. Dữ liệu save không hỏng (kỷ lục vẫn nằm trong progress.levels), chỉ là trải nghiệm bị "lùi". Cách xử lý tuỳ thiết kế:
- Chấp nhận: màn mới là nội dung bắt buộc.
- Thêm màn mới vào cuối hành trình (hoặc một khu mới ở cuối) để không chen vào giữa chuỗi mở khoá.
- Đổi luật mở khoá thành "màn đã từng xong thì luôn mở". Đây là sửa engine (
isLevelUnlocked), cần cập nhật hợp đồng docs/STORY.md.
Đây là cầu nối với Bài 11: thay đổi dữ liệu nội dung cũng có thể ảnh hưởng tới dữ liệu người chơi.
⭐⭐⭐ Hành trình mới ocean #
Danh sách file #
| File | Việc |
|---|---|
src/journeys/ocean/theme.js |
mới: chủ đề ocean-beach |
src/journeys/ocean/content.js |
mới: 7 thẻ, 2 câu đố, truyện intro |
src/journeys/ocean/journey.js |
mới: 2 khu × 3 màn |
assets/photos/ocean/credits.js |
mới: danh sách ảnh rỗng (hợp đồng của photoChecks) |
index.html |
thêm 4 thẻ <script> |
docs/credits.html |
thêm 1 thẻ <script> |
tests/smoke.js |
thêm 'ocean' vào JOURNEYS |
Không phải sửa file nào trong src/core, src/game, src/story. Đó chính là data-driven.
src/journeys/ocean/theme.js #
// @ts-check
/* ============================================================================
* Hành trình "ocean" — CHỦ ĐỀ CẢNH NỀN 'ocean-beach' (chỉ dữ liệu ThemeDef, không vẽ tay)
* ----------------------------------------------------------------------------
* Vai trò: Đăng ký chủ đề bãi biển dùng cho khu 'shore'. Mọi lớp/decor là loại CÓ SẴN.
* Khái niệm: data-driven design · kế thừa chủ đề (base) · tái sử dụng decor của hành trình khác
* Bài học: docs/learn/12-data-driven-man-cau-do-hanh-trinh.md · docs/learn/08-camera-parallax.md
* Phụ thuộc: OG.Scenery.registerTheme (LOAD) · decor 'science-shell' (src/journeys/science/theme.js, nạp TRƯỚC)
* Đọc tiếp: src/journeys/ocean/content.js
* ========================================================================== */
(function () {
'use strict';
var OG = window.OG;
OG.Scenery.registerTheme('ocean-beach', {
name: 'Bãi biển', base: 'garden', // thiếu phần nào thì mượn của 'garden'
sky: { top: '#7fd3ff', bottom: '#fff2c4' },
layers: [
// Hai dải "đồi" màu nước biển: lớp xa cuộn chậm (parallax nhỏ) hơn lớp gần.
{ kind: 'hills', parallax: 0.15, y: 440, height: 30, color: '#4fb3e8', spacing: 360 },
{ kind: 'hills', parallax: 0.35, y: 470, height: 24, color: '#2f8fd0', spacing: 260 }
],
platformStyle: { floor: { fill: '#e8c98a', top: '#fbe7b5', topH: 10 } },
decor: [{ kind: 'science-shell', every: 700, start: 400, scale: 0.9 }]
});
})();Lần thử đầu dùng
sceneryTheme: 'beach'(tên lấy từ@examplecủaregisterTheme). Validate báo cảnh báochủ đề "beach" chưa đăng ký (sẽ vẽ nền mặc định). Nó chỉ là ví dụ trong JSDoc, chưa ai đăng ký. Cảnh báo không làm test đỏ, nhưng là dấu hiệu bạn đang tham chiếu một thứ không tồn tại.
src/journeys/ocean/content.js #
// @ts-check
/* ============================================================================
* Hành trình "ocean" (Đại dương xanh) — NỘI DUNG HỌC: THẺ, CÂU ĐỐ, TRUYỆN TRANH
* ----------------------------------------------------------------------------
* Vai trò: Dữ liệu thuần: 7 thẻ kiến thức, 2 câu đố, 1 truyện mở đầu. Được GỘP với journey.js (cùng id).
* Khái niệm: data-driven design · tham chiếu chéo bằng id (thẻ ↔ câu đố ↔ màn)
* Bài học: docs/learn/12-data-driven-man-cau-do-hanh-trinh.md
* Định dạng: CardDef, QuizDef, PanelDef (src/types.d.ts)
* Đọc tiếp: src/journeys/ocean/journey.js
* ========================================================================== */
(function () {
'use strict';
var OG = window.OG;
OG.Journeys.register({
id: 'ocean',
// Thẻ dùng icon emoji (IconSpec dạng chuỗi) cho nhanh; muốn đẹp thì viết drawIcon như home/content.js.
cards: [
{ id: 'o_turtle', areaId: 'shore', title: 'Rùa biển', icon: '🐢',
fact: 'Rùa biển lên bãi cát để đẻ trứng. Rùa con nở ra sẽ bò ngay về phía biển.' },
{ id: 'o_plastic', areaId: 'shore', title: 'Biển sạch', icon: '🗑️',
fact: 'Rác nhựa trôi ra biển có thể làm hại rùa và cá. Hãy bỏ rác vào thùng nhé!' },
{ id: 'o_salt', areaId: 'shore', title: 'Nước biển mặn', icon: '🧂',
fact: 'Nước biển mặn vì có muối hoà tan. Vì vậy chúng ta không uống nước biển được.' },
{ id: 'o_crab', areaId: 'shore', title: 'Bạn cua', icon: '🦀',
fact: 'Cua có mười chân và thường bò ngang. Lớp vỏ cứng giúp cua tự bảo vệ.' },
{ id: 'o_gills', areaId: 'reef', title: 'Cá thở bằng mang', icon: '🐟',
fact: 'Cá thở bằng mang. Mang lấy khí ô-xy có trong nước nên cá sống được dưới nước.' },
{ id: 'o_coral', areaId: 'reef', title: 'San hô', icon: '🪸',
fact: 'San hô trông giống đá nhưng thật ra là những con vật nhỏ xíu sống thành đàn.' },
{ id: 'o_whale', areaId: 'reef', title: 'Cá voi xanh', icon: '🐋',
fact: 'Cá voi xanh là con vật lớn nhất trên Trái Đất. Nó thở bằng phổi nên phải ngoi lên mặt nước.' }
],
quizzes: [
{ id: 'q_plastic', areaId: 'shore', cardId: 'o_plastic',
question: 'Ăn kem xong ở bãi biển, em bỏ vỏ kem ở đâu?',
choices: [{ text: 'Thả xuống biển', icon: '🌊' }, { text: 'Bỏ vào thùng rác', icon: '🗑️' }],
answer: 1, hint: 'Gợi ý: rùa biển không thích ăn nhầm rác đâu!',
explain: 'Đúng rồi! Bỏ rác đúng chỗ giúp biển luôn sạch.' },
{ id: 'q_gills', areaId: 'reef', cardId: 'o_gills',
question: 'Cá thở bằng gì?',
choices: [{ text: 'Bằng mang', icon: '🐟' }, { text: 'Bằng mũi', icon: '👃' }, { text: 'Bằng vây', icon: '🐠' }],
answer: 0, hint: 'Gợi ý: hai bên đầu cá có khe nhỏ mở ra khép vào.',
explain: 'Chính xác! Mang giúp cá lấy ô-xy từ nước.' }
],
comics: {
intro: [
{ bg: 'ocean-beach', chars: [{ id: 'oggy', anim: 'idle', x: 0.3 }, { id: 'olivia', anim: 'wave', x: 0.66, face: -1 }],
bubbles: [{ who: 'olivia', text: 'Oggy ơi, ra biển chơi nào! Biển có bao nhiêu điều thú vị.' },
{ who: 'oggy', text: 'Đi thôi! Mình mang theo cả sổ tay nữa.' }] }
]
}
});
})();src/journeys/ocean/journey.js #
// @ts-check
/* ============================================================================
* Hành trình "ocean" (Đại dương xanh) — CẤU TRÚC: 2 KHU × 3 MÀN
* ----------------------------------------------------------------------------
* Vai trò: Khung hành trình: thông tin chung, khu, màn. Mọi màn MƯỢN hình học màn v1 bằng `from`
* nên chỉ cần ghi phần học tập (pages, quizGate, guideSpots).
* Khái niệm: data-driven design · kế thừa dữ liệu bằng `from` · factory cho dữ liệu
* Bài học: docs/learn/12-data-driven-man-cau-do-hanh-trinh.md
* Định dạng: JourneyDef → AreaDef → LevelDef (src/types.d.ts)
* Đọc tiếp: src/core/journeys.js (compileLevel, validateDef)
* ========================================================================== */
(function () {
'use strict';
var OG = window.OG;
/** @param {number} x @returns {EnemySpawn} gián hiền, toạ độ thế giới px */
function roach(x) { return { type: 'roach', x: x, range: 60, speed: 45 }; }
OG.Journeys.register({
id: 'ocean',
order: 5,
title: 'Đại dương xanh',
emoji: '🌊',
description: 'Cùng Olivia khám phá bãi biển và rạn san hô!',
guide: { charId: 'olivia', name: 'Olivia' },
palette: { primary: '#1f8fd6', secondary: '#38c3b5', accent: '#ffd166', bg: '#eaf8ff', ink: '#17233d' },
intro: 'intro',
ending: { title: 'BIỂN XANH THẬT ĐẸP! 🌊', text: 'Bạn đã hiểu thêm về biển và các bạn sinh vật biển.' },
areas: [
{
id: 'shore', name: 'Bãi biển', emoji: '🏖️', sceneryTheme: 'ocean-beach', music: 'garden',
levels: [
{ id: 'ocean-shore-1', name: 'Dấu chân rùa con', icon: '🐢', from: 'kitchen', goal: 3,
enemies: [roach(1000)],
pages: [{ card: 'o_turtle', x: 470, y: 440 }, { card: 'o_plastic', x: 1150, y: 440 }],
quizGate: { quiz: 'q_plastic' }, // bỏ x = cổng ở cửa EXIT, SAU trang o_plastic
guideSpots: [{ x: 230, lines: ['Chào bạn! Hôm nay mình đi biển nhé!', 'Nhặt trang sách 📄 để mở thẻ kiến thức.'] }],
checkpoints: [900] },
{ id: 'ocean-shore-2', name: 'Sóng vỗ rì rào', icon: '🌊', from: 'garden', goal: 3,
enemies: [roach(900)],
pages: [{ card: 'o_salt', x: 700, y: 440 }] },
{ id: 'ocean-shore-3', name: 'Lâu đài cát', icon: '🏰', from: 'bathroom', goal: 3,
enemies: [roach(1200)],
pages: [{ card: 'o_crab', x: 650, y: 440 }] } // ⚠️ mỗi màn hành trình nên có ≥ 1 trang (smoke test kiểm HUD 📖)
]
},
{
id: 'reef', name: 'Rạn san hô', emoji: '🪸', sceneryTheme: 'science-ocean', music: 'garden',
boss: { bossId: 'deedee' }, // trùm gắn vào màn CUỐI khu (ocean-reef-3)
levels: [
{ id: 'ocean-reef-1', name: 'Đàn cá nhỏ', icon: '🐟', from: 'roof', goal: 3,
enemies: [roach(1100)],
pages: [{ card: 'o_gills', x: 600, y: 440 }],
quizGate: { quiz: 'q_gills', x: 1500 } }, // x < exit.x (2210) và SAU trang o_gills (600)
{ id: 'ocean-reef-2', name: 'Vườn san hô', icon: '🪸', from: 'attic', goal: 3,
enemies: [roach(1300)],
pages: [{ card: 'o_coral', x: 800, y: 440 }] },
{ id: 'ocean-reef-3', name: 'Bài hát cá voi', icon: '🐋', from: 'lair', goal: 3,
enemies: [roach(1400)],
pages: [{ card: 'o_whale', x: 900, y: 440 }] }
]
}
]
});
})();Nếu đã làm bài tập ⭐⭐ của Bài 10 (bản nhạc beach), đổi music: 'garden' của khu shore thành music: 'beach'.
assets/photos/ocean/credits.js #
/* Ảnh chụp thực tế cho thẻ kiến thức — hành trình "ocean" (chưa có ảnh nào).
* Khi có ảnh: python tools/photos/fetch_commons.py write ocean (công cụ ghi lại khối bên dưới).
*/
OG.Photos.register("ocean", /* BEGIN-ENTRIES */ {} /* END-ENTRIES */);Nối dây #
index.html: thêm dòng credits ngay sau assets/photos/science/credits.js, và 3 dòng hành trình ngay sau src/journeys/science/journey.js:
<script src="assets/photos/ocean/credits.js"></script><script src="src/journeys/ocean/theme.js"></script>
<script src="src/journeys/ocean/content.js"></script>
<script src="src/journeys/ocean/journey.js"></script>docs/credits.html: sau dòng ../assets/photos/science/credits.js:
<script src="../assets/photos/ocean/credits.js"></script>const JOURNEYS = ['home', 'vietnam', 'world', 'science', 'ocean'];Để ý: check của sổ tay so sánh với
JOURNEYS.length, nhưng thông báo lỗi của nó vẫn viết cứng chữ "đủ 4 hành trình". Thông báo test cũng là code và cũng có thể "cũ". Nếu sửa, hãy ghép số từJOURNEYS.lengthvào chuỗi.
Hành trình này phải thoả những hợp đồng nào? #
Mỗi hợp đồng dưới đây được tìm ra bằng cách chạy smoke test và đọc lỗi, không phải đoán trước:
| Hợp đồng | Ai kiểm tra | Nếu vi phạm |
|---|---|---|
Danh sách hành trình đúng bằng JOURNEYS |
journeyChecks |
"Danh sách hành trình khác mong đợi" |
| ≥ 6 màn | journeyChecks |
"Hành trình ocean quá ít màn" |
Có truyện intro |
playJourney (expectScene('comic', ...)) |
"Mong đợi scene comic" |
| Mọi màn có ≥ 1 trang sách | playJourney (HUD 📖) |
"HUD DOM không hiện số trang sách" (lần thử đầu, ocean-shore-3 có pages: []) |
assets/photos/ocean/credits.js nạp sau photos.js và có khối BEGIN-ENTRIES/END-ENTRIES |
photoChecks |
"Thiếu assets/photos/ocean/credits.js" |
docs/credits.html nạp file credits |
photoChecks |
"docs/credits.html chưa nạp ..." |
Id màn có tiền tố ocean-, goal ≤ số pizza, tham chiếu id đúng... |
OG.Journeys.validate |
hành trình bị ẩn + lỗi có đường dẫn |
Không phải sửa .github/workflows/pages.yml: bước đóng gói đã cp -r cả src, docs và assets.
Vì sao from giúp rất nhiều? Sáu màn chỉ tốn khoảng 25 dòng dữ liệu, vì bục, pizza, xu và cửa ra đều mượn của màn cổ điển. from chỉ chép các field trong GEOMETRY, không chép boss, quests hay theme của màn gốc. Vì vậy ocean-reef-3 (mượn lair, vốn có trùm trio) vẫn đấu với trùm của khu là Dee Dee, và vẫn dùng cảnh nền science-ocean của khu.
Smoke test chạy lâu hơn một chút, vì nó chơi trọn cả hành trình mới: đọc truyện, nhặt trang, trả lời câu đố (sai rồi đúng), đánh trùm, mở sổ tay.