Compare commits

...

2 Commits

45 changed files with 1065 additions and 13 deletions

9
.gitignore vendored
View File

@ -4,3 +4,12 @@ research/
.playwright-mcp/ .playwright-mcp/
cache/ cache/
star-raid/.env star-raid/.env
# Star Raid Android App
starraid-android/build/
starraid-android/app/build/
starraid-android/.gradle/
starraid-android/local.properties
starraid-android/app/release/
starraid-android/.keystore-pass
*.apk

View File

@ -31,8 +31,9 @@ git add -A && git commit -m "提交信息"
## 验证规则(最高优先级,违反会被用户骂) ## 验证规则(最高优先级,违反会被用户骂)
1. **游戏视觉/手感禁止 Agent 验收**——用户亲自验收。但**账号/大厅/排行榜/结算上报等功能流允许 playwright 测试**(用户 2026-08-06 明确授权):注册/登陆/数据断言可跑,只是不许用截图评判游戏画面效果。 1. **游戏视觉/手感禁止 Agent 验收**——用户亲自验收。但**账号/大厅/排行榜/结算上报等功能流允许 playwright 测试**(用户 2026-08-06 明确授权):注册/登陆/数据断言可跑,只是不许用截图评判游戏画面效果。
2. 交付时必须诚实标注验证状态。 2. **游戏玩法改动(武装配置/Boss机制/手感/数值等)默认由用户亲自测试**(用户 2026-08-06 补充Agent 不主动跑 playwright 验收这类改动,只做语法检查等静态校验,交付时标注“未做功能验证,待用户验收”;用户明确要求时才跑功能流测试。
3. 完成了操作 ≠ 任务完成,未经验证不得说“修复了/完成了”。 3. 交付时必须诚实标注验证状态。
4. 完成了操作 ≠ 任务完成,未经验证不得说“修复了/完成了”。
## 项目结构 ## 项目结构

106
deploy_starraid.sh Executable file
View File

@ -0,0 +1,106 @@
#!/bin/bash
set -euo pipefail
# ============================================================================
# deploy_starraid.sh — Star Raid 一键部署2026-08-06
# ----------------------------------------------------------------------------
# 参考项目本体 agents 的 deploy_agents.sh / refresh_agents.sh 双脚本模式:
# 本地git push → rsync 上传 → SSH 触发服务器 refresh_starraid.sh
# 服务器:/opt/starraid/refresh_starraid.sh重启 + 健康检查 + nginx reload
#
# 用法:
# bash deploy_starraid.sh # 默认push main + rsync + 触发刷新
# bash deploy_starraid.sh --no-push # 跳过 git push例如已手动 push
# REMOTE_HOST=xxx bash deploy_starraid.sh # 覆盖远程主机
#
# 说明:
# - rsync 排除 .env生产密钥、demo/、.DS_Store
# - Star Raid 是纯静态 + node 服务,无前端构建步骤
# - 游戏服务器目录 /opt/starraid 无 git采用 rsync 直传(与项目本体 git pull 模式不同)
# ============================================================================
# ── 配置(可通过环境变量覆盖) ──
REMOTE_HOST="${REMOTE_HOST:-59.110.19.30}"
REMOTE_USER="${REMOTE_USER:-root}"
REMOTE_PORT="${REMOTE_PORT:-22}"
REMOTE_DIR="${REMOTE_DIR:-/opt/starraid}"
GIT_BRANCH="${GIT_BRANCH:-main}"
LOCAL_ROOT="$(cd "$(dirname "$0")" && pwd)"
GAME_DIR="$LOCAL_ROOT/star-raid"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
log "缺少命令 $1,请先安装后重试"
exit 1
fi
}
ssh_exec() { ssh -p "$REMOTE_PORT" $SSH_OPTS "$REMOTE_USER@$REMOTE_HOST" "$@"; }
# ── 参数解析 ──
SKIP_PUSH=false
for arg in "$@"; do
case "$arg" in
--no-push) SKIP_PUSH=true ;;
-h|--help)
echo "用法: bash deploy_starraid.sh [--no-push]"
echo " --no-push 跳过 git push仅 rsync 上传 + 服务器刷新)"
exit 0
;;
*) log "未知参数: $arg(支持 --no-push"; exit 1 ;;
esac
done
main() {
need_cmd ssh
need_cmd rsync
# ── 0. 前置检查 ──
if [[ ! -d "$GAME_DIR" ]]; then
log "错误: 未找到游戏目录 $GAME_DIR"
exit 1
fi
# ── 1. Git Push ──
if $SKIP_PUSH; then
log "1/3 跳过 git push--no-push"
else
log "1/3 git push → origin/$GIT_BRANCH"
cd "$LOCAL_ROOT"
if ! git diff --quiet --exit-code 2>/dev/null || ! git diff --cached --quiet --exit-code 2>/dev/null; then
log "警告:有未提交的改动,请先 commit 或 stash"
log " - 查看: git status"
log " - 提交: git add -A && git commit -m '...'"
log " - 暂存: git stash push -m 'WIP'"
log "若仍要部署,请使用 --no-push 跳过此步骤"
exit 1
fi
git push origin "$GIT_BRANCH"
log "push 完成"
fi
# ── 2. rsync 上传 ──
log "2/3 rsync 上传 → $REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR"
# 注意:不用 --delete——服务器端 refresh_starraid.sh 等本地没有的文件必须保留;
# 且避免本地临时缺文件时连带删除服务器文件(与手动 rsync 命令保持一致)
rsync -az \
--exclude='.env' \
--exclude='.env.example' \
--exclude='.DS_Store' \
--exclude='demo/' \
--exclude='*.log' \
--exclude='refresh_starraid.sh' \
"$GAME_DIR/" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/"
log "上传完成"
# ── 3. 触发服务器刷新 ──
log "3/3 触发服务器 refresh_starraid.sh"
ssh_exec "bash $REMOTE_DIR/refresh_starraid.sh"
log "全部完成 🚀 https://starraid.cyjai.com"
}
main "$@"

View File

@ -134,6 +134,11 @@ h1.logo{font-size:clamp(20px,5.4vw,44px);color:#fff;letter-spacing:4px;line-heig
.cx-item .nm{font-size:clamp(6px,1.1vw,8px);margin-top:4px;letter-spacing:1px} .cx-item .nm{font-size:clamp(6px,1.1vw,8px);margin-top:4px;letter-spacing:1px}
.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)}
/* 自选武装Boss挑战选中高亮 + 槽位计数 */
.cx-item.sel{border-color:var(--cyan);box-shadow:0 0 12px rgba(65,230,255,.4);background:rgba(65,230,255,.08)}
.cnt{color:var(--gold);font-size:clamp(8px,1.4vw,11px)}
.cx-grid.full .cx-item:not(.sel){opacity:.55}
.cx-grid.full .cx-item:not(.sel):hover{border-color:var(--line);transform:none}
.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}
/* ---------- 登陆/注册 ---------- */ /* ---------- 登陆/注册 ---------- */

View File

@ -102,7 +102,7 @@
<!-- Boss 挑战 --> <!-- Boss 挑战 -->
<div class="ovl hide" id="o-rush"> <div class="ovl hide" id="o-rush">
<div class="h2">— BOSS 挑战 —</div> <div class="h2">— BOSS 挑战 —</div>
<div class="h3">随机武装3主武Lv3 · 3副武Lv3 · 2僚机Lv2 · 击破计时</div> <div class="h3">随机武装3主武Lv4 · 3副武Lv4 · 2僚机Lv3 · 阶段回血50% · 击破计时</div>
<div class="togrow"><label><input type="checkbox" id="rush-anim"> 老虎机抽奖动画</label></div> <div class="togrow"><label><input type="checkbox" id="rush-anim"> 老虎机抽奖动画</label></div>
<div class="pick" id="rush-pick"></div> <div class="pick" id="rush-pick"></div>
<button class="btn" id="b-back6" style="margin-top:6px">返回</button> <button class="btn" id="b-back6" style="margin-top:6px">返回</button>
@ -115,6 +115,35 @@
<div class="h3" id="slot-hint"> </div> <div class="h3" id="slot-hint"> </div>
</div> </div>
<!-- 出击方式选择Boss挑战 -->
<div class="ovl hide" id="o-rushmode">
<div class="h2" style="color:var(--red)">— BOSS 挑战 —</div>
<div class="h3" id="rushmode-name" style="letter-spacing:2px"> </div>
<div class="h3">选择出击方式</div>
<div class="col">
<button class="btn primary" id="b-rushmode-random">随机出击</button>
<button class="btn" id="b-rushmode-pick">自选武器</button>
<button class="btn" id="b-rushmode-cancel">取消</button>
</div>
</div>
<!-- 自选武装Boss挑战 -->
<div class="ovl hide" id="o-rushpick">
<div class="h2"><span id="rp-secret"></span>选武装 —</div>
<div class="h3" id="rushpick-title"> </div>
<div class="h3" style="margin:8px 0 2px">主武器 <span class="cnt" id="rushpick-cnt-m">0/3</span></div>
<div class="cx-grid" id="rushpick-m"></div>
<div class="h3" style="margin:8px 0 2px">副武器 <span class="cnt" id="rushpick-cnt-s">0/3</span></div>
<div class="cx-grid" id="rushpick-s"></div>
<div class="h3" style="margin:8px 0 2px">僚机 <span class="cnt" id="rushpick-cnt-d">0/2</span></div>
<div class="cx-grid" id="rushpick-d"></div>
<div class="h3" style="color:var(--dim)">未选满出击时自动随机补齐(仅已解锁)</div>
<div class="col">
<button class="btn primary" id="b-rushpick-go">出击</button>
<button class="btn" id="b-rushpick-back">返回</button>
</div>
</div>
<!-- 机库 --> <!-- 机库 -->
<div class="ovl hide" id="o-hangar"> <div class="ovl hide" id="o-hangar">
<div class="h2">— 机库 · 强化 —</div> <div class="h2">— 机库 · 强化 —</div>

View File

@ -282,7 +282,13 @@ function updateBoss(){
if(np>b.phase){b.phase=np;b.transT=70;b.r=(def.rr&&def.rr[np])||b.r;b.ry=(def.ryy&&def.ryy[np])||undefined; if(np>b.phase){b.phase=np;b.transT=70;b.r=(def.rr&&def.rr[np])||b.r;b.ry=(def.ryy&&def.ryy[np])||undefined;
G.eb.length=0;G.bbeams.length=0;b.keys=null;SFX.warn();G.shake=Math.max(G.shake,5); G.eb.length=0;G.bbeams.length=0;b.keys=null;SFX.warn();G.shake=Math.max(G.shake,5);
if(b.idx===6){G.funnels.length=0;b.form=null;if(np===1){spawnFunnels(b,4);b.plT=Math.round(300/b.fireM);}} // 夺权者X二阶段增至4架僚机 if(b.idx===6){G.funnels.length=0;b.form=null;if(np===1){spawnFunnels(b,4);b.plT=Math.round(300/b.fireM);}} // 夺权者X二阶段增至4架僚机
banner(b.name+" — 第"+["一","二","三"][np]+"形态","#ff5566",90);} banner(b.name+" — 第"+["一","二","三"][np]+"形态","#ff5566",90);
if(G.mode==="rush"){ // Boss挑战通过一个阶段恢复50%血量
const heal=Math.ceil(G.maxHp/2);
G.hp=Math.min(G.maxHp,G.hp+heal);
G.texts.push({x:G.p.x,y:G.p.y-16,t:"+50% 生命恢复",col:"#5cff9d",life:70});
SFX.graze();
}}
if(b.transT>0){b.transT--;b.dashTrail=null; // 变形期间清冲撞残影 if(b.transT>0){b.transT--;b.dashTrail=null; // 变形期间清冲撞残影
if(b.transT===0){explodeFx(b.x,b.y,"dark",1.3);G.shake=Math.max(G.shake,6); if(b.transT===0){explodeFx(b.x,b.y,"dark",1.3);G.shake=Math.max(G.shake,6);
for(let i=0;i<10;i++)ebul(b.x,b.y,i/10*TAU,1.5,"orbP",12);} // 变形完成爆发环弹 for(let i=0;i<10;i++)ebul(b.x,b.y,i/10*TAU,1.5,"orbP",12);} // 变形完成爆发环弹

View File

@ -1,6 +1,6 @@
"use strict"; "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","o-unsaved"]; 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","o-rushmode","o-rushpick"];
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:"endless",ship:"falcon",diff:0}; // 出击默认无尽模式 const SETUP={mode:"endless",ship:"falcon",diff:0}; // 出击默认无尽模式
function bestLine(){ function bestLine(){
@ -70,6 +70,18 @@ $("cx-secret").onclick=()=>{
cxClicks=0;codexTestAll=!codexTestAll; cxClicks=0;codexTestAll=!codexTestAll;
SFX.pick();buildCodex(); SFX.pick();buildCodex();
}; };
/* 自选武装测试模式隐藏入口连点“自”字10次开关与图鉴“军”字同一开关 */
let rpClicks=0,rpLastClick=0;
$("rp-secret").onclick=()=>{
const now=Date.now();
if(now-rpLastClick>2000)rpClicks=0; // 间隔超2秒重新计数
rpLastClick=now;
if(++rpClicks<10)return;
rpClicks=0;codexTestAll=!codexTestAll;
SFX.pick();
$("rp-secret").style.color=codexTestAll?"var(--gold)":"";
if(!$("o-rushpick").classList.contains("hide"))openRushPick(rushPickIdx); // 面板开着时立即刷新生效
};
/* 开始出击:新开无尽对局(不分难度)前,若云端有未完成对局,先弹结算确认 */ /* 开始出击:新开无尽对局(不分难度)前,若云端有未完成对局,先弹结算确认 */
$("b-start").onclick=async()=>{ $("b-start").onclick=async()=>{
if(SETUP.mode==="endless"&&typeof NET!=="undefined"&&NET.user){ if(SETUP.mode==="endless"&&typeof NET!=="undefined"&&NET.user){
@ -359,21 +371,96 @@ function buildRush(){
if(row.children.length){grp.appendChild(row);el.appendChild(grp);} if(row.children.length){grp.appendChild(row);el.appendChild(grp);}
} }
} }
function rollRushBuild(){ // 随机武装3主武Lv3 + 3副武Lv3 + 2僚机Lv2 function rollRushBuild(){ // 随机武装3主武Lv4 + 3副武Lv4 + 2僚机Lv3
const sh=a=>{const x=a.slice();for(let i=x.length-1;i>0;i--){const j=rndi(0,i),t2=x[i];x[i]=x[j];x[j]=t2;}return x;}; const sh=a=>{const x=a.slice();for(let i=x.length-1;i>0;i--){const j=rndi(0,i),t2=x[i];x[i]=x[j];x[j]=t2;}return x;};
return {m:sh(Object.keys(WEAPONS)).slice(0,3),s:sh(Object.keys(SUBS)).slice(0,3),d:sh(Object.keys(DRONES)).slice(0,2)}; return {m:sh(Object.keys(WEAPONS)).slice(0,3),s:sh(Object.keys(SUBS)).slice(0,3),d:sh(Object.keys(DRONES)).slice(0,2)};
} }
function startRush(i){ function startRush(i){ // 点Boss卡片弹窗选择 随机出击/自选武器
AU.init();if(AU.ctx&&AU.ctx.state==="suspended")AU.ctx.resume(); AU.init();if(AU.ctx&&AU.ctx.state==="suspended")AU.ctx.resume();
const build=rollRushBuild(); openRushMode(i);
if(SAVE.rushAnim)playSlot(build,()=>startRushGo(i,build)); // 老虎机动画抽取 }
else startRushGo(i,build); // 直接随机 /* ---- 出击方式弹窗:随机 / 自选 ---- */
let rushPickIdx=0;
function openRushMode(i){
rushPickIdx=i;
const b=BOSSES[i],tierName=["简单","中等","困难","地狱"][b.tier]||"";
const nm=$("rushmode-name");nm.textContent=b.name+(tierName?" · "+tierName:"");nm.style.color=b.col;
$("b-rushmode-random").onclick=()=>{
show("o-rush");
const build=rollRushBuild();
if(SAVE.rushAnim)playSlot(build,()=>startRushGo(i,build)); // 老虎机动画抽取
else startRushGo(i,build); // 直接随机
};
$("b-rushmode-pick").onclick=()=>openRushPick(i);
$("b-rushmode-cancel").onclick=()=>show("o-rush");
show("o-rushmode");
}
/* ---- 自选武装面板:从图鉴已解锁中挑 3主+3副+2僚等级与随机同级 Lv4/4/3 ---- */
const rushPick={m:[null,null,null],s:[null,null,null],d:[null,null,null]}; // 槽位null=未选
function openRushPick(i){
rushPickIdx=i;
const b=BOSSES[i];
$("rushpick-title").textContent=b.name+" — 从已解锁图鉴中选择";
for(const k of ["m","s","d"])rushPick[k].fill(null);
const mk=(label,keys,dict)=>{
const box=$("rushpick-"+label);box.innerHTML="";
for(const k of keys){
const def=dict[k],owned=!!SAVE.codex[k]||codexTestAll; // 测试模式:全部可选
const d=document.createElement("div");
d.className="cx-item"+(owned?"":" lock");
const img=def.icon?ICONS[def.icon]:SPR[def.spr];
d.appendChild(iconCanvas(img,28,!owned));
d.insertAdjacentHTML("beforeend",`<div class="nm">${owned?def.name:"???"}</div>`);
d.dataset.key=k;
if(owned)d.onclick=()=>toggleRushPick(d,label);
box.appendChild(d);
}
};
mk("m",MAINS,WEAPONS);
mk("s",SUB_KEYS,SUBS);
mk("d",DRONE_KEYS,DRONES);
updRushPickCaps();
show("o-rushpick");
}
function toggleRushPick(el,label){
const cap=label==="d"?2:3,slots=rushPick[label];
const i=slots.indexOf(el.dataset.key);
if(i>=0){slots[i]=null;el.classList.remove("sel");} // 取消已选
else{
if(slots.filter(Boolean).length>=cap)return; // 槽位已满:忽略点击
const empty=slots.indexOf(null);if(empty<0)return;
slots[empty]=el.dataset.key;el.classList.add("sel");
SFX.pick();
}
updRushPickCaps();
}
function updRushPickCaps(){
const cap={m:3,s:3,d:2};
for(const k of ["m","s","d"]){
const n=rushPick[k].filter(Boolean).length;
const c=$("rushpick-cnt-"+k);if(c)c.textContent=n+"/"+cap[k];
$("rushpick-"+k).classList.toggle("full",n>=cap[k]);
}
}
function rushPickGo(){ // 未选满的槽位:从已解锁池随机补齐(图鉴全空时兜底全池)
const owned=kk=>!!SAVE.codex[kk]||codexTestAll;
const pools={m:MAINS,s:SUB_KEYS,d:DRONE_KEYS};
const build={m:[],s:[],d:[]};
for(const k of ["m","s","d"]){
const cap=k==="d"?2:3;
const ownedPool=pools[k].filter(owned);
const src=ownedPool.length?ownedPool:pools[k]; // 兜底
build[k]=rushPick[k].filter(Boolean).slice(0,cap);
const rest=src.filter(x=>!build[k].includes(x));
while(build[k].length<cap&&rest.length)build[k].push(rest.splice(rndi(0,rest.length-1),1)[0]);
}
startRushGo(rushPickIdx,build);
} }
function startRushGo(i,build){ function startRushGo(i,build){
newRun("rush","falcon",0); newRun("rush","falcon",0);
G.weapons={};build.m.forEach(k=>G.weapons[k]=3);G.loadout=build.m.slice(); G.weapons={};build.m.forEach(k=>G.weapons[k]=4);G.loadout=build.m.slice();
G.subs={};build.s.forEach(k=>G.subs[k]=3);G.subLoadout=build.s.slice(); G.subs={};build.s.forEach(k=>G.subs[k]=4);G.subLoadout=build.s.slice();
G.drones={};build.d.forEach(k=>G.drones[k]=2);G.droneLoadout=build.d.slice(); G.drones={};build.d.forEach(k=>G.drones[k]=3);G.droneLoadout=build.d.slice();
G.xpNeed=1e9; // 挑战模式无升级打断 G.xpNeed=1e9; // 挑战模式无升级打断
G.rushIdx=i;G.rushT0=0; G.rushIdx=i;G.rushT0=0;
G.stage=i; // 排行榜统计rush 局 stage 记录 Boss 索引(0~10) G.stage=i; // 排行榜统计rush 局 stage 记录 Boss 索引(0~10)
@ -384,6 +471,8 @@ function startRushGo(i,build){
G.rushT0=G.time; G.rushT0=G.time;
startBoss(); startBoss();
} }
$("b-rushpick-go").onclick=()=>{show("o-rush");rushPickGo();};
$("b-rushpick-back").onclick=()=>show("o-rush");
/* ---- 老虎机抽奖动画 ---- */ /* ---- 老虎机抽奖动画 ---- */
let slotTimers=[]; let slotTimers=[];
function playSlot(build,cb){ function playSlot(build,cb){

25
starraid-android/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# macOS
.DS_Store
# Local Android/IDE state
local.properties
.idea/
.gradle/
# Build outputs
build/
**/build/
# Signing keys and secrets
*.jks
*.keystore
# Logs
*.log
# Release artifacts
*.apk
*.aab
*.apks
*.dm
app/release/

View File

@ -0,0 +1,7 @@
# Star Raid Android 更新说明
## 1.0.02026-08-06
- 首个版本WebView 壳加载 https://starraid.cyjai.com
- 锁定竖屏 + 沉浸式全屏 + 游戏期间屏幕常亮
- 像素风启动画面与 App 图标(初始飞机「猎鹰」)
- 账号 / 排行榜 / 云存档与 Web 版完全互通

View File

@ -0,0 +1,34 @@
# Star Raid Android 客户端
星尘突击队Star Raid安卓客户端WebView 壳工程。
- 游戏线上地址https://starraid.cyjai.com
- 客户端仅承载 WebView游戏/账号/排行榜/云存档全部走线上服务
- 锁定竖屏、沉浸式全屏、游戏期间屏幕常亮
## 构建
```bash
# 需要 Android SDKlocal.properties 或 ANDROID_HOME与 JDK 17
# 签名参数(也可用同名环境变量)
./gradlew assembleRelease \
-PANDROID_KEYSTORE_PATH=/path/to/starraid-release.jks \
-PANDROID_KEYSTORE_PASSWORD=xxx \
-PANDROID_KEY_ALIAS=starraid \
-PANDROID_KEY_PASSWORD=xxx
```
产物:`app/build/outputs/apk/release/app-release.apk`
## 可改项
- 包名:`app/build.gradle.kts`namespace/applicationId`MainActivity.kt``package`
- 应用名:`app/src/main/res/values/strings.xml`
- 首页地址:`MainActivity.kt` 中 `HOME_URL`
- 图标:`tools/gen_icons.py`(像素数据取自游戏 `js/sprites.js` 的 ship0重新生成后需在 Android Studio 或 gradle 里 rebuild
## 版本发布
- 递增 `app/build.gradle.kts``versionCode` / `versionName`
- 顶部更新 `APP_CHANGELOG.md`

View File

@ -0,0 +1,66 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
fun resolveConfig(name: String): String? {
return (project.findProperty(name) as String?)?.takeIf { it.isNotBlank() }
?: System.getenv(name)?.takeIf { it.isNotBlank() }
}
android {
namespace = "com.cyjai.starraid"
compileSdk = 35
defaultConfig {
applicationId = "com.cyjai.starraid"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
val storeFilePath = resolveConfig("ANDROID_KEYSTORE_PATH")
val storePass = resolveConfig("ANDROID_KEYSTORE_PASSWORD")
val keyAliasValue = resolveConfig("ANDROID_KEY_ALIAS")
val keyPass = resolveConfig("ANDROID_KEY_PASSWORD")
if (!storeFilePath.isNullOrBlank()) {
storeFile = file(storeFilePath)
}
storePassword = storePass
keyAlias = keyAliasValue
keyPassword = keyPass
}
}
buildTypes {
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.activity:activity-ktx:1.9.2")
}

View File

@ -0,0 +1 @@
# Intentionally minimal for WebView shell app

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.StarRaid"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|uiMode|density">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,120 @@
package com.cyjai.starraid
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
/**
* Star Raid星尘突击队安卓客户端
* WebView 壳加载线上游戏账号/排行榜/云存档与 Web 版完全互通
*/
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private var lastBackPressedAt = 0L
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 沉浸式全屏:隐藏状态栏/导航栏,滑动手势可临时唤出
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
// 游戏期间屏幕常亮
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
webView = WebView(this)
webView.isVerticalScrollBarEnabled = false
webView.isHorizontalScrollBarEnabled = false
webView.overScrollMode = View.OVER_SCROLL_NEVER
setContentView(webView)
CookieManager.getInstance().apply {
setAcceptCookie(true)
setAcceptThirdPartyCookies(webView, true)
}
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
mediaPlaybackRequiresUserGesture = false
// 服务器静态资源为 immutable 长缓存App 侧强制走网络,确保游戏更新即时生效
cacheMode = WebSettings.LOAD_NO_CACHE
useWideViewPort = true
loadWithOverviewMode = true
setSupportZoom(false)
builtInZoomControls = false
displayZoomControls = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
safeBrowsingEnabled = true
}
}
webView.clearCache(true)
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url?.toString() ?: return false
if (url.startsWith(HOME_URL)) return false
// 站外链接交给系统浏览器
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
} catch (_: Exception) {
// 忽略无浏览器可用的极端情况
}
return true
}
}
// 返回键2 秒内再按一次退出,防误触
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val now = System.currentTimeMillis()
if (now - lastBackPressedAt < 2000) {
finish()
} else {
lastBackPressedAt = now
Toast.makeText(this@MainActivity, "再按一次退出游戏", Toast.LENGTH_SHORT).show()
}
}
})
if (savedInstanceState == null) {
webView.loadUrl(HOME_URL)
} else {
webView.restoreState(savedInstanceState)
}
}
override fun onSaveInstanceState(outState: Bundle) {
webView.saveState(outState)
super.onSaveInstanceState(outState)
}
override fun onDestroy() {
webView.destroy()
super.onDestroy()
}
companion object {
private const val HOME_URL = "https://starraid.cyjai.com"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/splash_bg" />
</shape>
</item>
<item android:gravity="center">
<bitmap android:src="@drawable/splash_logo" android:gravity="center" />
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@drawable/ic_launcher_fg" />
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@drawable/ic_launcher_fg" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@drawable/ic_launcher_fg" />
<monochrome android:drawable="@drawable/ic_launcher_mono" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@drawable/ic_launcher_fg" />
<monochrome android:drawable="@drawable/ic_launcher_mono" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_bg">#0A0A18</color>
<color name="splash_bg">#0A0A18</color>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">星尘突击队</string>
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.StarRaid" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
<item name="android:windowBackground">@drawable/splash_bg</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">starraid.cyjai.com</domain>
</domain-config>
</network-security-config>

View File

@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

View File

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-milestone-1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
starraid-android/gradlew vendored Executable file
View File

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
starraid-android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "StarRaidApp"
include(":app")

View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Star Raid Android 图标生成:使用游戏初始飞机 falcon(ship0) 像素数据"""
import os
import random
from PIL import Image, ImageDraw
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RES = os.path.join(BASE, "app", "src", "main", "res")
# —— 初始飞机 falcon (ship0) 像素数据(来自 star-raid/js/sprites.js——
PAL = {"K": "#14142b", "W": "#f2f0e6", "C": "#43e8ff", "R": "#e0393e", "D": "#8f1d2c", "O": "#ff9a3d"}
SHIP = [
"......K......", ".....KWK.....", ".....KWK.....", ".....KCK.....",
"....KWWWK....", "....KRCRK....", "...KWRRRWK...", "..KWKRDRKWK..",
"..KWRRRRRWK..", ".KWKRRRRRKWK.", ".KRRKWWWKRRK.", "KKRDKWKWKDRKK",
"KD..KWKWK..DK", "K...KOKOK...K", "....KOKOK....",
]
BG_COLOR = (10, 10, 24) # 深空蓝黑
def hex2rgb(h):
h = h.lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def render_ship(scale, mono=False):
w, h = len(SHIP[0]), len(SHIP)
img = Image.new("RGBA", (w * scale, h * scale), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
for y, row in enumerate(SHIP):
for x, ch in enumerate(row):
if ch != ".":
c = (255, 255, 255) if mono else hex2rgb(PAL[ch])
d.rectangle([x * scale, y * scale, (x + 1) * scale - 1, (y + 1) * scale - 1], fill=c + (255,))
return img
def stars_bg(size, seed=7, n=120):
rnd = random.Random(seed)
img = Image.new("RGB", (size, size), BG_COLOR)
d = ImageDraw.Draw(img)
for _ in range(n):
x, y = rnd.randint(0, size - 1), rnd.randint(0, size - 1)
r = rnd.choice([1, 1, 1, 2])
col = rnd.choice([(242, 240, 230), (67, 232, 255), (255, 255, 255), (150, 170, 220)])
d.rectangle([x, y, x + r, y + r], fill=col)
for _ in range(6):
x, y = rnd.randint(20, size - 20), rnd.randint(20, size - 20)
d.rectangle([x - 1, y, x + 1, y], fill=(242, 240, 230))
d.rectangle([x, y - 1, x, y + 1], fill=(242, 240, 230))
return img
def paste_center(canvas, layer, scale):
fg = render_ship(scale)
x = (canvas.width - fg.width) // 2
y = (canvas.height - fg.height) // 2
canvas.alpha_composite(fg, (x, y))
def save_png(img, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
img.save(path, "PNG")
print("saved", path, img.size)
# 1) adaptive icon foreground512 透明底 + 飞机 16x176x240居中安全区内
fg = Image.new("RGBA", (512, 512), (0, 0, 0, 0))
paste_center(fg, render_ship, 16)
save_png(fg, os.path.join(RES, "drawable", "ic_launcher_fg.png"))
# 2) monoAndroid 13 主题图标):白色飞机
mono = Image.new("RGBA", (512, 512), (0, 0, 0, 0))
mono_ship = render_ship(16, mono=True)
mono.alpha_composite(mono_ship, ((512 - mono_ship.width) // 2, (512 - mono_ship.height) // 2))
save_png(mono, os.path.join(RES, "drawable", "ic_launcher_mono.png"))
# 3) 完整图标(背景 + 飞机):用于 legacy mipmap
full = stars_bg(512).convert("RGBA")
paste_center(full, render_ship, 16)
save_png(full, os.path.join(RES, "drawable", "ic_launcher_full.png"))
# 4) legacy mipmap 各尺寸
mipmap_sizes = {"mdpi": 48, "hdpi": 72, "xhdpi": 96, "xxhdpi": 144, "xxxhdpi": 192}
for dpi, size in mipmap_sizes.items():
icon = full.resize((size, size), Image.NEAREST)
for name in ("ic_launcher", "ic_launcher_round"):
save_png(icon, os.path.join(RES, "mipmap-" + dpi, name + ".png"))
# 5) splash logo透明底飞机大图windowBackground layer-list 用)
splash = Image.new("RGBA", (512, 512), (0, 0, 0, 0))
paste_center(splash, render_ship, 24)
save_png(splash, os.path.join(RES, "drawable", "splash_logo.png"))
print("DONE")