diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/c/event.c | 717 | ||||
| -rw-r--r-- | src/c/event.h | 44 | ||||
| -rw-r--r-- | src/c/gw2et.c | 131 |
3 files changed, 892 insertions, 0 deletions
diff --git a/src/c/event.c b/src/c/event.c new file mode 100644 index 0000000..779be43 --- /dev/null +++ b/src/c/event.c @@ -0,0 +1,717 @@ +/* + * Copyright (C) 2026 Reiner Herrmann + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "event.h" + +#define TIME2MIN(hh,mm) (hh * 60 + mm) + +#define ARRAY_SIZE(x) (sizeof(x)/sizeof(x[0])) + +static const struct Gw2Event event_list[] = { + /* Core Tyria */ + { + .name = "Admiral Taidha Covington", + .utc_offset = TIME2MIN(00,00), + .category = CORE, + .location = "Bloodtide Coast", + .schedule_hours = 3, + }, + { + .name = "Svanir Shaman Chief", + .utc_offset = TIME2MIN(00,15), + .category = CORE, + .location = "Wayfarer Foothills", + .schedule_hours = 2, + }, + { + .name = "Megadestroyer", + .utc_offset = TIME2MIN(00,30), + .category = CORE, + .location = "Mount Maelstrom", + .schedule_hours = 3, + }, + { + .name = "Fire Elemental", + .utc_offset = TIME2MIN(00,45), + .category = CORE, + .location = "Metrica Province", + .schedule_hours = 2, + }, + { + .name = "The Shatterer", + .utc_offset = TIME2MIN(01,00), + .category = CORE, + .location = "Blazeridge Steppes", + .schedule_hours = 3, + }, + { + .name = "Great Jungle Worm", + .utc_offset = TIME2MIN(01,15), + .category = CORE, + .location = "Caledon Forest", + .schedule_hours = 2, + }, + { + .name = "Modnir Ulgoth", + .utc_offset = TIME2MIN(01,30), + .category = CORE, + .location = "Harathi Hinterlands", + .schedule_hours = 3, + }, + { + .name = "Shadow Behemoth", + .utc_offset = TIME2MIN(01,45), + .category = CORE, + .location = "Queensdale", + .schedule_hours = 2, + }, + { + .name = "Golem Mark II", + .utc_offset = TIME2MIN(02,00), + .category = CORE, + .location = "Mount Maelstrom", + .schedule_hours = 3, + }, + { + .name = "Claw of Jormag", + .utc_offset = TIME2MIN(02,30), + .category = CORE, + .location = "Frostgorge Sound", + .schedule_hours = 3, + }, + { + .name = "Ley-Line Anomaly", + .utc_offset = TIME2MIN(00,20), + .category = CORE, + .location = "Timberline Falls", + .schedule_hours = 6, + }, + { + .name = "Ley-Line Anomaly", + .utc_offset = TIME2MIN(02,20), + .category = CORE, + .location = "Iron Marches", + .schedule_hours = 6, + }, + { + .name = "Ley-Line Anomaly", + .utc_offset = TIME2MIN(04,20), + .category = CORE, + .location = "Gendarran Fields", + .schedule_hours = 6, + }, + { + .name = "Fractal Incursion", + .utc_offset = TIME2MIN(00,00), + .category = CORE, + .location = "Kessex Hills", + .schedule_hours = 4, + }, + { + .name = "Fractal Incursion", + .utc_offset = TIME2MIN(01,00), + .category = CORE, + .location = "Diessa Plateau", + .schedule_hours = 4, + }, + { + .name = "Fractal Incursion", + .utc_offset = TIME2MIN(02,00), + .category = CORE, + .location = "Brisban Wildlands", + .schedule_hours = 4, + }, + { + .name = "Fractal Incursion", + .utc_offset = TIME2MIN(03,00), + .category = CORE, + .location = "Snowden Drifts", + .schedule_hours = 4, + }, + + /* Living World Season 1 */ + { + .name = "Twisted Marionette", + .utc_offset = TIME2MIN(00,00), + .category = LWS1, + .location = "Eye of the North", + .schedule_hours = 2, + }, + { + .name = "Battle for Lion's Arch", + .utc_offset = TIME2MIN(00,30), + .category = LWS1, + .location = "Eye of the North", + .schedule_hours = 2, + }, + { + .name = "Tower of Nightmares", + .utc_offset = TIME2MIN(01,30), + .category = LWS1, + .location = "Eye of the North", + .schedule_hours = 2, + }, + + /* Living World Season 2 */ + { + .name = "Sandstorm", + .utc_offset = TIME2MIN(00,40), + .category = LWS2, + .location = "Dry Top", + .schedule_hours = 1, + }, + + /* Living World Season 3 */ + { + .name = "New Loamhurst", + .utc_offset = TIME2MIN(01,45), + .category = LWS3, + .location = "Lake Doric", + .schedule_hours = 2, + }, + { + .name = "Noran's Homestead", + .utc_offset = TIME2MIN(00,30), + .category = LWS3, + .location = "Lake Doric", + .schedule_hours = 2, + }, + { + .name = "Saidra's Haven", + .utc_offset = TIME2MIN(01,00), + .category = LWS3, + .location = "Lake Doric", + .schedule_hours = 2, + }, + + /* Living World Season 4 */ + { + .name = "Palawadan", + .utc_offset = TIME2MIN(01,45), + .category = LWS4, + .location = "Domain of Istan", + .schedule_hours = 2, + }, + { + .name = "Death-Branded Shatterer", + .utc_offset = TIME2MIN(01,15), + .category = LWS4, + .location = "Jahai Bluffs", + .schedule_hours = 2, + }, + { + .name = "The Oil Floes", + .utc_offset = TIME2MIN(00,45), + .category = LWS4, + .location = "Thunderhead Peaks", + .schedule_hours = 2, + }, + { + .name = "Thunderhead Keep", + .utc_offset = TIME2MIN(01,45), + .category = LWS4, + .location = "Thunderhead Peaks", + .schedule_hours = 2, + }, + + /* Ice Brood Saga */ + { + .name = "Effigy", + .utc_offset = TIME2MIN(00,10), + .category = IBS, + .location = "Grothmar Valley", + .schedule_hours = 2, + }, + { + .name = "Doomlore Shrine", + .utc_offset = TIME2MIN(00,38), + .category = IBS, + .location = "Grothmar Valley", + .schedule_hours = 2, + }, + { + .name = "Ooze Pits", + .utc_offset = TIME2MIN(01,05), + .category = IBS, + .location = "Grothmar Valley", + .schedule_hours = 2, + }, + { + .name = "Metal Concert", + .utc_offset = TIME2MIN(01,40), + .category = IBS, + .location = "Grothmar Valley", + .schedule_hours = 2, + }, + { + .name = "Storms of Winter", + .utc_offset = TIME2MIN(01,45), + .category = IBS, + .location = "Bjora Marches", + .schedule_hours = 2, + }, + { + .name = "Drakkar", + .utc_offset = TIME2MIN(01,05), + .category = IBS, + .location = "Bjora Marches", + .schedule_hours = 2, + }, + + /* Heart of Thorns */ + { + .name = "Chak Gerent", + .utc_offset = TIME2MIN(00,30), + .category = HOT, + .location = "Tangled Depths", + .schedule_hours = 2, + }, + { + .name = "Octovine", + .utc_offset = TIME2MIN(01,00), + .category = HOT, + .location = "Auric Basin", + .schedule_hours = 2, + }, + { + .name = "Dragon's Stand", + .utc_offset = TIME2MIN(01,30), + .category = HOT, + .location = "Dragon's Stand", + .schedule_hours = 2, + }, + { + .name = "Night and the Enemy", + .utc_offset = TIME2MIN(01,45), + .category = HOT, + .location = "Verdant Brink", + .schedule_hours = 2, + }, + + /* Path of Fire */ + { + .name = "Casino Blitz", + .utc_offset = TIME2MIN(00,05), + .category = POF, + .location = "Crystal Oasis", + .schedule_hours = 2, + }, + { + .name = "Buried Treasure", + .utc_offset = TIME2MIN(01,00), + .category = POF, + .location = "Desert Highlands", + .schedule_hours = 2, + }, + { + .name = "The Path to Ascension", + .utc_offset = TIME2MIN(01,30), + .category = POF, + .location = "Elon Riverlands", + .schedule_hours = 2, + }, + { + .name = "Maws of Torment", + .utc_offset = TIME2MIN(01,00), + .category = POF, + .location = "The Desolation", + .schedule_hours = 2, + }, + { + .name = "Junundu Rising", + .utc_offset = TIME2MIN(00,30), + .category = POF, + .location = "The Desolation", + .schedule_hours = 1, + }, + { + .name = "Forged with Fire", + .utc_offset = TIME2MIN(00,00), + .category = POF, + .location = "Domain of Vabbi", + .schedule_hours = 1, + }, + { + .name = "Serpents' Ire", + .utc_offset = TIME2MIN(00,30), + .category = POF, + .location = "Domain of Vabbi", + .schedule_hours = 2, + }, + { + .name = "Dragonstorm", + .utc_offset = TIME2MIN(01,00), + .category = POF, + .location = "Eye of the North", + .schedule_hours = 2, + }, + + /* End of Dragons */ + { + .name = "Aetherblade Assault", + .utc_offset = TIME2MIN(01,30), + .category = EOD, + .location = "Seitung Province", + .schedule_hours = 2, + }, + { + .name = "Kaineng Blackout", + .utc_offset = TIME2MIN(00,00), + .category = EOD, + .location = "New Kaineng City", + .schedule_hours = 2, + }, + { + .name = "Gang War", + .utc_offset = TIME2MIN(00,30), + .category = EOD, + .location = "The Echovald Wilds", + .schedule_hours = 2, + }, + { + .name = "Aspenwood", + .utc_offset = TIME2MIN(01,40), + .category = EOD, + .location = "The Echovald Wilds", + .schedule_hours = 2, + }, + { + .name = "Battle for the Jade Sea", + .utc_offset = TIME2MIN(01,00), + .category = EOD, + .location = "Dragon's End", + .schedule_hours = 2, + }, + + /* Secrets of the Obscure */ + { + .name = "Unlocking the Wizard's Tower", + .utc_offset = TIME2MIN(01,00), + .category = SOTO, + .location = "Skywatch Archipelago", + .schedule_hours = 2, + }, + { + .name = "Defense of Amnytas", + .utc_offset = TIME2MIN(00,00), + .category = SOTO, + .location = "Amnytas", + .schedule_hours = 2, + }, + { + .name = "Outer Nayos", + .utc_offset = TIME2MIN(01,30), + .category = SOTO, + .location = "The Wizard's Tower", + .schedule_hours = 3, + }, + + /* Janthir Wilds */ + { + .name = "Of Mists and Monsters", + .utc_offset = TIME2MIN(00,30), + .category = JW, + .location = "Janthir Syntri", + .schedule_hours = 2, + }, + { + .name = "A Titanic Voyage", + .utc_offset = TIME2MIN(01,20), + .category = JW, + .location = "Bava Nisos", + .schedule_hours = 2, + }, + { + .name = "Mount Balrior", + .utc_offset = TIME2MIN(00,00), + .category = JW, + .location = "Lowland Shore", + .schedule_hours = 3, + }, + + /* Visions of Eternity */ + { + .name = "Hammerhart Rumble!", + .utc_offset = TIME2MIN(00,40), + .category = VOE, + .location = "Shipwreck Strand", + .schedule_hours = 2, + }, + { + .name = "Secrets of the Weald", + .utc_offset = TIME2MIN(01,40), + .category = VOE, + .location = "Starlit Weald", + .schedule_hours = 2, + }, +}; + +/* these events have no regular schedule */ +static const struct Gw2Event event_list_irregular[] = { + { + .name = "Tequatl the Sunless", + .utc_offset = TIME2MIN(00,00), + .category = CORE, + .location = "Sparkfly Fen", + .schedule_hours = 24, + }, + { + .name = "Tequatl the Sunless", + .utc_offset = TIME2MIN(03,00), + .category = CORE, + .location = "Sparkfly Fen", + .schedule_hours = 24, + }, + { + .name = "Tequatl the Sunless", + .utc_offset = TIME2MIN(07,00), + .category = CORE, + .location = "Sparkfly Fen", + .schedule_hours = 24, + }, + { + .name = "Tequatl the Sunless", + .utc_offset = TIME2MIN(11,30), + .category = CORE, + .location = "Sparkfly Fen", + .schedule_hours = 24, + }, + { + .name = "Tequatl the Sunless", + .utc_offset = TIME2MIN(16,00), + .category = CORE, + .location = "Sparkfly Fen", + .schedule_hours = 24, + }, + { + .name = "Tequatl the Sunless", + .utc_offset = TIME2MIN(19,00), + .category = CORE, + .location = "Sparkfly Fen", + .schedule_hours = 24, + }, + { + .name = "Karka Queen", + .utc_offset = TIME2MIN(02,00), + .category = CORE, + .location = "Southsun Cove", + .schedule_hours = 24, + }, + { + .name = "Karka Queen", + .utc_offset = TIME2MIN(06,00), + .category = CORE, + .location = "Southsun Cove", + .schedule_hours = 24, + }, + { + .name = "Karka Queen", + .utc_offset = TIME2MIN(10,30), + .category = CORE, + .location = "Southsun Cove", + .schedule_hours = 24, + }, + { + .name = "Karka Queen", + .utc_offset = TIME2MIN(15,00), + .category = CORE, + .location = "Southsun Cove", + .schedule_hours = 24, + }, + { + .name = "Karka Queen", + .utc_offset = TIME2MIN(19,00), + .category = CORE, + .location = "Southsun Cove", + .schedule_hours = 24, + }, + { + .name = "Karka Queen", + .utc_offset = TIME2MIN(23,00), + .category = CORE, + .location = "Southsun Cove", + .schedule_hours = 24, + }, + { + .name = "Triple Trouble", + .utc_offset = TIME2MIN(01,00), + .category = CORE, + .location = "Bloodtide Coast", + .schedule_hours = 24, + }, + { + .name = "Triple Trouble", + .utc_offset = TIME2MIN(04,00), + .category = CORE, + .location = "Bloodtide Coast", + .schedule_hours = 24, + }, + { + .name = "Triple Trouble", + .utc_offset = TIME2MIN(8,00), + .category = CORE, + .location = "Bloodtide Coast", + .schedule_hours = 24, + }, + { + .name = "Triple Trouble", + .utc_offset = TIME2MIN(12,30), + .category = CORE, + .location = "Bloodtide Coast", + .schedule_hours = 24, + }, + { + .name = "Triple Trouble", + .utc_offset = TIME2MIN(17,00), + .category = CORE, + .location = "Bloodtide Coast", + .schedule_hours = 24, + }, + { + .name = "Triple Trouble", + .utc_offset = TIME2MIN(20,00), + .category = CORE, + .location = "Bloodtide Coast", + .schedule_hours = 24, + }, +}; + +static struct Gw2Event *all_events = NULL; +static int all_events_count = 0; +static int event_index = 0; + +static inline uint16_t enabled_categories() { + /* TODO: make this mask configurable */ + return 0xffff; +} + +static inline bool is_category_enabled(enum Category category) { + return enabled_categories() & (1 << category); +} + +static int event_time_comp(const void *p1, const void *p2) { + const struct Gw2Event *event1 = p1; + const struct Gw2Event *event2 = p2; + + if (event1->utc_offset == event2->utc_offset) { + /* if they start at the same time, sort by category */ + return event1->category - event2->category; + } + + return (event1->utc_offset < event2->utc_offset) ? -1 : 1; +} + +void cleanup_events() { + if (all_events) { + free(all_events); + all_events = NULL; + } + all_events_count = 0; + event_index = 0; +} + +void init_events() { + cleanup_events(); + + /* count all events in a day */ + all_events_count = 0; + for (size_t i=0; i<ARRAY_SIZE(event_list); i++) { + if (!is_category_enabled(event_list[i].category)) { + continue; + } + all_events_count += 24 / event_list[i].schedule_hours; + } + for (size_t i=0; i<ARRAY_SIZE(event_list_irregular); i++) { + if (!is_category_enabled(event_list_irregular[i].category)) { + continue; + } + all_events_count++; + } + + all_events = calloc(all_events_count, sizeof(struct Gw2Event)); + + /* create all individual events */ + int pos = 0; + for (size_t i=0; i<ARRAY_SIZE(event_list); i++) { + if (!is_category_enabled(event_list[i].category)) { + continue; + } + const int daily_count = 24 / event_list[i].schedule_hours; + for (int j=0; j<daily_count; j++) { + memcpy(&all_events[pos], &event_list[i], sizeof(struct Gw2Event)); + all_events[pos].utc_offset += all_events[pos].schedule_hours * 60 * j; + pos++; + } + } + for (size_t i=0; i<ARRAY_SIZE(event_list_irregular); i++) { + if (!is_category_enabled(event_list_irregular[i].category)) { + continue; + } + memcpy(&all_events[pos], &event_list_irregular[i], sizeof(struct Gw2Event)); + pos++; + } + + /* sort list by time */ + qsort(all_events, all_events_count, sizeof(struct Gw2Event), event_time_comp); + + update_event_index(); +} + +const struct Gw2Event *get_next_event(int offset) { + offset = (offset + event_index) % all_events_count; + return &all_events[offset]; +} + +/* moves the index to the first event that is not yet running */ +void update_event_index() { + const time_t now = time(NULL); + const struct tm *utc = gmtime(&now); + const int utc_offset = utc->tm_hour * 60 + utc->tm_min; + + event_index = 0; + for (int i=0; i<all_events_count; i++) { + if (all_events[i].utc_offset >= utc_offset) { + event_index = i; + break; + } + } +} + +int minutes_to_event(const struct Gw2Event *event) { + const time_t now = time(NULL); + const struct tm *utc = gmtime(&now); + const int utc_offset = utc->tm_hour * 60 + utc->tm_min; + + return event->utc_offset - utc_offset; +} + +GColor8 color_for_category(enum Category category) { + switch(category) { + case CORE: + case LWS1: + return GColorDarkCandyAppleRed; + case LWS2: + return GColorLimerick; + case LWS3: + return GColorMayGreen; + case LWS4: + return GColorLavenderIndigo; + case IBS: + return GColorVividCerulean; + case HOT: + return GColorSpringBud; + case POF: + return GColorPurpureus; + case EOD: + return GColorTiffanyBlue; + case SOTO: + return GColorChromeYellow; + case JW: + return GColorBabyBlueEyes; + case VOE: + return GColorRajah; + } + return GColorFashionMagenta; +} diff --git a/src/c/event.h b/src/c/event.h new file mode 100644 index 0000000..b59a662 --- /dev/null +++ b/src/c/event.h @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 Reiner Herrmann + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#ifndef EVENT_H +#define EVENT_H + +#include <pebble.h> + +enum Category { + CORE = 0, + LWS1, + LWS2, + LWS3, + LWS4, + IBS, + HOT, + POF, + EOD, + SOTO, + JW, + VOE, +}; + +struct Gw2Event { + char name[32]; + uint16_t utc_offset; + enum Category category; + char location[32]; + uint8_t schedule_hours; + // uint16_t length; // TODO: to show currently running events +}; + +void init_events(); +void cleanup_events(); +void update_event_index(); + +const struct Gw2Event *get_next_event(int offset); +int minutes_to_event(const struct Gw2Event *event); + +GColor8 color_for_category(enum Category category); + +#endif diff --git a/src/c/gw2et.c b/src/c/gw2et.c new file mode 100644 index 0000000..e591868 --- /dev/null +++ b/src/c/gw2et.c @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2026 Reiner Herrmann + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include <pebble.h> +#include "event.h" + +#define DEFAULT_NUM_EVENTS 30 + +static Window *main_window; +static MenuLayer *main_menu; + +static uint16_t get_num_rows_callback(MenuLayer *menu_layer, uint16_t section_index, void *context) { + // TODO: make this configurable + return DEFAULT_NUM_EVENTS; +} + +static int16_t get_cell_height_callback(struct MenuLayer *menu_layer, MenuIndex *cell_index, void *context) { + // TODO: adjust for round displays (large in center, smaller above/below) + return 50; +} + +static void draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIndex *cell_index, void *context) { + const struct Gw2Event *event = get_next_event(cell_index->row); + + const int16_t timer_box_width = 40; + + /* event title and region */ + GRect box = layer_get_bounds(cell_layer); + box.size.w -= timer_box_width; + box.origin.x += timer_box_width; + box.size.h /= 2; + graphics_draw_text(ctx, event->name, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD), + box, GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, NULL); + box.origin.y += box.size.h; + graphics_draw_text(ctx, event->location, fonts_get_system_font(FONT_KEY_GOTHIC_18), + box, GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, NULL); + + /* color indicator for region */ +#if defined(PBL_COLOR) + GRect color_box = layer_get_bounds(cell_layer); + color_box.size.w = timer_box_width - 3; + const GColor8 category_color = color_for_category(event->category); + graphics_context_set_fill_color(ctx, category_color); + graphics_fill_rect(ctx, color_box, 0, 0); + graphics_context_set_text_color(ctx, gcolor_legible_over(category_color)); +#endif + + /* remaining minutes until start of event */ + const int minutes_remaining = minutes_to_event(event); + const int hours = minutes_remaining / 60; + const int minutes = minutes_remaining % 60; + char timebuf[16]; + snprintf(timebuf, sizeof(timebuf), "%02d:%02d", hours, minutes); + + GRect timer_box = layer_get_bounds(cell_layer); + timer_box.origin.x += 2; + timer_box.origin.y += 15; + timer_box.size.w = timer_box_width - 5; + timer_box.size.h -= 15; + graphics_draw_text(ctx, timebuf, fonts_get_system_font(FONT_KEY_GOTHIC_18), + timer_box, GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, NULL); +} + +static void draw_header_callback(GContext *ctx, const Layer *cell_layer, uint16_t section_index, void *callback_context) { + menu_cell_basic_header_draw(ctx, cell_layer, "Upcoming Events"); +} + +static int16_t get_header_height_callback(struct MenuLayer *menu_layer, uint16_t section_index, void *callback_context) { + return MENU_CELL_BASIC_HEADER_HEIGHT; +} + +static int16_t get_separator_height_callback(struct MenuLayer *menu_layer, MenuIndex *cell_index, void *callback_context) { + return 1; +} + +static void select_callback(struct MenuLayer *menu_layer, MenuIndex *cell_index, void *context) { +} + +static void main_window_load(Window *window) { + Layer *window_layer = window_get_root_layer(window); + const GRect bounds = layer_get_unobstructed_bounds(window_layer); + + main_menu = menu_layer_create(bounds); + menu_layer_set_click_config_onto_window(main_menu, window); + menu_layer_set_callbacks(main_menu, NULL, (MenuLayerCallbacks) { + .get_num_rows = get_num_rows_callback, + .draw_row = draw_row_callback, + .get_cell_height = get_cell_height_callback, + .get_header_height = get_header_height_callback, + .draw_header = draw_header_callback, + .select_click = select_callback, + .get_separator_height = get_separator_height_callback, + }); + + layer_add_child(window_layer, menu_layer_get_layer(main_menu)); +} + +static void main_window_unload(Window *window) { + menu_layer_destroy(main_menu); +} + +static void tick_handler(struct tm *tick_time, TimeUnits units_changed) { + update_event_index(); + menu_layer_reload_data(main_menu); +} + +static void init() { + main_window = window_create(); + window_set_window_handlers(main_window, (WindowHandlers) { + .load = main_window_load, + .unload = main_window_unload, + }); + window_stack_push(main_window, true); + + tick_timer_service_subscribe(MINUTE_UNIT, tick_handler); + init_events(); +} + +static void deinit() { + tick_timer_service_unsubscribe(); + window_destroy(main_window); + cleanup_events(); +} + +int main() { + init(); + app_event_loop(); + deinit(); +} |
