原神、星穹鉄道、絶区零を脱坑した後、最近は鳴潮に夢中になっています ||(そうです、「xxx さえあれば xxx でいいのに、xxx は考慮すべきことがたくさんある」というゲームです)||。このような二次元ゲームについて話すと、悪名高いガチャは避けて通れない要素です。ガチャがあれば、必ずキャラクターや武器の偏りがあり、それが「xxx 小助手」や「xxx 工房」などのサードパーティの小プログラムを生み出しました。その重要な機能の一つはガチャ分析です。
事の発端は、ある友人が私の星穹鉄道のガチャ記録を見たいと言ったことですが、以前使用していた小プログラムを開くと、保存していたガチャ記録がすべて消えていました。星鉄クラウドゲームを再ダウンロードしてガチャリンクを再インポートしてデータを復元しようとしましたが、半日かけても小プログラムはずっとガチャアドレスが間違っていると表示し、歴史的な記録を取り戻すことができませんでした。その後、この件がずっと気になっていたので、自分でガチャ記録分析ツールを作ることにしました。データを自分の手元に保存するために、以下のガチャ分析ツールを作成しました:
Astrionyx は Next.js に基づいた Web アプリで、異なるタイプのガチャ記録を分析することができ、手動でデータをインポートまたは更新することも、API を通じてデータをインポートすることもできます。
また、Vercel や自分のサーバーにデプロイすることもサポートしており、MySQL と Vercel Postgres の二つのデータベースをサポートしています。現在、海外のトラフィックは Vercel に解析され、Vercel Postgres データベースを使用しています。一方、国内のトラフィックは自分のサーバーに解析され、自分の MySQL データベースを使用しています。非常に便利です。
Astrionyx の開発過程で、いくつかの面白い問題に直面しました。もしあなたもこのようなアプリを自分で作りたいと思っているなら、参考になれば幸いです。
データのインポートと更新#
データインポート#
ほとんどのゲームと同様に、鳴潮はガチャデータをエクスポートするための公式 API を提供していません。ガチャの履歴の表示方法は、ユーザーが「ガチャ履歴」ボタンをクリックすると、ゲームが一時的なリンクを生成し、ゲーム内のブラウザでそのリンクを開いてガチャ履歴の内容を表示します。この https://aki-gm-resources.aki-game.com/aki/gacha/index.html
で始まるリンクをパケットキャプチャやログファイルから取得できます。
このページは、POST
の方法で API https://gmserver-api.aki-game2.com/gacha/record/query
に対して各ガチャプールの抽選履歴を取得します。このリクエストには、プレイヤー ID(playerId
)、サーバー ID(serverId
)、ガチャプール ID(cardPoolId
)などの重要な情報が含まれており、これらは URL のパラメータから抽出できます:
const url = new URL(sanitizedInput);
const params = new URLSearchParams(url.hash.substring(url.hash.indexOf('?') + 1));
const playerId = params.get('player_id') || '';
const cardPoolId = params.get('resources_id') || '';
const cardPoolType = params.get('gacha_type') || '';
const serverId = params.get('svr_id') || '';
const languageCode = params.get('lang') || '';
const recordId = params.get('record_id') || '';
ここで、ガチャプールタイプパラメータ cardPoolType
の値は [1, 7]
で、具体的なマッピングは以下の通りです:
export const POOL_TYPES = [
{ type: 1, name: "キャラクターイベント召喚" },
{ type: 2, name: "武器イベント召喚" },
{ type: 3, name: "キャラクター常設召喚" },
{ type: 4, name: "武器常設召喚" },
{ type: 5, name: "初心者召喚" },
{ type: 6, name: "初心者自選召喚" },
{ type: 7, name: "感謝の定向召喚" },
];
バックエンド API を作成し、公式 API に転送して各ガチャプールの抽選履歴を取得すれば良いです。
データ更新#
鳴潮などのゲームでは、ガチャに「十連抽」が存在します。
図からわかるように、「十連抽」では同じアイテムが二つ取得されることがあり、つまり二つの記録が抽選時間を含む完全に同じ属性を持っています。Json から取得した結果は以下のようになります:
{
"code": 0,
"message": "success",
"data": [
{
"cardPoolType": "キャラクター精密調整",
"resourceId": 21050043,
"qualityLevel": 3,
"resourceType": "武器",
"name": "遠行者マトリックス・探幽",
"count": 1,
"time": "2025-05-29 01:47:36"
},
...
{
"cardPoolType": "キャラクター精密調整",
"resourceId": 21050043,
"qualityLevel": 3,
"resourceType": "武器",
"name": "遠行者マトリックス・探幽",
"count": 1,
"time": "2025-05-29 01:47:36"
},
...
]
}
これにより、後でガチャ記録を更新する際に、どの記録がすでにインポートされたかを特定できなくなり、統計データの正確性に影響を与えます。したがって、同じ時間に同じアイテムを区別できる番号を構築する必要があります。
ガチャプールタイプ ID + タイムスタンプ + ガチャシーケンス番号 を使用してこのユニーク番号を決定できます。ここで、ガチャシーケンス番号は、同じタイムスタンプでの何回目のガチャかを示します。これにより、上記の二つの同じ武器にユニーク ID を設定し、それぞれ 01174845445601
と 01174845445605
にすることができます。
function generateUniqueId(poolType: string | number, timestamp: number, drawNumber: number): string {
const poolTypeStr = poolType.toString().padStart(2, '0');
const drawNumberStr = drawNumber.toString().padStart(2, '0');
return `${poolTypeStr}${timestamp}${drawNumberStr}`;
}
const uniqueId = generateUniqueId(timestampInSeconds, requestPoolType, drawNumber);
こうすることで、インポートされたデータが元のデータと重複している場合でも、新しいガチャ記録を正常に識別し、データベースを更新できます。
確率データ計算#
ページの統計概要では、ECharts ライブラリを使用してガチャ確率分析の折れ線グラフをコンポーネントの背景として実装し、ガチャシステムの確率分布を表示します。折れ線グラフのデータは、二つの主要な関数によって計算されます:
理論確率計算#
B 站の UP 主一棵平衡樹が書いた 鳴潮ガチャシステム簡析 によると、鳴潮のガチャ確率は以下のモデルに従います。ここで $i$ はガチャ回数です:
これに基づいて理論確率関数 calculateTheoreticalProbability
を構築します:
export const calculateTheoreticalProbability = (): [number, number][] => {
const baseRate = 0.008; // 基礎確率0.8%
const hardPity = 79; // 保底79抽
const data: [number, number][] = [];
const rateIncrease = [
...Array(65).fill(0), // 1-65抽確率は増加しない
...Array(5).fill(0.04), // 66-70抽は毎回+4%
...Array(5).fill(0.08), // 71-75抽は毎回+8%
...Array(3).fill(0.10) // 76-78抽は毎回+10%
];
let currentProbability = baseRate;
for (let i = 1; i <= hardPity; i++) {
if (i === hardPity) {
currentProbability = 1; // 第79抽は必ず出る
} else if (i > 65) {
currentProbability = i === 66
? baseRate + rateIncrease[i - 1]
: currentProbability + rateIncrease[i - 1];
}
data.push([i, currentProbability]);
}
return data;
};
実際の確率計算#
このツールは主に個人使用であり、ガチャデータのサンプル数が少ないため、特定の抽数位置にデータが全くない場合があります。頻度推定を直接使用すると、確率曲線が大きく変動し、0% や 100% といった極端な値が現れることがあります。
このような事態を避けるために、ベイズ平滑化 を導入してより良い結果を実現します。
ここで、
$k_i$:第 $i$ 抽位置で観察された 5 星の数、$n_i$:第 $i$ 抽位置の総抽数、$P_{\text {prior}}$:理論確率、$S$:平滑因子(ここでは 20 を取ります)
その動作は以下の通りです:
もし $n_i$ が小さい(つまりデータ量が少ない場合)、$P_{\text {posterior}}$ は理論確率 $P_{\text {prior}}$ に近づきます。
もし $n_i$ が大きい(つまりデータ量が大きい場合)、$P_{\text {posterior}}$ は実際の頻度 $\frac {k_i}{n_i}$ に近づきます。
最適化された効果は以下の通りです:
実装コードは以下の通りです:
export const calculateActualProbability = (
gachaItems: GachaItem[] | undefined,
theoreticalProbabilityData: [number, number][]
): [number, number][] | null => {
// データ処理ロジック
const S = 20;
const probabilityData: [number, number][] = new Array(79);
for (let i = 0; i < 79; i++) {
const n_i = pullCounts[i];
const k_i = rarityFiveCounts[i];
if (n_i === 0) {
// その抽数位置にデータがない場合、理論確率を使用
probabilityData[i] = [i + 1, theoreticalProbabilityData[i][1]];
} else {
const prior_prob_i = theoreticalProbabilityData[i][1];
const smoothed_probability = (k_i + S * prior_prob_i) / (n_i + S);
probabilityData[i] = [i + 1, smoothed_probability];
}
}
return probabilityData;
};
自動デプロイ#
前述のように、Astrionyx は Vercel と自分のサーバーに同時にデプロイされるため、プロジェクトを修正してリポジトリにプッシュするたびに、サーバーでプルしてビルドする必要があり、非常に面倒です。このテーマの作者は、テーマを自動的に構築し、リモートサーバーにデプロイするワークフローを作成しました。それを少し修正して Astrionyx に使用しました。
しかし、使用中に GitHub からサーバーへのネットワーク接続の質が非常に悪く、構築されたパッケージをサーバーに転送するのに多くの時間がかかることがわかりました(平均 20 分以上)。幸いなことに、Cloudflare の R2 オブジェクトストレージは毎月 10GB の無料ストレージとダウンロード料金が無料で、国内のダウンロード品質も悪くないため、「中継地点」としてこの問題を解決できます。
オブジェクトストレージの主なワークフローは以下の通りで、アップロードとダウンロードの両方に 5 回のリトライを追加しています(単一のダウンロードはネットワークの変動により失敗する可能性があるため、実際に 5 回のリトライでほとんどの問題が解決されます)。
name: 構築とデプロイ
on:
push:
branches:
- main
paths-ignore:
- '**.md'
- 'docs/**'
repository_dispatch:
types: [trigger-workflow]
permissions: write-all
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
PNPM_VERSION: 9.x.x
NODE_VERSION: 20.x
KEEP_DEPLOYMENTS: 1
RELEASE_FILE: release.zip
jobs:
build_and_deploy:
name: 構築とデプロイ
runs-on: ubuntu-latest
steps:
- name: コードをチェックアウト
uses: actions/checkout@v4
with:
fetch-depth: 1
lfs: true
- name: PNPM を設定
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Node.js を設定
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: 依存関係をインストール
run: pnpm install --frozen-lockfile
- name: プロジェクトを構築
run: |
if [ -f "./ci-release-build.sh" ]; then
sh ./ci-release-build.sh
else
echo "構築スクリプトが存在しないため、デフォルトの構築コマンドを使用"
pnpm build
fi
- name: リリースアーカイブを作成
run: |
mkdir -p release_dir
if [ -f "./assets/release.zip" ]; then
mv assets/release.zip release_dir/${{ env.RELEASE_FILE }}
else
echo "assets/release.zip が存在しないため、構築スクリプトの出力を確認"
exit 1
fi
- name: rclone をインストール
run: |
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip
unzip rclone-current-linux-amd64.zip
cd rclone-*-linux-amd64
sudo cp rclone /usr/local/bin/
sudo chown root:root /usr/local/bin/rclone
sudo chmod 755 /usr/local/bin/rclone
rclone version
- name: rclone を設定
run: |
mkdir -p ~/.config/rclone
cat > ~/.config/rclone/rclone.conf << EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = ${{ secrets.R2_ACCESS_KEY_ID }}
secret_access_key = ${{ secrets.R2_SECRET_ACCESS_KEY }}
endpoint = ${{ secrets.R2_ENDPOINT_URL }}
region = auto
acl = private
bucket_acl = private
no_check_bucket = true
force_path_style = true
EOF
- name: R2 ストレージにアップロード
id: upload_to_r2
run: |
echo "R2 にアップロードを開始"
max_retries=5
retry_count=0
upload_success=false
while [ $retry_count -lt $max_retries ] && [ "$upload_success" = "false" ]; do
echo "R2 ストレージに ${retry_count}/${max_retries} をアップロード"
if rclone copy release_dir/${{ env.RELEASE_FILE }} r2:${{ secrets.R2_BUCKET }} --retries 3 --retries-sleep 10s --progress --s3-upload-cutoff=64M --s3-chunk-size=8M --s3-disable-checksum; then
upload_success=true
echo "アップロード成功"
else
echo "アップロード失敗、再試行の準備"
retry_count=$((retry_count + 1))
if [ $retry_count -lt $max_retries ]; then
echo "5秒後に再試行"
sleep 5
fi
fi
done
if [ "$upload_success" = "false" ]; then
echo "最大再試行回数に達しました"
exit 1
fi
DOWNLOAD_URL="${{ secrets.R2_PUBLIC_URL }}/${{ env.RELEASE_FILE }}"
echo "DOWNLOAD_URL=$DOWNLOAD_URL" >> $GITHUB_ENV
echo "ダウンロードURL: $DOWNLOAD_URL"
- name: R2 からダウンロードしてデプロイ
uses: appleboy/[email protected]
with:
command_timeout: 10m
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
password: ${{ secrets.PASSWORD }}
key: ${{ secrets.KEY }}
port: ${{ secrets.PORT }}
script: |
set -e
source $HOME/.bashrc
mkdir -p /tmp/astrionyx
cd /tmp/astrionyx
rm -f release.zip
max_retries=5
retry_count=0
download_success=false
echo ${{ env.DOWNLOAD_URL }}
while [ $retry_count -lt $max_retries ] && [ "$download_success" = "false" ]; do
echo "ダウンロード (${retry_count}/${max_retries})"
if timeout 60s wget -q --show-progress --progress=bar:force:noscroll --no-check-certificate -O release.zip "${{ env.DOWNLOAD_URL }}"; then
if [ -s release.zip ]; then
download_success=true
else
echo "ダウンロードしたファイルが空です、再試行の準備"
fi
else
echo "ダウンロード失敗、再試行の準備"
fi
retry_count=$((retry_count + 1))
if [ $retry_count -lt $max_retries ] && [ "$download_success" = "false" ]; then
echo "5秒後に再試行"
sleep 5
rm -f release.zip
fi
done
if [ "$download_success" = "false" ]; then
echo "最大再試行回数に達しました"
exit 1
fi
basedir=$HOME/astrionyx
workdir=$basedir/${{ github.run_number }}
mkdir -p $workdir
mkdir -p $basedir/.cache
mv /tmp/astrionyx/release.zip $workdir/release.zip
cd $workdir
unzip -q $workdir/release.zip
rm -f $workdir/release.zip
rm -rf $workdir/standalone/.env
ln -s $HOME/astrionyx/.env $workdir/standalone/.env
export NEXT_SHARP_PATH=$(npm root -g)/sharp
cp $workdir/standalone/ecosystem.config.js $basedir/ecosystem.config.js
rm -f $basedir/server.mjs
ln -s $workdir/standalone/server.mjs $basedir/server.mjs
mkdir -p $workdir/standalone/.next
rm -rf $workdir/standalone/.next/cache
ln -sf $basedir/.cache $workdir/standalone/.next/cache
cd $basedir
# 使用可能なポートに変更することを忘れないでください 👇
export PORT=8523
pm2 reload server.mjs --update-env || pm2 start server.mjs --name astrionyx --interpreter node --interpreter-args="--enable-source-maps"
pm2 save
echo "${{ github.run_number }}" > $basedir/current_deployment
echo "古いデプロイをクリーンアップし、最新の ${{ env.KEEP_DEPLOYMENTS }} バージョンを保持"
current_run=${{ github.run_number }}
ls -d $basedir/[0-9]* 2>/dev/null | grep -v "$basedir/$current_run" | sort -rn | awk -v keep=${{ env.KEEP_DEPLOYMENTS }} 'NR>keep' | while read dir; do
echo "古いバージョンを削除: $dir"
rm -rf "$dir"
done
rm -rf /tmp/astrionyx 2>/dev/null || true
echo "デプロイ完了"
- name: デプロイ後スクリプトを実行
if: success()
run: |
if [ -n "${{ secrets.AFTER_DEPLOY_SCRIPT }}" ]; then
echo "デプロイ後スクリプトを実行"
${{ secrets.AFTER_DEPLOY_SCRIPT }}
fi
- name: R2 のファイルを削除
if: always()
run: |
echo "R2 ストレージの一時ファイルをクリーンアップ"
rclone delete r2:${{ secrets.R2_BUCKET }}/${{ env.RELEASE_FILE }}
このワークフローを使用することで、主分支にコードをプッシュするたびに、Astrionyx は自動的に構築され、サーバーにデプロイされます。また、R2 オブジェクトストレージを中継地点として使用することで、デプロイ時間を大幅に短縮できます(全体のワークフローが 20 分から 4 分以内に短縮され、時間を節約できます)。
iOS ウィジェット#
Astrionyx は他のアプリに接続するための API を提供しており、iOS では Scriptable というアプリを使用してウィジェットを開発できます。
/**
* 鳴潮ウィジェット
*
* Author: Vinking
*/
const config = {
apiUrl: "https://example.com/path/to/your/api",
userId: "uid",
design: {
typography: {
small: {
hero: { size: 18, weight: 'heavy' },
title: { size: 12, weight: 'medium' },
body: { size: 10, weight: 'regular' },
caption: { size: 8, weight: 'medium' }
},
medium: {
hero: { size: 24, weight: 'heavy' },
title: { size: 14, weight: 'medium' },
body: { size: 12, weight: 'regular' },
caption: { size: 10, weight: 'medium' }
},
large: {
hero: { size: 28, weight: 'heavy' },
title: { size: 16, weight: 'medium' },
body: { size: 14, weight: 'regular' },
caption: { size: 11, weight: 'medium' }
}
},
spacing: {
small: { xs: 4, sm: 6, md: 8, lg: 12, xl: 16 },
medium: { xs: 6, sm: 8, md: 12, lg: 16, xl: 20 },
large: { xs: 8, sm: 10, md: 16, lg: 20, xl: 24 }
},
radius: {
small: 12,
medium: 16,
large: 20,
element: 8
}
},
colors: {
primary: "#7c3aed",
primarySoft: "#8b5cf6",
success: "#059669",
warning: "#d97706",
accent: "#a855f7",
pitySmall: "#0891b2",
text: {
primary: "#0f172a",
secondary: "#1e293b",
tertiary: "#475569",
muted: "#64748b",
accent: "#4f46e5"
},
surface: {
primary: "#f8fafc",
secondary: "#f1f5f9",
elevated: "#e2e8f0"
},
luck: {
positive: "#eab308",
negative: "#65a30d"
}
}
};
function getColor(colorName) {
if (colorName && colorName.startsWith('#')) {
return new Color(colorName);
}
if (typeof config.colors[colorName] === 'string') {
return new Color(config.colors[colorName]);
}
if (colorName && colorName.includes('#')) {
return new Color(colorName);
}
if (colorName && colorName.includes('.')) {
const [category, subcategory] = colorName.split('.');
if (category === 'text' || category === 'surface') {
return new Color(config.colors[category][subcategory]);
}
if (category === 'luck') {
return new Color(config.colors.luck[subcategory]);
}
}
console.warn(`色を解析できません: ${colorName}`);
return new Color("#666666");
}
function getWidgetSize() {
return config.widgetFamily || 'medium';
}
function getDesignTokens(size) {
return {
typography: config.design.typography[size],
spacing: config.design.spacing[size],
radius: config.design.radius[size] || config.design.radius.medium
};
}
async function createWidget() {
const widget = new ListWidget();
const size = getWidgetSize();
const tokens = getDesignTokens(size);
setupWidgetStyle(widget, size, tokens);
try {
const data = await fetchGachaData();
if (!data.success || !data.data?.length) {
throw new Error("データがありません");
}
// 限定キャラクタープールデータを探す (poolType = "1")
const characterPool = data.data.find(pool => pool.poolType === "1");
if (!characterPool) {
throw new Error("限定キャラクタープールデータが見つかりません");
}
switch (size) {
case 'small':
buildSmallLayout(widget, characterPool, tokens);
break;
case 'medium':
buildMediumLayout(widget, characterPool, tokens);
break;
case 'large':
buildLargeLayout(widget, characterPool, tokens);
break;
}
} catch (error) {
buildErrorLayout(widget, error.message, tokens);
}
return widget;
}
function setupWidgetStyle(widget, size, tokens) {
widget.backgroundColor = getColor('surface.primary');
widget.cornerRadius = tokens.radius;
const padding = tokens.spacing.lg;
widget.setPadding(padding, padding, padding, padding);
}
async function fetchGachaData() {
const request = new Request(config.apiUrl);
request.timeoutInterval = 15;
return await request.loadJSON();
}
function buildSmallLayout(widget, pool, tokens) {
const { totalCount = 0, highestRarityCount = 0, luckIndex = 0 } = pool;
const header = createHeader(widget, tokens, true);
widget.addSpacer(tokens.spacing.md);
const statsContainer = widget.addStack();
statsContainer.layoutHorizontally();
statsContainer.spacing = tokens.spacing.sm;
const totalCard = createStatCard(
statsContainer,
totalCount.toString(),
"総抽数",
getColor('primary'),
tokens
);
const legendaryCard = createStatCard(
statsContainer,
highestRarityCount.toString(),
"五星数",
getColor('accent'),
tokens
);
widget.addSpacer(tokens.spacing.sm);
createLuckIndicator(widget, luckIndex, tokens, 'compact');
}
function buildMediumLayout(widget, pool, tokens) {
const {
totalCount = 0,
highestRarityCount = 0,
averagePull = 0,
isSmallPity,
luckIndex = 0
} = pool;
createHeader(widget, tokens, false);
widget.addSpacer(tokens.spacing.lg);
const mainStats = widget.addStack();
mainStats.layoutHorizontally();
mainStats.spacing = tokens.spacing.md;
createStatColumn(mainStats, totalCount.toString(), "総抽数", getColor('primary'), tokens);
const divider1 = mainStats.addStack();
divider1.backgroundColor = new Color(getColor('text.muted').hex, 0.1);
divider1.cornerRadius = 1;
divider1.size = new Size(1, tokens.typography.hero.size);
createStatColumn(mainStats, highestRarityCount.toString(), "五星数", getColor('accent'), tokens);
const divider2 = mainStats.addStack();
divider2.backgroundColor = new Color(getColor('text.muted').hex, 0.1);
divider2.cornerRadius = 1;
divider2.size = new Size(1, tokens.typography.hero.size);
createStatColumn(mainStats, averagePull.toFixed(1), "平均", getColor('success'), tokens);
widget.addSpacer(tokens.spacing.lg);
const bottomRow = widget.addStack();
bottomRow.layoutHorizontally();
bottomRow.centerAlignContent();
const pityIndicator = createPityStatus(bottomRow, isSmallPity, tokens);
bottomRow.addSpacer();
createLuckIndicator(bottomRow, luckIndex, tokens, 'inline');
}
function buildLargeLayout(widget, pool, tokens) {
const {
totalCount = 0,
highestRarityCount = 0,
averagePull = 0,
isSmallPity,
luckIndex = 0,
lastPullTime
} = pool;
createDetailedHeader(widget, tokens);
widget.addSpacer(tokens.spacing.xl);
const statsGrid = widget.addStack();
statsGrid.layoutHorizontally();
statsGrid.spacing = tokens.spacing.lg;
createStatColumn(statsGrid, totalCount.toString(), "総抽数", getColor('primary'), tokens);
createStatColumn(statsGrid, highestRarityCount.toString(), "五星数", getColor('accent'), tokens);
createStatColumn(statsGrid, averagePull.toFixed(1), "平均", getColor('success'), tokens);
widget.addSpacer(tokens.spacing.xl);
createDivider(widget);
widget.addSpacer(tokens.spacing.lg);
createDetailedPityStatus(widget, isSmallPity, tokens);
widget.addSpacer(tokens.spacing.md);
createLuckIndicator(widget, luckIndex, tokens, 'detailed');
widget.addSpacer(tokens.spacing.lg);
createFooter(widget, tokens);
}
function createHeader(widget, tokens, compact = false) {
const header = widget.addStack();
header.layoutHorizontally();
header.centerAlignContent();
const iconSymbol = SFSymbol.named("person.fill.viewfinder");
iconSymbol.applyFont(Font.systemFont(tokens.typography.title.size + 2));
const icon = header.addImage(iconSymbol.image);
icon.imageSize = new Size(tokens.typography.title.size + 2, tokens.typography.title.size + 2);
icon.tintColor = getColor('primary');
header.addSpacer(tokens.spacing.xs);
const title = header.addText("限定キャラクター");
title.font = Font.boldSystemFont(tokens.typography.title.size);
title.textColor = getColor('text.accent');
title.lineLimit = 1;
if (!compact) {
header.addSpacer();
const uid = header.addText(`UID: ${config.userId}`);
uid.font = Font.mediumSystemFont(tokens.typography.caption.size);
uid.textColor = getColor('text.muted');
}
return header;
}
function createDetailedHeader(widget, tokens) {
const header = createHeader(widget, tokens, false);
widget.addSpacer(tokens.spacing.sm);
const subtitleStack = widget.addStack();
subtitleStack.layoutHorizontally();
const subtitleIcon = SFSymbol.named("square.grid.3x3.bottomright.filled");
subtitleIcon.applyFont(Font.systemFont(tokens.typography.body.size));
const icon = subtitleStack.addImage(subtitleIcon.image);
icon.imageSize = new Size(tokens.typography.body.size, tokens.typography.body.size);
icon.tintColor = getColor('primarySoft');
subtitleStack.addSpacer(tokens.spacing.xs);
const subtitle = subtitleStack.addText("限定キャラクタープールデータ概要");
subtitle.font = Font.systemFont(tokens.typography.body.size);
subtitle.textColor = getColor('text.secondary');
}
function createStatCard(container, value, label, color, tokens) {
const card = container.addStack();
card.layoutVertically();
card.centerAlignContent();
const bgColor = getColor('surface.elevated');
const borderColor = new Color(color.hex, 0.2);
card.backgroundColor = new Color(bgColor.hex, 0.3);
card.borderColor = borderColor;
card.borderWidth = 1;
card.cornerRadius = config.design.radius.element;
card.setPadding(tokens.spacing.sm, tokens.spacing.md, tokens.spacing.sm, tokens.spacing.md);
const valueText = card.addText(value);
valueText.font = Font.heavySystemFont(tokens.typography.hero.size);
valueText.textColor = color;
valueText.centerAlignText();
valueText.shadowColor = new Color(color.hex, 0.3);
valueText.shadowOffset = new Point(0, 1);
valueText.shadowRadius = 2;
card.addSpacer(2);
const labelText = card.addText(label);
labelText.font = Font.mediumSystemFont(tokens.typography.caption.size);
labelText.textColor = getColor('text.tertiary');
labelText.centerAlignText();
return card;
}
function createStatColumn(container, value, label, color, tokens) {
const column = container.addStack();
column.layoutVertically();
column.centerAlignContent();
const valueText = column.addText(value);
valueText.font = Font.heavySystemFont(tokens.typography.hero.size);
valueText.textColor = color;
valueText.centerAlignText();
valueText.shadowColor = new Color(color.hex, 0.3);
valueText.shadowOffset = new Point(0, 1);
valueText.shadowRadius = 1;
column.addSpacer(tokens.spacing.xs);
const labelText = column.addText(label);
labelText.font = Font.systemFont(tokens.typography.body.size);
labelText.textColor = getColor('text.secondary');
labelText.centerAlignText();
return column;
}
function createPityStatus(container, isSmallPity, tokens) {
const status = container.addStack();
status.layoutHorizontally();
status.centerAlignContent();
const dotColor = isSmallPity
? new Color(config.colors.pitySmall)
: getColor('success');
const bgColor = new Color(dotColor.hex, 0.15);
status.backgroundColor = bgColor;
status.cornerRadius = tokens.spacing.sm;
status.setPadding(tokens.spacing.xs, tokens.spacing.md, tokens.spacing.xs, tokens.spacing.md);
const iconName = isSmallPity ? "diamond.bottomhalf.filled" : "diamond.fill";
const iconSymbol = SFSymbol.named(iconName);
iconSymbol.applyFont(Font.systemFont(tokens.typography.caption.size));
const icon = status.addImage(iconSymbol.image);
icon.imageSize = new Size(tokens.typography.caption.size, tokens.typography.caption.size);
icon.tintColor = dotColor;
status.addSpacer(tokens.spacing.xs);
const text = status.addText(isSmallPity ? "小保底" : "大保底");
text.font = Font.boldSystemFont(tokens.typography.body.size);
text.textColor = dotColor;
return status;
}
function createDetailedPityStatus(widget, isSmallPity, tokens) {
const row = widget.addStack();
row.layoutHorizontally();
row.centerAlignContent();
const label = row.addText("保底状態");
label.font = Font.systemFont(tokens.typography.body.size);
label.textColor = getColor('text.secondary');
row.addSpacer();
createPityStatus(row, isSmallPity, tokens);
}
function createLuckIndicator(container, luckIndex, tokens, style = 'inline') {
const luckColor = getLuckColor(luckIndex);
if (style === 'compact') {
const luck = container.addStack();
luck.layoutHorizontally();
luck.centerAlignContent();
const iconSymbol = SFSymbol.named("sparkles");
iconSymbol.applyFont(Font.systemFont(tokens.typography.body.size));
const icon = luck.addImage(iconSymbol.image);
icon.imageSize = new Size(tokens.typography.body.size, tokens.typography.body.size);
icon.tintColor = luckColor;
luck.addSpacer(tokens.spacing.xs);
const value = luck.addText(`${luckIndex > 0 ? '+' : ''}${luckIndex}%`);
value.font = Font.heavySystemFont(tokens.typography.title.size);
value.textColor = luckColor;
value.shadowColor = new Color(luckColor.hex, 0.3);
value.shadowOffset = new Point(0, 1);
value.shadowRadius = 1;
} else if (style === 'inline') {
const luck = container.addStack();
luck.layoutHorizontally();
luck.centerAlignContent();
const label = luck.addText("欧気");
label.font = Font.systemFont(tokens.typography.body.size);
label.textColor = getColor('text.secondary');
luck.addSpacer(tokens.spacing.xs);
const value = luck.addText(`${luckIndex > 0 ? '+' : ''}${luckIndex}%`);
value.font = Font.heavySystemFont(tokens.typography.title.size);
value.textColor = luckColor;
value.shadowColor = new Color(luckColor.hex, 0.3);
value.shadowOffset = new Point(0, 1);
value.shadowRadius = 1;
} else if (style === 'detailed') {
const row = container.addStack();
row.layoutHorizontally();
row.centerAlignContent();
const iconSymbol = SFSymbol.named("sparkles.square.filled.on.square");
iconSymbol.applyFont(Font.systemFont(tokens.typography.body.size));
const icon = row.addImage(iconSymbol.image);
icon.imageSize = new Size(tokens.typography.body.size, tokens.typography.body.size);
icon.tintColor = luckColor;
row.addSpacer(tokens.spacing.xs);
const label = row.addText("欧気指数");
label.font = Font.systemFont(tokens.typography.body.size);
label.textColor = getColor('text.secondary');
row.addSpacer();
const value = row.addText(`${luckIndex > 0 ? '+' : ''}${luckIndex}%`);
value.font = Font.heavySystemFont(tokens.typography.title.size);
value.textColor = luckColor;
value.shadowColor = new Color(luckColor.hex, 0.3);
value.shadowOffset = new Point(0, 1);
value.shadowRadius = 1;
}
}
function getLuckColor(luckIndex) {
return luckIndex >= 0
? getColor('luck.positive')
: getColor('luck.negative');
}
function createDivider(widget) {
const divider = widget.addStack();
const gradient = new LinearGradient();
gradient.colors = [
new Color(getColor('text.muted').hex, 0.05),
new Color(getColor('text.muted').hex, 0.2),
new Color(getColor('text.muted').hex, 0.05)
];
gradient.locations = [0, 0.5, 1];
divider.backgroundGradient = gradient;
divider.cornerRadius = 1;
divider.size = new Size(0, 1);
}
function createFooter(widget, tokens) {
const footerStack = widget.addStack();
footerStack.layoutHorizontally();
footerStack.centerAlignContent();
const iconSymbol = SFSymbol.named("wave.3.right");
iconSymbol.applyFont(Font.systemFont(tokens.typography.caption.size));
const icon = footerStack.addImage(iconSymbol.image);
icon.imageSize = new Size(tokens.typography.caption.size, tokens.typography.caption.size);
icon.tintColor = getColor('text.muted');
footerStack.addSpacer(tokens.spacing.xs);
const footer = footerStack.addText("Powered by Astrionyx");
footer.font = Font.systemFont(tokens.typography.caption.size);
footer.textColor = getColor('text.muted');
footer.alpha = 0.8;
}
function buildErrorLayout(widget, message, tokens) {
const container = widget.addStack();
container.layoutVertically();
container.centerAlignContent();
const iconSymbol = SFSymbol.named("xmark.octagon.fill");
iconSymbol.applyFont(Font.systemFont(tokens.typography.hero.size));
const icon = container.addImage(iconSymbol.image);
icon.imageSize = new Size(tokens.typography.hero.size, tokens.typography.hero.size);
icon.tintColor = getColor('warning');
container.addSpacer(tokens.spacing.sm);
const errorTitle = container.addText("データ取得失敗");
errorTitle.font = Font.boldSystemFont(tokens.typography.title.size);
errorTitle.textColor = getColor('text.primary');
errorTitle.centerAlignText();
container.addSpacer(tokens.spacing.xs);
const errorStack = container.addStack();
errorStack.backgroundColor = new Color(getColor('warning').hex, 0.1);
errorStack.cornerRadius = 8;
errorStack.setPadding(tokens.spacing.sm, tokens.spacing.md, tokens.spacing.sm, tokens.spacing.md);
const text = errorStack.addText(message);
text.font = Font.mediumSystemFont(tokens.typography.body.size);
text.textColor = getColor('warning');
text.centerAlignText();
text.lineLimit = 2;
container.addSpacer(tokens.spacing.md);
const refreshStack = container.addStack();
refreshStack.layoutHorizontally();
refreshStack.centerAlignContent();
const refreshIcon = SFSymbol.named("arrow.clockwise");
refreshIcon.applyFont(Font.systemFont(tokens.typography.caption.size));
const refreshIconImage = refreshStack.addImage(refreshIcon.image);
refreshIconImage.imageSize = new Size(tokens.typography.caption.size, tokens.typography.caption.size);
refreshIconImage.tintColor = getColor('text.tertiary');
refreshStack.addSpacer(tokens.spacing.xs);
const refreshHint = refreshStack.addText("後でリフレッシュして再試行してください");
refreshHint.font = Font.systemFont(tokens.typography.caption.size);
refreshHint.textColor = getColor('text.tertiary');
}
async function main() {
const widget = await createWidget();
if (config.runsInWidget) {
Script.setWidget(widget);
} else {
const size = config.previewSize || "medium";
switch (size) {
case "small":
widget.presentSmall();
break;
case "large":
widget.presentLarge();
break;
default:
widget.presentMedium();
}
}
Script.complete();
}
await main();
以上です🎉。
この記事は Mix Space によって xLog に同期更新されました
元のリンクは https://www.vinking.top/posts/codes/astrionyx-wuwa-gacha-analysis