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 + 時間戳 + 抽卡序號 來確定這個唯一編號。其中,抽卡序號表示:從 1 開始計數,在相同時間戳下的第幾次抽卡。由此,可以將上面的兩個相同的武器設置 uniqueId,分別為 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 以及自己的伺服器上,所以每次修改完項目 push 到倉庫的時候,都需要在伺服器上 pull 以及 build,非常麻煩。這個主題的作者寫過一個將主題自動構建並且部署到遠程伺服器的工作流,將其稍作修改後用到了 Astrionyx 上。

但是使用過程中發現,GitHub 到伺服器之間的網絡連接質量非常差,導致每次將構建好的包傳輸到伺服器都需要花費大量時間(平均 20 min+)。恰好的是,Cloudflare 家的 R2 對象存儲有每月 10 GB 的免費存儲額度以及免下載費用,境內的下載質量也算不錯,可以作為「中轉站」來解決這個問題。

結合對象存儲的主要工作流如下,給上傳和下載都加上了 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 "上傳 ${retry_count}/${max_retries} 到 R2 存儲"
            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 對象存儲作為中轉站,能極大縮短部署時間(整套工作流從 20min 降低到 4 min 內),節省生命。

使用 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();

That's all🎉.

此文由 Mix Space 同步更新至 xLog
原始鏈接為 https://www.vinking.top/posts/codes/astrionyx-wuwa-gacha-analysis


載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。