Compare commits

...

19 Commits

Author SHA1 Message Date
Soneoylys
d8efe9c1f1 Update README.md 2024-05-13 10:15:51 -07:00
Soneoylys
866eefabd3 Merge pull request #98 from DavidScann/saltcute-readme-testflight
Update README.md
2024-05-13 09:34:34 -07:00
DavidScann
7c0cc48734 Update README.md 2024-05-13 09:20:10 -06:00
DavidScann
f3c735936a Update README.md
- Changed phrasing of first header
- Added disclaimer about iOS TestFlight
2024-05-13 09:13:38 -06:00
fumiko
d657fafab5 Merge pull request #95 from DavidScann/main
Changed around a bit of the English and updated the stable version number
2024-05-04 17:53:47 +08:00
DavidScann
af2a5478f1 Fixed up several odd English texts and version name 2024-05-04 03:27:59 -06:00
DavidScann
840ac79e38 Update README.md
Fixed up a couple of grammatical errors, as well as adding a few miscellaneous changes
2024-04-29 08:34:28 -06:00
Soneoylys
cf45ac7697 Make banner smaller 2024-04-29 06:31:39 -07:00
Soneoylys
2ecf542087 Update README.md 2024-04-29 06:29:02 -07:00
Soneoylys
7f6a1b01f8 B... Browser? 2024-04-16 03:43:42 -07:00
Soneoy_Mac
14db91ea0f ~~English (Traditional)~~ 2024-04-16 03:41:43 -07:00
Soneoy_Mac
b776940c48 Update scripts 2024-04-16 03:40:27 -07:00
Soneoylys
d26e1bba1f Group C NOW! 2024-02-22 08:41:29 -08:00
Soneoylys
37bd37a850 Update README.md 2024-02-02 13:03:16 -08:00
Soneoylys
04d6db6ec4 Update... a translation mistake 2024-02-02 07:16:16 -08:00
Soneoylys
b80a535c65 Add README translation 2024-02-02 05:42:53 -08:00
fumiko
de387958fa Updated Discord badge styles 2023-12-08 22:45:20 +08:00
fumiko
c94206f398 Updated Discord badges 2023-12-08 22:44:23 +08:00
Soneoylys
8ddffec227 Shiny Smily Discord invitation links~ 2023-11-17 07:20:08 -08:00
42 changed files with 1159 additions and 20 deletions

View File

@@ -23,7 +23,6 @@ If applicable, add screenshots to help explain your problem.
**Device specs**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Macintosh index files
.DS_Store

View File

@@ -1,15 +1,17 @@
### *FROM MAY 2024, ISSUES WITHOUT ENGLISH TRANSLATION WILL BE CLOSED WITHOUT EXPLANATION*
# AstroDX
![GitHub release (latest by date)](https://img.shields.io/github/v/release/2394425147/maipaddx?label=stable)
[![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/2394425147/maipaddx?include_prereleases)](https://github.com/2394425147/maipaddx/releases/latest)
[![Discord](https://img.shields.io/discord/892807792996536453)](https://discord.gg/6fpETgpvjZ)
[![Discord](https://dcbadge.vercel.app/api/server/6fpETgpvjZ?style=flat)](https://discord.gg/6fpETgpvjZ)
AstroDX (Formerly named MaipadDX), is a mobile maimai simulator intended for those who don't yet have access to a cabinet, those who want to practice, and everyone interested in maimai.
AstroDX (Formerly MaipadDX) is a mobile maimai simulator intended for those who do not yet have access to a cabinet, those who want to practice, and everyone interested in maimai who otherwise could not play the arcade game.
# Open-source status
We initially intended for AstroDX to be fully open-source after it's uploaded to official app stores, but as the game contains paid assets, we might be only able to partially open-source AstroDX.
We initially intended for AstroDX to be fully open-source after it has been uploaded to official app stores, but as the game contains paid assets, we may only be able to partially open-source AstroDX.
However, If you have issues, please don't hesitate to point it out in issues and we'll try to answer them as best as we can.
However, if you have issues, please don't hesitate to point it out in issues and we'll try to answer them as best as we can.
This game is a clean-room implementation of maimai, and has been developed without using any original arcade data.
@@ -21,26 +23,30 @@ This game is a clean-room implementation of maimai, and has been developed witho
## Which version should I download?
[![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/2394425147/maipaddx?include_prereleases)](https://github.com/2394425147/maipaddx/releases/latest)
v2.0 is the latest version cycle, with full support for festival features, and a superset of simai3 syntaxes (deserialized via [SimaiSharp](https://github.com/reflektone-games/SimaiSharp)).
v2.0 also includes optimizations, better interfaces, and more customizations. However, it's still in its pre-beta cycle, which means features aren't fully settled, and is prone to changes.
v2.0 is the latest version cycle, with full support for FESTiVAL features, and a superset of simai3 syntaxes (deserialized via [SimaiSharp](https://github.com/reflektone-games/SimaiSharp)).
v2.0 also includes performance optimizations, a better interface, and more customization. However, it's still in its pre-beta cycle, which means features are yet to be finalised, and very much prone to changes.
![GitHub release (latest by date)](https://img.shields.io/github/v/release/2394425147/maipaddx?label=stable)
If you prefer a stable build instead, v1.1.1 is the **latest stable version**. You should use this if you prefer a long-term support experience.
If you prefer a stable build instead, v2.0.0.beta.pre.98.5 is the **latest stable version**. You should use this if you prefer a long-term support experience.
## Wait, is there version for iOS?
Yes (kind of)! You can join the test at [TestFlight Group A](https://testflight.apple.com/join/rACTLjPL) or [TestFlight Group B](https://testflight.apple.com/join/ocj3yptn).
## Wait, is there a version for iOS?
Well... It's a bit finnicky. There's a major change happening regarding TestFlight (which you can read more on our Discord server [here](https://discord.com/channels/892807792996536453/1210127565986205726/1238882652040200373)).
**TL;DR: AstroDX for iOS is migrating to a new developer account, and during this period we won't free up more tester slots. However, we hope to finish this transition soon.**
In the meantime, you will still be able to join the tests as per usual.
You can join the test at [TestFlight Group A](https://testflight.apple.com/join/rACTLjPL) or [TestFlight Group B](https://testflight.apple.com/join/ocj3yptn) or [TestFlight Group C](https://testflight.apple.com/join/CuMxZE2M).
![TestFlight](https://img.shields.io/github/downloads/2394425147/maipaddx/total?label=TestFlight)
Due to restriction of Apple, developer must be 18 (or 19 depends on local law) to publish Applications on App Store. Currently, we borrow JiNALE TEAM's developer account for bringing TestFlight up.
Each public link only can hold 10k players (count by Apple ID), so please NOT duplicate join the test if you already have AstroDX installed.
Since both of those slots are full again, we are planning on create another one soon, but ETA is not guaranteed on this.
*Each public link only can hold 10k players (they counted by Apple ID, not devices), so please DO NOT rejoin the test if you already have AstroDX installed.*
## Are there any tutorials on importing?
Yep, they should be on the [wiki](https://github.com/2394425147/maipaddx/wiki/Importing-levels) of this repo.
Detailed guides for Android and iOS are available here:
- [Installation Guide for Android](https://sht.moe/adx-android)
- [Installation Guide for iOS/iPadOS](https://rentry.org/adx_ios)
## Can I use charts transcribed from the official arcade game maimai?
@@ -50,11 +56,32 @@ We **don't recommend** doing this, as it violates SEGA's policies.
You can open an issue [here](https://github.com/2394425147/maipaddx/issues).
We welcome issues written in Mandarin, Japanese and English. However, it would be strongly suggested to provide translations (even using online translator) to English when submitting them, thus other people could understand as well.
**As of May 2024, an attached English translation of the issue is mandatory.**
We welcome issues written in Chinese, Japanese and English. However, it would be strongly suggested to provide translations (even using online translator) to English when submitting them, thus other people could understand as well.
When submitting issues, please always ensure that you are running the latest released version. We also recommend reviewing existing issues to avoid duplication of questions or concerns.
[![Discord](https://img.shields.io/discord/892807792996536453)](https://discord.gg/6fpETgpvjZ)
Alternatively, on our Discord server, we also have a help forum dedicated for issues, an faq, as well as a suggestions channel for feedback.
Alternatively, on our [Discord server](https://discord.gg/6fpETgpvjZ), we also have a help forum dedicated for issues, an faq, as well as a suggestions channel for feedback.
Happy playing!
## 質問がある場合
イシューは[こちら](https://github.com/2394425147/maipaddx/issues)から提出できます。
中国語、日本語、英語でのイシュー投稿を大歓迎します。ただし、他の方も理解できるように、イシューを提出する際には英文への翻訳を(オンライン翻訳を使用しても)お願いいたします。
イシューを提出する際には、最新バージョンを使用していることチェックしてください。また、重複を避けるため、既存のイシューを確認することをおすすめします。
また、AstroDXの[Discordサーバー](https://discord.gg/6fpETgpvjZ)には、質問用、FAQ、フィードバックのための提案チャンネルもご用意しています。
## 當遇到了問題的時候
可以在[這裏](https://github.com/2394425147/maipaddx/issues)提出issue。
我們歡迎使用中文、日文和英文編寫的issue。然而在提交問題時強烈建議提供英文翻譯甚至使用線上翻譯以便其他人也能理解。
提交issue時請務必確保您正在執行的是最新發布的版本。 我們也建議審查現有issue以避免重複或疑慮。
此外,在我們的[Discord伺服器](https://discord.gg/6fpETgpvjZ)上,還有一個專門解決問題的幫助論壇、常見問題解答以及回饋及建議頻道。
Happy playing!

View File

@@ -0,0 +1,20 @@
using SimaiSharp.Structures;
namespace AstroDX.Models.Scoring.Metrics.Extended
{
public class HealthStats : IReactiveStatistic
{
public HealthStats(int maxHealth)
{
MaxHealth = maxHealth;
}
public void Push(in NoteType type, in JudgeData data)
{
Loss += GameSettings.Settings.Profile.Mods.LifeDrain.GetHealthLoss(type, data);
}
public int MaxHealth { get; }
public int Loss { get; private set; }
}
}

View File

@@ -0,0 +1,9 @@
using SimaiSharp.Structures;
namespace AstroDX.Models.Scoring.Metrics
{
public interface IReactiveStatistic
{
void Push(in NoteType type, in JudgeData data);
}
}

View File

@@ -0,0 +1,110 @@
using System;
namespace AstroDX.Models.Scoring.Metrics.Internal
{
public sealed class AchievementStats
{
private readonly Statistics _statistics;
private readonly double _baseScore;
public AchievementStats(Statistics statistics)
{
_statistics = statistics;
var totalPointCount = statistics.JudgementStats.MaxTapCount +
statistics.JudgementStats.MaxTouchCount +
statistics.JudgementStats.MaxHoldCount * 2 +
statistics.JudgementStats.MaxSlideCount * 3 +
statistics.JudgementStats.MaxBreakCount * 5;
_baseScore = totalPointCount > 0 ? 100.00 / totalPointCount : 0;
}
/// <param name="precise">Used when displaying achievement as text to avoid double rounding</param>
public double GetAchievement(bool precise = false)
{
var baseAchievement = GetBaseAchievement();
var extraAchievement = _statistics.JudgementStats.BreakRecord.MaxCount > 0
? _statistics.JudgementStats.BreakRecord.Extras /
_statistics.JudgementStats.BreakRecord.MaxCount
: 0;
var totalAchievement = baseAchievement +
extraAchievement;
return precise ? totalAchievement : Math.Round(totalAchievement, 4);
}
private double GetBaseAchievement()
{
if (_baseScore == 0)
return 0;
var tapAchievement = _statistics.JudgementStats.TapRecord.Points
* _baseScore;
var touchAchievement = _statistics.JudgementStats.TouchRecord.Points
* _baseScore;
var holdAchievement = _statistics.JudgementStats.HoldRecord.Points
* _baseScore * 2;
var slideAchievement = _statistics.JudgementStats.SlideRecord.Points
* _baseScore * 3;
var breakAchievement = _statistics.JudgementStats.BreakRecord.Points
* _baseScore * 5;
return tapAchievement + touchAchievement + holdAchievement + slideAchievement + breakAchievement;
}
public double CalculateMaxAchievable(bool includeExtras)
{
var tapAchievement = _statistics.JudgementStats.TapRecord.PassedCount * _baseScore;
var touchAchievement = _statistics.JudgementStats.TouchRecord.PassedCount * _baseScore;
var holdAchievement = _statistics.JudgementStats.HoldRecord.PassedCount * _baseScore * 2;
var slideAchievement = _statistics.JudgementStats.SlideRecord.PassedCount * _baseScore * 3;
var breakAchievement = _statistics.JudgementStats.BreakRecord.PassedCount * _baseScore * 5;
return includeExtras && _statistics.JudgementStats.BreakRecord.MaxCount > 0
? tapAchievement + touchAchievement + holdAchievement + slideAchievement + breakAchievement +
(double)_statistics.JudgementStats.BreakRecord.PassedCount /
_statistics.JudgementStats.BreakRecord.MaxCount
: tapAchievement + touchAchievement + holdAchievement + slideAchievement + breakAchievement;
}
public ClearType EvaluateClearType(bool whilePlaying = false)
{
var achievement = GetAchievement();
var cleared = achievement >= 80 && _statistics.JudgementStats.AllNotesPassed;
var missed = _statistics.JudgementStats.JudgedMissCount >= 1;
var anyGood = _statistics.JudgementStats.JudgedGoodCount >= 1;
var anyGreat = _statistics.JudgementStats.JudgedGreatCount >= 1;
var chartHasBreaks = _statistics.JudgementStats.MaxBreakCount > 0;
if (!whilePlaying && !cleared)
return ClearType.Failed;
if (missed)
return ClearType.Clear;
if (anyGood)
return ClearType.FullCombo;
if (anyGreat)
return ClearType.FullComboPlus;
if (!chartHasBreaks)
return ClearType.AllPerfect;
var maxBreak = _statistics.JudgementStats.BreakRecord.CriticalCount ==
_statistics.JudgementStats.BreakRecord.PassedCount;
return maxBreak
? ClearType.AllPerfectPlus
: ClearType.AllPerfect;
}
}
}

View File

@@ -0,0 +1,23 @@
using System;
using SimaiSharp.Structures;
namespace AstroDX.Models.Scoring.Metrics.Internal
{
public sealed class ComboStats : IReactiveStatistic
{
public long lastCombo { get; private set; }
public long maxCombo { get; private set; }
public void Push(in NoteType type, in JudgeData data = default)
{
if (data.grade == JudgeGrade.Miss)
{
lastCombo = 0;
return;
}
lastCombo++;
maxCombo = Math.Max(lastCombo, maxCombo);
}
}
}

View File

@@ -0,0 +1,49 @@
using System.Linq;
namespace AstroDX.Models.Scoring.Metrics.Internal
{
public sealed class DxScoreStats
{
private readonly Statistics _statistics;
private const int CriticalWeight = 3;
private static readonly int[] Weights =
{
CriticalWeight, 2, 1, 0, 0
};
public DxScoreStats(Statistics statistics) => _statistics = statistics;
public uint TotalDxScore => _statistics.JudgementStats.MaxNoteCount * CriticalWeight;
/// <summary>
/// This is used to generate max health or max dx score.
/// </summary>
public uint MaxAchievableDxScore => _statistics.JudgementStats.JudgedNoteCount * CriticalWeight;
/// <summary>
/// This is used to generate the current health or dx score.
/// </summary>
public uint DxScore =>
(uint)(_statistics.JudgementStats.TapRecord.Judged.Sum(c => Weights[(int)c.grade]) +
_statistics.JudgementStats.TouchRecord.Judged.Sum(c => Weights[(int)c.grade]) +
_statistics.JudgementStats.HoldRecord.Judged.Sum(c => Weights[(int)c.grade]) +
_statistics.JudgementStats.SlideRecord.Judged.Sum(c => Weights[(int)c.grade]) +
_statistics.JudgementStats.BreakRecord.Judged.Sum(c => Weights[(int)c.grade]));
public int GetGrade()
{
var dxScorePercentage = (float)DxScore / TotalDxScore;
// // add 1 for every passed threshold
for (var i = 0; i < DxGradeThresholds.Thresholds.Length; i++)
{
if (dxScorePercentage < DxGradeThresholds.Thresholds[i])
return i;
}
return DxGradeThresholds.Thresholds.Length;
}
}
}

View File

@@ -0,0 +1,38 @@
using AstroDX.Utilities.Extensions;
using SimaiSharp.Structures;
namespace AstroDX.Models.Scoring.Metrics.Internal
{
public sealed class FluctuationStats : IReactiveStatistic
{
public long EarlyCount { get; private set; }
public long LateCount { get; private set; }
public double TotalDeviation { get; private set; }
public long NotesCounted { get; private set; }
public double Deviation => TotalDeviation / NotesCounted;
public void Push(in NoteType type, in JudgeData data)
{
if ((GameSettings.Settings.Profile.Metrics.fluctuationVisibility & data.grade.AsFlag()) == 0)
return;
if (type == NoteType.Slide)
return;
NotesCounted++;
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (data.state)
{
case JudgeState.Early:
EarlyCount++;
break;
case JudgeState.Late:
LateCount++;
break;
}
TotalDeviation += data.offset;
}
}
}

View File

@@ -0,0 +1,139 @@
using System;
using SimaiSharp.Structures;
using UnityEngine;
namespace AstroDX.Models.Scoring.Metrics.Internal
{
public sealed class JudgementStats : IReactiveStatistic
{
public NoteRecord HoldRecord { get; }
public NoteRecord SlideRecord { get; }
public NoteRecord TapRecord { get; }
public NoteRecord TouchRecord { get; }
public NoteRecord BreakRecord { get; }
public uint MaxTapCount { get; }
public uint MaxTouchCount { get; }
public uint MaxHoldCount { get; }
public uint MaxSlideCount { get; }
public uint MaxBreakCount { get; }
public uint JudgedCriticalCount { get; private set; }
public uint JudgedPerfectCount { get; private set; }
public uint JudgedGreatCount { get; private set; }
public uint JudgedGoodCount { get; private set; }
public uint JudgedMissCount { get; private set; }
public uint JudgedNoteCount { get; private set; }
public uint MaxNoteCount =>
TapRecord.MaxCount + TouchRecord.MaxCount +
HoldRecord.MaxCount + SlideRecord.MaxCount +
BreakRecord.MaxCount;
public bool AllNotesPassed =>
TapRecord.MaxCount == TapRecord.PassedCount &&
TouchRecord.MaxCount == TouchRecord.PassedCount &&
HoldRecord.MaxCount == HoldRecord.PassedCount &&
SlideRecord.MaxCount == SlideRecord.PassedCount &&
BreakRecord.MaxCount == BreakRecord.PassedCount;
public JudgementStats(ReadOnlyMemory<NoteCollection> noteCollections)
{
foreach (var noteCollection in noteCollections.Span)
{
foreach (var note in noteCollection)
{
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (note.type)
{
case NoteType.Tap:
MaxTapCount++;
break;
case NoteType.Touch:
MaxTouchCount++;
break;
case NoteType.Hold:
MaxHoldCount++;
break;
case NoteType.Break:
MaxBreakCount++;
break;
}
foreach (var slide in note.slidePaths)
{
if (slide.type == NoteType.Break)
MaxBreakCount++;
else
MaxSlideCount++;
}
}
}
TapRecord = new NoteRecord(MaxTapCount);
TouchRecord = new NoteRecord(MaxTouchCount);
HoldRecord = new NoteRecord(MaxHoldCount);
SlideRecord = new NoteRecord(MaxSlideCount);
BreakRecord = new NoteRecord(MaxBreakCount);
}
public void Push(in NoteType type, in JudgeData data)
{
switch (type)
{
case NoteType.Tap:
TapRecord.Push(in data);
break;
case NoteType.Touch:
TouchRecord.Push(in data);
break;
case NoteType.Hold:
HoldRecord.Push(in data);
break;
case NoteType.Slide:
SlideRecord.Push(in data);
break;
case NoteType.Break:
BreakRecord.Push(in data);
BreakRecord.Extras += data.grade switch
{
JudgeGrade.CriticalPerfect => 1,
JudgeGrade.Perfect =>
Mathf.Abs((float)data.offset) <= 0.033335f ? 0.75 : 0.5,
JudgeGrade.Great => 0.4,
JudgeGrade.Good => 0.3,
JudgeGrade.Miss => 0,
_ => 0
};
break;
case NoteType.ForceInvalidate:
default:
break;
}
switch (data.grade)
{
case JudgeGrade.CriticalPerfect:
JudgedCriticalCount++;
break;
case JudgeGrade.Perfect:
JudgedPerfectCount++;
break;
case JudgeGrade.Great:
JudgedGreatCount++;
break;
case JudgeGrade.Good:
JudgedGoodCount++;
break;
case JudgeGrade.Miss:
JudgedMissCount++;
break;
default:
throw new ArgumentOutOfRangeException();
}
JudgedNoteCount++;
}
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AstroDX.Models.Scoring.Metrics.Internal;
using Sigtrap.Relays;
using SimaiSharp.Structures;
namespace AstroDX.Models.Scoring.Metrics
{
[Serializable]
public sealed class Statistics
{
public Relay<NoteType, JudgeData> OnJudgementReceived { get; } = new();
public FluctuationStats FluctuationStats { get; }
public ComboStats ComboStats { get; }
public JudgementStats JudgementStats { get; }
public AchievementStats AchievementStats { get; }
public DxScoreStats DxScoreStats { get; }
private HashSet<IReactiveStatistic> Extensions { get; }
public Statistics(ReadOnlyMemory<NoteCollection> chart)
{
JudgementStats = new JudgementStats(chart);
FluctuationStats = new FluctuationStats();
ComboStats = new ComboStats();
AchievementStats = new AchievementStats(this);
DxScoreStats = new DxScoreStats(this);
Extensions = new HashSet<IReactiveStatistic>();
}
public void Push(in NoteType type, in JudgeData data)
{
JudgementStats.Push(in type, in data);
ComboStats.Push(in type, in data);
FluctuationStats.Push(in type, in data);
foreach (var statistic in Extensions)
statistic.Push(in type, in data);
OnJudgementReceived.Dispatch(type, data);
}
public void RegisterExtension(IReactiveStatistic extension)
{
Extensions.Add(extension);
}
public IReactiveStatistic GetExtensionOrDefault<T>()
where T : IReactiveStatistic
{
return Extensions.FirstOrDefault(e => e.GetType() == typeof(T));
}
}
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
namespace AstroDX.Models.Scoring
{
public class NoteRecord
{
public IReadOnlyList<JudgeData> Judged { get; }
public uint MaxCount { get; }
public uint PassedCount { get; private set; }
public uint CriticalCount { get; private set; }
public uint PerfectCount { get; private set; }
public uint GreatCount { get; private set; }
public uint GoodCount { get; private set; }
public uint MissCount { get; private set; }
public double Extras { get; set; }
public NoteRecord(uint maxCount)
{
Judged = new List<JudgeData>();
MaxCount = maxCount;
PassedCount = 0;
Extras = 0;
}
public void Push(in JudgeData data)
{
((List<JudgeData>)Judged).Add(data);
PassedCount++;
switch (data.grade)
{
case JudgeGrade.CriticalPerfect:
CriticalCount++;
break;
case JudgeGrade.Perfect:
PerfectCount++;
break;
case JudgeGrade.Great:
GreatCount++;
break;
case JudgeGrade.Good:
GoodCount++;
break;
case JudgeGrade.Miss:
MissCount++;
break;
default:
throw new ArgumentOutOfRangeException();
}
Points += GetScore(data.grade);
}
public double Points { get; private set; }
private static double GetScore(JudgeGrade grade)
{
return grade switch
{
JudgeGrade.CriticalPerfect => 1,
JudgeGrade.Perfect => 1,
JudgeGrade.Great => 0.8,
JudgeGrade.Good => 0.5,
JudgeGrade.Miss => 0,
_ => throw new
ArgumentOutOfRangeException()
};
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6a0d1a0a74b14c94af6f7d4d943075d8
timeCreated: 1710933820

View File

@@ -0,0 +1,101 @@
using System.Collections.Generic;
using DG.Tweening;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.Pool;
using UnityEngine.UI;
namespace AstroDX.UI.RecyclingScrollRect.Interfaces
{
[RequireComponent(typeof(ScrollRect))]
public abstract class ScrollRectRecyclingBase<TData> : MonoBehaviour,
IDragHandler,
IBeginDragHandler,
IInitializePotentialDragHandler,
IEndDragHandler
{
protected IList<TData> DataSource { get; private set; } = new List<TData>();
protected ObjectPool<ScrollViewItem<TData>> itemsPool;
[field: Disable, SerializeField]
protected List<ScrollViewItem<TData>> ShownItemsUnordered { get; private set; } = new();
[BeginGroup("List View")]
[NotNull, SerializeField, Tooltip("Where the items will be created.")]
protected ScrollRect scrollRect;
[NotNull, SerializeField, Tooltip("The visual representation of each item.")]
[EndGroup]
protected ScrollViewItem<TData> itemPrefab;
private bool _initialized;
protected virtual void OnEnable()
{
if (_initialized)
return;
itemsPool = new ObjectPool<ScrollViewItem<TData>>(() =>
{
var item = Instantiate(itemPrefab, scrollRect.content);
item.DisableItem();
return item;
},
item => item.EnableItem(),
item => item.DisableItem());
}
public void SetDataSource(IList<TData> dataSource)
{
DataSource = dataSource;
ReloadItems();
}
/// <summary>
/// Reloads the scroll rect to match up with the data source.
/// </summary>
protected abstract void ReloadItems();
/// <summary>
/// Sets the current visible range, load and discards items depending on the previous range.
/// <param name="resetActiveItems">Set the item data again to items that can be recycled. Triggered when data source changes.</param>
/// </summary>
protected abstract void UpdateVisibleRange(bool resetActiveItems);
/// <summary>
/// Jumps to the item in the scroll rect.
/// </summary>
public void JumpToItem(TData item)
{
// We don't do any error handling here so that the error is thrown and developers using this can debug what's happening.
JumpToIndex(DataSource.IndexOf(item));
}
/// <summary>
/// Scrolls to the item in the scroll rect.
/// </summary>
public void ScrollToItem(TData item, float duration, Ease easing = default)
{
// We don't do any error handling here so that the error is thrown and developers using this can debug what's happening.
ScrollToIndex(DataSource.IndexOf(item), duration, easing);
}
/// <summary>
/// Jumps to the index provided by the data source in the scroll rect.
/// </summary>
public abstract void JumpToIndex(int index);
public abstract void ScrollToIndex(int index, float duration, Ease easing = default);
protected virtual void OnDestroy() => itemsPool.Dispose();
public abstract void OnDrag(PointerEventData eventData);
public abstract void OnBeginDrag(PointerEventData eventData);
public abstract void OnInitializePotentialDrag(PointerEventData eventData);
public abstract void OnEndDrag(PointerEventData eventData);
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5843eb5b52bc4cd085d670e17ff8be63
timeCreated: 1710933385

View File

@@ -0,0 +1,34 @@
using UnityEngine;
namespace AstroDX.UI.RecyclingScrollRect.Interfaces
{
public abstract class ScrollViewItem<T> : MonoBehaviour
{
public T Data { get; private set; }
public int Index { get; private set; }
public void SetData(T data, int index)
{
Data = data;
Index = index;
OnDataUpdated();
}
/// <summary>
/// This is called whenever this instance is requested to render a new item with a different index.
/// </summary>
protected abstract void OnDataUpdated();
/// <summary>
/// When this object instance scrolls out of view, this is called to disable relevant components;
/// Should be as lightweight as possible since disabled items might get reused instantly.
/// </summary>
internal abstract void DisableItem();
/// <summary>
/// Called when an item is retrieved from the pool.
/// </summary>
internal abstract void EnableItem();
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2f1b83f97d7847728b5f776276799111
timeCreated: 1710933857

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d313c27e4a974b3baea75f0d80917046
timeCreated: 1710933392

View File

@@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using AstroDX.GameSettings;
using AstroDX.UI.RecyclingScrollRect.Interfaces;
using AstroDX.Utilities.Extensions;
using DG.Tweening;
using UnityEngine;
using UnityEngine.EventSystems;
namespace AstroDX.UI.RecyclingScrollRect.ScrollRects
{
[Serializable]
public class RecyclingGridView<TData> : ScrollRectRecyclingBase<TData>
{
[BeginGroup("Scrolling")]
[SerializeField]
private float gap;
[SerializeField]
private float marginTop;
[SerializeField]
private float marginBottom;
[Disable, SerializeField]
private Vector2 itemSize;
[Disable, SerializeField]
private int visibleStart;
[Disable, SerializeField]
private int columnCount;
[Disable, SerializeField]
private int rowCount;
[SerializeField, Disable]
private float usableWidth;
[Disable, SerializeField]
private int maxVisibleCount;
[Disable, SerializeField]
private float contentHeight;
private Tweener _positionTween;
private void OnValidate()
{
itemSize = itemPrefab?.transform.AsRectTransform().sizeDelta ?? Vector2.zero;
scrollRect.content.anchorMin = new Vector2(0, 1);
scrollRect.content.anchorMax = new Vector2(1, 1);
scrollRect.content.pivot = new Vector2(0.5f, 1);
scrollRect.content.localScale = Vector3.one;
var dt = new DrivenRectTransformTracker();
dt.Clear();
//Object to drive the transform
dt.Add(this, scrollRect.content, DrivenTransformProperties.All);
}
protected override void OnEnable()
{
base.OnEnable();
Settings.Profile.Graphics.scaleFactorChanged.AddListener(ReloadItems);
}
private void Update()
{
if (scrollRect.velocity.y == 0)
return;
UpdateVisibleRange(false);
}
protected override void ReloadItems()
{
columnCount = Mathf.FloorToInt((scrollRect.viewport.rect.width + gap) / (itemSize.x + gap));
usableWidth = columnCount * itemSize.x + (columnCount - 1) * gap;
rowCount = Mathf.CeilToInt((float)DataSource.Count / columnCount);
maxVisibleCount = Mathf.CeilToInt((scrollRect.viewport.rect.size.y + gap) / (itemSize.y + gap)) * columnCount;
contentHeight = marginTop + rowCount * (itemSize.y + gap) + marginBottom;
scrollRect.content.sizeDelta = new Vector2(scrollRect.content.sizeDelta.x, contentHeight);
UpdateVisibleRange(true);
}
private readonly HashSet<int> _itemsToAdd = new();
private int _previousStartIndexInclusive;
private int _previousEndIndexExclusive;
protected override void UpdateVisibleRange(bool resetActiveItems)
{
visibleStart = scrollRect.content.anchoredPosition.y <= marginTop
? 0
: columnCount *
Mathf.FloorToInt((scrollRect.content.anchoredPosition.y - marginTop) / (itemSize.y + gap));
var startIndexInclusive = visibleStart;
var endIndexExclusive = Mathf.Min(startIndexInclusive + maxVisibleCount, DataSource.Count);
// If the visible range hasn't changed, just update the positions of all items in range
if (startIndexInclusive == _previousStartIndexInclusive &&
endIndexExclusive == _previousEndIndexExclusive)
{
foreach (var item in ShownItemsUnordered)
{
item.GetRectTransform().anchoredPosition = AbsolutePositionAtIndex(item.Index);
if (resetActiveItems)
item.SetData(DataSource[item.Index], item.Index);
}
return;
}
_previousStartIndexInclusive = startIndexInclusive;
_previousEndIndexExclusive = endIndexExclusive;
// Populate the list of items that should be shown by the end of this procedure
for (var i = startIndexInclusive; i < endIndexExclusive; i++)
_itemsToAdd.Add(i);
// Find and skip items that are already shown
// Since the shown items are unordered (for example, we may request to show 1 after 2 and 3 are shown, resulting in [2, 3, 1]),
// We can't make any assumptions about items not appearing later in the list (e.g. [1, 2, 4, 5, 6, 3])
for (var i = 0; i < ShownItemsUnordered.Count; i++)
{
var item = ShownItemsUnordered[i];
if (_itemsToAdd.Contains(item.Index))
{
item.GetRectTransform().anchoredPosition = AbsolutePositionAtIndex(item.Index);
if (resetActiveItems)
item.SetData(DataSource[item.Index], item.Index);
_itemsToAdd.Remove(item.Index);
}
else
{
// We don't need to render this item in the new visible range, so we can safely release it
itemsPool.Release(item);
// Modifying a list while enumerating it isn't recommended
// But we're doing it here for performance
ShownItemsUnordered.RemoveAt(i);
i--;
}
}
// Lastly, generate the actual missing items
foreach (var index in _itemsToAdd)
{
var itemInstance = itemsPool.Get();
ShownItemsUnordered.Add(itemInstance);
itemInstance.SetData(DataSource[index], index);
itemInstance.GetRectTransform().anchoredPosition = AbsolutePositionAtIndex(index);
}
_itemsToAdd.Clear();
}
public override void JumpToIndex(int index)
{
scrollRect.velocity = new Vector3(0, 0);
scrollRect.verticalNormalizedPosition = GetScrollPosition(index);
UpdateVisibleRange(false);
}
public override void ScrollToIndex(int index, float duration, Ease easing = default)
{
scrollRect.velocity = new Vector3(0, 0);
_positionTween?.Kill();
_positionTween = DOTween.To(() => scrollRect.verticalNormalizedPosition,
p =>
{
scrollRect.verticalNormalizedPosition = p;
UpdateVisibleRange(false);
},
GetScrollPosition(index),
duration).SetEase(easing);
}
private float GetScrollPosition(int index)
{
// absolute position at index starts 0 at the top, and decreases when descending
var normalizedPosition = 1 + AbsolutePositionAtIndex(index).y / contentHeight;
normalizedPosition -= ((itemSize.y + marginBottom) * (1 - normalizedPosition) - marginTop * normalizedPosition) /
contentHeight;
return normalizedPosition;
}
public override void OnInitializePotentialDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;
_positionTween?.Kill();
}
public override void OnBeginDrag(PointerEventData eventData)
{
}
public override void OnEndDrag(PointerEventData eventData)
{
}
public override void OnDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;
UpdateVisibleRange(false);
}
private Vector2 AbsolutePositionAtIndex(int index)
{
var column = index % columnCount;
var fullContainerWidth = scrollRect.content.rect.size.x;
var row = Mathf.FloorToInt((float)index / columnCount);
return new Vector2(column * (itemSize.x + gap) + (fullContainerWidth - usableWidth) / 2,
-marginTop - row * (itemSize.y + gap));
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 163b88140e634e719f1a2e8ae214523f
timeCreated: 1710933419

View File

@@ -0,0 +1,207 @@
using System.Collections.Generic;
using AstroDX.UI.RecyclingScrollRect.Interfaces;
using AstroDX.Utilities.Extensions;
using DG.Tweening;
using UnityEngine;
using UnityEngine.EventSystems;
namespace AstroDX.UI.RecyclingScrollRect.ScrollRects
{
public class RecyclingVerticalListView<TData> : ScrollRectRecyclingBase<TData>
{
[BeginGroup("Scrolling")]
[SerializeField]
private float gap;
[SerializeField]
private float marginTop;
[SerializeField]
[EndGroup]
private float marginBottom;
[Disable, SerializeField]
private float itemHeight;
[Disable, SerializeField]
private int maxVisibleCount;
[Disable, SerializeField]
private float contentHeight;
private void OnValidate()
{
itemHeight = itemPrefab?.GetRectTransform().sizeDelta.y ?? 0;
scrollRect.content.anchorMin = new Vector2(0, 1);
scrollRect.content.anchorMax = new Vector2(1, 1);
scrollRect.content.pivot = new Vector2(0.5f, 1);
scrollRect.content.localScale = Vector3.one;
var dt = new DrivenRectTransformTracker();
dt.Clear();
//Object to drive the transform
dt.Add(this, scrollRect.content, DrivenTransformProperties.All);
}
private void Update()
{
if (scrollRect.velocity.y == 0)
return;
UpdateVisibleRange(false);
}
protected override void ReloadItems()
{
maxVisibleCount = Mathf.CeilToInt((scrollRect.viewport.rect.size.y + gap) / (itemHeight + gap));
contentHeight = marginTop + (itemHeight + gap) + marginBottom;
scrollRect.content.sizeDelta = new Vector2(scrollRect.content.sizeDelta.x, contentHeight);
UpdateVisibleRange(true);
}
private readonly HashSet<int> _itemsToAdd = new();
private int _previousStartIndexInclusive;
private int _previousEndIndexExclusive;
protected override void UpdateVisibleRange(bool resetActiveItems)
{
var startIndexInclusive = scrollRect.content.anchoredPosition.y <= marginTop
? 0
: Mathf.FloorToInt((scrollRect.content.anchoredPosition.y - marginTop) /
(itemHeight + gap));
var endIndexExclusive = Mathf.Min(startIndexInclusive + maxVisibleCount, DataSource.Count);
// If the visible range hasn't changed, just update the positions of all items in range
if (startIndexInclusive == _previousStartIndexInclusive &&
endIndexExclusive == _previousEndIndexExclusive)
{
foreach (var item in ShownItemsUnordered)
{
var position = item.GetRectTransform().anchoredPosition;
position.y = AbsolutePositionAtIndex(item.Index);
item.GetRectTransform().anchoredPosition = position;
if (resetActiveItems)
item.SetData(DataSource[item.Index], item.Index);
}
return;
}
_previousStartIndexInclusive = startIndexInclusive;
_previousEndIndexExclusive = endIndexExclusive;
// Populate the list of items that should be shown by the end of this procedure
for (var i = startIndexInclusive; i < endIndexExclusive; i++)
_itemsToAdd.Add(i);
// Find and skip items that are already shown
// Since the shown items are unordered (for example, we may request to show 1 after 2 and 3 are shown, resulting in [2, 3, 1]),
// We can't make any assumptions about items not appearing later in the list (e.g. [1, 2, 4, 5, 6, 3])
for (var i = 0; i < ShownItemsUnordered.Count; i++)
{
var item = ShownItemsUnordered[i];
if (_itemsToAdd.Contains(item.Index))
{
var position = item.GetRectTransform().anchoredPosition;
position.y = AbsolutePositionAtIndex(item.Index);
item.GetRectTransform().anchoredPosition = position;
if (resetActiveItems)
item.SetData(DataSource[item.Index], item.Index);
_itemsToAdd.Remove(item.Index);
}
else
{
// We don't need to render this item in the new visible range, so we can safely release it
itemsPool.Release(item);
// Modifying a list while enumerating it isn't recommended
// But we're doing it here for performance
ShownItemsUnordered.RemoveAt(i);
i--;
}
}
// Lastly, generate the actual missing items
foreach (var index in _itemsToAdd)
{
var itemInstance = itemsPool.Get();
ShownItemsUnordered.Add(itemInstance);
itemInstance.SetData(DataSource[index], index);
var position = itemInstance.GetRectTransform().anchoredPosition;
position.y = AbsolutePositionAtIndex(index);
itemInstance.GetRectTransform().anchoredPosition = position;
}
_itemsToAdd.Clear();
}
public override void JumpToIndex(int index)
{
scrollRect.velocity = new Vector3(0, 0);
scrollRect.verticalNormalizedPosition = GetScrollPosition(index);
UpdateVisibleRange(false);
}
private Tweener _positionTween;
public override void ScrollToIndex(int index, float duration, Ease easing = default)
{
scrollRect.velocity = Vector2.zero;
_positionTween?.Kill();
_positionTween = DOTween.To(() => scrollRect.verticalNormalizedPosition, p =>
{
scrollRect.verticalNormalizedPosition = p;
UpdateVisibleRange(false);
}, GetScrollPosition(index), duration).SetEase(easing);
}
private float GetScrollPosition(int index)
{
// absolute position at index starts 0 at the top, and decreases when descending
var normalizedPosition = 1 + AbsolutePositionAtIndex(index) / contentHeight;
normalizedPosition -= ((itemHeight + marginBottom) * (1 - normalizedPosition) - marginTop * normalizedPosition) /
contentHeight;
return normalizedPosition;
}
public override void OnInitializePotentialDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;
_positionTween?.Kill();
}
public override void OnBeginDrag(PointerEventData eventData)
{
}
public override void OnEndDrag(PointerEventData eventData)
{
}
public override void OnDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;
UpdateVisibleRange(false);
}
private float AbsolutePositionAtIndex(int index)
{
return -index * (itemHeight + gap) - marginTop;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6421e225fef64262bce238235e6f6e0a
timeCreated: 1711013891