无尽模式断点续玩:进度云端存档,中途退出可续玩,新开需确认结算旧档

- 服务端:run_saves 表(每账号1栏位)+ GET/POST/DELETE /api/run_save,vRunSave 白名单校验
- 波次检查点:startWave 拍快照上传;中途放弃任务不结算(星币/分数留快照);死亡删档
- 出击页卡片显示未完成对局并一键续玩(该波从头重打,bossN 不偏移)
- 新开无尽(不分难度)弹窗确认→旧档按当前进度结算(星币入账+上报排行榜)再开新局
- NET._rsQ 串行队列防删建竞态;顺带修复 SHIP_KEYS 漏 asura 致修罗云同步丢失
This commit is contained in:
JOJO 2026-08-06 12:50:37 +08:00
parent 9edc366cf9
commit 9d048a2aa9
7 changed files with 235 additions and 6 deletions

View File

@ -101,3 +101,5 @@ star-raid/
- 背景规则render() 用 battleUI() 门控;**主页/基地背景=实战模拟**home.js startHomeSim随机3主Lv3+3副Lv3+2僚Lv2G.homeSim 标记,复用 demoSim 自动驾驶刷怪AU.simMute 静音,不伤/不计分/不上报,主循环看门狗自动重建);其余空闲=星空。离场必须 G=nullb-home/b-quit/buildRush 已处理)
- 新增装备时必须同步 `server/api.js` 的 CODEX_KEYS 白名单(现 25+17+13+8=63
- 星币/机体/选中机体/永久强化/本地纪录已上服务端POST /api/state → player_state 表,服务端权威整体替换,登出清空;客户端 persist() 钩子防抖600ms上传
- **无尽断点续玩2026-08-06**run_saves 表每账号1栏位不分难度GET/POST/DELETE /api/run_save。波次检查点startWave 开头 maybeSaveRun() 拍快照(仅无尽+登录态);中途“放弃任务”不结算(星币/分数留快照);出击页 resume-slot 卡片续玩resumeSavedRun该波从头重打bossN 不偏移);新开无尽(不分难度)弹 o-unsaved 确认→settleSavedRun 直接结算旧档(星币入账+上报排行榜+删档);死亡/通关 endRun 删档。NET._rsQ 串行队列防删建竞态。闯关/rush 不产生云档
- server/api.js SHIP_KEYS 含全部5机体曾漏 asura 会致修罗云同步被白名单丢弃2026-08-06 修)

View File

@ -81,6 +81,7 @@
<!-- 出击准备 -->
<div class="ovl hide" id="o-setup">
<div class="h2">— 出击准备 —</div>
<div id="resume-slot"></div>
<div class="h3">选择模式</div>
<div class="pick" id="mode-pick">
<div class="pcard" data-m="stage"><div class="nm" style="margin-top:2px">闯关模式</div><div class="ds">6 大关卡<br>击破母巢核心<br>即获胜利</div></div>
@ -171,6 +172,18 @@
</div>
</div>
<!-- 结算未完成对局确认 -->
<div class="ovl hide" id="o-unsaved">
<div class="h2" style="color:var(--gold)">⚠ 存在未完成的对局</div>
<div class="panel" style="width:min(92%,320px)"><div class="stat-grid" id="unsaved-info"></div></div>
<div class="h3" style="color:var(--red)">开始新的无尽对局将直接结算该对局</div>
<div class="col">
<button class="btn danger" id="b-settle-go">结算旧局 · 开始新对局</button>
<button class="btn primary" id="b-unsaved-resume">▶ 继续旧对局</button>
<button class="btn" id="b-unsaved-cancel">取消</button>
</div>
</div>
<button id="tb-pause" style="display:none">| |</button>
</div>

View File

@ -129,6 +129,7 @@ const MUTS={
/* ================= 波次生成 ================= */
function startWave(){
G.wave++; G.waveT=0; G.spawning=[];
if(typeof maybeSaveRun==="function")maybeSaveRun(); // 无尽模式:波次检查点上传云端(内部已做模式/登录门控)
const n=tierN();
// 词缀
G.mutator=null;

View File

@ -5,6 +5,7 @@ const NET = {
stats: null, // {bossKills,endlessHigh,runs}
codexTotal: 63,
online: false,
runSave: undefined, // 无尽断点存档缓存undefined=未拉取null=无档object=有档
async api(path, method, body) {
let res;
@ -45,6 +46,7 @@ const NET = {
async logout() {
await this.api("/api/logout", "POST");
this.user = null; this.stats = null; this.online = false;
this.runSave = undefined; // 防同机下一账号继承云端断点存档
/* 防同机下一账号继承:图鉴/星币/机体/强化/纪录全部清空user 已置空persist 不会触发上传) */
SAVE.codex = {};
SAVE.coins = 0; SAVE.ships = ["falcon"]; SAVE.m = {};
@ -105,4 +107,33 @@ const NET = {
}
}).catch(() => { /* 网络失败静默:本地存档仍在 */ });
},
/* ---- ----
所有 run_save 操作串行入队 删除新建 乱序覆盖死亡后立刻开新局会竞态 */
_rsQ: Promise.resolve(),
_rsEnqueue(op) {
const p = this._rsQ.then(op).catch(() => ({ ok: false, data: {} }));
this._rsQ = p;
return p;
},
/* 拉取云端断点存档(同时刷新缓存) */
fetchRunSave() {
if (!this.user) { this.runSave = null; return Promise.resolve(null); }
return this._rsEnqueue(() => this.api("/api/run_save", "GET")).then((r) => {
this.runSave = r.ok && r.data ? (r.data.save || null) : null;
return this.runSave;
});
},
/* 上传断点快照fire-and-forget仅限无尽模式波次开始 */
saveRunSnapshot(snap) {
if (!this.user) return;
this.runSave = snap;
this._rsEnqueue(() => this.api("/api/run_save", "POST", { save: snap }));
},
/* 删除云端断点存档(结算/死亡后;返回 Promise 供需要时等待) */
deleteRunSave() {
if (!this.user) return Promise.resolve();
this.runSave = null;
return this._rsEnqueue(() => this.api("/api/run_save", "DELETE"));
},
};

View File

@ -1,13 +1,13 @@
"use strict";
/* ================= 界面流转 ================= */
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"];
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","o-unsaved"];
function show(id){for(const o of OVLS)$(o).classList.toggle("hide",o!==id);}
const SETUP={mode:"endless",ship:"falcon",diff:0}; // 出击默认无尽模式
function bestLine(){
const b=SAVE.best;
$("best-line").textContent=`无尽纪录 ${Math.max(b.e0,b.e1)} · 闯关进度 ${b.stage}/6`;
}
$("b-go").onclick=()=>{AU.init();show("o-setup");};
$("b-go").onclick=()=>{AU.init();show("o-setup");refreshRunSave();};
$("b-back1").onclick=()=>show("o-title");
$("b-back2").onclick=()=>show("o-title");
$("b-hangar").onclick=()=>{AU.init();buildShop();show("o-hangar");};
@ -27,11 +27,116 @@ $("cx-secret").onclick=()=>{
cxClicks=0;codexTestAll=!codexTestAll;
SFX.pick();buildCodex();
};
$("b-start").onclick=()=>startGame();
/* 开始出击:新开无尽对局(不分难度)前,若云端有未完成对局,先弹结算确认 */
$("b-start").onclick=async()=>{
if(SETUP.mode==="endless"&&typeof NET!=="undefined"&&NET.user){
if(NET.runSave===undefined)await NET.fetchRunSave();
if(NET.runSave){showUnsaved(NET.runSave);return;}
}
startGame();
};
/* ================= =================
每波开始拍快照上传startWave 调用中途退出=从当前波开头重打
该波内的得分/击杀不保留build与层数完整保留 */
function takeRunSnapshot(){
return {v:1,mode:"endless",diff:G.diff,ship:G.ship,
wave:G.wave,score:Math.round(G.score),coins:Math.round(G.coins),kills:G.kills,
level:G.level,xp:Math.round(G.xp),xpNeed:G.xpNeed,luck:G.luck,
weapons:Object.assign({},G.weapons),loadout:G.loadout.slice(),
subs:Object.assign({},G.subs),subLoadout:G.subLoadout.slice(),
drones:Object.assign({},G.drones),droneLoadout:G.droneLoadout.slice(),
u:Object.assign({},G.u),
lives:G.lives,maxHp:G.maxHp,hp:Math.round(G.hp),
shield:G.shield,od:Math.round(G.od),
bossN:G.bossN,bossKills:G.bossKills||0,time:G.time};
}
/* startWave 开头调用:仅无尽模式实战(演示/主页模拟/未登录跳过) */
function maybeSaveRun(){
if(!G||G.mode!=="endless"||G.demoSim)return;
if(typeof NET==="undefined"||!NET.user)return;
NET.saveRunSnapshot(takeRunSnapshot());
}
/* 出击页:拉取云端存档并渲染“继续对局”卡片 */
async function refreshRunSave(){
if(typeof NET==="undefined"||!NET.user){buildResumeSlot();return;}
await NET.fetchRunSave();
buildResumeSlot();
}
function buildResumeSlot(){
const box=$("resume-slot");if(!box)return;box.innerHTML="";
const s=typeof NET!=="undefined"?NET.runSave:null;
if(!s||!NET.user)return;
const d=document.createElement("div");d.className="panel";
d.style.cssText="border-color:var(--gold);margin-bottom:8px;padding:8px 10px";
d.innerHTML=`<div class="h3" style="color:var(--gold)">⏸ 未完成的无尽对局</div>
<div class="small" style="margin:2px 0 6px">WAVE ${s.wave} · ${s.diff?"困难":"普通"} · 得分 ${s.score} · ${SHIPS[s.ship]?SHIPS[s.ship].name:"猎鹰"}</div>`;
const b=document.createElement("button");
b.className="btn primary";b.style.minWidth="min(56vw,200px)";
b.textContent="▶ 继续对局";
b.onclick=()=>{SFX.pick();resumeSavedRun(s);};
d.appendChild(b);box.appendChild(d);
}
/* 从波次检查点重建对局:该波从头重打 */
function resumeSavedRun(s){
AU.init();if(AU.ctx&&AU.ctx.state==="suspended")AU.ctx.resume();
newRun("endless",s.ship,s.diff);
G.score=s.score;G.coins=s.coins;G.kills=s.kills;
G.level=s.level;G.xp=s.xp;G.xpNeed=s.xpNeed;G.luck=s.luck;
G.weapons=Object.assign({},s.weapons);G.loadout=s.loadout.slice();
G.subs=Object.assign({},s.subs);G.subLoadout=s.subLoadout.slice();
G.drones=Object.assign({},s.drones);G.droneLoadout=s.droneLoadout.slice();
G.u=Object.assign({fire:0,crit:0,shield:0,nano:0,mag:0,bounty:0,odcore:0,ghost:0},s.u);
G.lives=s.lives;G.maxHp=s.maxHp;G.hp=Math.min(s.hp,s.maxHp);
G.shield=Math.min(s.shield,shieldCap());G.od=s.od;
G.bossN=s.bossN;G.bossKills=s.bossKills;G.time=s.time;
G.wave=s.wave-1; // startWave 会 ++ 回这一波整波重打Boss波则重抽BossbossN未预增不偏移
UI="play";show("");acc=0;
MUS.start(false);
banner("继续对局 — WAVE "+s.wave,"#41e6ff",130);
startWave();
}
/* 结算未完成对局:星币入账+本地纪录+排行榜上报,返回删除云档的 Promise */
function settleSavedRun(s){
SAVE.coins+=s.coins;
SAVE.best["e"+s.diff]=Math.max(SAVE.best["e"+s.diff]||0,s.score);
persist();
if(typeof NET!=="undefined"&&NET.user){
NET.submitRun({mode:"endless",diff:s.diff,score:s.score,wave:s.wave,stage:1,
timeSec:Math.round((s.time||0)/60),bossKills:s.bossKills||0,win:false,
codex:Object.keys(SAVE.codex)});
return NET.deleteRunSave();
}
return Promise.resolve();
}
/* “将直接结算未完成的对局”确认弹窗 */
function showUnsaved(s){
$("unsaved-info").innerHTML=
`<span>抵达波次</span><b>WAVE ${s.wave}</b>
<span>难度</span><b>${s.diff?"":""}</b>
<span>当前得分</span><b>${s.score}</b>
<span>持有星币</span><b> ${s.coins}</b>`;
show("o-unsaved");
}
$("b-settle-go").onclick=async()=>{ // 结算旧档(等删除完成再开新局,防队列外竞态已由 NET 队列兜底)
const s=typeof NET!=="undefined"?NET.runSave:null;
if(s)await settleSavedRun(s);
startGame();
};
$("b-unsaved-resume").onclick=()=>{
const s=typeof NET!=="undefined"?NET.runSave:null;
if(s)resumeSavedRun(s);else show("o-setup");
};
$("b-unsaved-cancel").onclick=()=>show("o-setup");
if("ontouchstart" in window){const pb=$("tb-pause");pb.style.display="block";pb.onclick=()=>togglePause();}
$("b-resume").onclick=()=>togglePause();
$("b-restart").onclick=()=>{show("");restartSameRun();};
$("b-quit").onclick=()=>{bank();G=null;MUS.stop();UI="title";bestLine();show("o-title");};
/* 放弃任务:无尽模式且已有云端断点存档时【不结算】——星币/分数留在波次快照里,回基地可续玩;其余情况照旧结算 */
$("b-quit").onclick=()=>{
const keep=G&&G.mode==="endless"&&!G.demoSim&&typeof NET!=="undefined"&&NET.user&&NET.runSave;
if(!keep)bank();
G=null;MUS.stop();UI="title";bestLine();show("o-title");
};
/* 再来一局:按刚结束那一局自己的 mode/ship/diff 重开(修复 SETUP 漂移导致进错模式,如 rush/闯关死后进了无尽) */
function restartSameRun(){
if(!G){startGame();return;}
@ -263,6 +368,7 @@ function endRun(win){
if(UI==="over")return;
bank();MUS.stop();
submitRun(win);
if(G.mode==="endless"&&typeof NET!=="undefined"&&NET.user)NET.deleteRunSave(); // 对局真正结束:清除云端断点存档
UI="over";
$("over-title").textContent=win?"★ 任务完成 ★":"任务失败";
$("over-title").style.color=win?"#ffcf5c":"#ff5566";

View File

@ -5,7 +5,7 @@ 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 */
/* 图鉴合法 key 白名单(与 js/data.js 保持同步25主武+17副武+13僚机+8被动=63 */
const CODEX_KEYS = new Set([
"vulcan", "shotgun", "needle", "flame", "shotgun2", "tesla", "railgun", "deathray",
"crescent", "fission", "frost", "swarm", "bubble", "starfall", "boomerang", "singularity",
@ -19,7 +19,8 @@ const CODEX_KEYS = new Set([
const CODEX_TOTAL = CODEX_KEYS.size;
const RUN_MODES = new Set(["stage", "endless", "rush"]);
/* 存档白名单(与 js/data.js SHIPS、js/ui.js META 保持同步) */
const SHIP_KEYS = new Set(["falcon", "ghost", "bastion", "valkyrie"]);
const SHIP_KEYS = new Set(["falcon", "ghost", "bastion", "valkyrie", "asura"]);
const PASSIVE_KEYS = new Set(["fire", "crit", "shield", "nano", "mag", "bounty", "odcore", "ghost"]);
const META_MAX = { weapon: 2, armor: 2, hpup: 2, luck: 2, wallet: 3, mag2: 2, odstart: 1 };
const DEFAULT_STATE = { coins: 0, ships: ["falcon"], ship: "falcon", m: {}, best: { s0: 0, s1: 0, e0: 0, e1: 0, stage: 0 }, rush: {} };
@ -40,6 +41,10 @@ const qUpdStats = db.prepare(`INSERT INTO user_stats(user_id,boss_kills,endless_
const qGetState = db.prepare("SELECT state FROM player_state WHERE user_id=?");
const qPutState = db.prepare(`INSERT INTO player_state(user_id,state,updated_at) VALUES(?,?,?)
ON CONFLICT(user_id) DO UPDATE SET state=excluded.state, updated_at=excluded.updated_at`);
const qGetSave = db.prepare("SELECT payload, updated_at FROM run_saves WHERE user_id=?");
const qPutSave = db.prepare(`INSERT INTO run_saves(user_id,payload,updated_at) VALUES(?,?,?)
ON CONFLICT(user_id) DO UPDATE SET payload=excluded.payload, updated_at=excluded.updated_at`);
const qDelSave = db.prepare("DELETE FROM run_saves WHERE user_id=?");
function statsOf(userId) {
const s = qStats.get(userId) || { boss_kills: 0, endless_high: 0, runs: 0 };
@ -74,6 +79,69 @@ function vState(s) {
return { coins: s.coins, ships, ship, m, best, rush };
}
/* 无尽模式断点存档校验(波次检查点快照;装备 key 复用图鉴白名单) */
function vEquipMap(o) {
if (!o || typeof o !== "object" || Array.isArray(o)) return null;
const out = {};
for (const k of Object.keys(o).slice(0, 8))
if (CODEX_KEYS.has(k) && vInt(o[k], 1, 5)) out[k] = o[k];
return out;
}
function vLoadout(a, max, src) {
if (!Array.isArray(a)) return null;
return [...new Set(a.filter((k) => typeof k === "string" && src[k]))].slice(0, max);
}
function vRunSave(s) {
if (!s || typeof s !== "object" || Array.isArray(s)) return null;
if (s.mode !== "endless") return null; // 断点续玩仅限无尽模式
if (!SHIP_KEYS.has(s.ship)) return null;
if (!vInt(s.diff, 0, 1)) return null;
if (!vInt(s.wave, 1, 9999) || !vInt(s.score, 0, 1e8) || !vInt(s.coins, 0, 1e7)) return null;
if (!vInt(s.kills, 0, 1e6) || !vInt(s.level, 1, 999) || !vInt(s.xp, 0, 1e6) || !vInt(s.xpNeed, 1, 1e9)) return null;
if (!vInt(s.lives, 0, 99) || !vInt(s.maxHp, 1, 9999) || !vInt(s.hp, 0, 9999)) return null;
if (!vInt(s.shield, 0, 9) || !vInt(s.od, 0, 100)) return null;
if (!vInt(s.bossN, 0, 9999) || !vInt(s.bossKills, 0, 60) || !vInt(s.time, 0, 5184000)) return null;
const weapons = vEquipMap(s.weapons), subs = vEquipMap(s.subs), drones = vEquipMap(s.drones);
if (!weapons || !subs || !drones) return null;
const loadout = vLoadout(s.loadout, 5, weapons);
const subLoadout = vLoadout(s.subLoadout, 5, subs);
const droneLoadout = vLoadout(s.droneLoadout, 3, drones);
if (!loadout || !loadout.length || !subLoadout || !droneLoadout) return null;
const u = {};
if (s.u && typeof s.u === "object") for (const k of PASSIVE_KEYS)
if (vInt(s.u[k], 0, 5)) u[k] = s.u[k];
const luck = vInt(s.luck, 0, 2) ? s.luck : 0;
return { v: 1, mode: "endless", diff: s.diff, ship: s.ship, wave: s.wave, score: s.score,
coins: s.coins, kills: s.kills, level: s.level, xp: s.xp, xpNeed: s.xpNeed, luck,
weapons, loadout, subs, subLoadout, drones, droneLoadout, u,
lives: s.lives, maxHp: s.maxHp, hp: Math.min(s.hp, s.maxHp),
shield: s.shield, od: s.od, bossN: s.bossN, bossKills: s.bossKills, time: s.time };
}
/* ---------- GET /api/run_save无档返回 save:null ---------- */
function getRunSave(req, res, ctx) {
const r = qGetSave.get(ctx.user.id);
if (!r) return ctx.ok({ save: null });
let save = null;
try { save = JSON.parse(r.payload); } catch (e) { /* 损坏按无档处理 */ }
return ctx.ok({ save, updatedAt: r.updated_at });
}
/* ---------- POST /api/run_save每账号1栏位新档覆盖旧档 ---------- */
function putRunSave(req, res, ctx, body) {
if (!rateLimit("rsave:" + ctx.user.id, 40, 5 * 60000)) return ctx.fail(429, "同步太频繁");
const save = vRunSave(body && body.save);
if (!save) return ctx.fail(400, "非法对局存档");
qPutSave.run(ctx.user.id, JSON.stringify(save), Date.now());
return ctx.ok({ saved: true });
}
/* ---------- DELETE /api/run_save结算/死亡后清除) ---------- */
function delRunSave(req, res, ctx) {
qDelSave.run(ctx.user.id);
return ctx.ok({ deleted: true });
}
/* ---------- POST /api/register ---------- */
function register(req, res, ctx, body) {
if (!rateLimit("reg:" + ctx.ip, 10, 5 * 60000)) return ctx.fail(429, "操作太频繁,请稍后再试");
@ -181,6 +249,9 @@ const ROUTES = {
"GET /api/leaderboard": { fn: leaderboard, auth: true },
"POST /api/run": { fn: run, auth: true },
"POST /api/state": { fn: saveState, auth: true },
"GET /api/run_save": { fn: getRunSave, auth: true },
"POST /api/run_save": { fn: putRunSave, auth: true },
"DELETE /api/run_save": { fn: delRunSave, auth: true },
};
module.exports = { ROUTES, CODEX_TOTAL };

View File

@ -60,6 +60,11 @@ CREATE TABLE IF NOT EXISTS player_state(
state TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS run_saves(
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
payload TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
/* 定期清理过期 session */