电子斗蛐蛐
by RocketFlare631054 lines29.9 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>电子斗蛐蛐</title>
<style>
html, body {
margin: 0; padding: 0; height: 100%; background: #141414;
display: flex; align-items: center; justify-content: center;
overflow: hidden; font-family: sans-serif;
}
canvas {
width: 94vw; height: 94vw;
max-width: 800px; max-height: 800px;
display: block; border-radius: 8px;
box-shadow: 0 0 40px rgba(0,0,0,.8);
touch-action: none; user-select: none;
background: #1a1a1a;
}
</style>
</head>
<body>
<canvas id="game" width="800" height="800"></canvas>
<script>
'use strict';
// ==================== 超级报错拦截器 ====================
window.onerror = function(msg, url, line, col, error) {
try {
const cvs = document.getElementById('game');
if (!cvs) return;
const c = cvs.getContext('2d');
if (!c) return;
c.fillStyle = '#550000';
c.fillRect(0, 0, 800, 800);
c.fillStyle = '#ffffff';
c.font = 'bold 24px sans-serif';
c.textAlign = 'left';
c.fillText('❌ 游戏代码崩溃', 30, 60);
c.font = '18px sans-serif';
c.fillText('原因: ' + msg, 30, 110);
c.fillText('行号: ' + line + ':' + col, 30, 140);
} catch (e) {}
return true;
};
// ==================== 基础配置 ====================
const CELL = 80;
// 场地尺寸:1400(宽) X 2800(长)
const WORLD_W = 1400;
const WORLD_H = 2800;
const BORDER = 80; // 边界厚度
const PLAY = {
minX: BORDER, maxX: WORLD_W - BORDER,
minY: BORDER, maxY: WORLD_H - BORDER,
};
const VIEW = 800;
// 按比例缩放,保证整个场地能完整居中显示在 800x800 画布中
const SCALE = Math.min(VIEW / WORLD_W, VIEW / WORLD_H);
const OFFSET_X = (VIEW - WORLD_W * SCALE) / 2;
const OFFSET_Y = (VIEW - WORLD_H * SCALE) / 2;
const G = CELL; // 基础单位
const canvas = document.getElementById('game');
canvas.width = VIEW; canvas.height = VIEW;
const ctx = canvas.getContext('2d');
// ==================== 工具函数 ====================
// 世界坐标转屏幕坐标,包含居中偏移
function w2s(x, y) {
return {
x: x * SCALE + OFFSET_X,
y: y * SCALE + OFFSET_Y
};
}
function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function rand(a, b) { return a + Math.random() * (b - a); }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function roundRect(c, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
function wrapText(text, maxWidth, font) {
ctx.font = font;
const lines = [];
let line = '';
for (const ch of text) {
const test = line + ch;
if (ctx.measureText(test).width > maxWidth && line) {
lines.push(line); line = ch;
} else {
line = test;
}
}
if (line) lines.push(line);
return lines;
}
// ==================== 状态 ====================
const STATE = { MENU: 0, BATTLE: 1, RESULT: 2 };
let state = STATE.MENU;
let winnerText = '';
let resultBtn = null;
let lastBattleIds = null;
// ==================== 世界 ====================
const world = {
fighters: [], minions: [], bullets: [], grenades: [],
zones: [], damageTexts: [], explosions: [],
reset() {
this.fighters = []; this.minions = []; this.bullets = [];
this.grenades = []; this.zones = []; this.damageTexts = [];
this.explosions = [];
}
};
// ==================== 实体基类 ====================
class Entity {
constructor(x, y, radius) {
this.x = x; this.y = y; this.radius = radius; this.dead = false;
}
get isOut() {
return this.x < PLAY.minX || this.x > PLAY.maxX ||
this.y < PLAY.minY || this.y > PLAY.maxY;
}
clampToArena() {
let hit = false;
if (this.x - this.radius < PLAY.minX) { this.x = PLAY.minX + this.radius; hit = true; }
else if (this.x + this.radius > PLAY.maxX) { this.x = PLAY.maxX - this.radius; hit = true; }
if (this.y - this.radius < PLAY.minY) { this.y = PLAY.minY + this.radius; hit = true; }
else if (this.y + this.radius > PLAY.maxY) { this.y = PLAY.maxY - this.radius; hit = true; }
return hit;
}
}
// ==================== 伤害飘字 ====================
class DamageText {
constructor(x, y, value, big = false) {
this.x = x + rand(-18, 18);
this.y = y - 36;
this.label = value < 10 ? value.toFixed(1) : String(Math.round(value));
this.life = 0.9;
this.big = big;
this.vy = -72;
}
update(dt) {
this.life -= dt;
this.y += this.vy * dt;
this.vy *= 0.96;
}
draw() {
const p = w2s(this.x, this.y);
const alpha = clamp(this.life / 0.32, 0, 1);
ctx.globalAlpha = alpha;
ctx.font = this.big ? 'bold 18px sans-serif' : 'bold 13px sans-serif';
ctx.textAlign = 'center';
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(0,0,0,0.85)';
ctx.strokeText(this.label, p.x, p.y);
ctx.fillStyle = this.big ? '#ffb020' : '#ff6b6b';
ctx.fillText(this.label, p.x, p.y);
ctx.globalAlpha = 1;
}
}
// ==================== 子弹 ====================
class Bullet extends Entity {
constructor(owner, x, y, tx, ty, speedPx, damage, radius = 12) {
super(x, y, radius);
this.owner = owner;
this.damage = damage;
const d = Math.hypot(tx - x, ty - y) || 1;
this.vx = (tx - x) / d * speedPx;
this.vy = (ty - y) / d * speedPx;
}
update(dt) {
this.x += this.vx * dt;
this.y += this.vy * dt;
if (this.isOut) this.dead = true;
}
draw() {
const p = w2s(this.x, this.y);
ctx.beginPath();
ctx.arc(p.x, p.y, this.radius * SCALE, 0, Math.PI * 2);
ctx.fillStyle = this.owner.color || '#fff';
ctx.fill();
}
}
// ==================== 手雷 ====================
class Grenade extends Entity {
constructor(owner, x, y, tx, ty, speedPx, damage, aoeRadius) {
super(x, y, 14);
this.owner = owner;
this.damage = damage;
this.aoeRadius = aoeRadius;
this.tx = tx; this.ty = ty;
const d = Math.hypot(tx - x, ty - y) || 1;
this.vx = (tx - x) / d * speedPx;
this.vy = (ty - y) / d * speedPx;
this.rot = 0;
}
update(dt) {
if (this.dead) return;
this.x += this.vx * dt;
this.y += this.vy * dt;
this.rot += dt * 14;
const dx = this.tx - this.x, dy = this.ty - this.y;
const dot = dx * this.vx + dy * this.vy;
if (dot <= 0 || Math.hypot(dx, dy) < 8) { this.explode(); return; }
if (this.isOut) this.dead = true;
}
explode() {
this.dead = true;
world.explosions.push(new Explosion(this.x, this.y, this.aoeRadius));
for (const f of world.fighters) {
if (f.dead || f === this.owner || f.team === this.owner.team) continue;
if (dist(this, f) <= this.aoeRadius + f.radius) f.takeDamage(this.damage, this.owner, { big: true });
}
for (const m of world.minions) {
if (m.dead || m.team === this.owner.team) continue;
if (dist(this, m) <= this.aoeRadius + m.radius) m.takeDamage(this.damage, this.owner, { big: true });
}
}
draw() {
const t = w2s(this.tx, this.ty);
ctx.beginPath();
ctx.arc(t.x, t.y, this.aoeRadius * SCALE, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,90,40,0.4)';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 5]);
ctx.stroke();
ctx.setLineDash([]);
const p = w2s(this.x, this.y);
const r = this.radius * SCALE;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(this.rot);
ctx.beginPath();
ctx.ellipse(0, 0, r, r * 0.72, 0, 0, Math.PI * 2);
ctx.fillStyle = '#2b2b2b';
ctx.fill();
ctx.strokeStyle = '#ff6b3d';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -r * 0.72, r * 0.32, 0, Math.PI * 2);
ctx.fillStyle = '#ff6b3d';
ctx.fill();
ctx.restore();
}
}
// ==================== 爆炸特效 ====================
class Explosion {
constructor(x, y, radius) {
this.x = x; this.y = y; this.radius = radius;
this.life = 0.45; this.maxLife = 0.45;
}
update(dt) { this.life -= dt; }
draw() {
const p = w2s(this.x, this.y);
const t = 1 - this.life / this.maxLife;
const r = this.radius * SCALE * (0.45 + 0.55 * t);
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, Math.max(r, 0.1));
g.addColorStop(0, `rgba(255,225,130,${0.8 * (1 - t)})`);
g.addColorStop(0.6, `rgba(255,120,40,${0.55 * (1 - t)})`);
g.addColorStop(1, 'rgba(255,60,20,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(r, 0.1), 0, Math.PI * 2);
ctx.fill();
}
}
// ==================== 红日区域 ====================
class RedSunZone extends Entity {
constructor(owner, life = 3) {
super(0, 0, 0);
this.owner = owner;
this.life = life;
this.tickTimer = 0;
}
update(dt, fighters) {
this.life -= dt;
if (this.life <= 0) { this.dead = true; return; }
this.tickTimer += dt;
while (this.tickTimer >= 0.1) {
this.tickTimer -= 0.1;
for (const f of fighters) {
if (f.dead || f.team === this.owner.team) continue;
f.takeDamage(1.5, this.owner, { silent: true });
}
}
}
draw() {
const c = w2s(WORLD_W / 2, WORLD_H / 2);
const r = VIEW * 0.85;
const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, r);
g.addColorStop(0, 'rgba(255,70,30,0.30)');
g.addColorStop(1, 'rgba(255,70,30,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, VIEW, VIEW);
}
}
// ==================== 角色基类 ====================
class Fighter extends Entity {
constructor(name, x, y, hp, speedGrid, color) {
super(x, y, CELL / 2);
this.name = name;
this.maxHp = hp; this.hp = hp;
this.speedGrid = speedGrid;
this.color = color;
this.team = name;
this.atkTimer = 0;
this.dir = { x: 1, y: 0 };
this.dirTimer = 0;
this.damageMult = 1;
this.setRandomDir();
}
get speed() { return this.speedGrid * G; }
setRandomDir() {
const a = Math.random() * Math.PI * 2;
this.dir = { x: Math.cos(a), y: Math.sin(a) };
}
moveRandom(dt) {
this.dirTimer -= dt;
if (this.dirTimer <= 0) {
this.setRandomDir();
this.dirTimer = rand(1, 2);
}
this.x += this.dir.x * this.speed * dt;
this.y += this.dir.y * this.speed * dt;
if (this.clampToArena()) this.setRandomDir();
}
// 智能索敌:支持混合传入英雄和人民的集合
nearestEnemy(all, excludeTeam) {
let best = null, bd = Infinity;
for (const f of all) {
if (f.dead || f === this) continue;
if (excludeTeam && f.team === excludeTeam) continue;
const d = dist(this, f);
if (d < bd) { bd = d; best = f; }
}
return best;
}
takeDamage(v, src, opts = {}) {
if (this.dead) return;
this.hp -= v;
if (!opts.silent) {
world.damageTexts.push(new DamageText(this.x, this.y, v, opts.big));
}
if (this.hp <= 0) { this.hp = 0; this.dead = true; this.onDeath(); }
}
onDeath() {}
update(dt, world) {}
draw() {
const p = w2s(this.x, this.y);
const r = this.radius * SCALE;
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
const ratio = this.hp / this.maxHp;
const bw = r * 2, bh = 4;
ctx.fillStyle = '#000';
ctx.fillRect(p.x - bw / 2, p.y - r - 8, bw, bh);
ctx.fillStyle = ratio > 0.5 ? '#2ecc71' : ratio > 0.25 ? '#f1c40f' : '#e74c3c';
ctx.fillRect(p.x - bw / 2, p.y - r - 8, bw * ratio, bh);
ctx.fillStyle = '#fff';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(this.name, p.x, p.y - r - 12);
}
}
// ==================== 角色:一封普通的信 ====================
class LetterFighter extends Fighter {
constructor(x, y) {
super('一封普通的信', x, y, 1000, 4.5, '#dcdcdc');
this.stage = 1;
this.stageTimer = 0;
this.bulletSpeed = 10 * G;
}
get atkCD() { return this.stage === 1 ? 1 : this.stage === 2 ? 0.8 : 1.25; }
get atkDamage() { return this.stage === 1 ? 50 : this.stage === 2 ? 60 : 75; }
updateStage(dt) {
this.stageTimer += dt;
if (this.stage === 1 && this.stageTimer >= 20) {
this.stage = 2; this.stageTimer = 0; this.speedGrid = 5;
} else if (this.stage === 2 && this.stageTimer >= 25) {
this.stage = 3; this.stageTimer = 0; this.speedGrid = 2;
}
}
update(dt, world) {
if (this.dead) return;
this.updateStage(dt);
this.moveRandom(dt);
this.atkTimer -= dt;
if (this.atkTimer <= 0) {
// 锁定最近单位(包括英雄和人民)
const allEnemies = [...world.fighters, ...world.minions];
const enemy = this.nearestEnemy(allEnemies, this.team);
if (!enemy) return;
this.atkTimer = this.atkCD;
if (this.stage === 3 && Math.random() < 0.05) return;
if (this.stage === 3 && Math.random() < 0.03) {
this.takeDamage(this.hp, this);
return;
}
if (Math.random() < 0.05) {
world.grenades.push(new Grenade(
this, this.x, this.y, enemy.x, enemy.y,
12 * G, 250, 4 * CELL
));
return;
}
let dmg = this.atkDamage;
if (this.stage === 2 && Math.random() < 0.15) dmg *= 1.2;
dmg *= this.damageMult;
world.bullets.push(new Bullet(
this, this.x, this.y, enemy.x, enemy.y,
this.bulletSpeed, dmg
));
}
}
draw() {
super.draw();
const p = w2s(this.x, this.y);
const names = ['待我回家', '带我回家', '代我回家'];
ctx.fillStyle = '#ffeaa0';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('[' + names[this.stage - 1] + ']', p.x, p.y + this.radius * SCALE + 13);
}
}
// ==================== 召唤物:人民 ====================
class Minion extends Fighter {
constructor(x, y, owner) {
// HP 改为 70
super('人民', x, y, 70, 3, '#ff6b6b');
this.owner = owner;
this.team = owner.team;
this.atkTimer = 0;
}
update(dt, world) {
if (this.dead) return;
// 人民也锁定所有最近的敌方单位(包括人民和英雄)
const allEnemies = [...world.fighters, ...world.minions];
const enemy = this.nearestEnemy(allEnemies, this.owner.team);
if (!enemy) { this.moveRandom(dt); return; }
const d = dist(this, enemy);
if (d > this.radius + enemy.radius) {
const nx = (enemy.x - this.x) / d;
const ny = (enemy.y - this.y) / d;
this.x += nx * this.speed * dt;
this.y += ny * this.speed * dt;
this.clampToArena();
} else {
this.atkTimer -= dt;
if (this.atkTimer <= 0) {
this.atkTimer = 1;
enemy.takeDamage(25, this); // 伤害改为 25
}
}
}
}
// ==================== 角色:红日 ====================
class RedSunFighter extends Fighter {
constructor(x, y) {
super('红日', x, y, 1050, 3, '#ff3b1f');
this.produceTimer = 0;
this.peopleTimer = 0;
this.myMinions = [];
}
get atkCD() { return 1.5; }
get atkDamage() { return 85; }
update(dt, world) {
if (this.dead) return;
this.moveRandom(dt);
this.produceTimer += dt;
if (this.produceTimer >= 5) {
this.produceTimer -= 5;
this.hp = Math.min(this.maxHp, this.hp + 15);
this.damageMult *= 1.05;
}
this.peopleTimer += dt;
if (this.peopleTimer >= 8) {
this.peopleTimer -= 8;
for (let i = 0; i < 2; i++) {
const a = Math.random() * Math.PI * 2;
const r = Math.random() * 4 * CELL;
let mx = this.x + Math.cos(a) * r;
let my = this.y + Math.sin(a) * r;
mx = clamp(mx, PLAY.minX + CELL / 2, PLAY.maxX - CELL / 2);
my = clamp(my, PLAY.minY + CELL / 2, PLAY.maxY - CELL / 2);
const m = new Minion(mx, my, this);
world.minions.push(m);
this.myMinions.push(m);
}
}
this.atkTimer -= dt;
if (this.atkTimer <= 0) {
// 锁定最近单位(包括英雄和人民)
const allEnemies = [...world.fighters, ...world.minions];
const enemy = this.nearestEnemy(allEnemies, this.team);
if (!enemy) return;
this.atkTimer = this.atkCD;
let dmg = this.atkDamage * this.damageMult;
if (Math.random() < 0.05) dmg *= 1.1;
world.bullets.push(new Bullet(
this, this.x, this.y, enemy.x, enemy.y, 8 * G, dmg, 14
));
if (Math.random() < 0.08) {
world.zones.push(new RedSunZone(this, 3));
}
}
}
onDeath() {
for (const m of this.myMinions) m.dead = true;
this.myMinions.length = 0;
}
}
// ==================== 角色数据库 ====================
const CHARACTER_DB = [
{
id: 'letter',
name: '一封普通的信',
brief: '在战争中,一个人的死亡显得如此普通。',
color: '#dcdcdc',
create: (x, y) => new LetterFighter(x, y),
},
{
id: 'redsun',
name: '红日',
brief: '在遥远的东方,一轮红日冉冉升起。',
color: '#ff3b1f',
create: (x, y) => new RedSunFighter(x, y),
},
];
// ==================== 选角界面 ====================
const menu = {
selected: new Set(),
hovered: null,
hoverStart: false,
pressedCard: null,
pressStart: 0,
longPressFired: false,
briefId: null,
toastText: '',
toastTimer: 0,
cards: [],
startBtn: { x: 280, y: 360, w: 240, h: 60 },
briefBox: { x: 100, y: 460, w: 600, h: 280 },
};
function layoutCards() {
const n = CHARACTER_DB.length;
const cols = Math.min(n, 3);
const cardW = 190, cardH = 160, gap = 24;
const totalW = cols * cardW + (cols - 1) * gap;
const startX = (VIEW - totalW) / 2;
const startY = 120;
menu.cards = [];
for (let i = 0; i < n; i++) {
const r = Math.floor(i / cols);
const c = i % cols;
menu.cards.push({
id: CHARACTER_DB[i].id,
x: startX + c * (cardW + gap),
y: startY + r * (cardH + gap),
w: cardW, h: cardH,
});
}
}
layoutCards();
function showToast(text) {
menu.toastText = text;
menu.toastTimer = 1.6;
}
function updateMenu(dt) {
if (menu.toastTimer > 0) menu.toastTimer -= dt;
if (menu.pressedCard && !menu.longPressFired) {
if (performance.now() - menu.pressStart >= 450) {
menu.longPressFired = true;
menu.briefId = menu.pressedCard;
}
}
}
function drawMenu() {
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, VIEW, VIEW);
ctx.textAlign = 'center';
ctx.fillStyle = '#f5e9c9';
ctx.font = 'bold 34px sans-serif';
ctx.fillText('电 子 斗 蛐 蛐', VIEW / 2, 58);
ctx.fillStyle = '#aaaaaa';
ctx.font = '15px sans-serif';
ctx.fillText('选择 2 ~ 5 名角色进入竞技场', VIEW / 2, 90);
for (const card of menu.cards) {
const def = CHARACTER_DB.find(c => c.id === card.id);
if (!def) continue;
const selected = menu.selected.has(card.id);
const hovered = menu.hovered === card.id;
roundRect(ctx, card.x, card.y, card.w, card.h, 12);
ctx.fillStyle = selected ? '#26303d' : (hovered ? '#2a2a2a' : '#252525');
ctx.fill();
ctx.lineWidth = selected ? 3 : 1.5;
ctx.strokeStyle = selected ? '#5fc4ff' : '#444444';
ctx.stroke();
const cx = card.x + card.w / 2;
const cy = card.y + 62;
ctx.beginPath();
ctx.arc(cx, cy, 30, 0, Math.PI * 2);
ctx.fillStyle = def.color;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#eee';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(def.name, cx, card.y + 122);
if (selected) {
ctx.beginPath();
ctx.arc(card.x + card.w - 18, card.y + 18, 12, 0, Math.PI * 2);
ctx.fillStyle = '#5fc4ff';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(card.x + card.w - 24, card.y + 18);
ctx.lineTo(card.x + card.w - 19, card.y + 23);
ctx.lineTo(card.x + card.w - 12, card.y + 13);
ctx.stroke();
}
}
ctx.fillStyle = '#8d8d8d';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`已选 ${menu.selected.size} / 5`, VIEW / 2, menu.startBtn.y - 14);
const sb = menu.startBtn;
roundRect(ctx, sb.x, sb.y, sb.w, sb.h, 10);
ctx.fillStyle = menu.hoverStart ? '#4aa8e8' : '#3498db';
ctx.fill();
ctx.strokeStyle = '#7fd0ff';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = 'bold 22px sans-serif';
ctx.fillText('开 始', sb.x + sb.w / 2, sb.y + sb.h / 2 + 8);
const bb = menu.briefBox;
roundRect(ctx, bb.x, bb.y, bb.w, bb.h, 12);
ctx.fillStyle = '#222222';
ctx.fill();
ctx.strokeStyle = '#444444';
ctx.lineWidth = 1.5;
ctx.stroke();
if (menu.briefId) {
const def = CHARACTER_DB.find(c => c.id === menu.briefId);
if (def) {
ctx.textAlign = 'left';
ctx.fillStyle = def.color;
ctx.font = 'bold 22px sans-serif';
ctx.fillText(def.name, bb.x + 26, bb.y + 44);
ctx.fillStyle = '#c9c9c9';
const font = '16px sans-serif';
const lines = wrapText(def.brief, bb.w - 52, font);
ctx.font = font;
lines.forEach((line, i) => {
ctx.fillText(line, bb.x + 26, bb.y + 88 + i * 28);
});
}
} else {
ctx.textAlign = 'center';
ctx.fillStyle = '#777777';
ctx.font = '15px sans-serif';
ctx.fillText('长按角色卡片查看简介', bb.x + bb.w / 2, bb.y + bb.h / 2 + 5);
}
if (menu.toastTimer > 0) {
const alpha = clamp(menu.toastTimer / 0.3, 0, 1);
ctx.globalAlpha = alpha;
const tw = 260, th = 96;
const tx = VIEW / 2 - tw / 2;
const ty = VIEW / 2 - th / 2;
roundRect(ctx, tx, ty, tw, th, 14);
ctx.fillStyle = 'rgba(32,10,10,0.96)';
ctx.fill();
ctx.strokeStyle = '#e74c3c';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(VIEW / 2, ty + 22);
ctx.lineTo(VIEW / 2 - 15, ty + 50);
ctx.lineTo(VIEW / 2 + 15, ty + 50);
ctx.closePath();
ctx.strokeStyle = '#e74c3c';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#e74c3c';
ctx.font = 'bold 20px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(menu.toastText, VIEW / 2, ty + 78);
ctx.globalAlpha = 1;
}
}
// ==================== 开始战斗 ====================
function tryStart() {
const n = menu.selected.size;
if (n < 2) { showToast('角色过少'); return; }
if (n > 5) { showToast('角色过多'); return; }
const ids = CHARACTER_DB.filter(c => menu.selected.has(c.id)).map(c => c.id);
startBattle(ids);
}
function startBattle(ids) {
world.reset();
winnerText = '';
resultBtn = null;
lastBattleIds = ids.slice();
const defs = ids.map(id => CHARACTER_DB.find(c => c.id === id)).filter(Boolean);
const n = defs.length;
defs.forEach((def, i) => {
const angle = (i / n) * Math.PI * 2 + rand(-0.25, 0.25);
// 出生半径适应长方形场地
const r = rand(300, 500);
let x = WORLD_W / 2 + Math.cos(angle) * r;
let y = WORLD_H / 2 + Math.sin(angle) * r;
x = clamp(x, PLAY.minX + 60, PLAY.maxX - 60);
y = clamp(y, PLAY.minY + 60, PLAY.maxY - 60);
world.fighters.push(def.create(x, y));
});
state = STATE.BATTLE;
last = performance.now();
}
// ==================== 战斗逻辑 ====================
function resolveFighterCollisions(list) {
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
const a = list[i], b = list[j];
if (a.dead || b.dead) continue;
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const minD = a.radius + b.radius;
if (d < minD && d > 0) {
const overlap = minD - d;
const nx = dx / d, ny = dy / d;
a.x -= nx * overlap / 2; a.y -= ny * overlap / 2;
b.x += nx * overlap / 2; b.y += ny * overlap / 2;
a.setRandomDir(); b.setRandomDir();
a.clampToArena(); b.clampToArena();
}
}
}
}
function bulletHits(b) {
for (const f of world.fighters) {
if (f.dead || f === b.owner || f.team === b.owner.team) continue;
if (dist(b, f) < b.radius + f.radius) {
f.takeDamage(b.damage, b.owner);
return true;
}
}
for (const m of world.minions) {
if (m.dead || m.owner.team === b.owner.team) continue;
if (dist(b, m) < b.radius + m.radius) {
m.takeDamage(b.damage, b.owner);
return true;
}
}
return false;
}
function checkVictory() {
if (state !== STATE.BATTLE) return;
const alive = world.fighters.filter(f => !f.dead);
if (alive.length === 1) {
state = STATE.RESULT;
winnerText = alive[0].name + ' 胜利!';
} else if (alive.length === 0) {
state = STATE.RESULT;
winnerText = '同归于尽!';
}
}
function updateBattle(dt) {
for (const f of world.fighters) f.update(dt, world);
world.fighters = world.fighters.filter(f => !f.dead);
world.minions = world.minions.filter(m => !m.dead);
for (const m of world.minions) m.update(dt, world);
resolveFighterCollisions(world.fighters);
for (const b of world.bullets) {
b.update(dt);
if (!b.dead && bulletHits(b)) b.dead = true;
}
world.bullets = world.bullets.filter(b => !b.dead);
for (const g of world.grenades) g.update(dt);
world.grenades = world.grenades.filter(g => !g.dead);
for (const z of world.zones) z.update(dt, world.fighters);
world.zones = world.zones.filter(z => !z.dead);
for (const ex of world.explosions) ex.update(dt);
world.explosions = world.explosions.filter(e => e.life > 0);
for (const t of world.damageTexts) t.update(dt);
world.damageTexts = world.damageTexts.filter(t => t.life > 0);
checkVictory();
}
// ==================== 战斗渲染 ====================
function drawArena() {
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, VIEW, VIEW); // 整个画布底色(包括留白处)
const p1 = w2s(PLAY.minX, PLAY.minY);
const p2 = w2s(PLAY.maxX, PLAY.maxY);
// 场地底色
ctx.fillStyle = '#252525';
ctx.fillRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y);
// 绘制内部网格线
ctx.strokeStyle = '#333333';
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = PLAY.minX; x <= PLAY.maxX; x += CELL) {
const px = w2s(x, 0).x;
ctx.moveTo(px, p1.y);
ctx.lineTo(px, p2.y);
}
for (let y = PLAY.minY; y <= PLAY.maxY; y += CELL) {
const py = w2s(0, y).y;
ctx.moveTo(p1.x, py);
ctx.lineTo(p2.x, py);
}
ctx.stroke();
// 场地边界
ctx.strokeStyle = '#888';
ctx.lineWidth = 4;
ctx.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y);
}
function drawBattle() {
drawArena();
for (const z of world.zones) z.draw();
for (const ex of world.explosions) ex.draw();
for (const g of world.grenades) g.draw();
for (const m of world.minions) m.draw();
for (const b of world.bullets) b.draw();
for (const f of world.fighters) f.draw();
for (const t of world.damageTexts) t.draw();
if (state === STATE.RESULT) drawResult();
}
function drawResult() {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, VIEW, VIEW);
ctx.textAlign = 'center';
ctx.fillStyle = '#ffe9a8';
ctx.font = 'bold 38px sans-serif';
ctx.fillText(winnerText, VIEW / 2, VIEW / 2 - 30);
const bw = 220, bh = 58;
const bx = VIEW / 2 - bw / 2, by = VIEW / 2 + 30;
roundRect(ctx, bx, by, bw, bh, 10);
ctx.fillStyle = '#3498db';
ctx.fill();
ctx.strokeStyle = '#7fd0ff';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = 'bold 20px sans-serif';
ctx.fillText('再来一局', VIEW / 2, by + 37);
resultBtn = { x: bx, y: by, w: bw, h: bh };
}
// ==================== 输入 ====================
function getPointerPos(e) {
const rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (canvas.width / rect.width),
y: (e.clientY - rect.top) * (canvas.height / rect.height),
};
}
function hitCard(p) {
for (const c of menu.cards) {
if (p.x >= c.x && p.x <= c.x + c.w && p.y >= c.y && p.y <= c.y + c.h) return c;
}
return null;
}
function hitStart(p) {
const b = menu.startBtn;
return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
}
function hitResult(p) {
if (!resultBtn) return false;
const b = resultBtn;
return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
}
canvas.addEventListener('pointerdown', (e) => {
try { if (e.cancelable) e.preventDefault(); } catch(err){}
const p = getPointerPos(e);
if (state === STATE.MENU) {
const card = hitCard(p);
if (card) {
menu.pressedCard = card.id;
menu.pressStart = performance.now();
menu.longPressFired = false;
}
}
});
canvas.addEventListener('pointerup', (e) => {
try { if (e.cancelable) e.preventDefault(); } catch(err){}
const p = getPointerPos(e);
if (state === STATE.MENU) {
const card = hitCard(p);
if (card && menu.pressedCard === card.id && !menu.longPressFired) {
if (menu.selected.has(card.id)) menu.selected.delete(card.id);
else menu.selected.add(card.id);
}
menu.pressedCard = null;
menu.longPressFired = false;
if (hitStart(p)) tryStart();
} else if (state === STATE.RESULT) {
if (hitResult(p)) {
if (lastBattleIds && lastBattleIds.length >= 2) {
startBattle(lastBattleIds);
} else {
state = STATE.MENU;
resultBtn = null;
}
}
}
});
canvas.addEventListener('pointermove', (e) => {
try { if (e.cancelable) e.preventDefault(); } catch(err){}
const p = getPointerPos(e);
if (state === STATE.MENU) {
const card = hitCard(p);
menu.hovered = card ? card.id : null;
menu.hoverStart = hitStart(p);
canvas.style.cursor = (card || menu.hoverStart) ? 'pointer' : 'default';
} else if (state === STATE.RESULT) {
canvas.style.cursor = hitResult(p) ? 'pointer' : 'default';
} else {
canvas.style.cursor = 'default';
}
});
canvas.addEventListener('pointerleave', () => {
menu.pressedCard = null;
menu.longPressFired = false;
});
// ==================== 主循环 ====================
let last = performance.now();
function loop(now) {
if (typeof now !== 'number') now = performance.now();
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (state === STATE.MENU) {
updateMenu(dt);
drawMenu();
} else {
if (state === STATE.BATTLE) updateBattle(dt);
drawBattle();
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>Game Source: 电子斗蛐蛐
Creator: RocketFlare63
Libraries: none
Complexity: complex (1054 lines, 29.9 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: game-rocketflare63-mvbxb0zb" to link back to the original. Then publish at arcadelab.ai/publish.