mirror of
https://github.com/yairm210/Unciv.git
synced 2025-07-15 02:09:21 +07:00
Spruced up ModManagementScreen - phase 1 (#3983)
* Spruced up ModManagementScreen - phase 1 * Spruced up ModManagementScreen - phase 1 - patch1
This commit is contained in:
@ -940,6 +940,7 @@ Invalid ID! =
|
|||||||
|
|
||||||
Mods =
|
Mods =
|
||||||
Download [modName] =
|
Download [modName] =
|
||||||
|
Update [modName] =
|
||||||
Could not download mod list =
|
Could not download mod list =
|
||||||
Download mod from URL =
|
Download mod from URL =
|
||||||
Download =
|
Download =
|
||||||
@ -956,6 +957,9 @@ Disable as permanent visual mod =
|
|||||||
Installed =
|
Installed =
|
||||||
Downloaded! =
|
Downloaded! =
|
||||||
Could not download mod =
|
Could not download mod =
|
||||||
|
Online query result is incomplete =
|
||||||
|
No description provided =
|
||||||
|
[stargazers]✯ =
|
||||||
|
|
||||||
# Uniques that are relevant to more than one type of game object
|
# Uniques that are relevant to more than one type of game object
|
||||||
|
|
||||||
|
@ -34,6 +34,8 @@ class ModOptions {
|
|||||||
|
|
||||||
var lastUpdated = ""
|
var lastUpdated = ""
|
||||||
var modUrl = ""
|
var modUrl = ""
|
||||||
|
var author = ""
|
||||||
|
var modSize = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
class Ruleset {
|
class Ruleset {
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
package com.unciv.ui.pickerscreens
|
package com.unciv.ui.pickerscreens
|
||||||
|
|
||||||
import com.badlogic.gdx.Gdx
|
import com.badlogic.gdx.Gdx
|
||||||
|
import com.badlogic.gdx.files.FileHandle
|
||||||
import com.badlogic.gdx.graphics.Color
|
import com.badlogic.gdx.graphics.Color
|
||||||
import com.badlogic.gdx.scenes.scene2d.Actor
|
import com.badlogic.gdx.scenes.scene2d.Actor
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
|
import com.badlogic.gdx.scenes.scene2d.Touchable
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
import com.badlogic.gdx.scenes.scene2d.ui.*
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.TextArea
|
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
|
|
||||||
import com.badlogic.gdx.utils.Align
|
import com.badlogic.gdx.utils.Align
|
||||||
import com.badlogic.gdx.utils.Json
|
import com.badlogic.gdx.utils.Json
|
||||||
import com.unciv.JsonParser
|
import com.unciv.JsonParser
|
||||||
@ -16,46 +15,137 @@ import com.unciv.models.ruleset.Ruleset
|
|||||||
import com.unciv.models.ruleset.RulesetCache
|
import com.unciv.models.ruleset.RulesetCache
|
||||||
import com.unciv.models.translations.tr
|
import com.unciv.models.translations.tr
|
||||||
import com.unciv.ui.utils.*
|
import com.unciv.ui.utils.*
|
||||||
|
import com.unciv.ui.utils.UncivDateFormat.formatDate
|
||||||
|
import com.unciv.ui.utils.UncivDateFormat.parseDate
|
||||||
import com.unciv.ui.worldscreen.mainmenu.Github
|
import com.unciv.ui.worldscreen.mainmenu.Github
|
||||||
import java.text.DateFormat
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.collections.HashMap
|
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Mod Management Screen - called only from [MainMenuScreen]
|
||||||
|
*/
|
||||||
// All picker screens auto-wrap the top table in a ScrollPane.
|
// All picker screens auto-wrap the top table in a ScrollPane.
|
||||||
// Since we want the different parts to scroll separately, we disable the default ScrollPane, which would scroll everything at once.
|
// Since we want the different parts to scroll separately, we disable the default ScrollPane, which would scroll everything at once.
|
||||||
class ModManagementScreen: PickerScreen(disableScroll = true) {
|
class ModManagementScreen: PickerScreen(disableScroll = true) {
|
||||||
|
|
||||||
val modTable = Table().apply { defaults().pad(10f) }
|
private val modTable = Table().apply { defaults().pad(10f) }
|
||||||
val downloadTable = Table().apply { defaults().pad(10f) }
|
private val scrollInstalledMods = ScrollPane(modTable)
|
||||||
val modActionTable = Table().apply { defaults().pad(10f) }
|
private val downloadTable = Table().apply { defaults().pad(10f) }
|
||||||
|
private val scrollOnlineMods = ScrollPane(downloadTable)
|
||||||
|
private val modActionTable = Table().apply { defaults().pad(10f) }
|
||||||
|
|
||||||
val amountPerPage = 30
|
val amountPerPage = 30
|
||||||
|
|
||||||
var lastSelectedButton: TextButton? = null
|
private var lastSelectedButton: Button? = null
|
||||||
val modDescriptions: HashMap<String, String> = hashMapOf()
|
private var lastSyncMarkedButton: Button? = null
|
||||||
|
private var selectedModName = ""
|
||||||
|
private var selectedAuthor = ""
|
||||||
|
|
||||||
|
// keep running count of mods fetched from online search for comparison to total count as reported by GitHub
|
||||||
|
private var downloadModCount = 0
|
||||||
|
|
||||||
|
// Description data from installed mods and online search
|
||||||
|
private val modDescriptionsInstalled: HashMap<String, String> = hashMapOf()
|
||||||
|
private val modDescriptionsOnline: HashMap<String, String> = hashMapOf()
|
||||||
|
private fun showModDescription(modName: String) {
|
||||||
|
val online = modDescriptionsOnline[modName] ?: ""
|
||||||
|
val installed = modDescriptionsInstalled[modName] ?: ""
|
||||||
|
val separator = if(online.isEmpty() || installed.isEmpty()) "" else "\n"
|
||||||
|
descriptionLabel.setText(online + separator + installed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable syncing entries in 'installed' and 'repo search ScrollPanes
|
||||||
|
private class ScrollToEntry(val y: Float, val height: Float, val button: Button)
|
||||||
|
private val installedScrollIndex = HashMap<String,ScrollToEntry>(30)
|
||||||
|
private val onlineScrollIndex = HashMap<String,ScrollToEntry>(30)
|
||||||
|
private var onlineScrollCurrentY = -1f
|
||||||
|
|
||||||
|
|
||||||
|
// cleanup - background processing needs to be stopped on exit and memory freed
|
||||||
|
private var runningSearchThread: Thread? = null
|
||||||
|
private var stopBackgroundTasks = false
|
||||||
|
override fun dispose() {
|
||||||
|
// make sure the worker threads will not continue trying their time-intensive job
|
||||||
|
runningSearchThread?.interrupt()
|
||||||
|
stopBackgroundTasks = true
|
||||||
|
super.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper class keeps references to decoration images of installed mods to enable dynamic visibility
|
||||||
|
* (actually we do not use isVisible but refill a container selectively which allows the aggregate height to adapt and the set to center vertically)
|
||||||
|
* @param container the table containing the indicators (one per mod, narrow, arranges up to three indicators vertically)
|
||||||
|
* @param visualImage image indicating _enabled as permanent visual mod_
|
||||||
|
* @param updatedImage image indicating _online mod has been updated_
|
||||||
|
*/
|
||||||
|
private class ModStateImages (
|
||||||
|
val container: Table,
|
||||||
|
isVisual: Boolean = false,
|
||||||
|
isUpdated: Boolean = false,
|
||||||
|
val visualImage: Image = ImageGetter.getImage("UnitPromotionIcons/Scouting"),
|
||||||
|
val updatedImage: Image = ImageGetter.getImage("OtherIcons/Mods")
|
||||||
|
) {
|
||||||
|
// mad but it's really initializing with the primary constructor parameter and not calling update()
|
||||||
|
var isVisual: Boolean = isVisual
|
||||||
|
set(value) { if(field!=value) { field = value; update() } }
|
||||||
|
var isUpdated: Boolean = isUpdated
|
||||||
|
set(value) { if(field!=value) { field = value; update() } }
|
||||||
|
private val spacer = Table().apply { width = 20f; height = 0f }
|
||||||
|
fun update() {
|
||||||
|
container.run {
|
||||||
|
clear()
|
||||||
|
if (isVisual) add(visualImage).row()
|
||||||
|
if (isUpdated) add(updatedImage).row()
|
||||||
|
if (!isVisual && !isUpdated) add(spacer)
|
||||||
|
pack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val modStateImages = HashMap<String,ModStateImages>(30)
|
||||||
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setDefaultCloseAction(MainMenuScreen())
|
setDefaultCloseAction(MainMenuScreen())
|
||||||
refreshModTable()
|
refreshInstalledModTable()
|
||||||
|
|
||||||
topTable.add("Current mods".toLabel()).padRight(35f) // 35 = 10 default pad + 25 to compensate for permanent visual mod decoration icon
|
// Header row
|
||||||
topTable.add("Downloadable mods".toLabel())
|
topTable.add().expandX() // empty cols left and right for separator
|
||||||
// topTable.add("Mod actions")
|
topTable.add("Current mods".toLabel()).pad(5f).minWidth(200f).padLeft(25f)
|
||||||
|
// 30 = 5 default pad + 20 to compensate for 'permanent visual mod' decoration icon
|
||||||
|
topTable.add("Downloadable mods".toLabel()).pad(5f)
|
||||||
|
topTable.add("".toLabel()).minWidth(200f) // placeholder for "Mod actions"
|
||||||
|
topTable.add().expandX()
|
||||||
topTable.row()
|
topTable.row()
|
||||||
|
|
||||||
topTable.add(ScrollPane(modTable)).pad(10f)
|
// horizontal separator looking like the SplitPane handle
|
||||||
|
val separator = Table(skin)
|
||||||
|
separator.background = skin.get("default-vertical", SplitPane.SplitPaneStyle::class.java).handle
|
||||||
|
topTable.add(separator).minHeight(3f).fillX().colspan(5).row()
|
||||||
|
|
||||||
downloadTable.add(getDownloadButton()).row()
|
// main row containing the three 'blocks' installed, online and information
|
||||||
tryDownloadPage(1)
|
topTable.add() // skip empty first column
|
||||||
topTable.add(ScrollPane(downloadTable))
|
topTable.add(scrollInstalledMods)
|
||||||
|
|
||||||
|
reloadOnlineMods()
|
||||||
|
topTable.add(scrollOnlineMods)
|
||||||
|
|
||||||
topTable.add(modActionTable)
|
topTable.add(modActionTable)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tryDownloadPage(pageNum: Int) {
|
private fun reloadOnlineMods() {
|
||||||
thread {
|
onlineScrollCurrentY = -1f
|
||||||
|
downloadTable.clear()
|
||||||
|
onlineScrollIndex.clear()
|
||||||
|
downloadTable.add(getDownloadFromUrlButton()).padBottom(15f).row()
|
||||||
|
downloadTable.add("...".toLabel()).row()
|
||||||
|
tryDownloadPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** background worker: querying GitHub for Mods (repos with 'unciv-mod' in its topics)
|
||||||
|
*
|
||||||
|
* calls itself for the next page of search results
|
||||||
|
*/
|
||||||
|
private fun tryDownloadPage(pageNum: Int) {
|
||||||
|
runningSearchThread = thread(name="GitHubSearch") {
|
||||||
val repoSearch: Github.RepoSearch
|
val repoSearch: Github.RepoSearch
|
||||||
try {
|
try {
|
||||||
repoSearch = Github.tryGetGithubReposWithTopic(amountPerPage, pageNum)!!
|
repoSearch = Github.tryGetGithubReposWithTopic(amountPerPage, pageNum)!!
|
||||||
@ -63,83 +153,158 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
|
|||||||
Gdx.app.postRunnable {
|
Gdx.app.postRunnable {
|
||||||
ToastPopup("Could not download mod list", this)
|
ToastPopup("Could not download mod list", this)
|
||||||
}
|
}
|
||||||
|
runningSearchThread = null
|
||||||
return@thread
|
return@thread
|
||||||
}
|
}
|
||||||
|
|
||||||
Gdx.app.postRunnable {
|
Gdx.app.postRunnable {
|
||||||
|
// clear and hide last cell if it is the "..." indicator
|
||||||
|
val lastCell = downloadTable.cells.lastOrNull()
|
||||||
|
if (lastCell != null && lastCell.actor is Label && (lastCell.actor as Label).text.toString() == "...") {
|
||||||
|
lastCell.setActor<Actor>(null)
|
||||||
|
lastCell.pad(0f)
|
||||||
|
}
|
||||||
|
|
||||||
for (repo in repoSearch.items) {
|
for (repo in repoSearch.items) {
|
||||||
|
if (stopBackgroundTasks) return@postRunnable
|
||||||
repo.name = repo.name.replace('-', ' ')
|
repo.name = repo.name.replace('-', ' ')
|
||||||
|
|
||||||
modDescriptions[repo.name] = repo.description + "\n" + "[${repo.stargazers_count}]✯".tr() +
|
modDescriptionsOnline[repo.name] =
|
||||||
if (modDescriptions.contains(repo.name))
|
(repo.description ?: "-{No description provided}-".tr()) +
|
||||||
"\n" + modDescriptions[repo.name]
|
"\n" + "[${repo.stargazers_count}]✯".tr()
|
||||||
else ""
|
|
||||||
|
|
||||||
var downloadButtonText = repo.name
|
var downloadButtonText = repo.name
|
||||||
|
|
||||||
val existingMod = RulesetCache.values.firstOrNull { it.name == repo.name }
|
val existingMod = RulesetCache.values.firstOrNull { it.name == repo.name }
|
||||||
if (existingMod != null) {
|
if (existingMod != null) {
|
||||||
if (existingMod.modOptions.lastUpdated != "" && existingMod.modOptions.lastUpdated != repo.updated_at)
|
if (existingMod.modOptions.lastUpdated != "" && existingMod.modOptions.lastUpdated != repo.updated_at) {
|
||||||
downloadButtonText += " - {Updated}"
|
downloadButtonText += " - {Updated}"
|
||||||
}
|
modStateImages[repo.name]?.isUpdated = true
|
||||||
|
|
||||||
val downloadButton = downloadButtonText.toTextButton()
|
|
||||||
|
|
||||||
downloadButton.onClick {
|
|
||||||
lastSelectedButton?.color = Color.WHITE
|
|
||||||
downloadButton.color = Color.BLUE
|
|
||||||
lastSelectedButton = downloadButton
|
|
||||||
descriptionLabel.setText(modDescriptions[repo.name])
|
|
||||||
removeRightSideClickListeners()
|
|
||||||
rightSideButton.enable()
|
|
||||||
rightSideButton.setText("Download [${repo.name}]".tr())
|
|
||||||
rightSideButton.onClick {
|
|
||||||
rightSideButton.setText("Downloading...".tr())
|
|
||||||
rightSideButton.disable()
|
|
||||||
downloadMod(repo) {
|
|
||||||
rightSideButton.setText("Downloaded!".tr())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (existingMod.modOptions.author.isEmpty()) {
|
||||||
|
rewriteModOptions(repo, Gdx.files.local("mods").child(repo.name))
|
||||||
|
existingMod.modOptions.author = repo.owner.login
|
||||||
|
existingMod.modOptions.modSize = repo.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val downloadButton = downloadButtonText.toTextButton()
|
||||||
|
downloadButton.onClick { onlineButtonAction(repo, downloadButton) }
|
||||||
|
|
||||||
modActionTable.clear()
|
val cell = downloadTable.add(downloadButton)
|
||||||
addModInfoToActionTable(repo.html_url, repo.updated_at)
|
downloadTable.row()
|
||||||
}
|
if (onlineScrollCurrentY < 0f) onlineScrollCurrentY = cell.padTop
|
||||||
downloadTable.add(downloadButton).row()
|
onlineScrollIndex[repo.name] = ScrollToEntry(onlineScrollCurrentY, cell.prefHeight, downloadButton)
|
||||||
|
onlineScrollCurrentY += cell.padBottom + cell.prefHeight + cell.padTop
|
||||||
|
downloadModCount++
|
||||||
}
|
}
|
||||||
if (repoSearch.items.size == amountPerPage) {
|
|
||||||
val nextPageButton = "Next page".toTextButton()
|
// Now the tasks after the 'page' of search results has been fully processed
|
||||||
nextPageButton.onClick {
|
if (repoSearch.items.size < amountPerPage) {
|
||||||
nextPageButton.remove()
|
// The search has reached the last page!
|
||||||
tryDownloadPage(pageNum + 1)
|
// Check: due to time passing between github calls it is not impossible we get a mod twice
|
||||||
|
val checkedMods: MutableSet<String> = mutableSetOf()
|
||||||
|
val duplicates: MutableList<Cell<Actor>> = mutableListOf()
|
||||||
|
downloadTable.cells.forEach {
|
||||||
|
cell->
|
||||||
|
cell.actor?.name?.apply {
|
||||||
|
if (checkedMods.contains(this)) {
|
||||||
|
duplicates.add(cell)
|
||||||
|
} else checkedMods.add(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
downloadTable.add(nextPageButton).row()
|
duplicates.forEach {
|
||||||
|
it.setActor(null)
|
||||||
|
it.pad(0f) // the cell itself cannot be removed so stop it occupying height
|
||||||
|
}
|
||||||
|
downloadModCount -= duplicates.size
|
||||||
|
// Check: It is also not impossible we missed a mod - just inform user
|
||||||
|
if (repoSearch.total_count > downloadModCount || repoSearch.incomplete_results) {
|
||||||
|
val retryLabel = "Online query result is incomplete".toLabel(Color.RED)
|
||||||
|
retryLabel.touchable = Touchable.enabled
|
||||||
|
retryLabel.onClick { reloadOnlineMods() }
|
||||||
|
downloadTable.add(retryLabel)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// the page was full so there may be more pages.
|
||||||
|
// indicate that search will be continued
|
||||||
|
downloadTable.add("...".toLabel()).row()
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadTable.pack()
|
downloadTable.pack()
|
||||||
|
// Shouldn't actor.parent.actor = actor be a no-op? No, it has side effects we need.
|
||||||
|
// See [commit for #3317](https://github.com/yairm210/Unciv/commit/315a55f972b8defe22e76d4a2d811c6e6b607e57)
|
||||||
(downloadTable.parent as ScrollPane).actor = downloadTable
|
(downloadTable.parent as ScrollPane).actor = downloadTable
|
||||||
|
|
||||||
|
// continue search unless last page was reached
|
||||||
|
if (repoSearch.items.size >= amountPerPage && !stopBackgroundTasks)
|
||||||
|
tryDownloadPage(pageNum + 1)
|
||||||
}
|
}
|
||||||
|
runningSearchThread = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addModInfoToActionTable(repoUrl: String, updatedAt: String) {
|
private fun syncOnlineSelected(name: String, button: Button) {
|
||||||
|
syncSelected(name, button, installedScrollIndex, scrollInstalledMods)
|
||||||
|
}
|
||||||
|
private fun syncInstalledSelected(name: String, button: Button) {
|
||||||
|
syncSelected(name, button, onlineScrollIndex, scrollOnlineMods)
|
||||||
|
}
|
||||||
|
private fun syncSelected(name: String, button: Button, index: HashMap<String, ScrollToEntry>, scroll: ScrollPane) {
|
||||||
|
// manage selection color for user selection
|
||||||
|
lastSelectedButton?.color = Color.WHITE
|
||||||
|
button.color = Color.BLUE
|
||||||
|
lastSelectedButton = button
|
||||||
|
if (lastSelectedButton == lastSyncMarkedButton) lastSyncMarkedButton = null
|
||||||
|
// look for sync-able same mod in other list
|
||||||
|
val pos = index[name] ?: return
|
||||||
|
// scroll into view
|
||||||
|
scroll.scrollY = (pos.y + (pos.height - scroll.height) / 2).coerceIn(0f, scroll.maxY)
|
||||||
|
// and color it so it's easier to find. ROYAL and SLATE too dark.
|
||||||
|
lastSyncMarkedButton?.color = Color.WHITE
|
||||||
|
pos.button.color = Color.valueOf("7499ab") // about halfway between royal and sky
|
||||||
|
lastSyncMarkedButton = pos.button
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recreate the information part of the right-hand column
|
||||||
|
* @param repo: the repository instance as received from the GitHub api
|
||||||
|
*/
|
||||||
|
private fun addModInfoToActionTable(repo: Github.Repo) {
|
||||||
|
addModInfoToActionTable(repo.name, repo.html_url, repo.updated_at, repo.owner.login, repo.size)
|
||||||
|
}
|
||||||
|
/** Recreate the information part of the right-hand column
|
||||||
|
* @param modName: The mod name (name from the RuleSet)
|
||||||
|
* @param modOptions: The ModOptions as enriched by us with GitHub metadata when originally downloaded
|
||||||
|
*/
|
||||||
|
private fun addModInfoToActionTable(modName: String, modOptions: ModOptions) {
|
||||||
|
addModInfoToActionTable(modName, modOptions.modUrl, modOptions.lastUpdated, modOptions.author, modOptions.modSize)
|
||||||
|
}
|
||||||
|
private fun addModInfoToActionTable(modName: String, repoUrl: String, updatedAt: String, author: String, modSize: Int) {
|
||||||
|
// remember selected mod - for now needed only to display a background-fetched image while the user is watching
|
||||||
|
selectedModName = modName
|
||||||
|
selectedAuthor = author
|
||||||
|
|
||||||
|
// Display metadata
|
||||||
|
if (author.isNotEmpty())
|
||||||
|
modActionTable.add("Author: [$author]".toLabel()).row()
|
||||||
|
if (modSize > 0)
|
||||||
|
modActionTable.add("Size: [$modSize] kB".toLabel()).padBottom(15f).row()
|
||||||
|
|
||||||
|
// offer link to open the repo itself in a browser
|
||||||
if (repoUrl != "") {
|
if (repoUrl != "") {
|
||||||
modActionTable.add("Open Github page".toTextButton().onClick {
|
modActionTable.add("Open Github page".toTextButton().onClick {
|
||||||
Gdx.net.openURI(repoUrl)
|
Gdx.net.openURI(repoUrl)
|
||||||
}).row()
|
}).row()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatedAt != "") {
|
// display "updated" date
|
||||||
// Everything under java.time is from Java 8 onwards, meaning older phones that use Java 7 won't be able to handle it :/
|
if (updatedAt.isNotEmpty()) {
|
||||||
// So we're forced to use ancient Java 6 classes instead of the newer and nicer LocalDateTime.parse :(
|
val date = updatedAt.parseDate()
|
||||||
// Direct solution from https://stackoverflow.com/questions/2201925/converting-iso-8601-compliant-string-to-java-util-date
|
val updateString = "{Updated}: " + date.formatDate()
|
||||||
val df2 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) // example: 2021-04-11T14:43:33Z
|
modActionTable.add(updateString.toLabel()).row()
|
||||||
val date = df2.parse(updatedAt)
|
|
||||||
|
|
||||||
val updateString = "{Updated}: " +DateFormat.getDateInstance(DateFormat.SHORT).format(date)
|
|
||||||
modActionTable.add(updateString.toLabel())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDownloadButton(): TextButton {
|
/** Create the special "Download from URL" button */
|
||||||
|
private fun getDownloadFromUrlButton(): TextButton {
|
||||||
val downloadButton = "Download mod from URL".toTextButton()
|
val downloadButton = "Download mod from URL".toTextButton()
|
||||||
downloadButton.onClick {
|
downloadButton.onClick {
|
||||||
val popup = Popup(this)
|
val popup = Popup(this)
|
||||||
@ -158,22 +323,42 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
|
|||||||
return downloadButton
|
return downloadButton
|
||||||
}
|
}
|
||||||
|
|
||||||
fun downloadMod(repo: Github.Repo, postAction: () -> Unit = {}) {
|
/** Used as onClick handler for the online Mod list buttons */
|
||||||
thread { // to avoid ANRs - we've learnt our lesson from previous download-related actions
|
private fun onlineButtonAction(repo: Github.Repo, button: Button) {
|
||||||
|
syncOnlineSelected(repo.name, button)
|
||||||
|
showModDescription(repo.name)
|
||||||
|
removeRightSideClickListeners()
|
||||||
|
rightSideButton.enable()
|
||||||
|
val label = if (modStateImages[repo.name]?.isUpdated == true)
|
||||||
|
"Update [${repo.name}]"
|
||||||
|
else "Download [${repo.name}]"
|
||||||
|
rightSideButton.setText(label.tr())
|
||||||
|
rightSideButton.onClick {
|
||||||
|
rightSideButton.setText("Downloading...".tr())
|
||||||
|
rightSideButton.disable()
|
||||||
|
downloadMod(repo) {
|
||||||
|
rightSideButton.setText("Downloaded!".tr())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modActionTable.clear()
|
||||||
|
addModInfoToActionTable(repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download and install a mod in the background, called from the right-bottom button */
|
||||||
|
private fun downloadMod(repo: Github.Repo, postAction: () -> Unit = {}) {
|
||||||
|
thread(name="DownloadMod") { // to avoid ANRs - we've learnt our lesson from previous download-related actions
|
||||||
try {
|
try {
|
||||||
val modFolder = Github.downloadAndExtract(repo.html_url, repo.default_branch,
|
val modFolder = Github.downloadAndExtract(repo.html_url, repo.default_branch,
|
||||||
Gdx.files.local("mods"))
|
Gdx.files.local("mods"))
|
||||||
if (modFolder == null) return@thread
|
?: return@thread
|
||||||
// rewrite modOptions file
|
rewriteModOptions(repo, modFolder)
|
||||||
val modOptionsFile = modFolder.child("jsons/ModOptions.json")
|
|
||||||
val modOptions = if (modOptionsFile.exists()) JsonParser().getFromJson(ModOptions::class.java, modOptionsFile) else ModOptions()
|
|
||||||
modOptions.modUrl = repo.html_url
|
|
||||||
modOptions.lastUpdated = repo.updated_at
|
|
||||||
Json().toJson(modOptions, modOptionsFile)
|
|
||||||
Gdx.app.postRunnable {
|
Gdx.app.postRunnable {
|
||||||
ToastPopup("Downloaded!", this)
|
ToastPopup("Downloaded!", this)
|
||||||
RulesetCache.loadRulesets()
|
RulesetCache.loadRulesets()
|
||||||
refreshModTable()
|
refreshInstalledModTable()
|
||||||
|
showModDescription(repo.name)
|
||||||
|
unMarkUpdatedMod(repo.name)
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Gdx.app.postRunnable {
|
Gdx.app.postRunnable {
|
||||||
@ -185,67 +370,125 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshModActions(mod: Ruleset, decorationImage: Actor) {
|
/** Rewrite modOptions file for a mod we just installed to include metadata we got from the GitHub api
|
||||||
|
*
|
||||||
|
* (called on background thread)
|
||||||
|
*/
|
||||||
|
private fun rewriteModOptions(repo: Github.Repo, modFolder: FileHandle) {
|
||||||
|
val modOptionsFile = modFolder.child("jsons/ModOptions.json")
|
||||||
|
val modOptions = if (modOptionsFile.exists()) JsonParser().getFromJson(ModOptions::class.java, modOptionsFile) else ModOptions()
|
||||||
|
modOptions.modUrl = repo.html_url
|
||||||
|
modOptions.lastUpdated = repo.updated_at
|
||||||
|
modOptions.author = repo.owner.login
|
||||||
|
modOptions.modSize = repo.size
|
||||||
|
Json().toJson(modOptions, modOptionsFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the visual indicators for an 'updated' mod after re-downloading it.
|
||||||
|
* (" - Updated" on the button text in the online mod list and the icon beside the installed mod's button)
|
||||||
|
* It should be up to date now (unless the repo's date is in the future relative to system time)
|
||||||
|
*
|
||||||
|
* (called under postRunnable posted by background thread)
|
||||||
|
*/
|
||||||
|
private fun unMarkUpdatedMod(name: String) {
|
||||||
|
modStateImages[name]?.isUpdated = false
|
||||||
|
val button = (onlineScrollIndex[name]?.button as? TextButton) ?: return
|
||||||
|
button.setText(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild the right-hand column for clicks on installed mods
|
||||||
|
* Display single mod metadata, offer additional actions (delete is elsewhere)
|
||||||
|
*/
|
||||||
|
private fun refreshModActions(mod: Ruleset) {
|
||||||
modActionTable.clear()
|
modActionTable.clear()
|
||||||
|
// show mod information first
|
||||||
|
addModInfoToActionTable(mod.name, mod.modOptions)
|
||||||
|
|
||||||
|
// offer 'permanent visual mod' toggle
|
||||||
val visualMods = game.settings.visualMods
|
val visualMods = game.settings.visualMods
|
||||||
if (!visualMods.contains(mod.name)) {
|
val isVisual = visualMods.contains(mod.name)
|
||||||
decorationImage.isVisible = false
|
modStateImages[mod.name]?.isVisual = isVisual
|
||||||
|
if (!isVisual) {
|
||||||
modActionTable.add("Enable as permanent visual mod".toTextButton().onClick {
|
modActionTable.add("Enable as permanent visual mod".toTextButton().onClick {
|
||||||
visualMods.add(mod.name)
|
visualMods.add(mod.name)
|
||||||
game.settings.save()
|
game.settings.save()
|
||||||
ImageGetter.setNewRuleset(ImageGetter.ruleset)
|
ImageGetter.setNewRuleset(ImageGetter.ruleset)
|
||||||
refreshModActions(mod, decorationImage)
|
refreshModActions(mod)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
decorationImage.isVisible = true
|
|
||||||
modActionTable.add("Disable as permanent visual mod".toTextButton().onClick {
|
modActionTable.add("Disable as permanent visual mod".toTextButton().onClick {
|
||||||
visualMods.remove(mod.name)
|
visualMods.remove(mod.name)
|
||||||
game.settings.save()
|
game.settings.save()
|
||||||
ImageGetter.setNewRuleset(ImageGetter.ruleset)
|
ImageGetter.setNewRuleset(ImageGetter.ruleset)
|
||||||
refreshModActions(mod, decorationImage)
|
refreshModActions(mod)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
modActionTable.row()
|
modActionTable.row()
|
||||||
|
|
||||||
addModInfoToActionTable(mod.modOptions.modUrl, mod.modOptions.lastUpdated)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshModTable() {
|
/** Rebuild the left-hand column containing all installed mods */
|
||||||
|
private fun refreshInstalledModTable() {
|
||||||
modTable.clear()
|
modTable.clear()
|
||||||
val currentMods = RulesetCache.values.filter { it.name != "" }
|
installedScrollIndex.clear()
|
||||||
|
|
||||||
|
var currentY = -1f
|
||||||
|
val currentMods = RulesetCache.values.asSequence().filter { it.name != "" }.sortedBy { it.name }
|
||||||
for (mod in currentMods) {
|
for (mod in currentMods) {
|
||||||
val summary = mod.getSummary()
|
val summary = mod.getSummary()
|
||||||
modDescriptions[mod.name] = "Installed".tr() +
|
modDescriptionsInstalled[mod.name] = "Installed".tr() +
|
||||||
(if (summary.isEmpty()) "" else ": $summary")
|
(if (summary.isEmpty()) "" else ": $summary")
|
||||||
val decorationImage = ImageGetter.getPromotionIcon("Scouting", 25f)
|
|
||||||
|
var imageMgr = modStateImages[mod.name]
|
||||||
|
val decorationTable =
|
||||||
|
if (imageMgr != null) imageMgr.container
|
||||||
|
else {
|
||||||
|
val table = Table().apply { defaults().size(20f).align(Align.topLeft) }
|
||||||
|
imageMgr = ModStateImages(table, isVisual = mod.name in game.settings.visualMods)
|
||||||
|
modStateImages[mod.name] = imageMgr
|
||||||
|
table
|
||||||
|
}
|
||||||
|
imageMgr.update() // rebuilds decorationTable content
|
||||||
|
|
||||||
val button = mod.name.toTextButton()
|
val button = mod.name.toTextButton()
|
||||||
button.onClick {
|
button.onClick {
|
||||||
lastSelectedButton?.color = Color.WHITE
|
syncInstalledSelected(mod.name, button)
|
||||||
button.color = Color.BLUE
|
refreshModActions(mod)
|
||||||
lastSelectedButton = button
|
|
||||||
refreshModActions(mod, decorationImage)
|
|
||||||
rightSideButton.setText("Delete [${mod.name}]".tr())
|
rightSideButton.setText("Delete [${mod.name}]".tr())
|
||||||
rightSideButton.enable()
|
rightSideButton.isEnabled = true
|
||||||
descriptionLabel.setText(modDescriptions[mod.name])
|
showModDescription(mod.name)
|
||||||
removeRightSideClickListeners()
|
removeRightSideClickListeners()
|
||||||
rightSideButton.onClick {
|
rightSideButton.onClick {
|
||||||
YesNoPopup("Are you SURE you want to delete this mod?",
|
rightSideButton.isEnabled = false
|
||||||
{ deleteMod(mod) }, this).open()
|
YesNoPopup(
|
||||||
|
question = "Are you SURE you want to delete this mod?",
|
||||||
|
action = {
|
||||||
|
deleteMod(mod)
|
||||||
|
rightSideButton.setText("[${mod.name}] was deleted.".tr())
|
||||||
|
},
|
||||||
|
screen = this,
|
||||||
|
restoreDefault = { rightSideButton.isEnabled = true }
|
||||||
|
).open()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val decoratedButton = Table()
|
val decoratedButton = Table()
|
||||||
decoratedButton.add(button)
|
decoratedButton.add(button)
|
||||||
decorationImage.isVisible = game.settings.visualMods.contains(mod.name)
|
decoratedButton.add(decorationTable).align(Align.center+Align.left)
|
||||||
decoratedButton.add(decorationImage).align(Align.topLeft)
|
val cell = modTable.add(decoratedButton)
|
||||||
modTable.add(decoratedButton).row()
|
modTable.row()
|
||||||
|
if (currentY < 0f) currentY = cell.padTop
|
||||||
|
installedScrollIndex[mod.name] = ScrollToEntry(currentY, cell.prefHeight, button)
|
||||||
|
currentY += cell.padBottom + cell.prefHeight + cell.padTop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteMod(mod: Ruleset) {
|
/** Delete a Mod, refresh ruleset cache and update installed mod table */
|
||||||
|
private fun deleteMod(mod: Ruleset) {
|
||||||
val modFileHandle = Gdx.files.local("mods").child(mod.name)
|
val modFileHandle = Gdx.files.local("mods").child(mod.name)
|
||||||
if (modFileHandle.isDirectory) modFileHandle.deleteDirectory()
|
if (modFileHandle.isDirectory) modFileHandle.deleteDirectory()
|
||||||
else modFileHandle.delete()
|
else modFileHandle.delete() // This should never happen
|
||||||
RulesetCache.loadRulesets()
|
RulesetCache.loadRulesets()
|
||||||
refreshModTable()
|
modStateImages.remove(mod.name)
|
||||||
|
refreshInstalledModTable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@ import com.unciv.logic.UncivShowableException
|
|||||||
import com.unciv.models.translations.tr
|
import com.unciv.models.translations.tr
|
||||||
import com.unciv.ui.pickerscreens.PickerScreen
|
import com.unciv.ui.pickerscreens.PickerScreen
|
||||||
import com.unciv.ui.utils.*
|
import com.unciv.ui.utils.*
|
||||||
import java.text.SimpleDateFormat
|
import com.unciv.ui.utils.UncivDateFormat.formatDate
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.CancellationException
|
import java.util.concurrent.CancellationException
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
@ -180,8 +180,7 @@ class LoadGameScreen(previousScreen:CameraStageBaseScreen) : PickerScreen(disabl
|
|||||||
|
|
||||||
|
|
||||||
val savedAt = Date(save.lastModified())
|
val savedAt = Date(save.lastModified())
|
||||||
var textToSet = save.name() +
|
var textToSet = save.name() + "\n${"Saved at".tr()}: " + savedAt.formatDate()
|
||||||
"\n${"Saved at".tr()}: " + SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(savedAt)
|
|
||||||
thread { // Even loading the game to get its metadata can take a long time on older phones
|
thread { // Even loading the game to get its metadata can take a long time on older phones
|
||||||
try {
|
try {
|
||||||
val game = GameSaver.loadGamePreviewFromFile(save)
|
val game = GameSaver.loadGamePreviewFromFile(save)
|
||||||
|
@ -10,6 +10,8 @@ import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener
|
|||||||
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener
|
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener
|
||||||
import com.unciv.models.UncivSound
|
import com.unciv.models.UncivSound
|
||||||
import com.unciv.models.translations.tr
|
import com.unciv.models.translations.tr
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@ -102,8 +104,7 @@ fun Table.addSeparator(): Cell<Image> {
|
|||||||
|
|
||||||
fun Table.addSeparatorVertical(): Cell<Image> {
|
fun Table.addSeparatorVertical(): Cell<Image> {
|
||||||
val image = ImageGetter.getWhiteDot()
|
val image = ImageGetter.getWhiteDot()
|
||||||
val cell = add(image).width(2f).fillY()
|
return add(image).width(2f).fillY()
|
||||||
return cell
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T : Actor> Table.addCell(actor: T): Table {
|
fun <T : Actor> Table.addCell(actor: T): Table {
|
||||||
@ -200,3 +201,28 @@ fun <T> List<T>.randomWeighted(weights: List<Float>, random: Random = Random): T
|
|||||||
}
|
}
|
||||||
return this.last()
|
return this.last()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardize date formatting so dates are presented in a consistent style and all decisions
|
||||||
|
* to change date handling are encapsulated here
|
||||||
|
*/
|
||||||
|
object UncivDateFormat {
|
||||||
|
private val standardFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
|
||||||
|
|
||||||
|
/** Format a date to ISO format with minutes */
|
||||||
|
fun Date.formatDate(): String = standardFormat.format(this)
|
||||||
|
// Previously also used:
|
||||||
|
//val updateString = "{Updated}: " +DateFormat.getDateInstance(DateFormat.SHORT).format(date)
|
||||||
|
|
||||||
|
// Everything under java.time is from Java 8 onwards, meaning older phones that use Java 7 won't be able to handle it :/
|
||||||
|
// So we're forced to use ancient Java 6 classes instead of the newer and nicer LocalDateTime.parse :(
|
||||||
|
// Direct solution from https://stackoverflow.com/questions/2201925/converting-iso-8601-compliant-string-to-java-util-date
|
||||||
|
|
||||||
|
@Suppress("SpellCheckingInspection")
|
||||||
|
private val utcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||||
|
|
||||||
|
/** Parse an UTC date as passed by online API's
|
||||||
|
* example: `"2021-04-11T14:43:33Z".parseDate()`
|
||||||
|
*/
|
||||||
|
fun String.parseDate(): Date = utcFormat.parse(this)
|
||||||
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
package com.unciv.ui.worldscreen.mainmenu
|
package com.unciv.ui.worldscreen.mainmenu
|
||||||
|
|
||||||
import com.badlogic.gdx.files.FileHandle
|
|
||||||
import com.unciv.logic.GameInfo
|
import com.unciv.logic.GameInfo
|
||||||
import com.unciv.logic.GameSaver
|
import com.unciv.logic.GameSaver
|
||||||
import com.unciv.ui.saves.Gzip
|
import com.unciv.ui.saves.Gzip
|
||||||
@ -8,8 +7,6 @@ import java.io.*
|
|||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.util.zip.ZipEntry
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
|
|
||||||
object DropBox {
|
object DropBox {
|
||||||
@ -19,6 +16,7 @@ object DropBox {
|
|||||||
with(URL(url).openConnection() as HttpURLConnection) {
|
with(URL(url).openConnection() as HttpURLConnection) {
|
||||||
requestMethod = "POST" // default is GET
|
requestMethod = "POST" // default is GET
|
||||||
|
|
||||||
|
@Suppress("SpellCheckingInspection")
|
||||||
setRequestProperty("Authorization", "Bearer LTdBbopPUQ0AAAAAAAACxh4_Qd1eVMM7IBK3ULV3BgxzWZDMfhmgFbuUNF_rXQWb")
|
setRequestProperty("Authorization", "Bearer LTdBbopPUQ0AAAAAAAACxh4_Qd1eVMM7IBK3ULV3BgxzWZDMfhmgFbuUNF_rXQWb")
|
||||||
|
|
||||||
if (dropboxApiArg != "") setRequestProperty("Dropbox-API-Arg", dropboxApiArg)
|
if (dropboxApiArg != "") setRequestProperty("Dropbox-API-Arg", dropboxApiArg)
|
||||||
@ -76,8 +74,7 @@ object DropBox {
|
|||||||
|
|
||||||
fun downloadFileAsString(fileName: String): String {
|
fun downloadFileAsString(fileName: String): String {
|
||||||
val inputStream = downloadFile(fileName)
|
val inputStream = downloadFile(fileName)
|
||||||
val text = BufferedReader(InputStreamReader(inputStream)).readText()
|
return BufferedReader(InputStreamReader(inputStream)).readText()
|
||||||
return text
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun uploadFile(fileName: String, data: String, overwrite: Boolean = false){
|
fun uploadFile(fileName: String, data: String, overwrite: Boolean = false){
|
||||||
@ -98,13 +95,14 @@ object DropBox {
|
|||||||
// return BufferedReader(InputStreamReader(result)).readText()
|
// return BufferedReader(InputStreamReader(result)).readText()
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
@Suppress("PropertyName")
|
||||||
class FolderList{
|
class FolderList{
|
||||||
var entries = ArrayList<FolderListEntry>()
|
var entries = ArrayList<FolderListEntry>()
|
||||||
var cursor = ""
|
var cursor = ""
|
||||||
var has_more = false
|
var has_more = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("PropertyName")
|
||||||
class FolderListEntry{
|
class FolderListEntry{
|
||||||
var name=""
|
var name=""
|
||||||
var path_display=""
|
var path_display=""
|
||||||
@ -128,127 +126,10 @@ class OnlineMultiplayer {
|
|||||||
/**
|
/**
|
||||||
* WARNING!
|
* WARNING!
|
||||||
* Does not initialize transitive GameInfo data.
|
* Does not initialize transitive GameInfo data.
|
||||||
* It is therefore stateless and save to call for Multiplayer Turn Notifier, unlike tryDownloadGame().
|
* It is therefore stateless and safe to call for Multiplayer Turn Notifier, unlike tryDownloadGame().
|
||||||
*/
|
*/
|
||||||
fun tryDownloadGameUninitialized(gameId: String): GameInfo {
|
fun tryDownloadGameUninitialized(gameId: String): GameInfo {
|
||||||
val zippedGameInfo = DropBox.downloadFileAsString(getGameLocation(gameId))
|
val zippedGameInfo = DropBox.downloadFileAsString(getGameLocation(gameId))
|
||||||
return GameSaver.gameInfoFromStringWithoutTransients(Gzip.unzip(zippedGameInfo))
|
return GameSaver.gameInfoFromStringWithoutTransients(Gzip.unzip(zippedGameInfo))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object Github {
|
|
||||||
// Consider merging this with the Dropbox function
|
|
||||||
fun download(url: String, action: (HttpURLConnection) -> Unit = {}): InputStream? {
|
|
||||||
with(URL(url).openConnection() as HttpURLConnection)
|
|
||||||
{
|
|
||||||
action(this)
|
|
||||||
|
|
||||||
try {
|
|
||||||
return inputStream
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
println(ex.message)
|
|
||||||
val reader = BufferedReader(InputStreamReader(errorStream))
|
|
||||||
println(reader.readText())
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This took a long time to get just right, so if you're changing this, TEST IT THOROUGHLY on both Desktop and Phone
|
|
||||||
fun downloadAndExtract(gitRepoUrl:String, defaultBranch:String, folderFileHandle:FileHandle): FileHandle? {
|
|
||||||
val zipUrl = "$gitRepoUrl/archive/$defaultBranch.zip"
|
|
||||||
val inputStream = download(zipUrl)
|
|
||||||
if (inputStream == null) return null
|
|
||||||
|
|
||||||
val tempZipFileHandle = folderFileHandle.child("tempZip.zip")
|
|
||||||
tempZipFileHandle.write(inputStream, false)
|
|
||||||
val unzipDestination = tempZipFileHandle.sibling("tempZip") // folder, not file
|
|
||||||
Zip.extractFolder(tempZipFileHandle, unzipDestination)
|
|
||||||
val innerFolder = unzipDestination.list().first() // tempZip/<repoName>-master/
|
|
||||||
|
|
||||||
val finalDestinationName = innerFolder.name().replace("-$defaultBranch", "").replace('-', ' ')
|
|
||||||
val finalDestination = folderFileHandle.child(finalDestinationName)
|
|
||||||
finalDestination.mkdirs() // If we don't create this as a directory, it will think this is a file and nothing will work.
|
|
||||||
for (innerFileOrFolder in innerFolder.list()) {
|
|
||||||
innerFileOrFolder.moveTo(finalDestination)
|
|
||||||
}
|
|
||||||
|
|
||||||
tempZipFileHandle.delete()
|
|
||||||
unzipDestination.deleteDirectory()
|
|
||||||
|
|
||||||
return finalDestination
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun tryGetGithubReposWithTopic(amountPerPage:Int, page:Int): RepoSearch? {
|
|
||||||
// Default per-page is 30 - when we get to above 100 mods, we'll need to start search-queries
|
|
||||||
val inputStream = download("https://api.github.com/search/repositories?q=topic:unciv-mod&per_page=$amountPerPage&page=$page")
|
|
||||||
if (inputStream == null) return null
|
|
||||||
return GameSaver.json().fromJson(RepoSearch::class.java, inputStream.bufferedReader().readText())
|
|
||||||
}
|
|
||||||
|
|
||||||
class RepoSearch {
|
|
||||||
var items = ArrayList<Repo>()
|
|
||||||
}
|
|
||||||
|
|
||||||
class Repo {
|
|
||||||
var name = ""
|
|
||||||
var description = ""
|
|
||||||
var stargazers_count = 0
|
|
||||||
var default_branch = ""
|
|
||||||
var html_url = ""
|
|
||||||
var updated_at = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object Zip {
|
|
||||||
|
|
||||||
// I went through a lot of similar answers that didn't work until I got to this gem by NeilMonday
|
|
||||||
// (with mild changes to fit the FileHandles)
|
|
||||||
// https://stackoverflow.com/questions/981578/how-to-unzip-files-recursively-in-java
|
|
||||||
fun extractFolder(zipFile: FileHandle, unzipDestination: FileHandle) {
|
|
||||||
println(zipFile)
|
|
||||||
val BUFFER = 2048
|
|
||||||
val file = zipFile.file()
|
|
||||||
val zip = ZipFile(file)
|
|
||||||
unzipDestination.mkdirs()
|
|
||||||
val zipFileEntries = zip.entries()
|
|
||||||
|
|
||||||
// Process each entry
|
|
||||||
while (zipFileEntries.hasMoreElements()) {
|
|
||||||
// grab a zip file entry
|
|
||||||
val entry = zipFileEntries.nextElement() as ZipEntry
|
|
||||||
val currentEntry = entry.name
|
|
||||||
val destFile = unzipDestination.child(currentEntry)
|
|
||||||
val destinationParent = destFile.parent()
|
|
||||||
|
|
||||||
// create the parent directory structure if needed
|
|
||||||
destinationParent.mkdirs()
|
|
||||||
if (!entry.isDirectory) {
|
|
||||||
val inputStream = BufferedInputStream(zip
|
|
||||||
.getInputStream(entry))
|
|
||||||
var currentByte: Int
|
|
||||||
// establish buffer for writing file
|
|
||||||
val data = ByteArray(BUFFER)
|
|
||||||
|
|
||||||
// write the current file to disk
|
|
||||||
val fos = FileOutputStream(destFile.file())
|
|
||||||
val dest = BufferedOutputStream(fos,
|
|
||||||
BUFFER)
|
|
||||||
|
|
||||||
// read and write until last byte is encountered
|
|
||||||
while (inputStream.read(data, 0, BUFFER).also { currentByte = it } != -1) {
|
|
||||||
dest.write(data, 0, currentByte)
|
|
||||||
}
|
|
||||||
dest.flush()
|
|
||||||
dest.close()
|
|
||||||
inputStream.close()
|
|
||||||
}
|
|
||||||
if (currentEntry.endsWith(".zip")) {
|
|
||||||
// found a zip file, try to open
|
|
||||||
extractFolder(destFile, unzipDestination)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
zip.close() // Needed so we can delete the zip file later
|
|
||||||
}
|
|
||||||
}
|
|
336
core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt
Normal file
336
core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
package com.unciv.ui.worldscreen.mainmenu
|
||||||
|
|
||||||
|
import com.badlogic.gdx.files.FileHandle
|
||||||
|
import com.unciv.logic.GameSaver
|
||||||
|
import java.io.*
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility managing Github access (except the link in WorldScreenCommunityPopup)
|
||||||
|
*
|
||||||
|
* Singleton - RateLimit is shared app-wide and has local variables, and is not tested for thread safety.
|
||||||
|
* Therefore, additional effort is required should [tryGetGithubReposWithTopic] ever be called non-sequentially.
|
||||||
|
* [download] and [downloadAndExtract] should be thread-safe as they are self-contained.
|
||||||
|
* They do not join in the [RateLimit] handling because Github doc suggests each API
|
||||||
|
* has a separate limit (and I found none for cloning via a zip).
|
||||||
|
*/
|
||||||
|
object Github {
|
||||||
|
|
||||||
|
// Consider merging this with the Dropbox function
|
||||||
|
/**
|
||||||
|
* Helper opens am url and accesses its input stream, logging errors to the console
|
||||||
|
* @param url String representing a [URL] to download.
|
||||||
|
* @param action Optional callback that will be executed between opening the connection and
|
||||||
|
* accessing its data - passes the [connection][HttpURLConnection] and allows e.g. reading the response headers.
|
||||||
|
* @return The [InputStream] if successful, `null` otherwise.
|
||||||
|
*/
|
||||||
|
fun download(url: String, action: (HttpURLConnection) -> Unit = {}): InputStream? {
|
||||||
|
with(URL(url).openConnection() as HttpURLConnection)
|
||||||
|
{
|
||||||
|
action(this)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
inputStream
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
println(ex.message)
|
||||||
|
val reader = BufferedReader(InputStreamReader(errorStream))
|
||||||
|
println(reader.readText())
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a mod and extract, deleting any pre-existing version.
|
||||||
|
* @param gitRepoUrl Url of the repository as delivered by the Github search query
|
||||||
|
* @param defaultBranch Branch name as delivered by the Github search query
|
||||||
|
* @param folderFileHandle Destination handle of mods folder - also controls Android internal/external
|
||||||
|
* @author **Warning**: This took a long time to get just right, so if you're changing this, ***TEST IT THOROUGHLY*** on _both_ Desktop _and_ Phone
|
||||||
|
* @return FileHandle for the downloaded Mod's folder or null if download failed
|
||||||
|
*/
|
||||||
|
fun downloadAndExtract(
|
||||||
|
gitRepoUrl: String,
|
||||||
|
defaultBranch: String,
|
||||||
|
folderFileHandle: FileHandle
|
||||||
|
): FileHandle? {
|
||||||
|
// Initiate download - the helper returns null when it fails
|
||||||
|
val zipUrl = "$gitRepoUrl/archive/$defaultBranch.zip"
|
||||||
|
val inputStream = download(zipUrl) ?: return null
|
||||||
|
|
||||||
|
// Download to temporary zip
|
||||||
|
val tempZipFileHandle = folderFileHandle.child("tempZip.zip")
|
||||||
|
tempZipFileHandle.write(inputStream, false)
|
||||||
|
|
||||||
|
// prepare temp unpacking folder
|
||||||
|
val unzipDestination = tempZipFileHandle.sibling("tempZip") // folder, not file
|
||||||
|
// prevent mixing new content with old - hopefully there will never be cadavers of our tempZip stuff
|
||||||
|
if (unzipDestination.exists())
|
||||||
|
if (unzipDestination.isDirectory) unzipDestination.deleteDirectory() else unzipDestination.delete()
|
||||||
|
|
||||||
|
Zip.extractFolder(tempZipFileHandle, unzipDestination)
|
||||||
|
|
||||||
|
val innerFolder = unzipDestination.list().first()
|
||||||
|
// innerFolder should now be "tempZip/$repoName-$defaultBranch/" - use this to get mod name
|
||||||
|
val finalDestinationName = innerFolder.name().replace("-$defaultBranch", "").replace('-', ' ')
|
||||||
|
// finalDestinationName is now the mod name as we display it. Folder name needs to be identical.
|
||||||
|
val finalDestination = folderFileHandle.child(finalDestinationName)
|
||||||
|
|
||||||
|
// prevent mixing new content with old
|
||||||
|
var tempBackup: FileHandle? = null
|
||||||
|
if (finalDestination.exists()) {
|
||||||
|
tempBackup = finalDestination.sibling("$finalDestinationName.updating")
|
||||||
|
finalDestination.moveTo(tempBackup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move temp unpacked content to their final place
|
||||||
|
finalDestination.mkdirs() // If we don't create this as a directory, it will think this is a file and nothing will work.
|
||||||
|
// The move will reset the last modified time (recursively, at least on Linux)
|
||||||
|
// This sort will guarantee the desktop launcher will not re-pack textures and overwrite the atlas as delivered by the mod
|
||||||
|
for (innerFileOrFolder in innerFolder.list()
|
||||||
|
.sortedBy { file -> file.extension() == "atlas" } ) {
|
||||||
|
innerFileOrFolder.moveTo(finalDestination)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean up
|
||||||
|
tempZipFileHandle.delete()
|
||||||
|
unzipDestination.deleteDirectory()
|
||||||
|
if (tempBackup != null)
|
||||||
|
if (tempBackup.isDirectory) tempBackup.deleteDirectory() else tempBackup.delete()
|
||||||
|
|
||||||
|
return finalDestination
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the ability wo work with GitHub's rate limit, recognize blocks from previous attempts, wait and retry.
|
||||||
|
*/
|
||||||
|
object RateLimit {
|
||||||
|
// https://docs.github.com/en/rest/reference/search#rate-limit
|
||||||
|
const val maxRequestsPerInterval = 10
|
||||||
|
const val intervalInMilliSeconds = 60000L
|
||||||
|
private const val maxWaitLoop = 3
|
||||||
|
|
||||||
|
private var account = 0 // used requests
|
||||||
|
private var firstRequest = 0L // timestamp window start (java epoch millisecond)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Github rate limits do not use sliding windows - you (if anonymous) get one window
|
||||||
|
which starts with the first request (if a window is not already active)
|
||||||
|
and ends 60s later, and a budget of 10 requests in that window. Once it expires,
|
||||||
|
everything is forgotten and the process starts from scratch
|
||||||
|
*/
|
||||||
|
|
||||||
|
private val millis: Long
|
||||||
|
get() = System.currentTimeMillis()
|
||||||
|
|
||||||
|
/** calculate required wait in ms
|
||||||
|
* @return Estimated number of milliseconds to wait for the rate limit window to expire
|
||||||
|
*/
|
||||||
|
private fun getWaitLength()
|
||||||
|
= (firstRequest + intervalInMilliSeconds - millis)
|
||||||
|
|
||||||
|
/** Maintain and check a rate-limit
|
||||||
|
* @return **true** if rate-limited, **false** if another request is allowed
|
||||||
|
*/
|
||||||
|
private fun isLimitReached(): Boolean {
|
||||||
|
val now = millis
|
||||||
|
val elapsed = if (firstRequest == 0L) intervalInMilliSeconds else now - firstRequest
|
||||||
|
if (elapsed >= intervalInMilliSeconds) {
|
||||||
|
firstRequest = now
|
||||||
|
account = 1
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (account >= maxRequestsPerInterval) return true
|
||||||
|
account++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If rate limit in effect, sleep long enough to allow next request.
|
||||||
|
*
|
||||||
|
* @return **true** if waiting did not clear isLimitReached() (can only happen if the clock is broken),
|
||||||
|
* or the wait has been interrupted by Thread.interrupt()
|
||||||
|
* **false** if we were below the limit or slept long enough to drop out of it.
|
||||||
|
*/
|
||||||
|
fun waitForLimit(): Boolean {
|
||||||
|
var loopCount = 0
|
||||||
|
while (isLimitReached()) {
|
||||||
|
val waitLength = getWaitLength()
|
||||||
|
try {
|
||||||
|
Thread.sleep(waitLength)
|
||||||
|
} catch ( ex: InterruptedException ) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (++loopCount >= maxWaitLoop) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** http responses should be passed to this so the actual rate limit window can be evaluated and used.
|
||||||
|
* The very first response and all 403 ones are good candidates if they can be expected to contain GitHub's rate limit headers.
|
||||||
|
*
|
||||||
|
* see: https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
|
||||||
|
*/
|
||||||
|
fun notifyHttpResponse(response: HttpURLConnection) {
|
||||||
|
if (response.responseMessage != "rate limit exceeded" && response.responseCode != 200) return
|
||||||
|
|
||||||
|
fun getHeaderLong(name: String, default: Long = 0L) =
|
||||||
|
response.headerFields[name]?.get(0)?.toLongOrNull() ?: default
|
||||||
|
val limit = getHeaderLong("X-RateLimit-Limit", maxRequestsPerInterval.toLong()).toInt()
|
||||||
|
val remaining = getHeaderLong("X-RateLimit-Remaining").toInt()
|
||||||
|
val reset = getHeaderLong("X-RateLimit-Reset")
|
||||||
|
|
||||||
|
if (limit != maxRequestsPerInterval)
|
||||||
|
println("GitHub API Limit reported via http ($limit) not equal assumed value ($maxRequestsPerInterval)")
|
||||||
|
account = maxRequestsPerInterval - remaining
|
||||||
|
if (reset == 0L) return
|
||||||
|
firstRequest = (reset + 1L) * 1000L - intervalInMilliSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query GitHub for repositories marked "unciv-mod"
|
||||||
|
* @param amountPerPage Number of search results to return for this request.
|
||||||
|
* @param page The "page" number, starting at 1.
|
||||||
|
* @return Parsed [RepoSearch] json on success, `null` on failure.
|
||||||
|
* @see <a href="https://docs.github.com/en/rest/reference/search#search-repositories">Github API doc</a>
|
||||||
|
*/
|
||||||
|
fun tryGetGithubReposWithTopic(amountPerPage:Int, page:Int): RepoSearch? {
|
||||||
|
val link = "https://api.github.com/search/repositories?q=topic:unciv-mod&sort:stars&per_page=$amountPerPage&page=$page"
|
||||||
|
var retries = 2
|
||||||
|
while (retries > 0) {
|
||||||
|
retries--
|
||||||
|
// obey rate limit
|
||||||
|
if (RateLimit.waitForLimit()) return null
|
||||||
|
// try download
|
||||||
|
val inputStream = download(link) {
|
||||||
|
if (it.responseCode == 403 || it.responseCode == 200 && page == 1 && retries == 1) {
|
||||||
|
// Pass the response headers to the rate limit handler so it can process the rate limit headers
|
||||||
|
RateLimit.notifyHttpResponse(it)
|
||||||
|
retries++ // An extra retry so the 403 is ignored in the retry count
|
||||||
|
}
|
||||||
|
} ?: continue
|
||||||
|
return GameSaver.json().fromJson(RepoSearch::class.java, inputStream.bufferedReader().readText())
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed GitHub repo search response
|
||||||
|
* @property total_count Total number of hits for the search (ignoring paging window)
|
||||||
|
* @property incomplete_results A flag set by github to indicate search was incomplete (never seen it on)
|
||||||
|
* @property items Array of [repositories][Repo]
|
||||||
|
* @see <a href="https://docs.github.com/en/rest/reference/search#search-repositories--code-samples">Github API doc</a>
|
||||||
|
*/
|
||||||
|
@Suppress("PropertyName")
|
||||||
|
class RepoSearch {
|
||||||
|
var total_count = 0
|
||||||
|
var incomplete_results = false
|
||||||
|
var items = ArrayList<Repo>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Part of [RepoSearch] in Github API response - one repository entry in [items][RepoSearch.items] */
|
||||||
|
@Suppress("PropertyName")
|
||||||
|
class Repo {
|
||||||
|
var name = ""
|
||||||
|
var full_name = ""
|
||||||
|
var description: String? = null
|
||||||
|
var owner = RepoOwner()
|
||||||
|
var stargazers_count = 0
|
||||||
|
var default_branch = ""
|
||||||
|
var html_url = ""
|
||||||
|
var updated_at = ""
|
||||||
|
//var pushed_at = "" // if > updated_at might indicate an update soon?
|
||||||
|
var size = 0
|
||||||
|
//var stargazers_url = ""
|
||||||
|
//var homepage: String? = null // might use instead of go to repo?
|
||||||
|
//var has_wiki = false // a wiki could mean proper documentation for the mod?
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Part of [Repo] in Github API response */
|
||||||
|
@Suppress("PropertyName")
|
||||||
|
class RepoOwner {
|
||||||
|
var login = ""
|
||||||
|
var avatar_url: String? = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Utility - extract Zip archives
|
||||||
|
* @see [Zip.extractFolder]
|
||||||
|
*/
|
||||||
|
object Zip {
|
||||||
|
private const val bufferSize = 2048
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract one Zip file recursively (nested Zip files are extracted in turn).
|
||||||
|
*
|
||||||
|
* The source Zip is not deleted, but successfully extracted nested ones are.
|
||||||
|
*
|
||||||
|
* **Warning**: Extracting into a non-empty destination folder will merge contents. Existing
|
||||||
|
* files also included in the archive will be partially overwritten, when the new data is shorter
|
||||||
|
* than the old you will get _mixed contents!_
|
||||||
|
*
|
||||||
|
* @param zipFile The Zip file to extract
|
||||||
|
* @param unzipDestination The folder to extract into, preferably empty (not enforced).
|
||||||
|
*/
|
||||||
|
fun extractFolder(zipFile: FileHandle, unzipDestination: FileHandle) {
|
||||||
|
// I went through a lot of similar answers that didn't work until I got to this gem by NeilMonday
|
||||||
|
// (with mild changes to fit the FileHandles)
|
||||||
|
// https://stackoverflow.com/questions/981578/how-to-unzip-files-recursively-in-java
|
||||||
|
|
||||||
|
println("Extracting $zipFile to $unzipDestination")
|
||||||
|
// establish buffer for writing file
|
||||||
|
val data = ByteArray(bufferSize)
|
||||||
|
|
||||||
|
fun streamCopy(fromStream: InputStream, toHandle: FileHandle) {
|
||||||
|
val inputStream = BufferedInputStream(fromStream)
|
||||||
|
var currentByte: Int
|
||||||
|
|
||||||
|
// write the current file to disk
|
||||||
|
val fos = FileOutputStream(toHandle.file())
|
||||||
|
val dest = BufferedOutputStream(fos, bufferSize)
|
||||||
|
|
||||||
|
// read and write until last byte is encountered
|
||||||
|
while (inputStream.read(data, 0, bufferSize).also { currentByte = it } != -1) {
|
||||||
|
dest.write(data, 0, currentByte)
|
||||||
|
}
|
||||||
|
dest.flush()
|
||||||
|
dest.close()
|
||||||
|
inputStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = zipFile.file()
|
||||||
|
val zip = ZipFile(file)
|
||||||
|
//unzipDestination.mkdirs()
|
||||||
|
val zipFileEntries = zip.entries()
|
||||||
|
|
||||||
|
// Process each entry
|
||||||
|
while (zipFileEntries.hasMoreElements()) {
|
||||||
|
// grab a zip file entry
|
||||||
|
val entry = zipFileEntries.nextElement() as ZipEntry
|
||||||
|
val currentEntry = entry.name
|
||||||
|
val destFile = unzipDestination.child(currentEntry)
|
||||||
|
val destinationParent = destFile.parent()
|
||||||
|
|
||||||
|
// create the parent directory structure if needed
|
||||||
|
destinationParent.mkdirs()
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
streamCopy ( zip.getInputStream(entry), destFile)
|
||||||
|
}
|
||||||
|
// The new file has a current last modification time
|
||||||
|
// and not the one stored in the archive - we could:
|
||||||
|
// 'destFile.file().setLastModified(entry.time)'
|
||||||
|
// but later handling will throw these away anyway,
|
||||||
|
// and GitHub sets all timestamps to the download time.
|
||||||
|
|
||||||
|
if (currentEntry.endsWith(".zip")) {
|
||||||
|
// found a zip file, try to open
|
||||||
|
extractFolder(destFile, destinationParent)
|
||||||
|
destFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zip.close() // Needed so we can delete the zip file later
|
||||||
|
}
|
||||||
|
}
|
@ -57,6 +57,8 @@ internal object DesktopLauncher {
|
|||||||
LwjglApplication(game, config)
|
LwjglApplication(game, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Work in Progress?
|
||||||
|
@Suppress("unused")
|
||||||
private fun startMultiplayerServer() {
|
private fun startMultiplayerServer() {
|
||||||
// val games = HashMap<String, GameSetupInfo>()
|
// val games = HashMap<String, GameSetupInfo>()
|
||||||
val files = HashMap<String, String>()
|
val files = HashMap<String, String>()
|
||||||
@ -115,7 +117,7 @@ internal object DesktopLauncher {
|
|||||||
// https://github.com/yairm210/UnCiv/issues/1340
|
// https://github.com/yairm210/UnCiv/issues/1340
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These should be as big as possible in order to accommodate ALL the images together in one bug file.
|
* These should be as big as possible in order to accommodate ALL the images together in one big file.
|
||||||
* Why? Because the rendering function of the main screen renders all the images consecutively, and every time it needs to switch between textures,
|
* Why? Because the rendering function of the main screen renders all the images consecutively, and every time it needs to switch between textures,
|
||||||
* this causes a delay, leading to horrible lag if there are enough switches.
|
* this causes a delay, leading to horrible lag if there are enough switches.
|
||||||
* The cost of this specific solution is that the entire game.png needs be be kept in-memory constantly.
|
* The cost of this specific solution is that the entire game.png needs be be kept in-memory constantly.
|
||||||
@ -163,14 +165,14 @@ internal object DesktopLauncher {
|
|||||||
private fun packImagesIfOutdated(settings: TexturePacker.Settings, input: String, output: String, packFileName: String) {
|
private fun packImagesIfOutdated(settings: TexturePacker.Settings, input: String, output: String, packFileName: String) {
|
||||||
fun File.listTree(): Sequence<File> = when {
|
fun File.listTree(): Sequence<File> = when {
|
||||||
this.isFile -> sequenceOf(this)
|
this.isFile -> sequenceOf(this)
|
||||||
this.isDirectory -> this.listFiles().asSequence().flatMap { it.listTree() }
|
this.isDirectory -> this.listFiles()!!.asSequence().flatMap { it.listTree() }
|
||||||
else -> sequenceOf()
|
else -> sequenceOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
val atlasFile = File("$output${File.separator}$packFileName.atlas")
|
val atlasFile = File("$output${File.separator}$packFileName.atlas")
|
||||||
if (atlasFile.exists() && File("$output${File.separator}$packFileName.png").exists()) {
|
if (atlasFile.exists() && File("$output${File.separator}$packFileName.png").exists()) {
|
||||||
val atlasModTime = atlasFile.lastModified()
|
val atlasModTime = atlasFile.lastModified()
|
||||||
if (!File(input).listTree().any { it.extension in listOf("png", "jpg", "jpeg") && it.lastModified() > atlasModTime }) return
|
if (File(input).listTree().none { it.extension in listOf("png", "jpg", "jpeg") && it.lastModified() > atlasModTime }) return
|
||||||
}
|
}
|
||||||
|
|
||||||
TexturePacker.process(settings, input, output, packFileName)
|
TexturePacker.process(settings, input, output, packFileName)
|
||||||
|
Reference in New Issue
Block a user