经典大战 · 选人与决斗
by ApexFalcon661029 lines31.6 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>经典大战 · 选人与决斗</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
background: radial-gradient(ellipse at 50% 40%, #0b1a24 0%, #05090d 55%, #010204 100%);
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #d7f6ff;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.screen {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
transition: opacity .35s ease, transform .35s ease;
}
.screen.hidden {
opacity: 0; pointer-events: none;
transform: scale(1.02);
}
#screen-start { gap: 28px; }
.title-main {
font-size: clamp(32px, 7vw, 64px);
letter-spacing: 10px;
font-weight: 900;
color: #eafffd;
text-shadow: 0 0 18px rgba(0,255,224,.6), 0 0 60px rgba(0,200,180,.35);
}
.title-sub { font-size: 13px; letter-spacing: 4px; color: rgba(0,255,224,.55); }
.start-btn {
margin-top: 8px; padding: 16px 64px;
font-size: 18px; letter-spacing: 6px;
color: #031412;
background: linear-gradient(180deg, #9ffff2, #22e3c5);
border: none; border-radius: 6px;
box-shadow: 0 0 22px rgba(0,255,224,.45), inset 0 1px 0 rgba(255,255,255,.6);
cursor: pointer; transition: transform .15s, box-shadow .15s;
}
.start-btn:hover { transform: translateY(-2px); box-shadow: 0 0 34px rgba(0,255,224,.7); }
#screen-select { gap: 16px; padding: 20px; }
.select-title { font-size: 15px; letter-spacing: 5px; color: rgba(0,255,224,.7); }
.select-hint { font-size: 12px; color: rgba(215,246,255,.45); margin-top: -6px; }
.roster {
display: flex; gap: 10px;
flex-wrap: wrap; justify-content: center;
max-width: 760px;
}
.card {
width: 128px;
background: linear-gradient(170deg, rgba(14,32,44,.95), rgba(6,14,20,.98));
border: 1px solid rgba(0,255,224,.16);
border-radius: 8px; padding: 9px 10px 8px;
cursor: pointer; position: relative;
transition: border-color .18s, box-shadow .18s, transform .18s;
}
.card:hover { border-color: rgba(0,255,224,.45); transform: translateY(-2px); }
.card.selected {
border-color: #3dffe4;
box-shadow: 0 0 0 1px rgba(61,255,228,.5), 0 0 18px rgba(0,255,224,.25);
}
.card.selected::after {
content: "✓";
position: absolute; top: 5px; right: 6px;
width: 14px; height: 14px;
font-size: 10px; line-height: 14px; text-align: center;
color: #031412; background: #3dffe4;
border-radius: 50%; font-weight: 700;
}
.char-name { font-size: 15px; font-weight: 800; letter-spacing: 1px; }
.char-source { font-size: 9px; color: rgba(215,246,255,.4); margin-top: 1px; }
.tag-row { display: flex; gap: 4px; margin: 6px 0 7px; }
.tag {
font-size: 9px; letter-spacing: 1px;
padding: 1px 5px; border-radius: 3px;
border: 1px solid rgba(0,255,224,.25); color: rgba(0,255,224,.8);
}
.tag.ranged { border-color: rgba(255,190,90,.35); color: rgba(255,200,120,.9); }
.stats {
display: grid; grid-template-columns: 1fr 1fr; gap: 2px 8px;
font-size: 9px; color: rgba(215,246,255,.5);
}
.stats b { color: rgba(215,246,255,.85); font-weight: 600; margin-left: 3px; }
.select-actions { display: flex; gap: 14px; margin-top: 6px; }
.btn {
padding: 12px 34px; font-size: 14px; letter-spacing: 3px;
border-radius: 5px; border: 1px solid rgba(0,255,224,.3);
background: rgba(0,255,224,.06); color: rgba(0,255,224,.8);
cursor: pointer; transition: .18s;
}
.btn:hover:not(:disabled) { background: rgba(0,255,224,.14); }
.btn:disabled { opacity: .28; cursor: not-allowed; }
.btn-primary {
background: linear-gradient(180deg, #9ffff2, #22e3c5);
color: #031412; border: none; font-weight: 800;
}
.btn-primary:disabled { background: rgba(120,180,170,.2); color: rgba(215,246,255,.3); }
#screen-map { gap: 12px; }
.map-hud {
display: flex; gap: 40px; align-items: center;
font-size: 12px; letter-spacing: 1px; color: rgba(215,246,255,.6);
}
.hud-name { font-weight: 700; color: #eafffd; font-size: 14px; }
.hud-hp { color: #3dffe4; font-weight: 700; }
.hud-vs { color: rgba(255,255,255,.2); font-size: 16px; letter-spacing: 4px; }
canvas { display: block; touch-action: none; }
.back-link {
font-size: 11px; color: rgba(0,255,224,.4);
cursor: pointer; letter-spacing: 2px; margin-top: 4px;
}
.back-link:hover { color: rgba(0,255,224,.8); }
#screen-result {
gap: 18px;
background: radial-gradient(ellipse at 50% 50%, rgba(0,40,36,.85), rgba(1,2,4,.96) 70%);
}
.result-label {
font-size: 13px; letter-spacing: 8px; color: rgba(0,255,224,.55);
animation: fadeUp .5s ease;
}
.result-name {
font-size: clamp(40px, 10vw, 76px); font-weight: 900; letter-spacing: 8px;
color: #eafffd;
text-shadow: 0 0 24px rgba(0,255,224,.75), 0 0 90px rgba(0,200,180,.45);
animation: resultPop .55s cubic-bezier(.2,1.5,.5,1);
}
.result-sub {
font-size: 12px; letter-spacing: 3px; color: rgba(215,246,255,.35);
margin-bottom: 8px; animation: fadeUp .7s ease .1s both;
}
#btn-result-back { animation: fadeUp .7s ease .18s both; }
@keyframes resultPop {
0% { transform: scale(.6); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes fadeUp {
0% { transform: translateY(10px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
</style>
</head>
<body>
<section class="screen" id="screen-start">
<div class="title-main">经典大战</div>
<div class="title-sub">CLASSIC ARENA · 文本作证 · 规则统一</div>
<button class="start-btn" id="btn-to-select">开 始 游 戏</button>
</section>
<section class="screen hidden" id="screen-select">
<div class="select-title">选择参战角色</div>
<div class="select-hint">至少选中 2 名角色,点击“开始决斗”进入地图</div>
<div class="roster" id="roster"></div>
<div class="select-actions">
<button class="btn" id="btn-back-start">返回</button>
<button class="btn btn-primary" id="btn-start-duel" disabled>开始决斗</button>
</div>
</section>
<section class="screen hidden" id="screen-map">
<div class="map-hud" id="map-hud"></div>
<canvas id="game"></canvas>
<div class="back-link" id="btn-back-select">← 返回选人</div>
</section>
<section class="screen hidden" id="screen-result">
<div class="result-label">胜 者</div>
<div class="result-name" id="result-name">—</div>
<div class="result-sub" id="result-sub">经典大战</div>
<button class="btn btn-primary" id="btn-result-back">返回重新选择</button>
</section>
<script>
(function () {
'use strict';
/* ============================================================
0. 角色数据
============================================================ */
const CHARACTERS = [
{
id: 'xiangzi', name: '祥子', source: '《骆驼祥子》',
type: 'melee', typeLabel: '近战',
hp: 100, sta: 100, mp: 0,
speed: 1.6, atk: 9,
atkRange: 1.4, detectRange: 5,
atkCd: 0.7, color: '#3dffe4',
},
{
id: 'ruifeng', name: '瑞丰', source: '《四世同堂》',
type: 'ranged', typeLabel: '远程',
hp: 72, sta: 80, mp: 100,
speed: 1.15, atk: 6,
atkRange: 6.5, detectRange: 0,
atkCd: 1.1, color: '#ffd76a',
},
{
id: 'fushan', name: '富善先生', source: '《四世同堂》',
type: 'ranged', typeLabel: '远程',
hp: 85, sta: 70, mp: 100,
speed: 1.0, atk: 7,
atkRange: 7.2, detectRange: 0,
atkCd: 1.25, color: '#7ec8ff',
},
{
id: 'baoeer', name: '保尔·柯察金', source: '《钢铁是怎样炼成的》',
type: 'melee', typeLabel: '近战',
hp: 110, sta: 100, mp: 0,
speed: 1.5, atk: 10,
atkRange: 1.5, detectRange: 5.5,
atkCd: 0.75, color: '#e05a3a',
passive: 'steel_will',
},
{
id: 'zhuhelai', name: '朱赫来', source: '《钢铁是怎样炼成的》',
type: 'melee', typeLabel: '近战',
hp: 115, sta: 100, mp: 0,
speed: 1.45, atk: 11,
atkRange: 1.3, detectRange: 5,
atkCd: 0.95, color: '#c9a227',
passive: 'lead_fist',
},
{
id: 'victor', name: '维克托', source: '《钢铁是怎样炼成的》',
type: 'ranged', typeLabel: '远程',
hp: 68, sta: 60, mp: 100,
speed: 1.3, atk: 5,
atkRange: 6.0, detectRange: 0,
atkCd: 1.0, color: '#b07ac9',
passive: 'inform',
},
];
/* ============================================================
1. 地图常量与物理
============================================================ */
const COLS = 20, ROWS = 20, CELL = 32, WALL = 16;
const GRID_W = COLS * CELL, GRID_H = ROWS * CELL;
const WORLD_W = GRID_W + WALL * 2, WORLD_H = GRID_H + WALL * 2;
const WALLS = [
{ x: 0, y: 0, w: WORLD_W, h: WALL },
{ x: 0, y: WORLD_H - WALL, w: WORLD_W, h: WALL },
{ x: 0, y: 0, w: WALL, h: WORLD_H },
{ x: WORLD_W - WALL, y: 0, w: WALL, h: WORLD_H },
];
function aabbHit(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
function moveBody(body, dx, dy) {
body.x += dx;
for (const wall of WALLS) {
if (!aabbHit(body, wall)) continue;
if (dx > 0) body.x = wall.x - body.w;
else if (dx < 0) body.x = wall.x + wall.w;
}
body.y += dy;
for (const wall of WALLS) {
if (!aabbHit(body, wall)) continue;
if (dy > 0) body.y = wall.y - body.h;
else if (dy < 0) body.y = wall.y + wall.h;
}
return body;
}
function cellCenter(col, row) {
return { x: WALL + col * CELL + CELL / 2, y: WALL + row * CELL + CELL / 2 };
}
/* ============================================================
2. 选人界面
============================================================ */
const rosterEl = document.getElementById('roster');
const btnStartDuel = document.getElementById('btn-start-duel');
const selected = new Set();
function renderRoster() {
rosterEl.innerHTML = CHARACTERS.map(c => {
const typeClass = c.type === 'ranged' ? 'tag ranged' : 'tag';
return `
<div class="card" data-id="${c.id}">
<div class="char-name">${c.name}</div>
<div class="char-source">${c.source}</div>
<div class="tag-row"><span class="${typeClass}">${c.typeLabel}</span></div>
<div class="stats">
<span>生命<b>${c.hp}</b></span>
<span>攻击<b>${c.atk}</b></span>
<span>速度<b>${c.speed}</b></span>
<span>射程<b>${c.atkRange}</b></span>
</div>
</div>
`;
}).join('');
rosterEl.querySelectorAll('.card').forEach(card => {
card.addEventListener('click', () => {
const id = card.dataset.id;
if (selected.has(id)) { selected.delete(id); card.classList.remove('selected'); }
else { selected.add(id); card.classList.add('selected'); }
btnStartDuel.disabled = selected.size < 2;
});
});
}
/* ============================================================
3. 界面切换
============================================================ */
const screens = {
start: document.getElementById('screen-start'),
select: document.getElementById('screen-select'),
map: document.getElementById('screen-map'),
result: document.getElementById('screen-result'),
};
function show(name) {
Object.values(screens).forEach(s => s.classList.add('hidden'));
screens[name].classList.remove('hidden');
}
document.getElementById('btn-to-select').addEventListener('click', () => {
renderRoster();
selected.clear();
btnStartDuel.disabled = true;
show('select');
});
document.getElementById('btn-back-start').addEventListener('click', () => show('start'));
document.getElementById('btn-back-select').addEventListener('click', () => {
stopDuel();
show('select');
});
/* ============================================================
4. 地图绘制
============================================================ */
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const mapHud = document.getElementById('map-hud');
function resizeCanvas() {
const size = Math.min(window.innerWidth * 0.9, window.innerHeight * 0.72, 640);
const dpr = window.devicePixelRatio || 1;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
canvas.width = Math.round(size * dpr);
canvas.height = Math.round(size * dpr);
}
function drawArena(time) {
const scale = canvas.width / WORLD_W;
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.clearRect(0, 0, WORLD_W, WORLD_H);
const bg = ctx.createLinearGradient(WALL, WALL, WALL + GRID_W, WALL + GRID_H);
bg.addColorStop(0, '#0b1927');
bg.addColorStop(0.5, '#0d1e2e');
bg.addColorStop(1, '#061018');
ctx.fillStyle = bg;
ctx.fillRect(WALL, WALL, GRID_W, GRID_H);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(80,200,255,.10)';
ctx.beginPath();
for (let c = 0; c <= COLS; c++) {
const x = WALL + c * CELL;
ctx.moveTo(x, WALL); ctx.lineTo(x, WALL + GRID_H);
}
for (let r = 0; r <= ROWS; r++) {
const y = WALL + r * CELL;
ctx.moveTo(WALL, y); ctx.lineTo(WALL + GRID_W, y);
}
ctx.stroke();
const pulse = 0.5 + 0.5 * Math.sin(time * 0.002);
ctx.save();
ctx.shadowColor = 'rgba(0,255,224,.85)';
ctx.shadowBlur = 8 + pulse * 16;
ctx.beginPath();
ctx.rect(0, 0, WORLD_W, WORLD_H);
ctx.rect(WALL, WALL, GRID_W, GRID_H);
ctx.fillStyle = '#00cdb2';
ctx.fill('evenodd');
ctx.restore();
ctx.strokeStyle = `rgba(190,255,248,${0.4 + pulse * 0.45})`;
ctx.lineWidth = 2;
ctx.strokeRect(WALL + 1, WALL + 1, GRID_W - 2, GRID_H - 2);
}
/* ============================================================
5. 决斗逻辑
============================================================ */
const AGGRO_DURATION = 3.5; // 受击激怒持续时间(秒)
let fighters = [];
let projectiles = [];
let effects = [];
let floatingTexts = [];
let duelActive = false;
let rafId = null;
let lastTime = 0;
let duelSession = 0;
function assignWanderLeg(f) {
f.wanderAngle = Math.random() * Math.PI * 2;
f.wanderSpeedBoost = 1.15 + Math.random() * 1.05;
f.wanderDistLeft = (2.5 + Math.random() * 5) * CELL;
}
function stopDuel() {
duelActive = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
fighters = [];
projectiles = [];
effects = [];
floatingTexts = [];
}
function startDuel() {
stopDuel();
duelSession++;
resizeCanvas();
show('map');
const chosen = CHARACTERS.filter(c => selected.has(c.id));
const duo = chosen.slice(0, 2);
const spawnA = cellCenter(3, 17);
const spawnB = cellCenter(16, 2);
fighters = duo.map((c, i) => {
const f = {
def: c,
x: (i === 0 ? spawnA : spawnB).x - 13,
y: (i === 0 ? spawnA : spawnB).y - 13,
w: 26, h: 26,
hp: c.hp, maxHp: c.hp,
sta: c.sta, maxSta: c.sta,
mp: c.mp, maxMp: c.mp,
atkTimer: 0,
shield: 0, shieldTimer: 0, shieldInit: false,
marked: 0,
informMark: 0,
atkCount: 0,
alive: true,
speedMult: 1,
wanderAngle: 0,
wanderSpeedBoost: 1,
wanderDistLeft: 0,
dashTimer: 0,
dashCooldown: 0,
hitFlash: 0,
atkFlash: 0,
// 受击激怒
aggroTarget: null,
aggroTimer: 0,
};
assignWanderLeg(f);
return f;
});
updateHud();
duelActive = true;
lastTime = performance.now();
rafId = requestAnimationFrame(duelLoop);
}
function duelLoop(now) {
if (!duelActive) return;
const dt = Math.min((now - lastTime) / 1000, 0.05);
lastTime = now;
update(dt);
render(now);
if (duelActive) rafId = requestAnimationFrame(duelLoop);
}
function fireProjectile(owner, target, dmg, opts) {
const ox = owner.x + owner.w / 2;
const oy = owner.y + owner.h / 2;
const tx = target.x + target.w / 2;
const ty = target.y + target.h / 2;
const ang = Math.atan2(ty - oy, tx - ox);
const speed = opts.speed || 420;
projectiles.push({
owner,
x: ox, y: oy,
vx: Math.cos(ang) * speed,
vy: Math.sin(ang) * speed,
dmg,
color: opts.color || '#fff',
radius: opts.radius || 5,
life: 2.5,
isRuifeng: !!opts.isRuifeng,
isVictor: !!opts.isVictor,
trail: [],
});
owner.atkFlash = 0.12;
}
/* ---- 核心伤害结算 ---- */
function applyDamage(attacker, target, dmg, meta) {
let finalDmg = dmg;
if (meta.isRuifeng) {
if (target.marked > 0) finalDmg *= 1.2;
target.marked = 3;
}
if (target.informMark > 0) {
finalDmg *= 1.25;
}
if (target.shield > 0) {
finalDmg *= 0.4;
target.shield = 0;
target.shieldTimer = 0;
}
if (target.def.passive === 'steel_will') {
const missing = 1 - target.hp / target.maxHp;
const reduction = Math.min(0.3, missing * 0.5);
finalDmg *= (1 - reduction);
}
target.hp = Math.max(0, target.hp - finalDmg);
target.hitFlash = 0.15;
// —— 近战受击自动激怒:锁定伤害来源 ——
if (target.def.type === 'melee' && attacker && attacker !== target && attacker.alive) {
target.aggroTarget = attacker;
target.aggroTimer = AGGRO_DURATION;
}
floatingTexts.push({
x: target.x + target.w / 2 + (Math.random() - 0.5) * 18,
y: target.y - 2,
text: '-' + Math.round(finalDmg),
life: 0.9, maxLife: 0.9,
color: '#ff3b3b',
});
if (target.hp <= 0) {
target.alive = false;
endDuel(attacker);
return;
}
updateHud();
}
function update(dt) {
const aliveList = fighters.filter(f => f.alive);
if (aliveList.length < 2) return;
/* ---- 祥子:体力透支 ---- */
const xz = fighters.find(f => f.def.id === 'xiangzi' && f.alive);
if (xz) {
if (xz.hp / xz.maxHp < 0.4 && xz.sta > 0) {
xz.sta = Math.max(0, xz.sta - 2 * dt);
xz.speedMult = xz.sta > 0 ? 1.3 : 0.6;
} else {
xz.speedMult = 1;
}
}
/* ---- 富善先生:旧物屏障 ---- */
const fs = fighters.find(f => f.def.id === 'fushan' && f.alive);
if (fs) {
if (!fs.shieldInit) { fs.shield = 1; fs.shieldInit = true; }
fs.shieldTimer += dt;
if (fs.shieldTimer >= 12) { fs.shieldTimer = 0; fs.shield = 1; }
}
/* ---- 冷却/标记/激怒倒计时 ---- */
for (const f of fighters) {
if (!f.alive) continue;
f.atkTimer = Math.max(0, f.atkTimer - dt);
if (f.marked > 0) f.marked = Math.max(0, f.marked - dt);
if (f.informMark > 0) f.informMark = Math.max(0, f.informMark - dt);
if (f.dashTimer > 0) f.dashTimer = Math.max(0, f.dashTimer - dt);
if (f.dashCooldown > 0) f.dashCooldown = Math.max(0, f.dashCooldown - dt);
if (f.hitFlash > 0) f.hitFlash = Math.max(0, f.hitFlash - dt);
if (f.atkFlash > 0) f.atkFlash = Math.max(0, f.atkFlash - dt);
// 激怒计时与失效清理
if (f.aggroTimer > 0) {
f.aggroTimer = Math.max(0, f.aggroTimer - dt);
if (f.aggroTimer === 0 || !f.aggroTarget || !f.aggroTarget.alive) {
f.aggroTarget = null;
f.aggroTimer = 0;
}
}
}
/* ---- 单位移动与攻击 ---- */
for (const f of fighters) {
if (!f.alive) continue;
// 选目标:近战优先追激怒目标(无视探测范围),否则走原逻辑
let target = null;
let tracking = false;
let isAggro = false;
if (
f.def.type === 'melee' &&
f.aggroTimer > 0 &&
f.aggroTarget &&
f.aggroTarget.alive
) {
target = f.aggroTarget;
tracking = true;
isAggro = true;
} else {
target = fighters.find(o => o !== f && o.alive);
}
if (!target) continue;
const dx = (target.x + target.w / 2) - (f.x + f.w / 2);
const dy = (target.y + target.h / 2) - (f.y + f.h / 2);
const targetDist = Math.hypot(dx, dy);
let moveAngle = f.wanderAngle;
let shouldMove = true;
if (isAggro) {
// 激怒追击:直接朝伤害来源走
moveAngle = Math.atan2(dy, dx);
if (targetDist <= f.def.atkRange * CELL) shouldMove = false;
} else if (f.def.type === 'melee' && targetDist <= f.def.detectRange * CELL) {
moveAngle = Math.atan2(dy, dx);
tracking = true;
if (targetDist <= f.def.atkRange * CELL) shouldMove = false;
}
if (!tracking && f.dashCooldown <= 0 && Math.random() < 0.008) {
f.dashTimer = 0.15 + Math.random() * 0.2;
f.dashCooldown = 0.8 + Math.random() * 1.0;
}
// 移动
if (shouldMove) {
let speedMul = tracking ? 1.0 : f.wanderSpeedBoost;
if (f.dashTimer > 0) speedMul *= 2.5;
const speed = f.def.speed * CELL * (f.speedMult || 1) * speedMul * dt;
const oldX = f.x, oldY = f.y;
moveBody(f, Math.cos(moveAngle) * speed, Math.sin(moveAngle) * speed);
const moved = Math.hypot(f.x - oldX, f.y - oldY);
if (moved < speed * 0.4) {
if (!tracking) { assignWanderLeg(f); f.dashTimer = 0; }
} else if (!tracking) {
f.wanderDistLeft -= moved;
if (f.wanderDistLeft <= 0) assignWanderLeg(f);
}
}
// 攻击
if (f.def.type === 'melee') {
if (targetDist <= f.def.atkRange * CELL && f.atkTimer <= 0) {
f.atkTimer = f.def.atkCd;
f.atkFlash = 0.15;
let meleeDmg = f.def.atk;
if (f.def.passive === 'lead_fist') {
f.atkCount++;
if (f.atkCount % 3 === 0) meleeDmg *= 1.8;
}
effects.push({
x: target.x + target.w / 2,
y: target.y + target.h / 2,
angle: Math.atan2(dy, dx),
life: 0.22, maxLife: 0.22,
color: f.def.color,
heavy: f.def.passive === 'lead_fist' && f.atkCount % 3 === 0,
});
applyDamage(f, target, meleeDmg, { isRuifeng: false });
}
} else {
if (f.atkTimer <= 0) {
f.atkTimer = f.def.atkCd;
let dmg = f.def.atk;
const isRuifeng = f.def.id === 'ruifeng';
const isVictor = f.def.id === 'victor';
if (isRuifeng && targetDist < CELL * 2) dmg *= 0.65;
if (isVictor && targetDist > CELL * 4) dmg *= 0.8;
fireProjectile(f, target, dmg, {
speed: 420,
color: f.def.color,
radius: 5,
isRuifeng,
isVictor,
});
}
}
}
/* ---- 投掷物 ---- */
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
p.life -= dt;
if (p.life <= 0) { projectiles.splice(i, 1); continue; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.trail.push({ x: p.x, y: p.y });
if (p.trail.length > 7) p.trail.shift();
if (p.x < WALL || p.x > WORLD_W - WALL || p.y < WALL || p.y > WORLD_H - WALL) {
projectiles.splice(i, 1);
continue;
}
let hit = false;
for (const f of fighters) {
if (!f.alive || f === p.owner) continue;
const fx = f.x + f.w / 2;
const fy = f.y + f.h / 2;
const d = Math.hypot(p.x - fx, p.y - fy);
if (d <= f.w / 2 + p.radius) {
if (p.isVictor) f.informMark = 4;
applyDamage(p.owner, f, p.dmg, { isRuifeng: p.isRuifeng });
hit = true;
break;
}
}
if (hit) projectiles.splice(i, 1);
}
/* ---- 特效与伤害数字 ---- */
for (let i = effects.length - 1; i >= 0; i--) {
effects[i].life -= dt;
if (effects[i].life <= 0) effects.splice(i, 1);
}
for (let i = floatingTexts.length - 1; i >= 0; i--) {
floatingTexts[i].life -= dt;
if (floatingTexts[i].life <= 0) floatingTexts.splice(i, 1);
}
}
function endDuel(winner) {
duelActive = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
const mySession = duelSession;
setTimeout(() => {
if (mySession !== duelSession) return;
if (screens.map.classList.contains('hidden')) return;
document.getElementById('result-name').textContent = winner.def.name;
document.getElementById('result-sub').textContent = winner.def.source + ' · 经典大战';
show('result');
}, 550);
}
function updateHud() {
if (fighters.length < 2) return;
const [a, b] = fighters;
mapHud.innerHTML = `
<span><span class="hud-name">${a.def.name}</span> <span class="hud-hp">${Math.ceil(a.hp)}</span>/${a.maxHp}</span>
<span class="hud-vs">VS</span>
<span><span class="hud-name">${b.def.name}</span> <span class="hud-hp">${Math.ceil(b.hp)}</span>/${b.maxHp}</span>
`;
}
/* ---- 渲染 ---- */
function render(time) {
drawArena(time);
for (const f of fighters) {
if (!f.alive) continue;
const cx = f.x + f.w / 2;
const cy = f.y + f.h / 2;
const isAggro = f.def.type === 'melee' && f.aggroTimer > 0 && f.aggroTarget && f.aggroTarget.alive;
// 近战探测范围(激怒时改成红色)
if (f.def.type === 'melee' && f.def.detectRange > 0) {
ctx.save();
ctx.setLineDash([5, 5]);
ctx.strokeStyle = isAggro
? 'rgba(255,70,70,.28)'
: 'rgba(61,255,228,.18)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy, f.def.detectRange * CELL, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
const dashing = f.dashTimer > 0;
const hit = f.hitFlash > 0;
const atk = f.atkFlash > 0;
let steelRing = false;
if (f.def.passive === 'steel_will' && f.hp / f.maxHp < 0.5) steelRing = true;
// 激怒脉冲环
if (isAggro) {
const pulse = 0.5 + 0.5 * Math.sin(time * 0.012);
ctx.save();
ctx.strokeStyle = `rgba(255,70,70,${0.35 + pulse * 0.4})`;
ctx.lineWidth = 2.5;
ctx.shadowColor = 'rgba(255,70,70,.8)';
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.arc(cx, cy, f.w / 2 + 9 + pulse * 2, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
ctx.save();
ctx.shadowColor = f.def.color;
ctx.shadowBlur = hit ? 28 : (atk ? 22 : (dashing ? 26 : 12));
ctx.fillStyle = hit ? '#ffffff' : (atk ? '#ffffff' : (dashing ? '#ffffff' : f.def.color));
ctx.beginPath();
ctx.arc(cx, cy, f.w / 2, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
if (steelRing) {
ctx.save();
ctx.strokeStyle = 'rgba(224,90,58,.65)';
ctx.lineWidth = 2.5;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(cx, cy, f.w / 2 + 7, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
if (f.def.type === 'melee' && atk) {
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,.9)';
ctx.lineWidth = 2;
ctx.shadowColor = f.def.color;
ctx.shadowBlur = 14;
ctx.beginPath();
ctx.arc(cx, cy, f.w / 2 + 6, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
ctx.fillStyle = 'rgba(255,255,255,.85)';
ctx.font = '11px -apple-system, "PingFang SC", sans-serif';
ctx.textAlign = 'center';
ctx.fillText(f.def.name, cx, f.y - 8);
// 血条
const barW = 34, barH = 4;
const bx = cx - barW / 2, by = f.y - 20;
ctx.fillStyle = 'rgba(0,0,0,.5)';
ctx.fillRect(bx, by, barW, barH);
ctx.fillStyle = f.def.color;
ctx.fillRect(bx, by, barW * (f.hp / f.maxHp), barH);
if (f.shield > 0) {
ctx.strokeStyle = 'rgba(126,200,255,.8)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, f.w / 2 + 5, 0, Math.PI * 2);
ctx.stroke();
}
if (f.marked > 0) {
ctx.fillStyle = 'rgba(255,215,106,.9)';
ctx.beginPath();
ctx.moveTo(cx, f.y - 28);
ctx.lineTo(cx - 4, f.y - 34);
ctx.lineTo(cx + 4, f.y - 34);
ctx.closePath();
ctx.fill();
}
if (f.informMark > 0) {
ctx.fillStyle = 'rgba(176,122,201,.95)';
ctx.fillRect(cx - 4, f.y - 35, 8, 8);
}
// 朱赫来铅拳就绪提示
if (f.def.passive === 'lead_fist' && f.atkCount % 3 === 2) {
ctx.save();
ctx.strokeStyle = 'rgba(255,220,80,.8)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy, f.w / 2 + 4, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// 激怒感叹号(头顶)
if (isAggro) {
ctx.save();
ctx.fillStyle = '#ff4646';
ctx.font = 'bold 16px -apple-system, "PingFang SC", sans-serif';
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(255,70,70,.9)';
ctx.shadowBlur = 8;
ctx.fillText('!', cx, f.y - 40);
ctx.restore();
}
}
// 投掷物
for (const p of projectiles) {
for (let i = 0; i < p.trail.length; i++) {
const t = p.trail[i];
const k = (i + 1) / p.trail.length;
ctx.globalAlpha = k * 0.45;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(t.x, t.y, p.radius * (0.25 + k * 0.75), 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.save();
ctx.shadowColor = p.color;
ctx.shadowBlur = 16;
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.strokeStyle = p.color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.stroke();
}
// 近战命中特效
for (const e of effects) {
const k = e.life / e.maxLife;
const progress = 1 - k;
ctx.save();
ctx.globalAlpha = k * 0.95;
ctx.strokeStyle = '#ffffff';
ctx.shadowColor = e.color;
ctx.shadowBlur = e.heavy ? 28 : 20;
ctx.lineWidth = e.heavy ? 4 : 3;
ctx.beginPath();
ctx.arc(e.x, e.y, (e.heavy ? 16 : 12) + progress * (e.heavy ? 38 : 28), 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = e.color;
ctx.lineWidth = e.heavy ? 9 : 6;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(e.x, e.y, (e.heavy ? 14 : 10) + progress * 20, e.angle - 0.9, e.angle + 0.9);
ctx.stroke();
ctx.restore();
}
// 伤害数字
for (const t of floatingTexts) {
const k = t.life / t.maxLife;
const age = 1 - k;
const alpha = Math.min(1, k * 1.8);
ctx.save();
ctx.globalAlpha = alpha;
ctx.font = 'bold 15px -apple-system, "PingFang SC", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = t.color;
ctx.shadowColor = 'rgba(255,0,0,.9)';
ctx.shadowBlur = 10;
ctx.fillText(t.text, t.x, t.y - age * 38);
ctx.restore();
}
}
/* ============================================================
6. 启动
============================================================ */
document.getElementById('btn-start-duel').addEventListener('click', startDuel);
document.getElementById('btn-result-back').addEventListener('click', () => {
stopDuel();
duelSession++;
renderRoster();
selected.clear();
btnStartDuel.disabled = true;
show('select');
});
window.addEventListener('resize', () => {
if (!screens.map.classList.contains('hidden')) resizeCanvas();
});
show('start');
})();
</script>
</body>
</html>Game Source: 经典大战 · 选人与决斗
Creator: ApexFalcon66
Libraries: none
Complexity: complex (1029 lines, 31.6 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-apexfalcon66-mvdfhk05" to link back to the original. Then publish at arcadelab.ai/publish.