diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt index 2dcce5d..5024f56 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt @@ -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 } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/Leaderboard.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/Leaderboard.kt new file mode 100644 index 0000000..81760d1 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/Leaderboard.kt @@ -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? = 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 { + 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): List = + 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, 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' } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LeaderboardPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LeaderboardPage.kt new file mode 100644 index 0000000..2e3ec6f --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LeaderboardPage.kt @@ -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 = emptyList() + private var badges: Map> = 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(R.id.leaderboard_retry).setOnClickListener { load() } + root.findViewById(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) { + 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 = 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(R.id.player_top).setBackgroundResource(R.drawable.bg_top_label_neutral) + it.findViewById(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(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()) } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LiveRooms.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LiveRooms.kt index 4c09c01..3e2706c 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/LiveRooms.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LiveRooms.kt @@ -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 get() = rooms.flatMap { room -> room.players.map { it.friendCode } }.toSet() - // The leaderboard's cache, on the worker thread only. - private var leaderboard: List? = null - private var leaderboardAt = 0L - private val ticker = object : Runnable { override fun run() { fetch() @@ -117,18 +109,12 @@ object LiveRooms { } } - private fun topPlayers(): List? { - 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? = 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. diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerActions.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerActions.kt new file mode 100644 index 0000000..335e446 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerActions.kt @@ -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) + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerRow.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerRow.kt new file mode 100644 index 0000000..5c5b44f --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerRow.kt @@ -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, + topLabel: String?, + isOpenHost: Boolean, + ) { + val picture = row.findViewById(R.id.player_mii) + val drawn = mii != null && MiiRenderResource.installed(activity) + row.findViewById(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(R.id.player_name).text = name + row.findViewById(R.id.player_code).text = friendCode + row.findViewById(R.id.player_vr).text = vrText + + row.findViewById(R.id.player_badges_box).visibility = if (badges.isEmpty()) View.GONE else View.VISIBLE + row.findViewById(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(R.id.player_top).apply { + text = topLabel.orEmpty() + visibility = if (topLabel != null) View.VISIBLE else View.GONE + } + row.findViewById(R.id.player_host).visibility = if (isOpenHost) View.VISIBLE else View.GONE + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/PodiumCard.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/PodiumCard.kt new file mode 100644 index 0000000..9268182 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/PodiumCard.kt @@ -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 = 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() + 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(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(R.id.podium_rank).text = activity.getString(R.string.leaderboard_rank, row.rank) + root.findViewById(R.id.podium_place).text = place + root.findViewById(R.id.podium_name).text = name + root.findViewById(R.id.podium_vr).text = vrText + root.findViewById(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) + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt index 4761baf..b67e866 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt @@ -84,8 +84,17 @@ object RetroWfc { val players: List, ) - /** 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 = 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")), ) } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt index 121a26e..e61aecf 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt @@ -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(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(R.id.player_mii) - val mii = player.mii - val drawn = mii != null && MiiRenderResource.installed(activity) - row.findViewById(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(R.id.player_name).text = player.name - row.findViewById(R.id.player_code).text = player.friendCode - row.findViewById(R.id.player_vr).text = player.vrText - - val badgeList = badges[player.friendCode].orEmpty() - row.findViewById(R.id.player_badges_box).visibility = if (badgeList.isEmpty()) View.GONE else View.VISIBLE - row.findViewById(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(R.id.player_top).apply { - text = player.topLabel - visibility = if (player.leaderboardRank != null) View.VISIBLE else View.GONE - } - row.findViewById(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 - } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt index fce2164..f97f7dd 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt @@ -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 + } } diff --git a/android/app/src/main/res/drawable/bg_podium_avatar.xml b/android/app/src/main/res/drawable/bg_podium_avatar.xml new file mode 100644 index 0000000..0e16512 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_podium_avatar.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_podium_badge.xml b/android/app/src/main/res/drawable/bg_podium_badge.xml new file mode 100644 index 0000000..91bdb2c --- /dev/null +++ b/android/app/src/main/res/drawable/bg_podium_badge.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_podium_particle.xml b/android/app/src/main/res/drawable/bg_podium_particle.xml new file mode 100644 index 0000000..4fd2a4c --- /dev/null +++ b/android/app/src/main/res/drawable/bg_podium_particle.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_podium_rank.xml b/android/app/src/main/res/drawable/bg_podium_rank.xml new file mode 100644 index 0000000..f6c24c2 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_podium_rank.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_podium_shimmer.xml b/android/app/src/main/res/drawable/bg_podium_shimmer.xml new file mode 100644 index 0000000..08baee1 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_podium_shimmer.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_top_label_neutral.xml b/android/app/src/main/res/drawable/bg_top_label_neutral.xml new file mode 100644 index 0000000..f09bc4b --- /dev/null +++ b/android/app/src/main/res/drawable/bg_top_label_neutral.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/fg_podium_avatar.xml b/android/app/src/main/res/drawable/fg_podium_avatar.xml new file mode 100644 index 0000000..0509282 --- /dev/null +++ b/android/app/src/main/res/drawable/fg_podium_avatar.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_refresh.xml b/android/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..445bc82 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_warning_triangle.xml b/android/app/src/main/res/drawable/ic_warning_triangle.xml new file mode 100644 index 0000000..615b9c9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_warning_triangle.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/activity_launcher.xml b/android/app/src/main/res/layout/activity_launcher.xml index abd2bb0..5d9d15e 100644 --- a/android/app/src/main/res/layout/activity_launcher.xml +++ b/android/app/src/main/res/layout/activity_launcher.xml @@ -213,6 +213,20 @@ android:text="0" /> + + + + + + + + + diff --git a/android/app/src/main/res/layout/header_leaderboard.xml b/android/app/src/main/res/layout/header_leaderboard.xml new file mode 100644 index 0000000..643eae4 --- /dev/null +++ b/android/app/src/main/res/layout/header_leaderboard.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_player.xml b/android/app/src/main/res/layout/item_player.xml index f6cea60..c97af94 100644 --- a/android/app/src/main/res/layout/item_player.xml +++ b/android/app/src/main/res/layout/item_player.xml @@ -1,6 +1,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/view_podium_card.xml b/android/app/src/main/res/layout/view_podium_card.xml new file mode 100644 index 0000000..1a9859c --- /dev/null +++ b/android/app/src/main/res/layout/view_podium_card.xml @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7a55ea1..b189e9c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ Settings Online Rooms + Leaderboard There are currently no players online There is currently %d player online @@ -186,6 +187,22 @@ View Mii View Profile This player has no valid Mii data. + Loading leaderboard… + Could not load leaderboard. + Failed to load leaderboard. + Retry + No leaderboard data + There are no ranked players available right now. + Refresh + Top 3 Podium + Live from RWFC + Champion + 2nd Place + 3rd Place + #%1$d + Unknown Player + View Room + Could not find an active room for this player. %d day %d days diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/LeaderboardTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/LeaderboardTest.kt new file mode 100644 index 0000000..3b31576 --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/LeaderboardTest.kt @@ -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, "")) + } +} diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt index 588b755..801a512 100644 --- a/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt @@ -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 diff --git a/docs/quest-port.md b/docs/quest-port.md index 23292ec..566115d 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -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,