Added Quest leaderboard podium

- Added PlayerRow.kt to bind player data for leaderboard and rooms.
- Introduced PodiumCard.kt for podium animations and display of player rankings.
- Created drawable resources for podium backgrounds, badges, and particles.
- Developed layout files for leaderboard header and podium cards.
- Implemented LeaderboardTest to validate leaderboard functionality and player ranking logic.
This commit is contained in:
iChris4 committed 2026-09-24 15:13:23 +02:00
1 parent 18781f2c88
commit 775d9f89d8
28 files changed
+1384 -121

No files matched your search

@@ -31,7 +31,8 @@ import org.wiicompiled.quest.R
* with a Home page that sets up and starts the game, a My profiles page that shows the licences
* of Retro Rewind's save, a Patches page that finds mods for Retro Rewind on GameBanana or imports
* them, a My Miis page that makes and edits the game's Miis, a Settings page that edits
* Config.toml, and under Online a Rooms page that shows who plays on Retro WFC now.
* Config.toml, and under Online a Rooms page that shows who plays on Retro WFC now and a
* Leaderboard page with its top 50.
*
* The APK carries no game code. Playing needs two things the player owns: the game files (DATA,
* extracted from their disc image here or on a PC) and the game itself (libmain.so, built from
@@ -44,7 +45,7 @@ import org.wiicompiled.quest.R
*/
class LauncherActivity : Activity() {
private enum class Page { Home, Profiles, Patches, Miis, Settings, Rooms }
private enum class Page { Home, Profiles, Patches, Miis, Settings, Rooms, Leaderboard }
/** What Home's main and secondary buttons do. */
private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset }
@@ -56,12 +57,14 @@ class LauncherActivity : Activity() {
private lateinit var navSettings: View
private lateinit var navRooms: View
private lateinit var navRoomsCount: TextView
private lateinit var navLeaderboard: View
private lateinit var homePage: View
private lateinit var profilesView: View
private lateinit var patchesView: View
private lateinit var miisView: View
private lateinit var settingsView: View
private lateinit var roomsView: View
private lateinit var leaderboardView: View
private lateinit var trails: WheelTrailsView
private lateinit var playButton: View
private lateinit var playIcon: ImageView
@@ -81,6 +84,7 @@ class LauncherActivity : Activity() {
private lateinit var profilesPage: ProfilesPage
private lateinit var sidebarProfile: SidebarProfileCard
private lateinit var rooms: RoomsPage
private lateinit var leaderboard: LeaderboardPage
/** The sidebar's count of players online (WheelWizard's UpdatePlayerCount). */
private val playersOnline: () -> Unit = {
@@ -131,12 +135,14 @@ class LauncherActivity : Activity() {
navSettings = findViewById(R.id.nav_settings)
navRooms = findViewById(R.id.nav_rooms)
navRoomsCount = findViewById(R.id.nav_rooms_count)
navLeaderboard = findViewById(R.id.nav_leaderboard)
homePage = findViewById(R.id.page_home)
profilesView = findViewById(R.id.page_profiles)
patchesView = findViewById(R.id.page_patches)
miisView = findViewById(R.id.page_miis)
settingsView = findViewById(R.id.page_settings)
roomsView = findViewById(R.id.page_rooms)
leaderboardView = findViewById(R.id.page_leaderboard)
trails = findViewById(R.id.home_trails)
playButton = findViewById(R.id.home_play)
playIcon = findViewById(R.id.home_play_icon)
@@ -181,6 +187,11 @@ class LauncherActivity : Activity() {
sidebarProfile = SidebarProfileCard(this, findViewById(R.id.sidebar_profile)) { showPage(Page.Profiles) }
profilesPage = ProfilesPage(this, profilesView) { snapshot -> sidebarProfile.show(snapshot) }
rooms = RoomsPage(this, roomsView)
// The leaderboard's View Room opens the player's room on the Rooms page.
leaderboard = LeaderboardPage(this, leaderboardView) { room ->
showPage(Page.Rooms)
rooms.showRoom(room)
}
LiveRooms.addListener(playersOnline)
playersOnline()
savedInstanceState?.getString(KEY_TAB)?.let { name ->
@@ -193,6 +204,7 @@ class LauncherActivity : Activity() {
navMiis.setOnClickListener { showPage(Page.Miis) }
navSettings.setOnClickListener { showPage(Page.Settings) }
navRooms.setOnClickListener { showPage(Page.Rooms) }
navLeaderboard.setOnClickListener { showPage(Page.Leaderboard) }
playButton.setOnClickListener { perform(mainAction) }
secondary.setOnClickListener { secondaryAction?.let(::perform) }
cancel.setOnClickListener { GameSetup.cancel() }
@@ -251,6 +263,7 @@ class LauncherActivity : Activity() {
// Who plays online is followed while the launcher is on screen, for the sidebar's count.
LiveRooms.start()
rooms.setVisible(page == Page.Rooms)
leaderboard.setVisible(page == Page.Leaderboard)
// The game's process can take a moment to go away after its activity
// closes, which would still read as running.
val runningAtResume = isGameRunning()
@@ -264,6 +277,7 @@ class LauncherActivity : Activity() {
patches.detach()
profilesPage.setVisible(false)
rooms.setVisible(false)
leaderboard.setVisible(false)
LiveRooms.stop()
super.onPause()
}
@@ -321,6 +335,7 @@ class LauncherActivity : Activity() {
navMiis.isSelected = target == Page.Miis
navSettings.isSelected = target == Page.Settings
navRooms.isSelected = target == Page.Rooms
navLeaderboard.isSelected = target == Page.Leaderboard
homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE
profilesView.visibility = if (target == Page.Profiles) View.VISIBLE else View.GONE
patchesView.visibility = if (target == Page.Patches) View.VISIBLE else View.GONE
@@ -329,6 +344,8 @@ class LauncherActivity : Activity() {
settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE
roomsView.visibility = if (target == Page.Rooms) View.VISIBLE else View.GONE
rooms.setVisible(target == Page.Rooms)
leaderboardView.visibility = if (target == Page.Leaderboard) View.VISIBLE else View.GONE
leaderboard.setVisible(target == Page.Leaderboard)
if (target == Page.Home) {
trailsAway = true
}
@@ -342,8 +359,8 @@ class LauncherActivity : Activity() {
Page.Patches -> patches.refresh()
Page.Miis -> miis.refresh()
Page.Settings -> settings.refresh()
// Rooms follow Retro WFC's answers by themselves.
Page.Rooms -> Unit
// Rooms follow Retro WFC's answers by themselves, and the leaderboard asks when shown.
Page.Rooms, Page.Leaderboard -> Unit
}
}
@@ -0,0 +1,82 @@
package org.wiicompiled.quest.launcher
import android.os.SystemClock
import java.io.IOException
/**
* The top 50 of Retro WFC's leaderboard: WheelWizard's RrLeaderboardSingletonService, which the
* Rooms page ([LiveRooms]) and the Leaderboard page share, so one answer serves both for 90
* seconds and the last one stands in while Retro WFC fails; and how LeaderboardPage ranks it.
*/
object Leaderboard {
const val SIZE = 50
private const val FRESH_MS = 90_000L
/** ResolveRank trusts a rank this far down; beyond it the row's place in the answer is used. */
private const val MAX_RANK = 50_000
/** A player of the top 50 as the page shows them (LeaderboardPlayerItem); [name] can be blank. */
class Row(
val rank: Int,
val name: String,
val friendCode: String,
val vr: Int?,
val isSuspicious: Boolean,
val mii: Mii?,
)
private var cached: List<RetroWfc.LeaderboardEntry>? = null
private var cachedAt = 0L
/**
* The top 50, from the cache while it is fresh, else from Retro WFC, else the last answer;
* throws when Retro WFC fails and never answered. Blocks, so callers run it off the main thread.
*/
@Synchronized
fun top(): List<RetroWfc.LeaderboardEntry> {
val now = SystemClock.elapsedRealtime()
cached?.let { if (now - cachedAt < FRESH_MS) return it }
return try {
RetroWfc.topPlayers(SIZE).also {
cached = it
cachedAt = now
}
} catch (e: IOException) {
cached ?: throw e
}
}
// What the page shows, worked out apart from Android so it can be tested.
/** ReloadLeaderboardAsync: the rows by rank, at most 50, each with its Mii when it has a valid one. */
fun rows(entries: List<RetroWfc.LeaderboardEntry>): List<Row> =
entries.mapIndexed { index, entry -> rank(entry, index) to entry }
// Stable, as LINQ's OrderBy: players sharing a rank keep Retro WFC's order.
.sortedBy { it.first }
.take(SIZE)
.map { (rank, entry) ->
Row(
rank = rank,
name = entry.name,
friendCode = entry.friendCode,
vr = entry.vr,
isSuspicious = entry.isSuspicious,
mii = entry.mii?.let { data -> runCatching { MiiData.parse(data) }.getOrNull() },
)
}
/** ResolveRank: the rank, else the active rank, else the place in Retro WFC's answer. */
private fun rank(entry: RetroWfc.LeaderboardEntry, index: Int): Int =
entry.rank?.takeIf { it in 1..MAX_RANK } ?: entry.activeRank?.takeIf { it in 1..MAX_RANK } ?: (index + 1)
/**
* The room [friendCode] plays in now, for the row's View Room (JoinRoom_OnClick), or null. Codes
* are compared by their digits, as the PC compares the profile IDs they stand for.
*/
fun roomOf(rooms: List<LiveRooms.Room>, friendCode: String): LiveRooms.Room? {
val digits = digits(friendCode)
if (digits.isEmpty()) return null
return rooms.firstOrNull { room -> room.players.any { digits(it.friendCode) == digits } }
}
private fun digits(friendCode: String): String = friendCode.filter { it in '0'..'9' }
}
@@ -0,0 +1,197 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import java.io.IOException
import java.text.NumberFormat
import java.util.Locale
import java.util.concurrent.Executors
import org.wiicompiled.quest.R
/**
* The launcher's Leaderboard page, WheelWizard's LeaderboardPage: Retro WFC's top 50
* ([Leaderboard]), the first three on a podium and the rest in a list. A player opens their actions
* ([PlayerActions]); one in a room now also has View Room, which opens it on the Rooms page
* ([viewRoom]). Like the PC, which makes the page anew each time it is opened, it asks again every
* time it is shown, through the 90-second cache it shares with the rooms.
*/
class LeaderboardPage(
private val activity: Activity,
root: View,
private val viewRoom: (LiveRooms.Room) -> Unit,
) {
private val loading: View = root.findViewById(R.id.leaderboard_loading)
private val error: View = root.findViewById(R.id.leaderboard_error)
private val errorText: TextView = root.findViewById(R.id.leaderboard_error_text)
private val empty: View = root.findViewById(R.id.leaderboard_empty)
private val list: ListView = root.findViewById(R.id.leaderboard_list)
private val header: View = LayoutInflater.from(activity).inflate(R.layout.header_leaderboard, list, false)
/** First, second and third, with the PC's avatar sizes, entry delays and floats. */
private val podium = listOf(
PodiumCard(activity, header.findViewById(R.id.podium_first), PodiumCard.Medal.Gold, 122, 0, PodiumCard.Drift(5_200, 0f, -5f)),
PodiumCard(activity, header.findViewById(R.id.podium_second), PodiumCard.Medal.Silver, 104, 80, PodiumCard.Drift(4_700, -1f, -6f)),
PodiumCard(activity, header.findViewById(R.id.podium_third), PodiumCard.Medal.Bronze, 104, 160, PodiumCard.Drift(5_700, -2f, -7f)),
)
private val adapter = RowAdapter()
private var visible = false
/** Changes with every load and when the page is hidden, so an answer that comes late is dropped. */
private var generation = 0
private var rows: List<Leaderboard.Row> = emptyList()
private var badges: Map<String, List<RetroWfc.Badge>> = emptyMap()
/** A player can join or leave a room while the list is shown; their View Room follows. */
private val roomsChanged: () -> Unit = { adapter.notifyDataSetChanged() }
init {
list.addHeaderView(header, null, false)
list.adapter = adapter
root.findViewById<View>(R.id.leaderboard_retry).setOnClickListener { load() }
root.findViewById<View>(R.id.leaderboard_refresh).setOnClickListener { load() }
podium.forEachIndexed { index, card ->
card.root.setOnClickListener {
rows.getOrNull(index)?.let { row -> PlayerActions.show(activity, card.root, row.friendCode, row.mii, offsetDp = 20) }
}
}
}
fun setVisible(shown: Boolean) {
if (shown == visible) return
visible = shown
if (!shown) {
generation++
LiveRooms.removeListener(roomsChanged)
podium.forEach { it.stop() }
return
}
LiveRooms.addListener(roomsChanged)
ProfileStore.badges { loaded ->
badges = loaded
if (activity.isDestroyed) return@badges
adapter.notifyDataSetChanged()
podium.forEachIndexed { index, card -> card.showBadge(rows.getOrNull(index)?.let(::firstBadge)) }
}
load()
}
/** ReloadLeaderboardAsync. */
private fun load() {
val asked = ++generation
podium.forEach { it.stop() }
show(State.Loading)
worker.execute {
val result = try {
Result.success(Leaderboard.rows(Leaderboard.top()))
} catch (e: IOException) {
Result.failure(e)
}
main.post {
if (asked != generation || activity.isDestroyed) return@post
result.fold(onSuccess = ::fill, onFailure = { e ->
errorText.text = e.message?.takeIf { it.isNotBlank() } ?: activity.getString(R.string.leaderboard_error_fallback)
show(State.Error)
})
}
}
}
private fun fill(loaded: List<Leaderboard.Row>) {
rows = loaded
if (loaded.isEmpty()) {
show(State.Empty)
return
}
podium.forEachIndexed { index, card ->
val row = loaded.getOrNull(index)
card.fill(row, row?.let(::name).orEmpty(), row?.let { place(it.rank) }.orEmpty(), row?.let(::vrText).orEmpty(), row?.let(::firstBadge))
}
adapter.rows = loaded.drop(PODIUM)
adapter.notifyDataSetChanged()
list.setSelection(0)
show(State.Data)
podium.forEachIndexed { index, card -> if (index < loaded.size) card.enter() }
}
private enum class State { Loading, Error, Empty, Data }
private fun show(state: State) {
loading.visibility = if (state == State.Loading) View.VISIBLE else View.GONE
error.visibility = if (state == State.Error) View.VISIBLE else View.GONE
empty.visibility = if (state == State.Empty) View.VISIBLE else View.GONE
list.visibility = if (state == State.Data) View.VISIBLE else View.GONE
}
// What a row shows (CreateLeaderboardPlayer).
private fun name(row: Leaderboard.Row): String = row.name.ifBlank { activity.getString(R.string.leaderboard_unknown_player) }
private fun vrText(row: Leaderboard.Row): String = row.vr?.let { NumberFormat.getIntegerInstance(Locale.getDefault()).format(it) } ?: "--"
private fun firstBadge(row: Leaderboard.Row): RetroWfc.Badge? = badges[row.friendCode]?.firstOrNull()
/** GetPlacementLabel. */
private fun place(rank: Int): String = when (rank) {
1 -> activity.getString(R.string.leaderboard_champion)
2 -> activity.getString(R.string.leaderboard_second)
3 -> activity.getString(R.string.leaderboard_third)
else -> activity.getString(R.string.leaderboard_rank, rank)
}
/** JoinRoom_OnClick: the room the player is in now, on the Rooms page. */
private fun joinRoom(row: Leaderboard.Row) {
val room = Leaderboard.roomOf(LiveRooms.rooms, row.friendCode)
if (room == null) {
Toast.makeText(activity, R.string.leaderboard_no_room, Toast.LENGTH_SHORT).show()
return
}
viewRoom(room)
}
/** The players after the podium, as PlayerListItem rows with their place. */
private inner class RowAdapter : BaseAdapter() {
var rows: List<Leaderboard.Row> = emptyList()
override fun getCount(): Int = rows.size
override fun getItem(position: Int): Any = rows[position]
override fun getItemId(position: Int): Long = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val row = convertView ?: LayoutInflater.from(activity).inflate(R.layout.item_player, parent, false).also {
it.findViewById<View>(R.id.player_top).setBackgroundResource(R.drawable.bg_top_label_neutral)
it.findViewById<TextView>(R.id.player_top).setTextColor(activity.getColor(R.color.neutral_100))
}
val player = rows[position]
PlayerRow.bind(
activity,
row,
mii = player.mii,
name = name(player),
friendCode = player.friendCode,
vrText = vrText(player),
badges = badges[player.friendCode].orEmpty(),
topLabel = activity.getString(R.string.leaderboard_rank, player.rank),
isOpenHost = false,
)
row.findViewById<View>(R.id.player_join).apply {
visibility = if (Leaderboard.roomOf(LiveRooms.rooms, player.friendCode) != null) View.VISIBLE else View.GONE
setOnClickListener { joinRoom(player) }
}
row.setOnClickListener { PlayerActions.show(activity, row, player.friendCode, player.mii) }
return row
}
}
private companion object {
const val PODIUM = 3
val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "Leaderboard").apply { isDaemon = true } }
val main by lazy { Handler(Looper.getMainLooper()) }
}
}
@@ -2,7 +2,6 @@ package org.wiicompiled.quest.launcher
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import java.io.IOException
import java.util.concurrent.Executors
@@ -10,15 +9,12 @@ import java.util.concurrent.Executors
/**
* Who plays on Retro WFC now, for the Rooms page, the sidebar's player count and the profiles'
* Online glow: WheelWizard's RRLiveRooms. While the launcher is on screen it asks every 40 seconds
* for the open rooms and the top 50 of the leaderboard, splits the rooms Retro WFC merged by
* mistake, and marks the players of the top 50 with their rank.
* for the open rooms and the top 50 of the leaderboard ([Leaderboard], shared with its page),
* splits the rooms Retro WFC merged by mistake, and marks the players of the top 50 with their rank.
*/
object LiveRooms {
private const val TAG = "WiiCompiledLauncher"
private const val REFRESH_MS = 40_000L
private const val TOP_PLAYERS = 50
/** RrLeaderboardSingletonService keeps the top players this long, and the last ones when Retro WFC fails. */
private const val LEADERBOARD_FRESH_MS = 90_000L
/** A player in a room (RrPlayer). Two are the same player when their profile and friend code are. */
class Player(
@@ -67,10 +63,6 @@ object LiveRooms {
/** Everyone in a room, as GameLicenseService.RefreshOnlineStatus finds a licence online. */
val onlineFriendCodes: Set<String> get() = rooms.flatMap { room -> room.players.map { it.friendCode } }.toSet()
// The leaderboard's cache, on the worker thread only.
private var leaderboard: List<RetroWfc.LeaderboardEntry>? = null
private var leaderboardAt = 0L
private val ticker = object : Runnable {
override fun run() {
fetch()
@@ -117,18 +109,12 @@ object LiveRooms {
}
}
private fun topPlayers(): List<RetroWfc.LeaderboardEntry>? {
val now = SystemClock.elapsedRealtime()
leaderboard?.let { if (now - leaderboardAt < LEADERBOARD_FRESH_MS) return it }
return try {
RetroWfc.topPlayers(TOP_PLAYERS).also {
leaderboard = it
leaderboardAt = now
}
} catch (e: IOException) {
Log.i(TAG, "Retro WFC's leaderboard is unavailable: ${e.message}")
leaderboard
}
/** The rooms go on without ranks while the leaderboard is unavailable. */
private fun topPlayers(): List<RetroWfc.LeaderboardEntry>? = try {
Leaderboard.top()
} catch (e: IOException) {
Log.i(TAG, "Retro WFC's leaderboard is unavailable: ${e.message}")
null
}
// What the pages show, worked out apart from Android so it can be tested.
@@ -0,0 +1,55 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.view.View
import android.widget.ArrayAdapter
import android.widget.ListPopupWindow
import android.widget.Toast
import org.wiicompiled.quest.R
/**
* The context menu WheelWizard gives a player in a room and on the leaderboard, opened here by a
* tap on them: copy their friend code, see their Mii, or their profile. The PC's Add Friend waits
* for the Friends page, which reads and writes the save's friend list.
*/
object PlayerActions {
/** Opens the menu under [anchor], [offsetDp] from its start. */
fun show(activity: Activity, anchor: View, friendCode: String, mii: Mii?, offsetDp: Int = 60) {
val actions = listOf(
R.string.room_copy_friend_code to { copyFriendCode(activity, friendCode) },
R.string.room_view_mii to { viewMii(activity, mii) },
R.string.room_view_profile to { if (friendCode.isNotEmpty()) PlayerProfileDialog.show(activity, friendCode) },
)
ListPopupWindow(activity).apply {
anchorView = anchor
setAdapter(ArrayAdapter(activity, R.layout.item_dropdown_popup, actions.map { activity.getString(it.first) }))
setBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup))
width = PatchesWidgets.dp(activity, 220)
horizontalOffset = PatchesWidgets.dp(activity, offsetDp)
isModal = true
setOnItemClickListener { _, _, position, _ ->
dismiss()
actions[position].second()
}
show()
}
}
private fun copyFriendCode(activity: Activity, friendCode: String) {
if (friendCode.isEmpty()) return
activity.getSystemService(ClipboardManager::class.java)
?.setPrimaryClip(ClipData.newPlainText(activity.getString(R.string.profiles_code_label), friendCode))
Toast.makeText(activity, R.string.profiles_code_copied, Toast.LENGTH_SHORT).show()
}
private fun viewMii(activity: Activity, mii: Mii?) {
if (mii == null) {
Toast.makeText(activity, R.string.room_no_mii, Toast.LENGTH_SHORT).show()
return
}
MiiViewDialog.show(activity, mii)
}
}
@@ -0,0 +1,60 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import org.wiicompiled.quest.R
/**
* Fills a row of item_player.xml, WheelWizard's PlayerListItem, for the Rooms and Leaderboard
* pages: the player's Mii, name and friend code, VR, badges, place and whether they host.
*/
object PlayerRow {
/** The Mii's circle in item_player.xml, drawn at twice its pixels for smooth edges. */
private const val MII_DP = 50
fun miiSize(activity: Activity): Int = (2 * MII_DP * activity.resources.displayMetrics.density).toInt() and 1.inv()
fun bind(
activity: Activity,
row: View,
mii: Mii?,
name: String,
friendCode: String,
vrText: String,
badges: List<RetroWfc.Badge>,
topLabel: String?,
isOpenHost: Boolean,
) {
val picture = row.findViewById<ImageView>(R.id.player_mii)
val drawn = mii != null && MiiRenderResource.installed(activity)
row.findViewById<View>(R.id.player_placeholder).visibility = if (drawn) View.GONE else View.VISIBLE
if (drawn) {
MiiImages.head(activity, picture, mii!!, miiSize(activity))
} else {
picture.setTag(R.id.mii_image_key, null)
picture.setImageDrawable(null)
}
row.findViewById<TextView>(R.id.player_name).text = name
row.findViewById<TextView>(R.id.player_code).text = friendCode
row.findViewById<TextView>(R.id.player_vr).text = vrText
row.findViewById<View>(R.id.player_badges_box).visibility = if (badges.isEmpty()) View.GONE else View.VISIBLE
row.findViewById<LinearLayout>(R.id.player_badges).apply {
removeAllViews()
badges.forEachIndexed { index, badge ->
val size = PatchesWidgets.dp(activity, 30)
addView(BadgeView(activity, badge), LinearLayout.LayoutParams(size, size).apply {
if (index > 0) marginStart = PatchesWidgets.dp(activity, 3)
})
}
}
row.findViewById<TextView>(R.id.player_top).apply {
text = topLabel.orEmpty()
visibility = if (topLabel != null) View.VISIBLE else View.GONE
}
row.findViewById<View>(R.id.player_host).visibility = if (isOpenHost) View.VISIBLE else View.GONE
}
}
@@ -0,0 +1,218 @@
package org.wiicompiled.quest.launcher
import android.animation.ValueAnimator
import android.app.Activity
import android.graphics.drawable.GradientDrawable
import android.view.MotionEvent
import android.view.View
import android.view.animation.LinearInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
import org.wiicompiled.quest.R
/**
* A step of the leaderboard's podium, WheelWizard's LeaderboardPodiumCard (view_podium_card.xml):
* the player on a card in their medal's colours. As on the PC it rises in when it is filled, then
* floats, glows, glints and sparkles while the page is shown, and grows a little under the pointer.
*/
class PodiumCard(
private val activity: Activity,
val root: View,
medal: Medal,
avatarDp: Int,
/** The PC's EntryFirst, EntrySecond and EntryThird start 80 ms apart. */
private val entryDelayMs: Long,
/** FloatA, FloatB or FloatC: how long the card takes to rise and settle, and from where to where. */
private val drift: Drift,
) {
/** A medal's border and the two ends of its card's diagonal gradient (#AARRGGBB, as the PC's). */
enum class Medal(val border: Int, val from: Int, val to: Int) {
Gold(0xFFEEC21E.toInt(), 0x2E341B07, 0x2D3A2710),
Silver(0x90A9B8D7.toInt(), 0x27363A42, 0x29313847),
Bronze(0xA8D77E47.toInt(), 0x2C3C2418, 0x2B4C2A1D),
}
/** An animation of [durationMs] from [from] to [to] and back, in dp. */
class Drift(val durationMs: Long, val from: Float, val to: Float)
private val surface: View = root.findViewById(R.id.podium_surface)
private val aura: View = root.findViewById(R.id.podium_aura)
private val shimmer: View = root.findViewById(R.id.podium_shimmer)
private val particles: List<View> = listOf(
root.findViewById(R.id.podium_particle_a),
root.findViewById(R.id.podium_particle_b),
root.findViewById(R.id.podium_particle_c),
)
private val picture: ImageView = root.findViewById(R.id.podium_mii)
private val placeholder: View = root.findViewById(R.id.podium_placeholder)
private val badgeBox: FrameLayout = root.findViewById(R.id.podium_badge)
private val miiSize = (2 * avatarDp * activity.resources.displayMetrics.density).toInt() and 1.inv()
private val loops = ArrayList<ValueAnimator>()
private var entry: ValueAnimator? = null
init {
val density = activity.resources.displayMetrics.density
surface.background = GradientDrawable(GradientDrawable.Orientation.TL_BR, intArrayOf(medal.from, medal.to)).apply {
cornerRadius = 20 * density
setStroke(density.toInt().coerceAtLeast(1), medal.border)
}
// The PC blurs a circle of the border's colour by 70 pixels; a radial fade looks the same.
aura.background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
gradientType = GradientDrawable.RADIAL_GRADIENT
gradientRadius = 100 * density
colors = intArrayOf(medal.border, withAlpha(medal.border, 0.4f), withAlpha(medal.border, 0f))
}
root.findViewById<View>(R.id.podium_avatar).layoutParams.apply {
width = (avatarDp * density).toInt()
height = width
}
// Hovered: scale(1.03) over 0.22 s, CircularEaseOut.
root.setOnHoverListener { _, event ->
when (event.actionMasked) {
MotionEvent.ACTION_HOVER_ENTER -> scaleSurface(1.03f)
MotionEvent.ACTION_HOVER_EXIT -> scaleSurface(1f)
}
false
}
}
/** Shows [row], or nothing in its place (the PC's HasPodiumFirst and so on). */
fun fill(row: Leaderboard.Row?, name: String, place: String, vrText: String, badge: RetroWfc.Badge?) {
if (row == null) {
root.visibility = View.INVISIBLE
return
}
root.visibility = View.VISIBLE
root.findViewById<TextView>(R.id.podium_rank).text = activity.getString(R.string.leaderboard_rank, row.rank)
root.findViewById<TextView>(R.id.podium_place).text = place
root.findViewById<TextView>(R.id.podium_name).text = name
root.findViewById<TextView>(R.id.podium_vr).text = vrText
root.findViewById<View>(R.id.podium_suspicious).visibility = if (row.isSuspicious) View.VISIBLE else View.GONE
showBadge(badge)
val mii = row.mii
val drawn = mii != null && MiiRenderResource.installed(activity)
placeholder.visibility = if (drawn) View.GONE else View.VISIBLE
if (drawn) {
MiiImages.head(activity, picture, mii!!, miiSize)
} else {
picture.setTag(R.id.mii_image_key, null)
picture.setImageDrawable(null)
}
}
/** The first of the player's badges, in a circle at the foot of the Mii. */
fun showBadge(badge: RetroWfc.Badge?) {
badgeBox.removeAllViews()
badgeBox.visibility = if (badge == null) View.GONE else View.VISIBLE
if (badge != null) badgeBox.addView(BadgeView(activity, badge), FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
}
/** Rises in (0.45 s, CircularEaseOut, 30 px up while fading in), then keeps moving. */
fun enter() {
stop()
root.alpha = 0f
root.translationY = dp(30f)
entry = ValueAnimator.ofFloat(0f, 1f).apply {
duration = ENTRY_MS
startDelay = entryDelayMs
interpolator = LinearInterpolator()
addUpdateListener {
val progress = circularOut(it.animatedFraction)
root.alpha = progress
root.translationY = dp(30f * (1 - progress))
}
start()
}
start()
}
/** The endless animations, while the page is shown. */
fun start() {
if (loops.isNotEmpty()) return
// FloatA/B/C: the card's surface rises and settles.
loop(drift.durationMs, ::sineInOut) { p -> surface.translationY = dp(between(drift.from, drift.to, there(p))) }
// The aura's opacity breathes between 0.2 and 0.34; the PC also turns it, which a round
// glow of one colour does not show.
loop(4_400, ::sineInOut) { p -> aura.alpha = between(0.2f, 0.34f, there(p)) }
// The glint shows between 20% and 80% of the way across. The PC's band, 220 px long, sweeps
// from 90 px left to 160 px right of its 146-px cards and spills over them; the Quest's
// cards are wider and taller and clip it, so the band is stretched along its length to
// reach the card's top and bottom, and crosses from beyond one side to beyond the other.
loop(3_800, ::circularInOut) { p ->
if (surface.width == 0) return@loop
val length = surface.height / cos(TILT) + dp(40f)
shimmer.scaleY = length / shimmer.height.coerceAtLeast(1)
val reach = (length * sin(TILT) + shimmer.width * cos(TILT)) / 2
shimmer.translationX = -reach - shimmer.width / 2f + (surface.width + 2 * reach) * p
shimmer.alpha = 0.27f * when {
p < 0.2f -> p / 0.2f
p > 0.8f -> (1 - p) / 0.2f
else -> 1f
}
}
// Three sparkles, each rising and brightening at its own pace.
sparkle(particles[0], 3_200, 0.35f, 0.95f, 8f)
sparkle(particles[1], 2_900, 0.2f, 0.8f, 7f)
sparkle(particles[2], 3_700, 0.25f, 0.85f, 6f)
}
fun stop() {
entry?.cancel()
entry = null
loops.forEach { it.cancel() }
loops.clear()
root.alpha = 1f
root.translationY = 0f
}
private fun sparkle(view: View, durationMs: Long, dim: Float, bright: Float, rise: Float) =
loop(durationMs, ::sineInOut) { p ->
view.alpha = between(dim, bright, there(p))
view.translationY = dp(-rise * there(p))
}
/** Avalonia eases a whole iteration, then goes between its key frames in a straight line. */
private fun loop(durationMs: Long, easing: (Float) -> Float, apply: (Float) -> Unit) {
loops += ValueAnimator.ofFloat(0f, 1f).apply {
duration = durationMs
repeatCount = ValueAnimator.INFINITE
interpolator = LinearInterpolator()
addUpdateListener { apply(easing(it.animatedFraction)) }
start()
}
}
private fun scaleSurface(scale: Float) {
surface.animate().scaleX(scale).scaleY(scale).setDuration(HOVER_MS).setInterpolator { circularOut(it) }.start()
}
private fun dp(value: Float): Float = value * activity.resources.displayMetrics.density
private companion object {
const val ENTRY_MS = 450L
const val HOVER_MS = 220L
/** The glint's slant, rotate(25deg) in the layout. */
val TILT = Math.toRadians(25.0).toFloat()
/** Key frames at 0%, 50% and 100% going there and back: 0 to 1 to 0. */
fun there(p: Float): Float = if (p < 0.5f) p * 2 else (1 - p) * 2
fun between(from: Float, to: Float, amount: Float): Float = from + (to - from) * amount
fun sineInOut(t: Float): Float = (-(cos(PI * t) - 1) / 2).toFloat()
fun circularOut(t: Float): Float = sqrt(1 - (t - 1) * (t - 1))
fun circularInOut(t: Float): Float =
if (t < 0.5f) (1 - sqrt(1 - (2 * t) * (2 * t))) / 2 else (sqrt(1 - (2 - 2 * t) * (2 - 2 * t)) + 1) / 2
fun withAlpha(color: Int, alpha: Float): Int = (((color ushr 24) * alpha).toInt() shl 24) or (color and 0xFFFFFF)
}
}
@@ -84,8 +84,17 @@ object RetroWfc {
val players: List<RoomPlayer>,
)
/** A row of the leaderboard (RwfcLeaderboardEntry), as far as the rooms use it. */
class LeaderboardEntry(val pid: String, val friendCode: String, val rank: Int?, val activeRank: Int?)
/** A row of the leaderboard (RwfcLeaderboardEntry); [mii] is the 74 bytes of their Mii. */
class LeaderboardEntry(
val pid: String,
val friendCode: String,
val rank: Int?,
val activeRank: Int?,
val name: String = "",
val vr: Int? = null,
val isSuspicious: Boolean = false,
val mii: ByteArray? = null,
)
/** RwfcLeaderboardVrStats: the VR won or lost lately. */
class VrStats(val last24Hours: Int, val lastWeek: Int, val lastMonth: Int)
@@ -189,7 +198,7 @@ object RetroWfc {
}
}
/** The top of the leaderboard, best first. */
/** The top of the leaderboard, in Retro WFC's order. */
fun parseLeaderboard(json: String): List<LeaderboardEntry> = parsing {
objects(JSONArray(json)).mapNotNull { entry ->
LeaderboardEntry(
@@ -197,6 +206,10 @@ object RetroWfc {
friendCode = entry.opt("friendCode") as? String ?: "",
rank = (entry.opt("rank") as? Number)?.toInt(),
activeRank = (entry.opt("activeRank") as? Number)?.toInt(),
name = entry.opt("name") as? String ?: "",
vr = (entry.opt("vr") as? Number)?.toInt(),
isSuspicious = entry.opt("isSuspicious") as? Boolean ?: false,
mii = miiBytes(entry.text("miiData")),
)
}
}
@@ -1,8 +1,6 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
@@ -10,24 +8,18 @@ import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.ArrayAdapter
import android.widget.BaseAdapter
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ListPopupWindow
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import org.wiicompiled.quest.R
/**
* The launcher's Rooms page, WheelWizard's RoomsPage and RoomDetailsPage: the rooms open on Retro
* WFC ([LiveRooms]), or with a search the players whose name or friend code holds it. A room opens
* its details in place of the list, and a player there their actions: copy their friend code, see
* their Mii, or their profile. As on the PC, nothing here joins a room.
*
* The PC's Add Friend waits for the Friends page, which reads and writes the save's friend list.
* its details in place of the list, and a player there their actions ([PlayerActions]): copy their
* friend code, see their Mii, or their profile. As on the PC, nothing here joins a room.
*/
class RoomsPage(private val activity: Activity, root: View) {
@@ -50,7 +42,7 @@ class RoomsPage(private val activity: Activity, root: View) {
private val roomAdapter = RoomAdapter()
private val searchAdapter = PlayerAdapter { player -> roomOf(player)?.let(::openRoom) }
private val detailsAdapter = PlayerAdapter(::showActions)
private val detailsAdapter = PlayerAdapter { player, row -> PlayerActions.show(activity, row, player.friendCode, player.mii) }
private var visible = false
private var query = ""
@@ -59,8 +51,6 @@ class RoomsPage(private val activity: Activity, root: View) {
private var detailsRoom: LiveRooms.Room? = null
private val roomsChanged: () -> Unit = { render() }
private val miiSize = (2 * MII_DP * activity.resources.displayMetrics.density).toInt() and 1.inv()
init {
detailsPlayers.adapter = detailsAdapter
root.findViewById<View>(R.id.room_back).setOnClickListener { closeRoom() }
@@ -95,6 +85,12 @@ class RoomsPage(private val activity: Activity, root: View) {
render()
}
/** Opens [room]'s details, as the leaderboard's View Room does (RoomDetailsPage). */
fun showRoom(room: LiveRooms.Room) {
if (search.text.isNotEmpty()) search.text.clear()
openRoom(room)
}
/** Back closes a room's details before leaving the page. */
fun back(): Boolean {
if (detailsRoom == null) return false
@@ -168,43 +164,6 @@ class RoomsPage(private val activity: Activity, root: View) {
detailsAdapter.notifyDataSetChanged()
}
/** RoomDetailsPage's context menu, opened on the player's row. */
private fun showActions(player: LiveRooms.Player, row: View) {
val actions = listOf(
R.string.room_copy_friend_code to { copyFriendCode(player) },
R.string.room_view_mii to { viewMii(player) },
R.string.room_view_profile to { if (player.friendCode.isNotEmpty()) PlayerProfileDialog.show(activity, player.friendCode) },
)
ListPopupWindow(activity).apply {
anchorView = row
setAdapter(ArrayAdapter(activity, R.layout.item_dropdown_popup, actions.map { activity.getString(it.first) }))
setBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup))
width = dp(220)
horizontalOffset = dp(60)
isModal = true
setOnItemClickListener { _, _, position, _ ->
dismiss()
actions[position].second()
}
show()
}
}
private fun copyFriendCode(player: LiveRooms.Player) {
activity.getSystemService(ClipboardManager::class.java)
?.setPrimaryClip(ClipData.newPlainText(activity.getString(R.string.profiles_code_label), player.friendCode))
Toast.makeText(activity, R.string.profiles_code_copied, Toast.LENGTH_SHORT).show()
}
private fun viewMii(player: LiveRooms.Player) {
val mii = player.mii
if (mii == null) {
Toast.makeText(activity, R.string.room_no_mii, Toast.LENGTH_SHORT).show()
return
}
MiiViewDialog.show(activity, mii)
}
// Rows
/** RrRoom.TimeOnline: how long ago Retro WFC opened the room. */
@@ -252,42 +211,19 @@ class RoomsPage(private val activity: Activity, root: View) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val row = convertView ?: LayoutInflater.from(activity).inflate(R.layout.item_player, parent, false)
val player = players[position]
val picture = row.findViewById<ImageView>(R.id.player_mii)
val mii = player.mii
val drawn = mii != null && MiiRenderResource.installed(activity)
row.findViewById<View>(R.id.player_placeholder).visibility = if (drawn) View.GONE else View.VISIBLE
if (drawn) {
MiiImages.head(activity, picture, mii!!, miiSize)
} else {
picture.setTag(R.id.mii_image_key, null)
picture.setImageDrawable(null)
}
row.findViewById<TextView>(R.id.player_name).text = player.name
row.findViewById<TextView>(R.id.player_code).text = player.friendCode
row.findViewById<TextView>(R.id.player_vr).text = player.vrText
val badgeList = badges[player.friendCode].orEmpty()
row.findViewById<View>(R.id.player_badges_box).visibility = if (badgeList.isEmpty()) View.GONE else View.VISIBLE
row.findViewById<LinearLayout>(R.id.player_badges).apply {
removeAllViews()
badgeList.forEachIndexed { index, badge ->
addView(BadgeView(activity, badge), LinearLayout.LayoutParams(dp(30), dp(30)).apply { if (index > 0) marginStart = dp(3) })
}
}
row.findViewById<TextView>(R.id.player_top).apply {
text = player.topLabel
visibility = if (player.leaderboardRank != null) View.VISIBLE else View.GONE
}
row.findViewById<View>(R.id.player_host).visibility = if (player.isOpenHost) View.VISIBLE else View.GONE
PlayerRow.bind(
activity,
row,
mii = player.mii,
name = player.name,
friendCode = player.friendCode,
vrText = player.vrText,
badges = badges[player.friendCode].orEmpty(),
topLabel = player.topLabel.takeIf { player.leaderboardRank != null },
isOpenHost = player.isOpenHost,
)
row.setOnClickListener { picked(player, row) }
return row
}
}
private fun dp(value: Int): Int = PatchesWidgets.dp(activity, value)
private companion object {
/** The Mii's circle in item_player.xml, drawn at twice its pixels for smooth edges. */
const val MII_DP = 50
}
}
@@ -3,12 +3,35 @@ package org.wiicompiled.quest.launcher
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ViewGroup
import android.widget.LinearLayout
/**
* A list row that takes every tap itself. A view with a tooltip, or a badge strip that scrolls,
* would otherwise swallow a tap on it; the pointer's hover still reaches them, so their tips show.
* Only a button inside the row, a clickable view such as the leaderboard's View Room, keeps the
* taps that begin on it.
*/
class TapRow(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
override fun onInterceptTouchEvent(event: MotionEvent): Boolean = true
/** The gesture began on a button inside the row, which then has all of it. */
private var onButton = false
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN) onButton = buttonAt(this, event.x, event.y)
return !onButton
}
private fun buttonAt(group: ViewGroup, x: Float, y: Float): Boolean {
for (index in group.childCount - 1 downTo 0) {
val child = group.getChildAt(index)
if (child.visibility != VISIBLE) continue
val childX = x + group.scrollX - child.left
val childY = y + group.scrollY - child.top
if (childX < 0 || childY < 0 || childX >= child.width || childY >= child.height) continue
if (child.isClickable && child.isEnabled) return true
if (child is ViewGroup && buttonAt(child, childX, childY)) return true
}
return false
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- LeaderboardPodiumCard's Mii circle; its ring is fg_podium_avatar, drawn over the Mii. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#22000000" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The circle of LeaderboardPodiumCard's badge, at the foot of the Mii. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/neutral_950" />
<stroke android:width="1dp" android:color="@color/neutral_700" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A LeaderboardPodiumCard sparkle, its edge softened as the PC blurs it. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<gradient
android:centerColor="#AAFFFFFF"
android:endColor="#00FFFFFF"
android:gradientRadius="50%"
android:startColor="#AAFFFFFF"
android:type="radial" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- LeaderboardPodiumCard's rank pill, such as #1. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#22FFFFFF" />
<stroke android:width="1dp" android:color="#4FFFFFFF" />
<corners android:radius="999dp" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The glint that sweeps across a LeaderboardPodiumCard. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:angle="0"
android:centerColor="#9FFFFFFF"
android:centerX="0.52"
android:endColor="#00FFFFFF"
android:startColor="#00FFFFFF" />
<corners android:radius="14dp" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- PlayerListItem's place on the Leaderboard page, which greys the rooms' green label. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<stroke android:width="1dp" android:color="@color/neutral_600" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The ring of LeaderboardPodiumCard's Mii circle, over the Mii so its body cannot cover it. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<stroke android:width="2dp" android:color="#64FFFFFF" />
</shape>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Refresh icon (Font Awesome, 512x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M105.1 202.6c7.7-21.8 20.2-42.3 37.8-59.8c62.5-62.5 163.8-62.5 226.3 0L386.3 160 352 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l111.5 0c0 0 0 0 0 0l.4 0c17.7 0 32-14.3 32-32l0-112c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 35.2L414.4 97.6c-87.5-87.5-229.3-87.5-316.8 0C73.2 122 55.6 150.7 44.8 181.4c-5.9 16.7 2.9 34.9 19.5 40.8s34.9-2.9 40.8-19.5zM39 289.3c-5 1.5-9.8 4.2-13.7 8.2c-4 4-6.7 8.8-8.1 14c-.3 1.2-.6 2.5-.8 3.8c-.3 1.7-.4 3.4-.4 5.1L16 432c0 17.7 14.3 32 32 32s32-14.3 32-32l0-35.1 17.6 17.5c0 0 0 0 0 0c87.5 87.4 229.3 87.4 316.7 0c24.4-24.4 42.1-53.1 52.9-83.8c5.9-16.7-2.9-34.9-19.5-40.8s-34.9 2.9-40.8 19.5c-7.7 21.8-20.2 42.3-37.8 59.8c-62.5 62.5-163.8 62.5-226.3 0l-.1-.1L125.6 352l34.4 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L48.4 288c-1.6 0-3.2 .1-4.8 .3s-3.1 .5-4.6 1z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's WarningTriangle icon (Font Awesome, 512x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z" />
</group>
</vector>
@@ -213,6 +213,20 @@
android:text="0" />
</org.wiicompiled.quest.launcher.TapRow>
<LinearLayout
android:id="@+id/nav_leaderboard"
style="@style/Launcher.NavItem">
<ImageView
style="@style/Launcher.NavIcon"
android:importantForAccessibility="no"
android:src="@drawable/ic_award" />
<TextView
style="@style/Launcher.NavLabel"
android:text="@string/launcher_nav_leaderboard" />
</LinearLayout>
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
@@ -267,5 +281,10 @@
android:id="@+id/page_rooms"
layout="@layout/page_rooms"
android:visibility="gone" />
<include
android:id="@+id/page_leaderboard"
layout="@layout/page_leaderboard"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The top of the leaderboard's list (LeaderboardPage's podium): its title and the first three
players, second, first and third from the left, the first on a wider, higher step. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingBottom="4dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:layout_width="14dp"
android:layout_height="14dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_star"
android:tint="@color/warning_500" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="7dp"
android:text="@string/leaderboard_podium"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/leaderboard_live"
android:textColor="@color/neutral_300"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:baselineAligned="false"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingBottom="16dp">
<include
android:id="@+id/podium_second"
layout="@layout/view_podium_card"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="4dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="4dp"
android:layout_weight="1" />
<include
android:id="@+id/podium_first"
layout="@layout/view_podium_card"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_weight="1.1" />
<include
android:id="@+id/podium_third"
layout="@layout/view_podium_card"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="4dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="4dp"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A player in a room or in the search (WheelWizard's PlayerListItem): their Mii, name and friend
code, VR, badges, leaderboard place and whether they host the room. -->
<!-- A player in a room, the search or the leaderboard (WheelWizard's PlayerListItem): their Mii,
name and friend code, VR, badges, View Room on the leaderboard, leaderboard place and whether
they host the room. -->
<org.wiicompiled.quest.launcher.TapRow xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -98,6 +99,25 @@
</HorizontalScrollView>
</FrameLayout>
<!-- The leaderboard's View Room, for a player in a room now: a button, which TapRow lets through. -->
<ImageView
android:id="@+id/player_join"
android:layout_width="30dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_play_button"
android:clickable="true"
android:contentDescription="@string/leaderboard_view_room"
android:focusable="true"
android:paddingStart="8dp"
android:paddingTop="5dp"
android:paddingEnd="8dp"
android:paddingBottom="5dp"
android:src="@drawable/ic_road"
android:tint="@color/play_button_content"
android:tooltipText="@string/leaderboard_view_room"
android:visibility="gone" />
<TextView
android:id="@+id/player_top"
android:layout_width="wrap_content"
@@ -0,0 +1,161 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Leaderboard page (Views/Pages/LeaderboardPage.axaml): Retro WFC's top 50, the
first three on a podium (header_leaderboard.xml) and the rest in a list, or what stops them. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingEnd="24dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="64dp">
<TextView
style="@style/Launcher.PageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="10dp"
android:text="@string/launcher_nav_leaderboard" />
</FrameLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/leaderboard_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical">
<ProgressBar
android:layout_width="68dp"
android:layout_height="68dp"
android:indeterminateTint="@color/neutral_700" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/leaderboard_loading"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/leaderboard_error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_warning_triangle"
android:tint="@color/danger_500" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:fontFamily="sans-serif-medium"
android:text="@string/leaderboard_error_title"
android:textColor="@color/neutral_100"
android:textSize="18sp" />
<TextView
android:id="@+id/leaderboard_error_text"
android:layout_width="310dp"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
<TextView
android:id="@+id/leaderboard_retry"
style="@style/Launcher.Button.Primary"
android:layout_marginTop="10dp"
android:paddingStart="20dp"
android:paddingEnd="24dp"
android:drawableStart="@drawable/ic_refresh"
android:drawablePadding="10dp"
android:drawableTint="@color/play_button_content"
android:text="@string/leaderboard_retry" />
</LinearLayout>
<LinearLayout
android:id="@+id/leaderboard_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_award"
android:tint="@color/neutral_600" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:fontFamily="sans-serif-medium"
android:text="@string/leaderboard_empty_title"
android:textColor="@color/neutral_200"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
android:maxWidth="440dp"
android:text="@string/leaderboard_empty_message"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
<TextView
android:id="@+id/leaderboard_refresh"
style="@style/Launcher.Button.Primary"
android:layout_marginTop="10dp"
android:paddingStart="20dp"
android:paddingEnd="24dp"
android:drawableStart="@drawable/ic_refresh"
android:drawablePadding="10dp"
android:drawableTint="@color/play_button_content"
android:text="@string/leaderboard_refresh" />
</LinearLayout>
<ListView
android:id="@+id/leaderboard_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:divider="@android:color/transparent"
android:dividerHeight="6dp"
android:listSelector="@android:color/transparent"
android:paddingTop="10dp"
android:paddingBottom="12dp"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,208 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A step of the leaderboard's podium (WheelWizard's LeaderboardPodiumCard): the player's rank and
place, Mii with their first badge, name and VR, over a glow in their medal's colour, with a
glint and three sparkles. The PC lets the glow and glint spill over the card's edge; here the
card clips them to its rounded box, and only the sparkles float outside it. PodiumCard colours
and animates it. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:clipChildren="false"
android:clipToPadding="false"
android:focusable="true"
android:minHeight="190dp">
<FrameLayout
android:id="@+id/podium_surface"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="4dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="5dp"
android:clipToOutline="true">
<View
android:id="@+id/podium_aura"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="top|center_horizontal"
android:layout_marginTop="-17dp"
android:alpha="0.3" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="10dp"
android:paddingTop="14dp"
android:paddingEnd="10dp"
android:paddingBottom="10dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingEnd="18dp">
<TextView
android:id="@+id/podium_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_podium_rank"
android:paddingStart="8dp"
android:paddingTop="2dp"
android:paddingEnd="8dp"
android:paddingBottom="2dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/podium_place"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:singleLine="true"
android:textColor="@color/neutral_200"
android:textSize="12sp" />
</LinearLayout>
<ImageView
android:id="@+id/podium_suspicious"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="top|end"
android:layout_marginTop="2dp"
android:layout_marginEnd="2dp"
android:contentDescription="@string/player_profile_suspicious"
android:src="@drawable/ic_warning_tip"
android:tint="@color/danger_400"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="10dp"
android:clipChildren="false">
<FrameLayout
android:id="@+id/podium_avatar"
android:layout_width="104dp"
android:layout_height="104dp"
android:background="@drawable/bg_podium_avatar"
android:clipToOutline="true"
android:foreground="@drawable/fg_podium_avatar">
<ImageView
android:id="@+id/podium_placeholder"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/ic_user"
android:tint="#52FFFFFF" />
<ImageView
android:id="@+id/podium_mii"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no" />
</FrameLayout>
<FrameLayout
android:id="@+id/podium_badge"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="bottom|end"
android:layout_marginEnd="-6dp"
android:layout_marginBottom="-2dp"
android:background="@drawable/bg_podium_badge"
android:padding="4dp"
android:visibility="gone" />
</FrameLayout>
<TextView
android:id="@+id/podium_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_horizontal"
android:singleLine="true"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:layout_width="14dp"
android:layout_height="14dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_chart"
android:tint="@color/neutral_200" />
<TextView
android:id="@+id/podium_vr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:fontFamily="sans-serif-medium"
android:textColor="@color/neutral_100"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
<View
android:id="@+id/podium_shimmer"
android:layout_width="80dp"
android:layout_height="220dp"
android:layout_gravity="center_vertical|start"
android:alpha="0"
android:background="@drawable/bg_podium_shimmer"
android:rotation="25" />
</FrameLayout>
<View
android:id="@+id/podium_particle_a"
android:layout_width="7dp"
android:layout_height="7dp"
android:layout_gravity="top|start"
android:layout_marginStart="12dp"
android:layout_marginTop="22dp"
android:background="@drawable/bg_podium_particle" />
<View
android:id="@+id/podium_particle_b"
android:layout_width="5dp"
android:layout_height="5dp"
android:layout_gravity="top|end"
android:layout_marginTop="38dp"
android:layout_marginEnd="16dp"
android:background="@drawable/bg_podium_particle" />
<View
android:id="@+id/podium_particle_c"
android:layout_width="4dp"
android:layout_height="4dp"
android:layout_gravity="top|center_horizontal"
android:layout_marginTop="12dp"
android:background="@drawable/bg_podium_particle" />
</FrameLayout>
@@ -13,6 +13,7 @@
<string name="launcher_nav_settings">Settings</string>
<string name="launcher_section_online">Online</string>
<string name="launcher_nav_rooms">Rooms</string>
<string name="launcher_nav_leaderboard">Leaderboard</string>
<string name="launcher_players_online_none">There are currently no players online</string>
<plurals name="launcher_players_online">
<item quantity="one">There is currently %d player online</item>
@@ -186,6 +187,22 @@
<string name="room_view_mii">View Mii</string>
<string name="room_view_profile">View Profile</string>
<string name="room_no_mii">This player has no valid Mii data.</string>
<string name="leaderboard_loading">Loading leaderboard…</string>
<string name="leaderboard_error_title">Could not load leaderboard.</string>
<string name="leaderboard_error_fallback">Failed to load leaderboard.</string>
<string name="leaderboard_retry">Retry</string>
<string name="leaderboard_empty_title">No leaderboard data</string>
<string name="leaderboard_empty_message">There are no ranked players available right now.</string>
<string name="leaderboard_refresh">Refresh</string>
<string name="leaderboard_podium">Top 3 Podium</string>
<string name="leaderboard_live">Live from RWFC</string>
<string name="leaderboard_champion">Champion</string>
<string name="leaderboard_second">2nd Place</string>
<string name="leaderboard_third">3rd Place</string>
<string name="leaderboard_rank">#%1$d</string>
<string name="leaderboard_unknown_player">Unknown Player</string>
<string name="leaderboard_view_room">View Room</string>
<string name="leaderboard_no_room">Could not find an active room for this player.</string>
<plurals name="time_days">
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
@@ -0,0 +1,56 @@
package org.wiicompiled.quest.launcher
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
/** The leaderboard page ranks Retro WFC's top players as WheelWizard's LeaderboardPage does. */
class LeaderboardTest {
private fun entry(pid: String, rank: Int? = null, activeRank: Int? = null, name: String = pid, mii: ByteArray? = null) =
RetroWfc.LeaderboardEntry(pid, "$pid-0000-0000", rank, activeRank, name = name, vr = 1000, mii = mii)
@Test
fun rowsAreOrderedByTheirResolvedRank() {
val rows = Leaderboard.rows(
listOf(
entry("a", rank = 3),
// No rank: its active rank.
entry("b", activeRank = 1),
// Neither, or ranks beyond 50,000: its place in the answer, here the third.
entry("c", rank = 0, activeRank = 50_001),
// Sharing a rank keeps Retro WFC's order.
entry("d", rank = 3),
),
)
assertEquals(listOf("b", "a", "c", "d"), rows.map { it.name })
assertEquals(listOf(1, 3, 3, 3), rows.map { it.rank })
}
@Test
fun atMostFiftyRowsWithTheirMiiAndBlankNamesKept() {
val mii = MiiFactory.female("Racer")
val entries = (1..60).map { index -> entry("p$index", rank = index) } +
listOf(entry("first", rank = 1, name = " ", mii = MiiData.serialize(mii)), entry("broken", rank = 2, mii = ByteArray(MiiData.SIZE) { 0xFF.toByte() }))
val rows = Leaderboard.rows(entries)
assertEquals(50, rows.size)
assertEquals(listOf("p1", " ", "p2", "broken"), rows.take(4).map { it.name })
assertEquals(mii, rows[1].mii)
// Bytes the PC cannot read as a Mii are no Mii.
assertNull(rows[3].mii)
assertNull(rows[0].mii)
}
@Test
fun viewRoomFindsThePlayersRoomByTheirFriendCodesDigits() {
fun player(friendCode: String) = LiveRooms.Player("1", "Racer", friendCode, 5000, null, false, false, null, null)
val first = LiveRooms.Room("AAAA", 0L, "anybody", "vs_10", listOf(player("1111-2222-3333")))
val second = LiveRooms.Room("BBBB", 0L, "anybody", "vs_10", listOf(player("4444-5555-6666")))
val rooms = listOf(first, second)
assertSame(second, Leaderboard.roomOf(rooms, "444455556666"))
assertSame(first, Leaderboard.roomOf(rooms, "1111-2222-3333"))
assertNull(Leaderboard.roomOf(rooms, "7777-8888-9999"))
assertNull(Leaderboard.roomOf(rooms, ""))
}
}
@@ -79,17 +79,29 @@ class RetroWfcTest {
@Test
fun theLeaderboardGivesRanksByProfileAndFriendCode() {
val mii = MiiData.serialize(MiiFactory.male("Top"))
val json = """
[{"pid": "592986326", "name": "s", "friendCode": "1111-2222-3333", "vr": 161570, "rank": 1, "isSuspicious": false},
{"pid": "2", "friendCode": "", "rank": null, "activeRank": 7},
[{"pid": "592986326", "name": "s", "friendCode": "1111-2222-3333", "vr": 161570, "rank": 1, "isSuspicious": false,
"lastSeen": "2026-09-17T16:18:05.961583Z", "vrStats": {"last24Hours": 0, "lastWeek": -448, "lastMonth": 1481},
"miiImageBase64": null, "miiData": "${Base64.getEncoder().encodeToString(mii)}", "badges": [2009, 2016],
"vehiclePreference": "bike", "vehicleRank": 1},
{"pid": "2", "friendCode": "", "rank": null, "activeRank": 7, "isSuspicious": true, "miiData": null},
{"name": "no pid", "rank": 3}]
""".trimIndent()
val entries = RetroWfc.parseLeaderboard(json)
assertEquals(listOf("592986326", "2"), entries.map { it.pid })
assertEquals(1, entries[0].rank)
assertEquals("1111-2222-3333", entries[0].friendCode)
assertEquals("s", entries[0].name)
assertEquals(161570, entries[0].vr)
assertEquals(false, entries[0].isSuspicious)
assertArrayEquals(mii, entries[0].mii)
assertNull(entries[1].rank)
assertEquals(7, entries[1].activeRank)
assertEquals("", entries[1].name)
assertNull(entries[1].vr)
assertEquals(true, entries[1].isSuspicious)
assertNull(entries[1].mii)
}
@Test
+27 -3
View File
@@ -285,7 +285,9 @@ parallel, and a choice about 40 ms. `MiiDataTest`, `MiiDatabaseTest`, `MiiIdsTes
**Rooms**, under Online after Settings, is the PC's RoomsPage and RoomDetailsPage
(`RoomsPage`), fed by `LiveRooms`, the PC's RRLiveRooms: while the launcher is on
screen it asks Retro WFC every 40 s for `/api/roomstatus` and the top 50 of
`/api/leaderboard/top/50` (kept 90 s, the last ones reused when it fails), splits
`/api/leaderboard/top/50` (`Leaderboard`, the PC's RrLeaderboardSingletonService:
kept 90 s for this page and the Leaderboard alike, the last ones reused when it
fails), splits
the rooms Retro WFC merged by mistake where their players' connection maps are not
linked both ways (the PC's SplitMergedRooms, checked against it on a real answer),
and marks top-50 players with their place. The same answers drive the sidebar's
@@ -295,14 +297,36 @@ older fields. The page lists rooms with their game mode (the PC's `rk` names), I
time online and player count, or with a search the players whose name or friend
code holds it; a room opens its details (ID, time online, mode, average VR, and its
players with their Mii, VR, badges, place and open host), and a player there Copy
Friend Code, View Mii and View Profile. View Mii is the PC's MiiCarouselWindow: the
Friend Code, View Mii and View Profile (`PlayerActions`, shared with the
Leaderboard). View Mii is the PC's MiiCarouselWindow: the
whole Mii in the renderer's `all_body` view, turned by dragging and zoomed with the
thumbstick, small while it moves and sharp once it rests. View Profile is the PC's
PlayerProfileWindow, with the VR history of My profiles (`VrHistoryPanel`, one
`view_vr_history` layout for both). The PC's Add Friend waits for the Friends page,
which reads and writes the save's friend list. Rows are `TapRow`s, which take every
tap so a tooltip or the badge strip inside cannot swallow one, while the pointer's
hover still shows their tips. `LiveRoomsTest` and `RetroWfcTest` cover it.
hover still shows their tips; only a button inside a row keeps the taps that begin
on it. `LiveRoomsTest` and `RetroWfcTest` cover it.
**Leaderboard**, after Rooms, is the PC's LeaderboardPage (`LeaderboardPage`): the
top 50 from that shared cache, asked again each time the page is shown as the PC
makes the page anew, ordered by rank (else the active rank, else the place in the
answer, as ResolveRank), a blank name shown as Unknown Player. The first three stand
on the PC's podium (`PodiumCard`, LeaderboardPodiumCard): second, first and third,
each card in its medal's colours with the rank, the place, the Mii, the first badge,
the name, the VR and a mark on suspicious players, and the PC's animations, which
move only view properties: the cards rise in one after another, then float, their
glow breathes, a glint sweeps across and three sparkles rise, and a card grows a
little under the pointer; they stop while the page is hidden. Unlike the PC, the card
clips its glow and glint to its rounded box, and the glint is stretched to the taller
cards' height and swept from beyond one side to beyond the other so it crosses all of
the wider cards; only the sparkles sit outside. The rest are
PlayerListItem rows with their place in grey. A tap on a player opens the same
actions as in a room; a row whose player is in a room now also has View Room, which
opens that room on the Rooms page (the PC's JoinRoom, matching friend codes by their
digits) and follows the rooms as they change. Loading, the error with Retry and the
empty board with Refresh are the PC's too. `LeaderboardTest` and `RetroWfcTest`
cover it.
The launcher follows the runtime's rules exactly. `TomlConfig` edits one line
the way `RuntimeConfigFile::WriteSetting` does, and every edit re-reads the file,