After getting out of the pit of Genshin Impact, Star Rail, and Zero Zone, I recently became obsessed with Tide || (Yes, it's the game where "xxx only needs xxx, while xxx has a lot to consider") ||. When it comes to this type of 2D game, the evil gacha system is an unavoidable part; where there are gacha pulls, there will inevitably be character and weapon discrepancies, leading to a plethora of third-party mini-programs like "xxx Assistant" and "xxx Workshop," one of whose important functions is gacha analysis.
The incident began when a friend wanted to check my Star Rail gacha records, but upon opening the mini-program I previously used, I found that all the gacha records I had saved were gone. I tried downloading the Star Rail cloud game to re-import the gacha link to recover the data, but after a long struggle, the mini-program kept indicating that the gacha address was incorrect, and I couldn't retrieve the historical records. I was troubled by this for a long time, so I decided to create a gacha record analysis tool to keep the data in my own hands, which led to the development of the following gacha analysis tool:
Astrionyx is a web application based on Next.js that supports analyzing different types of gacha pool records. You can manually import or update data, or import data via API.
It also supports deployment on Vercel and your own server, with support for both MySQL and Vercel Postgres databases. Currently, overseas traffic is routed to Vercel, using the Vercel Postgres database; while domestic traffic is routed to your own server, using your own MySQL database, which is very convenient.
During the development of Astrionyx, I encountered many interesting problems. If you also want to write such an application, I hope this helps you.
Data Import and Update#
Data Import#
Like most games, Tide does not provide an official API for exporting gacha data. The display method for gacha history is: when the user clicks the "Gacha History" button, the game generates a temporary link and opens it through the in-game browser to present the gacha history content. We can obtain this link starting with https://aki-gm-resources.aki-game.com/aki/gacha/index.html
through packet capturing or from log files.
This page will use POST
to request the API https://gmserver-api.aki-game2.com/gacha/record/query
to get the gacha history for each pool. This request contains key information such as player ID (playerId
), server ID (serverId
), and pool ID (cardPoolId
), all of which can be extracted from the parameters in the 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') || '';
Among them, the pool type parameter cardPoolType
can take values of [1, 7]
, with specific mappings as follows:
export const POOL_TYPES = [
{ type: 1, name: "Character Event Gacha" },
{ type: 2, name: "Weapon Event Gacha" },
{ type: 3, name: "Character Permanent Gacha" },
{ type: 4, name: "Weapon Permanent Gacha" },
{ type: 5, name: "Beginner Gacha" },
{ type: 6, name: "Beginner Select Gacha" },
{ type: 7, name: "Thanksgiving Targeted Gacha" },
];
Create a backend API to forward requests to the official API to get the gacha history for each pool.
Data Update#
In games like Tide, gacha pulls can occur in "ten consecutive pulls."
As shown in the image, "ten consecutive pulls" can result in obtaining two identical items, meaning two records have completely identical attributes, including the pull time. The result obtained via JSON looks like this:
{
"code": 0,
"message": "success",
"data": [
{
"cardPoolType": "Character Precision Tuning",
"resourceId": 21050043,
"qualityLevel": 3,
"resourceType": "Weapon",
"name": "Traveler Matrix·Explore",
"count": 1,
"time": "2025-05-29 01:47:36"
},
...
{
"cardPoolType": "Character Precision Tuning",
"resourceId": 21050043,
"qualityLevel": 3,
"resourceType": "Weapon",
"name": "Traveler Matrix·Explore",
"count": 1,
"time": "2025-05-29 01:47:36"
},
...
]
}
This will lead to uncertainty about which records have already been imported when updating gacha records later, thus affecting the accuracy of statistical data. Therefore, we need to construct a unique identifier that can distinguish two identical items even at the same time.
We can determine this unique identifier through pool type ID + timestamp + gacha sequence number. Here, the gacha sequence number indicates: starting from 1, it counts how many times the gacha was pulled at the same timestamp. Thus, the two identical weapons above can be set with uniqueIds of 01174845445601
and 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);
This way, even if the imported data overlaps with existing data, new gacha records can still be correctly identified and the database updated.
Probability Data Calculation#
In the statistical overview of the page, an ECharts library is used to implement a gacha probability analysis line chart as a component background to display the probability distribution of the gacha system. The data for the line chart is calculated through two main functions:
Theoretical Probability Calculation#
According to the analysis of the gacha system by Bilibili UP master "A Balanced Tree," the gacha probability in Tide conforms to the following model, where $i$ is the number of pulls:
Based on this, we construct a theoretical probability function calculateTheoreticalProbability
:
export const calculateTheoreticalProbability = (): [number, number][] => {
const baseRate = 0.008; // Base probability 0.8%
const hardPity = 79; // Hard pity at 79 pulls
const data: [number, number][] = [];
const rateIncrease = [
...Array(65).fill(0), // Probability does not increase for pulls 1-65
...Array(5).fill(0.04), // For pulls 66-70, increase by 4% each
...Array(5).fill(0.08), // For pulls 71-75, increase by 8% each
...Array(3).fill(0.10) // For pulls 76-78, increase by 10% each
];
let currentProbability = baseRate;
for (let i = 1; i <= hardPity; i++) {
if (i === hardPity) {
currentProbability = 1; // The 79th pull guarantees a 5-star
} else if (i > 65) {
currentProbability = i === 66
? baseRate + rateIncrease[i - 1]
: currentProbability + rateIncrease[i - 1];
}
data.push([i, currentProbability]);
}
return data;
};
Actual Probability Calculation#
Since the tool is mainly for personal use, the sample size of gacha data is small, and some pull positions may have no data at all. If frequency estimation is used directly, it can lead to extreme values like 0% or 100% in the probability curve.
To avoid this situation, we can introduce Bayesian smoothing for better results.
Where,
$k_i$: Number of 5-star items observed at the $i$-th pull position, $n_i$: Total number of pulls at the $i$-th position, $P_{\text{prior}}$: Theoretical probability, $S$: Smoothing factor (set to 20 here)
Its behavior is as follows:
If $n_i$ is small (i.e., when the data volume is small), $P_{\text{posterior}}$ will be closer to the theoretical probability $P_{\text{prior}}$.
If $n_i$ is large (i.e., when the data volume is large), $P_{\text{posterior}}$ will be closer to the actual frequency $\frac{k_i}{n_i}$.
The optimized effect is as follows:
The implementation code is as follows:
export const calculateActualProbability = (
gachaItems: GachaItem[] | undefined,
theoreticalProbabilityData: [number, number][]
): [number, number][] | null => {
// Data processing logic
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) {
// If there is no data for this pull position, use theoretical probability
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;
};
Automatic Deployment#
As mentioned earlier, Astrionyx is deployed on both Vercel and its own server, so every time the project is modified and pushed to the repository, it is necessary to pull and build on the server, which is very cumbersome. The author of this theme wrote a workflow to automatically build and deploy the theme to a remote server, which I slightly modified for use in Astrionyx.
However, during use, I found that the network connection quality between GitHub and the server is very poor, causing the transfer of the built package to the server to take a lot of time (averaging over 20 minutes). Fortunately, Cloudflare's R2 object storage offers 10 GB of free storage per month and no download fees, and the download quality within the country is decent, making it a good "transit station" to solve this problem.
The main workflow for object storage is as follows, with 5 retries added for both uploads and downloads (a single download may still fail due to network fluctuations, and in practice, 5 retries can solve most problems):
name: Build and Deploy
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: Build and Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 1
lfs: true
- name: Set PNPM
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Set Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Build Project
run: |
if [ -f "./ci-release-build.sh" ]; then
sh ./ci-release-build.sh
else
echo "Build script does not exist, using default build command"
pnpm build
fi
- name: Create Release Archive
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 does not exist, check build script output"
exit 1
fi
- name: Install 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: Configure 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: Upload to R2 Storage
id: upload_to_r2
run: |
echo "Starting upload to R2"
max_retries=5
retry_count=0
upload_success=false
while [ $retry_count -lt $max_retries ] && [ "$upload_success" = "false" ]; do
echo "Uploading ${retry_count}/${max_retries} to R2 storage"
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 "Upload successful"
else
echo "Upload failed, preparing to retry"
retry_count=$((retry_count + 1))
if [ $retry_count -lt $max_retries ]; then
echo "Waiting 5 seconds before retrying"
sleep 5
fi
fi
done
if [ "$upload_success" = "false" ]; then
echo "Reached maximum retry count"
exit 1
fi
DOWNLOAD_URL="${{ secrets.R2_PUBLIC_URL }}/${{ env.RELEASE_FILE }}"
echo "DOWNLOAD_URL=$DOWNLOAD_URL" >> $GITHUB_ENV
echo "Download URL: $DOWNLOAD_URL"
- name: Download and Deploy from 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 "Downloading (${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 "Downloaded file is empty, preparing to retry"
fi
else
echo "Download failed, preparing to retry"
fi
retry_count=$((retry_count + 1))
if [ $retry_count -lt $max_retries ] && [ "$download_success" = "false" ]; then
echo "Waiting 5 seconds before retrying"
sleep 5
rm -f release.zip
fi
done
if [ "$download_success" = "false" ]; then
echo "Reached maximum retry count"
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
# Remember to change to your available port 👇
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 "Cleaning up old deployments, keeping ${{ env.KEEP_DEPLOYMENTS }} latest versions"
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 "Deleting old version: $dir"
rm -rf "$dir"
done
rm -rf /tmp/astrionyx 2>/dev/null || true
echo "Deployment completed"
- name: Run Post-Deployment Script
if: success()
run: |
if [ -n "${{ secrets.AFTER_DEPLOY_SCRIPT }}" ]; then
echo "Executing post-deployment script"
${{ secrets.AFTER_DEPLOY_SCRIPT }}
fi
- name: Delete Files in R2
if: always()
run: |
echo "Cleaning up temporary files in R2 storage"
rclone delete r2:${{ secrets.R2_BUCKET }}/${{ env.RELEASE_FILE }}
With this workflow, every time code is pushed to the main branch, Astrionyx will automatically build and deploy to the server. Using R2 object storage as a transit station can greatly shorten deployment time (reducing the entire workflow from 20 minutes to under 4 minutes), saving time.
iOS Widget#
Astrionyx provides an API for integration with other applications. On iOS, you can use the Scriptable app to develop a display widget.
/**
* Tide Widget
*
* 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(`Unable to parse color: ${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("No data available");
}
// Find limited character pool data (poolType = "1")
const characterPool = data.data.find(pool => pool.poolType === "1");
if (!characterPool) {
throw new Error("Limited character pool data not found");
}
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(),
"Total Pulls",
getColor('primary'),
tokens
);
const legendaryCard = createStatCard(
statsContainer,
highestRarityCount.toString(),
"5-Star Count",
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(), "Total Pulls", 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(), "5-Star Count", 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), "Average", 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(), "Total Pulls", getColor('primary'), tokens);
createStatColumn(statsGrid, highestRarityCount.toString(), "5-Star Count", getColor('accent'), tokens);
createStatColumn(statsGrid, averagePull.toFixed(1), "Average", 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("Limited Characters");
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("Overview of Limited Character Pool Data");
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 ? "Small Pity" : "Large Pity");
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("Pity Status");
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("Luck");
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("Luck Index");
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("Data Retrieval Failed");
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("Please refresh and try again later");
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🎉.
This article was synchronized to xLog by Mix Space. The original link is https://www.vinking.top/posts/codes/astrionyx-wuwa-gacha-analysis