banner
Vinking

Vinking

你写下的每一个BUG 都是人类反抗被人工智能统治的一颗子弹

Astrionyx - 鳴潮抽卡分析ツールとその詳細

原神、星穹鉄道、絶区零を脱坑した後、最近は鳴潮に夢中になっています ||(そうです、「xxx さえあれば xxx でいいのに、xxx は考慮すべきことがたくさんある」というゲームです)||。このような二次元ゲームについて話すと、悪名高いガチャは避けて通れない要素です。ガチャがあれば、必ずキャラクターや武器の偏りがあり、それが「xxx 小助手」や「xxx 工房」などのサードパーティの小プログラムを生み出しました。その重要な機能の一つはガチャ分析です。

事の発端は、ある友人が私の星穹鉄道のガチャ記録を見たいと言ったことですが、以前使用していた小プログラムを開くと、保存していたガチャ記録がすべて消えていました。星鉄クラウドゲームを再ダウンロードしてガチャリンクを再インポートしてデータを復元しようとしましたが、半日かけても小プログラムはずっとガチャアドレスが間違っていると表示し、歴史的な記録を取り戻すことができませんでした。その後、この件がずっと気になっていたので、自分でガチャ記録分析ツールを作ることにしました。データを自分の手元に保存するために、以下のガチャ分析ツールを作成しました:

Astrionyx

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 を設定し、それぞれ 0117484544560101174845445605 にすることができます。

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$ はガチャ回数です:

P5[i]={0.008(i<66)0.008+0.04(i65)(66i<71)0.208+0.08(i70)(71i<76)0.608+0.1(i75)(76i<79)1(i=79)P_5[i] = \begin{cases} 0.008 & (i < 66) \\ 0.008 + 0.04 \cdot (i - 65) & (66 \leq i < 71) \\ 0.208 + 0.08 \cdot (i - 70) & (71 \leq i < 76) \\ 0.608 + 0.1 \cdot (i - 75) & (76 \leq i < 79) \\ 1 & (i = 79) \end{cases}

これに基づいて理論確率関数 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% といった極端な値が現れることがあります。

頻度推定

このような事態を避けるために、ベイズ平滑化 を導入してより良い結果を実現します。

Pposterior=ki+SPpriorni+SP_{\text{posterior}} = \frac{k_i + S \cdot P_{\text{prior}}}{n_i + S}

ここで、

$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 分以内に短縮され、時間を節約できます)。

R2 オブジェクトストレージを使用して最適化

iOS ウィジェット#

Astrionyx は他のアプリに接続するための API を提供しており、iOS では Scriptable というアプリを使用してウィジェットを開発できます。

R2 オブジェクトストレージを使用して最適化

/**
 * 鳴潮ウィジェット
 * 
 * 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


読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。