生成单文件战机生存小游戏

SKILL开发

原名 survivor · 标识 orb-survivor-game

开发 未标注权限 许可证不明确
安装这个技能

从零生成可直接双击运行的单文件网页小游戏,含移动、自动开火、敌机波次、掉落成长、升级三选一与像素画风特效,附完整参数与实现细节。

一句话选型结论

按资料字段整理

生成单文件战机生存小游戏是开发类的 Agent Skill,安装方式未标注,权限级别未标注,许可证不明确,有可直接复制的安装命令。技能本质是给模型读的说明书,效果取决于模型与客户端;装完先用一个小任务验证,再放进正式流程。

分类开发 安装方式未标注 权限级别未标注权限 许可证许可证不明确
累计安装 未标注 本周 未标注
GitHub 星标 未标注 仓库 未标注
下载量 未标注 技能市场下载数
权限级别 未标注权限 安装前请确认风险范围
许可证 许可证不明确 未说明

安装

未标注
在支持 Skills 的客户端里执行,装完问一句「你有哪些技能」验证
skillhub install user_4db551b0/survivor
  1. 1
    确认客户端支持

    确认你的客户端支持 Skills(Claude、Codex、Cursor 等),并升级到较新的版本。

  2. 2
    拿到技能文件

    执行上面的命令安装;也可以把仓库里的 SKILL.md 手动放进客户端的 skills 目录。

  3. 3
    刷新技能列表

    重新打开客户端,或刷新技能列表,让新放进来的技能被加载。

  4. 4
    验证是否生效

    问一句「你有哪些技能」,确认列表里出现它,再拿一个小任务试跑。

技能档案

开发
字段来自技能仓库与公开索引,中文说明由本站标注
作者 / 维护方user_4db551b0仓库 未标注 · 版本 未标注
宿主客户端未标注技能可被这些客户端加载
安装方式未标注安装包 community
权限级别未标注权限安装前请确认风险范围
许可证未说明许可证不明确,商用前请自行确认条款
仓库可用性available技能仓库当前是否可访问
最近核验2026-11-06上游最后核验时间 · 数据更新 2026-11-06
正文体量54.2 KBSKILL.md 原文大小,越大说明说明书写得越细

能做什么

0 项能力
上游给出的能力概览与适用场景
做浏览器射击小游戏教学实操项目演示极简生存游戏原型

典型使用场景

3 类
按分类整理的常见用法,具体以 SKILL.md 为准
写代码与改 bug

按需求补代码、定位并修掉报错。

读仓库与生成改动

让模型读懂项目结构,再给出可落地的改动。

跑测试与排查故障

按现象排查问题,补测试用例并复跑。

选择建议

装之前先看这几条
按条目字段给出的判断,不替你决定用不用

值得选的地方

  • 有可直接复制的安装命令,不用自己翻文档找入口。

先想清楚的地方

  • 没有标注权限级别,实际能做什么要装好后确认。
  • 许可证不明确,商用或二次分发前先到仓库确认条款。
  • 没有做站内核验,内容以作者仓库为准。
  • 技能只是说明书,实际效果取决于模型与客户端,装完先用小任务试一遍。

更适合

  • 需要在开发场景里给模型补一套做法的人。
  • 愿意先读一遍 SKILL.md、确认它会让模型做什么的人。

不太适合

  • 对许可证要求很严、必须明确可商用的人。
  • 只想装完就自动变强、不打算看说明书的人。

同类对比

6 个同类
同分类里挑最接近的几条,字段以各自详情页为准
技能权限级别安装方式累计安装宿主客户端安装命令
生成单文件战机生存小游戏当前 未标注权限 未标注 未标注 未标注 有
技能审查员 浏览器控制 ClawHub 市场 877.9 万 OpenClaw 有
Vercel React 最佳实践 浏览器控制 GitHub 仓库 849.1 万 Codex、Claude、Cursor 有
GitHub 代码协作 联网访问 ClawHub 市场 763.3 万 OpenClaw 有
网页设计指南 联网访问 GitHub 仓库 668.6 万 Codex、Claude、Cursor 有
本体论 只读 ClawHub 市场 663.7 万 OpenClaw 有

SKILL.md 全文

54.2 KB
模型实际读到的说明书原文,来自技能仓库

Orb Survivor · 从零生成指南

一句话玩法:WASD 控制战机上下左右移动,自动朝机身指向开火,打爆敌机掉金币和经验,
升级时三选一强化技能,坚持越久分越高。

产物是一个 .html 文件,双击即玩,无安装、无外部依赖、无需联网。
目标用时 5–6 分钟。验收标准只有一条:能不能玩(WASD 能动、会自动开火、
能打爆敌机捡到球、升级能弹窗选技能)。

本文按「必须先做对的硬约束 → 九个步骤 → 每步的完整实现细节」组织。
所有代码片段都可以直接抄,不需要自己发明数值。


开始前必读:四条会让游戏「不能玩」的硬约束

这四条不是风格建议,是实测踩过、会导致游戏看起来做完了但实际不能玩的硬约束。

  1. 敌机出生必须偏中心。子弹是固定朝上的单列枪线(方案禁止自瞄),如果敌机在
    整个画布宽度上均匀撒开,绝大多数怪永远进不了枪线 → 击杀率个位数 → 经验攒不够 →
    升级弹窗整局不出现。实测均匀分布时静止命中率只有 17%,30 秒内升不了级。

  2. 掉落物要能追到人。敌机在屏幕高处被打爆、玩家在底部,如果吸附半径外的掉落物
    只是直线下落,经验球漂不到玩家就过期了,exp 会长期为 0。必须让半径外的球也
    以 260px/s 追踪玩家。

  3. 技能上限要在 apply() 里 clamp。只在筛选池子时用 max 过滤是不够的——
    同一个技能被反复点选,数值会无限叠上去(伤害叠到 10、子弹叠到 7 发)。

  4. 横飞的敌机必须慢、且入口偏下。第一版让它在屏幕中部快速飘过,实测击杀率 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. 复制步骤 1 的 HTML 骨架存成 .html,<script> 里只留 "use strict";。
  2. 按出现顺序把 18 个「语言标为 js 的围栏」追加到 <script> 尾部。追加,不替换,不改顺序。
  3. 验语法(模板见 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() 的整体顺序(照这个次序,别乱排):

  1. elapsed += dt; score += dt * 2;
  2. 玩家移动 → 转向 → 无敌倒计时
  3. 自动开火
  4. 子弹移动与碰撞(含击毁)
  5. 敌机生成
  6. 敌机移动、朝向、受击衰减、离屏、撞玩家
  7. 掉落物吸附与拾取
  8. 粒子 / 尾迹 / 冲击波 / 飘字 / 枪口闪光 的生命周期推进
  9. 星星滚动、引擎尾烟粒子
  10. 衰减:shake、hitStop、recoil、flash
  11. 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:半径用 easeOut maxR * (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 用空实现,然后喂键盘输入推进主循环。

必须断言的六条:

  1. 静止挂机 30 秒内一定弹出升级窗(最关键的一条。做不到就说明前面的
    出生分布或掉落追踪有问题)。
  2. 8 个技能各叠 12–14 次,数值都不越过上限。
  3. 子弹 r 始终等于玩家的 bulletRadius(特效字段不得污染它)。
  4. 跑 50 局无异常抛出、无 NaN、无数值越界、无卡死。
  5. 每次弹窗恒为 3 张不重复的卡,点选后 paused 变回 false。
  6. 静止时 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 的性能缓存。那几处是实测会让游戏「不能玩」或「卡成幻灯片」
的点,其余都只是不好看而已。

常见问题

5 问
答案由本站依据条目字段整理
生成单文件战机生存小游戏怎么安装?

在支持 Skills 的客户端里执行:skillhub install user_4db551b0/survivor。安装方式为未标注。装完刷新技能列表或重开客户端。

生成单文件战机生存小游戏支持哪些客户端?

资料没有标注宿主客户端,但只要客户端支持 Skills(Claude、Codex、Cursor 等),一般都能加载。

生成单文件战机生存小游戏权限高吗,安全吗?

权限级别为未标注权限。安装前先看它会读取或写入哪些内容。

生成单文件战机生存小游戏收费吗?

本站不代收费用,也没有标注具体价格。是否收费取决于技能本身和所用的模型或客户端,正式使用前按作者说明确认。

生成单文件战机生存小游戏用什么许可证?

许可证标注为 未说明(许可证不明确)。商用或二次分发前请到仓库确认条款原文。

热门专题

按场景挑技能
全部专题 →

技能内容以作者仓库为准;分类、权限级别与许可证状态的中文说明为中文整理,最近核验 2026-11-06。