图鉴测试模式+巡飞弹新武器+存档云同步+机库合并+默认无尽

①图鉴隐藏测试模式:连点标题'军'字10次开关,纯内存标志位全部军械可见可演示,不动SAVE.codex(persist/submitRun读它,动了会污染后端),刷新失效
②24件主武器说明文案全部替换(用户提供梗文案);演示页介绍单行显示、去掉类别前缀、删'实战模拟·自动驾驶'提示行
③新主武器'巡飞弹'(loiter):慢攻速追踪导弹(制导引擎新增maxSpd上限5保持中速),命中留火场(zones新kind:fire纯粒子火舌,无圆形区域)+分裂4枚小炸弹快爆;新精灵loms;CODEX_KEYS同步63
④存档云同步:星币/机体/选中机体/永久强化/本地纪录上服务端(player_state表+POST /api/state+vState白名单清洗);login/me/register响应带state;客户端persist()钩子防抖600ms自动上传;服务端整体替换本地防串号,登出全清;老账号首登自动上传本地存档迁移
⑤机体选择/购买从出击准备并入机库强化(buildShips);出击默认无尽模式
服务端已重启生效;/api/state全链路在独立测试库curl验证通过(合法推送/回读/非法拦截清洗/401)
This commit is contained in:
JOJO 2026-08-06 12:15:17 +08:00
parent e178bc9f1b
commit 9edc366cf9
13 changed files with 177 additions and 58 deletions

View File

@ -99,5 +99,5 @@ star-raid/
- 结算上报:`submitRun()`ui.js在 endRun 与 rush 胜利enemies.js killBoss两处调用`G.submitted` 防重 - 结算上报:`submitRun()`ui.js在 endRun 与 rush 胜利enemies.js killBoss两处调用`G.submitted` 防重
- 图鉴:服务端 `codex_unlocks` 为准,登陆时与本地 SAVE.codex 并集合并;未收集=剪影+???不可演示 - 图鉴:服务端 `codex_unlocks` 为准,登陆时与本地 SAVE.codex 并集合并;未收集=剪影+???不可演示
- 背景规则render() 用 battleUI() 门控;**主页/基地背景=实战模拟**home.js startHomeSim随机3主Lv3+3副Lv3+2僚Lv2G.homeSim 标记,复用 demoSim 自动驾驶刷怪AU.simMute 静音,不伤/不计分/不上报,主循环看门狗自动重建);其余空闲=星空。离场必须 G=nullb-home/b-quit/buildRush 已处理) - 背景规则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 白名单(现 24+17+13+8=62 - 新增装备时必须同步 `server/api.js` 的 CODEX_KEYS 白名单(现 25+17+13+8=63
- 星币/机库/机体仍存 localStorage 不同步v1 边界,见 DEPLOY.md - 星币/机体/选中机体/永久强化/本地纪录已上服务端POST /api/state → player_state 表,服务端权威整体替换,登出清空;客户端 persist() 钩子防抖600ms上传

View File

@ -53,7 +53,7 @@ server {
- 邮箱仅作唯一标识,**无验证邮件、无找回密码**(需 SMTP 服务,留 v2 - 邮箱仅作唯一标识,**无验证邮件、无找回密码**(需 SMTP 服务,留 v2
- **反作弊仅为基础校验**(数值范围/限流):纯客户端游戏无法杜绝作弊,有志者改内存照样能伪造高分。如需严肃竞技,要加服务端回放校验(大工程) - **反作弊仅为基础校验**(数值范围/限流):纯客户端游戏无法杜绝作弊,有志者改内存照样能伪造高分。如需严肃竞技,要加服务端回放校验(大工程)
- 星币/机库强化/机体解锁仍存浏览器 localStorage**不随账号同步**(跨设备冲突解决复杂,留 v2 - 星币/机体/永久强化/本地纪录已上服务端(`POST /api/state``player_state` 表2026-08-06登陆时服务端存档整体替换本地最后登录的设备覆盖云端无冲突合并音乐/音效开关等设置仍存 localStorage 不同步
- 排行榜 = 全服单榜,无分页/赛季 - 排行榜 = 全服单榜,无分页/赛季
## 数据维护 ## 数据维护

View File

@ -83,11 +83,9 @@
<div class="h2">— 出击准备 —</div> <div class="h2">— 出击准备 —</div>
<div class="h3">选择模式</div> <div class="h3">选择模式</div>
<div class="pick" id="mode-pick"> <div class="pick" id="mode-pick">
<div class="pcard sel" data-m="stage"><div class="nm" style="margin-top:2px">闯关模式</div><div class="ds">6 大关卡<br>击破母巢核心<br>即获胜利</div></div> <div class="pcard" data-m="stage"><div class="nm" style="margin-top:2px">闯关模式</div><div class="ds">6 大关卡<br>击破母巢核心<br>即获胜利</div></div>
<div class="pcard" data-m="endless"><div class="nm" style="margin-top:2px">无尽模式</div><div class="ds">无限波次<br>突变词缀<br>冲击最高分</div></div> <div class="pcard sel" data-m="endless"><div class="nm" style="margin-top:2px">无尽模式</div><div class="ds">无限波次<br>突变词缀<br>冲击最高分</div></div>
</div> </div>
<div class="h3">选择机体</div>
<div class="pick" id="ship-pick"></div>
<div class="h3">难度</div> <div class="h3">难度</div>
<div class="row" id="diff-pick"> <div class="row" id="diff-pick">
<button class="btn sel-diff primary" data-d="0" style="min-width:110px;padding:8px 14px">普通</button> <button class="btn sel-diff primary" data-d="0" style="min-width:110px;padding:8px 14px">普通</button>
@ -117,9 +115,12 @@
<!-- 机库 --> <!-- 机库 -->
<div class="ovl hide" id="o-hangar"> <div class="ovl hide" id="o-hangar">
<div class="h2">— 机库 · 永久强化 —</div> <div class="h2">— 机库 · 强化 —</div>
<div class="wallet">星币 ✦ <span id="wallet">0</span></div> <div class="wallet">星币 ✦ <span id="wallet">0</span></div>
<div class="shop" id="shop"></div> <div class="h3">机体(选择 / 解锁)</div>
<div class="pick" id="ship-pick"></div>
<div class="h3">永久强化</div>
<div class="shop" id="shop" style="overflow-y:auto;max-height:42%"></div>
<button class="btn" id="b-back2" style="margin-top:6px">返回</button> <button class="btn" id="b-back2" style="margin-top:6px">返回</button>
</div> </div>
@ -144,8 +145,8 @@
<!-- 图鉴 --> <!-- 图鉴 -->
<div class="ovl hide" id="o-codex"> <div class="ovl hide" id="o-codex">
<div class="h2">— 军械图鉴 —</div> <div class="h2"><span id="cx-secret" style="user-select:none"></span>械图鉴 —</div>
<div class="h3">已收集 <span id="codex-n">0</span>/<span id="codex-t">0</span> · 点击可实时演示</div> <div class="h3">已收集 <span id="codex-n">0</span>/<span id="codex-t">0</span> · 点击可实时演示<span id="codex-test" title="临时解锁全部军械查看/演示,不写入后端,刷新页面即失效" style="display:none;color:var(--gold)"> · ⚠ 测试模式</span></div>
<div id="codex" style="overflow-y:auto;max-height:72%;width:100%;display:flex;flex-direction:column;align-items:center"></div> <div id="codex" style="overflow-y:auto;max-height:72%;width:100%;display:flex;flex-direction:column;align-items:center"></div>
<button class="btn" id="b-back4">返回</button> <button class="btn" id="b-back4">返回</button>
</div> </div>
@ -154,8 +155,7 @@
<div class="ovl hide" id="o-demo" style="background:transparent;pointer-events:none;justify-content:space-between;overflow:hidden"> <div class="ovl hide" id="o-demo" style="background:transparent;pointer-events:none;justify-content:space-between;overflow:hidden">
<div style="margin-top:3%"> <div style="margin-top:3%">
<div class="h2" id="demo-name">演示</div> <div class="h2" id="demo-name">演示</div>
<div class="h3" id="demo-ds" style="max-width:88%;margin:0 auto"></div> <div class="h3" id="demo-ds" style="margin:0 auto;white-space:nowrap"></div>
<div class="h3" style="color:var(--dim);margin-top:4px">实战模拟 · 自动驾驶 · 触屏/方向键可接管</div>
</div> </div>
<button class="btn" id="b-back5" style="pointer-events:auto;margin-bottom:4%">返回图鉴</button> <button class="btn" id="b-back5" style="pointer-events:auto;margin-bottom:4%">返回图鉴</button>
</div> </div>

View File

@ -93,7 +93,7 @@ function updateBullets(){
if(b.softH){ // 星云奥秘:缓慢转向,不加速 if(b.softH){ // 星云奥秘:缓慢转向,不加速
const na=c+clamp(d,-0.05,0.05),s=Math.hypot(b.vx,b.vy); const na=c+clamp(d,-0.05,0.05),s=Math.hypot(b.vx,b.vy);
b.vx=Math.cos(na)*s;b.vy=Math.sin(na)*s; b.vx=Math.cos(na)*s;b.vy=Math.sin(na)*s;
}else{const na=c+clamp(d,-0.16,0.16),s=Math.min(6,Math.hypot(b.vx,b.vy)+0.08); }else{const na=c+clamp(d,-0.16,0.16),s=Math.min(b.maxSpd||6,Math.hypot(b.vx,b.vy)+0.08);
b.vx=Math.cos(na)*s;b.vy=Math.sin(na)*s;}}} b.vx=Math.cos(na)*s;b.vy=Math.sin(na)*s;}}}
if(b.back){b.bt--;if(b.bt<=0&&b.vy<0){b.vy*=-1;b.vx*=-0.4;}} if(b.back){b.bt--;if(b.bt<=0&&b.vy<0){b.vy*=-1;b.vx*=-0.4;}}
if(b.curve){const c=Math.atan2(b.vy,b.vx)+b.curve,s=Math.hypot(b.vx,b.vy)||0.1; if(b.curve){const c=Math.atan2(b.vy,b.vx)+b.curve,s=Math.hypot(b.vx,b.vy)||0.1;
@ -232,7 +232,7 @@ function collide(){
areaDamage(b.x,b.y,18,b.dmg*0.5); areaDamage(b.x,b.y,18,b.dmg*0.5);
explodeFx(b.x,b.y,"plasma",0.8);} explodeFx(b.x,b.y,"plasma",0.8);}
SFX.hit(); SFX.hit();
if(b.dead){if(b.t==="sg"||b.t==="ho"||b.t==="bb"||b.t==="am"||b.t==="tb"||b.t==="tb2"||b.t==="dw"||b.t==="ph"||b.t==="nb"||b.t==="ex")explodeOnce(b);break;} if(b.dead){if(b.t==="sg"||b.t==="ho"||b.t==="bb"||b.t==="am"||b.t==="tb"||b.t==="tb2"||b.t==="dw"||b.t==="ph"||b.t==="nb"||b.t==="ex"||b.t==="lm"||b.t==="lb")explodeOnce(b);break;}
} }
} }
if(!b.dead&&G.boss){ if(!b.dead&&G.boss){
@ -240,7 +240,7 @@ function collide(){
if(bossHit(b.x,b.y,b.r)){ if(bossHit(b.x,b.y,b.r)){
if(b.pierce){if(!b.hits)b.hits={};if(!b.hits.boss){b.hits.boss=1;hurtBoss(b.dmg);}} if(b.pierce){if(!b.hits)b.hits={};if(!b.hits.boss){b.hits.boss=1;hurtBoss(b.dmg);}}
else{b.dead=true; else{b.dead=true;
if(b.t==="sg"||b.t==="ho"||b.t==="bb"||b.t==="am"||b.t==="tb"||b.t==="tb2"||b.t==="dw"||b.t==="ph"||b.t==="nb"||b.t==="ex")explodeOnce(b); if(b.t==="sg"||b.t==="ho"||b.t==="bb"||b.t==="am"||b.t==="tb"||b.t==="tb2"||b.t==="dw"||b.t==="ph"||b.t==="nb"||b.t==="ex"||b.t==="lm"||b.t==="lb")explodeOnce(b);
else hurtBoss(b.dmg);} else hurtBoss(b.dmg);}
SFX.hit(); SFX.hit();
} }

View File

@ -24,7 +24,8 @@ function loadSave(){
SAVE.m=SAVE.m||{}; SAVE.ships=SAVE.ships||["falcon"]; SAVE.best=SAVE.best||{s0:0,s1:0,e0:0,e1:0,stage:0}; SAVE.m=SAVE.m||{}; SAVE.ships=SAVE.ships||["falcon"]; SAVE.best=SAVE.best||{s0:0,s1:0,e0:0,e1:0,stage:0};
SAVE.codex=SAVE.codex||{}; SAVE.codex=SAVE.codex||{};
} }
function persist(){try{localStorage.setItem(SAVE_KEY,JSON.stringify(SAVE));}catch(e){}} function persist(){try{localStorage.setItem(SAVE_KEY,JSON.stringify(SAVE));}catch(e){}
if(typeof NET!=="undefined")NET.pushState();} // 登录态下云端同步NET 内防抖;未登录自动跳过)
loadSave(); loadSave();
/* ---------------- ---------------- /* ---------------- ----------------

View File

@ -251,6 +251,16 @@ function explodeBullet(b){
areaDamage(b.x,b.y,b.aoe||26,b.dmg); areaDamage(b.x,b.y,b.aoe||26,b.dmg);
explodeFx(b.x,b.y,"executor",1); explodeFx(b.x,b.y,"executor",1);
SFX.boom(true);G.shake=Math.max(G.shake,8); SFX.boom(true);G.shake=Math.max(G.shake,8);
}else if(b.t==="lm"){ // 巡飞弹原地火场kind:fire+ 分裂4枚小炸弹很快起爆
areaDamage(b.x,b.y,b.zone||20,b.dmg);
G.zones.push({x:b.x,y:b.y,r:b.zone||20,dps:b.dmg*0.12,life:b.fire||95,col:"#ff8a2e",kind:"fire"});
for(let i=0;i<4;i++){const la=-Math.PI/2+(i-1.5)*0.85+rnd(-0.2,0.2);
pbul(b.x,b.y,Math.cos(la)*2.8,Math.sin(la)*2.8,b.dmg*0.35,{t:"lb",r:2,bt:12+rndi(0,6),aoe:12,trailCol:"#ff9a3d"});}
explodeFx(b.x,b.y,"fire",1.2);
SFX.boom(false);G.shake=Math.max(G.shake,3);
}else if(b.t==="lb"){ // 巡飞弹小炸弹:小范围快爆
areaDamage(b.x,b.y,b.aoe||12,b.dmg);
explodeFx(b.x,b.y,"fire",0.6);SFX.boom(false);
}else if(b.t==="tb"||b.t==="tb2"){ }else if(b.t==="tb"||b.t==="tb2"){
areaDamage(b.x,b.y,b.aoe||b.zone||24,b.dmg); areaDamage(b.x,b.y,b.aoe||b.zone||24,b.dmg);
explodeFx(b.x,b.y,"boom",1); explodeFx(b.x,b.y,"boom",1);
@ -358,6 +368,11 @@ function fireWeapons(){
for(let i=0;i<st.n;i++)pbul(p.x+(i-(st.n-1)/2)*6,p.y-4,(i-(st.n-1)/2)*0.9,-st.spd,st.dmg*wdm,{t:"m",spr:"msl2",homing:true,r:3,aoe:st.aoe,trailCol:"#e8e8e8"}); for(let i=0;i<st.n;i++)pbul(p.x+(i-(st.n-1)/2)*6,p.y-4,(i-(st.n-1)/2)*0.9,-st.spd,st.dmg*wdm,{t:"m",spr:"msl2",homing:true,r:3,aoe:st.aoe,trailCol:"#e8e8e8"});
SFX.shoot();} SFX.shoot();}
break; break;
case "loiter": // 巡飞弹:慢攻速+追踪中速上限5命中留火场+分裂4枚快爆小炸弹
if(G.wt[wk]<=0){G.wt[wk]=Math.max(30,st.int/fm);
pbul(p.x,p.y-10,0,-st.spd,st.dmg*wdm,{t:"lm",spr:"loms",r:4,zone:st.zone,fire:st.fire,homing:true,maxSpd:5,trailCol:"#ff8a2e"});
SFX.shoot();}
break;
case "bubble": case "bubble":
if(G.wt[wk]<=0){G.wt[wk]=Math.max(6,st.int/fm); if(G.wt[wk]<=0){G.wt[wk]=Math.max(6,st.int/fm);
pbul(p.x,p.y-10,rnd(-0.3,0.3),-st.spd,st.dmg*wdm,{t:"bb",spr:"bub",aoe:st.aoe,pierce:true,life:150,r:6});} pbul(p.x,p.y-10,rnd(-0.3,0.3),-st.spd,st.dmg*wdm,{t:"bb",spr:"bub",aoe:st.aoe,pierce:true,life:150,r:6});}

View File

@ -12,57 +12,59 @@ const SHIPS={
asura:{name:"修罗",spr:"ship4",cost:5000,trait:"暴击+15% · 伤害+10% · 命-1", asura:{name:"修罗",spr:"ship4",cost:5000,trait:"暴击+15% · 伤害+10% · 命-1",
mod:{spd:1,dmg:1.1,lives:-1,crit:0.15,dodge:0,hitR:0,mag:1,bombs:1,shield:0,drone:0,droneBoost:1}} mod:{spd:1,dmg:1.1,lives:-1,crit:0.15,dodge:0,hitR:0,mag:1,bombs:1,shield:0,drone:0,droneBoost:1}}
}; };
/* ================= 21 ================= /* ================= 25 =================
beh: 行为引擎类型 lvl(w): 该等级的战斗参数 */ beh: 行为引擎类型 lvl(w): 该等级的战斗参数 */
const WEAPONS={ const WEAPONS={
vulcan:{name:"火神机炮",spr:"trcr",rare:0,beh:"streams",ds:"金色曳光弹雨,可靠的老伙计", vulcan:{name:"火神机炮",spr:"trcr",rare:0,beh:"streams",ds:"!?神神?!",
lvl:w=>({n:1+Math.floor(w/2),dmg:7*(1+.22*(w-1)),int:8,spd:5.4,gap:6})}, lvl:w=>({n:1+Math.floor(w/2),dmg:7*(1+.22*(w-1)),int:8,spd:5.4,gap:6})},
shotgun:{name:"超级霰弹",spr:"pelt",rare:0,beh:"streams",ds:"弹丸只从两翼倾泻,中央中空", shotgun:{name:"超级霰弹",spr:"pelt",rare:0,beh:"streams",ds:"“友谊颗粒”",
lvl:w=>({n:5+2*w,dmg:4.2*(1+.18*(w-1)),int:22,spd:5.5,fan:0.5,gap:2,skipMid:1})}, lvl:w=>({n:5+2*w,dmg:4.2*(1+.18*(w-1)),int:22,spd:5.5,fan:0.5,gap:2,skipMid:1})},
needle:{name:"蜂针刺弹",spr:"ndl",rare:0,beh:"streams",ds:"淬毒细针,命中后持续蚀甲", needle:{name:"蜂针刺弹",spr:"ndl",rare:0,beh:"streams",ds:"这可是吐了剧毒的飞刀",
lvl:w=>({n:1+Math.floor(w/3),dmg:2.6*(1+.2*(w-1)),int:3,spd:7,gap:4,poison:100})}, lvl:w=>({n:1+Math.floor(w/3),dmg:2.6*(1+.2*(w-1)),int:3,spd:7,gap:4,poison:100})},
flame:{name:"火焰喷射器",icon:"flame",rare:0,beh:"flame",ds:"周期性喷吐的宽幅烈焰,近身湮灭", flame:{name:"火焰喷射器",icon:"flame",rare:0,beh:"flame",ds:"让火焰净化一切",
lvl:w=>({n:3+Math.floor(w/2),dmg:1.1*(1+.22*(w-1)),range:70+8*w,burst:34+4*w,cd:Math.max(46,74-5*w)})}, lvl:w=>({n:3+Math.floor(w/2),dmg:1.1*(1+.22*(w-1)),range:70+8*w,burst:34+4*w,cd:Math.max(46,74-5*w)})},
shotgun2:{name:"霜冻霰弹",spr:"shard",rare:1,beh:"streams",ds:"冰晶霰弹,大面积冻结减速", shotgun2:{name:"霜冻霰弹",spr:"shard",rare:1,beh:"streams",ds:"冷冷冷",
lvl:w=>({n:3+w,dmg:3*(1+.18*(w-1)),int:23,spd:5.2,fan:0.42,gap:2,slow:80+25*w})}, lvl:w=>({n:3+w,dmg:3*(1+.18*(w-1)),int:23,spd:5.2,fan:0.42,gap:2,slow:80+25*w})},
tesla:{name:"电弧发生器",icon:"chain",rare:1,beh:"tesla",ds:"自动电击周围敌人,无处可逃", tesla:{name:"电弧发生器",icon:"chain",rare:1,beh:"tesla",ds:"?!电电!?",
lvl:w=>({n:1+Math.floor(w/2),dmg:8*(1+.25*(w-1)),int:44-2*w,range:110})}, lvl:w=>({n:1+Math.floor(w/2),dmg:8*(1+.25*(w-1)),int:44-2*w,range:110})},
railgun:{name:"电磁炮",icon:"rail",rare:2,beh:"rail",ds:"蓄力磁轨贯穿敌阵,命中留下电浆区", railgun:{name:"电磁炮",icon:"rail",rare:2,beh:"rail",ds:"带轮子的垃圾桶",
lvl:w=>({dmg:55*(1+.3*(w-1)),charge:120-10*w,spd:11,zone:24+4*w})}, lvl:w=>({dmg:55*(1+.3*(w-1)),charge:120-10*w,spd:11,zone:24+4*w})},
deathray:{name:"死光",icon:"laser",rare:2,beh:"deathray",ds:"蓄力后自动锁敌的死亡射线", deathray:{name:"死光",icon:"laser",rare:2,beh:"deathray",ds:"记得带镜子",
lvl:w=>({dps:0.9*(1+.28*(w-1)),charge:160-12*w,beam:55,width:5+2*w})}, lvl:w=>({dps:0.9*(1+.28*(w-1)),charge:160-12*w,beam:55,width:5+2*w})},
crescent:{name:"月牙斩",spr:"cres",rare:1,beh:"crescent",ds:"巨型旋刃缓慢斩出,贯穿敌阵", crescent:{name:"月牙斩",spr:"cres",rare:1,beh:"crescent",ds:"青豆?",
lvl:w=>({dmg:44*(1+.24*(w-1)),int:120,spd:3.4})}, lvl:w=>({dmg:44*(1+.24*(w-1)),int:120,spd:3.4})},
fission:{name:"分裂弹",spr:"fiss",rare:1,beh:"fission",ds:"飞行一段距离后炸裂为散射弹幕", fission:{name:"分裂弹",spr:"fiss",rare:1,beh:"fission",ds:"四阶村长",
lvl:w=>({dmg:6*(1+.22*(w-1)),int:20,spd:5.2,split:3+Math.floor(w/2),splitAt:26})}, lvl:w=>({dmg:6*(1+.22*(w-1)),int:20,spd:5.2,split:3+Math.floor(w/2),splitAt:26})},
frost:{name:"冰冻枪",spr:"icelance",rare:1,beh:"frost",ds:"贯穿冰枪,命中将敌人彻底冻结", frost:{name:"冰冻枪",spr:"icelance",rare:1,beh:"frost",ds:"冷冷冷冷",
lvl:w=>({dmg:6*(1+.2*(w-1)),int:16,spd:6,freeze:70+25*w})}, lvl:w=>({dmg:6*(1+.2*(w-1)),int:16,spd:6,freeze:70+25*w})},
swarm:{name:"蜂巢导弹",spr:"msl2",rare:1,beh:"homing",ds:"自动追踪的导弹蜂群,命中爆裂", swarm:{name:"蜂巢导弹",spr:"msl2",rare:1,beh:"homing",ds:"不可用于对付肉山",
lvl:w=>({n:1+w,dmg:2.2*(1+.2*(w-1)),int:22,spd:3.2,aoe:10})}, lvl:w=>({n:1+w,dmg:2.2*(1+.2*(w-1)),int:22,spd:3.2,aoe:10})},
bubble:{name:"重力泡泡",spr:"bub",rare:1,beh:"bubble",ds:"缓慢漂浮的大泡泡,爆裂冲击", bubble:{name:"重力泡泡",spr:"bub",rare:1,beh:"bubble",ds:"天呐!是海绵宝宝",
lvl:w=>({dmg:18*(1+.25*(w-1)),int:26,spd:1.4,aoe:30+4*w})}, lvl:w=>({dmg:18*(1+.25*(w-1)),int:26,spd:1.4,aoe:30+4*w})},
starfall:{name:"流星火雨",spr:"met",rare:2,beh:"starfall",ds:"流星自脚下逆冲而起,轰炸前方空域", starfall:{name:"流星火雨",spr:"met",rare:2,beh:"starfall",ds:"天火",
lvl:w=>({n:3+Math.floor(w/2),dmg:16*(1+.25*(w-1)),int:150-8*w,aoe:16})}, lvl:w=>({n:3+Math.floor(w/2),dmg:16*(1+.25*(w-1)),int:150-8*w,aoe:16})},
boomerang:{name:"回旋镖",spr:"boomer",rare:0,beh:"boom",ds:"亮蓝回旋刃,远射贯穿后回旋返航", boomerang:{name:"回旋镖",spr:"boomer",rare:0,beh:"boom",ds:"但愿不会打到自己",
lvl:w=>({n:1+Math.floor(w/2),dmg:7*(1+.22*(w-1)),int:21,spd:4.8})}, lvl:w=>({n:1+Math.floor(w/2),dmg:7*(1+.22*(w-1)),int:21,spd:4.8})},
singularity:{name:"奇点炮",spr:"sing",rare:2,beh:"sing",ds:"发射微型黑洞,牵引后剧烈坍缩", singularity:{name:"奇点炮",spr:"sing",rare:2,beh:"sing",ds:"雷工蛋?",
lvl:w=>({dmg:40*(1+.3*(w-1)),int:200,pull:88,aoe:62,fuse:160})}, // 牵引/爆炸范围与射程增强 lvl:w=>({dmg:40*(1+.3*(w-1)),int:200,pull:88,aoe:62,fuse:160})}, // 牵引/爆炸范围与射程增强
wavecannon:{name:"波动炮",spr:"waveb",rare:2,beh:"wave",ds:"蓄力斩出贯穿敌阵的剑气弧光", wavecannon:{name:"波动炮",spr:"waveb",rare:2,beh:"wave",ds:"炼狱无双波动炮",
lvl:w=>({dmg:32*(1+.3*(w-1)),charge:100-8*w,spd:5,width:14+6*w})}, lvl:w=>({dmg:32*(1+.3*(w-1)),charge:100-8*w,spd:5,width:14+6*w})},
holy:{name:"圣光弹",spr:"holyo",rare:2,beh:"holy",ds:"爆裂为旋转生长的圣光十字", holy:{name:"圣光弹",spr:"holyo",rare:2,beh:"holy",ds:"哇哦",
lvl:w=>({dmg:13*(1+.25*(w-1)),int:65,spd:3,len:16+3*w})}, lvl:w=>({dmg:13*(1+.25*(w-1)),int:65,spd:3,len:16+3*w})},
thundercall:{name:"落雷术",spr:"thnd",rare:1,beh:"thunder",ds:"呼唤天雷轰击前方区域", thundercall:{name:"落雷术",spr:"thnd",rare:1,beh:"thunder",ds:"何方道友在此渡劫",
lvl:w=>({n:2+Math.floor(w/2),dmg:12*(1+.25*(w-1)),int:90-4*w,aoe:13})}, lvl:w=>({n:2+Math.floor(w/2),dmg:12*(1+.25*(w-1)),int:90-4*w,aoe:13})},
arcshu:{name:"电弧手里剑",spr:"shuri",rare:2,beh:"shuriken",ds:"旋转雷刃一路放电,贯穿全场", arcshu:{name:"电弧手里剑",spr:"shuri",rare:2,beh:"shuriken",ds:"这个很熟悉了吧",
lvl:w=>({dmg:6*(1+.22*(w-1)),int:95-4*w,spd:2.2,zapDmg:5*(1+.25*(w-1)),pulse:7})}, // 电弧释放速度×3 lvl:w=>({dmg:6*(1+.22*(w-1)),int:95-4*w,spd:2.2,zapDmg:5*(1+.25*(w-1)),pulse:7})}, // 电弧释放速度×3
volt:{name:"电能机炮",spr:"voltb",rare:1,beh:"volt",ds:"高频电能弹,命中后电弧连锁跳跃", volt:{name:"电能机炮",spr:"voltb",rare:1,beh:"volt",ds:"疑似电大",
lvl:w=>({n:1+Math.floor(w/3),dmg:3.2*(1+.2*(w-1)),int:5,spd:6.2,gap:5,chain:2+Math.floor(w/2)})}, lvl:w=>({n:1+Math.floor(w/3),dmg:3.2*(1+.2*(w-1)),int:5,spd:6.2,gap:5,chain:2+Math.floor(w/2)})},
dawn:{name:"黎明重炮",spr:"dawnsh",rare:2,beh:"dawn",ds:"长蓄力重炮,一炮轰出黎明", dawn:{name:"黎明重炮",spr:"dawnsh",rare:2,beh:"dawn",ds:"祖宗发射器",
lvl:w=>({dmg:70*(1+.3*(w-1)),charge:170-12*w,spd:5,aoe:30+3*w})}, lvl:w=>({dmg:70*(1+.3*(w-1)),charge:170-12*w,spd:5,aoe:30+3*w})},
proto:{name:"协议武装",spr:"protb",rare:2,beh:"protocol",ds:"青蓝连发压制,周期性蓄能重炮", proto:{name:"协议武装",spr:"protb",rare:2,beh:"protocol",ds:"搜索,歼灭",
lvl:w=>({dmg:3*(1+.2*(w-1)),int:6,spd:6.5,heavy:120-8*w,hdmg:36*(1+.28*(w-1)),haoe:26+3*w})}, lvl:w=>({dmg:3*(1+.2*(w-1)),int:6,spd:6.5,heavy:120-8*w,hdmg:36*(1+.28*(w-1)),haoe:26+3*w})},
nebula:{name:"星云奥秘",spr:"neb",rare:2,beh:"nebula",ds:"缓慢追踪的奥术漩涡,命中绽放群星", nebula:{name:"星云奥秘",spr:"neb",rare:2,beh:"nebula",ds:"字面意思",
lvl:w=>({dmg:12*(1+.25*(w-1)),int:100-5*w,spd:1.6,stars:10+2*w})} lvl:w=>({dmg:12*(1+.25*(w-1)),int:100-5*w,spd:1.6,stars:10+2*w})},
loiter:{name:"巡飞弹",spr:"loms",rare:1,beh:"loiter",ds:"堵桥来",
lvl:w=>({dmg:22*(1+.25*(w-1)),int:68-5*w,spd:4.2,zone:20+3*w,fire:95+15*w})}
}; };
const MAINS=Object.keys(WEAPONS); const MAINS=Object.keys(WEAPONS);
/* ================= 18 副武器 ================= */ /* ================= 18 副武器 ================= */

View File

@ -3,7 +3,7 @@
const NET = { const NET = {
user: null, // {username,email} user: null, // {username,email}
stats: null, // {bossKills,endlessHigh,runs} stats: null, // {bossKills,endlessHigh,runs}
codexTotal: 62, codexTotal: 63,
online: false, online: false,
async api(path, method, body) { async api(path, method, body) {
@ -45,7 +45,12 @@ const NET = {
async logout() { async logout() {
await this.api("/api/logout", "POST"); await this.api("/api/logout", "POST");
this.user = null; this.stats = null; this.online = false; this.user = null; this.stats = null; this.online = false;
SAVE.codex = {}; persist(); // 防同机下一账号继承图鉴 /* 防同机下一账号继承:图鉴/星币/机体/强化/纪录全部清空user 已置空persist 不会触发上传) */
SAVE.codex = {};
SAVE.coins = 0; SAVE.ships = ["falcon"]; SAVE.m = {};
SAVE.best = { s0: 0, s1: 0, e0: 0, e1: 0, stage: 0 }; SAVE.rush = {};
if (typeof SETUP !== "undefined") SETUP.ship = "falcon";
persist();
}, },
/* /*
@ -53,11 +58,38 @@ const NET = {
_apply(d) { _apply(d) {
this.user = d.user; this.user = d.user;
this.stats = d.stats; this.stats = d.stats;
this.codexTotal = d.codexTotal || 62; this.codexTotal = d.codexTotal || 63;
this.online = true; this.online = true;
SAVE.codex = {}; SAVE.codex = {};
if (Array.isArray(d.codex)) for (const k of d.codex) SAVE.codex[k] = true; if (Array.isArray(d.codex)) for (const k of d.codex) SAVE.codex[k] = true;
/* 服务端存档权威:整体替换本地(防串号,与图鉴同理);替换期间禁止回传 */
this._mutePush = true;
if (d.state) {
SAVE.coins = d.state.coins;
SAVE.ships = d.state.ships.slice();
SAVE.m = Object.assign({}, d.state.m);
SAVE.best = Object.assign({ s0: 0, s1: 0, e0: 0, e1: 0, stage: 0 }, d.state.best);
SAVE.rush = Object.assign({}, d.state.rush);
if (d.state.ship && SAVE.ships.includes(d.state.ship) && typeof SETUP !== "undefined") SETUP.ship = d.state.ship;
}
persist(); persist();
this._mutePush = false;
if (!d.state) this.pushState(); // 服务端无存档(老账号首登):把本地存档上传初始化
},
/* 存档云同步(防抖 600ms星币/机体/选中机体/永久强化/本地纪录。任何 persist 都会触发 */
_stateT: null,
pushState() {
if (!this.user || this._mutePush) return;
clearTimeout(this._stateT);
this._stateT = setTimeout(() => {
if (!this.user) return;
this.api("/api/state", "POST", { state: {
coins: SAVE.coins, ships: SAVE.ships,
ship: typeof SETUP !== "undefined" ? SETUP.ship : "falcon",
m: SAVE.m, best: SAVE.best, rush: SAVE.rush || {},
} }).catch(() => { /* 网络失败静默:本地存档仍在 */ });
}, 600);
}, },
/* 结算上报fire-and-forget成功后回写最新统计 */ /* 结算上报fire-and-forget成功后回写最新统计 */

View File

@ -329,6 +329,11 @@ function render(){
if(G.time%9===0)G.parts.push({x:z.x+rnd(-z.r/2,z.r/2),y:z.y+rnd(-z.r/2,z.r/2),vx:0,vy:-0.2,life:10,col:"#43e8ff",sz:1}); if(G.time%9===0)G.parts.push({x:z.x+rnd(-z.r/2,z.r/2),y:z.y+rnd(-z.r/2,z.r/2),vx:0,vy:-0.2,life:10,col:"#43e8ff",sz:1});
continue; continue;
} }
if(z.kind==="fire"){ // 巡飞弹火场:纯粒子火舌上卷+黑烟(无圆形区域)
if(G.time%3===0)for(let i=0;i<2;i++)G.parts.push({x:z.x+rnd(-1,1)*z.r*0.7,y:z.y+rnd(-1,1)*z.r*0.5,vx:rnd(-0.25,0.25),vy:rnd(-1.3,-0.5),life:rndi(12,22),col:pick(["#ff8a2e","#ffc64a","#ff4a1f"]),sz:rndi(1,2)});
if(G.time%8===0)G.parts.push({x:z.x+rnd(-1,1)*z.r*0.5,y:z.y-z.r*0.3,vx:rnd(-0.15,0.15),vy:rnd(-0.7,-0.3),life:rndi(24,38),col:"#5a5a6a",sz:2});
continue;
}
ctx.globalAlpha=0.16;ctx.fillStyle=z.col; ctx.globalAlpha=0.16;ctx.fillStyle=z.col;
ctx.beginPath();ctx.arc(z.x,z.y,z.r,0,TAU);ctx.fill(); ctx.beginPath();ctx.arc(z.x,z.y,z.r,0,TAU);ctx.fill();
ctx.globalAlpha=0.5+Math.sin(G.time*0.1)*0.2;ctx.strokeStyle=z.col; ctx.globalAlpha=0.5+Math.sin(G.time*0.1)*0.2;ctx.strokeStyle=z.col;

View File

@ -148,6 +148,7 @@ SPR.icelance=bake(["...K...","..KCK..","..KCK..",".KCWK.",".KCCK.",".KCCK.","..K
SPR.msl2=bake(["..K..",".KYK.","KYGYK","KYGYK",".KGK.","K.K.K"],{K:"#2a240d",Y:"#ffe95c",G:"#8ca86a"}); // 蜂巢小导弹 SPR.msl2=bake(["..K..",".KYK.","KYGYK","KYGYK",".KGK.","K.K.K"],{K:"#2a240d",Y:"#ffe95c",G:"#8ca86a"}); // 蜂巢小导弹
SPR.amsl=bake(["...K...","..KRK..",".KRRRK.","KRWRKRK",".KRRRK.",".KRRRK.","K.KRK.K"],{K:"#241a08",R:"#e0763e",W:"#fff4d9"}); // 空爆重型导弹 SPR.amsl=bake(["...K...","..KRK..",".KRRRK.","KRWRKRK",".KRRRK.",".KRRRK.","K.KRK.K"],{K:"#241a08",R:"#e0763e",W:"#fff4d9"}); // 空爆重型导弹
SPR.cmsl=bake(["...KK...","..KRRK..",".KRRRRK.","KRWRRWRK",".KRRRRK.",".KRRRRK.","..KRRK..",".K.KK.K."],{K:"#1a0d0d",R:"#e0393e",W:"#ffd23d"}); // 斩首巡航导弹 SPR.cmsl=bake(["...KK...","..KRRK..",".KRRRRK.","KRWRRWRK",".KRRRRK.",".KRRRRK.","..KRRK..",".K.KK.K."],{K:"#1a0d0d",R:"#e0393e",W:"#ffd23d"}); // 斩首巡航导弹
SPR.loms=bake(["...W...","..KWK..",".KOOOK.","KKOOOKK","..KOK..","..KOK..","...K..."],{K:"#26160d",W:"#fff4d9",O:"#ff8a2e"}); // 巡飞弹:白尖橙体+翼横杆与msl2/cmsl/amsl区分
SPR.broad=bake([".KKK.","KBBBK","KBWBK","KBBBK",".KKK."],{K:"#241a08",B:"#c8923e",W:"#fff4d9"}); // 侧舷炮弹 SPR.broad=bake([".KKK.","KBBBK","KBWBK","KBBBK",".KKK."],{K:"#241a08",B:"#c8923e",W:"#fff4d9"}); // 侧舷炮弹
SPR.flare=bake(["..KK..",".KWWK.","KWWWWK","KWWWWK",".KWWK.","..KK.."],{K:"#33240a",W:"#ffd23d"}); // 热诱弹 SPR.flare=bake(["..KK..",".KWWK.","KWWWWK","KWWWWK",".KWWK.","..KK.."],{K:"#33240a",W:"#ffd23d"}); // 热诱弹
SPR.sticky=bake([".KKKK.","KGKKGK","KGRRGK","KGRRGK","KGKKGK",".KKKK."],{K:"#142a0d",G:"#5cff9d",R:"#ff5566"}); // 粘着炸弹 SPR.sticky=bake([".KKKK.","KGKKGK","KGRRGK","KGRRGK","KGKKGK",".KKKK."],{K:"#142a0d",G:"#5cff9d",R:"#ff5566"}); // 粘着炸弹

View File

@ -2,12 +2,12 @@
/* ================= 界面流转 ================= */ /* ================= 界面流转 ================= */
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"];
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:"endless",ship:"falcon",diff:0}; // 出击默认无尽模式
function bestLine(){ function bestLine(){
const b=SAVE.best; const b=SAVE.best;
$("best-line").textContent=`无尽纪录 ${Math.max(b.e0,b.e1)} · 闯关进度 ${b.stage}/6`; $("best-line").textContent=`无尽纪录 ${Math.max(b.e0,b.e1)} · 闯关进度 ${b.stage}/6`;
} }
$("b-go").onclick=()=>{AU.init();buildSetup();show("o-setup");}; $("b-go").onclick=()=>{AU.init();show("o-setup");};
$("b-back1").onclick=()=>show("o-title"); $("b-back1").onclick=()=>show("o-title");
$("b-back2").onclick=()=>show("o-title"); $("b-back2").onclick=()=>show("o-title");
$("b-hangar").onclick=()=>{AU.init();buildShop();show("o-hangar");}; $("b-hangar").onclick=()=>{AU.init();buildShop();show("o-hangar");};
@ -16,6 +16,17 @@ $("b-rush").onclick=()=>{AU.init();buildRush();show("o-rush");};
$("b-back6").onclick=()=>{UI="title";show("o-title");}; $("b-back6").onclick=()=>{UI="title";show("o-title");};
$("b-back4").onclick=()=>{UI="title";bestLine();show("o-title");}; $("b-back4").onclick=()=>{UI="title";bestLine();show("o-title");};
$("b-back5").onclick=exitDemoSim; $("b-back5").onclick=exitDemoSim;
/* 10
纯内存开关不动 SAVE.codexpersist/结算上报都读它动了会污染后端刷新页面即失效 */
let codexTestAll=false,cxClicks=0,cxLastClick=0;
$("cx-secret").onclick=()=>{
const now=Date.now();
if(now-cxLastClick>2000)cxClicks=0; // 间隔超2秒重新计数
cxLastClick=now;
if(++cxClicks<10)return;
cxClicks=0;codexTestAll=!codexTestAll;
SFX.pick();buildCodex();
};
$("b-start").onclick=()=>startGame(); $("b-start").onclick=()=>startGame();
if("ontouchstart" in window){const pb=$("tb-pause");pb.style.display="block";pb.onclick=()=>togglePause();} if("ontouchstart" in window){const pb=$("tb-pause");pb.style.display="block";pb.onclick=()=>togglePause();}
$("b-resume").onclick=()=>togglePause(); $("b-resume").onclick=()=>togglePause();
@ -40,7 +51,8 @@ $("diff-pick").addEventListener("click",e=>{
SETUP.diff=+b.dataset.d; SETUP.diff=+b.dataset.d;
for(const el of $("diff-pick").children)el.classList.toggle("primary",el===b); for(const el of $("diff-pick").children)el.classList.toggle("primary",el===b);
}); });
function buildSetup(){ /* 机体选择/购买(机库内):购买后整店刷新(星币/按钮态变化),选择只刷新机体卡 */
function buildShips(){
const box=$("ship-pick");box.innerHTML=""; const box=$("ship-pick");box.innerHTML="";
for(const k in SHIPS){ for(const k in SHIPS){
const s=SHIPS[k],owned=SAVE.ships.includes(k); const s=SHIPS[k],owned=SAVE.ships.includes(k);
@ -53,11 +65,11 @@ function buildSetup(){
d.insertAdjacentHTML("beforeend",`<div class="nm">${s.name}</div><div class="ds">${s.trait}</div>`+(owned?"":`<div class="cost">✦ ${s.cost} 解锁</div>`)); d.insertAdjacentHTML("beforeend",`<div class="nm">${s.name}</div><div class="ds">${s.trait}</div>`+(owned?"":`<div class="cost">✦ ${s.cost} 解锁</div>`));
d.onclick=()=>{ d.onclick=()=>{
if(!SAVE.ships.includes(k)){ if(!SAVE.ships.includes(k)){
if(SAVE.coins>=s.cost){SAVE.coins-=s.cost;SAVE.ships.push(k);persist();SFX.level();buildSetup();} if(SAVE.coins>=s.cost){SAVE.coins-=s.cost;SAVE.ships.push(k);persist();SFX.level();buildShop();}
else SFX.hurt(); else SFX.hurt();
return; return;
} }
SETUP.ship=k;buildSetup(); SETUP.ship=k;persist();buildShips(); // persist 触发云端同步选中机体
}; };
box.appendChild(d); box.appendChild(d);
} }
@ -74,6 +86,7 @@ const META=[
]; ];
function buildShop(){ function buildShop(){
$("wallet").textContent=SAVE.coins; $("wallet").textContent=SAVE.coins;
buildShips();
const box=$("shop");box.innerHTML=""; const box=$("shop");box.innerHTML="";
for(const m of META){ for(const m of META){
const lv=SAVE.m[m.key]||0,maxed=lv>=m.max,cost=maxed?0:m.costs[lv]; const lv=SAVE.m[m.key]||0,maxed=lv>=m.max,cost=maxed?0:m.costs[lv];
@ -142,12 +155,14 @@ 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=codexCount(); $("codex-n").textContent=codexCount(); // 计数永远显示真实收集数,测试模式不影响
$("codex-test").style.display=codexTestAll?"":"none";
$("cx-secret").style.color=codexTestAll?"var(--gold)":""; // 开启后“军”字变金
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],owned=!!SAVE.codex[k]; const def=dict[k],owned=!!SAVE.codex[k]||codexTestAll; // 测试模式:全部可见可演示
const d=document.createElement("div"); const d=document.createElement("div");
d.className="cx-item"+(owned?"":" lock"); // 未收集:剪影+???,不可演示 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];
@ -338,7 +353,7 @@ function startDemoSim(kind,key){
if(key==="shield")G.shield=shieldCap();} if(key==="shield")G.shield=shieldCap();}
$("demo-name").textContent=def.name; $("demo-name").textContent=def.name;
const ds=typeof def.ds==="function"?def.ds(lv):def.ds; const ds=typeof def.ds==="function"?def.ds(lv):def.ds;
$("demo-ds").textContent=(kind==="m"?"主武器 · ":kind==="s"?"副武器 · ":kind==="d"?"僚机 · ":"被动 · ")+ds; $("demo-ds").textContent=ds; // 只显示介绍本身单行不折行nowrap 在 index.html 上)
UI="demoSim";show("o-demo");acc=0; UI="demoSim";show("o-demo");acc=0;
banner(def.name,"#41e6ff",90); banner(def.name,"#41e6ff",90);
} }

View File

@ -9,7 +9,7 @@ const { vUsername, vEmail, vPassword, vInt, rateLimit, clientIp } = require("./u
const CODEX_KEYS = new Set([ const CODEX_KEYS = new Set([
"vulcan", "shotgun", "needle", "flame", "shotgun2", "tesla", "railgun", "deathray", "vulcan", "shotgun", "needle", "flame", "shotgun2", "tesla", "railgun", "deathray",
"crescent", "fission", "frost", "swarm", "bubble", "starfall", "boomerang", "singularity", "crescent", "fission", "frost", "swarm", "bubble", "starfall", "boomerang", "singularity",
"wavecannon", "holy", "thundercall", "arcshu", "volt", "dawn", "proto", "nebula", "wavecannon", "holy", "thundercall", "arcshu", "volt", "dawn", "proto", "nebula", "loiter",
"broad", "sweep", "leech", "orbit", "sats", "pd", "slowfield", "flares", "seeker", "broad", "sweep", "leech", "orbit", "sats", "pd", "slowfield", "flares", "seeker",
"beamdrone", "frostnova", "thorns", "sticky", "magnetar", "executor", "airstrike", "repair", "beamdrone", "frostnova", "thorns", "sticky", "magnetar", "executor", "airstrike", "repair",
"gunner", "gunner2", "sniperd", "beamer", "bomber", "frostd", "teslaD", "rammer", "gunner", "gunner2", "sniperd", "beamer", "bomber", "frostd", "teslaD", "rammer",
@ -18,6 +18,10 @@ const CODEX_KEYS = new Set([
]); ]);
const CODEX_TOTAL = CODEX_KEYS.size; const CODEX_TOTAL = CODEX_KEYS.size;
const RUN_MODES = new Set(["stage", "endless", "rush"]); 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 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: {} };
const qInsUser = db.prepare("INSERT INTO users(username,email,pass_hash,created_at) VALUES(?,?,?,?)"); 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 qInsStats = db.prepare("INSERT OR IGNORE INTO user_stats(user_id) VALUES(?)");
@ -33,6 +37,9 @@ const qUpdStats = db.prepare(`INSERT INTO user_stats(user_id,boss_kills,endless_
endless_high=MAX(endless_high,excluded.endless_high), endless_high=MAX(endless_high,excluded.endless_high),
runs=runs+1, runs=runs+1,
updated_at=excluded.updated_at`); updated_at=excluded.updated_at`);
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`);
function statsOf(userId) { function statsOf(userId) {
const s = qStats.get(userId) || { boss_kills: 0, endless_high: 0, runs: 0 }; const s = qStats.get(userId) || { boss_kills: 0, endless_high: 0, runs: 0 };
@ -41,6 +48,31 @@ function statsOf(userId) {
function codexOf(userId) { function codexOf(userId) {
return qCodex.all(userId).map((r) => r.entry_key); return qCodex.all(userId).map((r) => r.entry_key);
} }
function stateOf(userId) {
const r = qGetState.get(userId);
if (!r) return null; // null=从未同步(老账号首次登陆:客户端用本地存档初始化上传)
try { return JSON.parse(r.state); } catch (e) { return null; }
}
/* 存档白名单校验:只留合法字段并 clamp任何字段非法则整体拒收 */
function vState(s) {
if (!s || typeof s !== "object" || Array.isArray(s)) return null;
if (!vInt(s.coins, 0, 1e7)) return null;
if (!Array.isArray(s.ships)) return null;
const ships = [...new Set(s.ships.filter((k) => SHIP_KEYS.has(k)))].slice(0, 8);
if (!ships.includes("falcon")) ships.unshift("falcon");
const ship = SHIP_KEYS.has(s.ship) ? s.ship : "falcon";
const m = {};
if (s.m && typeof s.m === "object") for (const k of Object.keys(META_MAX))
if (vInt(s.m[k], 0, META_MAX[k])) m[k] = s.m[k];
const best = { s0: 0, s1: 0, e0: 0, e1: 0, stage: 0 };
if (s.best && typeof s.best === "object") for (const k of Object.keys(best))
if (vInt(s.best[k], 0, 1e8)) best[k] = s.best[k];
const rush = {};
if (s.rush && typeof s.rush === "object") for (const k of Object.keys(s.rush).slice(0, 32))
if (/^\d{1,2}$/.test(k) && vInt(s.rush[k], 0, 8640000)) rush[k] = s.rush[k];
return { coins: s.coins, ships, ship, m, best, rush };
}
/* ---------- POST /api/register ---------- */ /* ---------- POST /api/register ---------- */
function register(req, res, ctx, body) { function register(req, res, ctx, body) {
@ -56,7 +88,7 @@ function register(req, res, ctx, body) {
qInsStats.run(uid); qInsStats.run(uid);
const token = auth.createSession(db, uid); const token = auth.createSession(db, uid);
ctx.setCookie = auth.sessionCookie(token, ctx.secure); ctx.setCookie = auth.sessionCookie(token, ctx.secure);
return ctx.ok({ user: { username, email: email.trim().toLowerCase() }, stats: statsOf(uid), codex: [], codexTotal: CODEX_TOTAL }); return ctx.ok({ user: { username, email: email.trim().toLowerCase() }, stats: statsOf(uid), codex: [], codexTotal: CODEX_TOTAL, state: DEFAULT_STATE });
} }
/* ---------- POST /api/login ---------- */ /* ---------- POST /api/login ---------- */
@ -72,7 +104,7 @@ function login(req, res, ctx, body) {
qInsStats.run(u.id); qInsStats.run(u.id);
const token = auth.createSession(db, u.id); const token = auth.createSession(db, u.id);
ctx.setCookie = auth.sessionCookie(token, ctx.secure); 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 }); return ctx.ok({ user: { username: u.username, email: u.email }, stats: statsOf(u.id), codex: codexOf(u.id), codexTotal: CODEX_TOTAL, state: stateOf(u.id) });
} }
/* ---------- POST /api/logout ---------- */ /* ---------- POST /api/logout ---------- */
@ -86,7 +118,7 @@ function logout(req, res, ctx) {
function me(req, res, ctx) { function me(req, res, ctx) {
const u = ctx.user; const u = ctx.user;
if (!u) return ctx.ok({ user: null }); if (!u) return ctx.ok({ user: null });
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 }); 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, state: stateOf(u.id) });
} }
/* ---------- GET /api/leaderboard?sort=endless|boss|codex ---------- */ /* ---------- GET /api/leaderboard?sort=endless|boss|codex ---------- */
@ -131,6 +163,16 @@ function run(req, res, ctx, body) {
return ctx.ok({ stats: statsOf(uid), codex: codexOf(uid), codexTotal: CODEX_TOTAL }); return ctx.ok({ stats: statsOf(uid), codex: codexOf(uid), codexTotal: CODEX_TOTAL });
} }
/* ---------- POST /api/state ----------
存档云同步星币/机体/选中机体/永久强化/本地纪录防抖由客户端负责 */
function saveState(req, res, ctx, body) {
if (!rateLimit("state:" + ctx.user.id, 60, 5 * 60000)) return ctx.fail(429, "同步太频繁");
const st = vState(body && body.state);
if (!st) return ctx.fail(400, "非法存档数据");
qPutState.run(ctx.user.id, JSON.stringify(st), Date.now());
return ctx.ok({ saved: true });
}
const ROUTES = { const ROUTES = {
"POST /api/register": { fn: register, auth: false }, "POST /api/register": { fn: register, auth: false },
"POST /api/login": { fn: login, auth: false }, "POST /api/login": { fn: login, auth: false },
@ -138,6 +180,7 @@ const ROUTES = {
"GET /api/me": { fn: me, auth: false }, "GET /api/me": { fn: me, auth: false },
"GET /api/leaderboard": { fn: leaderboard, auth: true }, "GET /api/leaderboard": { fn: leaderboard, auth: true },
"POST /api/run": { fn: run, auth: true }, "POST /api/run": { fn: run, auth: true },
"POST /api/state": { fn: saveState, auth: true },
}; };
module.exports = { ROUTES, CODEX_TOTAL }; module.exports = { ROUTES, CODEX_TOTAL };

View File

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