From 9d048a2aa94a5080c5862a1f2a13db660a651a9b Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Thu, 6 Aug 2026 12:50:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=A0=E5=B0=BD=E6=A8=A1=E5=BC=8F=E6=96=AD?= =?UTF-8?q?=E7=82=B9=E7=BB=AD=E7=8E=A9=EF=BC=9A=E8=BF=9B=E5=BA=A6=E4=BA=91?= =?UTF-8?q?=E7=AB=AF=E5=AD=98=E6=A1=A3=EF=BC=8C=E4=B8=AD=E9=80=94=E9=80=80?= =?UTF-8?q?=E5=87=BA=E5=8F=AF=E7=BB=AD=E7=8E=A9=EF=BC=8C=E6=96=B0=E5=BC=80?= =?UTF-8?q?=E9=9C=80=E7=A1=AE=E8=AE=A4=E7=BB=93=E7=AE=97=E6=97=A7=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务端:run_saves 表(每账号1栏位)+ GET/POST/DELETE /api/run_save,vRunSave 白名单校验 - 波次检查点:startWave 拍快照上传;中途放弃任务不结算(星币/分数留快照);死亡删档 - 出击页卡片显示未完成对局并一键续玩(该波从头重打,bossN 不偏移) - 新开无尽(不分难度)弹窗确认→旧档按当前进度结算(星币入账+上报排行榜)再开新局 - NET._rsQ 串行队列防删建竞态;顺带修复 SHIP_KEYS 漏 asura 致修罗云同步丢失 --- AGENTS.md | 2 + star-raid/index.html | 13 +++++ star-raid/js/enemies.js | 1 + star-raid/js/net.js | 31 +++++++++++ star-raid/js/ui.js | 114 ++++++++++++++++++++++++++++++++++++++-- star-raid/server/api.js | 75 +++++++++++++++++++++++++- star-raid/server/db.js | 5 ++ 7 files changed, 235 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0ef9150..59acc88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,3 +101,5 @@ star-raid/ - 背景规则:render() 用 battleUI() 门控;**主页/基地背景=实战模拟**(home.js startHomeSim:随机3主Lv3+3副Lv3+2僚Lv2,G.homeSim 标记,复用 demoSim 自动驾驶刷怪,AU.simMute 静音,不伤/不计分/不上报,主循环看门狗自动重建);其余空闲=星空。离场必须 G=null(b-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 修) diff --git a/star-raid/index.html b/star-raid/index.html index 1034f32..8095c8f 100644 --- a/star-raid/index.html +++ b/star-raid/index.html @@ -81,6 +81,7 @@
— 出击准备 —
+
选择模式
闯关模式
6 大关卡
击破母巢核心
即获胜利
@@ -171,6 +172,18 @@
+ +
+
⚠ 存在未完成的对局
+
+
开始新的无尽对局将直接结算该对局
+
+ + + +
+
+ diff --git a/star-raid/js/enemies.js b/star-raid/js/enemies.js index c052795..277dbc4 100644 --- a/star-raid/js/enemies.js +++ b/star-raid/js/enemies.js @@ -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; diff --git a/star-raid/js/net.js b/star-raid/js/net.js index c9c4c1e..2baecca 100644 --- a/star-raid/js/net.js +++ b/star-raid/js/net.js @@ -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")); + }, }; diff --git a/star-raid/js/ui.js b/star-raid/js/ui.js index 703a3a9..3178987 100644 --- a/star-raid/js/ui.js +++ b/star-raid/js/ui.js @@ -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=`
⏸ 未完成的无尽对局
+
WAVE ${s.wave} · ${s.diff?"困难":"普通"} · 得分 ${s.score} · ${SHIPS[s.ship]?SHIPS[s.ship].name:"猎鹰"}
`; + 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波则重抽Boss(bossN未预增,不偏移) + 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= + `抵达波次WAVE ${s.wave} + 难度${s.diff?"困难":"普通"} + 当前得分${s.score} + 持有星币✦ ${s.coins}`; + 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"; diff --git a/star-raid/server/api.js b/star-raid/server/api.js index 952563e..df79f24 100644 --- a/star-raid/server/api.js +++ b/star-raid/server/api.js @@ -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 }; diff --git a/star-raid/server/db.js b/star-raid/server/db.js index 2b6c41a..bd3074c 100644 --- a/star-raid/server/db.js +++ b/star-raid/server/db.js @@ -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 */