✋ Hãy tự làm trước. Lời giải chỉ có ích khi bạn đã thử và bị kẹt ít nhất 15 phút. Đọc lời giải quá sớm sẽ lấy mất phần "à, ra thế!", mà đó lại là lúc bạn học được nhiều nhất.
Quay lại bài học: 05-state-machine-scene.md
Mọi đoạn dưới đây dán thẳng vào Console của trình duyệt (hoặc đặt vào một file src/dev/*.js nạp trước src/core/main.js).
Nếu bạn làm file thật, nhớ chạy node tests/smoke.js: lời giải ⭐⭐ và ⭐⭐⭐ đổi luồng scene nên chỉ được bật sau cờ
OG.Config.DEBUG (xem ô ⚠️ đầu phần Bài tập của bài 05), nếu không test sẽ ĐỎ.
⭐ Scene "Xin chào" #
OG.Scenes.register('hello', {
enter: function (params, fromName) {
this.t = 0; // giây đã ở trong scene
this.from = fromName || '(không có)';
OG.DOM.hidePanel(); // scene canvas: dọn panel DOM của scene trước
OG.DOM.setHudVisible(false);
OG.DOM.setControlsVisible(false);
},
update: function (dt) {
this.t += dt;
if (OG.Input.pressed('confirm') || OG.Input.pressed('back')) {
OG.Input.consume('confirm'); OG.Input.consume('back');
OG.Scenes.go('menu');
}
},
draw: function (ctx) {
ctx.fillStyle = '#10192b';
ctx.fillRect(0, 0, OG.W, OG.H);
ctx.textAlign = 'center';
ctx.fillStyle = '#ffc94a';
ctx.font = OG.font(56, 'bold');
// Chữ nhún lên xuống: vị trí là HÀM của thời gian, không cần biến "vận tốc".
ctx.fillText('Xin chào, Oggy!', OG.W / 2, OG.H / 2 + Math.sin(this.t * 3) * 12);
ctx.fillStyle = '#fff6df';
ctx.font = OG.font(20);
ctx.fillText('Vào từ scene: ' + this.from + ' · đã ở đây ' + this.t.toFixed(1) + ' giây', OG.W / 2, OG.H / 2 + 60);
ctx.fillText('Nhấn Enter hoặc Esc để về menu', OG.W / 2, OG.H / 2 + 96);
}
});
OG.Scenes.go('hello');Giải thích.
- Scene chỉ là object.
registercất nó vàoregistry,gotra theo tên. Không có lớp cha, không phải "kế thừa" gì cả. - Trạng thái của scene (
this.t,this.from) được đặt lại trongenter. Nếu khởi tạot: 0ngay trong object literal thì lần vào thứ hai sẽ đếm tiếp từ số cũ, vì registry giữ cùng một object qua mọi lần vào. - Không cần
ctx.save()/restore()ở đây:Scenes.drawđã bọc mỗi scene trong một cặpsave/restore.
Không consume thì sao? Trong trường hợp này không có lỗi, vì go() gọi OG.Input.reset() (xoá cả trạng thái giữ phím lẫn
cờ "vừa nhấn") trước khi enter scene mới. consume vẫn là thói quen tốt: nó báo "cú bấm này đã được xử lý", phòng khi sau này
bạn đổi go thành push, hoặc thêm code xử lý confirm ở phía sau trong cùng update.
⭐⭐ Đếm ngược 3-2-1 #
OG.Scenes.register('countdown', {
drawBelow: true, // vẫn thấy màn chơi (đứng yên) phía sau
enter: function (params) {
this.total = (params && params.seconds) || 3;
this.left = this.total; // giây còn lại
},
update: function (dt) {
this.left -= dt;
if (this.left <= 0) OG.Scenes.pop(); // lộ lại 'level' → level.resume() chạy
},
draw: function (ctx) {
var n = Math.ceil(this.left);
var k = n - this.left; // 0 → 1 trong mỗi giây
var scale = 1.6 - 0.6 * k; // số to rồi co lại
ctx.fillStyle = 'rgba(16,25,43,0.35)';
ctx.fillRect(0, 0, OG.W, OG.H);
ctx.save();
ctx.translate(OG.W / 2, OG.H / 2);
ctx.scale(scale, scale);
ctx.globalAlpha = 1 - 0.5 * k;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.font = OG.font(120, 'bold');
ctx.lineWidth = 10; ctx.strokeStyle = '#17233d';
ctx.strokeText(String(n), 0, 0);
ctx.fillStyle = '#ffc94a';
ctx.fillText(String(n), 0, 0);
ctx.restore();
}
});
OG.Level.addHook({
id: 'countdown-start',
update: function (st) {
if (st.countdownDone) return;
st.countdownDone = true; // field riêng của hook, nằm trong state của MÀN NÀY
OG.Scenes.push('countdown', { seconds: 3 });
return true; // dừng frame: scene trên cùng đã đổi
}
});Dán xong là đếm ngược ngay trong màn hiện tại (hook update chạy từ frame kế tiếp), và lặp lại mỗi lần vào màn mới. Gỡ bằng
OG.Level.removeHook('countdown-start').
Giải thích.
- Đóng băng miễn phí. Trong lúc đếm, stack là
['level', 'countdown'].Scenes.updatechỉ gọicountdown.update, nên vật lý, kẻ địch,st.tvà đồng hồOG.Game.tickTimeđều đứng yên. Không có dòngif (counting)nào tronglevel.js. - Vẫn thấy màn chơi.
drawBelow: truelàmScenes.drawvẽleveltrước rồi mới vẽ lớp phủ. - Vì sao push trong
updatemà không trongentercủa hook? Hookenterđược gọi từentercủa scenelevel, tức là bên trongScenes.go('level'). Lúc đógochưa phát'scene:change'. Nếu tapushở đây, thứ tự event sẽ làlevel → countdown (push)rồi mới tới→ level (go). Audio và các module nghe event sẽ hiểu sai. Ởupdatethìgođã xong hẳn. return truebáo chorunUpdateHooksdừng frame này. (Thật ra nó cũng tự dừng vìOG.Scenes.current() !== 'level', nhưng ghi rõ ý định vẫn tốt hơn.)- Cờ nằm trên
st, không nằm trong biến của hook. "Chơi lại màn" hay vào màn khác đều dựngstmới bằngbuildState, nên cờ tự mất mà không cần nghe event nào. Đây là lợi ích của việc tách dữ liệu màn (LevelDef) khỏi trạng thái chạy (st).
Trả lời câu hỏi kiểm tra. Giữ → trong lúc đếm: nhân vật không đi, OG.Level.state.t không tăng. Khi pop(), Input.reset()
xoá phím đang giữ. Với nhảy và tạm dừng, người chơi phải nhấn lại. Với ←/→ thì không cần: trên trình duyệt thật, phím đang
giữ được hệ điều hành bắn keydown lặp (e.repeat), và bindKeyboard cố ý cho trái/phải "giữ lại" ngay sau reset() (bài 03,
mục Đọc code 4). Vì vậy Oggy đi tiếp gần như ngay khi đếm xong. Sau đó level.resume() ẩn panel DOM (nếu có) và phát 'game:resume'.
Mở rộng. Đổi drawBelow thành false để thấy cách Scenes.draw bỏ qua scene dưới. Thử bấm P trong lúc đếm: không có gì xảy ra,
vì countdown.update không đọc phím pause. Muốn cho phép tạm dừng thì phải tự xử lý, và đó là một quyết định thiết kế.
⭐⭐⭐ Chuyển cảnh mờ dần #
(function () {
var FADE_DUR = 0.4; // giây cho mỗi nửa (tối dần / sáng dần)
var COLOR = '16,25,43';
OG.Scenes.register('fade', {
drawBelow: true,
enter: function (params) {
this.p = params || {};
this.mode = this.p.mode || 'out'; // 'out' = tối dần, 'in' = sáng dần
this.dur = this.p.dur || FADE_DUR;
this.t = 0;
},
update: function (dt) {
this.t += dt;
if (this.t < this.dur) return;
if (this.mode === 'out') {
var p = this.p, dur = this.dur;
// Đổi scene THẬT ở giữa hiệu ứng (lúc màn hình đen kịt) → người chơi không thấy chỗ "nối".
if (typeof p.fn === 'function') p.fn();
else OG.Scenes.go(p.to, p.params || {});
// go() đã exit cả stack (kể cả fade này). Đè fade mới lên scene vừa vào để sáng dần.
OG.Scenes.push('fade', { mode: 'in', dur: dur });
} else {
OG.Scenes.pop();
}
},
draw: function (ctx) {
var k = Math.min(1, this.t / this.dur);
var a = this.mode === 'out' ? k : 1 - k;
ctx.fillStyle = 'rgba(' + COLOR + ',' + a.toFixed(3) + ')';
ctx.fillRect(0, 0, OG.W, OG.H);
}
});
/**
* Chuyển scene có hiệu ứng mờ dần.
* @param {string|Function} to tên scene, hoặc hàm tự chuyển (vd vào màn phải qua OG.Game.startLevel)
* @param {Object} [params]
*/
window.fadeTo = function (to, params) {
if (OG.Scenes.current() === 'fade') return; // đang chuyển cảnh thì bỏ qua cú bấm thứ hai
OG.Scenes.push('fade', typeof to === 'function' ? { fn: to } : { to: to, params: params });
};
})();
// Ví dụ: fadeTo('menu'); fadeTo(function () { OG.Game.startLevel('garden'); });Diễn biến của stack khi đang chơi và gọi fadeTo('menu'):
['level'] → push fade(out) → ['level', 'fade'] màn chơi tối dần (đứng yên)
hết 0,4 s: go('menu') → exit level, fade → ['menu']
push fade(in) → ['menu', 'fade'] menu sáng dần
hết 0,4 s: pop() → ['menu'] gọi menu.resume() nếu scene có hàm nàyGiải thích.
- Hai pha trong một scene.
modelà một FSM hai trạng thái, giốngst.phasecủalevel. Không cần hai scenefadeOutvàfadeInriêng. - Chặn input. Lúc
fadenằm trên, scene dưới không đượcupdate, nên người chơi không bấm nhầm được gì trong 0,8 giây. - Chống bấm đúp. Kiểm tra
current() === 'fade'ngay đầufadeTo. Không có dòng này, gọi hai lần liên tiếp sẽ cho stack['level', 'fade', 'fade']. Stack không hỏng hẳn, vì fade trên cùng gọigo()và xoá cả hai. Nhưng hai mục đó là cùng một object: lầnpushthứ hai gọienter()đặttvề 0 và đổi đích sang đích của lần bấm sau, còn lớp tối bị vẽ hai lần nên đậm gấp đôi. Xem thêm câu (b) bên dưới. - Vì sao hỗ trợ
fn? Vào màn chơi phải quaOG.Game.startLevel(id): nó đảm bảo có lượt chơi (ensureRun), tìmLevelDef, rồi mớigo('level'). Quy ước này có ghi tronggoNextcủasrc/story/story.js. Truyền hàm giúpfadeTokhông cần biết những quy tắc đó.
Trả lời câu hỏi.
- (a) Panel DOM không mờ.
fadevẽ lên canvas, còn menu dựng panel bằng DOM trong#overlay, một lớp HTML nằm trên canvas. Canvas không thể vẽ đè lên DOM. Muốn mờ cả panel thì phải đổiopacitycủa#overlaybằng CSS. Đây là cái giá của thiết kế "DOM cho menu, canvas cho gameplay" (khối đầusrc/ui/menus.jsgiải thích lý do chọn). - (b) Một object, hai lần trong stack. Có. Ở frame đổi cảnh,
updatecủa fade(out) gọigo()(exit chính nó) rồipush('fade').enterchạy trên cùng object nên ghi đèthis.mode,this.t,this.p. May là ta đã chéppvàdurra biến cục bộ trước khigo. Nếu đọcthis.p.tosaupushthì sẽ nhận giá trị của fade(in). Nếu cùng lúc có hai fade sống trong stack (vdpushfade lên trên một fade khác) thì cả hai chia nhau một bộ trạng thái và chạy sai. Cách an toàn hơn cho scene có thể lồng nhau: đăng ký một factory (hàm tạo object mới) thay vì một object, hoặc lưu trạng thái vàoparamsriêng của từng lần push.
Mở rộng.
- Thay
rgbaphẳng bằng iris wipe (vòng tròn co lại quanh nhân vật): vẽ hình tròn bằngctx.arcvớiglobalCompositeOperation = 'destination-out'trên một canvas phụ (xem bài 07). - Dùng easing cho
k(vdk * k * (3 - 2 * k), smoothstep) để đầu và cuối hiệu ứng mềm hơn.