✋ 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: 09-ai-ke-dich-boss.md
Hai bài đầu dán vào Console khi đang ở trong một màn chơi. Bài ba dán ở bất kỳ đâu rồi gọi OG.Game.ensureRun() và
OG.Scenes.go('boss', ...) (thiếu ensureRun() khi chưa bấm "Chơi" thì boss.enter báo lỗi, xem bài 09 "Thử ngay" bước 1).
⭐ Ốc sên #
OG.Enemies.registerType('snail', {
w: 36, h: 24, score: 30,
init: function (e) { e.speed *= 0.4; }, // chậm hơn gián: 75 → 30 px/giây (trước khi độ khó nhân thêm)
update: function (e, dt) {
e.x += e.dir * e.speed * dt;
if (e.x - e.base > e.range) { e.x = e.base + e.range; e.dir = -1; } // kẹp biên rồi quay đầu
else if (e.base - e.x > e.range) { e.x = e.base - e.range; e.dir = 1; }
},
draw: function (ctx, e, t) {
ctx.save();
ctx.translate(e.x + e.w / 2, e.y + e.h); // gốc = chân (giữa-đáy hitbox), toạ độ thế giới
if (!e.alive) { // bị giẫm: bẹp + mờ dần (Enemies.draw tự ẩn sau 0.5 s)
ctx.globalAlpha = Math.max(0, 1 - e.deadT * 2);
ctx.scale(1.3, 0.3);
}
ctx.scale(e.dir, 1); // vẽ hướng phải, scale(-1) lật sang trái
ctx.lineWidth = 2.5; ctx.strokeStyle = '#17233d';
// thân (vẽ trước) → vỏ (đè lên) → mắt trên cọng (sau cùng)
var crawl = Math.sin(e.t * 6) * 2; // thân co duỗi khi bò
ctx.fillStyle = '#c9e27a';
ctx.beginPath(); ctx.ellipse(2, -5, 18 + crawl, 6, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#d98b4a';
ctx.beginPath(); ctx.arc(-3, -13, 11, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.arc(-3, -13, 5, 0, Math.PI * 1.5); ctx.stroke(); // vân xoắn
ctx.beginPath(); ctx.moveTo(14, -8); ctx.lineTo(17, -22); ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.beginPath(); ctx.arc(17, -23, 3.5, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.restore();
}
});
// Thả một con trước mặt người chơi (đang ở trong màn):
var st = OG.Level.state;
st.enemies.push(OG.Enemies.create({ type: 'snail', x: st.player.x + 250, range: 80 }, st.enemies.length));Giải thích.
- Không sửa dòng nào của
level.jshayenemies.js.Enemies.createtratypes['snail'],Enemies.update/drawgọiT.update/T.draw, cònupdateEnemiestronglevel.jslo giẫm (e.stompable, mặc địnhtrue) và chạm (OG.Enemies.hurtbox(e)) cho mọi loại. Đó là sức mạnh của strategy + registry. initchạy trướcscaleEnemy. Trongcreate, thứ tự làT.init(e, src)rồi mớiOG.util.call('Difficulty', 'scaleEnemy', e). Ta giảm tốc độ gốc, độ khó vẫn được áp đúng lên trên (chế độ Bé chơi ×0,7 nữa).- Hitbox mặc định đặt trên sàn.
createđặty = OG.FLOOR_Y − hkhi dữ liệu không ghiy. Ốc sên thả trên một đoạn hố (sàn bị ngắt) sẽ "bò trên không", vì AI tuần tra này không xét sàn bên dưới. Muốn chuẩn thì phải dòworld.platformsnhư hàmunderLedgecủa Dee Dee. - Vẽ một lần, cho cả hai hướng.
ctx.scale(e.dir, 1)lật toàn bộ hình. Hàm vẽ chỉ cần nghĩ "nhìn sang phải". - Thứ tự lớp (painter's algorithm): thân → vỏ đè lên thân → cọng mắt sau cùng.
⭐⭐ Gián "đuổi bắt" #
(function () {
var SEE_X = 260; // px: thấy người chơi trong khoảng ngang này
var SEE_Y = 120; // px: và lệch dọc không quá chừng này (đứng trên bục cao thì không thấy)
var ALERT_TIME = 0.6; // giây telegraph: đứng lại, hiện "!"
var CHASE_TIME = 2.5; // giây đuổi
var CHASE_MUL = 2.2; // tốc độ đuổi = 2.2 × tốc độ tuần tra
var TIRED_TIME = 1.2; // giây mệt: đứng thở, KHÔNG gây đau (khoảng hở)
function setMode(e, mode) { e.mode = mode; e.modeT = 0; e.hurts = mode !== 'tired'; }
OG.Enemies.registerType('chaser', {
w: 34, h: 28, score: 90,
init: function (e) { setMode(e, 'patrol'); },
update: function (e, dt, world) {
var p = world && world.player;
e.modeT += dt;
var dx = p ? (p.x + p.w / 2) - (e.x + e.w / 2) : Infinity;
var sees = p && Math.abs(dx) < SEE_X && Math.abs((p.y + p.h) - (e.y + e.h)) < SEE_Y;
switch (e.mode) {
case 'patrol':
e.x += e.dir * e.speed * dt;
if (e.x - e.base > e.range) { e.x = e.base + e.range; e.dir = -1; }
else if (e.base - e.x > e.range) { e.x = e.base - e.range; e.dir = 1; }
if (sees) setMode(e, 'alert');
break;
case 'alert': // telegraph: quay về phía người chơi, đứng yên
e.dir = dx < 0 ? -1 : 1;
if (e.modeT >= ALERT_TIME) setMode(e, 'chase');
break;
case 'chase':
e.dir = dx < 0 ? -1 : 1;
e.x += e.dir * e.speed * CHASE_MUL * dt;
if (world && world.width) e.x = Math.max(0, Math.min(world.width - e.w, e.x)); // không chạy khỏi màn
if (e.modeT >= CHASE_TIME) setMode(e, 'tired');
break;
case 'tired':
if (e.modeT >= TIRED_TIME) { e.base = e.x; setMode(e, 'patrol'); } // tuần tra quanh chỗ mới
break;
}
},
draw: function (ctx, e, t) {
var anim = !e.alive ? 'hurt' : e.mode === 'chase' ? 'attack' : e.mode === 'tired' ? 'stunned'
: e.mode === 'alert' ? 'idle' : 'run';
ctx.save();
ctx.translate(e.x + e.w / 2, e.y + e.h);
if (!e.alive) { ctx.globalAlpha = Math.max(0, 1 - e.deadT * 2); ctx.scale(1.2, 0.35); }
OG.Sprites.drawCharacter(ctx, 'roach', anim, e.t, { face: e.dir, w: e.w, h: e.h, colors: { body: '#c0392b' } });
ctx.restore();
if (e.alive && e.mode === 'alert') { // dấu "!" nhấp nháy phía trên đầu
ctx.save();
ctx.font = OG.font(26, 'bold'); ctx.textAlign = 'center';
ctx.lineWidth = 5; ctx.strokeStyle = '#17233d'; ctx.fillStyle = '#e5484d';
var y = e.y - 10 - Math.abs(Math.sin(e.modeT * 12)) * 6;
ctx.strokeText('!', e.x + e.w / 2, y); ctx.fillText('!', e.x + e.w / 2, y);
ctx.restore();
}
}
});
})();
var st = OG.Level.state;
st.enemies.push(OG.Enemies.create({ type: 'chaser', x: st.player.x + 400, range: 120 }, st.enemies.length));Giải thích.
- Một hàm
setModecho mọi phép chuyển. Đặt lạimodeTvà cập nhậte.hurtsở một chỗ, nên không bao giờ quên. Đây là phiên bản mini củab.setStatetrongK.createBase. Ở đây đếm lên (modeT += dt, so với thời lượng) thay vì đếm xuống như Joey. Hai cách tương đương; đếm lên tiện hơn khi cần biết "đã ở trạng thái này bao lâu" (dùng cho hiệu ứng nhấp nháy của "!"). - Telegraph phải nhìn thấy được.
alertvừa có dấu "!" vừa đổi animation sangidle(đứng khựng). 0,6 giây đủ để bé kịp nhảy. Thử giảmALERT_TIMExuống 0,1: trò chơi lập tức trở nên "bất công", dù AI không mạnh hơn chút nào. - Khoảng hở thật sự an toàn.
e.hurts = falsetrongtirednên chạm vào không đau, mà vẫn giẫm được (stompable). Người chơi được thưởng vì né được cú lao. - "Thấy" theo cả hai trục. Chỉ xét khoảng ngang thì gián dưới sàn sẽ "thấy" người chơi đứng trên bục cao rồi lao qua lao lại bên
dưới. Điều kiện dọc
SEE_Yloại trường hợp đó. - Vẽ chữ ngoài phép lật. "!" được vẽ sau
ctx.restore(), tức ngoàiface, nên không bị ngược.drawCharactercủa Sprites cũng làm vậy với chữ "OÁI!". colors: { body: ... }đổi màu thân gián.colorsnằm trong khoá cache củasprites.js(bài 07), nên gián đỏ và gián tím không dùng nhầm khung của nhau.
Mở rộng. Thêm điều kiện "mất dấu": trong chase, nếu người chơi ra khỏi SEE_X × 1,5 thì chuyển sớm sang tired. Khoảng
"thấy" (260) nhỏ hơn khoảng "mất dấu" (390) là hysteresis (trễ), giúp AI không chập chờn bật/tắt đuổi khi người chơi đứng đúng
ở mép tầm nhìn. src/story/guide.js dùng cùng ý tưởng cho NPC.
⭐⭐⭐ Trùm "Gián Khổng Lồ" #
(function () {
var K = OG.Bosses.kit;
/** @type {BossSpec} */
var SPEC = {
id: 'bigroach', name: 'Gián Khổng Lồ', charId: 'roach', maxHp: 4, scale: 2.6,
phaseAt: [2], // hp ≤ 2 → pha 2
phaseLines: { 2: 'Gián Khổng Lồ nổi điên! Lao nhanh hơn!' },
patterns: { 1: ['walk', 'charge'], 2: ['charge', 'charge', 'walk'] }
};
var WALK_SPEED = 60; // px/giây
var CHARGE_SPEED = [420, 540]; // px/giây theo pha
var WIND_TIME = [0.9, 0.6]; // giây telegraph theo pha
var DIZZY_TIME = 1.6; // giây choáng sau khi đâm tường (khoảng hở chính)
function create(arena, cfg) {
var b = K.createBase(SPEC, arena, cfg);
b.chargeDir = -1;
// Trạng thái nào giẫm được / trạng thái nào cả thân gây đau.
b.isVulnerable = function () { return b.state === 'walk' || b.state === 'dizzy'; };
b.isFullHurt = function () { return b.state === 'charge'; };
// Bắt đầu một đòn: chỉ ĐẶT trạng thái đầu tiên; think() lo phần còn lại.
b.startAction = function (a, arena) {
b.faceToPlayer(arena);
if (a === 'charge') {
b.chargeDir = b.dirToPlayer(arena);
b.setState('chargeWind', b.byPhase(WIND_TIME)); // tên kết thúc bằng "Wind" → kid mode kéo dài
} else {
b.setState('walk', 1.5);
}
};
// Mỗi case = một trạng thái: đặt vận tốc/animation, rồi kiểm tra điều kiện chuyển.
b.think = function (dt, arena) {
switch (b.state) {
case 'intro':
b.vx = 0; b.setAnim('idle'); b.faceToPlayer(arena);
if (b.stateDone()) b.choose(arena);
break;
case 'walk':
b.face = b.dirToPlayer(arena);
b.vx = b.face * WALK_SPEED;
b.setAnim('run');
if (b.stateDone()) { b.vx = 0; b.choose(arena); }
break;
case 'chargeWind':
b.vx = 0; b.face = b.chargeDir; b.setAnim('attack');
if (b.stateDone()) b.setState('charge', 3);
break;
case 'charge':
b.vx = b.chargeDir * b.byPhase(CHARGE_SPEED);
b.setAnim('run');
// stateDone() là lưới an toàn: lỡ không chạm tường thì 3 giây sau vẫn thoát.
if (b.hitWall === b.chargeDir || b.stateDone()) {
b.vx = 0;
K.call(arena, 'shake', 8, 0.3);
K.puff(arena, b.cx(), b.y + b.h / 2, '#ffffff', 12, 220);
b.setState('dizzy', DIZZY_TIME); // 'dizzy' có trong STUN_STATES → kid mode choáng lâu hơn
}
break;
case 'dizzy':
b.vx = 0; b.setAnim('stunned');
if (b.stateDone()) b.choose(arena);
break;
}
};
// Telegraph vẽ SAU LƯNG trùm: vùng đỏ trên sàn theo hướng sắp lao, đậm dần theo tiến độ.
b.drawBehind = function (ctx, arena, t) {
if (b.state !== 'chargeWind') return;
var x0 = b.chargeDir > 0 ? b.x + b.w : 0;
var w = b.chargeDir > 0 ? K.width(arena) - x0 : b.x;
K.drawWarnZone(ctx, x0, w, K.floorY(arena), t, b.stateT / Math.max(0.01, b.stateDur));
};
b.drawFront = function (ctx, arena, t) {
if (b.state === 'chargeWind') K.drawArrow(ctx, b.cx() + b.chargeDir * b.w * 0.6, b.y + b.h * 0.4, b.chargeDir, t);
};
return b;
}
OG.Bosses.factories.bigroach = create;
OG.Bosses.register({ id: SPEC.id, name: SPEC.name, maxHp: SPEC.maxHp, create: function (arena) { return create(arena); } });
})();
// Đánh thử (ghi tiến độ màn kitchen vào save khi thắng):
// OG.Game.ensureRun(); OG.Scenes.go('boss', { levelId: 'kitchen', bossId: 'bigroach' });Trong một lần chạy thử, chuỗi trạng thái ghi được là intro → walk → chargeWind → charge → dizzy → recoil → ... → roar → ... → dead.
recoil (bị đẩy lùi khi trúng đòn), roar (gầm khi sang pha 2) và dead là trạng thái chung do K.createBase tự lo, ta không
viết dòng nào cho chúng.
Giải thích.
- Ta chỉ viết phần "riêng". Máu, pha, toast đổi pha, phát
'boss:phase'(HUD tự hiện banner "GIAI ĐOẠN 2" nhờ nghe event này), i-frames, nháy trắng khi trúng đòn, weakspot/hurtbox, mũi tên "GIẪM!", vật lý, chết và văng ra đều có sẵn trong khung. Đó là template method:b.updategọib.thinkcủa ta ở đúng chỗ trong một quy trình cố định. - Phân vai rõ ràng.
startAction= "bắt đầu đòn nào" (chỉ đặt trạng thái đầu).think= "mỗi frame, trạng thái hiện tại làm gì và khi nào chuyển".choose(của khung) = "đòn kế tiếp là gì". Tách như vậy, thêm đòn mới chỉ cần thêm một nhánh trongstartActionvà vàicasetrongthink. - Mỗi trạng thái có lối thoát theo giờ.
chargethoát khi đụng tường hoặc sau 3 giây. Nếu lỡ đặt trùm ở giữa một arena không có tường (vd arena hành trình rộng hơn), FSM vẫn không bao giờ kẹt. - Telegraph có ba lớp tín hiệu: đứng khựng + animation
attack, vùng đỏ đậm dần trên sàn (K.drawWarnZone, tiến độstateT / stateDur), mũi tên chỉ hướng (K.drawArrow). Ở chế độ Bé chơi, khung còn vẽ thêm vòng đếm ngược (K.drawTelegraph) cho mọi trạng thái...Wind. - Dependency injection. Trùm rung màn hình bằng
K.call(arena, 'shake', ...), không phảiOG.Camera.shake. Trùm này chạy được với bất kỳ arena nào có (hoặc không có) hàmshake, kể cả arena giả trong test.
Vì sao chế độ Bé chơi tự dễ hơn mà không cần thêm code?
scene 'boss'nhândtcủa trùm vớibossTimeScale()= 0,75: mọi thứ (đi bộ, lao, đếm giờ) chậm lại 25%.b.setState('chargeWind', ...): tên khớp/Wind$/nên thời lượng ×bossTelegraphMul(1,3).b.setState('dizzy', ...):'dizzy'nằm trongSTUN_STATESnên thời lượng ×bossStunMul(1,35).K.createBaseco máu theobossHpMul(0,6): 4 × 0,6 = 2,4, làm tròn còn 2 máu, và ngưỡng pha co theo ([2]thành[1]).b.choosechènbreather0,9 giây sau mỗicharge(vì'charge'không nằm trongCALM_ACTIONS).
Nếu bạn đặt tên chargePrep thay vì chargeWind, hay stunned thay vì dizzy, thì (2) hoặc (3) im lặng không xảy ra. Đó là
cái giá của "quy ước thay cho cấu hình" mà ⚠️ Bẫy trong b.setState cảnh báo.
Mở rộng.
- Thêm đòn thứ ba
'stomp': nhảy lên (b.vy = -500), khib.landedthì sinh sóng xung kích chạy dọc sàn bằngK.spawnProj. ĐọcslamWindvà sóng trongsrc/bosses/marky.jsđể tham khảo. Nhớ telegraph (stompWind) trước. - Cho Gián Khổng Lồ gọi lính bằng
arena.spawnEnemy({ type: 'chaser', x: ..., range: 100 })(kẻ địch của bài ⭐⭐). Scenebosstự cập nhật và xét va chạm choarena.enemies. - Đăng ký trùm vào một màn thật: trong dữ liệu màn,
boss: 'bigroach'.OG.Game.finishStagesẽ tự đưa người chơi vào trận khi qua cửa (xem bài 05 và bài 12).