Easter eggs: Moddable floating imagery (#11483)

* New HolidayDates for potential easter eggs

* Dia de los Muertos

* Add Diawali

* Add Diwali credits

* Add some Qingming stuff

* Allow for zero images for a holiday configured to show floating art

* Revert contained art

* Simplify a few things

* Implement all special days I could think of

* Wiki

* Put chance back in so the full-range old holidays behave the same as before and unit tests work
This commit is contained in:
SomeTroglodyte 2024-07-18 16:55:41 +02:00 committed by GitHub
parent 5c84eebb87
commit 42d6218956
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 439 additions and 4 deletions

View File

@ -1,11 +1,22 @@
package com.unciv.logic
import com.unciv.ui.screens.mainmenuscreen.EasterEggFloatingArt
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.Month
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlin.random.Random
object HolidayDates {
enum class Holidays {
/**
* Known holidays (for easter egg use).
* @property getByYear Determines when the holiday happens for a given year.
* @property name Used to look for art automatically presented via [EasterEggFloatingArt], can be used as-is in a `-DeasterEgg=name` command line parameter for testing.
*/
enum class Holidays(val chance: Float = 1f) {
Easter {
override fun getByYear(year: Int): DateRange {
// https://en.wikipedia.org/wiki/Date_of_Easter
@ -31,7 +42,84 @@ object HolidayDates {
},
Xmas {
override fun getByYear(year: Int) = DateRange.of(year, 12, 24, 4)
}
},
DiaDeLosMuertos(0.5f) {
// https://en.wikipedia.org/wiki/Day_of_the_Dead
override fun getByYear(year: Int) = DateRange.of(year, 11, 1, 2)
},
YuleGoat {
//todo add art and visualization
// https://en.m.wikipedia.org/wiki/Advent:
// "Advent begins with First Vespers (Evening Prayer I) of the Sunday that falls on or closest to 30 November"
override fun getByYear(year: Int) =
DateRange.of(LocalDate.of(year, 11, 30).closestWeekday(DayOfWeek.SUNDAY))
},
Qingming {
// https://en.wikipedia.org/wiki/Qingming_Festival
// "it falls on the first day of the fifth solar term (also called Qingming) of the traditional Chinese lunisolar calendar.
// This makes it the 15th day after the Spring Equinox, either 4, 5 or 6 April in a given year"
override fun getByYear(year: Int): DateRange {
val springEquinoxInstant = Tables.equinoxes[year] ?: return DateRange.never
val springEquinox = LocalDate.ofInstant(springEquinoxInstant, ZoneId.systemDefault())
return DateRange.of(springEquinox.plusDays(15L))
}
},
Diwali(0.2f) {
// https://en.wikipedia.org/wiki/Diwali#Dates
// Darkest new moon night between mid-october and mid-november, then add +/- two days for a 5-day festival...
// For moon phase, could adapt http://www.stargazing.net/kepler/jsmoon.html - or use a table
override fun getByYear(year: Int): DateRange {
val knownValue = Tables.diwali[year] ?: return DateRange.never
return DateRange.of(knownValue.plusDays(-2L), 5)
}
},
LunarNewYear {
override fun getByYear(year: Int): DateRange {
val knownValue = Tables.lunarNewYear[year] ?: return DateRange.never
return DateRange.of(knownValue)
}
},
AprilFoolsDay {
override fun getByYear(year: Int) = DateRange.of(year, 4, 1)
},
PrideDay(0.333f) {
// https://en.wikipedia.org/wiki/LGBT_pride
// Actually, let's not make this a month. Beginning on original Christopher Street is fine IMO.
override fun getByYear(year: Int) = DateRange.of(year, 6, 28, 3)
},
TowelDay {
// https://en.wikipedia.org/wiki/Towel_Day
override fun getByYear(year: Int) = DateRange.of(year, 5, 25)
},
UncivBirthday {
// First commit according to github
override fun getByYear(year: Int) = DateRange.of(year, 11, 21)
},
Friday13th {
// Several a year are possible, but our DateRange format won't support that directly - choose one randomly
override fun getByYear(year: Int) =
(1..12)
.map { LocalDate.of(year, it, 13) }
.filter { it.dayOfWeek == DayOfWeek.FRIDAY }
.randomOrNull()
?.let { DateRange.of(it) }
?: DateRange.never
},
StarWarsDay {
// https://en.wikipedia.org/wiki/Star_Wars_Day
override fun getByYear(year: Int) = DateRange.of(year, 5, 4) // evil puns begone
},
Passover(0.2f) {
// Last not least: Passah
// חַג הַפֶּסַח
// https://en.wikipedia.org/wiki/Passover
// Should start at sundown the day before - we simplify that away
override fun getByYear(year: Int): DateRange {
val knownValue = Tables.passover[year] ?: return DateRange.never
return DateRange.of(knownValue.plusDays(-2L), 5)
}
},
;
abstract fun getByYear(year: Int): DateRange
@ -41,7 +129,10 @@ object HolidayDates {
}
}
class DateRange( override val start: LocalDate, override val endInclusive: LocalDate) : ClosedRange<LocalDate> {
open class DateRange(override val start: LocalDate, override val endInclusive: LocalDate) : ClosedRange<LocalDate> {
val length
get() = start.until(endInclusive, ChronoUnit.DAYS).toInt().coerceAtLeast(0)
override fun toString() = "$start..$endInclusive"
override fun equals(other: Any?): Boolean {
if (this === other) return true
@ -55,6 +146,7 @@ object HolidayDates {
fun of(year: Int, month: Int, day: Int) = of(LocalDate.of(year, month, day))
fun of(date: LocalDate, duration: Int) = DateRange(date, date.plusDays(duration - 1L))
fun of(year: Int, month: Int, day: Int, duration: Int) = of(LocalDate.of(year, month, day), duration)
val never = DateRange(LocalDate.now(), LocalDate.now().plusDays(-1L))
}
}
@ -64,11 +156,242 @@ object HolidayDates {
return System.getProperty("easterEgg")?.let {
Holidays.safeValueOf(it)
} ?: Holidays.values().firstOrNull {
date in it.getByYear(date.year)
val range = it.getByYear(date.year)
date in range && Random.nextFloat() <= it.chance
}
}
fun getMonth(): Month = System.getProperty("month")?.toIntOrNull()?.let {
Month.of(it)
} ?: LocalDate.now().month
private fun LocalDate.closestWeekday(day: DayOfWeek): LocalDate {
val delta = (7 + dayOfWeek.ordinal - day.ordinal) % 7
return if (delta < 4) plusDays(delta.toLong()) else minusDays((7 - delta).toLong())
}
private object Tables {
// http://www.astropixels.com/ephemeris/soleq2001.html
val equinoxes by lazy {
listOf(
2024 to "2024-03-20T03:07:00Z",
2025 to "2025-03-20T09:02:00Z",
2026 to "2026-03-20T14:46:00Z",
2027 to "2027-03-20T20:25:00Z",
2028 to "2028-03-20T02:17:00Z",
2029 to "2029-03-20T08:01:00Z",
2030 to "2030-03-20T13:51:00Z",
2031 to "2031-03-20T19:41:00Z",
2032 to "2032-03-20T01:23:00Z",
2033 to "2033-03-20T07:23:00Z",
2034 to "2034-03-20T13:18:00Z",
2035 to "2035-03-20T19:03:00Z",
2036 to "2036-03-20T01:02:00Z",
2037 to "2037-03-20T06:50:00Z",
2038 to "2038-03-20T12:40:00Z",
2039 to "2039-03-20T18:32:00Z",
2040 to "2040-03-20T00:11:00Z",
2041 to "2041-03-20T06:07:00Z",
2042 to "2042-03-20T11:53:00Z",
2043 to "2043-03-20T17:29:00Z",
2044 to "2044-03-19T23:20:00Z",
2045 to "2045-03-20T05:08:00Z",
2046 to "2046-03-20T10:58:00Z",
2047 to "2047-03-20T16:52:00Z",
2048 to "2048-03-19T22:34:00Z",
2049 to "2049-03-20T04:28:00Z",
2050 to "2050-03-20T10:20:00Z",
2051 to "2051-03-20T15:58:00Z",
2052 to "2052-03-19T21:56:00Z",
2053 to "2053-03-20T03:46:00Z",
2054 to "2054-03-20T09:35:00Z",
2055 to "2055-03-20T15:28:00Z",
2056 to "2056-03-19T21:11:00Z",
2057 to "2057-03-20T03:08:00Z",
2058 to "2058-03-20T09:04:00Z",
2059 to "2059-03-20T14:44:00Z",
2060 to "2060-03-19T20:37:00Z",
2061 to "2061-03-20T02:26:00Z",
2062 to "2062-03-20T08:07:00Z",
2063 to "2063-03-20T13:59:00Z",
2064 to "2064-03-19T19:40:00Z",
2065 to "2065-03-20T01:27:00Z",
2066 to "2066-03-20T07:19:00Z",
2067 to "2067-03-20T12:55:00Z",
2068 to "2068-03-19T18:51:00Z",
2069 to "2069-03-20T00:44:00Z",
2070 to "2070-03-20T06:35:00Z",
2071 to "2071-03-20T12:36:00Z",
2072 to "2072-03-19T18:19:00Z",
2073 to "2073-03-20T00:12:00Z",
2074 to "2074-03-20T06:09:00Z",
2075 to "2075-03-20T11:48:00Z",
2076 to "2076-03-19T17:37:00Z",
2077 to "2077-03-19T23:30:00Z",
2078 to "2078-03-20T05:11:00Z",
2079 to "2079-03-20T11:03:00Z",
2080 to "2080-03-19T16:43:00Z",
2081 to "2081-03-19T22:34:00Z",
2082 to "2082-03-20T04:32:00Z",
2083 to "2083-03-20T10:08:00Z",
2084 to "2084-03-19T15:58:00Z",
2085 to "2085-03-19T21:53:00Z",
2086 to "2086-03-20T03:36:00Z",
2087 to "2087-03-20T09:27:00Z",
2088 to "2088-03-19T15:16:00Z",
2089 to "2089-03-19T21:07:00Z",
2090 to "2090-03-20T03:03:00Z",
2091 to "2091-03-20T08:40:00Z",
2092 to "2092-03-19T14:33:00Z",
2093 to "2093-03-19T20:35:00Z",
2094 to "2094-03-20T02:20:00Z",
2095 to "2095-03-20T08:14:00Z",
2096 to "2096-03-19T14:03:00Z",
2097 to "2097-03-19T19:49:00Z",
2098 to "2098-03-20T01:38:00Z",
2099 to "2099-03-20T07:17:00Z",
2100 to "2100-03-20T13:04:00Z",
).associateBy({ it.first }, { Instant.parse(it.second) })
}
// https://jaysage.org/Passover_Dates.pdf, some confirmed via https://duckduckgo.com/?q=passover+date+NNNN
val passover by lazy {
listOf(
2023 to 5,
2024 to 22,
2025 to 12,
2026 to 1,
2027 to 21,
2028 to 10,
2029 to -2,
2030 to 17,
2031 to 7,
2032 to -6,
2033 to 13,
2034 to 3,
2035 to 23,
2036 to 11,
2037 to -2,
2038 to 19,
2039 to 8,
2040 to -4,
2041 to 15,
2042 to 4,
2043 to 24,
2044 to 11,
2045 to 1,
2046 to 20,
2047 to 10,
2048 to -4,
2049 to 16,
2050 to 6,
).associateBy({ it.first }, { LocalDate.of(it.first, 4, 1).plusDays(it.second.toLong()) })
}
// from e.g. https://www.theholidayspot.com/diwali/calendar.htm
val diwali by lazy {
listOf(
2024 to "2024-11-01",
2025 to "2025-10-21",
2026 to "2026-11-08",
2027 to "2027-10-29",
2028 to "2028-10-17",
2029 to "2029-11-05",
2030 to "2030-10-26",
2031 to "2031-11-14",
2032 to "2032-11-02",
2033 to "2033-10-22",
2034 to "2034-11-10",
2035 to "2035-10-20",
2036 to "2036-10-19",
2037 to "2037-11-07",
2038 to "2038-10-27",
2039 to "2039-10-17",
2040 to "2040-11-04",
).associateBy({ it.first }, { LocalDate.parse(it.second) })
}
// from https://github.com/00-Evan/shattered-pixel-dungeon/blob/master/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/utils/Holiday.java
val lunarNewYear by lazy {
listOf(
2024 to 31+10, // February 10th
2025 to 29, // January 29th
2026 to 31+17, // February 17th
2027 to 31+6, // February 6th
2028 to 26, // January 26th
2029 to 31+13, // February 13th
2030 to 31+3, // February 3rd
2031 to 23, // January 23rd
2032 to 31+11, // February 11th
2033 to 31, // January 31st
2034 to 31+19, // February 19th
2035 to 31+8, // February 8th
2036 to 28, // January 28th
2037 to 31+15, // February 15th
2038 to 31+4, // February 4th
2039 to 24, // January 24th
2040 to 31+12, // February 12th
2041 to 31+1, // February 1st
2042 to 22, // January 22nd
2043 to 31+10, // February 10th
2044 to 30, // January 30th
2045 to 31+17, // February 17th
2046 to 31+6, // February 6th
2047 to 26, // January 26th
2048 to 31+14, // February 14th
2049 to 31+2, // February 2nd
2050 to 23, // January 23rd
2051 to 31+11, // February 11th
2052 to 31+1, // February 1st
2053 to 31+19, // February 19th
2054 to 31+8, // February 8th
2055 to 28, // January 28th
2056 to 31+15, // February 15th
2057 to 31+4, // February 4th
2058 to 24, // January 24th
2059 to 31+12, // February 12th
2060 to 31+2, // February 2nd
2061 to 21, // January 21st
2062 to 31+9, // February 9th
2063 to 29, // January 29th
2064 to 31+17, // February 17th
2065 to 31+5, // February 5th
2066 to 26, // January 26th
2067 to 31+14, // February 14th
2068 to 31+3, // February 3rd
2069 to 23, // January 23rd
2070 to 31+11, // February 11th
2071 to 31, // January 31st
2072 to 31+19, // February 19th
2073 to 31+7, // February 7th
2074 to 27, // January 27th
2075 to 31+15, // February 15th
2076 to 31+5, // February 5th
2077 to 24, // January 24th
2078 to 31+12, // February 12th
2079 to 31+2, // February 2nd
2080 to 22, // January 22nd
2081 to 31+9, // February 9th
2082 to 29, // January 29th
2083 to 31+17, // February 17th
2084 to 31+6, // February 6th
2085 to 26, // January 26th
2086 to 31+14, // February 14th
2087 to 31+3, // February 3rd
2088 to 24, // January 24th
2089 to 31+10, // February 10th
2090 to 30, // January 30th
2091 to 31+18, // February 18th
2092 to 31+7, // February 7th
2093 to 27, // January 27th
2094 to 31+15, // February 15th
2095 to 31+5, // February 5th
2096 to 25, // January 25th
2097 to 31+12, // February 12th
2098 to 31+1, // February 1st
2099 to 21, // January 21st
2100 to 31+9, // February 9th
).associateBy({ it.first }, { LocalDate.of(it.first, 1, 1).plusDays(it.second - 1L) })
}
}
}

View File

@ -0,0 +1,76 @@
package com.unciv.ui.screens.mainmenuscreen
import com.badlogic.gdx.math.Interpolation
import com.badlogic.gdx.scenes.scene2d.Stage
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup
import com.badlogic.gdx.utils.Align
import com.unciv.ui.images.ImageGetter
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.random.Random
class EasterEggFloatingArt(stage: Stage, name: String) : WidgetGroup() {
private val images = sequence {
for (index in 1..99) {
val textureName = "EasterEggs/$name$index"
if (!ImageGetter.imageExists(textureName)) break
yield(ImageGetter.getImage(textureName))
}
}.toList()
private val centerX = stage.width / 2
private val centerY = stage.height / 2
private val placementRadius = sqrt(
centerX * centerX + centerY * centerY
+ (images.maxOfOrNull { it.prefWidth } ?: 0f).pow(2)
+ (images.maxOfOrNull { it.prefHeight } ?: 0f).pow(2)
)
init {
if (images.isNotEmpty()) {
setFillParent(true)
stage.addActor(this)
nextImage()
}
}
private fun nextImage() {
addAction(
Actions.delay(Random.nextFloat() * 4f + 2f, Actions.run {
val image = images.random()
val angle = Random.nextDouble() * 2.0 * PI
val angle2 = angle + (Random.nextDouble() * 0.3333 - 0.1667) * PI // +/- 30° so they won't always cross the center exactly
val offsetX = (placementRadius * cos(angle)).toFloat()
val offsetY = (placementRadius * sin(angle)).toFloat()
val moveToX = (placementRadius * cos(angle2)).toFloat()
val moveToY = (placementRadius * sin(angle2)).toFloat()
image.setPosition(centerX - offsetX, centerY - offsetY, Align.center)
addActor(image)
image.animate(offsetX + moveToX, offsetY + moveToY)
})
)
}
private fun Image.animate(moveByX: Float, moveByY: Float) {
val duration = Random.nextFloat() * 8f + 3f
val interpolation = when(Random.nextInt(6)) {
1 -> Interpolation.bounce
2 -> Interpolation.swing
3 -> Interpolation.smoother
4 -> Interpolation.fastSlow
5 -> Interpolation.slowFast
else -> Interpolation.linear
}
color.a = 1f
addAction(Actions.sequence(
Actions.moveBy(moveByX, moveByY, duration, interpolation),
Actions.fadeOut(0.2f),
Actions.run(::nextImage),
Actions.removeActor()
))
}
}

View File

@ -10,6 +10,7 @@ import com.unciv.GUI
import com.unciv.UncivGame
import com.unciv.logic.GameInfo
import com.unciv.logic.GameStarter
import com.unciv.logic.HolidayDates
import com.unciv.logic.UncivShowableException
import com.unciv.logic.map.MapParameters
import com.unciv.logic.map.MapShape
@ -119,6 +120,9 @@ class MainMenuScreen: BaseScreen(), RecreateOnResize {
ImageGetter.ruleset = baseRuleset
if (game.settings.enableEasterEggs) {
val holiday = HolidayDates.getHolidayByDate()
if (holiday != null)
EasterEggFloatingArt(stage, holiday.name)
val easterEggMod = EasterEggRulesets.getTodayEasterEggRuleset()
if (easterEggMod != null)
easterEggRuleset = RulesetCache.getComplexRuleset(baseRuleset, listOf(easterEggMod))

View File

@ -285,3 +285,35 @@ placed in a mod's `voices` folder, will play whenever that message is displayed.
Leader voice audio clips will be streamed, not cached, so they are allowed to be long - however, if another Leader voice or a city ambient sound needs to be played, they will be cut off without fade-out
Also note that voices for City-State leaders work only for those messages a City-state can actually use: `attacked`, `defeated`, and `introduction`.
## Modding Easter eggs
Here's a list of special dates (or date ranges) Unciv will recognize:
|-----|
| AprilFoolsDay |
| DiaDeLosMuertos |
| Diwali |
| Easter |
| Friday13th |
| LunarNewYear |
| Passover |
| PrideDay |
| Qingming |
| Samhain |
| StarWarsDay |
| TowelDay |
| UncivBirthday |
| Xmas |
| YuleGoat |
... When these are or what they mean - look it up, if in doubt in our sources (😈).
An audiovisual Mod (which the user **must** then mark as permanent) can define textures named "EasterEggs/`name`<index>", where name must correspond exactly to one from the table above, and index starts at 1 counting up.
Example: <mod>/Images/EasterEggs/Diwali1.png and so on.
Then, Unciv will display them as "floating art" on the main menu screen, on the corresponding dates. They will from time to time appear from off-screen, slide through the window, and disappear out the other side, with varying angles and speeds.
Notes:
- You can test this by launching the jar and including `-DeasterEgg=name` on the command line.
- In case of overlapping holidays, only one is chosen - and the "impact" of longer holidays is equalized by reducing the chance inversely proportional to the number of days. e.g. DiaDeLosMuertos is two days, so each Unciv launch on these days has 50% chance to show the egg.
- Unciv's "map-based" easter eggs work independently!
- No cultural prejudice is intended. If you know a nice custom we should include the date test for, just ask.