Mỗi đấu sĩ trong game là một package độc lập gồm 1 ảnh sprite + 4 file code JavaScript. Bạn upload cả folder qua trang Admin, hệ thống tự đọc và ghép thành 1 bóng chiến đấu được.
4 file code chia đúng 4 vai trò, mỗi vai trò chạy độc lập với nhau mỗi khung hình (frame):
| File | Vai trò | Chạy khi nào |
|---|---|---|
weapon.js | Vẽ vũ khí + tính va chạm cận chiến | Mỗi frame, 1 lần |
ai.js | Quyết định di chuyển | Mỗi frame, đầu tiên |
skill.js | Kỹ năng đặc biệt (buff, phi tiêu, vùng nổ...) | Mỗi frame, sau AI |
anim.js | Chọn frame sprite hiển thị | Mỗi frame, cuối cùng |
Tạo 1 folder bất kỳ, đặt tên gì cũng được, bên trong chứa:
Tên file linh hoạt — hệ thống chỉ cần tìm thấy từ khóa trong tên (không phân biệt hoa/thường). weapon_katana.js, WeaponCode.js, my-weapon-v2.js đều nhận diện đúng là "weapon". Nếu thiếu 1 trong 4 vai trò, hoặc thiếu ảnh, Admin sẽ báo lỗi rõ ràng và không lưu.
config.json bằng cols/rows — hitbox va chạm của bóng sẽ tự tính bằng kích thước 1 frame (chiều rộng ảnh ÷ cols, chiều cao ÷ rows).
Hoàn toàn tùy chọn — nếu không có, hệ thống dùng giá trị mặc định. Tất cả field cũng tùy chọn riêng lẻ.
{
"cols": 4,
"rows": 1,
"maxHP": 200,
"maxSpeed": 4,
"baseDamage": 8,
"radius": 28,
"color": "#4caf50",
"skillName": "Tên kỹ năng hiển thị",
"skillDesc": "Mô tả ngắn kỹ năng làm gì"
}
| Field | Mặc định | Ghi chú |
|---|---|---|
cols / rows | 1 / 1 | Số cột/hàng cắt sprite sheet |
maxHP | 200 | Máu tối đa |
maxSpeed | 4 | Tốc độ di chuyển tối đa |
baseDamage | 8 | Sát thương mặc định cho vũ khí/phi tiêu nếu code không tự ghi đè |
radius | tự tính theo ảnh | Bán kính va chạm — thường để trống, dùng giá trị tự động |
color | #888888 | Màu fallback khi ảnh chưa tải xong |
Nhận 3 tham số: (self, ctx, api). Nhiệm vụ: vẽ vũ khí bằng api.drawLine/drawCircle/drawTriangleTip, rồi return vị trí đầu vũ khí để engine tự tính va chạm.
self.weaponAngle = (self.weaponAngle || 0) + 0.12 * self.weaponBoost;
const len = 46, width = 10;
const tipX = self.x + Math.cos(self.weaponAngle) * len;
const tipY = self.y + Math.sin(self.weaponAngle) * len;
api.drawLine(self.x, self.y, tipX, tipY, "#c9d6e3", width);
api.drawCircle(tipX, tipY, width * 0.55, "#e8eef5");
// Bắt buộc: trả về vị trí đầu vũ khí + bán kính va chạm để gây damage
return { tipX, tipY, hitRadius: width / 2 + 14 };
// Không vẽ gì hoặc chỉ vẽ trang trí, rồi trả về null
return null;
Giá trị trả về (object hoặc null):
| Field | Bắt buộc? | Ý nghĩa |
|---|---|---|
tipX, tipY | Có | Tọa độ đầu vũ khí |
hitRadius | Không (mặc định 8) | Bán kính vùng gây damage quanh đầu vũ khí |
originX, originY | Không (mặc định = tâm bóng) | Điểm bắt đầu của vũ khí — engine tính va chạm theo cả đoạn thẳng từ đây đến tipX/tipY, không chỉ điểm đầu mũi |
damageOverride | Không (mặc định dùng Sát thương cơ bản) | Ghi đè damage riêng cho đòn cận chiến này |
Nhận (self, enemy, api). Nhiệm vụ duy nhất: chỉnh self.vx / self.vy để điều khiển hướng di chuyển. Vật lý va chạm (nảy tường, va bóng) engine tự lo — AI không thể và không cần can thiệp vào phần đó.
const dx = enemy.x - self.x, dy = enemy.y - self.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
if (api.now % 15 === 0) {
self.vx += (dx / dist) * 0.9 + (Math.random() - 0.5) * 0.6;
self.vy += (dy / dist) * 0.9 + (Math.random() - 0.5) * 0.6;
}
const dx = enemy.x - self.x, dy = enemy.y - self.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const desiredDist = 180;
if (api.now % 20 === 0) {
const away = dist < desiredDist ? -1 : 0.3;
self.vx += (dx / dist) * away * 1.2 + (Math.random() - 0.5) * 1.0;
self.vy += (dy / dist) * away * 1.2 + (Math.random() - 0.5) * 1.0;
}
api.now % N === 0 để chỉ ra quyết định mỗi N frame thay vì mỗi frame — tránh AI "rung" liên tục và giúp chuyển động tự nhiên hơn.
Nhận (self, enemy, api). Đây là nơi duy nhất có quyền gây damage đặc biệt, tạo phi tiêu, tạo vùng nổ, đổi trạng thái animation. Cooldown/thời điểm kích hoạt tự quản lý bằng cách lưu vào self.skillState (object tự do, bạn muốn lưu gì cũng được).
if (!self.skillState) self.skillState = { cooldown: 0, active: 0 };
if (self.skillState.active > 0) {
self.skillState.active--;
self.weaponBoost = 2.0;
} else {
self.weaponBoost = 1.0;
if (self.skillState.cooldown > 0) {
self.skillState.cooldown--;
} else {
self.skillState.active = 60; // buff kéo dài 60 frame
self.skillState.cooldown = 300; // cooldown 300 frame
}
}
if (!self.skillState) self.skillState = { cooldown: 0 };
if (self.skillState.cooldown > 0) {
self.skillState.cooldown--;
} else {
const dx = enemy.x - self.x, dy = enemy.y - self.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const spawned = api.spawnProjectile({
vx: (dx / dist) * 8,
vy: (dy / dist) * 8,
damage: 6,
radius: 8,
lifespan: 90
});
if (spawned) self.skillState.cooldown = 100;
}
Khác với phi tiêu tự tạo ở trên (bay 1 vật thể mới), cách này bắn đi đúng cây vũ khí đang quay quanh thân bóng — thân bóng sẽ mất khả năng cận chiến cho tới khi vũ khí bay đủ 3 lần nảy tường và tự quay về. Cần phối hợp 2 file cùng lúc:
if (!self.skillState) self.skillState = { cooldown: 0 };
if (self.mainWeaponFlying) {
// Vũ khí đang bay -> không bắn thêm, đợi nó tự về (đúng luật:
// "đợi vũ khí chính bật 3 lần và quay về thì mới xài tiếp")
} else if (self.skillState.cooldown > 0) {
self.skillState.cooldown--;
} else {
const dx = enemy.x - self.x, dy = enemy.y - self.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const spawned = api.spawnProjectile({
vx: (dx / dist) * 7,
vy: (dy / dist) * 7,
damage: 15, // damage riêng cho lúc bay (không nhất thiết = cận chiến)
radius: 10,
kind: "mainWeapon" // <- bắn chính vũ khí, không phải vật thể mới
});
if (spawned) self.skillState.cooldown = 250;
}
// Khi vũ khí đang bay làm phi tiêu, thân bóng không còn gì quay quanh nữa
if (self.mainWeaponFlying) return null;
// Code vẽ vũ khí bình thường — dùng LẠI CHÍNH đoạn code này để vẽ hình
// dạng lúc bay (engine tự gọi lại weapon.js với self giả đặt tại vị trí
// phi tiêu để hình dạng lúc bay giống hệt lúc quay quanh thân)
self.weaponAngle = (self.weaponAngle || 0) + 0.12 * self.weaponBoost;
const len = 46, width = 10;
const tipX = self.x + Math.cos(self.weaponAngle) * len;
const tipY = self.y + Math.sin(self.weaponAngle) * len;
api.drawLine(self.x, self.y, tipX, tipY, "#c9d6e3", width);
api.drawCircle(tipX, tipY, width * 0.55, "#e8eef5");
return { tipX, tipY, hitRadius: width / 2 + 14 };
if (self.mainWeaponFlying) return null;? Khi vũ khí đang bay, engine vẫn gọi weapon.js mỗi frame như bình thường để kiểm tra xem thân bóng có nên vẽ/gây damage cận chiến không. Nếu code không tự kiểm tra cờ này, vũ khí sẽ vừa bay đi vừa "mọc lại" ngay trên thân bóng — sai với ý đồ "chỉ có 1 vũ khí duy nhất, đang bay thì thân trống tay".
if (!self.skillState) self.skillState = { cooldown: 0 };
if (self.skillState.cooldown > 0) {
self.skillState.cooldown--;
} else {
api.setAnimTag("cast");
api.spawnZoneEffect({
x: enemy.x, y: enemy.y, // đánh dấu ngay vị trí địch lúc kích hoạt
radius: 50,
delay: 180, // đợi 180 frame (~3 giây @ 60fps) rồi nổ
damage: 35,
damageMode: "once"
});
self.skillState.cooldown = 400;
}
Nhận (self, api). Trả về {col, row} — tọa độ frame trong lưới sprite muốn hiển thị ngay lúc này.
if (!self.animState) self.animState = { tick: 0 };
self.animState.tick++;
const cols = self.sprite ? self.sprite.cols : 1;
const totalFrames = cols * (self.sprite ? self.sprite.rows : 1);
const frameIndex = Math.floor(self.animState.tick / 8) % totalFrames;
return { col: frameIndex % cols, row: Math.floor(frameIndex / cols) };
// self.animTag tự động là "idle" bình thường, tự thành "hurt" khi vừa
// trúng đòn (12 frame), hoặc bất kỳ chuỗi nào skill.js đặt qua setAnimTag.
if (self.animTag === "hurt") {
return { col: 3, row: 0 }; // frame nhăn mặt
}
if (self.animTag === "raise_hand") {
return { col: 2, row: 0 }; // frame giơ tay ra hiệu
}
// Mặc định: animation idle chạy vòng lặp
if (!self.animState) self.animState = { tick: 0 };
self.animState.tick++;
const frameIndex = Math.floor(self.animState.tick / 10) % 2;
return { col: frameIndex, row: 0 };
self.animTag được engine tự quản lý một phần: mặc định "idle", tự chuyển sang "hurt" trong 12 khung hình ngay sau khi bóng nhận damage (từ bất kỳ nguồn nào — vũ khí, phi tiêu, hay vùng nổ), rồi tự trả lại giá trị trước đó. Bạn không cần tự viết logic này — chỉ cần đọc self.animTag trong anim.js và xử lý theo ý muốn.
| Hàm / Biến | Dùng trong | Mô tả |
|---|---|---|
api.now | Cả 4 loại | Số khung hình hiện tại từ đầu trận |
api.drawLine(x1,y1,x2,y2,color,width) | weapon | Vẽ 1 đoạn thẳng |
api.drawCircle(x,y,r,color) | weapon | Vẽ 1 hình tròn đặc |
api.drawTriangleTip(x,y,angle,len,color) | weapon | Vẽ đầu mũi tam giác (kiểu mũi giáo) |
api.applyDamage(target, amount) | skill | Gây damage trực tiếp lên 1 bóng (target là self hoặc enemy) |
api.heal(target, amount) | skill | Hồi máu, không vượt quá maxHP |
api.setAnimTag(tag) | skill | Đổi self.animTag sang chuỗi tùy ý |
api.spawnProjectile(opts) | skill | Phóng phi tiêu (tối đa 1 cái đang bay/bóng) |
api.spawnZoneEffect(opts) | skill | Tạo vùng hiệu ứng hẹn giờ tại tọa độ cố định |
| Field | Mặc định | Ý nghĩa |
|---|---|---|
vx, vy | 0, 0 | Vận tốc bay ban đầu |
damage | baseDamage của bóng | Sát thương khi trúng |
radius | 6 | Bán kính va chạm của phi tiêu |
color | vàng nhạt | Màu vẽ (chỉ áp dụng khi kind:"custom") |
lifespan | 600 | Số frame tối đa trước khi tự bay về chủ |
returnTo | không | "owner" = bay về sớm sau khi bay quá nửa lifespan (kiểu boomerang) |
kind | "custom" | "custom" = vật thể tự tạo hình tròn; "mainWeapon" = bắn chính vũ khí đang quay quanh thân — xem mục 6 để biết cách phối hợp với weapon.js |
Phi tiêu tự động nảy tường như bi-a; sau đúng 3 lần nảy, tự bẻ hướng bay thẳng về chủ dù không set returnTo. Với kind:"mainWeapon": dù có bay trúng đối thủ giữa đường, vũ khí không biến mất — vẫn tiếp tục hành trình cho tới khi đủ 3 lần nảy tường rồi mới tự quay về; trong suốt thời gian đó thân bóng không có vũ khí nào để đánh cận chiến.
| Field | Mặc định | Ý nghĩa |
|---|---|---|
x, y | vị trí bóng chủ | Tọa độ cố định của vùng hiệu ứng (không di chuyển) |
radius | 40 | Bán kính vùng ảnh hưởng |
delay | 0 | Số frame đợi trước khi kích hoạt (vd 180 ≈ 3 giây) |
damage | baseDamage của bóng | Sát thương mỗi lần gây |
damageMode | "once" | "once" = nổ 1 phát khi hết delay; "tick" = gây damage đều đặn |
duration | 0 | (chỉ dùng với "tick") Số frame tồn tại sau khi kích hoạt |
tickInterval | 30 | (chỉ dùng với "tick") Số frame giữa mỗi lần gây damage |
friendlyFire | false | true nếu bóng chủ cũng có thể bị chính vùng này gây hại |
color | cam đỏ | Màu vẽ vùng hiệu ứng |
Minh họa cách kết hợp setAnimTag + spawnZoneEffect để tạo skill: khi kích hoạt, đổi hình sang tư thế giơ tay, đánh dấu vị trí đối thủ, đợi 3 giây rồi một quả tên lửa rơi xuống gây nổ diện rộng. Đây chỉ là ví dụ tham khảo — không có sẵn trong game, bạn cần tự tạo folder và sprite riêng nếu muốn dùng.
return null;
const dx = enemy.x - self.x, dy = enemy.y - self.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const desiredDist = 200;
if (api.now % 20 === 0) {
const away = dist < desiredDist ? -1 : 0.4;
self.vx += (dx / dist) * away * 1.0 + (Math.random() - 0.5) * 0.8;
self.vy += (dy / dist) * away * 1.0 + (Math.random() - 0.5) * 0.8;
}
if (!self.skillState) self.skillState = { cooldown: 0, raising: 0 };
if (self.skillState.raising > 0) {
// Đang trong tư thế giơ tay (30 frame) trước khi thực sự đánh dấu mục tiêu
api.setAnimTag("raise_hand");
self.skillState.raising--;
if (self.skillState.raising === 0) {
api.spawnZoneEffect({
x: enemy.x, y: enemy.y, // chốt vị trí đối thủ NGAY lúc này
radius: 55,
delay: 180, // 180 frame ~ 3 giây (@ 60fps)
damage: 35,
damageMode: "once",
color: "#ff5533"
});
}
} else if (self.skillState.cooldown > 0) {
self.skillState.cooldown--;
} else {
self.skillState.raising = 30; // bắt đầu tư thế giơ tay
self.skillState.cooldown = 500; // cooldown dài vì damage cao
}
if (self.animTag === "raise_hand") {
return { col: 2, row: 0 }; // frame giơ tay trong sprite sheet
}
if (self.animTag === "hurt") {
return { col: 3, row: 0 }; // frame trúng đòn (engine tự set, không cần gọi)
}
// idle mặc định: đi bộ tại chỗ, lặp giữa frame 0 và 1
if (!self.animState) self.animState = { tick: 0 };
self.animState.tick++;
const frameIndex = Math.floor(self.animState.tick / 12) % 2;
return { col: frameIndex, row: 0 };
{
"cols": 4,
"rows": 1,
"maxHP": 180,
"maxSpeed": 4.5,
"baseDamage": 35,
"color": "#5a7a4a",
"skillName": "Không Kích",
"skillDesc": "Giơ tay đánh dấu mục tiêu, 3 giây sau tên lửa rơi xuống gây 35 sát thương diện rộng."
}
Đây là phần cố định trong engine — áp dụng cho mọi bóng, kể cả bóng mới tạo, không code nào (weapon/ai/skill/anim) có thể ghi đè hay tắt được:
self.vx/vy) — không có cách nào trong 4 loại code khiến bóng xuyên tường, dịch chuyển tức thời, hay bỏ qua va chạm với bóng đối thủ.
Điều này đảm bảo mọi bóng — dù skill có sáng tạo đến đâu — vẫn tuân theo cùng 1 luật chơi vật lý nền tảng, giữ cho trận đấu luôn công bằng và có thể đoán trước được ở cấp độ cơ bản nhất.