mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 01:00:14 +02:00
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:
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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user