326 lines
12 KiB
JavaScript
326 lines
12 KiB
JavaScript
// BOSS 战系统:多阶段弹幕 / 冲撞 / 刺雨
|
|
import { clamp, sgn, rand, randInt, overlap } from './util.js';
|
|
import { sfx } from './audio.js';
|
|
import { TILE } from './util.js';
|
|
|
|
const DEFS = {
|
|
guy: { hp: 42, w: 64, h: 74, name: 'THE GUY' },
|
|
mecha: { hp: 62, w: 78, h: 62, name: 'MECHA KID' },
|
|
devil: { hp: 84, w: 74, h: 90, name: 'THE DEVIL' },
|
|
};
|
|
|
|
export class Boss {
|
|
constructor(level, e) {
|
|
const d = DEFS[e.kind] || DEFS.guy;
|
|
this.level = level;
|
|
this.kind = e.kind;
|
|
this.name = d.name;
|
|
this.x = e.x; this.y = e.y;
|
|
this.w = d.w; this.h = d.h;
|
|
this.maxHp = e.hp || d.hp;
|
|
this.hp = this.maxHp;
|
|
this.flash = 0;
|
|
this.dead = false;
|
|
this.deathT = 0;
|
|
this.bodyKill = true;
|
|
this.t = 0;
|
|
this.phase = 1;
|
|
this.vx = 0; this.vy = 0;
|
|
this.state = 'idle';
|
|
this.stateT = 0;
|
|
// BOSS 贴地活动:保证玩家在地面平射就能命中
|
|
this.bounds = {
|
|
x0: 40, x1: level.pxW - 40 - this.w,
|
|
y0: 40, y1: level.pxH - 32 - this.h, // 底部贴地
|
|
};
|
|
this.homeY = clamp(e.y, this.bounds.y0, this.bounds.y1);
|
|
this.y = this.homeY;
|
|
this.homeX = e.x;
|
|
this.wing = 0;
|
|
}
|
|
|
|
get rect() { return { x: this.x, y: this.y, w: this.w, h: this.h }; }
|
|
|
|
shoot(level, x, y, vx, vy, col = '#ff5c7a', big = false) {
|
|
level.ebullets.push({ x: x - 4, y: y - 4, w: big ? 12 : 8, h: big ? 12 : 8, vx, vy, dead: false, col, big, life: 500 });
|
|
}
|
|
|
|
hit(dmg = 1) {
|
|
if (this.dead || this.flash > 6) return;
|
|
this.hp -= dmg;
|
|
this.flash = 10;
|
|
sfx.bossHit();
|
|
this.level.particles.spawn(this.x + this.w / 2, this.y + this.h / 2, 6,
|
|
{ colors: ['#fff', '#ffc63a'], speed: 2.6, life: 18 });
|
|
const r = this.hp / this.maxHp;
|
|
this.phase = r > 0.6 ? 1 : r > 0.3 ? 2 : 3;
|
|
if (this.hp <= 0) this.die();
|
|
}
|
|
|
|
die() {
|
|
this.dead = true; this.hp = 0; this.bodyKill = false;
|
|
sfx.bossDie();
|
|
this.level.shake = 16;
|
|
this.level.particles.burst(this.x + this.w / 2, this.y + this.h / 2,
|
|
{ n: 40, colors: ['#ff5c7a', '#ffc63a', '#fff', '#e03050'], speed: 5, life: 60, size: 4 });
|
|
}
|
|
|
|
update(dt, player) {
|
|
if (this.dead) {
|
|
this.deathT++;
|
|
if (this.deathT % 8 === 0 && this.deathT < 90) {
|
|
this.level.particles.burst(this.x + rand(0, this.w), this.y + rand(0, this.h),
|
|
{ n: 6, colors: ['#ffc63a', '#ff5c7a', '#fff'], speed: 3, life: 30, size: 3 });
|
|
sfx.hit();
|
|
}
|
|
if (this.deathT === 90) { if (this.onDefeat) this.onDefeat(); }
|
|
return;
|
|
}
|
|
this.t++;
|
|
this.stateT++;
|
|
if (this.flash > 0) this.flash--;
|
|
|
|
// 子类型行为
|
|
if (this.kind === 'guy') this.updateGuy(player);
|
|
else if (this.kind === 'mecha') this.updateMecha(player);
|
|
else this.updateDevil(player);
|
|
|
|
this.x += this.vx; this.y += this.vy;
|
|
this.x = clamp(this.x, this.bounds.x0, Math.max(this.bounds.x0, this.bounds.x1));
|
|
this.y = clamp(this.y, this.bounds.y0, Math.max(this.bounds.y0, this.bounds.y1));
|
|
|
|
// 被子弹击中
|
|
for (const b of this.level.bullets) {
|
|
if (b.dead) continue;
|
|
if (overlap(b, this.rect)) {
|
|
b.dead = true;
|
|
this.hit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- THE GUY:飘移 + 扇形弹 + 砸地 ----------
|
|
updateGuy(player) {
|
|
const L = this.level;
|
|
if (this.phase === 1) {
|
|
this.vx = Math.sin(this.t / 60) * 1.5;
|
|
this.y = this.homeY + Math.sin(this.t / 45) * 8;
|
|
if (this.t % 96 === 0) {
|
|
const cx = this.x + this.w / 2, cy = this.y + this.h / 2;
|
|
for (let i = -1; i <= 1; i++) {
|
|
const a = Math.atan2(player.cy - cy, player.cx - cx) + i * 0.28;
|
|
this.shoot(L, cx, cy, Math.cos(a) * 2.8, Math.sin(a) * 2.8);
|
|
}
|
|
sfx.shoot();
|
|
}
|
|
} else {
|
|
// 追踪 + 砸地
|
|
const dir = sgn(player.cx - (this.x + this.w / 2));
|
|
if (this.state === 'idle') {
|
|
this.vx = dir * 1.9;
|
|
this.y = this.homeY + Math.sin(this.t / 30) * 6;
|
|
if (this.stateT > 70) { this.state = 'slam'; this.stateT = 0; this.vy = 0; }
|
|
} else if (this.state === 'slam') {
|
|
this.vx *= 0.9;
|
|
this.vy = Math.min(this.vy + 0.7, 11);
|
|
if (this.y + this.h >= this.bounds.y1 + this.h * 0.2 || this.y >= this.homeY + 150) {
|
|
// 落地冲击:左右各飞出 3 发
|
|
this.state = 'idle'; this.stateT = 0; this.vy = -3;
|
|
L.shake = 10; sfx.hit();
|
|
for (let i = 0; i < 4; i++) {
|
|
this.shoot(L, this.x + this.w / 2, this.y + this.h - 6, -2.6 - i * 0.4, -1.2 - i * 0.15, '#ffc63a');
|
|
this.shoot(L, this.x + this.w / 2, this.y + this.h - 6, 2.6 + i * 0.4, -1.2 - i * 0.15, '#ffc63a');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- MECHA KID:冲撞 + 刺雨 ----------
|
|
updateMecha(player) {
|
|
const L = this.level;
|
|
if (this.state === 'idle') {
|
|
this.vx *= 0.92;
|
|
this.y = this.homeY + Math.sin(this.t / 40) * 5;
|
|
if (this.stateT > 100 - this.phase * 18) {
|
|
this.state = 'dash'; this.stateT = 0;
|
|
this.vx = sgn(player.cx - (this.x + this.w / 2)) * (5 + this.phase * 0.8);
|
|
sfx.shoot();
|
|
}
|
|
if (this.t % 150 === 0) {
|
|
// 刺雨
|
|
for (let i = 0; i < 4 + this.phase; i++) {
|
|
const tx = randInt(1, L.w - 2);
|
|
L.entities.push({
|
|
type: 'dropspike', x: tx * TILE, y: -TILE, w: TILE, h: TILE,
|
|
falling: true, vy: 0, homeY: -TILE, t: 0, temp: true,
|
|
});
|
|
}
|
|
L.showToast('刺雨来了!', 80);
|
|
sfx.troll();
|
|
}
|
|
} else if (this.state === 'dash') {
|
|
this.vy = 0;
|
|
if (this.x <= this.bounds.x0 + 1 || this.x >= this.bounds.x1 - 1 || this.stateT > 90) {
|
|
this.state = 'idle'; this.stateT = 0; this.vx = 0;
|
|
L.shake = 6;
|
|
L.particles.spawn(this.x + this.w / 2, this.y + this.h / 2, 8,
|
|
{ colors: ['#9ad8ff', '#fff'], speed: 3, life: 20 });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- THE DEVIL:追踪弹 / 激光 / 环形弹幕 ----------
|
|
updateDevil(player) {
|
|
const L = this.level;
|
|
this.wing += 0.16;
|
|
const cx = this.x + this.w / 2, cy = this.y + this.h / 2;
|
|
const p2 = this.phase;
|
|
|
|
// 移动
|
|
if (p2 === 1) { this.vx = Math.sin(this.t / 70) * 1.6; this.y = this.homeY + Math.sin(this.t / 50) * 8; }
|
|
else if (p2 === 2) { this.vx = Math.sin(this.t / 40) * 2.6; this.y = this.homeY + Math.sin(this.t / 28) * 11; }
|
|
else { this.vx = Math.sin(this.t / 25) * 3.4; this.y = this.homeY + Math.sin(this.t / 20) * 14; }
|
|
|
|
if (p2 === 1) {
|
|
if (this.t % 60 === 0) {
|
|
const a = Math.atan2(player.cy - cy, player.cx - cx);
|
|
for (let i = -1; i <= 1; i++)
|
|
this.shoot(L, cx, cy, Math.cos(a + i * 0.2) * 3, Math.sin(a + i * 0.2) * 3, '#ff9a3a');
|
|
sfx.shoot();
|
|
}
|
|
} else if (p2 === 2) {
|
|
if (this.t % 110 === 0) { this.state = 'laser'; this.stateT = 0; }
|
|
if (this.state === 'laser' && this.stateT < 40 && this.stateT % 5 === 0) {
|
|
// 横向激光弹幕(从上往下扫)
|
|
const yy = 40 + (this.stateT / 40) * (L.pxH - 90);
|
|
this.shoot(L, 8, yy, 6.5, 0, '#ff4060', true);
|
|
this.shoot(L, L.pxW - 12, L.pxH - yy, -6.5, 0, '#ff4060', true);
|
|
sfx.shoot();
|
|
}
|
|
if (this.stateT > 44) this.state = 'idle';
|
|
} else {
|
|
if (this.t % 34 === 0) {
|
|
const n = 10;
|
|
const off = this.t / 200;
|
|
for (let i = 0; i < n; i++) {
|
|
const a = off + (i / n) * Math.PI * 2;
|
|
this.shoot(L, cx, cy, Math.cos(a) * 2.6, Math.sin(a) * 2.6, '#ff5c7a');
|
|
}
|
|
sfx.shoot();
|
|
}
|
|
if (this.t % 170 === 0) {
|
|
const a = Math.atan2(player.cy - cy, player.cx - cx);
|
|
this.shoot(L, cx, cy, Math.cos(a) * 4.4, Math.sin(a) * 4.4, '#ffc63a', true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------- 绘制 ----------------
|
|
draw(ctx, t) {
|
|
if (this.dead && this.deathT > 96) return;
|
|
const x = this.x, y = this.y, w = this.w, h = this.h;
|
|
const fl = this.flash > 0;
|
|
ctx.save();
|
|
if (this.dead) ctx.globalAlpha = Math.max(0, 1 - this.deathT / 96);
|
|
|
|
const body = fl ? '#fff' : null;
|
|
if (this.kind === 'guy') this.drawGuy(ctx, x, y, w, h, body, t);
|
|
else if (this.kind === 'mecha') this.drawMecha(ctx, x, y, w, h, body, t);
|
|
else this.drawDevil(ctx, x, y, w, h, body, t);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawGuy(ctx, x, y, w, h, fl, t) {
|
|
const c = fl || '#2a1020', c2 = fl || '#5a1a34';
|
|
ctx.fillStyle = c;
|
|
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.62, w * 0.42, h * 0.34, 0, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = c2;
|
|
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.3, w * 0.36, h * 0.3, 0, 0, Math.PI * 2); ctx.fill();
|
|
// 角
|
|
ctx.fillStyle = fl || '#c8305a';
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + w * 0.2, y + h * 0.16); ctx.lineTo(x + w * 0.05, y - h * 0.12); ctx.lineTo(x + w * 0.34, y + h * 0.06);
|
|
ctx.closePath(); ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + w * 0.8, y + h * 0.16); ctx.lineTo(x + w * 0.95, y - h * 0.12); ctx.lineTo(x + w * 0.66, y + h * 0.06);
|
|
ctx.closePath(); ctx.fill();
|
|
// 眼
|
|
ctx.fillStyle = fl ? '#000' : '#ffe14a';
|
|
ctx.fillRect(x + w * 0.3, y + h * 0.28, w * 0.14, h * 0.1);
|
|
ctx.fillRect(x + w * 0.56, y + h * 0.28, w * 0.14, h * 0.1);
|
|
// 嘴
|
|
ctx.fillStyle = fl ? '#000' : '#801025';
|
|
ctx.fillRect(x + w * 0.34, y + h * 0.46, w * 0.32, h * 0.06);
|
|
if (this.phase >= 2) {
|
|
ctx.strokeStyle = fl ? '#000' : '#ff4060'; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.arc(x + w / 2, y + h / 2, w * 0.62 + Math.sin(t / 90) * 3, 0, Math.PI * 2); ctx.stroke();
|
|
}
|
|
}
|
|
|
|
drawMecha(ctx, x, y, w, h, fl, t) {
|
|
ctx.fillStyle = fl || '#3a4258';
|
|
ctx.fillRect(x + 4, y + 6, w - 8, h - 10);
|
|
ctx.fillStyle = fl || '#68728e';
|
|
ctx.fillRect(x + 8, y + 10, w - 16, h - 20);
|
|
// 眼
|
|
const blink = (Math.floor(t / 200) % 5) === 0;
|
|
ctx.fillStyle = fl ? '#000' : (this.state === 'dash' ? '#ff4060' : '#7ae0ff');
|
|
ctx.fillRect(x + w * 0.22, y + h * 0.34, w * 0.18, h * 0.14);
|
|
ctx.fillRect(x + w * 0.6, y + h * 0.34, w * 0.18, h * 0.14);
|
|
// 炮口
|
|
ctx.fillStyle = fl || '#2a2f40';
|
|
ctx.fillRect(x + w * 0.42, y + h - 8, w * 0.16, 10);
|
|
// 天线
|
|
ctx.fillStyle = fl || '#8a94ad';
|
|
ctx.fillRect(x + w / 2 - 1, y - 6, 2, 8);
|
|
ctx.fillStyle = (Math.floor(t / 120) % 2) ? '#ff4060' : '#601020';
|
|
ctx.beginPath(); ctx.arc(x + w / 2, y - 8, 3, 0, Math.PI * 2); ctx.fill();
|
|
// 推进器
|
|
if (this.state === 'dash') {
|
|
ctx.fillStyle = '#ffb040';
|
|
ctx.fillRect(x - 6, y + h * 0.4, 8, h * 0.2);
|
|
ctx.fillRect(x + w - 2, y + h * 0.4, 8, h * 0.2);
|
|
}
|
|
}
|
|
|
|
drawDevil(ctx, x, y, w, h, fl, t) {
|
|
// 翅膀
|
|
const fl2 = Math.sin(this.wing) * 8;
|
|
ctx.fillStyle = fl || '#4a0f22';
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + 6, y + h * 0.4);
|
|
ctx.quadraticCurveTo(x - 26, y + h * 0.1 + fl2, x - 4, y + h * 0.72 + fl2);
|
|
ctx.closePath(); ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + w - 6, y + h * 0.4);
|
|
ctx.quadraticCurveTo(x + w + 26, y + h * 0.1 + fl2, x + w + 4, y + h * 0.72 + fl2);
|
|
ctx.closePath(); ctx.fill();
|
|
// 身体
|
|
ctx.fillStyle = fl || '#8e1030';
|
|
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.6, w * 0.4, h * 0.33, 0, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = fl || '#c01a3a';
|
|
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.32, w * 0.34, h * 0.29, 0, 0, Math.PI * 2); ctx.fill();
|
|
// 角
|
|
ctx.fillStyle = fl || '#ffc63a';
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + w * 0.18, y + h * 0.14); ctx.lineTo(x - w * 0.06, y - h * 0.16); ctx.lineTo(x + w * 0.32, y + h * 0.04);
|
|
ctx.closePath(); ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + w * 0.82, y + h * 0.14); ctx.lineTo(x + w * 1.06, y - h * 0.16); ctx.lineTo(x + w * 0.68, y + h * 0.04);
|
|
ctx.closePath(); ctx.fill();
|
|
// 眼(阶段变色)
|
|
const ec = this.phase === 3 ? '#7ae0ff' : this.phase === 2 ? '#ffb040' : '#ffe14a';
|
|
ctx.fillStyle = fl ? '#000' : ec;
|
|
ctx.fillRect(x + w * 0.26, y + h * 0.26, w * 0.16, h * 0.12);
|
|
ctx.fillRect(x + w * 0.58, y + h * 0.26, w * 0.16, h * 0.12);
|
|
// 牙
|
|
ctx.fillStyle = fl ? '#000' : '#fff';
|
|
for (let i = 0; i < 4; i++) ctx.fillRect(x + w * 0.32 + i * w * 0.1, y + h * 0.46, 4, 6);
|
|
}
|
|
}
|
|
|
|
export function bossName(kind) { return (DEFS[kind] || DEFS.guy).name; }
|
|
export { DEFS as BOSS_DEFS };
|