✋ 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: 06-event-bus-observer.md
Mọi đoạn dưới đây dán thẳng vào Console khi đang ở trong một màn chơi. Bấm vào Console làm game tự tạm dừng: dán xong thì bấm "Tiếp tục" để chơi tiếp (xem README mục 2.3).
⭐ Đếm cú nhảy, chúc mừng cú giẫm đầu tiên #
var jumps = 0;
var offJump = OG.Events.on(OG.EV.PLAYER_JUMP, function (e) {
jumps++;
if (jumps % 10 === 0) OG.DOM.toast('Bạn đã nhảy ' + jumps + ' lần! (' + e.charId + ')');
});
OG.Events.once(OG.EV.ENEMY_STOMP, function (e) {
OG.DOM.toast('Cú giẫm đầu tiên: ' + e.type + ' +' + e.score);
});
// Thôi nghe: offJump();Giải thích.
- Ta không sửa
player.jshaylevel.js. Cả hai tính năng "cắm" vào từ bên ngoài, đúng tinh thần open/closed. oncechính làon+ tựoff()ở lần gọi đầu (xem codeoncetrongsrc/core/events.js). Viết tay sẽ như sau:var off = OG.Events.on(name, function (p) { off(); ... }). Hàm huỷ được gọi trước khi xử lý, nên dù phần xử lý có ném lỗi thì listener vẫn đã được gỡ.offJump()gỡ đúng hàm đã đăng ký, vì hàm huỷ "nhớ" tham chiếufntrong closure. GọiOG.Events.off(OG.EV.PLAYER_JUMP, function (e) {...})với một hàm ẩn danh viết lại sẽ không gỡ được gì, vì đó là một object hàm khác.
Lưu ý. Nhảy đôi bằng Giày lò xo cũng phát 'player:jump' (payload có double: true, xem ARCHITECTURE mục 6), nên cũng được
đếm. Muốn chỉ đếm cú nhảy từ mặt đất thì thêm if (e.double) return;.
⭐⭐ Combo giẫm liên hoàn #
(function () {
var COMBO_WINDOW = 1.5; // giây: giẫm con kế tiếp trong khoảng này thì combo tăng
var COMBO_BONUS = 25; // điểm thưởng × số combo
var combo = 0;
var lastT = -Infinity; // OG.time lúc giẫm gần nhất, giây
function reset() { combo = 0; lastT = -Infinity; }
// Người PHÁT: chỉ biết "vừa có combo", không biết ai sẽ dùng thông tin này.
OG.Events.on(OG.EV.ENEMY_STOMP, function (e) {
combo = OG.time - lastT <= COMBO_WINDOW ? combo + 1 : 1;
lastT = OG.time;
if (combo >= 2) OG.Events.emit('combo:up', { count: combo, x: e.x, y: e.y });
});
OG.Events.on(OG.EV.PLAYER_HURT, reset);
OG.Events.on(OG.EV.LEVEL_START, reset);
// Hai người NGHE độc lập: thêm/bớt một cái không ảnh hưởng cái kia.
OG.Events.on('combo:up', function (c) {
OG.Particles.floatText(c.x + 17, c.y - 30, 'COMBO x' + c.count, '#ffc94a');
});
OG.Events.on('combo:up', function (c) {
OG.Game.addScore(COMBO_BONUS * c.count);
});
})();Giải thích.
- Ba tầng tách nhau. Gameplay phát
'enemy:stomp'→ bộ đếm combo phát'combo:up'→ hiệu ứng và điểm thưởng nghe'combo:up'. Muốn thêm âm thanh cho combo thì chỉ cần thêm listener thứ ba, ví dụOG.Events.on('combo:up', function () { OG.Audio.sfx('coin'); }). Hai phần kia không đổi. - Tên event mới theo quy ước
'<chủ thể>:<việc>'(ARCHITECTURE mục 6: "event mới do module tự phát và tự nghe không cần sửa core"). Nếu đưa vào dự án thật, nên thêm hằngCOMBO_UP: 'combo:up'vàoOG.EVvà ghi payload vàoINTEGRATION.mdcủa module. - Event lồng nhau.
addScorephát'score:change'bên trong listener của'combo:up', mà'combo:up'lại được phát bên trong listener của'enemy:stomp'. Mọi thứ chạy xong, đồng bộ, trước khiupdateEnemiessang con gián kế tiếp. - Điểm thưởng thực nhận có thể khác
25 × countmột chút:OG.Game.addScorenhân thêmrun.mods.scoreMul(hệ số điểm của nhân vật/vật phẩm) rồi làm tròn. -InfinitycholastTlàm phép trừOG.time - lastTluôn raInfinity, lớn hơn mọi cửa sổ, nên lần đầu luôn là combo 1 mà không cần cờ riêng.
Trả lời câu hỏi. Chữ bay vẫn hiện. emit gọi từng listener qua OG.util.safe (bắt lỗi, ghi log, đi tiếp), nên một
listener hỏng không chặn listener khác. Thứ tự gọi là thứ tự đăng ký: chữ bay đăng ký trước nên chạy trước. Nhưng kể cả đảo thứ
tự thì nó cũng vẫn chạy.
Bẫy nhỏ. OG.time vẫn tăng khi tạm dừng. Giẫm một con, bấm P 2 giây, chơi tiếp rồi giẫm con nữa thì combo bị mất. Muốn "thời
gian chơi thật" thì dùng OG.Level.state.t (chỉ tăng khi scene level được update, xem bài 05).
⭐⭐⭐ Bảng log event, không rò rỉ #
(function () {
var MAX_LINES = 6;
var LINE_LIFE = 4; // giây một dòng còn hiện
var IGNORE = { 'score:change': true, 'quest:progress': true };
var lines = []; // {name, t}
var off = null; // hàm huỷ đăng ký của listener đang sống (null = không nghe)
function stopListening() { if (off) { off(); off = null; } }
OG.Level.addHook({
id: 'event-log',
order: 99, // vẽ sau các hook khác
enter: function () {
// ⚠️ Hook KHÔNG có exit(): enter() chạy lại mỗi lần vào màn (kể cả "Chơi lại màn").
// Quên dòng dưới → mỗi lần vào màn thêm 1 listener, mỗi event bị ghi 2, 3, 4 lần.
stopListening();
lines = [];
off = OG.Events.on('*', function (name) {
if (IGNORE[name]) return;
lines.push({ name: name, t: OG.time });
if (lines.length > MAX_LINES) lines.shift();
});
},
drawScreen: function (ctx, st, t) {
ctx.save();
ctx.font = OG.font(14, 'bold');
ctx.textAlign = 'left';
for (var i = 0; i < lines.length; i++) {
var age = t - lines[i].t;
if (age > LINE_LIFE) continue;
ctx.globalAlpha = Math.max(0, 1 - age / LINE_LIFE);
var y = 120 + i * 20;
ctx.fillStyle = 'rgba(16,25,43,0.7)';
ctx.fillRect(12, y - 14, 200, 18);
ctx.fillStyle = '#fff6df';
ctx.fillText(lines[i].name, 18, y);
}
ctx.restore();
}
});
// Rời hẳn màn chơi (go sang scene khác) → thôi nghe. push/pop (pause, truyện chen giữa) thì vẫn giữ.
OG.Events.on(OG.EV.SCENE_CHANGE, function (e) {
if (!e.push && !e.pop && e.to !== 'level') stopListening();
});
window.eventLogLines = function () { return lines.slice(); };
})();
OG.Game.restartLevel(); // vào lại màn để enter() của hook chạyGỡ hẳn: OG.Level.removeHook('event-log'). (Listener 'scene:change' vẫn còn, nhưng vô hại: nó chỉ gọi stopListening.)
Kiểm chứng.
OG.Game.restartLevel(); OG.Game.restartLevel(); OG.Game.restartLevel();
OG.Events.emit('door:enter', { levelId: 'x' });
eventLogLines().filter(function (l) { return l.name === 'door:enter'; }).length; // → 1Xoá dòng stopListening(); trong enter, tải lại trang, dán lại rồi lặp thí nghiệm. Kết quả là 4:
restartLevel()ở dòng cuối đoạn code chạyenterlần 1 → listener thứ nhất. Ba lần chơi lại sau đó thêm ba listener nữa.- Listener
'scene:change'không cứu được: chơi lại màn làgo('level')từlevelsanglevel, mà ta chỉ dọn khie.to !== 'level'. lines = []ở mỗienterchỉ xoá dữ liệu cũ. Cả 4 listener vẫn sống và cùngpushvào biếnlines(closure đọc biến, không giữ mảng cũ), nên một event bị ghi 4 lần.
Mỗi lần chơi lại, con số tăng thêm 1. Đây là dấu hiệu điển hình của rò rỉ listener: hành vi sai tăng dần theo thời gian chơi, nên lúc test nhanh thường không thấy.
Giải thích các quyết định.
offsống trong closure, không gắn lênst.stbị bỏ đi khi chơi lại màn, mà ta cần chính hàm huỷ của lần trước để gỡ.- Hai điểm dọn dẹp.
enterdọn khi vào màn lần nữa. Listener'scene:change'dọn khi rời hẳn màn. Lọc!e.push && !e.popđể tạm dừng (pushpause) hay truyện chen giữa màn không làm mất log. Đây đúng là cáchOG.Learn.initdọn thông báo. - Không dùng
OG.Events.clear('*'). Hàm đó xoá mọi listener wildcard, kể cả của người khác (smoke test dùng'*'để đếm event). Chỉ được gỡ thứ mình đã đăng ký. - Giới hạn bộ nhớ.
lines.shift()giữ mảng tối đa 6 phần tử. Một bộ log không giới hạn cũng là một kiểu rò rỉ: chơi lâu, mảng phình mãi.
Mở rộng. Đếm số event mỗi giây theo từng tên (dùng một Object<string, number> và đặt lại mỗi giây) và vẽ thành thanh ngang.
Bạn sẽ thấy event nào "ồn" nhất. Đó là dữ liệu để quyết định có cần event queue hay gộp event không.