blob: 852742eb409d9c2722ab3ac032bfbdc3e660e69b [file] [log] [blame]
#include <cmath>
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <list>
#include "launcher.hpp"
#include "assets.hpp"
#include "engine/api_private.hpp"
#include "graphics/color.hpp"
#include "executable.hpp"
#include "metadata.hpp"
#include "dialog.hpp"
#include "theme.hpp"
#include "credits.hpp"
using namespace blit;
struct PathSave {
char last_path[512];
};
static const int path_save_slot = 256;
static Dialog dialog;
const Font launcher_font(font8x8);
constexpr uint32_t qspi_flash_sector_size = 64 * 1024;
static Screen current_screen = Screen::main;
static bool show_fps = false;
static bool sd_detected = true;
static Vec2 file_list_scroll_offset(10.0f, 0.0f);
static Point game_info_offset(120, 20);
static Point game_actions_offset(game_info_offset.x + 128 + 8, 28);
static float directory_list_scroll_offset = 0.0f;
static std::vector<GameInfo> game_list;
static std::list<DirectoryInfo> directory_list;
static std::list<DirectoryInfo>::iterator current_directory;
static SortBy file_sort = SortBy::name;
static GameInfo selected_game;
static BlitGameMetadata selected_game_metadata;
static Surface *spritesheet;
static Surface *screenshot;
static AutoRepeat ar_button_up(250, 600);
static AutoRepeat ar_button_down(250, 600);
static AutoRepeat ar_button_left(0, 0);
static AutoRepeat ar_button_right(0, 0);
#ifndef PICO_BUILD
static uint8_t screenshot_buf[320 * 240 * 3];
#endif
static int calc_num_blocks(uint32_t size) {
return (size - 1) / qspi_flash_sector_size + 1;
}
// insertion sort
template <class Iterator, class Compare>
static void insertion_sort(Iterator first, Iterator last, Compare comp) {
if(last - first < 2)
return;
for(auto it = first + 1; it != last; ++it) {
auto temp = it;
while(temp != first && comp(*temp, *(temp - 1))) {
std::swap(*temp, *(temp - 1));
--temp;
}
}
}
static bool parse_file_metadata(const std::string &filename, BlitGameMetadata &metadata, bool unpack_images = false) {
blit::File f(filename);
if(!f.is_open())
return false;
uint32_t offset = 0;
uint8_t buf[sizeof(BlitGameHeader)];
auto read = f.read(offset, sizeof(buf), (char *)&buf);
// skip relocation data
if(memcmp(buf, "RELO", 4) == 0) {
uint32_t num_relocs;
f.read(4, 4, (char *)&num_relocs);
offset = num_relocs * 4 + 8;
// re-read header
read = f.read(offset, sizeof(buf), (char *)&buf);
}
// game header - skip to metadata
if(memcmp(buf, "BLITMETA", 8) != 0) {
auto &header = *(BlitGameHeader *)buf;
if(read == sizeof(BlitGameHeader) && header.magic == blit_game_magic) {
offset += (header.end & 0x1FFFFFF);
read = f.read(offset, 10, (char *)buf);
}
}
if(read >= 10 && memcmp(buf, "BLITMETA", 8) == 0) {
// don't bother reading the whole thing if we don't want the images
auto metadata_len = unpack_images ? *reinterpret_cast<uint16_t *>(buf + 8) : sizeof(RawMetadata);
uint8_t metadata_buf[0xFFFF];
f.read(offset + 10, metadata_len, (char *)metadata_buf);
parse_metadata(reinterpret_cast<char *>(metadata_buf), metadata_len, metadata, unpack_images);
return true;
}
return false;
}
static void sort_file_list() {
using Iterator = std::vector<GameInfo>::iterator;
using Compare = bool(const GameInfo &, const GameInfo &);
if (file_sort == SortBy::name) {
// Sort by filename
insertion_sort<Iterator, Compare>(game_list.begin(), game_list.end(), [](const auto &a, const auto &b) { return a.title < b.title; });
}
if (file_sort == SortBy::size) {
// Sort by filesize
insertion_sort<Iterator, Compare>(game_list.begin(), game_list.end(), [](const auto &a, const auto &b) { return a.size < b.size; });
}
}
static void load_file_list(const std::string &directory) {
game_list.clear();
auto files = list_files(directory, [&](auto &file) {
if(file.flags & FileFlags::directory)
return false;
if(file.name[0] == '.') // hidden file
return false;
auto path = directory == "/" ? file.name : directory + "/" + file.name;
auto res = api.can_launch(path.c_str());
if(res == CanLaunchResult::UnknownType) {
// special case for images
auto last_dot = file.name.find_last_of('.');
auto ext = last_dot == std::string::npos ? "" : file.name.substr(last_dot + 1);
for(auto &c : ext)
c = tolower(c);
if(ext == "bmp" || ext == "blim")
return true;
}
// will filter incompatible later
return res == CanLaunchResult::Success || res == CanLaunchResult::IncompatibleBlit;
});
game_list.reserve(files.size()); // worst case
for(auto &file : files) {
auto last_dot = file.name.find_last_of('.');
auto ext = last_dot == std::string::npos ? "" : file.name.substr(last_dot + 1);
for(auto &c : ext)
c = tolower(c);
GameInfo game;
game.title = file.name.substr(0, file.name.length() - ext.length() - 1);
game.filename = directory == "/" ? file.name : directory + "/" + file.name;
game.size = file.size;
if(ext == "blit") {
game.type = GameType::game;
game.can_launch = api.can_launch(game.filename.c_str()) == CanLaunchResult::Success;
// check for metadata
BlitGameMetadata meta;
if(parse_file_metadata(game.filename, meta))
game.title = meta.title;
} else if(ext == "bmp" || ext == "blim") {
game.type = GameType::screenshot;
// Special case check for an installed handler for these types, ie: a sprite editor
game.can_launch = api.get_type_handler_metadata && api.get_type_handler_metadata(ext.c_str());
} else {
// it's launch-able so there must be a handler
game.type = GameType::file;
strncpy(game.ext, ext.c_str(), 5);
game.ext[4] = 0;
game.can_launch = true;
// check for a metadata file (fall back to handler's metadata)
BlitGameMetadata meta;
auto meta_filename = game.filename + ".blmeta";
if(parse_file_metadata(meta_filename, meta))
game.title = meta.title;
}
game_list.push_back(game);
}
int total_items = (int)game_list.size();
if(selected_menu_item >= total_items)
selected_menu_item = std::max(0, total_items - 1);
// probably doesn't do anything...
game_list.shrink_to_fit();
sort_file_list();
}
static void load_directory_list(const std::string &directory) {
directory_list.clear();
auto dir_filter = [](const FileInfo &info){
if(!(info.flags & FileFlags::directory))
return false;
if(info.name.compare("System Volume Information") == 0 || info.name[0] == '.')
return false;
return true;
};
for(auto &folder : ::list_files(directory, dir_filter))
directory_list.push_back({folder.name, 0, 0});
directory_list.sort([](const auto &a, const auto &b) { return a.name > b.name; });
directory_list.push_front({"/", 0, 0});
directory_list.push_front({"flash:", 0, 0});
// measure positions
int x = 0;
for(auto &dir : directory_list) {
dir.x = x;
dir.w = screen.measure_text(dir.name == "/" ? "ROOT" : dir.name, launcher_font).w;
x += dir.w + 10;
}
}
static void load_current_game_metadata() {
bool loaded = false;
if(!game_list.empty()) {
selected_game = game_list[selected_menu_item];
if(selected_game.type == GameType::file) {
// not a .blit - look for a metadata file
auto meta_filename = selected_game.filename + ".blmeta";
if(!parse_file_metadata(meta_filename, selected_game_metadata, true)) {
// fallback to handler metadata/placeholders
auto handler_meta = (char *)api.get_type_handler_metadata(selected_game.ext);
auto len = *reinterpret_cast<uint16_t *>(handler_meta + 8);
parse_metadata(handler_meta + 10, len, selected_game_metadata, true);
selected_game_metadata.description = "Launches with: " + selected_game_metadata.title;
selected_game_metadata.title = selected_game.title;
selected_game_metadata.author = "";
selected_game_metadata.version = "";
}
loaded = true;
} else
loaded = parse_file_metadata(selected_game.filename, selected_game_metadata, true);
}
#ifndef PICO_BUILD
if(selected_game.type == GameType::screenshot) {
// Free any old buffers
if(screenshot) {
delete[] screenshot->palette;
delete screenshot;
screenshot = nullptr;
}
// Load the new screenshot
screenshot = Surface::load(selected_game.filename, screenshot_buf, sizeof(screenshot_buf));
} else {
// Not showing a screenshot, free the buffers
if(screenshot) {
delete[] screenshot->palette;
delete screenshot;
screenshot = nullptr;
}
}
#endif
// no valid metadata, reset
if(!loaded) {
selected_game_metadata.free_surfaces();
selected_game_metadata = BlitGameMetadata();
}
}
static bool launch_current_game() {
// save last file launched
PathSave save{};
strncpy(save.last_path, selected_game.filename.c_str(), sizeof(save.last_path) - 1);
write_save(save, path_save_slot);
if(!api.launch)
return false;
return api.launch(selected_game.filename.c_str());
}
static void delete_current_game() {
dialog.show("Confirm", "Really delete " + selected_game.title + "?", [](bool yes){
if(yes) {
if(selected_game.filename.compare(0, 7, "flash:/") == 0)
api.erase_game(std::stoi(selected_game.filename.substr(7)) * qspi_flash_sector_size);
::remove_file(selected_game.filename);
load_file_list(current_directory->name);
load_current_game_metadata();
}
});
}
static void init_lists() {
load_directory_list("/");
current_directory = directory_list.begin();
load_file_list(current_directory->name);
load_current_game_metadata();
}
static void scan_flash() {
if(api.list_installed_games) {
api.list_installed_games([](const uint8_t *ptr, uint32_t block, uint32_t size){
File::add_buffer_file("flash:/" + std::to_string(block) + ".blit", ptr, size);
});
}
}
void init() {
set_screen_mode(ScreenMode::hires);
screen.clear();
// shrink the filename column on narrower screens
if(screen.bounds.w < game_actions_offset.x + 24) {
int diff = (game_actions_offset.x + 24) - screen.bounds.w;
game_info_offset.x -= diff;
game_actions_offset.x -= diff;
}
selected_menu_item = 0;
init_theme();
spritesheet = Surface::load(sprites);
scan_flash();
init_lists();
// restore previously selected file
PathSave save;
save.last_path[0] = 0;
if(read_save(save, path_save_slot)) {
auto path = std::string_view(save.last_path);
auto slash = path.find_first_of('/');
std::string_view dir;
if(slash == std::string_view::npos)
dir = "/";
else
dir = path.substr(0, slash);
// select dir
for(auto it = directory_list.begin(); it != directory_list.end(); ++it) {
if(it->name == dir) {
if(it != current_directory)
load_file_list(it->name);
current_directory = it;
break;
}
}
// select file
for(auto it = game_list.begin(); it != game_list.end(); ++it) {
if(it->filename == path) {
selected_menu_item = it - game_list.begin();
break;
}
}
load_current_game_metadata();
}
credits::prepare();
}
static void swoosh(uint32_t time, float t1, float t2, float s1, float s2, int t0, int offset_y=120, int size=60, int alpha=64) {
constexpr int swoosh_resolution = 32;
int w = (screen.bounds.w + swoosh_resolution - 1) / swoosh_resolution;
for(auto x = 0; x < w; x++) {
float t_a = (x / s1) + float(time + t0) / t1;
float t_b = (x / s2) + float(time + t0) / t2;
int y1 = sinf(t_a) * size;
int y2 = sinf(t_b) * size;
if(y1 > y2) std::swap(y1, y2);
y1 += offset_y;
y2 += offset_y + 2;
int range = y2 - y1;
for(auto y = 0; y <= range; y++) {
if(y > range / 2) {
screen.pen.a = alpha - (alpha * y / range);
} else {
screen.pen.a = alpha * y / range;
}
// This is an optimisation, not an aesthetic choice!
screen.h_span(Point(x * swoosh_resolution, y1 + y), swoosh_resolution);
}
}
}
static void render_fps(uint32_t us_start) {
if(!show_fps) return;
// draw FPS meter
uint32_t us_end = now_us();
uint32_t us_elapsed = us_diff(us_start, us_end);
screen.mask = nullptr;
screen.pen = Pen(0, 0, 0);
screen.rectangle(Rect(Point(0, screen.bounds.h - 14), Size(game_info_offset.x - 10, 14)));
screen.pen = Pen(255, 0, 0);
for (unsigned int i = 0; i < us_elapsed / 1000; i++) {
screen.pen = Pen(i * 5, 255 - (i * 5), 0);
screen.rectangle(Rect(i * 3 + 1, screen.bounds.h - 3, 2, 2));
}
screen.pen = Pen(255, 255, 255);
screen.text(std::to_string(us_elapsed), minimal_font, Point(0, screen.bounds.h - 12));
}
static void render_directory_list() {
// adjust alignment rect for vertical spacing
const int text_align_height = ROW_HEIGHT + launcher_font.spacing_y;
// list folders
if(directory_list.empty())
return;
int width = screen.bounds.w - game_info_offset.x - 10;
// darken behind if showing screenshot
if(screenshot) {
screen.pen = theme.color_background;
screen.pen.a = 150;
screen.rectangle(Rect(game_info_offset.x - 10, 0, width + 20, 20));
}
screen.clip = Rect(game_info_offset.x, 5, width, text_align_height);
for(auto &directory : directory_list) {
if(directory.name == current_directory->name)
screen.pen = theme.color_accent;
else
screen.pen = theme.color_text;
int x = 120 + (width / 2) + directory.x - directory_list_scroll_offset;
screen.text(directory.name == "/" ? "ROOT" : directory.name, launcher_font, Rect(x, 5, width, text_align_height), true, TextAlign::center_v);
}
screen.clip = Rect(Point(0, 0), screen.bounds);
}
static void render_file_list() {
// adjust alignment rect for vertical spacing
const int text_align_height = ROW_HEIGHT + launcher_font.spacing_y;
if(game_list.empty())
return;
// background
if(screenshot) {
// darken if showing screenshot
screen.pen = theme.color_background;
screen.pen.a = 150;
} else
screen.pen = theme.color_overlay;
screen.rectangle(Rect(0, 0, game_info_offset.x - 10, screen.bounds.h));
screen.clip = Rect(0, 0, game_info_offset.x - 20, screen.bounds.h);
int title_w = screen.clip.w - file_list_scroll_offset.x;
int y = (screen.bounds.h / 2) - 5 - file_list_scroll_offset.y;
int i = 0;
for(auto &file : game_list) {
if(i++ == selected_menu_item)
screen.pen = theme.color_accent;
else
screen.pen = theme.color_text;
screen.text(file.title, launcher_font, Rect(file_list_scroll_offset.x, y, title_w, text_align_height), true, TextAlign::center_v);
y += ROW_HEIGHT;
}
screen.clip = Rect(Point(0, 0), screen.bounds);
}
static void render_screenshot() {
if(screenshot->bounds.w == screen.bounds.w) {
// full screen image
screen.blit(screenshot, Rect(Point(0, 0), screenshot->bounds), Point(0, 0));
} else if(screenshot->bounds == Size(128, 128)) {
// standard spritesheet size, show in info column
screen.pen = Pen(0, 0, 0, 255);
screen.rectangle(Rect(game_info_offset, Size(128, 128)));
screen.blit(screenshot, Rect(Point(0, 0), screenshot->bounds), game_info_offset);
} else {
screen.stretch_blit(screenshot, Rect(Point(0, 0), screenshot->bounds), Rect(Point(0, 0), screen.bounds));
}
}
static void render_game_info() {
// run game / launch file
if(selected_game.can_launch) {
screen.sprite(1, Point(game_actions_offset.x, game_actions_offset.y + 12));
screen.sprite(0, Point(game_actions_offset.x + 10, game_actions_offset.y + 12), SpriteTransform::R90);
}
// game info
if(selected_game_metadata.splash)
screen.blit(selected_game_metadata.splash, Rect(Point(0, 0), selected_game_metadata.splash->bounds), game_info_offset);
screen.pen = theme.color_accent;
std::string wrapped_title = screen.wrap_text(selected_game_metadata.title, screen.bounds.w - game_info_offset.x - 10, launcher_font);
Size title_size = screen.measure_text(wrapped_title, launcher_font);
screen.text(wrapped_title, launcher_font, Point(game_info_offset.x, game_info_offset.y + 104));
Rect desc_rect(game_info_offset.x, game_info_offset.y + 108 + title_size.h, screen.bounds.w - game_info_offset.x - 10, 64);
screen.pen = theme.color_text;
std::string wrapped_desc = screen.wrap_text(selected_game_metadata.description, desc_rect.w, launcher_font);
screen.text(wrapped_desc, launcher_font, desc_rect);
screen.text(selected_game_metadata.author, minimal_font, Point(game_info_offset.x, screen.bounds.h - 32));
screen.text(selected_game_metadata.version, minimal_font, Point(game_info_offset.x, screen.bounds.h - 24));
int num_blocks = calc_num_blocks(selected_game.size);
char buf[20];
snprintf(buf, 20, "%i block%s", num_blocks, num_blocks == 1 ? "" : "s");
screen.text(buf, minimal_font, Point(game_info_offset.x, screen.bounds.h - 16));
if(!selected_game.can_launch) {
screen.pen = {255, 0, 0};
screen.text("INCOMPATIBLE", minimal_font, Point(screen.bounds.w - 10, screen.bounds.h - 16), true, TextAlign::top_right);
}
}
void render(uint32_t time) {
uint32_t us_start = now_us();
screen.sprites = spritesheet;
screen.pen = theme.color_background;
screen.clear();
// display background swoosh if not displaying a screenshot or the credits
if(current_screen != Screen::screenshot && current_screen != Screen::credits && selected_game.type != GameType::screenshot) {
screen.pen = Pen(255, 255, 255);
swoosh(time, 5100.0f, 3900.0f, 1900.0f, 900.0f, 3500);
screen.pen = theme.color_accent;
swoosh(time, 5000.0f, 3000.0f, 1000.0f, 1000.0f, 0);
screen.pen = Pen(~theme.color_accent.r, ~theme.color_accent.g, ~theme.color_accent.b);
swoosh(time, 5100.0f, 3900.0f, 900.0f, 1100.0f, 5000);
}
// display image preview
if(!game_list.empty() && screenshot)
render_screenshot();
// don't display lists over fullscreen screenshot
if(current_screen != Screen::screenshot) {
render_directory_list();
render_file_list();
}
// current file info/actions
if(!game_list.empty()) {
// delete
screen.sprite(2, Point(game_actions_offset.x, game_actions_offset.y));
screen.sprite(0, Point(game_actions_offset.x + 10, game_actions_offset.y));
if(selected_game.type == GameType::screenshot) {
if(screenshot && screenshot->bounds == Size(128, 128)) {
if(selected_game.can_launch){
// edit (in sprite editor, presumably)
screen.sprite(1, Point(game_actions_offset.x, game_actions_offset.y + 12));
screen.sprite(0, Point(game_actions_offset.x + 10, game_actions_offset.y + 12), SpriteTransform::R90);
}
} else if (current_screen == Screen::screenshot) {
// exit fullscreen
screen.sprite(5, Point(game_actions_offset.x, game_actions_offset.y + 12));
screen.sprite(0, Point(game_actions_offset.x + 10, game_actions_offset.y + 12), SpriteTransform::R180);
} else if(screenshot) {
// view screenshot fullscreen
screen.sprite(4, Point(game_actions_offset.x, game_actions_offset.y + 12));
screen.sprite(0, Point(game_actions_offset.x + 10, game_actions_offset.y + 12), SpriteTransform::R90);
}
} else {
render_game_info();
}
} else {
screen.pen = theme.color_text;
if(current_directory->name != "flash:" && !blit::is_storage_available())
screen.text("No SD Card\nDetected.", launcher_font, Point(screen.bounds.w / 2, screen.bounds.h / 2), true, TextAlign::center_center);
else
screen.text("No Games Found.", launcher_font, Point(screen.bounds.w / 2, screen.bounds.h / 2), true, TextAlign::center_center);
}
if (current_screen == Screen::credits) {
credits::render();
}
//progress.draw();
dialog.draw();
render_fps(us_start);
}
void update(uint32_t time) {
if(blit::is_storage_available() != sd_detected) {
init_lists();
sd_detected = blit::is_storage_available();
}
bool button_a = buttons.released & Button::A;
bool button_b = buttons.pressed & Button::B;
bool button_x = buttons.pressed & Button::X;
bool button_y = buttons.pressed & Button::Y;
bool button_menu = buttons.pressed & Button::MENU;
bool button_up = ar_button_up.next(time, buttons.state & Button::DPAD_UP || joystick.y < -0.2f);
bool button_down = ar_button_down.next(time, buttons.state & Button::DPAD_DOWN || joystick.y > 0.2f);
bool button_left = ar_button_left.next(time, buttons.state & Button::DPAD_LEFT || joystick.x < -0.5f);
bool button_right = ar_button_right.next(time, buttons.state & Button::DPAD_RIGHT || joystick.x > 0.5f);
if(dialog.update())
return;
// update/close credits
if (current_screen == Screen::credits) {
credits::update(time);
if (button_menu) {
current_screen = Screen::main;
}
if(button_y) {
show_fps = !show_fps;
}
return;
}
// display credits
if (button_menu) {
credits::reset_scrolling();
current_screen = Screen::credits;
}
// scroll through file list
int total_items = (int)game_list.size();
auto old_menu_item = selected_menu_item;
if(button_up) {
selected_menu_item--;
if(selected_menu_item < 0) {
selected_menu_item = total_items - 1;
}
}
if(button_down) {
selected_menu_item++;
if(selected_menu_item > total_items - 1) {
selected_menu_item = 0;
}
}
if(current_screen == Screen::screenshot) {
// b to exit full screen screenshot view
if(button_b) {
current_screen = Screen::main;
}
} else {
// scroll through directories
if(button_left) {
if(current_directory == directory_list.begin())
current_directory = --directory_list.end();
else
--current_directory;
}
if(button_right) {
current_directory++;
if(current_directory == directory_list.end()) {
current_directory = directory_list.begin();
}
}
// reload file list if dir changed
if(button_left || button_right) {
load_file_list(current_directory->name);
selected_menu_item = 0;
old_menu_item = -1;
}
// toggle sort mode
if (button_y) {
file_sort = file_sort == SortBy::name ? SortBy::size : SortBy::name;
sort_file_list();
}
}
// scroll list towards selected item
file_list_scroll_offset.y += ((selected_menu_item * ROW_HEIGHT) - file_list_scroll_offset.y) / 5.0f;
directory_list_scroll_offset += (current_directory->x + current_directory->w / 2 - directory_list_scroll_offset) / 5.0f;
if(game_list.empty())
return;
// load metadata for selected item
if(selected_menu_item != old_menu_item) {
load_current_game_metadata();
}
// paranoid bail out if you're browsing screenshots full screen and come across a game
if(selected_game.type != GameType::screenshot && current_screen == Screen::screenshot) {
current_screen = Screen::main;
}
// delete current game / screenshot
if(button_x) {
delete_current_game();
}
// launch game / show screenshot fullscreen
if(button_a) {
if(selected_game.type == GameType::screenshot && !selected_game.can_launch) {
current_screen = Screen::screenshot;
} else if(selected_game.can_launch) {
if(!launch_current_game())
dialog.show("Error!", "Failed to launch " + selected_game.filename, [](bool){}, false);
}
}
}