Orb Survivor · 从零生成指南
一句话玩法:WASD 控制战机上下左右移动,自动朝机身指向开火,打爆敌机掉金币和经验,
升级时三选一强化技能,坚持越久分越高。
产物是一个 .html 文件,双击即玩,无安装、无外部依赖、无需联网。
目标用时 5–6 分钟。验收标准只有一条:能不能玩(WASD 能动、会自动开火、
能打爆敌机捡到球、升级能弹窗选技能)。
本文按「必须先做对的硬约束 → 九个步骤 → 每步的完整实现细节」组织。
所有代码片段都可以直接抄,不需要自己发明数值。
开始前必读:四条会让游戏「不能玩」的硬约束
这四条不是风格建议,是实测踩过、会导致游戏看起来做完了但实际不能玩的硬约束。
-
敌机出生必须偏中心。子弹是固定朝上的单列枪线(方案禁止自瞄),如果敌机在
整个画布宽度上均匀撒开,绝大多数怪永远进不了枪线 → 击杀率个位数 → 经验攒不够 →
升级弹窗整局不出现。实测均匀分布时静止命中率只有 17%,30 秒内升不了级。 -
掉落物要能追到人。敌机在屏幕高处被打爆、玩家在底部,如果吸附半径外的掉落物
只是直线下落,经验球漂不到玩家就过期了,exp会长期为 0。必须让半径外的球也
以 260px/s 追踪玩家。 -
技能上限要在 apply() 里 clamp。只在筛选池子时用
max过滤是不够的——
同一个技能被反复点选,数值会无限叠上去(伤害叠到 10、子弹叠到 7 发)。 -
横飞的敌机必须慢、且入口偏下。第一版让它在屏幕中部快速飘过,实测击杀率 0%
却把玩家撞死 90 次——固定向上的枪永远摸不到它,纯惩罚零经验。
步骤 1:HTML 骨架 + CSS + 三个覆盖层
单个 HTML 文件,结构如下。这些 DOM 元素一个都不能少,后面几步都依赖它们。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Orb Survivor</title>
<style>
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden; /* 绝不出现滚动条 */
background: #0a0a0f; color: #ddd;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
display: flex; align-items: center; justify-content: center;
}
/* 逻辑分辨率固定 720×900,用 scale 整体缩放到视口内 */
#wrap { position: relative; width: 720px; height: 900px; transform-origin: center center; }
canvas {
display: block; width: 720px; height: 900px;
background: #000; border: 1px solid #2a2a3a; border-radius: 8px;
}
/* HUD:顶部一条,pointer-events:none 让它不吃鼠标 */
#hud {
position: absolute; top: 0; left: 0; width: 100%;
padding: 8px 12px; pointer-events: none;
display: flex; justify-content: space-between; align-items: center;
font-size: 14px; text-shadow: 0 0 4px #000;
}
#hud .box { display: flex; gap: 12px; }
/* 经验条:压在 HUD 下方一条细线 */
#expbar {
position: absolute; top: 30px; left: 12px; right: 12px; height: 5px;
background: #222; border-radius: 3px; overflow: hidden; pointer-events: none;
}
#expfill { height: 100%; width: 0%; background: linear-gradient(90deg,#3fd,#7af); }
/* 覆盖层:开始 / 结束 / 升级 共用一套,靠 .show 切换 */
.overlay {
position: absolute; inset: 0;
background: radial-gradient(ellipse at 50% 40%, rgba(30,20,70,.92), rgba(2,2,8,.97));
display: none; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 24px;
}
.overlay.show { display: flex; }
.overlay h1 { margin: 0 0 8px; font-size: 28px; color: #fff; letter-spacing: 2px; }
.overlay h2 { margin: 0 0 4px; font-size: 30px; letter-spacing: 3px; color: #fff;
text-shadow: 0 0 18px #7cf, 0 0 40px rgba(90,160,255,.6); }
.overlay p { margin: 4px 0; font-size: 14px; line-height: 1.7; color: #aab; }
.big {
margin-top: 18px; padding: 12px 34px; font-size: 18px;
color: #08111a; background: #6fe0ff; border: 0; border-radius: 24px;
cursor: pointer; font-weight: 700;
}
.big:hover { background: #a8ecff; }
#hint { position:absolute; bottom:6px; left:0; width:100%; text-align:center;
font-size:11px; color:#445; pointer-events:none; }
</style>
</head>
<body>
<div id="wrap">
<canvas id="cv" width="720" height="900"></canvas>
<!-- HUD:分数 / 等级 / 已获得技能标签 / 生命 -->
<div id="hud">
<div class="box"><span id="hScore">分数 0</span><span id="hLevel">Lv 1</span></div>
<div class="box"><span id="hSkills"></span><span id="hLives">♥♥♥</span></div>
</div>
<div id="expbar"><div id="expfill"></div></div>
<!-- 开始画面 -->
<div id="mStart" class="overlay show">
<h1>ORB SURVIVOR</h1>
<p>WASD 移动战机 · 自动开火<br>打爆敌机捡金币和经验 · 升级三选一强化<br>
敌机撞到你会扣一条命(共 3 条)</p>
<button id="btnStart" class="big">开始游戏</button>
<p style="margin-top:12px;color:#667">坚持越久,得分越高</p>
</div>
<!-- 升级弹窗 -->
<div id="mLevel" class="overlay">
<h2 id="lvTitle">升级 Lv 2</h2>
<p id="lvSub" style="letter-spacing:6px;color:#6fa8d8;font-size:13px">选择一项强化</p>
<div id="cards"></div>
</div>
<!-- 结束画面 -->
<div id="mOver" class="overlay">
<h1 style="color:#ff6b6b">GAME OVER</h1>
<p id="ovStats"></p>
<button id="btnRetry" class="big">再来一局</button>
</div>
<div id="hint">WASD 移动 · 自动开火 · 活得越久分越高</div>
</div>
<script>
"use strict";
/* ……见后续步骤…… */
</script>
</body>
</html>
装配契约:怎么把本文变成一个能玩的游戏(照做即可)
这是本 skill 能被机械执行的关键,也是它区别于「读灵感」的地方。
代码围栏有两种语言标记,含义完全不同:
| 围栏 | 数量 | 含义 |
|---|---|---|
围栏语言标 js |
18 个 | 要贴进文件。按文档从上到下的顺序原样追加 |
围栏语言标 js-text |
4 个 | 不要贴。只为讲解原理,权威实现已在 update() 里 |
那四段讲解围栏如果误贴进去,会得到 Identifier 'mag' has already been declared
之类的重复声明错误——因为它们和 update() 内部的对应段落是同一段代码的两个版本。
装配三步:
- 复制步骤 1 的 HTML 骨架存成
.html,<script>里只留"use strict";。 - 按出现顺序把 18 个「语言标为 js 的围栏」追加到
<script>尾部。追加,不替换,不改顺序。 - 验语法(模板见
references/selftest.md第 0 节)。三种报错对应三种失误:
Identifier xxx has already been declared→ 某个块贴了两遍,或误贴了讲解用的 js-text 围栏Cannot access 'X' before initialization→ 顺序被打乱Illegal return statement→ 把update()的内部片段贴到了顶层
为什么顺序不能乱:敌机生成、碰撞、掉落之间互为前向引用——函数声明会提升,
但 const/let 不会。乱序的典型后果不是启动报错,而是游戏跑起来了、却在第一次
走到某分支时才炸,看起来像随机 bug,白白烧掉课上那唯一一次迭代机会。
一处硬依赖(按序追加自然满足,写在这里是为了你手动调整时不踩):
步骤 9 的四个预渲染函数 nebulaLayer / engineGlow / coinGlow / bulletGlow
必须在 draw() 之前,因为 draw() 每帧调它们取缓存图。文档里正好挨着排在前面。
只有 5 分钟怎么办:跳过所有讲解正文,只做上面三步。装完你就有一个能玩的游戏;
至于「为什么敌机要偏中心出生」「为什么不给子弹加散角」这些改了会直接不能玩的道理,
在讲解用的 js-text 围栏和各步骤正文里,课后再读。
升级卡片的 CSS(配上面的 #cards)
卡片要有图标框、主题色、等级徽章,这是「炫酷」的主要来源:
#cards { display: flex; flex-direction: column; gap: 14px; width: 100%; max-width: 420px; }
.card {
position: relative; overflow: hidden;
display: flex; align-items: center; gap: 16px;
text-align: left; padding: 16px 18px; cursor: pointer;
background: linear-gradient(135deg, rgba(24,32,56,.95), rgba(12,16,30,.95));
border: 1px solid rgba(90,130,200,.35); border-radius: 14px;
color: #e8eefc; font-size: 14px; font-family: inherit;
opacity: 0; transform: translateX(-40px);
animation: cardIn .42s cubic-bezier(.2,.9,.3,1.2) forwards;
transition: transform .16s ease, border-color .16s, box-shadow .16s;
}
.card:nth-child(1) { animation-delay: .05s; }
.card:nth-child(2) { animation-delay: .16s; }
.card:nth-child(3) { animation-delay: .27s; }
@keyframes cardIn { to { opacity: 1; transform: translateX(0); } }
/* 顶部流光扫过 */
.card::before {
content: ''; position: absolute; top: 0; left: -60%; width: 45%; height: 100%;
background: linear-gradient(105deg, transparent, rgba(160,220,255,.14), transparent);
animation: sheen 3.2s linear infinite;
}
@keyframes sheen { 0% { left: -60%; } 60%, 100% { left: 130%; } }
.card:hover {
transform: translateX(6px) scale(1.035);
border-color: var(--ac, #6fe0ff);
box-shadow: 0 0 24px var(--glow, rgba(111,224,255,.4)), inset 0 0 22px rgba(111,224,255,.08);
}
.card:active { transform: translateX(6px) scale(.98); }
.ico {
flex: 0 0 52px; width: 52px; height: 52px; border-radius: 12px;
display: grid; place-items: center; font-size: 26px; line-height: 1;
background: radial-gradient(circle at 35% 30%, var(--glow, rgba(111,224,255,.28)), rgba(0,0,0,.35));
border: 1px solid var(--ac, #6fe0ff);
box-shadow: 0 0 14px var(--glow, rgba(111,224,255,.35));
animation: icoPulse 2.4s ease-in-out infinite;
}
@keyframes icoPulse { 50% { box-shadow: 0 0 26px var(--glow, rgba(111,224,255,.6)); transform: scale(1.06); } }
.txt { flex: 1; min-width: 0; }
.card b { display: block; font-size: 19px; font-weight: 700; color: var(--ac, #ffd76a);
margin-bottom: 4px; letter-spacing: 1px; text-shadow: 0 0 12px var(--glow, rgba(111,224,255,.5)); }
.card em { display: block; font-style: normal; font-size: 13.5px; color: #a8bcd8; line-height: 1.5; }
.lvbadge {
position: absolute; top: 10px; right: 12px; font-size: 11px; letter-spacing: 1px;
padding: 2px 8px; border-radius: 999px; color: #0a0f1a; font-weight: 700;
background: var(--ac, #6fe0ff); box-shadow: 0 0 10px var(--glow, rgba(111,224,255,.5));
font-style: normal;
}
不要做的事:不引入任何框架、不用 <img>、不外链 CSS/JS/字体、不加 <audio>。
步骤 2:常量、状态与响应式布局
const W = 720, H = 900;
const MAX_TILT = 22 * Math.PI / 180; // 机身最大倾斜角;32° 会把准星甩飞
// 把整个 #wrap 缩放到当前视口内(留 12px 边距)
function fitViewport() {
const s = Math.min((innerWidth - 12) / W, (innerHeight - 12) / H, 1);
document.getElementById('wrap').style.transform = 'scale(' + s.toFixed(4) + ')';
}
fitViewport();
addEventListener('resize', fitViewport);
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
// 工具函数
const rand = (a, b) => a + Math.random() * (b - a);
const clamp = (v, a, b) => v < a ? a : (v > b ? b : v);
const dist2 = (ax, ay, bx, by) => { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; };
const el = id => document.getElementById(id);
// 状态声明(注意 trails / shockwaves / floaters / muzzle 这四个特效数组也要有)
let player, bullets, enemies, pickups, particles, stars, trails, shockwaves, floaters, muzzle;
let score, level, exp, expNeed, elapsed, spawnTimer, lastShot;
let keys = Object.create(null);
let running = false, paused = false, over = false;
let shake = 0, hitStop = 0, recoil = 0, flash = null;
let rafId = 0, prevTime = 0;
const skillTaken = Object.create(null);
const cardEls = []; // 本弹窗实际插入的按钮,下次弹出前逐个移除
重置函数(开局和「再来一局」都调它):
function resetState() {
player = {
x: W / 2, y: H - 90, r: 12, angle: 0,
lives: 3, invuln: 1.2, // 开局给短暂无敌,避免刚出生就被围死
damage: 1, fireInterval: 170, moveSpeed: 220,
goldMul: 1, bulletRadius: 4, bullets: 1, shield: 0, magnetRadius: 70,
};
bullets = []; enemies = []; pickups = []; particles = [];
trails = []; shockwaves = []; floaters = []; muzzle = [];
shake = 0; hitStop = 0; recoil = 0; flash = null;
score = 0; level = 1; exp = 0; expNeed = 8; // 第一级 8 个经验球
elapsed = 0; spawnTimer = 0; lastShot = 0;
for (const k in skillTaken) delete skillTaken[k];
initStars();
syncHUD();
}
三层视差星空(远/中/近,参数分别是 [数量, 速度, 亮度]):
function initStars() {
stars = [];
const layers = [[70, 18, .3], [45, 26, .55], [25, 40, .85]];
for (const [n, spd, br] of layers)
for (let i = 0; i < n; i++)
stars.push({ x: rand(0, W), y: rand(0, H), s: rand(spd * .6, spd * 1.5), b: rand(br * .5, br) });
}
HUD 同步函数(每次状态变化都要调,包括技能标签):
function syncHUD() {
el('hScore').textContent = '分数 ' + Math.floor(score);
el('hLevel').textContent = 'Lv ' + level;
el('hLives').textContent = '♥'.repeat(Math.max(0, player.lives)) + '·'.repeat(player.shield);
el('expfill').style.width = clamp(exp / expNeed * 100, 0, 100) + '%';
const parts = [];
for (const k in skillTaken) if (skillTaken[k]) parts.push(k + '×' + skillTaken[k]);
el('hSkills').textContent = parts.join(' ');
}
步骤 3:输入处理(两个细节容易漏)
addEventListener('keydown', e => {
keys[e.key.toLowerCase()] = true;
// 防止按 WASD/空格 时页面滚动(虽然已经 overflow:hidden,但空格仍可能触发)
if (['w','a','s','d',' '].includes(e.key.toLowerCase())) e.preventDefault();
});
addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; });
// 失焦时必须清空按键,否则切走再回来角色会一直朝一个方向跑
addEventListener('blur', () => { keys = Object.create(null); });
步骤 4:玩家移动 + 机身转向 + 自动开火
> 以下这段只为讲解,装配时跳过——权威实现见文末完整的 update()。
// 移动(WASD 平移,不需要瞄准)
let mx = 0, my = 0;
if (keys['a']) mx -= 1; if (keys['d']) mx += 1;
if (keys['w']) my -= 1; if (keys['s']) my += 1;
if (mx && my) { mx *= 0.7071; my *= 0.7071; } // 斜向不加速
player.x = clamp(player.x + mx * player.moveSpeed * dt, player.r, W - player.r);
player.y = clamp(player.y + my * player.moveSpeed * dt, player.r + 40, H - player.r);
if (player.invuln > 0) player.invuln -= dt;
// 机身朝向跟随水平移动方向自动倾斜
// 只在水平移动时倾斜(竖直走位不该带走弹道),响应柔和避免追球时把准星甩飞
const wantAng = clamp(-mx * 0.38, -MAX_TILT, MAX_TILT);
player.angle += (wantAng - player.angle) * Math.min(1, dt * 7);
关键验证点:静止时 angle 必须严格等于 0,否则违反「自动向上开火」这条红线。
上面的写法满足(mx=0 → wantAng=0 → 收敛到 0)。
自动开火(弹速 760,多发平行于机体指向,不要加散角撒扇面):
> 以下这段只为讲解,装配时跳过——权威实现见文末完整的 update()。
lastShot += dt * 1000;
if (lastShot >= player.fireInterval) {
lastShot = 0;
const n = player.bullets, gap = 12;
const ca = Math.cos(player.angle), sa = Math.sin(player.angle);
const BSPEED = 760;
for (let i = 0; i < n; i++) {
const off = (i - (n - 1) / 2) * gap; // 横向间隔
const px = player.x + off * ca, py = player.y - player.r - off * sa;
bullets.push({
x: px, y: py, r: player.bulletRadius,
vx: sa * -BSPEED, vy: ca * -BSPEED, // 随角度旋转
hot: Math.min(1, (player.damage - 1) / 5), // 只用于画法,不参与碰撞
spin: player.bullets > 1, // 同上
});
trails.push({ x: px, y: py, life: 0.14, t: 0, r: player.bulletRadius, ang: player.angle });
muzzle.push({ x: px, y: py, t: 0, life: 0.075, r: player.bulletRadius * 3.1,
ang: player.angle, hot: Math.min(1, (player.damage - 1) / 5) });
}
recoil = 1; // 后坐力,下一帧衰减
}
为什么不给子弹加散角:实测回扫会白打、固定散角会让弹幕射到空白处,
经验收入下降,静止玩家弹窗从 14/16 掉到 8/16。割草感来自「单位时间多少发」。
步骤 5:敌机生成与三种飞行模式
同屏硬上限 34 架(防后期指数堆怪拖垮帧率,也保住「能躲开」的空间)。
// 权重随时间漂移:前 15 秒几乎全是竖落(先让学生上手),之后横飞/盘旋逐增
function pickMode() {
const t = Math.min(1, Math.max(0, elapsed - 15) / 85);
const wDrop = 0.82 - t * 0.50; // 82% → 32%
const wCross = 0.10 + t * 0.28; // 10% → 38%
const r = Math.random();
if (r < wDrop) return 'drop';
if (r < wDrop + wCross) return 'cross';
return 'spiral'; // 剩下 8% → 30%
}
function spawnEnemy(stagger) {
const mode = pickMode();
const speed = rand(88, 122) + Math.min(70, elapsed * 1.2);
const e = { hp: 3, r: 14, hue: rand(0, 40), mode,
phase: rand(0, Math.PI * 2), freq: rand(1.6, 3.2), amp: rand(55, 110),
rot: 0, kx: 0, ky: 0, flash: 0 };
if (mode === 'cross') {
// 横穿:入口在中上部、横移要慢,让它来得及吃三发子弹(第 4 号硬约束)
const fromLeft = Math.random() < 0.5;
e.x = fromLeft ? -24 : W + 24;
e.y = rand(H * 0.18, H * 0.46);
e.vx = (fromLeft ? 1 : -1) * rand(34, 62);
e.vy = rand(26, 52);
} else {
// drop / spiral:都从顶部出生。竖落偏中心(固定枪线能打到,保证经验收入),
// 两侧角落主要交给 cross 和 spiral 去覆盖。
const u = mode === 'drop'
? 0.5 + ((Math.random() + Math.random()) / 2 - 0.5) * (elapsed < 25 ? 0.75 : 1.0) // 开局收窄到枪线带,之后铺满全宽
: Math.random(); // 盘旋全宽撒开
e.x = W * u;
e.y = -20 - (stagger ? rand(0, stagger) : 0);
e.vx = rand(-14, 14);
e.vy = mode === 'spiral' ? speed * 0.5 : speed; // 盘旋下得慢,留在场上久
}
enemies.push(e);
}
出生横向系数的选取依据(第 1 号大坑的量化版)
drop 用两次随机数之和模拟弱中心偏好,系数直接决定静止玩家能不能 30 秒内升级。
实测扫描(静止挂机,同族方案比的是相对优劣):
| 方案 | 30s 内达标(120 种子) | 中位 | p95 | 整局没升级 |
|---|---|---|---|---|
| 固定 1.6 | 7/10 | 28.3s | — | 频繁超线 |
| 固定 1.0 | 117/120 (97.5%) | 21.7s | 27.8s | 3 |
| 固定 0.85 | 60/60 | 20.1s | 24.4s | 0,但边缘只剩 1.1% |
| 固定 0.7 | 60/60 | 19.1s | 23.1s | 0,但边缘 0%,两侧全空 |
| 开局 25s 用 0.75、之后回到 1.0 | 120/120 (100%) | 19.5s | 24.1s | 0 ← 用这个 |
为什么必须用「开局收窄 + 之后恢复」,而不是直接取一个小的固定值:固定 0.7 虽然
数据最好看,但实测它的敌机出生横向分布里最外侧两个区间占比为 0%,屏幕两侧永远是空的,
观感上就不是「满屏敌机」了。开局 25 秒窄一点只影响新手期那几秒,之后恢复 1.0 保住铺满。
另外澄清一个我一开始的错误结论:那 3/120 的失败不是散布太宽导致的,而是玩家
在攒够 8 个球之前就先被撞死了(逐秒 dump 显示 lives 从 3 → 1 → 死)。所以单纯调窄系数只是
碰巧缓解症状;真正的杠杆是保证前期打得死怪、经验进得来。
也不要把 spiral 一起收窄——它负责铺满屏幕做点缀,击杀率低一点没关系,
只要 drop 这一路能稳定输出经验就不会卡升级。
// 一波几只:前 16 秒恒定 1 只,之后每 28 秒多一只,封顶 7
function waveSize() {
const base = 1 + Math.max(0, elapsed - 16) / 28;
const jitter = Math.random() < (base % 1) ? 1 : 0;
return Math.min(7, Math.floor(base) + jitter);
}
spawnTimer 累加后按 every = max(0.26, (elapsed<25 ? 0.72 : 0.60) - elapsed*0.005) 的节奏批量生成,每波数量取 waveSize() 并与「同屏剩余名额 34 - enemies.length」取小;多只时用 90 的 stagger 错开出生高度避免挤成一堆。实现见下方 update() 的「敌机生成」段。
spiral 用 phase += freq*dt 叠加正弦横摆 cos(phase)*amp*dt(纯公式驱动,不需要记轨迹);drop 碰壁反弹并朝中线微回中;cross 既不反弹也不回中,就是要直直穿过。朝向 rot 以 dt*8 收敛到 atan2(vx,vy);白闪 flash 以 dt*12 退去;击退位移 kx/ky 以 0.86 衰减到清零。离屏判定必须同时查 y 溢出和 x 出界——漏了 x 判断的话横飞敌机会永远留在数组里。撞上玩家时代码走 e.r*0.78 + player.r 的宽容判定并 return 退出本轮 update。实现见下方 update()。
步骤 6:碰撞、击毁掉落、拾取成长
子弹打敌机(碰撞半径 = 绘制半径,方案红线,不许改):
> 以下这段只为讲解,装配时跳过——权威实现见文末完整的 update()。
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.y += b.vy * dt; b.x += (b.vx || 0) * dt;
if (b.y < -20 || b.x < -20 || b.x > W + 20) { bullets.splice(i, 1); continue; }
for (let j = enemies.length - 1; j >= 0; j--) {
const e = enemies[j];
if (dist2(b.x, b.y, e.x, e.y) < (b.r + e.r) ** 2) {
bullets.splice(i, 1);
e.hp -= player.damage;
// 打击感三件套:白闪、沿弹道后退一小截、火花加密
e.flash = 1;
e.kx = (b.vx || 0) * 0.05; e.ky = b.vy * 0.05;
hitStop = Math.min(hitStop + 0.012, 0.05); // 极短顿帧,封顶
shake = Math.min(shake + 1.2, 6);
burst(b.x, b.y, '#bffcff', 4 + Math.round(player.damage));
if (e.hp <= 0) { enemies.splice(j, 1); killEnemy(e); }
break; // 一发子弹只打一只
}
}
}
击毁 → 掉落 + 爆炸演出:
function killEnemy(e) {
boom(0.42, 0.16);
burst(e.x, e.y, `hsl(${e.hue},90%,60%)`, 14);
burst(e.x, e.y, '#ffffff', 6); // 白色内核,看着更爆
shockwaves.push({ x: e.x, y: e.y, r: 8, maxR: 58, life: 0.34, t: 0,
color: `hsl(${e.hue},95%,70%)`, ring: 2 });
shake = Math.min(shake + 3.2, 10);
hitStop = Math.min(hitStop + 0.02, 0.06); // 击毁比命中多一顿,形成反馈分层
score += 10 * player.goldMul;
// 金币 + 经验球(两类都慢落、长存在,配合下面的全场追踪保证「打掉就一定吃得到」)
pickups.push({ x: e.x, y: e.y, r: 5, kind: 'gold', vy: 34, life: 14 });
pickups.push({ x: e.x + rand(-6, 6), y: e.y, r: 5, kind: 'exp', vy: 28, life: 14 });
}
function burst(x, y, color, n) {
for (let i = 0; i < n; i++) {
const a = rand(0, Math.PI * 2), sp = rand(40, 170);
particles.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
life: rand(.25, .6), t: 0, color });
}
}
掉落物吸附拾取(第 2 号硬约束:半径外也必须追踪):
> 以下这段只为讲解,装配时跳过——权威实现见文末完整的 update()。
const mag = player.magnetRadius;
for (let i = pickups.length - 1; i >= 0; i--) {
const p = pickups[i];
p.life -= dt;
const d2 = dist2(p.x, p.y, player.x, player.y);
if (d2 < mag * mag) {
const d = Math.sqrt(d2) || 1;
const pull = 460 + (1 - d / mag) * 420; // 越近吸得越快
p.x += (player.x - p.x) / d * pull * dt;
p.y += (player.y - p.y) / d * pull * dt;
} else {
// 磁半径外仍以 260px/s 追玩家。之前只有 95px/s 而掉落物只存 14 秒——
// 屏幕顶部打死的怪,经验球漂不到底部就过期,导致「跑得越远升级越慢」。
const d = Math.sqrt(d2) || 1;
p.x += (player.x - p.x) / d * 260 * dt;
p.y += (player.y - p.y) / d * 260 * dt;
}
if (d2 < (player.r + p.r + 4) ** 2) { // 拾取
pickups.splice(i, 1);
if (p.kind === 'gold') { score += 5 * player.goldMul; blip(1050); }
else { exp += 1; blip(760); if (exp >= expNeed) { syncHUD(); levelUp(); } }
} else if (p.life <= 0 || p.y > H + 20) { pickups.splice(i, 1); }
}
受伤处理(金钟罩先挡、然后扣命、0.6 秒无敌):
function hitPlayer() {
if (player.invuln > 0) return; // 无敌期间完全无事
if (player.shield > 0) {
player.shield -= 1; // 先扣 1 层,不扣命
player.invuln = 0.6;
boom(0.5, 0.2); burst(player.x, player.y, '#ffd76a', 16);
} else {
player.lives -= 1;
player.invuln = 0.6;
boom(0.8, 0.3); burst(player.x, player.y, '#ff6b6b', 20);
shockwaves.push({ x: player.x, y: player.y, r: 12, maxR: 90, life: 0.5, t: 0, color: '#ff4040' });
shake = 14;
if (player.lives <= 0) return gameOver();
}
syncHUD();
}
存活时间也算分(放在 update 开头):score += dt * 2;
步骤 6 收尾:完整的 update()(照这个贴,别自己拼片段)
下面这份是当前已验证版本的完整 update()。前面几个小节里标着
「(update 内)」的代码块都是它的局部视图,仅供理解;实际装配时只贴这一份,
那些局部视图跳过。这样做的理由见上面的装配契约。
function update(dt) {
elapsed += dt;
score += dt * 2;
// 玩家移动
let mx = 0, my = 0;
if (keys['a']) mx -= 1; if (keys['d']) mx += 1;
if (keys['w']) my -= 1; if (keys['s']) my += 1;
if (mx && my) { mx *= 0.7071; my *= 0.7071; }
player.x = clamp(player.x + mx * player.moveSpeed * dt, player.r, W - player.r);
player.y = clamp(player.y + my * player.moveSpeed * dt, player.r + 40, H - player.r);
if (player.invuln > 0) player.invuln -= dt;
// 机身朝向跟随水平移动
const wantAng = clamp(-mx * 0.38, -MAX_TILT, MAX_TILT);
player.angle += (wantAng - player.angle) * Math.min(1, dt * 7);
// 自动开火
lastShot += dt * 1000;
if (lastShot >= player.fireInterval) {
lastShot = 0;
const n = player.bullets, gap = 12;
const ca = Math.cos(player.angle), sa = Math.sin(player.angle);
const BSPEED = 760;
for (let i = 0; i < n; i++) {
const off = (i - (n - 1) / 2) * gap;
const px = player.x + off * ca, py = player.y - player.r - off * sa;
bullets.push({ x: px, y: py, r: player.bulletRadius,
vx: sa * -BSPEED, vy: ca * -BSPEED,
hot: Math.min(1, (player.damage - 1) / 5), spin: player.bullets > 1 });
trails.push({ x: px, y: py, life: 0.14, t: 0, r: player.bulletRadius, ang: player.angle });
muzzle.push({ x: px, y: py, t: 0, life: 0.075, r: player.bulletRadius * 3.1,
ang: player.angle, hot: Math.min(1, (player.damage - 1) / 5) });
}
recoil = 1;
}
// 子弹移动与碰撞
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.y += b.vy * dt; b.x += (b.vx || 0) * dt;
if (b.y < -20 || b.x < -20 || b.x > W + 20) { bullets.splice(i, 1); continue; }
for (let j = enemies.length - 1; j >= 0; j--) {
const e = enemies[j];
if (dist2(b.x, b.y, e.x, e.y) < (b.r + e.r) ** 2) {
bullets.splice(i, 1);
e.hp -= player.damage;
e.flash = 1;
e.kx = (b.vx || 0) * 0.05; e.ky = b.vy * 0.05;
hitStop = Math.min(hitStop + 0.012, 0.05);
shake = Math.min(shake + 1.2, 6);
burst(b.x, b.y, '#bffcff', 4 + Math.round(player.damage));
if (e.hp <= 0) { enemies.splice(j, 1); killEnemy(e); }
break;
}
}
}
// 敌机生成
spawnTimer += dt;
const every = Math.max(0.26, (elapsed < 25 ? 0.72 : 0.60) - elapsed * 0.005);
if (spawnTimer >= every) {
spawnTimer = 0;
const room = 34 - enemies.length;
if (room > 0) {
const n = Math.min(waveSize(), room);
for (let i = 0; i < n; i++) spawnEnemy(n > 1 ? 90 : 0);
}
}
// 敌机移动 + 撞玩家
for (let i = enemies.length - 1; i >= 0; i--) {
const e = enemies[i];
if (e.mode === 'spiral') {
e.phase += e.freq * dt;
e.x += Math.cos(e.phase) * e.amp * dt;
e.y += e.vy * dt;
} else {
e.y += e.vy * dt; e.x += e.vx * dt;
if (e.mode === 'drop') {
if (e.x < e.r || e.x > W - e.r) e.vx *= -1;
e.vx += clamp(W / 2 - e.x, -1, 1) * 20 * dt;
}
}
const tgtRot = Math.atan2(e.vx, e.vy);
e.rot = e.rot + (tgtRot - e.rot) * Math.min(1, dt * 8);
if (e.flash > 0) e.flash = Math.max(0, e.flash - dt * 12);
if (e.kx || e.ky) {
e.x += e.kx * dt * 60; e.y += e.ky * dt * 60;
e.kx *= 0.86; e.ky *= 0.86;
if (Math.abs(e.kx) < 1) e.kx = 0;
if (Math.abs(e.ky) < 1) e.ky = 0;
}
if (e.y > H + 30 || e.x < -50 || e.x > W + 50) { enemies.splice(i, 1); continue; }
if (dist2(e.x, e.y, player.x, player.y) < (e.r * 0.78 + player.r) ** 2) {
enemies.splice(i, 1);
burst(e.x, e.y, '#f88', 8);
hitPlayer();
if (!running) return;
}
}
// 掉落物吸附拾取
const mag = player.magnetRadius;
for (let i = pickups.length - 1; i >= 0; i--) {
const p = pickups[i];
p.life -= dt;
const d2 = dist2(p.x, p.y, player.x, player.y);
if (d2 < mag * mag) {
const d = Math.sqrt(d2) || 1;
const pull = 460 + (1 - d / mag) * 420;
p.x += (player.x - p.x) / d * pull * dt;
p.y += (player.y - p.y) / d * pull * dt;
} else {
const d = Math.sqrt(d2) || 1;
p.x += (player.x - p.x) / d * 260 * dt;
p.y += (player.y - p.y) / d * 260 * dt;
}
if (d2 < (player.r + p.r + 4) ** 2) {
pickups.splice(i, 1);
if (p.kind === 'gold') { score += 5 * player.goldMul; blip(1050); }
else { exp += 1; blip(760); if (exp >= expNeed) { syncHUD(); levelUp(); } }
} else if (p.life <= 0 || p.y > H + 20) { pickups.splice(i, 1); }
}
// 特效生命周期
for (let i = particles.length - 1; i >= 0; i--) {
const q = particles[i]; q.t += dt;
if (q.t >= q.life) { particles.splice(i, 1); continue; }
q.x += q.vx * dt; q.y += q.vy * dt;
if (!q.nodrift) q.vy += 120 * dt;
if (q.nodrift) { q.vx *= 0.94; q.vy *= 0.94; }
}
for (let i = trails.length - 1; i >= 0; i--) {
trails[i].t += dt;
if (trails[i].t >= trails[i].life) trails.splice(i, 1);
}
for (let i = shockwaves.length - 1; i >= 0; i--) {
const w = shockwaves[i]; w.t += dt;
if (w.t >= w.life) { shockwaves.splice(i, 1); continue; }
const k = w.t / w.life;
w.r = w.maxR * (1 - (1 - k) * (1 - k));
}
for (let i = floaters.length - 1; i >= 0; i--) {
const f = floaters[i]; f.t += dt;
if (f.t >= f.life) floaters.splice(i, 1);
}
for (let i = muzzle.length - 1; i >= 0; i--) {
muzzle[i].t += dt;
if (muzzle[i].t >= muzzle[i].life) muzzle.splice(i, 1);
}
if (flash) { flash.t += dt; if (flash.t >= flash.life) flash = null; }
if (shake > 0) shake = Math.max(0, shake - dt * 28);
if (hitStop > 0) hitStop = Math.max(0, hitStop - dt);
if (recoil > 0) recoil = Math.max(0, recoil - dt * 9);
for (const s of stars) { s.y += s.s * dt; if (s.y > H) { s.y = -2; s.x = rand(0, W); } }
if (Math.random() < 0.5) {
particles.push({ x: player.x + rand(-4, 4), y: player.y + 16, vx: rand(-15, 15),
vy: rand(60, 120), life: rand(.15, .35), t: 0,
color: Math.random() < .5 ? '#ffb040' : '#ff7020' });
}
syncHUD();
}
步骤 7:八个技能 + 升级三选一
技能表。每个 apply() 内部必须 clamp(第 3 号硬约束),文案不得出现
px / ×0.82 / 上限 这类代码术语:
const SKILLS = [
{ id:'dmg', name:'火力强化', desc:'伤害提升,敌机更容易被打爆', max:5, icon:'🔥', ac:'#ff7a4a', glow:'rgba(255,122,74,.45)',
apply:s=>{ s.damage = Math.min(1 + 5, s.damage + 1); } },
{ id:'rate', name:'急速射击', desc:'射速更快,弹幕变得更密集', max:Math.Infinity, icon:'⚡', ac:'#ffd94a', glow:'rgba(255,217,74,.45)',
apply:s=>{ s.fireInterval = Math.max(55, s.fireInterval * 0.82); } },
{ id:'speed', name:'引擎加速', desc:'战机移动速度提升', max:3, icon:'🚀', ac:'#7ee787', glow:'rgba(126,231,135,.45)',
apply:s=>{ s.moveSpeed = Math.min(220 + 3 * 40, s.moveSpeed + 40); } },
{ id:'gold', name:'财运亨通', desc:'同样的金币能换更多分数', max:3, icon:'💰', ac:'#ffc94a', glow:'rgba(255,201,74,.45)',
apply:s=>{ s.goldMul = Math.min(1 + 3 * 0.5, s.goldMul + 0.5); } },
{ id:'bullet',name:'重型弹头', desc:'子弹变大,命中范围更广', max:4, icon:'🔵', ac:'#6fb8ff', glow:'rgba(111,184,255,.45)',
apply:s=>{ s.bulletRadius = Math.min(4 + 4 * 3, s.bulletRadius + 3); } },
{ id:'multi', name:'双管齐射', desc:'并行多发射击,覆盖面更大', max:2, icon:'⛓️', ac:'#b98cff', glow:'rgba(185,140,255,.45)',
apply:s=>{ s.bullets = Math.min(3, s.bullets + 1); } },
{ id:'shield',name:'金钟罩', desc:'护盾环绕机身,替您挡住一次撞击', max:3, icon:'🛡️', ac:'#ffe07a', glow:'rgba(255,224,122,.5)',
apply:s=>{ s.shield = Math.min(3, s.shield + 1); } },
{ id:'magnet',name:'磁力回收', desc:'掉落物从更远处被吸过来', max:3, icon:'🧲', ac:'#4ad8d8', glow:'rgba(74,216,216,.45)',
apply:s=>{ s.magnetRadius = Math.min(70 + 3 * 40, s.magnetRadius + 40); } },
];
升级弹窗。卡片清空必须显式摘除节点(不要依赖 innerHTML='' 的副作用,
否则第二次弹窗会变 6 张、第三次 9 张):
function keyOf(skill) { return skill.name; }
function levelUp() {
level++;
exp -= expNeed;
expNeed = Math.round(expNeed * 1.45); // 逐级变难:8→12→17→25→36→52
paused = true;
el('lvTitle').textContent = '升级 Lv ' + level;
el('lvSub').textContent = '选择一项强化';
// 从可用技能里随机抽 3 个不重复
const pool = SKILLS.filter(s => (skillTaken[keyOf(s)] || 0) < s.max);
const picks = [];
while (picks.length < 3 && pool.length) picks.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
const box = el('cards');
for (const old of cardEls) if (old.parentNode) old.parentNode.removeChild(old);
cardEls.length = 0;
if (!picks.length) { paused = false; el('mLevel').classList.remove('show'); return; }
picks.forEach(sk => {
const lv = skillTaken[keyOf(sk)] || 0;
const b = document.createElement('button');
b.className = 'card';
b.style.setProperty('--ac', sk.ac);
b.style.setProperty('--glow', sk.glow);
b.innerHTML =
(lv ? `<i class="lvbadge">Lv ${lv} → ${lv + 1}</i>` : `<i class="lvbadge">NEW</i>`) +
`<span class="ico">${sk.icon}</span>` +
`<span class="txt"><b>${sk.name}</b><em>${sk.desc}</em></span>`;
b.onclick = () => chooseSkill(sk);
box.appendChild(b);
cardEls.push(b);
});
el('mLevel').classList.add('show');
syncHUD();
}
function chooseSkill(sk) {
sk.apply(player);
const lv = (skillTaken[keyOf(sk)] || 0) + 1;
skillTaken[keyOf(sk)] = lv;
el('mLevel').classList.remove('show');
paused = false;
pickFanfare(sk, lv); // 见步骤 9
syncHUD();
}
弹窗按钮用 DOM <button>,不要在 canvas 里自己算点击命中——那是纯粹的踩坑源。
步骤 8:主循环 + 流程控制
function frame(now) {
if (!running) return;
// dt 必须钳制:不钳的话切标签页回来会有一个巨大的 dt,物体瞬移穿墙
const dt = Math.min(0.05, (now - prevTime) / 1000) || 0;
prevTime = now;
// hitStop 是命中顿帧(见步骤 9.5):有顿帧时把 dt 缩到 25%,画面明显迟一下再继续。
// 如果你跳过了步骤 9 的打击感部分,这里可以直接写 update(dt),不影响玩法。
if (!paused) update(hitStop > 0 ? dt * 0.25 : dt);
draw(); // 暂停时照样要 draw,否则会黑屏
rafId = requestAnimationFrame(frame);
}
function startGame() {
initAudio(); // 必须在用户手势里调用(见步骤 10)
if (actx && actx.state === 'suspended') actx.resume();
resetState();
over = false; paused = false; running = true;
el('mStart').classList.remove('show');
el('mOver').classList.remove('show');
el('mLevel').classList.remove('show');
startBGM();
prevTime = performance.now();
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(frame);
}
function gameOver() {
running = false; over = true; paused = false;
stopBGM();
boom(0.9, 0.5);
el('ovStats').innerHTML =
`最终分数 <b style="color:#ffd76a">${Math.floor(score)}</b> 等级 Lv${level}<br>` +
`存活 ${Math.floor(elapsed)} 秒`;
el('mLevel').classList.remove('show');
el('mOver').classList.add('show');
}
el('btnStart').onclick = startGame;
el('btnRetry').onclick = startGame;
resetState(); // 首帧静态背景,避免打开时全黑
draw();
update() 的整体顺序(照这个次序,别乱排):
elapsed += dt; score += dt * 2;- 玩家移动 → 转向 → 无敌倒计时
- 自动开火
- 子弹移动与碰撞(含击毁)
- 敌机生成
- 敌机移动、朝向、受击衰减、离屏、撞玩家
- 掉落物吸附与拾取
- 粒子 / 尾迹 / 冲击波 / 飘字 / 枪口闪光 的生命周期推进
- 星星滚动、引擎尾烟粒子
- 衰减:
shake、hitStop、recoil、flash syncHUD()
第 8–10 项的具体实现见步骤 9。
步骤 9:视觉层(像素精灵 + 特效 + 性能缓存)
前面 8 步做完游戏已经能玩了。这一步让它好看,全部零外部依赖。
9.1 像素风飞船
用字符串网格定义造型(1 格 = 2px),按字符映射颜色:
const PX = 2; // 每逻辑格占几像素
// 玩家战机:14×16 网格,朝上飞行
// . 透明 1=浅蓝高光 2=主色青 3=深蓝阴影 4=驾驶舱白 5=引擎橙 6=引擎黄
const PLAYER_SPRITE = [
'......44......', '.....4114.....', '.....4114.....', '....122221....',
'...12222221...', '..1223222321..', '.122322223221.', '12232222223221',
'12222222222221', '13222111122231', '.132211112231.', '..1321111231..',
'...11111111...', '...56....65...', '..56......65..', '.56........65.',
];
const PLAYER_COLORS = { '1':'#a0f0ff','2':'#40c8e8','3':'#1888b0','4':'#ffffff','5':'#ff8030','6':'#ffe040' };
// 敌机:12×12 网格,头朝下俯冲
// a=暗红轮廓 b=主体红 c=亮红高光 d=驾驶舱绿 e=炮口灰 f=火焰橘
const ENEMY_SPRITE = [
'.aeaaaaaaea.', '.afeeeeeefa.', '..ffffffff..', '.bcccccccb..',
'bccddddccb..', 'bcddbbddcb..', 'bcddddddcb..', '.bccccccb...',
'..bbbbbb....', '...ffff.....', '....ff......', '.....&......',
];
const ENEMY_COLORS_BASE = { a:'#401010', b:'#a02020', c:'#d04040', d:'#30ff60', e:'#706060', f:'#ff6020' };
function drawSprite(ctx, grid, colors, cx, cy, scale) {
const rows = grid.length, cols = grid[0].length;
const ox = cx - (cols * PX * scale) / 2, oy = cy - (rows * PX * scale) / 2;
for (let r = 0; r < rows; r++) {
const row = grid[r];
for (let c = 0; c < cols; c++) {
const ch = row[c];
if (ch === '.' || ch === '&') continue;
const col = colors[ch];
if (!col) continue;
ctx.fillStyle = col;
ctx.fillRect(ox + c * PX * scale, oy + r * PX * scale, PX * scale, PX * scale);
}
}
}
9.2 性能缓存(这个坑必须处理)
初版特效每帧新建 6.8 万个径向渐变(每颗子弹每帧一个),在学生那些破笔记本上会卡成
幻灯片。解法:启动时预渲染到离屏 canvas,运行时只 drawImage。实测每帧渐变从
67966 降到 0。四个缓存函数:
// 星云层:整张预渲染,每帧只贴一张图
let _nebula = null;
function nebulaLayer() {
if (_nebula) return _nebula;
const oc = document.createElement('canvas');
oc.width = W; oc.height = H + 32;
const o = oc.getContext('2d');
const neb = [[W*0.3, H*0.25, '#1a0a3a'], [W*0.72, H*0.68, '#0a2a3a'], [W*0.45, H*0.88, '#2a0a2a']];
for (const [nx, ny, nc] of neb) {
const g = o.createRadialGradient(nx, ny, 0, nx, ny, 260);
g.addColorStop(0, nc); g.addColorStop(1, 'rgba(0,0,0,0)');
o.fillStyle = g; o.fillRect(0, 0, oc.width, oc.height);
}
_nebula = oc;
return oc;
}
// 引擎尾焰辉光:固定尺寸离屏图
let _engGlow = null;
function engineGlow() {
if (_engGlow) return _engGlow;
const size = 48;
const oc = document.createElement('canvas'); oc.width = size; oc.height = size;
const o = oc.getContext('2d');
const g = o.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2);
g.addColorStop(0, 'rgba(255,180,60,.75)'); g.addColorStop(1, 'rgba(255,80,0,0)');
o.fillStyle = g; o.fillRect(0, 0, size, size);
_engGlow = oc;
return oc;
}
// 金币晕:只在拿到财运亨通后才会被调用
let _coinGlow = null;
function coinGlow() {
if (_coinGlow) return _coinGlow;
const size = 22;
const oc = document.createElement('canvas'); oc.width = size; oc.height = size;
const o = oc.getContext('2d');
const g = o.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2);
g.addColorStop(0, 'rgba(255,230,120,.85)'); g.addColorStop(1, 'rgba(255,170,0,0)');
o.fillStyle = g; o.fillRect(0, 0, size, size);
_coinGlow = oc;
return oc;
}
// 子弹辉光:按半径缓存成离屏图
const _glowCache = new Map();
function bulletGlow(r) {
if (_glowCache.has(r)) return _glowCache.get(r);
const size = Math.ceil(r * 6), half = size / 2;
const oc = document.createElement('canvas');
oc.width = size; oc.height = size;
const o = oc.getContext('2d');
const g = o.createRadialGradient(half, half, 0, half, half, half);
g.addColorStop(0, 'rgba(160,255,255,.9)'); g.addColorStop(1, 'rgba(0,180,255,0)');
o.fillStyle = g; o.fillRect(0, 0, size, size);
_glowCache.set(r, oc);
return oc;
}
9.3 draw() 的绘制次序
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H);
ctx.save();
if (shake > 0) ctx.translate(rand(-shake, shake), rand(-shake, shake)); // 屏幕震动
// 星云背景(预渲染层,缓慢浮动)
ctx.globalAlpha = 0.9;
ctx.drawImage(nebulaLayer(), 0, Math.sin(elapsed * 0.3) * 14);
ctx.globalAlpha = 1;
// 三层视差星空
for (const s of stars) { ctx.globalAlpha = s.b; ctx.fillStyle = '#8af'; ctx.fillRect(s.x, s.y, 2, 2); }
ctx.globalAlpha = 1;
// 磁力回收:吸附范围虚线圈(呼吸 + 流动)
if (player.magnetRadius > 70) { /* stroke dashed circle at player.magnetRadius */ }
// 掉落物:金币(rich 时光晕+自转)、经验球(靠近吸附圈时变亮)
// 冲击波环(多重环,easeOut 扩散)
// 粒子(big 标记的用 lighter 混合放大)
// 枪口闪光 → 子弹尾迹 → 子弹本体(三者都用 globalCompositeOperation='lighter')
// 敌机(rotate(e.rot),受击时叠一层全白同形状;血量条画三格定宽,掉血时暗两格)
// 玩家(rotate(player.angle) + 引擎辉光 + 引擎加速时的绿色喷射流)
// 金钟罩(相邻层反向旋转;≥2 层再套一个缓转六边形能量壁)
ctx.restore(); // 结束震动坐标系
// 全屏闪光(选技瞬间)—— 不参与震动偏移,盖在最上层
if (flash) { /* lighter 混合,alpha = k*k*0.34 */ }
// 飘字(技能名 Lv N,上升淡出)
}
各层的要点(完整可抄的实现见 references/effects.md):
- 子弹:先
drawImage(bulletGlow(b.r))贴辉光,再按atan2(-vx, -vy)旋转画
白色核心 + 主题色。hot值决定颜色(青 → 橙 → 金红)和长度,顶档核心还会闪烁。
spin(多发)时加一个小十字尾翼。 - 敌机血量条:固定画三格,
i < e.hp用.85不透明白,其余用.18暗色。
这样掉血一眼看出来,比「只画剩余小点」更好读。 - 敌机受击白闪:
globalCompositeOperation='lighter'+globalAlpha=e.flash*0.9
再画一遍精灵但颜色全换成白的。 - 震动衰减:
shake = Math.max(0, shake - dt * 28)。
9.4 选技仪式感(pickFanfare)
function pickFanfare(sk, lv) {
flash = { t: 0, life: 0.45, color: sk.glow };
shockwaves.push({ x: player.x, y: player.y, r: 10, maxR: 210, life: 0.62, t: 0, color: sk.ac, ring: 3 });
shockwaves.push({ x: player.x, y: player.y, r: 4, maxR: 130, life: 0.42, t: 0, color: '#ffffff', ring: 1 });
for (let i = 0; i < 26; i++) { // 沿四周喷一圈火花
const a = i / 26 * Math.PI * 2, sp = rand(190, 330);
particles.push({ x: player.x, y: player.y, vx: Math.cos(a)*sp, vy: Math.sin(a)*sp,
life: rand(.4,.75), t: 0, color: sk.ac, nodrift: true, big: true });
}
floaters.push({ x: player.x, y: player.y - 46, t: 0, life: 1.25, text: sk.name + ' Lv ' + lv, color: sk.ac });
shake = 10;
[0, 90, 180].forEach((d, i) => setTimeout(() => blip(900 + i * 260), d)); // 三连上行音
}
这些标记字段(hot / spin / big / nodrift / flash / kx / ky)只负责画法,
完全不参与碰撞与数值计算。 已验证子弹的 r 仍是唯一的碰撞半径来源。
9.5 特效数组的生命周期(update 内)
五个特效数组各自倒序遍历、t >= life 就 splice 掉,节奏各不相同:
- 粒子
particles:普通粒子受 120 的重力加速度;带nodrift标记的(选技火花)
反过来走 0.94 摩擦悬浮,所以看着是定住慢慢熄灭而不是下坠。 - 尾迹
trails:纯计时销毁,寿命 0.14 秒。 - 冲击波
shockwaves:半径用 easeOutmaxR * (1 - (1-k)^2),前快后慢——
线性扩散看起来像雷达扫描,这个曲线才像爆炸。 - 飘字
floaters/ 枪口闪光muzzle:纯计时销毁。 - 全局衰减:
shake以 dt28、hitStop以 dt、recoil以 dt9 归零;
flash超时后置 null。 - 星空越界后回到顶部并随机重取 x(保持三层视差密度恒定)。
- 引擎尾烟每帧 50% 概率喷一颗,橙/黄交替。
完整实现见本文末尾的 update() 后半段,这里不重复贴代码。
顿帧怎么生效:hitStop 不冻结时钟,而是缩减传给 update 的 dt(步骤 8 的 frame()
里已经写成 update(hitStop > 0 ? dt * 0.25 : dt),无需再改)。这样命中瞬间整个
世界慢下来、而动画时钟照走,手感就是「打实了」。注意不要把 hitStop 实现成
直接 return 跳过 update——那会让敌机子弹全部悬停在半空,看起来像卡死而不是顿帧。
步骤 10:Web Audio 合成音效
只用代码发声,绝对不要 mp3/wav 文件或 base64 音频串(那不是「生成旋律」,
是往文件里塞二进制,又长又容易坏)。
let actx = null, masterGain = null, bgTimer = 0;
// 必须在用户点「开始」之后才初始化 —— 浏览器不允许一打开就自动播放
function initAudio() {
if (actx) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return; // 没声音不影响「能玩」
actx = new AC();
masterGain = actx.createGain();
masterGain.gain.value = 0.5;
masterGain.connect(actx.destination);
}
// 击杀/受击爆音:一段噪声 + 音调下滑
function boom(vol = 0.5, dur = 0.18) {
if (!actx) return;
const t = actx.currentTime;
const len = Math.floor(actx.sampleRate * dur);
const buf = actx.createBuffer(1, len, actx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = actx.createBufferSource(); src.buffer = buf;
const ng = actx.createGain();
ng.gain.setValueAtTime(vol * 0.6, t);
ng.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(ng).connect(masterGain); src.start(t);
const o = actx.createOscillator(), g = actx.createGain();
o.type = 'square';
o.frequency.setValueAtTime(420, t);
o.frequency.exponentialRampToValueAtTime(70, t + dur); // 420Hz → 70Hz
g.gain.setValueAtTime(vol * 0.5, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g).connect(masterGain); o.start(t); o.stop(t + dur);
}
// 拾取清脆高音
function blip(freq = 880) {
if (!actx) return;
const t = actx.currentTime, o = actx.createOscillator(), g = actx.createGain();
o.type = 'triangle'; o.frequency.value = freq;
g.gain.setValueAtTime(0.12, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.07);
o.connect(g).connect(masterGain); o.start(t); o.stop(t + 0.08);
}
// 背景音乐:两三个振荡器循环几个音符
const MELODY = [262, 330, 392, 330, 294, 370, 440, 370]; // C E G E / D F# A F#
const BASS = [131, 131, 147, 147];
let step = 0;
function bgTick() {
if (!actx || !running || paused) return;
const t = actx.currentTime, n = 0.22;
const play = (freq, type, vol, detune) => {
if (!freq) return;
const o = actx.createOscillator(), g = actx.createGain();
o.type = type; o.frequency.value = freq; if (detune) o.detune.value = detune;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(vol, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, t + n);
o.connect(g).connect(masterGain); o.start(t); o.stop(t + n + 0.02);
};
play(MELODY[step % MELODY.length], 'square', 0.05, 6);
play(MELODY[step % MELODY.length] / 2, 'triangle', 0.06, 0);
if (step % 2 === 0) play(BASS[(step >> 1) % BASS.length], 'sine', 0.10, 0);
step++;
bgTimer = setTimeout(bgTick, n * 1000);
}
function startBGM() { clearTimeout(bgTimer); step = 0; bgTick(); }
function stopBGM() { clearTimeout(bgTimer); }
音量约定:击杀 boom(0.42, 0.16)、护盾挡下 boom(0.5, 0.2)、扣命 boom(0.8, 0.3)、
死亡 boom(0.9, 0.5);金币 blip(1050)、经验 blip(760)。
步骤 11:自检(很重要,别跳过)
静态检查完不等于能玩。把游戏脚本抽出来在无头环境里跑几局:DOM 用桩件替代、
canvas 用 node-canvas 或空 Proxy、AudioContext 用空实现,然后喂键盘输入推进主循环。
必须断言的六条:
- 静止挂机 30 秒内一定弹出升级窗(最关键的一条。做不到就说明前面的
出生分布或掉落追踪有问题)。 - 8 个技能各叠 12–14 次,数值都不越过上限。
- 子弹
r始终等于玩家的bulletRadius(特效字段不得污染它)。 - 跑 50 局无异常抛出、无 NaN、无数值越界、无卡死。
- 每次弹窗恒为 3 张不重复的卡,点选后
paused变回 false。 - 静止时
player.angle === 0(守住「自动向上开火」红线)。
桩件的三个易错点(我自己踩过,测试脚本骗自己四次):
- stub 要忠实反映真实语义。我的
innerHTML=''桩件顺手清了 children 数组,
于是「卡片累积」这个真 bug 一直显示 PASS。桩件改成不清之后问题才暴露。 - 多个 run 之间别共享随机序列。开关特效对比得到 10/14 vs 12/14 的差异,
以为是特效拖累玩法;用相同种子各跑 40 局后发现完全一致(都是 30/40),
差异纯属 RNG 耦合造成的假阳性。 - 别用「每秒杀怪数」评判机器人。横扫机器人看着杀得少,实际是它不会躲、
几秒就死,人均时长把速率压垮了。静止挂机才是最可靠的基线。 - 别忘了给 stub 暴露 getter(
get level(){return level}),否则读到 undefined,
平均值恒为 1.00,会误判成平衡问题。
具体桩件模板见 references/selftest.md。
实测基线(拿来对照,当前版本 100 种子):静止玩家首次弹窗中位数 19.4 秒、
p95 23.3 秒、100/100 在 30 秒内达标;平均存活 59 秒(中位 55 秒)、结束等级中位 Lv4、
同屏峰值钉在 34 架、每帧径向渐变分配数为 0(全命中缓存)。
> 这个数字的历史:早期版本是 27 秒。靠「敌机出生横向系数永久取小值」把它压下来是不对的
> ——那会让屏幕两侧永远空白。当前做法是开局 25 秒用 0.75、之后回 1.0,既守住红线又保住
> 满屏观感。细节见 references/pitfalls.md 第 1 号坑。
简化红线(加了就是错的)
- ❌ 自瞄(自动开火固定朝上即可;机身倾斜转向不算自瞄,因为它不看敌人)
- ❌ 滚屏大地图
- ❌ 技能树、进化合成、前置解锁
- ❌ 追踪弹、散射扇面、激光、环绕体、穿透、身周持续伤害圈
(双管齐射只是朝上的平行多发,不要给子弹加散角撒成扇面) - ❌ 外部图片、外部音频文件、base64 音频串
- ❌ 技能文案里的代码术语(px / ×0.82 / 上限 N / 间隔 12px)
已知取舍(照做就行,不是 bug)
- 一直按住 W 往上撞敌机会很快死(约 11 秒)。设计如此:W/S 用来调整站位,不是往前冲。
- 开局 25 秒刻意放宽生成节奏,保证学生第一次玩就能看到升级弹窗。
参考文件
references/pitfalls.md— 14 个踩坑的完整版,含每条的诊断数据和错误做法references/params.md— 所有可调数值的汇总表(含成长曲线 DPS 表)references/effects.md— 视觉层的完整可抄实现(draw 各层、精灵、缓存)references/selftest.md— 无头自检的桩件模板与断言脚本
拿到 skill 的人如果只有 5 分钟,至少读完本文的「开始前必读」四条 + 步骤 5 的出生分布
+ 步骤 7 的 clamp + 步骤 9.2 的性能缓存。那几处是实测会让游戏「不能玩」或「卡成幻灯片」
的点,其余都只是不好看而已。