728 lines
31 KiB
C
728 lines
31 KiB
C
// MBMNET Shop - Homebrew für den 3DS, aus der Universal-DB (über den MBMNET-Server).
|
|
//
|
|
// Aufgebaut wie der eShop, in MBMNET-Farben:
|
|
// Startseite: oben großes Vorschaubild der gewählten App, unten Reiter und ein Raster aus Symbolen.
|
|
// App-Seite: oben die Screenshots (L/R blättert), unten großer Knopf "Kostenlos herunterladen",
|
|
// Infos und die Beschreibung.
|
|
#include <3ds.h>
|
|
#include <citro2d.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#include "pics.h"
|
|
#include "store.h"
|
|
|
|
#define TOP_W 400
|
|
#define BOT_W 320
|
|
#define SCR_H 240
|
|
|
|
#define COL_RED C2D_Color32(0xE4, 0x00, 0x2B, 0xFF)
|
|
#define COL_RED_DARK C2D_Color32(0x9E, 0x00, 0x1E, 0xFF)
|
|
#define COL_BG C2D_Color32(0x16, 0x19, 0x1F, 0xFF)
|
|
#define COL_BG2 C2D_Color32(0x22, 0x26, 0x2E, 0xFF)
|
|
#define COL_CARD C2D_Color32(0x2A, 0x2F, 0x39, 0xFF)
|
|
#define COL_CARD_HI C2D_Color32(0x3A, 0x41, 0x4E, 0xFF)
|
|
#define COL_WHITE C2D_Color32(0xFF, 0xFF, 0xFF, 0xFF)
|
|
#define COL_MUTED C2D_Color32(0x9A, 0xA3, 0xAF, 0xFF)
|
|
#define COL_GREEN C2D_Color32(0x22, 0xC5, 0x5E, 0xFF)
|
|
#define COL_AMBER C2D_Color32(0xF5, 0x9E, 0x0B, 0xFF)
|
|
#define COL_SHADE C2D_Color32(0x10, 0x12, 0x17, 0xD8)
|
|
#define COL_SHADE_LT C2D_Color32(0x10, 0x12, 0x17, 0x90)
|
|
|
|
// Startseite: Reiter und Symbol-Raster
|
|
#define TAB_H 26
|
|
#define GRID_Y (TAB_H + 4)
|
|
#define COLS 5
|
|
#define CELL_W 64
|
|
#define CELL_H 62
|
|
#define FULL_ROWS 3
|
|
|
|
// App-Seite (unterer Bildschirm)
|
|
#define DESC_Y 156
|
|
#define DESC_LINE_H 13
|
|
#define DESC_LINES 6
|
|
|
|
typedef struct { const char *label, *match, *name; } Tab;
|
|
static const Tab TABS[] = {
|
|
{ "Alle", NULL, "Alle Apps" }, { "Spiele", "game", "Spiele" }, { "Tools", "utility", "Tools" },
|
|
{ "Apps", "app", "Apps" }, { "Emus", "emulator", "Emulatoren" }, { "Medien", "media", "Medien" },
|
|
{ "Meine", "*installed", "Meine Apps" },
|
|
};
|
|
#define TAB_COUNT (int)(sizeof(TABS) / sizeof(TABS[0]))
|
|
#define SEARCH_W 34 // Lupe rechts in der Reiterleiste
|
|
#define TAB_W ((float)(BOT_W - SEARCH_W) / TAB_COUNT)
|
|
static const char *SORT_NAMES[] = { "nach Name", "beliebteste zuerst", "neueste zuerst" };
|
|
|
|
typedef struct { float x, y, w, h; } Box;
|
|
static const Box backBox = { 6, 8, 30, 46 };
|
|
static const Box actionBox = { 12, 70, 296, 40 }, actionBoxNarrow = { 12, 70, 208, 40 }, removeBox = { 226, 70, 82, 40 };
|
|
|
|
static C2D_TextBuf textBuf, measureBuf; // measureBuf nur zum Messen (wird jedes Mal geleert)
|
|
static C2D_SpriteSheet sheets[8];
|
|
|
|
// --- Zeichenhilfen ------------------------------------------------------------
|
|
|
|
static void text(const char *s, float x, float y, float size, u32 color, u32 flags)
|
|
{
|
|
C2D_Text t;
|
|
C2D_TextParse(&t, textBuf, s);
|
|
C2D_TextOptimize(&t);
|
|
C2D_DrawText(&t, C2D_WithColor | flags, x, y, 0.5f, size, size, color);
|
|
}
|
|
|
|
static float text_width(const char *s, float size)
|
|
{
|
|
C2D_Text t;
|
|
float w = 0;
|
|
C2D_TextBufClear(measureBuf);
|
|
C2D_TextParse(&t, measureBuf, s);
|
|
C2D_TextGetDimensions(&t, size, size, &w, NULL);
|
|
return w;
|
|
}
|
|
|
|
// Text auf eine Breite kürzen ("...")
|
|
static void fit(char *out, size_t len, const char *s, float size, float width)
|
|
{
|
|
snprintf(out, len, "%s", s);
|
|
if (text_width(out, size) <= width) return;
|
|
size_t n = strlen(out);
|
|
while (n > 1) {
|
|
n--;
|
|
while (n > 0 && (out[n] & 0xC0) == 0x80) n--; // nicht mitten in einem UTF-8-Zeichen
|
|
snprintf(out + n, len - n, "...");
|
|
if (text_width(out, size) <= width) return;
|
|
}
|
|
}
|
|
|
|
static void round_rect(float x, float y, float w, float h, float r, u32 color)
|
|
{
|
|
if (r > h / 2) r = h / 2;
|
|
C2D_DrawRectSolid(x + r, y, 0.5f, w - 2 * r, h, color);
|
|
C2D_DrawRectSolid(x, y + r, 0.5f, w, h - 2 * r, color);
|
|
C2D_DrawCircleSolid(x + r, y + r, 0.5f, r, color);
|
|
C2D_DrawCircleSolid(x + w - r, y + r, 0.5f, r, color);
|
|
C2D_DrawCircleSolid(x + r, y + h - r, 0.5f, r, color);
|
|
C2D_DrawCircleSolid(x + w - r, y + h - r, 0.5f, r, color);
|
|
}
|
|
|
|
static bool inside(Box b, touchPosition tp) { return tp.px >= b.x && tp.px < b.x + b.w && tp.py >= b.y && tp.py < b.y + b.h; }
|
|
|
|
// Einkaufswagen (wie das Symbol der App)
|
|
static void draw_cart(float x, float y, float size)
|
|
{
|
|
float s = size / 100.0f, t = 9 * s;
|
|
round_rect(x, y, size, size, 22 * s, COL_RED);
|
|
C2D_DrawLine(x + 13 * s, y + 23 * s, COL_WHITE, x + 23 * s, y + 23 * s, COL_WHITE, t, 0.5f);
|
|
C2D_DrawLine(x + 23 * s, y + 23 * s, COL_WHITE, x + 33 * s, y + 61 * s, COL_WHITE, t, 0.5f);
|
|
C2D_DrawLine(x + 33 * s, y + 61 * s, COL_WHITE, x + 30 * s, y + 70 * s, COL_WHITE, t, 0.5f);
|
|
C2D_DrawLine(x + 30 * s, y + 70 * s, COL_WHITE, x + 78 * s, y + 70 * s, COL_WHITE, t, 0.5f);
|
|
C2D_DrawTriangle(x + 27 * s, y + 33 * s, COL_WHITE, x + 84 * s, y + 33 * s, COL_WHITE, x + 77 * s, y + 61 * s, COL_WHITE, 0.5f);
|
|
C2D_DrawTriangle(x + 27 * s, y + 33 * s, COL_WHITE, x + 77 * s, y + 61 * s, COL_WHITE, x + 33 * s, y + 61 * s, COL_WHITE, 0.5f);
|
|
C2D_DrawCircleSolid(x + 37 * s, y + 80 * s, 0.5f, 7 * s, COL_WHITE);
|
|
C2D_DrawCircleSolid(x + 72 * s, y + 80 * s, 0.5f, 7 * s, COL_WHITE);
|
|
}
|
|
|
|
static void draw_icon(const App *a, float x, float y, float size)
|
|
{
|
|
C2D_SpriteSheet sh = a->sheet >= 0 && a->sheet < 8 ? sheets[a->sheet] : NULL;
|
|
if (sh && a->icon >= 0 && (size_t)a->icon < C2D_SpriteSheetCount(sh)) {
|
|
C2D_Image img = C2D_SpriteSheetGetImage(sh, a->icon);
|
|
float sc = size / (img.subtex->width ? img.subtex->width : 48);
|
|
C2D_DrawImageAt(img, x, y, 0.5f, NULL, sc, sc);
|
|
return;
|
|
}
|
|
// kein Symbol: Anfangsbuchstabe auf grauem Feld
|
|
round_rect(x, y, size, size, size / 6, COL_CARD_HI);
|
|
char first[8] = { 0 };
|
|
int n = 1;
|
|
while (n < 4 && (a->title[n] & 0xC0) == 0x80) n++;
|
|
memcpy(first, a->title, n);
|
|
text(first, x + size / 2, y + size * 0.2f, size / 48.0f, COL_WHITE, C2D_AlignCenter);
|
|
}
|
|
|
|
static void size_text(char *out, size_t len, u32 bytes)
|
|
{
|
|
if (bytes >= 1024 * 1024) snprintf(out, len, "%.1f MB", bytes / (1024.0 * 1024.0));
|
|
else if (bytes) snprintf(out, len, "%lu KB", (unsigned long)((bytes + 1023) / 1024));
|
|
else snprintf(out, len, "?");
|
|
}
|
|
|
|
static const char *category_name(const char *c)
|
|
{
|
|
if (!strcmp(c, "game")) return "Spiel";
|
|
if (!strcmp(c, "emulator")) return "Emulator";
|
|
if (!strcmp(c, "media")) return "Medien";
|
|
if (!strcmp(c, "save-tool")) return "Spielstände";
|
|
if (!strcmp(c, "plugin")) return "Plugin";
|
|
if (!strcmp(c, "utility")) return "Tool";
|
|
return "App";
|
|
}
|
|
|
|
static u32 app_color(const App *a, float factor)
|
|
{
|
|
u32 c = a->colorBg ? a->colorBg : 0x3A414E;
|
|
int r = ((c >> 16) & 255) * factor, g = ((c >> 8) & 255) * factor, b = (c & 255) * factor;
|
|
return C2D_Color32(r > 255 ? 255 : r, g > 255 ? 255 : g, b > 255 ? 255 : b, 0xFF);
|
|
}
|
|
|
|
// --- Liste (gefiltert und sortiert) ---------------------------------------------
|
|
|
|
static int *view; // Indizes in den Katalog
|
|
static int viewCount;
|
|
static int sortMode;
|
|
static char search[64]; // Suchbegriff (leer = keine Suche)
|
|
|
|
// Enthält text den Suchbegriff? (Groß-/Kleinschreibung egal)
|
|
static bool contains(const char *text, const char *needle)
|
|
{
|
|
if (!text) return false;
|
|
size_t n = strlen(needle);
|
|
for (; *text; text++)
|
|
if (strncasecmp(text, needle, n) == 0) return true;
|
|
return false;
|
|
}
|
|
|
|
static bool matches_search(const App *a)
|
|
{
|
|
return !search[0] || contains(a->title, search) || contains(a->author, search) || contains(a->description, search);
|
|
}
|
|
|
|
static int cmp_name(const void *a, const void *b)
|
|
{
|
|
return strcasecmp(catalog_app(*(const int *)a)->title, catalog_app(*(const int *)b)->title);
|
|
}
|
|
static int cmp_stars(const void *a, const void *b)
|
|
{
|
|
const App *x = catalog_app(*(const int *)a), *y = catalog_app(*(const int *)b);
|
|
if (x->stars != y->stars) return x->stars > y->stars ? -1 : 1;
|
|
return strcasecmp(x->title, y->title);
|
|
}
|
|
static int cmp_new(const void *a, const void *b)
|
|
{
|
|
const App *x = catalog_app(*(const int *)a), *y = catalog_app(*(const int *)b);
|
|
int c = strcmp(y->updated, x->updated);
|
|
return c ? c : strcasecmp(x->title, y->title);
|
|
}
|
|
|
|
static bool in_tab(const App *a, int tab)
|
|
{
|
|
const char *m = TABS[tab].match;
|
|
if (!m) return true;
|
|
if (strcmp(m, "*installed") == 0) return a->installed;
|
|
if (strcmp(m, "utility") == 0)
|
|
return !strcmp(a->category, "utility") || !strcmp(a->category, "save-tool") || !strcmp(a->category, "plugin");
|
|
return strcmp(a->category, m) == 0;
|
|
}
|
|
|
|
static void rebuild_view(int tab)
|
|
{
|
|
int n = catalog_count();
|
|
free(view);
|
|
view = malloc(sizeof(int) * (n ? n : 1));
|
|
viewCount = 0;
|
|
for (int i = 0; i < n; i++)
|
|
if (in_tab(catalog_app(i), tab) && matches_search(catalog_app(i))) view[viewCount++] = i;
|
|
qsort(view, viewCount, sizeof(int), sortMode == 1 ? cmp_stars : sortMode == 2 ? cmp_new : cmp_name);
|
|
}
|
|
|
|
// --- Beschreibung in Zeilen umbrechen (einmal beim Öffnen der App-Seite) ----------
|
|
|
|
#define MAX_LINES 64
|
|
static char descLines[MAX_LINES][96];
|
|
static int descLineCount;
|
|
|
|
static void wrap_description(const App *a, float size, float width)
|
|
{
|
|
descLineCount = 0;
|
|
const char *p = a->description ? a->description : "";
|
|
char line[96] = "";
|
|
while (*p && descLineCount < MAX_LINES) {
|
|
while (*p == ' ') p++;
|
|
const char *end = p;
|
|
while (*end && *end != ' ') end++;
|
|
char candidate[96];
|
|
int wordLen = end - p;
|
|
if (wordLen > 80) wordLen = 80;
|
|
if (line[0]) snprintf(candidate, sizeof(candidate), "%s %.*s", line, wordLen, p);
|
|
else snprintf(candidate, sizeof(candidate), "%.*s", wordLen, p);
|
|
if (line[0] && (strlen(candidate) >= sizeof(line) - 1 || text_width(candidate, size) > width)) {
|
|
snprintf(descLines[descLineCount++], sizeof(descLines[0]), "%s", line);
|
|
snprintf(line, sizeof(line), "%.*s", wordLen, p);
|
|
} else {
|
|
snprintf(line, sizeof(line), "%s", candidate);
|
|
}
|
|
p = end;
|
|
}
|
|
if (line[0] && descLineCount < MAX_LINES) snprintf(descLines[descLineCount++], sizeof(descLines[0]), "%s", line);
|
|
}
|
|
|
|
// --- Oberer Bildschirm --------------------------------------------------------------
|
|
|
|
// Großes Bild der App: Screenshot/Banner vom Server, sonst ihre Farbe mit großem Symbol
|
|
static void draw_hero(const App *a, int which)
|
|
{
|
|
C2D_Image img;
|
|
PicState st = PIC_MISSING;
|
|
if (which >= 0 && which < a->shots) st = pics_get(a->slug, which, &img);
|
|
else if (a->banner) st = pics_get(a->slug, PIC_BANNER, &img);
|
|
if (st == PIC_READY) {
|
|
C2D_DrawImageAt(img, 0, 0, 0.5f, NULL, 1.0f, 1.0f);
|
|
return;
|
|
}
|
|
C2D_DrawRectangle(0, 0, 0.5f, TOP_W, SCR_H, app_color(a, 1.0f), app_color(a, 1.0f), app_color(a, 0.45f), app_color(a, 0.45f));
|
|
// Symbol groß, leicht versetzt, mit dezentem Schatten
|
|
round_rect(TOP_W / 2 - 54, 34, 108, 108, 16, COL_SHADE_LT);
|
|
draw_icon(a, TOP_W / 2 - 48, 38, 96);
|
|
if (st == PIC_LOADING) text("Lade Bild ...", TOP_W - 10, 30, 0.38f, COL_WHITE, C2D_AlignRight);
|
|
}
|
|
|
|
static void draw_top_bar(const char *left, const char *right)
|
|
{
|
|
C2D_DrawRectSolid(0, 0, 0.5f, TOP_W, 22, COL_SHADE);
|
|
draw_cart(6, 3, 16);
|
|
text(left, 28, 4, 0.45f, COL_WHITE, 0);
|
|
text(right, TOP_W - 8, 5, 0.38f, COL_MUTED, C2D_AlignRight);
|
|
}
|
|
|
|
static void draw_price_pill(const App *a, float right, float y)
|
|
{
|
|
AppStatus s = app_status(a);
|
|
const char *label = s == APP_NEW ? "Kostenlos" : s == APP_UPDATE ? "Update" : "Installiert";
|
|
u32 col = s == APP_NEW ? COL_RED : s == APP_UPDATE ? COL_AMBER : COL_GREEN;
|
|
float w = text_width(label, 0.5f) + 22;
|
|
round_rect(right - w, y, w, 24, 12, col);
|
|
text(label, right - w / 2, y + 4, 0.5f, s == APP_NEW ? COL_WHITE : COL_BG, C2D_AlignCenter);
|
|
}
|
|
|
|
// Infozeile: Version, Größe, Art, Kategorie
|
|
static void info_line(char *out, size_t len, const App *a)
|
|
{
|
|
char size[16];
|
|
size_text(size, sizeof(size), a->size);
|
|
snprintf(out, len, "%s · %s · %s · %s", a->version[0] ? a->version : "ohne Version", size,
|
|
category_name(a->category), strcmp(a->kind, "cia") == 0 ? "HOME-Menü" : "Homebrew Launcher");
|
|
}
|
|
|
|
static void draw_toast(const char *msg, u32 color)
|
|
{
|
|
if (!msg[0]) return;
|
|
float w = 0, h = 0;
|
|
C2D_Text t;
|
|
C2D_TextBufClear(measureBuf);
|
|
C2D_TextParse(&t, measureBuf, msg);
|
|
C2D_TextGetDimensions(&t, 0.46f, 0.46f, &w, &h);
|
|
if (w > TOP_W - 40) w = TOP_W - 40;
|
|
round_rect(TOP_W / 2 - w / 2 - 12, 30, w + 24, h + 12, 10, COL_SHADE);
|
|
C2D_DrawRectSolid(TOP_W / 2 - w / 2 - 12, 36, 0.5f, 3, h, color);
|
|
text(msg, TOP_W / 2 - w / 2, 36, 0.46f, COL_WHITE, 0);
|
|
}
|
|
|
|
static void draw_top_home(const App *a, int tab, bool showHero)
|
|
{
|
|
char right[64], line[96];
|
|
if (!a) {
|
|
C2D_DrawRectangle(0, 0, 0, TOP_W, SCR_H, COL_BG2, COL_BG2, COL_BG, COL_BG);
|
|
draw_cart(TOP_W / 2 - 40, 56, 80);
|
|
const char *m = catalog_state() != CAT_READY ? catalog_message()
|
|
: search[0] ? "Nichts gefunden. Y: anders suchen, B: Suche beenden"
|
|
: tab == TAB_COUNT - 1 ? "Noch nichts aus dem Shop installiert." : "Hier gibt es nichts.";
|
|
text(m[0] ? m : "Einen Moment ...", TOP_W / 2, 160, 0.5f, COL_MUTED, C2D_AlignCenter);
|
|
text("Homebrew aus der Universal-DB", TOP_W / 2, 184, 0.4f, COL_MUTED, C2D_AlignCenter);
|
|
draw_top_bar("MBMNET Shop", "v" APP_VERSION);
|
|
return;
|
|
}
|
|
if (showHero) draw_hero(a, 0);
|
|
else C2D_DrawRectangle(0, 0, 0.5f, TOP_W, SCR_H, app_color(a, 1.0f), app_color(a, 1.0f), app_color(a, 0.45f), app_color(a, 0.45f));
|
|
|
|
if (search[0]) snprintf(right, sizeof(right), "Suche \"%.20s\" · %d Treffer · B: Ende", search, viewCount);
|
|
else snprintf(right, sizeof(right), "%s · %d · %s%s", TABS[tab].name, viewCount, SORT_NAMES[sortMode],
|
|
catalog_offline() ? " · offline" : "");
|
|
draw_top_bar("MBMNET Shop", right);
|
|
|
|
// Unten: Symbol, Name, Autor, Infos, Preis-Schild
|
|
C2D_DrawRectSolid(0, 166, 0.5f, TOP_W, 74, COL_SHADE);
|
|
C2D_DrawRectSolid(0, 166, 0.5f, TOP_W, 2, COL_RED);
|
|
draw_icon(a, 10, 178, 48);
|
|
fit(line, sizeof(line), a->title, 0.62f, TOP_W - 180);
|
|
text(line, 68, 175, 0.62f, COL_WHITE, 0);
|
|
fit(line, sizeof(line), a->author, 0.42f, TOP_W - 180);
|
|
text(line, 68, 196, 0.42f, COL_MUTED, 0);
|
|
info_line(line, sizeof(line), a);
|
|
char cut[96];
|
|
fit(cut, sizeof(cut), line, 0.36f, TOP_W - 80);
|
|
text(cut, 68, 214, 0.36f, COL_MUTED, 0);
|
|
draw_price_pill(a, TOP_W - 10, 182);
|
|
}
|
|
|
|
static void draw_top_detail(const App *a, int shot)
|
|
{
|
|
draw_hero(a, a->shots ? shot : -1);
|
|
char right[48];
|
|
snprintf(right, sizeof(right), a->shots > 1 ? "L / R: Bilder B: Zurück" : "B: Zurück");
|
|
draw_top_bar(a->title, right);
|
|
// Punkte für die Screenshots
|
|
if (a->shots > 1) {
|
|
float w = a->shots * 12;
|
|
round_rect(TOP_W / 2 - w / 2 - 6, 222, w + 12, 14, 7, COL_SHADE);
|
|
for (int i = 0; i < a->shots; i++)
|
|
C2D_DrawCircleSolid(TOP_W / 2 - w / 2 + 6 + i * 12, 229, 0.5f, i == shot ? 4 : 2.5f, i == shot ? COL_RED : COL_WHITE);
|
|
}
|
|
}
|
|
|
|
// --- Unterer Bildschirm ------------------------------------------------------------
|
|
|
|
static void draw_tabs(int tab)
|
|
{
|
|
float w = TAB_W;
|
|
C2D_DrawRectSolid(0, 0, 0.5f, BOT_W, TAB_H, COL_BG2);
|
|
// Lupe (Suche); rot hinterlegt, solange gesucht wird
|
|
float lx = BOT_W - SEARCH_W;
|
|
if (search[0]) C2D_DrawRectSolid(lx, 0, 0.5f, SEARCH_W, TAB_H, COL_RED);
|
|
C2D_DrawCircleSolid(lx + 15, 11, 0.5f, 6.5f, COL_WHITE);
|
|
C2D_DrawCircleSolid(lx + 15, 11, 0.5f, 4.5f, search[0] ? COL_RED : COL_BG2);
|
|
C2D_DrawLine(lx + 19, 15, COL_WHITE, lx + 25, 21, COL_WHITE, 3, 0.5f);
|
|
for (int i = 0; i < TAB_COUNT; i++) {
|
|
bool on = i == tab;
|
|
if (on) {
|
|
C2D_DrawRectSolid(i * w, 0, 0.5f, w, TAB_H, COL_CARD_HI);
|
|
C2D_DrawRectSolid(i * w + 6, TAB_H - 3, 0.5f, w - 12, 3, COL_RED);
|
|
}
|
|
text(TABS[i].label, i * w + w / 2, 7, 0.38f, on ? COL_WHITE : COL_MUTED, C2D_AlignCenter);
|
|
}
|
|
}
|
|
|
|
static void draw_bottom_home(int tab, int selected, int scrollRow)
|
|
{
|
|
C2D_DrawRectSolid(0, 0, 0, BOT_W, SCR_H, COL_BG);
|
|
draw_tabs(tab);
|
|
if (catalog_state() != CAT_READY) {
|
|
const char *m = catalog_state() == CAT_ERROR ? "Kein Katalog.\nA: nochmal versuchen" : "Einen Moment ...";
|
|
text(m, BOT_W / 2, 110, 0.5f, COL_MUTED, C2D_AlignCenter);
|
|
return;
|
|
}
|
|
if (!viewCount) return;
|
|
for (int r = 0; r <= FULL_ROWS; r++) {
|
|
for (int c = 0; c < COLS; c++) {
|
|
int i = (scrollRow + r) * COLS + c;
|
|
if (i >= viewCount) break;
|
|
const App *a = catalog_app(view[i]);
|
|
float x = c * CELL_W + 5, y = GRID_Y + r * CELL_H;
|
|
if (i == selected) round_rect(x - 3, y - 3, 60, 60, 12, COL_RED);
|
|
round_rect(x, y, 54, 54, 10, i == selected ? COL_CARD_HI : COL_CARD);
|
|
draw_icon(a, x + 3, y + 3, 48);
|
|
AppStatus s = app_status(a);
|
|
if (s != APP_NEW) {
|
|
C2D_DrawCircleSolid(x + 50, y + 4, 0.5f, 6, COL_BG);
|
|
C2D_DrawCircleSolid(x + 50, y + 4, 0.5f, 4.5f, s == APP_UPDATE ? COL_AMBER : COL_GREEN);
|
|
}
|
|
}
|
|
}
|
|
// Rollbalken
|
|
int rows = (viewCount + COLS - 1) / COLS;
|
|
if (rows > FULL_ROWS) {
|
|
float h = SCR_H - GRID_Y - 4, bar = h * FULL_ROWS / rows;
|
|
float y = GRID_Y + (h - bar) * scrollRow / (rows - FULL_ROWS);
|
|
round_rect(BOT_W - 4, y, 3, bar, 1.5f, COL_MUTED);
|
|
}
|
|
}
|
|
|
|
static void draw_bottom_detail(const App *a, int armed, int descScroll)
|
|
{
|
|
C2D_DrawRectSolid(0, 0, 0, BOT_W, SCR_H, COL_BG);
|
|
// Kopf: Zurück, Symbol, Name, Autor
|
|
C2D_DrawRectSolid(0, 0, 0.5f, BOT_W, 62, COL_BG2);
|
|
round_rect(backBox.x, backBox.y + 8, backBox.w, 30, 8, COL_CARD);
|
|
C2D_DrawLine(backBox.x + 18, backBox.y + 15, COL_WHITE, backBox.x + 11, backBox.y + 23, COL_WHITE, 3, 0.5f);
|
|
C2D_DrawLine(backBox.x + 11, backBox.y + 23, COL_WHITE, backBox.x + 18, backBox.y + 31, COL_WHITE, 3, 0.5f);
|
|
draw_icon(a, 44, 7, 48);
|
|
char line[128];
|
|
fit(line, sizeof(line), a->title, 0.6f, BOT_W - 108);
|
|
text(line, 100, 9, 0.6f, COL_WHITE, 0);
|
|
fit(line, sizeof(line), a->author, 0.42f, BOT_W - 108);
|
|
text(line, 100, 32, 0.42f, COL_MUTED, 0);
|
|
|
|
// Großer Knopf (wie "Kostenlos herunterladen" im eShop) und Entfernen
|
|
AppStatus s = app_status(a);
|
|
bool running = job_state() == JOB_RUNNING;
|
|
Box b = s == APP_NEW ? actionBox : actionBoxNarrow;
|
|
const char *label = s == APP_NEW ? "Kostenlos herunterladen" : s == APP_UPDATE ? "Aktualisieren"
|
|
: strcmp(a->kind, "cia") == 0 ? "Öffnen" : "Neu laden";
|
|
u32 col = s == APP_INSTALLED ? COL_GREEN : COL_RED;
|
|
if (armed == 0) { label = s == APP_NEW ? "Nochmal tippen: Laden" : "Nochmal tippen"; col = COL_AMBER; }
|
|
char progress[48];
|
|
if (running) {
|
|
float p = job_progress();
|
|
round_rect(b.x, b.y, b.w, b.h, 12, COL_CARD_HI);
|
|
if (p > 0) round_rect(b.x, b.y, 24 + (b.w - 24) * (p > 1 ? 1 : p), b.h, 12, COL_RED);
|
|
if (p >= 0.999f) snprintf(progress, sizeof(progress), "Wird installiert ...");
|
|
else if (p >= 0) snprintf(progress, sizeof(progress), "Lädt ... %d %%", (int)(p * 100));
|
|
else snprintf(progress, sizeof(progress), "Lädt ...");
|
|
label = progress;
|
|
} else {
|
|
round_rect(b.x, b.y + 3, b.w, b.h, 12, s == APP_INSTALLED ? C2D_Color32(0x15, 0x80, 0x3D, 0xFF) : COL_RED_DARK);
|
|
round_rect(b.x, b.y, b.w, b.h, 12, col);
|
|
}
|
|
text(label, b.x + b.w / 2, b.y + 10, 0.55f, s == APP_INSTALLED && !running && armed != 0 ? COL_BG : COL_WHITE, C2D_AlignCenter);
|
|
if (s != APP_NEW) {
|
|
Box r = removeBox;
|
|
round_rect(r.x, r.y, r.w, r.h, 12, armed == 1 ? COL_AMBER : COL_CARD);
|
|
text(armed == 1 ? "Sicher?" : "Entfernen", r.x + r.w / 2, r.y + 12, 0.45f, armed == 1 ? COL_BG : COL_MUTED, C2D_AlignCenter);
|
|
}
|
|
|
|
// Infos
|
|
char size[16];
|
|
size_text(size, sizeof(size), a->size);
|
|
snprintf(line, sizeof(line), "%s · %s · %s", a->version[0] ? a->version : "ohne Version", size, category_name(a->category));
|
|
text(line, 14, 118, 0.42f, COL_WHITE, 0);
|
|
char info2[128];
|
|
snprintf(info2, sizeof(info2), "%s%s%s%s", strcmp(a->kind, "cia") == 0 ? "Im HOME-Menü" : "Im Homebrew Launcher",
|
|
a->updated[0] ? " · aktualisiert " : "", a->updated, a->license[0] ? " · " : "");
|
|
if (a->license[0]) snprintf(info2 + strlen(info2), sizeof(info2) - strlen(info2), "%s", a->license);
|
|
fit(line, sizeof(line), info2, 0.38f, BOT_W - 28);
|
|
text(line, 14, 134, 0.38f, COL_MUTED, 0);
|
|
C2D_DrawRectSolid(12, 150, 0.5f, BOT_W - 24, 1, COL_CARD_HI);
|
|
|
|
// Beschreibung
|
|
for (int i = 0; i < DESC_LINES && descScroll + i < descLineCount; i++)
|
|
text(descLines[descScroll + i], 14, DESC_Y + i * DESC_LINE_H, 0.42f, COL_WHITE, 0);
|
|
if (descLineCount > DESC_LINES) {
|
|
float h = DESC_LINES * DESC_LINE_H, bar = h * DESC_LINES / descLineCount;
|
|
float y = DESC_Y + (h - bar) * descScroll / (descLineCount - DESC_LINES);
|
|
round_rect(BOT_W - 6, y, 3, bar, 1.5f, COL_MUTED);
|
|
}
|
|
}
|
|
|
|
// --- Aktionen --------------------------------------------------------------------
|
|
|
|
// Installierte CIA starten (Sprung in die App, der Shop beendet sich)
|
|
static bool launch(const App *a, char *msg, size_t len)
|
|
{
|
|
if (!a->titleId) { snprintf(msg, len, "Starte %s bitte im HOME-Menü.", a->title); return false; }
|
|
Result rc = APT_PrepareToDoApplicationJump(0, a->titleId, MEDIATYPE_SD);
|
|
if (R_SUCCEEDED(rc)) {
|
|
static u8 param[0x300], hmac[0x20];
|
|
rc = APT_DoApplicationJump(param, sizeof(param), hmac);
|
|
}
|
|
if (R_FAILED(rc)) { snprintf(msg, len, "Starte %s bitte im HOME-Menü (%08lX).", a->title, (unsigned long)rc); return false; }
|
|
return true;
|
|
}
|
|
|
|
static void load_sheets(void)
|
|
{
|
|
for (int n = 0; n < catalog_sheet_count() && n < 8; n++) {
|
|
const char *p = catalog_sheet_path(n);
|
|
if (p[0] && !sheets[n]) sheets[n] = C2D_SpriteSheetLoad(p);
|
|
}
|
|
}
|
|
|
|
int main(int argc, char **argv)
|
|
{
|
|
(void)argc; (void)argv;
|
|
gfxInitDefault();
|
|
httpcInit(0);
|
|
amInit();
|
|
C3D_Init(C3D_DEFAULT_CMDBUF_SIZE);
|
|
C2D_Init(C2D_DEFAULT_MAX_OBJECTS);
|
|
C2D_Prepare();
|
|
C3D_RenderTarget *top = C2D_CreateScreenTarget(GFX_TOP, GFX_LEFT);
|
|
C3D_RenderTarget *bot = C2D_CreateScreenTarget(GFX_BOTTOM, GFX_LEFT);
|
|
textBuf = C2D_TextBufNew(8192);
|
|
measureBuf = C2D_TextBufNew(512);
|
|
hidSetRepeatParameters(18, 4);
|
|
pics_init();
|
|
|
|
catalog_start();
|
|
int tab = 0, selected = 0, scrollRow = 0;
|
|
bool ready = false, detail = false, quit = false;
|
|
int shot = 0, descScroll = 0, settle = 0; // settle: Bilder erst laden, wenn die Auswahl kurz stehen bleibt
|
|
char toast[192] = "";
|
|
u32 toastColor = COL_RED;
|
|
int toastFrames = 0;
|
|
int armed = -1; // Sicherheitsabfrage: 0 = laden, 1 = entfernen
|
|
int touchStartY = -1, touchStartScroll = 0, touchCell = -1;
|
|
bool dragged = false;
|
|
|
|
while (!quit && aptMainLoop()) {
|
|
hidScanInput();
|
|
u32 down = hidKeysDown(), rep = hidKeysDownRepeat(), held = hidKeysHeld();
|
|
bool busy = job_state() == JOB_RUNNING;
|
|
if (down & KEY_START && !busy) break;
|
|
pics_poll();
|
|
|
|
if (!ready && catalog_state() == CAT_READY) {
|
|
load_sheets();
|
|
rebuild_view(tab);
|
|
ready = true;
|
|
}
|
|
if (catalog_state() == CAT_ERROR && down & KEY_A) catalog_start();
|
|
|
|
// Ergebnis einer Installation
|
|
JobState js = job_state();
|
|
if (js == JOB_DONE || js == JOB_FAILED) {
|
|
snprintf(toast, sizeof(toast), "%s", job_message());
|
|
toastColor = js == JOB_DONE ? COL_GREEN : COL_AMBER;
|
|
toastFrames = 300;
|
|
job_acknowledge();
|
|
const App *cur = viewCount ? catalog_app(view[selected]) : NULL;
|
|
rebuild_view(tab);
|
|
selected = 0;
|
|
for (int i = 0; cur && i < viewCount; i++) if (catalog_app(view[i]) == cur) selected = i;
|
|
if (!viewCount) detail = false;
|
|
}
|
|
|
|
App *a = ready && viewCount ? catalog_app(view[selected]) : NULL;
|
|
touchPosition tp;
|
|
|
|
if (ready && !detail) {
|
|
// --- Startseite ---
|
|
int oldSel = selected, oldTab = tab;
|
|
if (down & KEY_R) tab = (tab + 1) % TAB_COUNT;
|
|
if (down & KEY_L) tab = (tab + TAB_COUNT - 1) % TAB_COUNT;
|
|
if (down & KEY_X) { sortMode = (sortMode + 1) % 3; rebuild_view(tab); selected = scrollRow = 0; }
|
|
if (rep & (KEY_RIGHT | KEY_CPAD_RIGHT)) selected++;
|
|
if (rep & (KEY_LEFT | KEY_CPAD_LEFT)) selected--;
|
|
if (rep & (KEY_DOWN | KEY_CPAD_DOWN) && selected + COLS < viewCount) selected += COLS;
|
|
if (rep & (KEY_UP | KEY_CPAD_UP) && selected >= COLS) selected -= COLS;
|
|
|
|
// Touch: Reiter antippen, Raster ziehen, Symbol antippen = App öffnen
|
|
int rows = (viewCount + COLS - 1) / COLS, maxRow = rows > FULL_ROWS ? rows - FULL_ROWS : 0;
|
|
bool openSearch = down & KEY_Y;
|
|
if (down & KEY_TOUCH) {
|
|
hidTouchRead(&tp);
|
|
if (tp.py < TAB_H && tp.px >= BOT_W - SEARCH_W) openSearch = true;
|
|
else if (tp.py < TAB_H) tab = tp.px / TAB_W;
|
|
else {
|
|
touchStartY = tp.py; touchStartScroll = scrollRow; dragged = false;
|
|
int c = tp.px / CELL_W, r = (tp.py - GRID_Y) / CELL_H;
|
|
touchCell = tp.py >= GRID_Y && c < COLS ? (scrollRow + r) * COLS + c : -1;
|
|
}
|
|
} else if (held & KEY_TOUCH && touchStartY >= 0) {
|
|
hidTouchRead(&tp);
|
|
int dy = touchStartY - tp.py;
|
|
if (abs(dy) > 8) dragged = true;
|
|
if (dragged) {
|
|
scrollRow = touchStartScroll + (dy + (dy > 0 ? CELL_H / 2 : -CELL_H / 2)) / CELL_H;
|
|
if (scrollRow > maxRow) scrollRow = maxRow;
|
|
if (scrollRow < 0) scrollRow = 0;
|
|
// Auswahl im sichtbaren Bereich halten
|
|
if (selected < scrollRow * COLS) selected = scrollRow * COLS;
|
|
if (selected >= (scrollRow + FULL_ROWS) * COLS) selected = (scrollRow + FULL_ROWS) * COLS - 1;
|
|
}
|
|
} else if (touchStartY >= 0) {
|
|
if (!dragged && touchCell >= 0 && touchCell < viewCount) { selected = touchCell; down |= KEY_A; }
|
|
touchStartY = -1;
|
|
}
|
|
|
|
// Suche: Bildschirmtastatur des 3DS. Gesucht wird in Name, Autor und Beschreibung, in allen Kategorien.
|
|
if (openSearch) {
|
|
SwkbdState kb;
|
|
char input[sizeof(search)];
|
|
swkbdInit(&kb, SWKBD_TYPE_NORMAL, 2, sizeof(search) - 1);
|
|
swkbdSetHintText(&kb, "Suchen: Name, Autor oder Stichwort");
|
|
swkbdSetInitialText(&kb, search);
|
|
swkbdSetButton(&kb, SWKBD_BUTTON_LEFT, "Abbrechen", false);
|
|
swkbdSetButton(&kb, SWKBD_BUTTON_RIGHT, "Suchen", true);
|
|
if (swkbdInputText(&kb, input, sizeof(input)) == SWKBD_BUTTON_RIGHT) {
|
|
snprintf(search, sizeof(search), "%s", input);
|
|
tab = oldTab = 0;
|
|
rebuild_view(tab);
|
|
selected = scrollRow = 0;
|
|
settle = 0;
|
|
}
|
|
} else if (down & KEY_B && search[0]) {
|
|
search[0] = 0;
|
|
rebuild_view(tab);
|
|
selected = scrollRow = 0;
|
|
}
|
|
if (tab != oldTab) { rebuild_view(tab); selected = scrollRow = 0; }
|
|
if (selected >= viewCount) selected = viewCount - 1;
|
|
if (selected < 0) selected = 0;
|
|
if (!dragged || touchStartY < 0) {
|
|
int row = selected / COLS;
|
|
if (row < scrollRow) scrollRow = row;
|
|
if (row >= scrollRow + FULL_ROWS) scrollRow = row - FULL_ROWS + 1;
|
|
}
|
|
if (selected != oldSel || tab != oldTab) settle = 0;
|
|
a = viewCount ? catalog_app(view[selected]) : NULL;
|
|
if (a && down & KEY_A) {
|
|
detail = true;
|
|
shot = descScroll = 0;
|
|
armed = -1;
|
|
wrap_description(a, 0.42f, BOT_W - 30);
|
|
}
|
|
} else if (ready && a) {
|
|
// --- App-Seite ---
|
|
AppStatus s = app_status(a);
|
|
bool hit = false, hitRemove = false;
|
|
if (down & KEY_TOUCH) {
|
|
hidTouchRead(&tp);
|
|
touchStartY = tp.py; touchStartScroll = descScroll; dragged = false;
|
|
if (inside(backBox, tp)) down |= KEY_B;
|
|
else if (inside(s == APP_NEW ? actionBox : actionBoxNarrow, tp)) hit = true;
|
|
else if (s != APP_NEW && inside(removeBox, tp)) hitRemove = true;
|
|
} else if (held & KEY_TOUCH && touchStartY >= DESC_Y - 10) {
|
|
hidTouchRead(&tp);
|
|
descScroll = touchStartScroll + (touchStartY - tp.py) / DESC_LINE_H;
|
|
} else touchStartY = -1;
|
|
|
|
if (rep & (KEY_DOWN | KEY_CPAD_DOWN)) descScroll++;
|
|
if (rep & (KEY_UP | KEY_CPAD_UP)) descScroll--;
|
|
int maxScroll = descLineCount > DESC_LINES ? descLineCount - DESC_LINES : 0;
|
|
if (descScroll > maxScroll) descScroll = maxScroll;
|
|
if (descScroll < 0) descScroll = 0;
|
|
if (a->shots > 1 && down & KEY_R) shot = (shot + 1) % a->shots;
|
|
if (a->shots > 1 && down & KEY_L) shot = (shot + a->shots - 1) % a->shots;
|
|
|
|
if (down & KEY_B && !busy) { detail = false; armed = -1; }
|
|
else if (!busy && (down & KEY_A || hit)) {
|
|
if (s == APP_INSTALLED && strcmp(a->kind, "cia") == 0) {
|
|
armed = -1;
|
|
if (launch(a, toast, sizeof(toast))) quit = true;
|
|
else { toastColor = COL_AMBER; toastFrames = 240; }
|
|
} else if (armed == 0) {
|
|
armed = -1;
|
|
job_install(a);
|
|
} else if (catalog_offline()) {
|
|
snprintf(toast, sizeof(toast), "Keine Verbindung zum MBMNET-Server.");
|
|
toastColor = COL_AMBER;
|
|
toastFrames = 240;
|
|
} else armed = 0;
|
|
} else if (!busy && s != APP_NEW && (down & KEY_Y || hitRemove)) {
|
|
if (armed == 1) { armed = -1; job_remove(a); }
|
|
else armed = 1;
|
|
}
|
|
}
|
|
|
|
if (toastFrames > 0 && --toastFrames == 0) toast[0] = 0;
|
|
if (settle < 1000) settle++;
|
|
|
|
const App *cur = ready && viewCount ? catalog_app(view[selected]) : NULL;
|
|
C2D_TextBufClear(textBuf);
|
|
C3D_FrameBegin(C3D_FRAME_SYNCDRAW);
|
|
C2D_TargetClear(top, COL_BG);
|
|
C2D_SceneBegin(top);
|
|
if (detail && cur) draw_top_detail(cur, shot);
|
|
else draw_top_home(cur, tab, settle > 12);
|
|
draw_toast(toast, toastColor);
|
|
C2D_TargetClear(bot, COL_BG);
|
|
C2D_SceneBegin(bot);
|
|
if (detail && cur) draw_bottom_detail(cur, armed, descScroll);
|
|
else draw_bottom_home(tab, selected, scrollRow);
|
|
C3D_FrameEnd(0);
|
|
}
|
|
|
|
store_exit();
|
|
pics_exit();
|
|
for (int n = 0; n < 8; n++) if (sheets[n]) C2D_SpriteSheetFree(sheets[n]);
|
|
free(view);
|
|
C2D_TextBufDelete(textBuf);
|
|
C2D_TextBufDelete(measureBuf);
|
|
C2D_Fini();
|
|
C3D_Fini();
|
|
amExit();
|
|
httpcExit();
|
|
gfxExit();
|
|
return 0;
|
|
}
|