后端化:账号系统+排行榜+图鉴收集同步+飞行员大厅(零依赖Node+SQLite)

- server/:node:http+node:sqlite 零依赖后端(users/sessions/user_stats/codex_unlocks/runs 五表)
- 安全:scrypt密码哈希/服务端session/全prepared statement/输入白名单/限流/CSP等安全头/静态白名单防穿越
- 前端:o-auth登陆注册 + o-home飞行员大厅(排行榜Top100三排序+图鉴展柜背景:随机3主3副2僚机+刷怪巡游)
- 图鉴改为收集点亮制(未收集=剪影+???),服务端codex_unlocks与本地并集同步
- 修复:回标题/大厅后背景卡在上场残影——render按battleUI门控+离场G=null;空闲=星空
- 结算上报:endRun/rush胜利两处submitRun,Boss击杀计数入排行榜
- playwright全流程验证通过(注册/登陆/游戏/结算/图鉴/安全注入/限流);游戏视觉未验收(用户亲自)
This commit is contained in:
JOJO 2026-08-06 04:20:25 +08:00
parent 93e1f4699d
commit ab403f4cc7
18 changed files with 938 additions and 26 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@
research/ research/
.playwright-mcp/ .playwright-mcp/
cache/ cache/
star-raid/data/

View File

@ -4,21 +4,24 @@
## 项目概述 ## 项目概述
- 纯 HTML5 Canvas + 原生 JavaScript**无构建、无依赖、无框架** - 客户端:纯 HTML5 Canvas + 原生 JavaScript**无构建、无依赖、无框架**
- 服务端:**零 npm 依赖** Node 原生(`node:http` + `node:sqlite`,要求 Node≥22.5SQLite 存储
- 240×320 像素画布,竖版弹幕射击(主武器/副武器/僚机/Boss 战/Boss Rush 模式) - 240×320 像素画布,竖版弹幕射击(主武器/副武器/僚机/Boss 战/Boss Rush 模式)
- 游戏本体在 `star-raid/` 子目录 - 账号系统:注册/登陆(用户名+邮箱+密码)、大厅排行榜 Top100、图鉴收集点亮同步
- 游戏本体在 `star-raid/` 子目录,后端在 `star-raid/server/`,部署见 `star-raid/DEPLOY.md`
## 常用命令 ## 常用命令
```bash ```bash
cd star-raid cd star-raid
# 语法检查(每次改完 JS 必须跑) # 语法检查(每次改完 JS 必须跑server/ 也要查
for f in js/*.js; do node --check "$f" || echo "FAIL $f"; done for f in js/*.js server/*.js; do node --check "$f" || echo "FAIL $f"; done
# 本地运行(用户试玩用) # 启动完整版(游戏+后端Node≥22.5
python3 -m http.server 8643 # 然后访问 http://localhost:8643 node server/server.js # 或 npm start访问 http://localhost:8643
# 注意8642 端口有浏览器缓存污染,固定用 8643 # 注意8643 现由 node server 占用;旧 python http.server 试玩方式已废弃
# 数据库star-raid/data/starraid.dbgit 忽略,删除重启自动重建空库)
# Git仓库根 = 工作区根目录,游戏在子目录) # Git仓库根 = 工作区根目录,游戏在子目录)
git add -A && git commit -m "提交信息" git add -A && git commit -m "提交信息"
@ -26,9 +29,9 @@ git add -A && git commit -m "提交信息"
## 验证规则(最高优先级,违反会被用户骂) ## 验证规则(最高优先级,违反会被用户骂)
1. **禁止运行游戏测试视觉效果**——包括 playwright 截图、浏览器数据断言。视觉和手感全部由用户亲自验收。Agent 只做:`node --check` 语法检查 + git 提交 1. **游戏视觉/手感禁止 Agent 验收**——用户亲自验收。但**账号/大厅/排行榜/结算上报等功能流允许 playwright 测试**(用户 2026-08-06 明确授权):注册/登陆/数据断言可跑,只是不许用截图评判游戏画面效果
2. 交付时必须诚实标注验证状态"仅语法检查通过,未运行验证" 2. 交付时必须诚实标注验证状态。
3. 完成了操作 ≠ 任务完成,未经验证不得说"修复了/完成了" 3. 完成了操作 ≠ 任务完成,未经验证不得说“修复了/完成了”
## 项目结构 ## 项目结构
@ -36,18 +39,29 @@ git add -A && git commit -m "提交信息"
star-raid/ star-raid/
├── index.html # 入口 + 全部覆盖层 UI 结构 ├── index.html # 入口 + 全部覆盖层 UI 结构
├── css/style.css # 全部样式(像素风 UI 变量配色) ├── css/style.css # 全部样式(像素风 UI 变量配色)
├── server/ # 后端(零依赖 node:http+node:sqlite
│ ├── server.js # 入口:静态白名单+/api路由+安全头
│ ├── db.js # SQLite schemausers/sessions/user_stats/codex_unlocks/runs
│ ├── auth.js # scrypt密码哈希+服务端session
│ ├── api.js # API处理器CODEX_KEYS 白名单(新增装备必须同步!)
│ └── util.js # body解析/限流/输入校验
├── data/ # SQLite 数据库git 忽略)
├── js/ ├── js/
│ ├── config.js # BAL 平衡参数、常量 │ ├── config.js # BAL 平衡参数、常量
│ ├── data.js # WEAPONS/SUBS/DRONES 装备数据表 │ ├── data.js # WEAPONS/SUBS/DRONES 装备数据表
│ ├── core.js # 游戏状态 G、发射逻辑、explodeFx 爆炸特效库 │ ├── core.js # 游戏状态 G、发射逻辑、explodeFx 爆炸特效库
│ ├── combat.js # 子弹运动/碰撞/光束判定/zones 区域效果 │ ├── combat.js # 子弹运动/碰撞/光束判定/zones 区域效果
│ ├── enemies.js # ENEMIES 杂兵表、BOSSES 表、bossMain/bossMinor 攻击库 │ ├── enemies.js # ENEMIES 杂兵表、BOSSES 表、bossMain/bossMinor 攻击库
│ ├── render.js # 世界渲染drawBoss 分支调度、血条、子弹、zones │ ├── render.js # 世界渲染(battleUI 门控只在战斗界面绘制游戏对象
│ ├── boss_art.js # Boss 美术库drawProgenitor/drawAssault/drawPiano/drawUsurper/drawSaucer │ ├── boss_art.js # Boss 美术库drawProgenitor/drawAssault/drawPiano/drawUsurper/drawSaucer
│ ├── sprites.js # 像素字符画 bake 系统、SPR 精灵表、BHALO 弹光晕 │ ├── sprites.js # 像素字符画 bake 系统、SPR 精灵表、BHALO 弹光晕
│ ├── audio.js # SFX 音效WebAudio 合成) │ ├── audio.js # SFX 音效WebAudio 合成)
│ └── ui.js # 菜单/图鉴/rush 选择/老虎机/结算 │ ├── net.js # NETAPI 客户端fetch 封装/图鉴同步/submitRun
├── demo/ # 动作设计 demo如 tentacle-sweep.html 触手扫击) │ ├── auth.js # 认证流程(登陆/注册/登出 + boot 引导)
│ ├── home.js # 大厅:排行榜 + 图鉴展柜背景drawHomeShowcase
│ └── ui.js # 菜单/图鉴/rush 选择/老虎机/结算submitRun 上报)
├── demo/ # 动作设计 demo不在静态白名单公网不可见
├── DEPLOY.md # 部署指南(公网/HTTPS/安全边界)
└── research/ # Boss 参考图与调研(不进 git └── research/ # Boss 参考图与调研(不进 git
``` ```
@ -76,3 +90,12 @@ star-raid/
- 大改动先讨论方案再动手;视觉效果由用户验收后再迭代 - 大改动先讨论方案再动手;视觉效果由用户验收后再迭代
- 每完成一个逻辑单元立即 git 提交(提交信息写清改了什么+为什么) - 每完成一个逻辑单元立即 git 提交(提交信息写清改了什么+为什么)
- 项目记忆(`.astrion/memory/`)沉淀重要约定,发现新约定主动更新 - 项目记忆(`.astrion/memory/`)沉淀重要约定,发现新约定主动更新
## 后端约定2026-08-06 后端化)
- 客户端流程boot → `/api/me` → 未登录 o-auth登陆/注册)→ 已登录 o-home大厅用户面板+排行榜)→ o-title基地菜单
- 结算上报:`submitRun()`ui.js在 endRun 与 rush 胜利enemies.js killBoss两处调用`G.submitted` 防重
- 图鉴:服务端 `codex_unlocks` 为准,登陆时与本地 SAVE.codex 并集合并;未收集=剪影+???不可演示
- 背景规则render() 只在 battleUI()play/demoSim/pause/cards/over/slot绘制游戏对象大厅 UI="home" 时 drawHomeShowcase()30s 重抽);其余=星空
- 新增装备时必须同步 `server/api.js` 的 CODEX_KEYS 白名单(现 24+17+13+8=62
- 星币/机库/机体仍存 localStorage 不同步v1 边界,见 DEPLOY.md

61
star-raid/DEPLOY.md Normal file
View File

@ -0,0 +1,61 @@
# Star Raid 部署指南
## 运行要求
- **Node.js ≥ 22.5**(使用内置 `node:sqlite`,零 npm 依赖,无需 `npm install`
- SQLite 数据库文件自动创建于 `star-raid/data/starraid.db`(已在 .gitignore
## 启动
```bash
cd star-raid
npm start # = node server/server.js
# 或指定环境变量
PORT=8643 HOST=0.0.0.0 node server/server.js
```
| 环境变量 | 默认 | 说明 |
|---|---|---|
| `PORT` | `8643` | 监听端口 |
| `HOST` | `0.0.0.0` | 监听地址 |
| `COOKIE_SECURE` | 空 | **HTTPS 部署时必须设为 `1`**Cookie 加 Secure |
| `DATA_DIR` | `star-raid/data` | 数据库目录 |
## 公网部署nginx + HTTPS 推荐)
```nginx
server {
listen 443 ssl;
server_name your-domain.com;
# ssl_certificate / certbot 申请
location / {
proxy_pass http://127.0.0.1:8643;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr; # 限流按真实 IP
}
}
```
启动时 `COOKIE_SECURE=1 PORT=8643 node server/server.js`,用 systemd/supervisor 守护进程。
## 已实现的安全措施
- 密码scrypt + 每用户随机盐 + 时序安全比较;登陆失败统一报错(不泄露账号是否存在),不存在账号也跑一次哈希防时序探测
- 会话32 字节随机 token 存服务端 SQLiteHttpOnly + SameSite=Lax7 天滑动续期,可吊销
- SQL 注入:全部 prepared statement图鉴 key 服务端白名单(`server/api.js` 的 `CODEX_KEYS`**新增装备时必须同步**
- 输入:用户名/邮箱/密码白名单校验;请求体 ≤16KBPOST 强制 `application/json`(配合 SameSite 防 CSRF
- 限流:注册 10 次/5 分钟/IP登陆 20 次/5 分钟/IP成绩上报 20 次/5 分钟/账号
- 静态资源白名单:只放行 `/`、`/css/`、`/js/``server/`、`data/`、`demo/` 不可访问),防目录穿越
- 安全头CSP / X-Frame-Options / X-Content-Type-Options / Referrer-Policy
## v1 边界(明确不做)
- 邮箱仅作唯一标识,**无验证邮件、无找回密码**(需 SMTP 服务,留 v2
- **反作弊仅为基础校验**(数值范围/限流):纯客户端游戏无法杜绝作弊,有志者改内存照样能伪造高分。如需严肃竞技,要加服务端回放校验(大工程)
- 星币/机库强化/机体解锁仍存浏览器 localStorage**不随账号同步**(跨设备冲突解决复杂,留 v2
- 排行榜 = 全服单榜,无分页/赛季
## 数据维护
- 备份:拷贝 `data/starraid.db`WAL 模式,建议先 `PRAGMA wal_checkpoint` 或直接停服拷贝)
- 重置排行榜:停服后删除 `data/` 下文件,重启自动重建空库

View File

@ -116,3 +116,27 @@ h1.logo{font-size:clamp(20px,5.4vw,44px);color:#fff;letter-spacing:4px;line-heig
.cx-item.lock{opacity:.35;cursor:default} .cx-item.lock{opacity:.35;cursor:default}
.cx-item.lock:hover{transform:none;border-color:var(--line)} .cx-item.lock:hover{transform:none;border-color:var(--line)}
.cx-sec{font-size:clamp(8px,1.5vw,10px);color:var(--cyan);margin:10px 0 4px;letter-spacing:3px} .cx-sec{font-size:clamp(8px,1.5vw,10px);color:var(--cyan);margin:10px 0 4px;letter-spacing:3px}
/* ---------- 登陆/注册 ---------- */
.auth-f{display:flex;flex-direction:column;gap:8px;margin-top:12px}
.auth-f.hide{display:none}
.auth-f input{font:inherit;font-size:clamp(8px,1.6vw,11px);background:var(--panel2);border:3px solid var(--line);
color:var(--ink);padding:10px 12px;letter-spacing:1px;width:100%;outline:none}
.auth-f input:focus{border-color:var(--cyan)}
.auth-f input::placeholder{color:var(--dim)}
.auth-f .btn{width:100%;min-width:0;margin-top:2px}
/* ---------- 飞行员大厅 ---------- */
#o-home{background:rgba(4,7,16,.42);justify-content:flex-start;gap:8px;overflow-y:auto}
#o-home .lb{width:min(94%,430px);background:var(--panel);border:3px solid var(--line);padding:10px 10px 8px;margin-top:auto;margin-bottom:2%}
.lb-tabs{display:flex;gap:6px;justify-content:center;margin:6px 0 8px}
.lb-tabs button{font:inherit;font-size:clamp(7px,1.3vw,9px);background:var(--panel2);color:var(--dim);
border:2px solid var(--line);padding:6px 10px;cursor:pointer;letter-spacing:1px}
.lb-tabs button.on{border-color:var(--cyan);color:var(--cyan)}
.lb-row{display:grid;grid-template-columns:30px 1fr 48px 58px 70px;gap:4px;align-items:center;
font-size:clamp(7px,1.3vw,9px);padding:4px 4px;letter-spacing:1px;text-align:left}
.lb-row span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lb-row span:first-child{color:var(--dim)}
.lb-row.lb-head{color:var(--dim);border-bottom:2px solid var(--line)}
.lb-row.me{background:rgba(65,230,255,.12);outline:1px solid rgba(65,230,255,.35)}
.lb-list{max-height:34%;min-height:60px;overflow-y:auto}
.lb-empty{color:var(--dim);font-size:clamp(7px,1.3vw,9px);padding:14px 0;letter-spacing:2px}

View File

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<link rel="icon" href="data:,">
<title>星尘突击队 STAR RAID</title> <title>星尘突击队 STAR RAID</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
@ -13,17 +14,66 @@
<canvas id="game" width="240" height="320"></canvas> <canvas id="game" width="240" height="320"></canvas>
<div id="scan"></div><div id="vig"></div> <div id="scan"></div><div id="vig"></div>
<!-- 登陆/注册 -->
<div class="ovl" id="o-auth">
<div class="tag">PIXEL ROGUELIKE SHMUP</div>
<h1 class="logo">STAR<br>RAID</h1>
<div class="logo-cn">星尘突击队</div>
<div class="panel" style="width:min(92%,340px)">
<div class="row" style="gap:6px">
<button class="btn primary" id="tab-login" style="min-width:0;flex:1">登 陆</button>
<button class="btn" id="tab-reg" style="min-width:0;flex:1">注 册</button>
</div>
<form class="auth-f" id="f-login">
<input id="li-id" placeholder="用户名 / 邮箱" maxlength="64" autocomplete="username" required>
<input id="li-pw" type="password" placeholder="密码" maxlength="72" autocomplete="current-password" required>
<button class="btn primary" type="submit">进入星域 ▶</button>
</form>
<form class="auth-f hide" id="f-reg">
<input id="rg-name" placeholder="用户名2-16位" maxlength="16" autocomplete="off" required>
<input id="rg-email" type="email" placeholder="邮箱" maxlength="64" autocomplete="off" required>
<input id="rg-pw" type="password" placeholder="密码至少8位" maxlength="72" autocomplete="new-password" required>
<input id="rg-pw2" type="password" placeholder="确认密码" maxlength="72" autocomplete="new-password" required>
<button class="btn primary" type="submit">创建飞行员档案</button>
</form>
<div class="h3" id="auth-err" style="color:var(--red)"> </div>
</div>
<div class="small">账号数据保存在服务器 · 排行榜与全服飞行员同台竞技</div>
</div>
<!-- 飞行员大厅(排行榜主页) -->
<div class="ovl hide" id="o-home">
<div class="h2" style="margin-top:2%">— 飞行员大厅 —</div>
<div class="h3" id="home-stats"> </div>
<div class="row">
<button class="btn primary" id="b-base" style="min-width:min(56vw,200px)">▶ 进入基地</button>
<button class="btn" id="b-logout" style="min-width:0">登出</button>
</div>
<div class="lb">
<div class="h3" style="color:var(--cyan);letter-spacing:3px">— 全服排行榜 TOP 100 —</div>
<div class="lb-tabs">
<button data-s="endless" class="on">无尽最高分</button>
<button data-s="boss">击坠Boss</button>
<button data-s="codex">图鉴收集</button>
</div>
<div class="lb-row lb-head"><span>#</span><span>飞行员</span><span>击坠</span><span>图鉴</span><span>无尽最高</span></div>
<div class="lb-list" id="lb-list"></div>
</div>
</div>
<!-- 标题 --> <!-- 标题 -->
<div class="ovl" id="o-title"> <div class="ovl hide" id="o-title">
<div class="tag">PIXEL ROGUELIKE SHMUP</div> <div class="tag">PIXEL ROGUELIKE SHMUP</div>
<h1 class="logo">STAR<br>RAID</h1> <h1 class="logo">STAR<br>RAID</h1>
<div class="logo-cn">星尘突击队</div> <div class="logo-cn">星尘突击队</div>
<div class="h3" id="best-line">BEST -- </div> <div class="h3" id="best-line">BEST -- </div>
<div class="h3" id="pilot-line" style="color:var(--cyan)"> </div>
<div class="col" style="margin-top:6px"> <div class="col" style="margin-top:6px">
<button class="btn primary" id="b-go">▶ 出击</button> <button class="btn primary" id="b-go">▶ 出击</button>
<button class="btn gold" id="b-hangar">✦ 机库强化</button> <button class="btn gold" id="b-hangar">✦ 机库强化</button>
<button class="btn" id="b-codex">❖ 军械图鉴</button> <button class="btn" id="b-codex">❖ 军械图鉴</button>
<button class="btn danger" id="b-rush">☠ BOSS 挑战</button> <button class="btn danger" id="b-rush">☠ BOSS 挑战</button>
<button class="btn" id="b-lobby" style="min-width:min(48vw,180px)">⌂ 飞行员大厅</button>
</div> </div>
<div class="small" style="margin-top:8px">键盘 WASD/方向键 移动 · 自动射击 · 超频充满自动暴走<br>SHIFT 精密移动 · P 暂停 · M 音乐</div> <div class="small" style="margin-top:8px">键盘 WASD/方向键 移动 · 自动射击 · 超频充满自动暴走<br>SHIFT 精密移动 · P 暂停 · M 音乐</div>
</div> </div>
@ -133,6 +183,9 @@
<script src="js/combat.js"></script> <script src="js/combat.js"></script>
<script src="js/render.js"></script> <script src="js/render.js"></script>
<script src="js/boss_art.js"></script> <script src="js/boss_art.js"></script>
<script src="js/net.js"></script>
<script src="js/ui.js"></script> <script src="js/ui.js"></script>
<script src="js/auth.js"></script>
<script src="js/home.js"></script>
</body> </body>
</html> </html>

63
star-raid/js/auth.js Normal file
View File

@ -0,0 +1,63 @@
"use strict";
/* ================= 认证流程:登陆/注册/登出 + 启动引导 ================= */
(function () {
const err = (t) => { $("auth-err").textContent = t || " "; };
function tab(login) {
$("tab-login").classList.toggle("primary", login);
$("tab-reg").classList.toggle("primary", !login);
$("f-login").classList.toggle("hide", !login);
$("f-reg").classList.toggle("hide", login);
err();
}
$("tab-login").onclick = () => tab(true);
$("tab-reg").onclick = () => tab(false);
$("f-login").addEventListener("submit", async (e) => {
e.preventDefault();
err("登陆中...");
const msg = await NET.login($("li-id").value.trim(), $("li-pw").value);
if (msg) { err(msg); SFX.hurt(); return; }
err(); SFX.level(); enterHome();
});
$("f-reg").addEventListener("submit", async (e) => {
e.preventDefault();
const name = $("rg-name").value.trim(), email = $("rg-email").value.trim();
const pw = $("rg-pw").value, pw2 = $("rg-pw2").value;
if (pw !== pw2) { err("两次输入的密码不一致"); SFX.hurt(); return; }
err("注册中...");
const msg = await NET.register(name, email, pw);
if (msg) { err(msg); SFX.hurt(); return; }
err(); SFX.level(); enterHome();
});
$("b-logout").onclick = async () => {
SFX.pick();
await NET.logout();
G = null; UI = "auth";
$("li-pw").value = "";
show("o-auth");
};
/* 进入基地(游戏主菜单) */
$("b-base").onclick = () => {
SFX.pick();
$("pilot-line").textContent = NET.user ? ("飞行员 " + NET.user.username) : " ";
bestLine(); UI = "title"; show("o-title");
};
$("b-lobby").onclick = () => { SFX.pick(); enterHome(); };
/* 启动引导:恢复会话 → 大厅;否则登陆页(网络故障给出提示) */
(async function boot() {
UI = "auth";
show("o-auth");
const r = await NET.api("/api/me", "GET");
if (r.ok) { NET._apply(r.data); enterHome(); }
else {
if (r.status === 0) err("无法连接服务器,请稍后刷新重试");
show("o-auth");
}
})();
})();

View File

@ -9,7 +9,7 @@ function newRun(mode,shipKey,diff){
G={mode,diff,ship:shipKey,stage:1,wave:0,waveT:0,betweenT:60,spawning:[], G={mode,diff,ship:shipKey,stage:1,wave:0,waveT:0,betweenT:60,spawning:[],
score:0,coins:0,kills:0,combo:0,comboT:0,gems:0,level:1,xp:0,xpNeed:BAL.xpNeed(1), score:0,coins:0,kills:0,combo:0,comboT:0,gems:0,level:1,xp:0,xpNeed:BAL.xpNeed(1),
luck:(meta.luck||0),mutator:null,mutList:[], luck:(meta.luck||0),mutator:null,mutList:[],
time:0,boss:null,bossN:0,shake:0,hitstop:0,flash:0,slowmo:0, time:0,boss:null,bossN:0,bossKills:0,shake:0,hitstop:0,flash:0,slowmo:0,
weapons:{vulcan:1},loadout:["vulcan"],subs:{},subLoadout:[],drones:{},droneLoadout:[], weapons:{vulcan:1},loadout:["vulcan"],subs:{},subLoadout:[],drones:{},droneLoadout:[],
wt:{},wst:{},sst:{},dst:{},zones:[],zaps:[],novas:[],flares:[],holies:[],shocks:[],bbeams:[],funnels:[], wt:{},wst:{},sst:{},dst:{},zones:[],zaps:[],novas:[],flares:[],holies:[],shocks:[],bbeams:[],funnels:[],
u:{fire:0,crit:0,shield:0,nano:0,mag:0,bounty:0,odcore:0,ghost:0}, u:{fire:0,crit:0,shield:0,nano:0,mag:0,bounty:0,odcore:0,ghost:0},
@ -49,6 +49,7 @@ function pCoinMul(){let c=G.stat.coinMul*(1+0.3*G.u.bounty); if(G.diff===1)c*=BA
const KEYS={}; const KEYS={};
const GAMEKEYS=["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"," ","KeyW","KeyA","KeyS","KeyD","KeyX","KeyC","KeyZ","Shift","KeyP","Escape","KeyM","Digit1","Digit2","Digit3"]; const GAMEKEYS=["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"," ","KeyW","KeyA","KeyS","KeyD","KeyX","KeyC","KeyZ","Shift","KeyP","Escape","KeyM","Digit1","Digit2","Digit3"];
addEventListener("keydown",e=>{ addEventListener("keydown",e=>{
if(e.target&&(e.target.tagName==="INPUT"||e.target.tagName==="TEXTAREA"))return; // 登陆/注册表单打字不拦截
if(GAMEKEYS.includes(e.code))e.preventDefault(); if(GAMEKEYS.includes(e.code))e.preventDefault();
KEYS[e.code]=true; KEYS[e.code]=true;
AU.init(); if(AU.ctx&&AU.ctx.state==="suspended")AU.ctx.resume(); AU.init(); if(AU.ctx&&AU.ctx.state==="suspended")AU.ctx.resume();

View File

@ -542,6 +542,7 @@ function hurtBoss(dmg){
} }
function killBoss(){ function killBoss(){
const b=G.boss; const b=G.boss;
G.bossKills=(G.bossKills||0)+1; // 账号累计击坠Boss数结算上报
SFX.boom(true);G.shake=16;G.flash=20; SFX.boom(true);G.shake=16;G.flash=20;
for(let i=0;i<40;i++)spawnPart(b.x+rnd(-24,24),b.y+rnd(-14,14),pick(["#ffcf5c","#ff5566","#fff","#ff9a3d"]),3); for(let i=0;i<40;i++)spawnPart(b.x+rnd(-24,24),b.y+rnd(-14,14),pick(["#ffcf5c","#ff5566","#fff","#ff9a3d"]),3);
addScore(5000*(1+G.bossN*0.5),b.x,b.y); addScore(5000*(1+G.bossN*0.5),b.x,b.y);
@ -558,6 +559,7 @@ function killBoss(){
SAVE.rush=SAVE.rush||{}; SAVE.rush=SAVE.rush||{};
const best=SAVE.rush[b.idx]; const best=SAVE.rush[b.idx];
if(!best||ct<best){SAVE.rush[b.idx]=ct;persist();} if(!best||ct<best){SAVE.rush[b.idx]=ct;persist();}
if(typeof submitRun==="function")submitRun(true); // rush胜利也上报击坠计入排行榜
setTimeout(()=>{if(!G||G.lives<=0)return;buildRush();UI="rush";show("o-rush");},1500); setTimeout(()=>{if(!G||G.lives<=0)return;buildRush();UI="rush";show("o-rush");},1500);
} }
else if(G.mode==="stage"){ else if(G.mode==="stage"){

121
star-raid/js/home.js Normal file
View File

@ -0,0 +1,121 @@
"use strict";
/* ================= 飞行员大厅(主页):排行榜 + 图鉴展柜背景 ================= */
let LB_SORT = "endless";
let HOME_SHOW = null;
/* 图鉴已收集数(只统计合法 key */
function codexCount() {
let n = 0;
for (const k in SAVE.codex)
if (SAVE.codex[k] && (WEAPONS[k] || SUBS[k] || DRONES[k] || PASSIVES[k])) n++;
return n;
}
async function enterHome() {
G = null;
UI = "home";
rollHomeShow();
show("o-home");
/* 刷新账号数据stats/codex再渲染面板与排行榜 */
const r = await NET.api("/api/me", "GET");
if (r.ok) NET._apply(r.data);
else if (r.status === 401) { NET.user = null; UI = "auth"; show("o-auth"); return; }
buildHomeStats();
buildLeaderboard(LB_SORT);
}
function buildHomeStats() {
const s = NET.stats || { bossKills: 0, endlessHigh: 0 };
const nm = NET.user ? NET.user.username : "--";
$("home-stats").textContent =
`${nm} · 击坠Boss ${s.bossKills} · 图鉴 ${codexCount()}/${NET.codexTotal} · 无尽最高 ${s.endlessHigh}`;
}
/* ---------- 排行榜 ---------- */
document.querySelectorAll(".lb-tabs button").forEach((b) => {
b.onclick = () => {
LB_SORT = b.dataset.s;
document.querySelectorAll(".lb-tabs button").forEach((x) => x.classList.toggle("on", x === b));
SFX.pick();
buildLeaderboard(LB_SORT);
};
});
async function buildLeaderboard(sort) {
const list = $("lb-list");
list.innerHTML = '<div class="lb-empty">载入中...</div>';
const r = await NET.api("/api/leaderboard?sort=" + sort, "GET");
if (!r.ok) { list.innerHTML = '<div class="lb-empty">载入失败</div>'; return; }
const rows = r.data.rows || [];
list.innerHTML = "";
if (!rows.length) { list.innerHTML = '<div class="lb-empty">虚位以待</div>'; return; }
const meName = NET.user && NET.user.username;
rows.forEach((row, i) => {
const d = document.createElement("div");
d.className = "lb-row" + (meName && row.username === meName ? " me" : "");
const cells = [String(i + 1), row.username, String(row.bossKills), row.codexN + "/" + r.data.codexTotal, String(row.endlessHigh)];
for (const c of cells) {
const s = document.createElement("span");
s.textContent = c; // textContent 防注入
d.appendChild(s);
}
list.appendChild(d);
});
}
/* ---------- 大厅背景:随机军械展柜 + 随机刷怪巡游 ---------- */
const HOME_MOB_TYPES = ["scout", "weaver", "kami", "splitter", "mine", "gunship", "sniper"];
function rollHomeShow() {
HOME_SHOW = {
m: shuffle(Object.keys(WEAPONS)).slice(0, 3),
s: shuffle(Object.keys(SUBS)).slice(0, 3),
d: shuffle(Object.keys(DRONES)).slice(0, 2),
t: 0, spawnT: 20, mobs: [],
};
}
/* 由 render() 在 UI==="home" 时每帧调用dt=1 */
function drawHomeShowcase(dt) {
if (!HOME_SHOW || HOME_SHOW.t > 1800) rollHomeShow(); // 30 秒重抽一轮
const hs = HOME_SHOW;
hs.t += dt;
/* 展柜3主武+3副武+2僚机两排漂浮 */
const items = [
...hs.m.map((k) => ({ d: WEAPONS[k] })),
...hs.s.map((k) => ({ d: SUBS[k] })),
...hs.d.map((k) => ({ d: DRONES[k] })),
];
ctx.textAlign = "center";
items.forEach((it, i) => {
const col = i % 4, row = (i / 4) | 0;
const x = W / 2 + (col - 1.5) * 56;
const y = 118 + row * 52 + Math.sin(hs.t * 0.03 + i * 1.7) * 2.5;
const img = it.d.icon ? ICONS[it.d.icon] : SPR[it.d.spr];
if (img) {
ctx.globalAlpha = 0.85;
ctx.drawImage(img, x - img.width * 1.5, y - img.height * 1.5, img.width * 3, img.height * 3);
ctx.globalAlpha = 1;
}
ctx.font = "6px 'Press Start 2P',monospace";
ctx.fillStyle = "#7f8cb8";
ctx.fillText(it.d.name, x, y + 22);
});
/* 随机刷怪:从顶部飘入,正弦摆动 */
hs.spawnT -= dt;
if (hs.spawnT <= 0 && hs.mobs.length < 8) {
hs.spawnT = rndi(50, 110);
const ty = pick(HOME_MOB_TYPES), elite = Math.random() < 0.2;
const spr = SPR[(elite ? "e_" : "") + ty] || SPR[ty] || SPR.scout;
hs.mobs.push({ x: rnd(20, W - 20), y: -16, vy: rnd(0.25, 0.6), ph: rnd(0, TAU), amp: rnd(6, 26), spr, sc: elite ? 1.8 : 1.3, t: 0 });
}
for (const m of hs.mobs) {
m.t += dt;
m.y += m.vy * dt;
const mx = m.x + Math.sin(m.t * 0.02 + m.ph) * m.amp;
ctx.globalAlpha = 0.8;
ctx.drawImage(m.spr, mx - (m.spr.width * m.sc) / 2, m.y - (m.spr.height * m.sc) / 2, m.spr.width * m.sc, m.spr.height * m.sc);
ctx.globalAlpha = 1;
}
hs.mobs = hs.mobs.filter((m) => m.y < H + 24);
}

76
star-raid/js/net.js Normal file
View File

@ -0,0 +1,76 @@
"use strict";
/* ================= 网络层:服务端 API 客户端 ================= */
const NET = {
user: null, // {username,email}
stats: null, // {bossKills,endlessHigh,runs}
codexTotal: 62,
online: false,
async api(path, method, body) {
let res;
try {
res = await fetch(path, {
method: method || "GET",
credentials: "same-origin",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
} catch (e) {
return { ok: false, status: 0, data: { error: "无法连接服务器" } };
}
let data = {};
try { data = await res.json(); } catch (e) { /* 空响应 */ }
return { ok: res.ok, status: res.status, data };
},
/* 启动尝试恢复会话。true=已登录 */
async boot() {
const r = await this.api("/api/me", "GET");
if (r.ok) { this._apply(r.data); return true; }
return false;
},
async login(id, password) {
const r = await this.api("/api/login", "POST", { id, password });
if (r.ok) { this._apply(r.data); return null; }
return r.data.error || "登陆失败";
},
async register(username, email, password) {
const r = await this.api("/api/register", "POST", { username, email, password });
if (r.ok) { this._apply(r.data); return null; }
return r.data.error || "注册失败";
},
async logout() {
await this.api("/api/logout", "POST");
this.user = null; this.stats = null; this.online = false;
},
/* 应用服务端返回的用户数据:同步图鉴解锁到本地存档(并集) */
_apply(d) {
this.user = d.user;
this.stats = d.stats;
this.codexTotal = d.codexTotal || 62;
this.online = true;
if (Array.isArray(d.codex)) {
SAVE.codex = SAVE.codex || {};
for (const k of d.codex) SAVE.codex[k] = true;
persist();
}
},
/* 结算上报fire-and-forget成功后回写最新统计 */
submitRun(payload) {
if (!this.user) return;
this.api("/api/run", "POST", payload).then((r) => {
if (r.ok) {
this.stats = r.data.stats;
if (Array.isArray(r.data.codex)) {
for (const k of r.data.codex) SAVE.codex[k] = true;
persist();
}
}
}).catch(() => { /* 网络失败静默:本地存档仍在 */ });
},
};

View File

@ -232,11 +232,14 @@ function drawSeg(x1,y1,x2,y2,w,col,alpha){
ctx.fillRect(Math.round(lerp(x1,x2,t2)-w/2),Math.round(lerp(y1,y2,t2)-w/2),w,w);} ctx.fillRect(Math.round(lerp(x1,x2,t2)-w/2),Math.round(lerp(y1,y2,t2)-w/2),w,w);}
ctx.globalAlpha=1; ctx.globalAlpha=1;
} }
/* 战斗相关界面才绘制游戏对象;大厅/标题/登陆页只画星空(修复:回基地后背景卡在上场残影) */
function battleUI(){return UI==="play"||UI==="demoSim"||UI==="pause"||UI==="cards"||UI==="over"||UI==="slot";}
function render(){ function render(){
ctx.save(); ctx.save();
if(G&&G.shake>0){ctx.translate(rnd(-G.shake,G.shake)*0.5,rnd(-G.shake,G.shake)*0.5);} if(G&&G.shake>0){ctx.translate(rnd(-G.shake,G.shake)*0.5,rnd(-G.shake,G.shake)*0.5);}
drawBG(1); drawBG(1);
if(G){ if(UI==="home"&&typeof drawHomeShowcase==="function")drawHomeShowcase(1);
if(G&&battleUI()){
// 拾取物 // 拾取物
for(const k of G.picks){const bob=Math.sin(k.t*0.1)*1.5;drawSprite(SPR[k.spr],k.x,k.y+bob);} for(const k of G.picks){const bob=Math.sin(k.t*0.1)*1.5;drawSprite(SPR[k.spr],k.x,k.y+bob);}
// 敌机 // 敌机

View File

@ -1,6 +1,6 @@
"use strict"; "use strict";
/* ================= 界面流转 ================= */ /* ================= 界面流转 ================= */
const OVLS=["o-title","o-setup","o-hangar","o-cards","o-pause","o-over","o-codex","o-demo","o-rush","o-slot"]; const OVLS=["o-auth","o-home","o-title","o-setup","o-hangar","o-cards","o-pause","o-over","o-codex","o-demo","o-rush","o-slot"];
function show(id){for(const o of OVLS)$(o).classList.toggle("hide",o!==id);} function show(id){for(const o of OVLS)$(o).classList.toggle("hide",o!==id);}
const SETUP={mode:"stage",ship:"falcon",diff:0}; const SETUP={mode:"stage",ship:"falcon",diff:0};
function bestLine(){ function bestLine(){
@ -22,7 +22,7 @@ $("b-resume").onclick=()=>togglePause();
$("b-restart").onclick=()=>{show("");if(G&&G.mode==="rush")startRush(G.rushIdx);else startGame();}; // Boss挑战重开仍在挑战模式 $("b-restart").onclick=()=>{show("");if(G&&G.mode==="rush")startRush(G.rushIdx);else startGame();}; // Boss挑战重开仍在挑战模式
$("b-quit").onclick=()=>{bank();G=null;MUS.stop();UI="title";bestLine();show("o-title");}; $("b-quit").onclick=()=>{bank();G=null;MUS.stop();UI="title";bestLine();show("o-title");};
$("b-again").onclick=()=>startGame(); $("b-again").onclick=()=>startGame();
$("b-home").onclick=()=>{UI="title";bestLine();show("o-title");}; $("b-home").onclick=()=>{G=null;UI="title";bestLine();show("o-title");};
$("mode-pick").addEventListener("click",e=>{ $("mode-pick").addEventListener("click",e=>{
const c=e.target.closest(".pcard");if(!c)return; const c=e.target.closest(".pcard");if(!c)return;
SETUP.mode=c.dataset.m; SETUP.mode=c.dataset.m;
@ -123,29 +123,30 @@ function buildLoadoutUI(){
mk("僚机",DRONES,G.drones,G.droneLoadout,3); mk("僚机",DRONES,G.drones,G.droneLoadout,3);
} }
/* ================= 图鉴 ================= */ /* ================= 图鉴 ================= */
function iconCanvas(img,size){ function iconCanvas(img,size,sil){
const c=document.createElement("canvas");c.width=c.height=size; const c=document.createElement("canvas");c.width=c.height=size;
const g=c.getContext("2d");g.imageSmoothingEnabled=false; const g=c.getContext("2d");g.imageSmoothingEnabled=false;
const sc=Math.min(size/img.width,size/img.height); const sc=Math.min(size/img.width,size/img.height);
g.drawImage(img,(size-img.width*sc)/2,(size-img.height*sc)/2,img.width*sc,img.height*sc); g.drawImage(img,(size-img.width*sc)/2,(size-img.height*sc)/2,img.width*sc,img.height*sc);
if(sil){g.globalCompositeOperation="source-atop";g.fillStyle="#232f52";g.fillRect(0,0,size,size);g.globalCompositeOperation="source-over";}
return c; return c;
} }
function buildCodex(){ function buildCodex(){
const box=$("codex");box.innerHTML=""; const box=$("codex");box.innerHTML="";
const total=MAINS.length+SUB_KEYS.length+DRONE_KEYS.length+PAS_KEYS.length; const total=MAINS.length+SUB_KEYS.length+DRONE_KEYS.length+PAS_KEYS.length;
$("codex-t").textContent=total; $("codex-t").textContent=total;
$("codex-n").textContent=Object.keys(SAVE.codex).length; $("codex-n").textContent=codexCount();
const mk=(title,keys,dict,kind)=>{ const mk=(title,keys,dict,kind)=>{
const h=document.createElement("div");h.className="cx-sec";h.textContent=title;box.appendChild(h); const h=document.createElement("div");h.className="cx-sec";h.textContent=title;box.appendChild(h);
const grid=document.createElement("div");grid.className="cx-grid"; const grid=document.createElement("div");grid.className="cx-grid";
for(const k of keys){ for(const k of keys){
const def=dict[k]; const def=dict[k],owned=!!SAVE.codex[k];
const d=document.createElement("div"); const d=document.createElement("div");
d.className="cx-item"; d.className="cx-item"+(owned?"":" lock"); // 未收集:剪影+???,不可演示
const img=def.icon?ICONS[def.icon]:SPR[def.spr]; const img=def.icon?ICONS[def.icon]:SPR[def.spr];
d.appendChild(iconCanvas(img,28)); d.appendChild(iconCanvas(img,28,!owned));
d.insertAdjacentHTML("beforeend",`<div class="nm">${def.name}</div>`); d.insertAdjacentHTML("beforeend",`<div class="nm">${owned?def.name:"???"}</div>`);
d.onclick=()=>{SFX.pick();startDemoSim(kind,k);}; if(owned)d.onclick=()=>{SFX.pick();startDemoSim(kind,k);};
grid.appendChild(d); grid.appendChild(d);
} }
box.appendChild(grid); box.appendChild(grid);
@ -154,7 +155,6 @@ function buildCodex(){
mk("副武器",SUB_KEYS,SUBS,"s"); mk("副武器",SUB_KEYS,SUBS,"s");
mk("僚机",DRONE_KEYS,DRONES,"d"); mk("僚机",DRONE_KEYS,DRONES,"d");
mk("被动强化",PAS_KEYS,PASSIVES,"p"); mk("被动强化",PAS_KEYS,PASSIVES,"p");
$("codex-n").textContent=total; // 图鉴全量直接展示,无需收集
} }
document.addEventListener("visibilitychange",()=>{if(document.hidden&&UI==="play")togglePause();}); document.addEventListener("visibilitychange",()=>{if(document.hidden&&UI==="play")togglePause();});
function bank(){ function bank(){
@ -166,6 +166,7 @@ function bank(){
} }
/* ---- Boss 挑战模式 ---- */ /* ---- Boss 挑战模式 ---- */
function buildRush(){ function buildRush(){
G=null; // 清掉上一局残留rush胜利后G仍在防背景卡残影
SAVE.rush=SAVE.rush||{}; SAVE.rush=SAVE.rush||{};
const ra=$("rush-anim"); // 老虎机开关(持久化) const ra=$("rush-anim"); // 老虎机开关(持久化)
if(ra){ra.checked=!!SAVE.rushAnim;ra.onchange=()=>{SAVE.rushAnim=ra.checked?1:0;persist();};} if(ra){ra.checked=!!SAVE.rushAnim;ra.onchange=()=>{SAVE.rushAnim=ra.checked?1:0;persist();};}
@ -227,9 +228,20 @@ function playSlot(build,cb){
},700+idx*280); },700+idx*280);
}); });
} }
/* 结算上报服务端每局一次含rush胜利图鉴演示除外 */
function submitRun(win){
if(!G||G.submitted||G.demoSim)return;
G.submitted=true;
if(typeof NET==="undefined"||!NET.user)return;
NET.submitRun({mode:G.mode,diff:G.diff,score:G.score,wave:G.wave,stage:G.stage,
timeSec:Math.round(G.time/60),bossKills:G.bossKills||0,win:!!win,
bestEndless:Math.max(SAVE.best.e0||0,SAVE.best.e1||0),
codex:Object.keys(SAVE.codex)});
}
function endRun(win){ function endRun(win){
if(UI==="over")return; if(UI==="over")return;
bank();MUS.stop(); bank();MUS.stop();
submitRun(win);
UI="over"; UI="over";
$("over-title").textContent=win?"★ 任务完成 ★":"任务失败"; $("over-title").textContent=win?"★ 任务完成 ★":"任务失败";
$("over-title").style.color=win?"#ffcf5c":"#ff5566"; $("over-title").style.color=win?"#ffcf5c":"#ff5566";

12
star-raid/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "star-raid",
"version": "1.0.0",
"private": true,
"description": "星尘突击队 STAR RAID —— 像素肉鸽竖版射击(含服务端:账号/排行榜/图鉴同步)",
"scripts": {
"start": "node server/server.js"
},
"engines": {
"node": ">=22.5.0"
}
}

142
star-raid/server/api.js Normal file
View File

@ -0,0 +1,142 @@
"use strict";
/* ================= API =================
所有 SQL 均为 prepared statement禁止字符串拼接用户输入 */
const db = require("./db");
const auth = require("./auth");
const { vUsername, vEmail, vPassword, vInt, rateLimit, clientIp } = require("./util");
/* 图鉴合法 key 白名单(与 js/data.js 保持同步24主武+17副武+13僚机+8被动=62 */
const CODEX_KEYS = new Set([
"vulcan", "shotgun", "needle", "flame", "shotgun2", "tesla", "railgun", "deathray",
"crescent", "fission", "frost", "swarm", "bubble", "starfall", "boomerang", "singularity",
"wavecannon", "holy", "thundercall", "arcshu", "volt", "dawn", "proto", "nebula",
"broad", "sweep", "leech", "orbit", "sats", "pd", "slowfield", "flares", "seeker",
"beamdrone", "frostnova", "thorns", "sticky", "magnetar", "executor", "airstrike", "repair",
"gunner", "gunner2", "sniperd", "beamer", "bomber", "frostd", "teslaD", "rammer",
"healer", "shieldd", "missileD", "bladeD", "sweeper",
"fire", "crit", "shield", "nano", "mag", "bounty", "odcore", "ghost",
]);
const CODEX_TOTAL = CODEX_KEYS.size;
const RUN_MODES = new Set(["stage", "endless", "rush"]);
const qInsUser = db.prepare("INSERT INTO users(username,email,pass_hash,created_at) VALUES(?,?,?,?)");
const qInsStats = db.prepare("INSERT OR IGNORE INTO user_stats(user_id) VALUES(?)");
const qByName = db.prepare("SELECT * FROM users WHERE username=?");
const qByEmail = db.prepare("SELECT * FROM users WHERE email=?");
const qCodex = db.prepare("SELECT entry_key FROM codex_unlocks WHERE user_id=?");
const qInsCodex = db.prepare("INSERT OR IGNORE INTO codex_unlocks(user_id,entry_key,unlocked_at) VALUES(?,?,?)");
const qStats = db.prepare("SELECT * FROM user_stats WHERE user_id=?");
const qInsRun = db.prepare("INSERT INTO runs(user_id,mode,diff,score,wave,stage,time_sec,boss_kills,win,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)");
const qUpdStats = db.prepare(`INSERT INTO user_stats(user_id,boss_kills,endless_high,runs,updated_at) VALUES(?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
boss_kills=boss_kills+excluded.boss_kills,
endless_high=MAX(endless_high,excluded.endless_high),
runs=runs+1,
updated_at=excluded.updated_at`);
function statsOf(userId) {
const s = qStats.get(userId) || { boss_kills: 0, endless_high: 0, runs: 0 };
return { bossKills: s.boss_kills, endlessHigh: s.endless_high, runs: s.runs };
}
function codexOf(userId) {
return qCodex.all(userId).map((r) => r.entry_key);
}
/* ---------- POST /api/register ---------- */
function register(req, res, ctx, body) {
if (!rateLimit("reg:" + ctx.ip, 10, 5 * 60000)) return ctx.fail(429, "操作太频繁,请稍后再试");
const username = body && body.username, email = body && body.email, password = body && body.password;
if (!vUsername(username)) return ctx.fail(400, "用户名需为 2-16 位(中文/英文/数字/下划线)");
if (!vEmail(email)) return ctx.fail(400, "邮箱格式不正确");
if (!vPassword(password)) return ctx.fail(400, "密码需为 8-72 位");
if (qByName.get(username)) return ctx.fail(409, "用户名已被注册");
if (qByEmail.get(email)) return ctx.fail(409, "邮箱已被注册");
const info = qInsUser.run(username.trim(), email.trim().toLowerCase(), auth.hashPassword(password), Date.now());
const uid = Number(info.lastInsertRowid);
qInsStats.run(uid);
const token = auth.createSession(db, uid);
ctx.setCookie = auth.sessionCookie(token, ctx.secure);
return ctx.ok({ user: { username, email: email.trim().toLowerCase() }, stats: statsOf(uid), codex: [], codexTotal: CODEX_TOTAL });
}
/* ---------- POST /api/login ---------- */
function login(req, res, ctx, body) {
if (!rateLimit("login:" + ctx.ip, 20, 5 * 60000)) return ctx.fail(429, "尝试太频繁,请稍后再试");
const id = body && body.id, password = body && body.password;
if (typeof id !== "string" || typeof password !== "string" || id.length > 64 || password.length > 72)
return ctx.fail(401, "用户名或密码错误");
const u = id.includes("@") ? qByEmail.get(id.trim().toLowerCase()) : qByName.get(id.trim());
/* 统一错误信息,不泄露账号是否存在;对不存在账号也跑一次哈希,防时序探测 */
const hash = u ? u.pass_hash : "0".repeat(32) + ":" + "0".repeat(128);
if (!auth.verifyPassword(password, hash) || !u) return ctx.fail(401, "用户名或密码错误");
qInsStats.run(u.id);
const token = auth.createSession(db, u.id);
ctx.setCookie = auth.sessionCookie(token, ctx.secure);
return ctx.ok({ user: { username: u.username, email: u.email }, stats: statsOf(u.id), codex: codexOf(u.id), codexTotal: CODEX_TOTAL });
}
/* ---------- POST /api/logout ---------- */
function logout(req, res, ctx) {
auth.destroySession(db, ctx.token);
ctx.setCookie = auth.clearCookie(ctx.secure);
return ctx.ok({ bye: true });
}
/* ---------- GET /api/me ---------- */
function me(req, res, ctx) {
const u = ctx.user;
return ctx.ok({ user: { username: u.username, email: u.email, createdAt: u.created_at }, stats: statsOf(u.id), codex: codexOf(u.id), codexTotal: CODEX_TOTAL });
}
/* ---------- GET /api/leaderboard?sort=endless|boss|codex ---------- */
const LB_COLS = { endless: "endless_high", boss: "boss_kills", codex: "codex_n" };
function leaderboard(req, res, ctx, _body, query) {
const col = LB_COLS[(query && query.get("sort")) || ""] || "endless_high";
/* col 来自上方白名单映射,非用户输入直接拼接 */
const rows = db.prepare(`
SELECT u.username AS username, s.boss_kills AS bossKills, s.endless_high AS endlessHigh,
(SELECT COUNT(*) FROM codex_unlocks c WHERE c.user_id=u.id) AS codexN
FROM users u JOIN user_stats s ON s.user_id=u.id
ORDER BY ${col} DESC, u.id ASC LIMIT 100`).all();
return ctx.ok({ rows, codexTotal: CODEX_TOTAL, sort: col });
}
/* ---------- POST /api/run ----------
结算上报基础合理性校验客户端游戏无法杜绝作弊只挡低级伪造 */
function run(req, res, ctx, body) {
if (!rateLimit("run:" + ctx.user.id, 20, 5 * 60000)) return ctx.fail(429, "上报太频繁");
const b = body || {};
if (!RUN_MODES.has(b.mode)) return ctx.fail(400, "非法模式");
if (!vInt(b.diff, 0, 1)) return ctx.fail(400, "非法难度");
if (!vInt(b.score, 0, 1e8)) return ctx.fail(400, "非法分数");
if (!vInt(b.wave, 0, 9999) || !vInt(b.stage, 0, 99)) return ctx.fail(400, "非法进度");
if (!vInt(b.timeSec, 0, 86400)) return ctx.fail(400, "非法时长");
if (!vInt(b.bossKills, 0, 60)) return ctx.fail(400, "非法击坠数");
if (!vInt(b.bestEndless === undefined ? 0 : b.bestEndless, 0, 1e8)) return ctx.fail(400, "非法纪录");
const keys = Array.isArray(b.codex) ? b.codex.slice(0, 100).filter((k) => CODEX_KEYS.has(k)) : [];
const uid = ctx.user.id, now = Date.now();
const endlessCand = Math.max(b.mode === "endless" ? b.score : 0, b.bestEndless || 0);
db.exec("BEGIN");
try {
qInsRun.run(uid, b.mode, b.diff, b.score, b.wave, b.stage, b.timeSec, b.bossKills, b.win ? 1 : 0, now);
qUpdStats.run(uid, b.bossKills, endlessCand, 1, now);
for (const k of keys) qInsCodex.run(uid, k, now);
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
return ctx.ok({ stats: statsOf(uid), codex: codexOf(uid), codexTotal: CODEX_TOTAL });
}
const ROUTES = {
"POST /api/register": { fn: register, auth: false },
"POST /api/login": { fn: login, auth: false },
"POST /api/logout": { fn: logout, auth: true },
"GET /api/me": { fn: me, auth: true },
"GET /api/leaderboard": { fn: leaderboard, auth: true },
"POST /api/run": { fn: run, auth: true },
};
module.exports = { ROUTES, CODEX_TOTAL };

69
star-raid/server/auth.js Normal file
View File

@ -0,0 +1,69 @@
"use strict";
/* ================= 认证scrypt 密码哈希 + 服务端会话 ================= */
const crypto = require("node:crypto");
const SCRYPT_N = 16384, SCRYPT_R = 8, SCRYPT_P = 1, KEYLEN = 64;
const SESSION_TTL = 7 * 24 * 3600 * 1000; // 7 天
const COOKIE_NAME = "sr_sid";
function hashPassword(pw) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(pw, salt, KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
return salt.toString("hex") + ":" + hash.toString("hex");
}
function verifyPassword(pw, stored) {
if (typeof stored !== "string") return false;
const i = stored.indexOf(":");
if (i < 0) return false;
const salt = Buffer.from(stored.slice(0, i), "hex");
const expect = Buffer.from(stored.slice(i + 1), "hex");
if (salt.length !== 16 || expect.length !== KEYLEN) return false;
const got = crypto.scryptSync(pw, salt, KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
return crypto.timingSafeEqual(got, expect);
}
function createSession(db, userId) {
const token = crypto.randomBytes(32).toString("hex");
const now = Date.now();
db.prepare("INSERT INTO sessions(token,user_id,created_at,expires_at) VALUES(?,?,?,?)")
.run(token, userId, now, now + SESSION_TTL);
return token;
}
/* 查 session含滑动续期剩余不足一半时顺延 */
function getSessionUser(db, token) {
if (!token || typeof token !== "string" || token.length > 128) return null;
const row = db.prepare(
"SELECT s.token,s.expires_at,u.id,u.username,u.email,u.created_at FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token=?"
).get(token);
if (!row) return null;
const now = Date.now();
if (row.expires_at < now) {
db.prepare("DELETE FROM sessions WHERE token=?").run(token);
return null;
}
if (row.expires_at - now < SESSION_TTL / 2)
db.prepare("UPDATE sessions SET expires_at=? WHERE token=?").run(now + SESSION_TTL, token);
return row;
}
function destroySession(db, token) {
if (token) db.prepare("DELETE FROM sessions WHERE token=?").run(token);
}
/* Set-Cookie 字符串secure=true 用于 HTTPS 部署(环境变量 COOKIE_SECURE=1 */
function sessionCookie(token, secure) {
const parts = [
COOKIE_NAME + "=" + token,
"Path=/", "HttpOnly", "SameSite=Lax",
"Max-Age=" + Math.floor(SESSION_TTL / 1000),
];
if (secure) parts.push("Secure");
return parts.join("; ");
}
function clearCookie(secure) {
return COOKIE_NAME + "=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + (secure ? "; Secure" : "");
}
module.exports = { hashPassword, verifyPassword, createSession, getSessionUser, destroySession, sessionCookie, clearCookie, COOKIE_NAME };

63
star-raid/server/db.js Normal file
View File

@ -0,0 +1,63 @@
"use strict";
/* ================= SQLitenode:sqlite 内置驱动,零依赖) ================= */
const { DatabaseSync } = require("node:sqlite");
const path = require("node:path");
const fs = require("node:fs");
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "..", "data");
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new DatabaseSync(path.join(DATA_DIR, "starraid.db"));
db.exec("PRAGMA journal_mode=WAL;");
db.exec("PRAGMA foreign_keys=ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
pass_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions(
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS user_stats(
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
boss_kills INTEGER NOT NULL DEFAULT 0,
endless_high INTEGER NOT NULL DEFAULT 0,
runs INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS codex_unlocks(
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entry_key TEXT NOT NULL,
unlocked_at INTEGER NOT NULL,
PRIMARY KEY(user_id, entry_key)
);
CREATE TABLE IF NOT EXISTS runs(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mode TEXT NOT NULL,
diff INTEGER NOT NULL,
score INTEGER NOT NULL,
wave INTEGER NOT NULL,
stage INTEGER NOT NULL,
time_sec INTEGER NOT NULL,
boss_kills INTEGER NOT NULL,
win INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_runs_user ON runs(user_id);
`);
/* 定期清理过期 session */
setInterval(() => {
try { db.prepare("DELETE FROM sessions WHERE expires_at<?").run(Date.now()); } catch (e) { /* 忽略 */ }
}, 3600000).unref();
module.exports = db;

106
star-raid/server/server.js Normal file
View File

@ -0,0 +1,106 @@
"use strict";
/* ================= Star Raid =================
零依赖node:http + node:sqlite静态资源白名单 + /api 路由 + 安全头 */
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const db = require("./db");
const auth = require("./auth");
const { ROUTES } = require("./api");
const { readJson, parseCookies, clientIp } = require("./util");
const PORT = Number(process.env.PORT || 8643);
const HOST = process.env.HOST || "0.0.0.0";
const SECURE = process.env.COOKIE_SECURE === "1"; // HTTPS 反代部署时置 1
const ROOT = path.join(__dirname, ".."); // star-raid/
/* 静态资源白名单:只允许入口页与 css/jsserver/、data/、demo/ 一律不放行) */
const STATIC_PREFIXES = ["/css/", "/js/"];
const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".ico": "image/x-icon" };
function secHeaders(res) {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'");
}
function sendJson(res, status, obj, setCookie) {
const body = JSON.stringify(obj);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...(setCookie ? { "Set-Cookie": setCookie } : {}),
});
res.end(body);
}
/* 请求上下文:统一成功/失败出口 */
function makeCtx(req, res) {
return {
ip: clientIp(req),
secure: SECURE,
token: parseCookies(req)[auth.COOKIE_NAME],
user: null,
setCookie: null,
ok(data) { sendJson(res, 200, data, this.setCookie); return true; },
fail(status, error) { sendJson(res, status, { error }, this.setCookie); return false; },
};
}
async function handleApi(req, res, pathname, query) {
const ctx = makeCtx(req, res);
const route = ROUTES[req.method + " " + pathname];
if (!route) return ctx.fail(404, "not found");
if (route.auth) {
const u = auth.getSessionUser(db, ctx.token);
if (!u) return ctx.fail(401, "未登录或会话已过期");
ctx.user = u;
}
try {
let body = null;
/* logout 等无 body 的 POST 不强制 JSON有 body 的必须 application/json */
if (req.method === "POST" && Number(req.headers["content-length"] || 0) > 0) body = await readJson(req);
return route.fn(req, res, ctx, body, query);
} catch (e) {
const status = e && e.status && Number.isInteger(e.status) ? e.status : 500;
if (status === 500) console.error("[api]", pathname, e);
return ctx.fail(status, status === 500 ? "服务器开小差了" : e.message);
}
}
function handleStatic(req, res, pathname) {
if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405); return res.end(); }
let p = pathname;
if (p === "/") p = "/index.html";
const allowed = p === "/index.html" || STATIC_PREFIXES.some((pre) => p.startsWith(pre));
if (!allowed || p.includes("..")) { res.writeHead(404); return res.end("not found"); }
const full = path.normalize(path.join(ROOT, p));
if (!full.startsWith(ROOT + path.sep)) { res.writeHead(404); return res.end("not found"); }
const ext = path.extname(full).toLowerCase();
const mime = MIME[ext];
if (!mime) { res.writeHead(404); return res.end("not found"); }
fs.readFile(full, (err, data) => {
if (err) { res.writeHead(404); return res.end("not found"); }
res.writeHead(200, { "Content-Type": mime, "Cache-Control": "no-cache" });
if (req.method === "HEAD") return res.end();
res.end(data);
});
}
const server = http.createServer((req, res) => {
secHeaders(res);
let url;
try { url = new URL(req.url, "http://x"); } catch (e) { res.writeHead(400); return res.end(); }
const pathname = url.pathname;
if (pathname.startsWith("/api/")) {
if (pathname === "/api/health" && req.method === "GET") return sendJson(res, 200, { ok: true, t: Date.now() });
handleApi(req, res, pathname, url.searchParams);
} else {
handleStatic(req, res, pathname);
}
});
server.listen(PORT, HOST, () => {
console.log(`[star-raid] server listening on http://${HOST}:${PORT} (secure-cookie=${SECURE})`);
});

80
star-raid/server/util.js Normal file
View File

@ -0,0 +1,80 @@
"use strict";
/* ================= 工具:请求体 / 限流 / 输入校验 ================= */
/* 读取请求体(带大小上限,超限即毁连接) */
function readBody(req, limit = 16384) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on("data", (c) => {
size += c.length;
if (size > limit) {
reject(Object.assign(new Error("body too large"), { status: 413 }));
req.destroy();
} else chunks.push(c);
});
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
/* 读取 JSON强制 content-type配合 SameSite=Lax 防 CSRF限长 */
async function readJson(req, limit) {
const ct = String(req.headers["content-type"] || "");
if (!ct.includes("application/json"))
throw Object.assign(new Error("Content-Type must be application/json"), { status: 415 });
const buf = await readBody(req, limit);
try {
return JSON.parse(buf.toString("utf8"));
} catch (e) {
throw Object.assign(new Error("invalid JSON"), { status: 400 });
}
}
/* ---------------- 限流(内存桶,按 key ---------------- */
const buckets = new Map();
/** 返回 true=放行false=超限 */
function rateLimit(key, max, winMs) {
const now = Date.now();
let b = buckets.get(key);
if (!b || now > b.reset) {
b = { count: 0, reset: now + winMs };
buckets.set(key, b);
}
b.count++;
return b.count <= max;
}
setInterval(() => {
const now = Date.now();
for (const [k, b] of buckets) if (now > b.reset) buckets.delete(k);
}, 60000).unref();
/* ---------------- 输入校验(白名单式) ---------------- */
const RE_USER = /^[A-Za-z0-9_一-龥]{2,16}$/;
const RE_EMAIL = /^[^\s@]{1,40}@[^\s@]{1,40}\.[^\s@]{1,20}$/;
const vUsername = (u) => typeof u === "string" && RE_USER.test(u);
const vEmail = (e) => typeof e === "string" && e.length <= 64 && RE_EMAIL.test(e);
const vPassword = (p) => typeof p === "string" && p.length >= 8 && p.length <= 72;
const vInt = (n, min, max) => Number.isInteger(n) && n >= min && n <= max;
/* 客户端 IP反代后取 X-Forwarded-For 首段,仅用于限流) */
function clientIp(req) {
const xf = req.headers["x-forwarded-for"];
if (xf) return String(xf).split(",")[0].trim().slice(0, 64);
return (req.socket && req.socket.remoteAddress) || "unknown";
}
/* 解析 Cookie 头 */
function parseCookies(req) {
const out = {};
const raw = req.headers.cookie;
if (!raw) return out;
for (const part of String(raw).split(";")) {
const i = part.indexOf("=");
if (i < 0) continue;
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
}
return out;
}
module.exports = { readJson, rateLimit, vUsername, vEmail, vPassword, vInt, clientIp, parseCookies };