mirror of
https://github.com/yairm210/Unciv.git
synced 2025-09-24 03:53:12 -04:00
Prettier Events - that now respect 'hidden from users' (#11474)
* Expand Events to allow civilopediaText, fixing hidden uniques as side effect * Fix ConditionalChance save scumming * Fix ExtraImages not working within mods * Missing documentation of the changes to Events * Kdoc to clarify why helper returns nullable * Lint getJavaClassByName: fix compiler warning and re-sort when * FormattedLine now throws on problems with extraImage loading * Fix Tutorial extraImage by removing auto and making it explicit * Revert "FormattedLine now throws on problems with extraImage loading" This reverts commit b5ab4084ee96133ea4c0c0d3d43ef4264c352057. * Partially revert the revert, so bad extraImage fields are console-logged
This commit is contained in:
parent
371690a678
commit
c75861af25
@ -246,6 +246,7 @@
|
||||
},
|
||||
{
|
||||
"name": "World Screen",
|
||||
"civilopediaText": [{"extraImage":"World_Screen"}],
|
||||
"steps": [
|
||||
"",
|
||||
"This is where you spend most of your time playing Unciv. See the world, control your units, access other screens from here.",
|
||||
|
@ -6,25 +6,41 @@ import com.unciv.models.ruleset.unique.StateForConditionals
|
||||
import com.unciv.models.ruleset.unique.Unique
|
||||
import com.unciv.models.ruleset.unique.UniqueTriggerActivation
|
||||
import com.unciv.models.stats.INamed
|
||||
import com.unciv.ui.components.input.KeyCharAndCode
|
||||
import com.unciv.ui.screens.civilopediascreen.FormattedLine
|
||||
import com.unciv.ui.screens.civilopediascreen.ICivilopediaText
|
||||
|
||||
|
||||
|
||||
class Event : INamed {
|
||||
class Event : INamed, ICivilopediaText {
|
||||
|
||||
override var name = ""
|
||||
var text = ""
|
||||
override var civilopediaText = listOf<FormattedLine>()
|
||||
override fun makeLink() = "Event/$name"
|
||||
|
||||
// todo: add unrepeatable events
|
||||
|
||||
var choices = ArrayList<EventChoice>()
|
||||
|
||||
/** @return `null` when no choice passes the condition tests, so client code can easily bail using Elvis `?:`. */
|
||||
fun getMatchingChoices(stateForConditionals: StateForConditionals) =
|
||||
choices.filter { it.matchesConditions(stateForConditionals) }.ifEmpty { null }
|
||||
}
|
||||
|
||||
class EventChoice {
|
||||
class EventChoice : ICivilopediaText {
|
||||
var text = ""
|
||||
override var civilopediaText = listOf<FormattedLine>()
|
||||
override fun makeLink() = ""
|
||||
|
||||
/** Keyboard support - not user-rebindable, mod control only. Will be [parsed][KeyCharAndCode.parse], so Gdx key names will work. */
|
||||
val keyShortcut = ""
|
||||
|
||||
var triggeredUniques = ArrayList<String>()
|
||||
val triggeredUniqueObjects by lazy { triggeredUniques.map { Unique(it) } }
|
||||
|
||||
var conditions = ArrayList<String>()
|
||||
val conditionObjects by lazy { conditions.map { Unique(it) } }
|
||||
|
||||
fun matchesConditions(stateForConditionals: StateForConditionals) =
|
||||
conditionObjects.all { Conditionals.conditionalApplies(null, it, stateForConditionals) }
|
||||
|
||||
|
@ -30,9 +30,6 @@ class Tutorial : RulesetObject() {
|
||||
override fun getUniqueTarget() = UniqueTarget.Tutorial
|
||||
override fun makeLink() = "Tutorial/$name"
|
||||
|
||||
override fun getCivilopediaTextLines(ruleset: Ruleset): List<FormattedLine> {
|
||||
val imageLine = FormattedLine(extraImage = name.replace(' ', '_'))
|
||||
if (steps == null) return listOf(imageLine)
|
||||
return (sequenceOf(imageLine) + steps.asSequence().map { FormattedLine(it) }).toList()
|
||||
}
|
||||
override fun getCivilopediaTextLines(ruleset: Ruleset) =
|
||||
steps?.map { FormattedLine(it) }.orEmpty()
|
||||
}
|
||||
|
@ -42,4 +42,27 @@ data class StateForConditionals(
|
||||
companion object {
|
||||
val IgnoreConditionals = StateForConditionals(ignoreConditionals = true)
|
||||
}
|
||||
|
||||
/** Used ONLY for stateBasedRandom in [Conditionals.conditionalApplies] to prevent save scumming on [UniqueType.ConditionalChance] */
|
||||
override fun hashCode(): Int {
|
||||
fun Civilization?.hash() = this?.civName?.hashCode() ?: 0
|
||||
fun City?.hash() = this?.id?.hashCode() ?: 0
|
||||
fun Tile?.hash() = this?.position?.hashCode() ?: 0
|
||||
fun MapUnit?.hash() = (this?.name?.hashCode() ?: 0) + 17 * this?.currentTile.hash()
|
||||
fun ICombatant?.hash() = (this?.getName()?.hashCode() ?: 0) + 17 * this?.getTile().hash()
|
||||
fun CombatAction?.hash() = this?.name?.hashCode() ?: 0
|
||||
fun Region?.hash() = this?.rect?.hashCode() ?: 0
|
||||
|
||||
var result = civInfo.hash()
|
||||
result = 31 * result + city.hash()
|
||||
result = 31 * result + unit.hash()
|
||||
result = 31 * result + tile.hash()
|
||||
result = 31 * result + ourCombatant.hash()
|
||||
result = 31 * result + theirCombatant.hash()
|
||||
result = 31 * result + attackedTile.hash()
|
||||
result = 31 * result + combatAction.hash()
|
||||
result = 31 * result + region.hash()
|
||||
result = 31 * result + ignoreConditionals.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
@ -105,8 +105,8 @@ object UniqueTriggerActivation {
|
||||
when (unique.type) {
|
||||
UniqueType.TriggerEvent -> {
|
||||
val event = ruleset.events[unique.params[0]] ?: return null
|
||||
val choices = event.choices.filter { it.matchesConditions(stateForConditionals) }
|
||||
if (choices.isEmpty()) return null
|
||||
val choices = event.getMatchingChoices(stateForConditionals)
|
||||
?: return null
|
||||
return {
|
||||
if (civInfo.isAI()) choices.random().triggerChoice(civInfo)
|
||||
else civInfo.popupAlerts.add(PopupAlert(AlertType.Event, event.name))
|
||||
|
@ -316,8 +316,7 @@ object TranslationFileWriter {
|
||||
val filename = jsonFile.nameWithoutExtension()
|
||||
|
||||
val javaClass = getJavaClassByName(filename)
|
||||
if (javaClass == String.javaClass)
|
||||
continue // unknown JSON, let's skip it
|
||||
?: continue // unknown JSON, let's skip it
|
||||
|
||||
val array = json().fromJsonFile(javaClass, jsonFile.path())
|
||||
|
||||
@ -497,13 +496,14 @@ object TranslationFileWriter {
|
||||
(clazz.componentType?.simpleName ?: clazz.simpleName) + "." + field.name !in untranslatableFieldSet
|
||||
}
|
||||
|
||||
private fun getJavaClassByName(name: String): Class<Any> {
|
||||
private fun getJavaClassByName(name: String): Class<Any>? {
|
||||
return when (name) {
|
||||
"Beliefs" -> emptyArray<Belief>().javaClass
|
||||
"Buildings" -> emptyArray<Building>().javaClass
|
||||
"CityStateTypes" -> emptyArray<CityStateType>().javaClass
|
||||
"Difficulties" -> emptyArray<Difficulty>().javaClass
|
||||
"Eras" -> emptyArray<Era>().javaClass
|
||||
"Speeds" -> emptyArray<Speed>().javaClass
|
||||
"Events" -> emptyArray<Event>().javaClass
|
||||
"GlobalUniques" -> GlobalUniques().javaClass
|
||||
"Nations" -> emptyArray<Nation>().javaClass
|
||||
"Policies" -> emptyArray<PolicyBranch>().javaClass
|
||||
@ -511,6 +511,7 @@ object TranslationFileWriter {
|
||||
"Religions" -> emptyArray<String>().javaClass
|
||||
"Ruins" -> emptyArray<RuinReward>().javaClass
|
||||
"Specialists" -> emptyArray<Specialist>().javaClass
|
||||
"Speeds" -> emptyArray<Speed>().javaClass
|
||||
"Techs" -> emptyArray<TechColumn>().javaClass
|
||||
"Terrains" -> emptyArray<Terrain>().javaClass
|
||||
"TileImprovements" -> emptyArray<TileImprovement>().javaClass
|
||||
@ -520,9 +521,7 @@ object TranslationFileWriter {
|
||||
"Units" -> emptyArray<BaseUnit>().javaClass
|
||||
"UnitTypes" -> emptyArray<UnitType>().javaClass
|
||||
"VictoryTypes" -> emptyArray<Victory>().javaClass
|
||||
"CityStateTypes" -> emptyArray<CityStateType>().javaClass
|
||||
"Events" -> emptyArray<Event>().javaClass
|
||||
else -> String.javaClass // dummy value
|
||||
else -> null // dummy value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -176,14 +176,33 @@ object ImageGetter {
|
||||
fun getWhiteDotDrawable() = textureRegionDrawables[whiteDotLocation]!!
|
||||
fun getDot(dotColor: Color) = getWhiteDot().apply { color = dotColor }
|
||||
|
||||
fun getExternalImage(fileName: String): Image {
|
||||
/** Finds an image file under /ExtraImages/, including Mods (which can override builtin).
|
||||
* Extension can be included or is guessed as png/jpg.
|
||||
* @return `null` if no match found.
|
||||
*/
|
||||
fun findExternalImage(name: String): FileHandle? {
|
||||
val folders = ruleset.mods.asSequence().map { Gdx.files.local("mods/$it/ExtraImages") } +
|
||||
sequenceOf(Gdx.files.internal("ExtraImages"))
|
||||
val extensions = sequenceOf("", ".png", ".jpg")
|
||||
return folders.flatMap { folder ->
|
||||
extensions.map { folder.child(name + it) }
|
||||
}.firstOrNull { it.exists() }
|
||||
}
|
||||
|
||||
/** Loads an image on the fly - uncached Texture, not too fast. */
|
||||
fun getExternalImage(file: FileHandle): Image {
|
||||
// Since these are not packed in an atlas, they have no scaling filter metadata and
|
||||
// default to Nearest filter, anisotropic level 1. Use Linear instead, helps
|
||||
// loading screen and Tutorial.WorldScreen quite a bit. More anisotropy barely helps.
|
||||
val texture = Texture("ExtraImages/$fileName")
|
||||
val texture = Texture(file)
|
||||
texture.setFilter(TextureFilter.Linear, TextureFilter.Linear)
|
||||
return ImageWithCustomSize(TextureRegion(texture))
|
||||
}
|
||||
/** Loads an image from (assets)/ExtraImages, from the jar if Unciv runs packaged.
|
||||
* Cannot load ExtraImages from a Mod - use [findExternalImage] and the [getExternalImage](FileHandle) overload instead.
|
||||
*/
|
||||
fun getExternalImage(fileName: String) =
|
||||
getExternalImage(Gdx.files.internal("ExtraImages/$fileName"))
|
||||
|
||||
fun getImage(fileName: String?): Image {
|
||||
return ImageWithCustomSize(getDrawable(fileName))
|
||||
|
@ -105,8 +105,9 @@ class TutorialRender(private val screen: BaseScreen) {
|
||||
val tutorialPopup = Popup(screen)
|
||||
tutorialPopup.name = Constants.tutorialPopupNamePrefix + tutorialName
|
||||
|
||||
if (Gdx.files.internal("ExtraImages/$tutorialName").exists()) {
|
||||
tutorialPopup.add(ImageGetter.getExternalImage(tutorialName)).row()
|
||||
val externalImage = ImageGetter.findExternalImage(tutorialName)
|
||||
if (externalImage != null) {
|
||||
tutorialPopup.add(ImageGetter.getExternalImage(externalImage)).row()
|
||||
}
|
||||
|
||||
tutorialPopup.addGoodSizedLabel(texts[0]).row()
|
||||
|
@ -12,6 +12,7 @@ import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable
|
||||
import com.badlogic.gdx.utils.Align
|
||||
import com.unciv.Constants
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.logic.UncivShowableException
|
||||
import com.unciv.models.metadata.BaseRuleset
|
||||
import com.unciv.models.ruleset.Ruleset
|
||||
import com.unciv.models.ruleset.RulesetCache
|
||||
@ -239,30 +240,7 @@ class FormattedLine (
|
||||
* @param iconDisplay Flag to omit link or all images.
|
||||
*/
|
||||
fun render(labelWidth: Float, iconDisplay: IconDisplay = IconDisplay.All): Actor {
|
||||
if (extraImage.isNotEmpty()) {
|
||||
val table = Table(BaseScreen.skin)
|
||||
try {
|
||||
val image = when {
|
||||
ImageGetter.imageExists(extraImage) ->
|
||||
if (centered) ImageGetter.getDrawable(extraImage).cropToContent()
|
||||
else ImageGetter.getImage(extraImage)
|
||||
Gdx.files.internal("ExtraImages/$extraImage.png").exists() ->
|
||||
ImageGetter.getExternalImage("$extraImage.png")
|
||||
Gdx.files.internal("ExtraImages/$extraImage.jpg").exists() ->
|
||||
ImageGetter.getExternalImage("$extraImage.jpg")
|
||||
else -> return table
|
||||
}
|
||||
// limit larger cordinate to a given max size
|
||||
val maxSize = if (imageSize.isNaN()) labelWidth else imageSize
|
||||
val (width, height) = if (image.width > image.height)
|
||||
maxSize to maxSize * image.height / image.width
|
||||
else maxSize * image.width / image.height to maxSize
|
||||
table.add(image).size(width, height)
|
||||
} catch (exception: Exception) {
|
||||
Log.error("Exception while rendering civilopedia text", exception)
|
||||
}
|
||||
return table
|
||||
}
|
||||
if (extraImage.isNotEmpty()) return renderExtraImage(labelWidth)
|
||||
|
||||
val fontSize = when {
|
||||
header in 1 until headerSizes.size -> headerSizes[header]
|
||||
@ -316,6 +294,30 @@ class FormattedLine (
|
||||
return table
|
||||
}
|
||||
|
||||
private fun renderExtraImage(labelWidth: Float): Table {
|
||||
val table = Table(BaseScreen.skin)
|
||||
fun getExtraImage(): Image {
|
||||
if (ImageGetter.imageExists(extraImage))
|
||||
return if (centered) ImageGetter.getDrawable(extraImage).cropToContent()
|
||||
else ImageGetter.getImage(extraImage)
|
||||
val externalImage = ImageGetter.findExternalImage(extraImage)
|
||||
?: throw UncivShowableException("Extra image '[$extraImage]' not found") // logged in catch below
|
||||
return ImageGetter.getExternalImage(externalImage)
|
||||
}
|
||||
try {
|
||||
val image = getExtraImage()
|
||||
// limit larger cordinate to a given max size
|
||||
val maxSize = if (imageSize.isNaN()) labelWidth else imageSize
|
||||
val (width, height) = if (image.width > image.height)
|
||||
maxSize to maxSize * image.height / image.width
|
||||
else maxSize * image.width / image.height to maxSize
|
||||
table.add(image).size(width, height)
|
||||
} catch (exception: Exception) {
|
||||
Log.error("Exception while rendering civilopedia text", exception)
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
/** Place a RuleSet object icon.
|
||||
* @return 1 if successful for easy counting
|
||||
*/
|
||||
|
@ -18,21 +18,27 @@ import com.unciv.logic.civilization.PopupAlert
|
||||
import com.unciv.logic.civilization.diplomacy.DiplomacyFlags
|
||||
import com.unciv.logic.civilization.diplomacy.DiplomaticModifiers
|
||||
import com.unciv.logic.civilization.diplomacy.RelationshipLevel
|
||||
import com.unciv.models.ruleset.EventChoice
|
||||
import com.unciv.models.ruleset.unique.StateForConditionals
|
||||
import com.unciv.models.ruleset.unique.UniqueType
|
||||
import com.unciv.models.translations.fillPlaceholders
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.audio.MusicMood
|
||||
import com.unciv.ui.audio.MusicTrackChooserFlags
|
||||
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
|
||||
import com.unciv.ui.components.extensions.disable
|
||||
import com.unciv.ui.components.extensions.pad
|
||||
import com.unciv.ui.components.extensions.toLabel
|
||||
import com.unciv.ui.components.extensions.toTextButton
|
||||
import com.unciv.ui.components.input.KeyCharAndCode
|
||||
import com.unciv.ui.components.input.KeyboardBinding
|
||||
import com.unciv.ui.components.input.keyShortcuts
|
||||
import com.unciv.ui.components.input.onActivation
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.popups.Popup
|
||||
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen
|
||||
import com.unciv.ui.screens.civilopediascreen.FormattedLine
|
||||
import com.unciv.ui.screens.civilopediascreen.MarkupRenderer
|
||||
import com.unciv.ui.screens.diplomacyscreen.LeaderIntroTable
|
||||
import com.unciv.ui.screens.victoryscreen.VictoryScreen
|
||||
import java.util.EnumSet
|
||||
@ -503,17 +509,17 @@ class AlertPopup(
|
||||
private fun addEvent(): Boolean {
|
||||
val event = gameInfo.ruleset.events[popupAlert.value] ?: return false
|
||||
|
||||
val civ = gameInfo.currentPlayerCiv
|
||||
val choices = event.choices.filter { it.matchesConditions(StateForConditionals(civ)) }
|
||||
if (choices.isEmpty()) return false
|
||||
val stateForConditionals = StateForConditionals(gameInfo.currentPlayerCiv)
|
||||
val choices = event.getMatchingChoices(stateForConditionals)
|
||||
?: return false
|
||||
|
||||
if (event.text.isNotEmpty())
|
||||
addGoodSizedLabel(event.text)
|
||||
for (choice in choices){
|
||||
addSeparator()
|
||||
add(choice.text.toTextButton().onActivation { close(); choice.triggerChoice(civ) }).row()
|
||||
for (triggeredUnique in choice.triggeredUniques)
|
||||
addGoodSizedLabel(triggeredUnique).row()
|
||||
if (event.civilopediaText.isNotEmpty()) {
|
||||
add(event.renderCivilopediaText(stageWidth * 0.5f, ::openCivilopedia)).row()
|
||||
}
|
||||
|
||||
for (choice in choices) addChoice(choice)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -524,4 +530,32 @@ class AlertPopup(
|
||||
worldScreen.shouldUpdate = true
|
||||
super.close()
|
||||
}
|
||||
|
||||
private fun addChoice(choice: EventChoice) {
|
||||
addSeparator()
|
||||
|
||||
val button = choice.text.toTextButton()
|
||||
button.onActivation {
|
||||
close()
|
||||
choice.triggerChoice(gameInfo.currentPlayerCiv)
|
||||
}
|
||||
val key = KeyCharAndCode.parse(choice.keyShortcut)
|
||||
if (key != KeyCharAndCode.UNKNOWN) {
|
||||
button.keyShortcuts.add(key)
|
||||
button.addTooltip(key)
|
||||
}
|
||||
add(button).row()
|
||||
|
||||
val lines = (
|
||||
choice.civilopediaText.asSequence()
|
||||
+ choice.triggeredUniqueObjects.asSequence()
|
||||
.filterNot { it.isHiddenToUsers() }
|
||||
.map { FormattedLine(it) }
|
||||
).asIterable()
|
||||
add(MarkupRenderer.render(lines, stageWidth * 0.5f, linkAction = ::openCivilopedia)).row()
|
||||
}
|
||||
|
||||
private fun openCivilopedia(link: String) {
|
||||
worldScreen.game.pushScreen(CivilopediaScreen(gameInfo.ruleset, link = link))
|
||||
}
|
||||
}
|
||||
|
@ -119,19 +119,25 @@ The code below is an example of a valid "turns" definition and it specifies that
|
||||
Events allow users to choose between options of triggers to activate.
|
||||
|
||||
| Attribute | Type | Default | Notes |
|
||||
|-----------|----------------------|----------|-----------------------------------------------------------|
|
||||
|-----------------|----------------------|----------|-------------------------------------------------------------------------------|
|
||||
| name | String | Required | Used for triggering via "Triggers a [event] event" unique |
|
||||
| text | String | None | Flavor text displayed to user |
|
||||
| civilopediaText | List | Optional | See [civilopediaText chapter](5-Miscellaneous-JSON-files.md#civilopedia-text) |
|
||||
| choices | List of EventChoices | | User can choose to trigger one of the viable choices |
|
||||
|
||||
You can use text and/or civilopediaText, if both are present both are shown (but why would you?)
|
||||
|
||||
Event choices are comprised of:
|
||||
|
||||
| Attribute | Type | Default | Notes |
|
||||
|------------------|-----------------------------|------------|---------------------------------------------------------------|
|
||||
| text | String | Required | Displayed to user. Should be an action name - "Do X" |
|
||||
|------------------|-----------------------------|------------|----------------------------------------------------------------------------------------------------------------------|
|
||||
| text | String | Required | Displayed to user as button. Should be an action name - "Do X" |
|
||||
| triggeredUniques | List of trigger uniques | Required | The triggers that this choice activates upon being chosen |
|
||||
| conditions | List of conditional uniques | Empty list | If any conditional is not met, this option becomes unpickable |
|
||||
| conditions | List of conditional uniques | Empty list | If any conditional is not met, this option becomes unpickable (not shown) |
|
||||
| keyShortcut | key to select (name) | none | Key names see [Gdx.Input.Keys](https://github.com/libgdx/libgdx/blob/master/gdx/src/com/badlogic/gdx/Input.java#L69) |
|
||||
| civilopediaText | List | Optional | See [civilopediaText chapter](5-Miscellaneous-JSON-files.md#civilopedia-text) |
|
||||
|
||||
Here, civilopediaText is shown outside the active Button, before the triggeredUniques.
|
||||
|
||||
## ModOptions.json
|
||||
|
||||
@ -268,7 +274,6 @@ When extension rulesets define GlobalUniques, all uniques are merged. At the mom
|
||||
**Note a Base Ruleset mod can define a "welcome page" here by adding a "Tutorial" with a name equal to the name of the mod!**
|
||||
As an exception to the general rule, this file in a Base Ruleset mod will not _replace_ the default, but add to it like extension mods do.
|
||||
Also, place it under `<mod>/jsons/` normally even if the original is found one level above the vanilla jsons.
|
||||
Also, place it under `<mod>/jsons/` normally even if the original is found one level above the vanilla jsons.
|
||||
|
||||
Each tutorial has the following structure:
|
||||
|
||||
@ -279,6 +284,8 @@ Each tutorial has the following structure:
|
||||
| steps | List of Strings | Optional | Plain text |
|
||||
|
||||
If an entry contains both `steps` and `civilopediaText` attributes, the `civilopediaText` is shown first.
|
||||
Tutorials shown as Popup can show an show an external image (not part of the texture atlases) if there is an image unter ExtraImages (directly under assets or the Mod folder) having the same name.
|
||||
This is searched for, meaning the mod defining the Tutorial is irrelevant, mods can override builtin ExtraImages, and case sensitivity depends on the OS.
|
||||
|
||||
## VictoryTypes.json
|
||||
|
||||
@ -362,6 +369,8 @@ The lines from json will 'surround' the automatically generated lines such that
|
||||
Note: `text` now also supports inline color markup. Insert `«color»` to start coloring text, `«»` to stop. `color` can be a name or 6/8-digit hex notation like `#ffa040` (different from the `color` attribute notation only by not allowing 3-digit codes, but allowing the alpha channel).
|
||||
Effectively, the `«»` markers are replaced with `[]` _after_ translation and then passed to [gdx markup language](https://libgdx.com/wiki/graphics/2d/fonts/color-markup-language).
|
||||
|
||||
Note: Using an ExtraImages folder in a mod was not working until version 4.11.5
|
||||
|
||||
## RGB colors list
|
||||
|
||||
Certain objects can be specified to have its own unique color. The colors are defined by a list of 3× Integer in this order: red, green, blue. The range of color is from \[0, 0, 0\] (black) to \[255, 255, 255\] (white).
|
||||
|
Loading…
x
Reference in New Issue
Block a user