🎯 Mục tiêu #
Sau bài này bạn sẽ:
- Giải thích được finite state machine (FSM, máy hữu hạn trạng thái) và nhận ra nó ở ba tầng của game: luồng màn hình
(scene), pha bên trong một scene (
st.phase), và AI của trùm (bài 09). - Dùng được scene stack (ngăn xếp scene):
gothay cả stack,pushđè lên trên,popgỡ ra. - Hiểu vòng đời (lifecycle) của scene:
enter,exit,resume, và cờdrawBelow. - Tự viết một scene thường, một scene overlay (lớp phủ) và một scene chuyển cảnh (transition).
- Tránh được ba bẫy kinh điển: đổi scene ngay trong
enter, phím "lọt" sang scene mới, vàpopsai thứ tự với callback.
🧠 Khái niệm #
1. Vì sao cần state machine? #
Thử tưởng tượng game không có khái niệm "scene". Vòng lặp chính sẽ đầy cờ boolean:
// ❌ "boolean soup" — mỗi cờ thêm vào nhân đôi số tổ hợp phải nghĩ tới
if (isMenu) { ... } else if (isPaused && !isGameOver) { ... } else if (isBoss && !isPaused) { ... }isPaused && isMenu có hợp lệ không? isBoss mà quên tắt khi sang gameover thì sao? Với 5 cờ đã có 32 tổ hợp, hầu hết vô nghĩa.
Finite state machine giải quyết bằng một quy tắc đơn giản: tại mỗi thời điểm, hệ thống ở ĐÚNG MỘT trạng thái, chọn trong một tập hữu hạn. Mỗi trạng thái tự biết cách cập nhật và vẽ. Muốn sang trạng thái khác thì đi qua một phép chuyển (transition) có tên rõ ràng. Không còn tổ hợp vô nghĩa, vì không thể "vừa ở menu vừa ở màn chơi".
2. Scene là một trạng thái cấp cao #
Trong Oggy Adventure, mỗi scene (một "màn hình" của game: menu, bản đồ, màn chơi, trùm, tạm dừng...) là một trạng thái. Scene
chỉ là một object có vài hàm tuỳ chọn (kiểu Scene trong src/types.d.ts):
scene = { enter(params, fromName), exit(toName), update(dt), draw(ctx), resume(), drawBelow: bool }OG.Loop (bài 02) không biết game đang ở đâu. Mỗi frame nó chỉ gọi OG.Scenes.update(dt) rồi OG.Scenes.draw(ctx), và
OG.Scenes chuyển tiếp lời gọi cho scene hiện tại. Đây cũng là polymorphism (đa hình) kiểu JavaScript: cùng lời gọi
scene.update(dt), mỗi scene làm một việc khác.
Luồng chính của chế độ cổ điển (xem docs/ARCHITECTURE.md mục 5):
stateDiagram-v2
[*] --> boot
boot --> menu: frame đầu tiên
menu --> worldmap: newRun
worldmap --> level: startLevel(id)
level --> boss: qua cửa, màn có trùm
level --> worldmap: qua cửa, màn không trùm
boss --> worldmap: thắng trùm
boss --> victory: thắng trùm màn cuối
level --> gameover: hết mạng
boss --> gameover: hết mạng
gameover --> menu
victory --> menu3. Scene stack: go, push, pop #
Một FSM "phẳng" chỉ nhớ trạng thái hiện tại. Nhưng khi bấm P để tạm dừng, ta muốn sau đó quay lại đúng màn đang chơi, với nguyên vị trí nhân vật, pizza đã nhặt, gián đang đi dở. Lời giải là một ngăn xếp (stack) trạng thái, trong tài liệu lý thuyết gọi là pushdown automaton:
| Lệnh | Việc làm | Ví dụ |
|---|---|---|
go(name, params) |
exit() mọi scene trong stack, rồi enter() scene mới. Stack còn đúng 1 phần tử. |
menu → worldmap, level → boss |
push(name, params) |
Đặt scene mới lên trên. Scene dưới không bị exit, chỉ ngừng nhận update. |
level → pause |
pop() |
Gỡ scene trên cùng (exit), gọi resume() của scene lộ ra. Không bao giờ pop scene đáy. |
pause → level |
flowchart LR
A["level"] -- "push('pause')" --> B["level · pause"]
B -- "push('settings')" --> C["level · pause · settings"]
C -- "pop()" --> B
B -- "pop()" --> A
B -- "go('menu')" --> D["menu"]Chỉ scene trên cùng được update, nên khi pause nằm trên level thì gameplay tự đứng yên. Không cần rải if (isPaused) vào
vật lý, kẻ địch, đồng hồ... Đó là lợi ích lớn nhất của scene stack.
4. Vòng đời của scene #
sequenceDiagram
participant Game as Code gọi
participant S as OG.Scenes
participant L as scene 'level'
participant P as scene 'pause'
Game->>S: push('pause', {from:'level'})
S->>P: enter(params, 'level')
Note over L: không exit, chỉ ngừng update
Game->>S: pop()
S->>P: exit('level')
S->>L: resume()
Game->>S: go('menu')
S->>L: exit('menu')enter(params, fromName): khởi tạo trạng thái của scene.paramslà cách truyền dữ liệu giữa các scene (vd{levelId: 'kitchen'}), thay vì dùng biến toàn cục.exit(toName): dọn dẹp (ẩn panel DOM, huỷ listener...). Ghép đôi vớientergiốngsave/restorecủa canvas.resume(): scene vừa "lộ ra" sau mộtpop. Ví dụpause.resume()vẽ lại panel của nó sau khisettingsđóng.drawBelow: khi scene nằm trên, scene bên dưới có được vẽ không?pauseđểtruenên màn chơi đứng yên hiện mờ phía sau. Một scene che kín màn hình thì đặtfalseđể khỏi vẽ phí.
5. State machine nhỏ bên trong một scene #
Không phải trạng thái nào cũng đáng thành một scene. Lúc chạm cửa EXIT, màn chơi có 0,9 giây ăn mừng: kẻ địch thôi gây đau,
nhân vật nhảy múa, rồi mới chuyển màn. Tạo scene riêng cho 0,9 giây này thì quá nặng (phải chép cách vẽ màn chơi sang). Thay vào đó
scene level có một FSM mini trong st.phase:
stateDiagram-v2
[*] --> play
play --> clear: chạm cửa + đủ pizza + không hook nào chặn
clear --> done: clearT ≥ 0,9 giây → OG.Game.finishStageScene boss cũng vậy: battle.phase là 'fight' → 'end', hoặc 'skip' khi không tạo được trùm.
Quy tắc chọn: trạng thái cần cách vẽ / cách điều khiển khác hẳn thì thành scene. Trạng thái chỉ đổi vài luật trong cùng một khung cảnh thì làm pha bên trong scene.
🔍 Đọc code #
1. Bộ máy scene — src/core/scenes.js #
Mở src/core/scenes.js. Toàn bộ trạng thái của bộ máy chỉ là hai biến:
/** @type {Object<string, Scene>} tên scene → object scene */
var registry = {};
/** @type {StackEntry[]} phần tử cuối = scene đang hoạt động (top) */
var stack = [];registry là bảng đăng ký: register(name, scene) lần sau ghi đè lần trước. Nhờ vậy src/game/default-scenes.js đăng ký
bản MVP của menu, pause... trước, rồi OG.Menus.init() đăng ký lại cùng tên bằng bản đẹp hơn (mẫu "stub rồi ghi đè" ở bài 01).
Tìm hàm go:
var from = Scenes.current();
while (stack.length) {
var e = stack.pop();
if (e.scene.exit) OG.util.safe('scene ' + e.name + '.exit', e.scene.exit, e.scene, [name]);
}
// ⚠️ Bẫy: không reset input khi đổi scene thì phím đang giữ (vd Enter để bấm nút menu) "lọt" sang
// scene mới và bị xử lý thêm lần nữa — vd vừa vào màn đã bấm nhầm nút.
OG.Input.reset();
stack.push({ name: name, scene: scene });
// 💡 Khái niệm: vòng đời (lifecycle) — scene tự khởi tạo trong enter() và dọn dẹp trong exit()
// (vd huỷ listener event). Bọc safe để scene lỗi không làm kẹt cả máy trạng thái.
if (scene.enter) OG.util.safe('scene ' + name + '.enter', scene.enter, scene, [params || {}, from]);
OG.Events.emit('scene:change', { from: from, to: name, params: params || {} });Để ý thứ tự: exit hết → reset input → enter → phát event. Listener của 'scene:change' (Audio đổi nhạc, Guide xoá lời thoại...)
chạy khi scene mới đã sẵn sàng.
Hàm pop:
// Không bao giờ pop scene đáy: stack rỗng thì Loop không còn gì để update/draw.
if (stack.length <= 1) return;
var e = stack.pop();
if (e.scene.exit) OG.util.safe('scene ' + e.name + '.exit', e.scene.exit, e.scene, [Scenes.current()]);
OG.Input.reset();
var t = topEntry();
if (t.scene.resume) OG.util.safe('scene ' + t.name + '.resume', t.scene.resume, t.scene, []);Hàm update chỉ gọi scene trên cùng. Hàm draw thì đi từ đỉnh xuống để tìm scene thấp nhất còn "nhìn thấy", rồi vẽ từ dưới
lên (vẽ sau đè lên vẽ trước):
var start = stack.length - 1;
while (start > 0 && stack[start].scene.drawBelow !== false) start--;
for (var i = start; i < stack.length; i++) {
var s = stack[i].scene;
// save/restore quanh từng scene: scene này đổi transform/alpha cũng không ảnh hưởng scene vẽ sau.
if (s.draw) { ctx.save(); s.draw(ctx); ctx.restore(); }
}2. Scene chuyển tiếp và scene overlay — src/game/default-scenes.js #
Mở src/game/default-scenes.js, tìm scene 'boot':
OG.Scenes.register('boot', {
enter: function () { OG.DOM.hidePanel(); this.done = false; },
update: function () { if (!this.done) { this.done = true; OG.Scenes.go('menu'); } },
draw: function (ctx) { OG.drawTitleBackdrop(ctx); }
});boot sống đúng một frame. Nó đổi scene trong update, không phải trong enter, vì enter đang chạy bên trong Scenes.go().
Gọi go() lồng trong go() sẽ làm thứ tự exit/enter/'scene:change' rối tung.
Cùng file, scene 'pause' mặc định có một dòng đáng nhớ:
// consume trước khi pop: cùng cú bấm P không được scene 'level' lộ ra xử lý tiếp (sẽ push pause lại ngay).
if (OG.Input.pressed('pause')) { OG.Input.consume('pause'); OG.Scenes.pop(); }3. Một scene, hai cách vào — src/ui/menus.js #
Mở src/ui/menus.js, tìm hàm settingsScene. Cài đặt có thể được go từ menu (thay hẳn scene) hoặc
push từ pause ({overlay: true}). Nút "Xong" phải quay về đúng chỗ:
leave: function () {
if (S.overlay) { OG.Scenes.pop(); return; }
OG.Scenes.go(OG.Scenes.has(S.back) ? S.back : 'menu');
},Ngay dưới, hàm pauseScene có resume để vẽ lại panel tạm dừng khi settings đóng:
// Quay lại từ settings (đã push lên trên pause).
resume: function () { S.render('[data-act="settings"]'); },4. Pha trong scene — src/game/level.js #
Mở src/game/level.js, tìm hàm checkExit:
if (stage.pizzas >= st.level.goal && !exitBlocked(st, p)) {
// 💡 Khái niệm: pha trong scene — 'play' → 'clear' (ăn mừng 0.9 giây, kẻ địch không gây đau nữa) → 'done'.
// Một state machine nhỏ BÊN TRONG scene, không cần tạo scene riêng cho mấy giây ăn mừng.
st.phase = 'clear';
st.clearT = 0;Và trong update của scene 'level':
if (st.phase === 'clear') {
st.clearT += dt;
if (st.clearT >= CLEAR_DELAY) { st.phase = 'done'; OG.Game.finishStage(st.level.id); return; }
}Còn hàm updateEnemies dùng pha để tắt sát thương: if (!e.alive || st.phase !== 'play') continue;.
Cũng trong update, dòng đầu tiên là cách màn chơi mở pause:
if (OG.Input.pressed('pause')) { OG.Input.consume('pause'); OG.Scenes.push('pause', { from: 'level' }); return; }5. Luồng game cấp cao — src/game/game.js #
Mở src/game/game.js, tìm finishStage. Mọi mũi tên của sơ đồ ở mục 2 đều là một lời gọi
Scenes.go() nằm tập trung ở file này:
finishStage: function (levelId) {
var level = OG.Data.getLevel(levelId);
if (level && level.boss && OG.util.call('Bosses', 'has', level.boss)) {
var toBoss = { scene: 'boss', params: { levelId: levelId, bossId: level.boss } };Đọc tiếp completeLevel: level:complete được phát trước khi chuyển scene (victory() hoặc go('worldmap')), nên mọi
listener thấy kết quả khi màn vừa xong.
6. Đổi scene an toàn — src/game/boss-scene.js và src/story/ #
Trong src/game/boss-scene.js, update của scene 'boss' xử lý pha 'skip' ở frame đầu tiên,
không phải trong enter:
// ⚠️ Bẫy: bỏ qua trận ở frame update đầu tiên thay vì gọi finish() ngay trong enter(): enter đang chạy bên trong
// Scenes.go(), đổi scene lồng vào đó dễ làm thứ tự exit/enter/'scene:change' bị rối.
if (battle.phase === 'skip') { finish(); return; }Trong src/story/story.js, hàm pushComic giải thích vì sao truyện chen giữa màn phải dùng push:
* ⚠️ Bẫy: dùng push chứ không phải go — go sẽ exit() màn chơi và mất toàn bộ trạng thái (vị trí, pizza đã nhặt).Còn src/story/scenes.js, hàm finish của scene comic, có thứ tự "pop trước, callback sau":
// ⚠️ Bẫy: pop TRƯỚC rồi mới gọi cb — cb có thể push scene khác; làm ngược lại sẽ pop nhầm scene mới.
OG.Scenes.pop(); // exit() bật lại HUD khi về màn chơi
if (cb) cb();🧪 Thử ngay #
Mở game, nhấn F12 để mở DevTools, chọn tab Console. Mọi module nằm trong biến toàn cục OG.
- Xem stack: vào một màn chơi, gõ
OG.Scenes.stackNames()(được['level']). Bấm P, gõ lại (được['level', 'pause']). Bấm ⚙️ Cài đặt trong panel tạm dừng rồi gõ lại lần nữa. - Theo dõi mọi phép chuyển:Chơi một vòng: menu → bản đồ → màn → tạm dừng → tiếp tục. Đếm xem bao nhiêu lần làjs
OG.Events.on('scene:change', function (e) { console.log(e.from, '→', e.to, e.push ? '(push)' : e.pop ? '(pop)' : '(go)'); });go, bao nhiêu lần làpush/pop. - Tắt drawBelow: đang chơi, gõ
OG.Scenes.get('pause').drawBelow = falserồi bấm P. Nền màn chơi biến mất vìScenes.drawdừng ở scenepause. Gõ... = trueđể trả lại. - Đi tắt qua cửa: trong màn Nhà bếp (chế độ cổ điển), gõ
OG.Game.finishStage(OG.Level.state.level.id). Bạn vào thẳng trận Dee Dee, đúng như khi chạm cửa. Mũi tên "level → boss" của sơ đồ chỉ là một lời gọi hàm. - Xem pha trong scene: chạy tới gần cửa EXIT khi đã đủ pizza, gõ liên tục
OG.Level.state.phase. Bạn sẽ thấy'play'rồi'clear'trong khoảng 0,9 giây trước khi scene đổi.
Tải lại trang (F5) để xoá mọi thứ đã gõ.
✍️ Bài tập #
Mọi bài làm được bằng cách dán code vào Console (hoặc tạo file
src/dev/*.jsvà thêm thẻ<script>ngay trướcsrc/core/main.jstrongindex.html). Đọc khối đầu filesrc/game/level.jsđể biết hợp đồng level hook (OG.Level.addHook).⚠️ Tính năng làm đổi luồng scene sẽ làm smoke test ĐỎ. Smoke test kiểm tra từng bước đổi scene (
expectScene, bài 13). Nếu bạn để countdown (⭐⭐) hay fade (⭐⭐⭐) thành file thật trongindex.html, mỗi lần vào màn đều có thêm một scene chen vào, và test báo hàng chục lỗi kiểuMong đợi scene "level" ... thực tế: level>countdown. Luật chung vẫn là smoke test phải XANH, nên hãy chọn một trong hai cách:
- Chỉ dán vào Console để thử (mất khi tải lại trang), hoặc
- Làm file thật nhưng bật bằng cờ: chỉ đăng ký hook / bọc
gokhiOG.Config.DEBUGđang bật, vd trong hook kiểm traif (!OG.Config.DEBUG) return;trước khipush('countdown'). Smoke test không bật cờ này nên luồng scene không đổi.Bài ⭐ (scene
'hello') không đổi luồng có sẵn nên làm file thật vẫn XANH.
⭐ Dễ — Scene "Xin chào" #
Đăng ký một scene mới tên 'hello' vẽ hoàn toàn bằng canvas:
- nền tối phủ kín 960 × 540, dòng chữ "Xin chào, Oggy!" nhún lên xuống theo thời gian;
- hiện tên scene đã chuyển tới đây (
fromName) và số giây đã ở trong scene; - nhấn Enter hoặc Esc thì
go('menu').
Gọi OG.Scenes.go('hello') từ menu để thử. Câu hỏi: nếu bạn không consume phím Enter trước khi go('menu') thì có chuyện gì
không? (Gợi ý: đọc lại go ở mục Đọc code 1.)
💡 Gợi ý
- Khung:
OG.Scenes.register('hello', { enter: function (params, fromName) {...}, update: function (dt) {...}, draw: function (ctx) {...} }). - Lưu trạng thái của scene trên
this(vdthis.t), và đặt lại trongenterđể mỗi lần vào đều bắt đầu từ 0. - Menu cũ dùng panel DOM, nên trong
enterhãyOG.DOM.hidePanel()(và ẩn HUD, nút cảm ứng nếu đi từ màn chơi sang). - Chữ:
ctx.font = OG.font(56, 'bold'),ctx.textAlign = 'center'. Phím:OG.Input.pressed('confirm'),OG.Input.pressed('back').
⭐⭐ Vừa — Đếm ngược 3-2-1 trước khi vào màn #
Mỗi khi vào một màn, game hiện số 3 → 2 → 1 to giữa màn hình, màn chơi phía sau đứng yên nhưng vẫn nhìn thấy, hết đếm ngược thì chơi tiếp bình thường.
- Viết scene overlay
'countdown'(nhậnparams.seconds), tựpop()khi đếm xong. - Mở nó tự động bằng một level hook. Không được sửa
level.js. - Kiểm tra: trong lúc đếm, giữ phím → có làm nhân vật đi không?
OG.Level.state.tcó tăng không?
💡 Gợi ý
- Scene overlay cần
drawBelow: true. Hiệu ứng "số to rồi co lại" lấy phần lẻ của thời gian:k = Math.ceil(left) - left. - Đừng
pushtrong hookenter:entercủa hook chạy bên trongentercủa scenelevel, tức là bên trongScenes.go()(xem bẫy ởboss-scene.js). Hãypushở frameupdateđầu tiên của hook rồireturn trueđể dừng frame. - Đánh dấu "đã đếm" bằng một field trên
st(state của màn), vdst.countdownDone. Chơi lại màn thìstđược dựng mới (hàmbuildState) nên cờ tự mất. pop()gọilevel.resume(). Đọcresumecủa scene'level'để biết nó làm gì.
⭐⭐⭐ Khó — Chuyển cảnh mờ dần (fade transition) #
Viết scene 'fade' và hàm fadeTo(to, params) để mọi lần đổi scene có hiệu ứng: màn hình tối dần trong 0,4 giây, đổi scene
lúc màn hình đen kịt, rồi sáng dần 0,4 giây trên scene mới.
Yêu cầu:
fadeTo('menu')chạy được từ màn chơi;fadeTo(function () { OG.Game.startLevel('garden'); })cũng phải chạy được (vào màn phải quaOG.Game.startLevel, khônggo('level')trần, xemgoNexttrongsrc/story/story.js).- Trong lúc chuyển cảnh, người chơi không điều khiển được scene bên dưới.
- Bấm
fadeTohai lần liên tiếp không làm hỏng stack. - Trả lời: (a) vì sao panel DOM của menu không mờ theo? (b) cùng một object scene
fadecó lúc nằm trong stack hai lần không? Nếu có thì nguy hiểm ở đâu?
💡 Gợi ý
- Hai nửa = hai pha của cùng scene:
this.mode = 'out' | 'in'. Phaout: khi hết giờ thì đổi scene thật rồipush('fade', {mode: 'in'}). Phain: hết giờ thìpop(). - Sau
go(), stack chỉ còn scene mới;pushngay sau đó là hợp lệ vìgo()đã chạy xong (đồng bộ). - Chặn input "miễn phí": scene dưới không nhận
updatekhifadenằm trên. - Câu (a): đọc khối đầu
src/ui/menus.js(DOM overlay vs canvas). Câu (b):registrylưu một object cho mỗi tên;enterghi đèthis.t,this.mode...
📚 Đọc thêm #
- Robert Nystrom — Game Programming Patterns, chương State: FSM, hierarchical state machine, và mục "Pushdown Automata" (chính là scene stack).
- Wikipedia — Finite-state machine (định nghĩa hình thức, bảng chuyển trạng thái).
- Wikipedia — Pushdown automaton.
- MDN — Anatomy of a video game (vòng lặp chính và chỗ của "trạng thái game").
- docs/ARCHITECTURE.md mục 5 (bảng scene và luồng game đầy đủ), docs/STORY.md mục 6.5 (scene của chế độ hành trình).
➡️ Bài tiếp theo #
Bài 06 — Event bus và observer: 'scene:change' bạn vừa nghe ở phần Thử ngay là một event. Bài sau
giải thích vì sao Audio đổi nhạc được mà scenes.js không hề biết Audio tồn tại.