import { globalScene } from "#app/global-scene"; import { TextStyle, addTextObject } from "#app/ui/text"; import type { nil } from "#app/utils"; import { isNullOrUndefined } from "#app/utils"; import i18next from "i18next"; import { Species } from "#enums/species"; import type { WeatherPoolEntry } from "#app/data/weather"; import { WeatherType } from "#enums/weather-type"; import { CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER } from "./data/balance/starters"; import { MysteryEncounterType } from "./enums/mystery-encounter-type"; import { MysteryEncounterTier } from "./enums/mystery-encounter-tier"; export enum EventType { SHINY, NO_TIMER_DISPLAY, LUCK } interface EventBanner { bannerKey?: string; xOffset?: number; yOffset?: number; scale?: number; availableLangs?: string[]; } interface EventEncounter { species: Species; blockEvolution?: boolean; } interface EventMysteryEncounterTier { mysteryEncounter: MysteryEncounterType; tier?: MysteryEncounterTier; disable?: boolean; } interface TimedEvent extends EventBanner { name: string; eventType: EventType; shinyMultiplier?: number; classicFriendshipMultiplier?: number; luckBoost?: number; upgradeUnlockedVouchers?: boolean; startDate: Date; endDate: Date; eventEncounters?: EventEncounter[]; delibirdyBuff?: string[]; weather?: WeatherPoolEntry[]; mysteryEncounterTierChanges?: EventMysteryEncounterTier[]; luckBoostedSpecies?: Species[]; } const timedEvents: TimedEvent[] = [ { name: "Winter Holiday Update", eventType: EventType.SHINY, shinyMultiplier: 2, upgradeUnlockedVouchers: true, startDate: new Date(Date.UTC(2024, 11, 21, 0)), endDate: new Date(Date.UTC(2025, 0, 4, 0)), bannerKey: "winter_holidays2024-event-", scale: 0.21, availableLangs: [ "en", "de", "it", "fr", "ja", "ko", "es-ES", "pt-BR", "zh-CN" ], eventEncounters: [ { species: Species.GIMMIGHOUL, blockEvolution: true }, { species: Species.DELIBIRD }, { species: Species.STANTLER }, { species: Species.CYNDAQUIL }, { species: Species.PIPLUP }, { species: Species.CHESPIN }, { species: Species.BALTOY }, { species: Species.SNOVER }, { species: Species.CHINGLING }, { species: Species.LITWICK }, { species: Species.CUBCHOO }, { species: Species.SWIRLIX }, { species: Species.AMAURA }, { species: Species.MUDBRAY }, { species: Species.ROLYCOLY }, { species: Species.MILCERY }, { species: Species.SMOLIV }, { species: Species.ALOLA_VULPIX }, { species: Species.GALAR_DARUMAKA }, { species: Species.IRON_BUNDLE } ], delibirdyBuff: [ "CATCHING_CHARM", "SHINY_CHARM", "ABILITY_CHARM", "EXP_CHARM", "SUPER_EXP_CHARM", "HEALING_CHARM" ], weather: [{ weatherType: WeatherType.SNOW, weight: 1 }], mysteryEncounterTierChanges: [ { mysteryEncounter: MysteryEncounterType.DELIBIRDY, tier: MysteryEncounterTier.COMMON }, { mysteryEncounter: MysteryEncounterType.PART_TIMER, disable: true }, { mysteryEncounter: MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, disable: true }, { mysteryEncounter: MysteryEncounterType.FIELD_TRIP, disable: true }, { mysteryEncounter: MysteryEncounterType.DEPARTMENT_STORE_SALE, disable: true } ] }, { name: "Year of the Snake", eventType: EventType.LUCK, luckBoost: 1, startDate: new Date(Date.UTC(2025, 0, 29, 0)), endDate: new Date(Date.UTC(2025, 1, 3, 0)), bannerKey: "yearofthesnakeevent-", scale: 0.21, availableLangs: [], eventEncounters: [ { species: Species.EKANS }, { species: Species.ONIX }, { species: Species.DRATINI }, { species: Species.CLEFFA }, { species: Species.UMBREON }, { species: Species.DUNSPARCE }, { species: Species.TEDDIURSA }, { species: Species.SEVIPER }, { species: Species.LUNATONE }, { species: Species.CHINGLING }, { species: Species.SNIVY }, { species: Species.DARUMAKA }, { species: Species.DRAMPA }, { species: Species.SILICOBRA }, { species: Species.BLOODMOON_URSALUNA } ], luckBoostedSpecies: [ Species.EKANS, Species.ARBOK, Species.ONIX, Species.STEELIX, Species.DRATINI, Species.DRAGONAIR, Species.DRAGONITE, Species.CLEFFA, Species.CLEFAIRY, Species.CLEFABLE, Species.UMBREON, Species.DUNSPARCE, Species.DUDUNSPARCE, Species.TEDDIURSA, Species.URSARING, Species.URSALUNA, Species.SEVIPER, Species.LUNATONE, Species.RAYQUAZA, Species.CHINGLING, Species.CHIMECHO, Species.CRESSELIA, Species.DARKRAI, Species.SNIVY, Species.SERVINE, Species.SERPERIOR, Species.DARUMAKA, Species.DARMANITAN, Species.ZYGARDE, Species.DRAMPA, Species.LUNALA, Species.BLACEPHALON, Species.SILICOBRA, Species.SANDACONDA, Species.ROARING_MOON, Species.BLOODMOON_URSALUNA ] } ]; export class TimedEventManager { constructor() {} isActive(event: TimedEvent) { return ( event.startDate < new Date() && new Date() < event.endDate ); } activeEvent(): TimedEvent | undefined { return timedEvents.find((te: TimedEvent) => this.isActive(te)); } isEventActive(): boolean { return timedEvents.some((te: TimedEvent) => this.isActive(te)); } activeEventHasBanner(): boolean { const activeEvents = timedEvents.filter((te) => this.isActive(te) && te.hasOwnProperty("bannerFilename")); return activeEvents.length > 0; } getShinyMultiplier(): number { let multiplier = 1; const shinyEvents = timedEvents.filter((te) => te.eventType === EventType.SHINY && this.isActive(te)); shinyEvents.forEach((se) => { multiplier *= se.shinyMultiplier ?? 1; }); return multiplier; } getEventBannerFilename(): string { return timedEvents.find((te: TimedEvent) => this.isActive(te))?.bannerKey ?? ""; } getEventEncounters(): EventEncounter[] { const ret: EventEncounter[] = []; timedEvents.filter((te) => this.isActive(te)).map((te) => { if (!isNullOrUndefined(te.eventEncounters)) { ret.push(...te.eventEncounters); } }); return ret; } /** * For events that change the classic candy friendship multiplier * @returns The highest classic friendship multiplier among the active events, or the default CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER */ getClassicFriendshipMultiplier(): number { let multiplier = CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER; const classicFriendshipEvents = timedEvents.filter((te) => this.isActive(te)); classicFriendshipEvents.forEach((fe) => { if (!isNullOrUndefined(fe.classicFriendshipMultiplier) && fe.classicFriendshipMultiplier > multiplier) { multiplier = fe.classicFriendshipMultiplier; } }); return multiplier; } /** * For events where defeated bosses (Gym Leaders, E4 etc) give out Voucher Plus even if they were defeated before * @returns Whether vouchers should be upgraded */ getUpgradeUnlockedVouchers(): boolean { return timedEvents.some((te) => this.isActive(te) && (te.upgradeUnlockedVouchers ?? false)); } /** * For events where Delibirdy gives extra items * @returns list of ids of {@linkcode ModifierType}s that Delibirdy hands out as a bonus */ getDelibirdyBuff(): string[] { const ret: string[] = []; timedEvents.filter((te) => this.isActive(te)).map((te) => { if (!isNullOrUndefined(te.delibirdyBuff)) { ret.push(...te.delibirdyBuff); } }); return ret; } /** * For events where there's a set weather for town biome (other biomes are hard) * @returns Event weathers for town */ getWeather(): WeatherPoolEntry[] { const ret: WeatherPoolEntry[] = []; timedEvents.filter((te) => this.isActive(te)).map((te) => { if (!isNullOrUndefined(te.weather)) { ret.push(...te.weather); } }); return ret; } getAllMysteryEncounterChanges(): EventMysteryEncounterTier[] { const ret: EventMysteryEncounterTier[] = []; timedEvents.filter((te) => this.isActive(te)).map((te) => { if (!isNullOrUndefined(te.mysteryEncounterTierChanges)) { ret.push(...te.mysteryEncounterTierChanges); } }); return ret; } getEventMysteryEncountersDisabled(): MysteryEncounterType[] { const ret: MysteryEncounterType[] = []; timedEvents.filter((te) => this.isActive(te) && !isNullOrUndefined(te.mysteryEncounterTierChanges)).map((te) => { te.mysteryEncounterTierChanges?.map((metc) => { if (metc.disable) { ret.push(metc.mysteryEncounter); } }); }); return ret; } getMysteryEncounterTierForEvent(encounterType: MysteryEncounterType, normal: MysteryEncounterTier): MysteryEncounterTier { let ret = normal; timedEvents.filter((te) => this.isActive(te) && !isNullOrUndefined(te.mysteryEncounterTierChanges)).map((te) => { te.mysteryEncounterTierChanges?.map((metc) => { if (metc.mysteryEncounter === encounterType) { ret = metc.tier ?? normal; } }); }); return ret; } getEventLuckBoost(): number { let ret = 0; const luckEvents = timedEvents.filter((te) => this.isActive(te) && !isNullOrUndefined(te.luckBoost)); luckEvents.forEach((le) => { ret += le.luckBoost!; }); return ret; } getEventLuckBoostedSpecies(): Species[] { const ret: Species[] = []; timedEvents.filter((te) => this.isActive(te)).map((te) => { if (!isNullOrUndefined(te.luckBoostedSpecies)) { ret.push(...te.luckBoostedSpecies.filter(s => !ret.includes(s))); } }); return ret; } } export class TimedEventDisplay extends Phaser.GameObjects.Container { private event: TimedEvent | nil; private eventTimerText: Phaser.GameObjects.Text; private banner: Phaser.GameObjects.Image; private availableWidth: number; private eventTimer: NodeJS.Timeout | null; constructor(x: number, y: number, event?: TimedEvent) { super(globalScene, x, y); this.availableWidth = globalScene.scaledCanvas.width; this.event = event; this.setVisible(false); } /** * Set the width that can be used to display the event timer and banner. By default * these elements get centered horizontally in that space, in the bottom left of the screen */ setWidth(width: number) { if (width !== this.availableWidth) { this.availableWidth = width; const xPosition = this.availableWidth / 2 + (this.event?.xOffset ?? 0); if (this.banner) { this.banner.x = xPosition; } if (this.eventTimerText) { this.eventTimerText.x = xPosition; } } } setup() { const lang = i18next.resolvedLanguage; if (this.event && this.event.bannerKey) { let key = this.event.bannerKey; if (lang && this.event.availableLangs && this.event.availableLangs.length > 0) { if (this.event.availableLangs.includes(lang)) { key += lang; } else { key += "en"; } } console.log(this.event.bannerKey); const padding = 5; const showTimer = this.event.eventType !== EventType.NO_TIMER_DISPLAY; const yPosition = globalScene.game.canvas.height / 6 - padding - (showTimer ? 10 : 0) - (this.event.yOffset ?? 0); this.banner = new Phaser.GameObjects.Image(globalScene, this.availableWidth / 2, yPosition - padding, key); this.banner.setName("img-event-banner"); this.banner.setOrigin(0.5, 1); this.banner.setScale(this.event.scale ?? 0.18); if (showTimer) { this.eventTimerText = addTextObject( this.banner.x, this.banner.y + 2, this.timeToGo(this.event.endDate), TextStyle.WINDOW ); this.eventTimerText.setName("text-event-timer"); this.eventTimerText.setScale(0.15); this.eventTimerText.setOrigin(0.5, 0); this.add(this.eventTimerText); } this.add(this.banner); } } show() { this.setVisible(true); this.updateCountdown(); this.eventTimer = setInterval(() => { this.updateCountdown(); }, 1000); } clear() { this.setVisible(false); this.eventTimer && clearInterval(this.eventTimer); this.eventTimer = null; } private timeToGo(date: Date) { // Utility to add leading zero function z(n) { return (n < 10 ? "0" : "") + n; } const now = new Date(); let diff = Math.abs(date.getTime() - now.getTime()); // Allow for previous times diff = Math.abs(diff); // Get time components const days = diff / 8.64e7 | 0; const hours = diff % 8.64e7 / 3.6e6 | 0; const mins = diff % 3.6e6 / 6e4 | 0; const secs = Math.round(diff % 6e4 / 1e3); // Return formatted string return i18next.t("menu:eventTimer", { days: z(days), hours: z(hours), mins: z(mins), secs: z(secs) }); } updateCountdown() { if (this.event && this.event.eventType !== EventType.NO_TIMER_DISPLAY) { this.eventTimerText.setText(this.timeToGo(this.event.endDate)); } } }