Add global leaderboard client (dormant by default)
End-of-game scores are POSTed to a configurable backend URL; the main menu
gains a "Global Leaderboard" entry that fetches and renders the top N for
the current board. Default `global_leaderboard=0` ships the code dormant
until the backend at leaderboard_url is live.
Architecture:
- httpclient.h: thin async interface. http_post_json / http_get fire and
forget; the user callback runs from http_pump() (native) or the
Emscripten runtime (web). No blocking calls.
- httpclient_native.cpp: libcurl-multi, polled per frame.
- httpclient_web.cpp: emscripten_fetch (-sFETCH=1 link option).
- leaderboard.{h,cpp}: portable game-side module. Hand-rolled JSON
build/parse for the tiny fixed-shape wire format; no JSON dep.
Identity: player name + a player-typed "board name" (shared room code).
A per-install RFC-4122-v4 UUID `client_id` is auto-generated on first run
via SDL_rand_bits and stored in the config — server uses it for rate
limiting / dedup, not as a public identity.
Wire format (informational, server is out-of-tree):
POST /api/scores body: {board, name, client_id, score, skill,
gametype, end_scene, end_lives, end_health,
playtime, start_date, end_date}
resp: {rank, total}
GET /api/scores?board=&limit=50
resp: {board, total, scores:[{name,score,end_scene}]}
Hook points:
- manage.cpp game_start: populate hi.start_date; reset_submission().
- manage.cpp game_stop: populate hi.end_date and the profile name on hi,
then leaderboard::submit_score after scorefile.record.
- kobo.cpp main: http_init/http_shutdown around the run; generate
client_id on first run when empty.
- kobo.cpp kobo_gfxengine_t::frame: http_pump() before gsm.frame().
- states.cpp st_game_over post_render: renders "Submitting…" / "Rank #N of M"
/ "(offline)" under "GAME OVER".
- states.cpp main_menu: "Global Leaderboard" button (only when enabled
+ board name set).
- states.cpp st_global_leaderboard_t: new browser state listing top 16.
- options.cpp game_options: onoff toggle.
Out of scope for this commit (left as follow-ups):
- In-game text editor for board name / URL (form toolkit has no free-text
widget; users edit via config file or CLI flags for now).
- Retry queue across sessions.
- Server implementation. tools/mock-leaderboard.py is a 100-line Python
scratch server for local testing only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7ac9875d11
commit
9238ce61c6
@ -57,6 +57,7 @@ if(EMSCRIPTEN)
|
|||||||
else()
|
else()
|
||||||
find_package(SDL3 CONFIG REQUIRED)
|
find_package(SDL3 CONFIG REQUIRED)
|
||||||
find_package(SDL3_image CONFIG REQUIRED)
|
find_package(SDL3_image CONFIG REQUIRED)
|
||||||
|
find_package(CURL REQUIRED)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(KOBO_ENABLE_OPENGL)
|
if(KOBO_ENABLE_OPENGL)
|
||||||
@ -199,14 +200,26 @@ set(KOBO_SOURCES
|
|||||||
myship.cpp radar.cpp random.cpp scenes.cpp score.cpp screen.cpp
|
myship.cpp radar.cpp random.cpp scenes.cpp score.cpp screen.cpp
|
||||||
filemap.cpp prefs.cpp cfgform.cpp options.cpp gamestate.cpp
|
filemap.cpp prefs.cpp cfgform.cpp options.cpp gamestate.cpp
|
||||||
states.cpp form.cpp cfgparse.cpp game.cpp kobo.cpp logger.c
|
states.cpp form.cpp cfgparse.cpp game.cpp kobo.cpp logger.c
|
||||||
dashboard.cpp sound.cpp prof.cpp bench.cpp
|
dashboard.cpp sound.cpp prof.cpp bench.cpp leaderboard.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(EMSCRIPTEN)
|
||||||
|
list(APPEND KOBO_SOURCES httpclient_web.cpp)
|
||||||
|
else()
|
||||||
|
list(APPEND KOBO_SOURCES httpclient_native.cpp)
|
||||||
|
endif()
|
||||||
|
|
||||||
add_executable(kobodl ${KOBO_SOURCES})
|
add_executable(kobodl ${KOBO_SOURCES})
|
||||||
target_include_directories(kobodl PRIVATE ${CMAKE_SOURCE_DIR}/data/sfx)
|
target_include_directories(kobodl PRIVATE ${CMAKE_SOURCE_DIR}/data/sfx)
|
||||||
target_link_libraries(kobodl PRIVATE graphics sound eel kobo_deps)
|
target_link_libraries(kobodl PRIVATE graphics sound eel kobo_deps)
|
||||||
set_target_properties(kobodl PROPERTIES OUTPUT_NAME ${KOBO_EXEFILE})
|
set_target_properties(kobodl PROPERTIES OUTPUT_NAME ${KOBO_EXEFILE})
|
||||||
|
|
||||||
|
if(EMSCRIPTEN)
|
||||||
|
target_link_options(kobodl PRIVATE -sFETCH=1)
|
||||||
|
else()
|
||||||
|
target_link_libraries(kobodl PRIVATE CURL::libcurl)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(EMSCRIPTEN)
|
if(EMSCRIPTEN)
|
||||||
# ASYNCIFY lets the existing blocking main loop (gengine->run(), SDL_Delay)
|
# ASYNCIFY lets the existing blocking main loop (gengine->run(), SDL_Delay)
|
||||||
# yield to the browser without rewriting it around emscripten_set_main_loop.
|
# yield to the browser without rewriting it around emscripten_set_main_loop.
|
||||||
|
|||||||
44
httpclient.h
Normal file
44
httpclient.h
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* httpclient.h — thin async HTTP client.
|
||||||
|
*
|
||||||
|
* Two impls share this header:
|
||||||
|
* httpclient_native.cpp — libcurl-multi, polled from http_pump().
|
||||||
|
* httpclient_web.cpp — emscripten_fetch; http_pump() is a no-op.
|
||||||
|
*
|
||||||
|
* Callbacks fire on the main thread: native impls dispatch from http_pump(),
|
||||||
|
* web from the Emscripten runtime (which is already main-thread).
|
||||||
|
*
|
||||||
|
* The `body` pointer in http_response_t is only valid for the duration of
|
||||||
|
* the callback — copy what you need.
|
||||||
|
*/
|
||||||
|
#ifndef KOBO_HTTPCLIENT_H
|
||||||
|
#define KOBO_HTTPCLIENT_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
typedef struct http_response_t {
|
||||||
|
int status; // HTTP status code; 0 = network/transport failure
|
||||||
|
const char *body; // NUL-terminated; valid only during the callback
|
||||||
|
size_t len;
|
||||||
|
} http_response_t;
|
||||||
|
|
||||||
|
typedef void (*http_callback_t)(const http_response_t *resp, void *user);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
void http_init(void);
|
||||||
|
void http_shutdown(void);
|
||||||
|
|
||||||
|
void http_post_json(const char *url, const char *body,
|
||||||
|
http_callback_t cb, void *user);
|
||||||
|
void http_get(const char *url, http_callback_t cb, void *user);
|
||||||
|
|
||||||
|
void http_pump(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* KOBO_HTTPCLIENT_H */
|
||||||
175
httpclient_native.cpp
Normal file
175
httpclient_native.cpp
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
/*
|
||||||
|
* httpclient_native.cpp — libcurl-multi backend.
|
||||||
|
*
|
||||||
|
* Single CURLM handle owned by this module. http_post_json / http_get queue
|
||||||
|
* an easy handle; http_pump() runs curl_multi_perform and drains finished
|
||||||
|
* transfers, firing user callbacks exactly once.
|
||||||
|
*
|
||||||
|
* Failure modes (DNS, connect refused, timeout, etc.) are surfaced as
|
||||||
|
* status=0 in the response — caller doesn't have to distinguish curl error
|
||||||
|
* codes from HTTP errors.
|
||||||
|
*/
|
||||||
|
#include "httpclient.h"
|
||||||
|
#include "kobolog.h"
|
||||||
|
|
||||||
|
#include <curl/curl.h>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
CURLM *g_multi = nullptr;
|
||||||
|
|
||||||
|
struct pending_t {
|
||||||
|
http_callback_t cb;
|
||||||
|
void *user;
|
||||||
|
std::string body;
|
||||||
|
curl_slist *headers = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyed by CURL* easy handle so curl_multi_info_read results can be routed.
|
||||||
|
std::unordered_map<CURL *, pending_t *> g_pending;
|
||||||
|
|
||||||
|
size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata)
|
||||||
|
{
|
||||||
|
pending_t *p = static_cast<pending_t *>(userdata);
|
||||||
|
p->body.append(ptr, size * nmemb);
|
||||||
|
return size * nmemb;
|
||||||
|
}
|
||||||
|
|
||||||
|
void start_request(CURL *easy, pending_t *pending)
|
||||||
|
{
|
||||||
|
curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, write_cb);
|
||||||
|
curl_easy_setopt(easy, CURLOPT_WRITEDATA, pending);
|
||||||
|
curl_easy_setopt(easy, CURLOPT_TIMEOUT, 10L);
|
||||||
|
curl_easy_setopt(easy, CURLOPT_CONNECTTIMEOUT, 5L);
|
||||||
|
curl_easy_setopt(easy, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
|
curl_easy_setopt(easy, CURLOPT_USERAGENT, "kobodl/0.5.1");
|
||||||
|
|
||||||
|
g_pending[easy] = pending;
|
||||||
|
CURLMcode rc = curl_multi_add_handle(g_multi, easy);
|
||||||
|
if(rc != CURLM_OK)
|
||||||
|
{
|
||||||
|
log_printf(ELOG, "http: curl_multi_add_handle failed: %s\n",
|
||||||
|
curl_multi_strerror(rc));
|
||||||
|
// Synthesize a transport failure callback.
|
||||||
|
http_response_t resp{0, "", 0};
|
||||||
|
pending->cb(&resp, pending->user);
|
||||||
|
g_pending.erase(easy);
|
||||||
|
if(pending->headers) curl_slist_free_all(pending->headers);
|
||||||
|
curl_easy_cleanup(easy);
|
||||||
|
delete pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
extern "C" void http_init(void)
|
||||||
|
{
|
||||||
|
if(g_multi) return;
|
||||||
|
curl_global_init(CURL_GLOBAL_DEFAULT);
|
||||||
|
g_multi = curl_multi_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" void http_shutdown(void)
|
||||||
|
{
|
||||||
|
if(!g_multi) return;
|
||||||
|
// Abandon any in-flight handles — we're shutting down, callbacks are
|
||||||
|
// moot. Free the resources to avoid leak reports.
|
||||||
|
for(auto &kv : g_pending)
|
||||||
|
{
|
||||||
|
curl_multi_remove_handle(g_multi, kv.first);
|
||||||
|
if(kv.second->headers) curl_slist_free_all(kv.second->headers);
|
||||||
|
curl_easy_cleanup(kv.first);
|
||||||
|
delete kv.second;
|
||||||
|
}
|
||||||
|
g_pending.clear();
|
||||||
|
curl_multi_cleanup(g_multi);
|
||||||
|
g_multi = nullptr;
|
||||||
|
curl_global_cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" void http_post_json(const char *url, const char *body,
|
||||||
|
http_callback_t cb, void *user)
|
||||||
|
{
|
||||||
|
if(!g_multi || !cb) return;
|
||||||
|
CURL *easy = curl_easy_init();
|
||||||
|
if(!easy)
|
||||||
|
{
|
||||||
|
http_response_t resp{0, "", 0};
|
||||||
|
cb(&resp, user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto *pending = new pending_t;
|
||||||
|
pending->cb = cb;
|
||||||
|
pending->user = user;
|
||||||
|
pending->headers = curl_slist_append(nullptr, "Content-Type: application/json");
|
||||||
|
|
||||||
|
curl_easy_setopt(easy, CURLOPT_URL, url);
|
||||||
|
curl_easy_setopt(easy, CURLOPT_POST, 1L);
|
||||||
|
curl_easy_setopt(easy, CURLOPT_COPYPOSTFIELDS, body ? body : "");
|
||||||
|
curl_easy_setopt(easy, CURLOPT_HTTPHEADER, pending->headers);
|
||||||
|
start_request(easy, pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" void http_get(const char *url, http_callback_t cb, void *user)
|
||||||
|
{
|
||||||
|
if(!g_multi || !cb) return;
|
||||||
|
CURL *easy = curl_easy_init();
|
||||||
|
if(!easy)
|
||||||
|
{
|
||||||
|
http_response_t resp{0, "", 0};
|
||||||
|
cb(&resp, user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto *pending = new pending_t;
|
||||||
|
pending->cb = cb;
|
||||||
|
pending->user = user;
|
||||||
|
|
||||||
|
curl_easy_setopt(easy, CURLOPT_URL, url);
|
||||||
|
curl_easy_setopt(easy, CURLOPT_HTTPGET, 1L);
|
||||||
|
start_request(easy, pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" void http_pump(void)
|
||||||
|
{
|
||||||
|
if(!g_multi) return;
|
||||||
|
int running = 0;
|
||||||
|
curl_multi_perform(g_multi, &running);
|
||||||
|
|
||||||
|
CURLMsg *msg;
|
||||||
|
int msgs_left = 0;
|
||||||
|
while((msg = curl_multi_info_read(g_multi, &msgs_left)) != nullptr)
|
||||||
|
{
|
||||||
|
if(msg->msg != CURLMSG_DONE) continue;
|
||||||
|
|
||||||
|
CURL *easy = msg->easy_handle;
|
||||||
|
auto it = g_pending.find(easy);
|
||||||
|
if(it == g_pending.end())
|
||||||
|
{
|
||||||
|
curl_multi_remove_handle(g_multi, easy);
|
||||||
|
curl_easy_cleanup(easy);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pending_t *p = it->second;
|
||||||
|
|
||||||
|
long status = 0;
|
||||||
|
if(msg->data.result == CURLE_OK)
|
||||||
|
curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &status);
|
||||||
|
else
|
||||||
|
log_printf(DLOG, "http: transport error: %s\n",
|
||||||
|
curl_easy_strerror(msg->data.result));
|
||||||
|
|
||||||
|
http_response_t resp;
|
||||||
|
resp.status = (int)status;
|
||||||
|
resp.body = p->body.c_str();
|
||||||
|
resp.len = p->body.size();
|
||||||
|
p->cb(&resp, p->user);
|
||||||
|
|
||||||
|
curl_multi_remove_handle(g_multi, easy);
|
||||||
|
if(p->headers) curl_slist_free_all(p->headers);
|
||||||
|
curl_easy_cleanup(easy);
|
||||||
|
g_pending.erase(it);
|
||||||
|
delete p;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
httpclient_web.cpp
Normal file
95
httpclient_web.cpp
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* httpclient_web.cpp — emscripten_fetch backend.
|
||||||
|
*
|
||||||
|
* emscripten_fetch is async and fires onsuccess/onerror on the main thread.
|
||||||
|
* We attach a small heap-owned context to userData; the trampolines invoke
|
||||||
|
* the user callback and free the context.
|
||||||
|
*
|
||||||
|
* http_pump() is a no-op because the runtime drives completion delivery.
|
||||||
|
*/
|
||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
|
||||||
|
#include "httpclient.h"
|
||||||
|
|
||||||
|
#include <emscripten/fetch.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct ctx_t {
|
||||||
|
http_callback_t cb;
|
||||||
|
void *user;
|
||||||
|
};
|
||||||
|
|
||||||
|
void deliver(emscripten_fetch_t *fetch, int status)
|
||||||
|
{
|
||||||
|
ctx_t *c = static_cast<ctx_t *>(fetch->userData);
|
||||||
|
http_response_t resp;
|
||||||
|
resp.status = status;
|
||||||
|
resp.body = fetch->data ? fetch->data : "";
|
||||||
|
resp.len = (size_t)fetch->numBytes;
|
||||||
|
c->cb(&resp, c->user);
|
||||||
|
emscripten_fetch_close(fetch);
|
||||||
|
delete c;
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_success(emscripten_fetch_t *fetch)
|
||||||
|
{
|
||||||
|
deliver(fetch, (int)fetch->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_error(emscripten_fetch_t *fetch)
|
||||||
|
{
|
||||||
|
// Distinguish: a non-2xx with body (e.g. 500 + JSON) reports its status;
|
||||||
|
// a true transport failure (no connection, CORS reject) reports 0.
|
||||||
|
int status = (fetch->status >= 100 && fetch->status < 600)
|
||||||
|
? (int)fetch->status : 0;
|
||||||
|
deliver(fetch, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
void issue(const char *method, const char *url, const char *body,
|
||||||
|
http_callback_t cb, void *user)
|
||||||
|
{
|
||||||
|
emscripten_fetch_attr_t attr;
|
||||||
|
emscripten_fetch_attr_init(&attr);
|
||||||
|
strncpy(attr.requestMethod, method, sizeof(attr.requestMethod) - 1);
|
||||||
|
attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY;
|
||||||
|
attr.onsuccess = on_success;
|
||||||
|
attr.onerror = on_error;
|
||||||
|
|
||||||
|
static const char *headers[] = {
|
||||||
|
"Content-Type", "application/json", nullptr
|
||||||
|
};
|
||||||
|
if(body)
|
||||||
|
{
|
||||||
|
attr.requestHeaders = headers;
|
||||||
|
attr.requestData = body;
|
||||||
|
attr.requestDataSize = strlen(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto *c = new ctx_t{cb, user};
|
||||||
|
attr.userData = c;
|
||||||
|
emscripten_fetch(&attr, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
extern "C" void http_init(void) {}
|
||||||
|
extern "C" void http_shutdown(void) {}
|
||||||
|
extern "C" void http_pump(void) {}
|
||||||
|
|
||||||
|
extern "C" void http_post_json(const char *url, const char *body,
|
||||||
|
http_callback_t cb, void *user)
|
||||||
|
{
|
||||||
|
if(!cb) return;
|
||||||
|
issue("POST", url, body ? body : "", cb, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" void http_get(const char *url, http_callback_t cb, void *user)
|
||||||
|
{
|
||||||
|
if(!cb) return;
|
||||||
|
issue("GET", url, nullptr, cb, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif /* __EMSCRIPTEN__ */
|
||||||
29
kobo.cpp
29
kobo.cpp
@ -63,6 +63,7 @@ extern "C" {
|
|||||||
#include "enemies.h"
|
#include "enemies.h"
|
||||||
#include "prof.h"
|
#include "prof.h"
|
||||||
#include "bench.h"
|
#include "bench.h"
|
||||||
|
#include "httpclient.h"
|
||||||
|
|
||||||
#define MAX_FPS_RESULTS 64
|
#define MAX_FPS_RESULTS 64
|
||||||
|
|
||||||
@ -1860,6 +1861,7 @@ void kobo_gfxengine_t::frame()
|
|||||||
/*
|
/*
|
||||||
* Run the current gamestate for one frame
|
* Run the current gamestate for one frame
|
||||||
*/
|
*/
|
||||||
|
http_pump();
|
||||||
gsm.frame();
|
gsm.frame();
|
||||||
|
|
||||||
if(prefs->cmd_autoshot && !manage.game_stopped())
|
if(prefs->cmd_autoshot && !manage.game_stopped())
|
||||||
@ -2088,6 +2090,7 @@ int main(int argc, char *argv[])
|
|||||||
prof_init();
|
prof_init();
|
||||||
|
|
||||||
SDL_Init(0);
|
SDL_Init(0);
|
||||||
|
http_init();
|
||||||
|
|
||||||
if(main_init())
|
if(main_init())
|
||||||
{
|
{
|
||||||
@ -2111,6 +2114,31 @@ int main(int argc, char *argv[])
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First-run client_id generation. Stable per-install identifier sent
|
||||||
|
// with leaderboard submissions for rate-limiting/dedup (not used as
|
||||||
|
// the public identity — that's name + board_name).
|
||||||
|
if(prefs->client_id[0] == '\0')
|
||||||
|
{
|
||||||
|
// Not cryptographically random — this is a rate-limit dedup
|
||||||
|
// token, not a secret. SDL_rand_bits is sufficient.
|
||||||
|
Uint8 b[16];
|
||||||
|
for(int i = 0; i < 16; i += 4)
|
||||||
|
{
|
||||||
|
Uint32 r = SDL_rand_bits();
|
||||||
|
b[i] = (Uint8)(r >> 24);
|
||||||
|
b[i+1] = (Uint8)(r >> 16);
|
||||||
|
b[i+2] = (Uint8)(r >> 8);
|
||||||
|
b[i+3] = (Uint8)r;
|
||||||
|
}
|
||||||
|
b[6] = (b[6] & 0x0F) | 0x40; // RFC 4122 v4
|
||||||
|
b[8] = (b[8] & 0x3F) | 0x80;
|
||||||
|
snprintf(prefs->client_id, sizeof(prefs->client_id),
|
||||||
|
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
|
||||||
|
b[0],b[1],b[2],b[3], b[4],b[5], b[6],b[7],
|
||||||
|
b[8],b[9], b[10],b[11],b[12],b[13],b[14],b[15]);
|
||||||
|
prefs->changed = 1;
|
||||||
|
}
|
||||||
|
|
||||||
if(prefs->cmd_options_man)
|
if(prefs->cmd_options_man)
|
||||||
{
|
{
|
||||||
put_options_man();
|
put_options_man();
|
||||||
@ -2194,6 +2222,7 @@ int main(int argc, char *argv[])
|
|||||||
km.print_fps_results();
|
km.print_fps_results();
|
||||||
|
|
||||||
km.close_logging();
|
km.close_logging();
|
||||||
|
http_shutdown();
|
||||||
main_cleanup();
|
main_cleanup();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
260
leaderboard.cpp
Normal file
260
leaderboard.cpp
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
/*
|
||||||
|
* leaderboard.cpp — submission + browse over HTTP.
|
||||||
|
*
|
||||||
|
* Hand-rolled JSON build/parse. The wire schema is tiny and fixed-shape
|
||||||
|
* (see plan); no point in pulling in a JSON dep just to handle two
|
||||||
|
* response types.
|
||||||
|
*
|
||||||
|
* Threading: callbacks fire from http_pump() (native) or the Emscripten
|
||||||
|
* runtime (web) — both single-threaded relative to the game loop, so
|
||||||
|
* mutating the global state structs from the callbacks is safe.
|
||||||
|
*/
|
||||||
|
// pfile.h (pulled in via score.h via leaderboard.h) uses FILE without
|
||||||
|
// including stdio.h itself; pull it in first.
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <ctype.h>
|
||||||
|
|
||||||
|
#include "leaderboard.h"
|
||||||
|
#include "httpclient.h"
|
||||||
|
#include "prefs.h"
|
||||||
|
#include "kobo.h"
|
||||||
|
#include "kobolog.h"
|
||||||
|
|
||||||
|
extern prefs_t *prefs;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
leaderboard_submission_t g_sub = { LB_IDLE, 0, 0 };
|
||||||
|
leaderboard_browse_t g_brw = { LB_IDLE, 0, 0, {}, "" };
|
||||||
|
|
||||||
|
bool enabled()
|
||||||
|
{
|
||||||
|
return prefs && prefs->global_leaderboard
|
||||||
|
&& prefs->leaderboard_url[0] != '\0'
|
||||||
|
&& prefs->leaderboard_board[0] != '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON helpers ----------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Build: snprintf with manual escaping for string fields.
|
||||||
|
// Parse: substring-search the known keys. Tolerates whitespace but assumes
|
||||||
|
// the server emits one occurrence of each key (which our server contract does).
|
||||||
|
|
||||||
|
void json_escape(char *out, size_t cap, const char *in)
|
||||||
|
{
|
||||||
|
size_t o = 0;
|
||||||
|
for(size_t i = 0; in[i] && o + 2 < cap; ++i)
|
||||||
|
{
|
||||||
|
char c = in[i];
|
||||||
|
if(c == '"' || c == '\\')
|
||||||
|
{
|
||||||
|
if(o + 3 >= cap) break;
|
||||||
|
out[o++] = '\\'; out[o++] = c;
|
||||||
|
}
|
||||||
|
else if((unsigned char)c < 0x20)
|
||||||
|
{
|
||||||
|
// Skip control bytes; the in-game name editor only emits ASCII
|
||||||
|
// letters anyway. Logging this would be noise.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else out[o++] = c;
|
||||||
|
}
|
||||||
|
out[o] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find `"key":` and return pointer to first char after the colon, or NULL.
|
||||||
|
const char *find_key(const char *body, const char *key)
|
||||||
|
{
|
||||||
|
size_t klen = strlen(key);
|
||||||
|
for(const char *p = body; (p = strchr(p, '"')) != NULL; ++p)
|
||||||
|
{
|
||||||
|
++p;
|
||||||
|
if(strncmp(p, key, klen) != 0) continue;
|
||||||
|
if(p[klen] != '"') continue;
|
||||||
|
const char *q = p + klen + 1;
|
||||||
|
while(*q == ' ' || *q == '\t') ++q;
|
||||||
|
if(*q != ':') continue;
|
||||||
|
++q;
|
||||||
|
while(*q == ' ' || *q == '\t') ++q;
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parse_int(const char *p, int *out)
|
||||||
|
{
|
||||||
|
if(!p) return false;
|
||||||
|
char *end = NULL;
|
||||||
|
long v = strtol(p, &end, 10);
|
||||||
|
if(end == p) return false;
|
||||||
|
*out = (int)v;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copies the next "string" into out, returns pointer past the closing quote.
|
||||||
|
const char *parse_string(const char *p, char *out, size_t cap)
|
||||||
|
{
|
||||||
|
if(!p || *p != '"') { if(cap) out[0] = '\0'; return NULL; }
|
||||||
|
++p;
|
||||||
|
size_t o = 0;
|
||||||
|
while(*p && *p != '"')
|
||||||
|
{
|
||||||
|
if(*p == '\\' && p[1])
|
||||||
|
{
|
||||||
|
if(o + 1 < cap) out[o++] = p[1];
|
||||||
|
p += 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if(o + 1 < cap) out[o++] = *p;
|
||||||
|
++p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(cap) out[o] = '\0';
|
||||||
|
return *p == '"' ? p + 1 : NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- callbacks --------------------------------------------------------------
|
||||||
|
|
||||||
|
void on_submit(const http_response_t *resp, void * /*user*/)
|
||||||
|
{
|
||||||
|
if(resp->status == 0)
|
||||||
|
{
|
||||||
|
g_sub.status = LB_OFFLINE;
|
||||||
|
log_printf(WLOG, "leaderboard: submission failed (network)\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(resp->status / 100 != 2)
|
||||||
|
{
|
||||||
|
g_sub.status = LB_FAILED;
|
||||||
|
log_printf(WLOG, "leaderboard: submission rejected (HTTP %d)\n",
|
||||||
|
resp->status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int rank = -1, total = -1;
|
||||||
|
parse_int(find_key(resp->body, "rank"), &rank);
|
||||||
|
parse_int(find_key(resp->body, "total"), &total);
|
||||||
|
g_sub.rank = rank;
|
||||||
|
g_sub.total = total;
|
||||||
|
g_sub.status = LB_DONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_browse(const http_response_t *resp, void * /*user*/)
|
||||||
|
{
|
||||||
|
if(resp->status == 0) { g_brw.status = LB_OFFLINE; return; }
|
||||||
|
if(resp->status / 100 != 2) { g_brw.status = LB_FAILED; return; }
|
||||||
|
|
||||||
|
g_brw.count = 0;
|
||||||
|
int total = -1;
|
||||||
|
parse_int(find_key(resp->body, "total"), &total);
|
||||||
|
g_brw.total = total;
|
||||||
|
|
||||||
|
// "scores": [ {name, score, end_scene}, ... ]
|
||||||
|
const char *p = find_key(resp->body, "scores");
|
||||||
|
if(!p || *p != '[') { g_brw.status = LB_FAILED; return; }
|
||||||
|
++p;
|
||||||
|
while(g_brw.count < LEADERBOARD_BROWSE_MAX)
|
||||||
|
{
|
||||||
|
while(*p && *p != '{' && *p != ']') ++p;
|
||||||
|
if(*p != '{') break;
|
||||||
|
// Each entry is a small object — scan it locally.
|
||||||
|
const char *end = strchr(p, '}');
|
||||||
|
if(!end) break;
|
||||||
|
|
||||||
|
leaderboard_entry_t &e = g_brw.entries[g_brw.count];
|
||||||
|
e.name[0] = '\0';
|
||||||
|
e.score = 0;
|
||||||
|
e.end_scene = 0;
|
||||||
|
|
||||||
|
const char *kp = find_key(p, "name");
|
||||||
|
if(kp && kp < end) parse_string(kp, e.name, sizeof(e.name));
|
||||||
|
|
||||||
|
kp = find_key(p, "score");
|
||||||
|
int sv = 0;
|
||||||
|
if(kp && kp < end && parse_int(kp, &sv) && sv >= 0)
|
||||||
|
e.score = (unsigned int)sv;
|
||||||
|
|
||||||
|
kp = find_key(p, "end_scene");
|
||||||
|
if(kp && kp < end) parse_int(kp, &e.end_scene);
|
||||||
|
|
||||||
|
++g_brw.count;
|
||||||
|
p = end + 1;
|
||||||
|
}
|
||||||
|
g_brw.status = LB_DONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
namespace leaderboard {
|
||||||
|
|
||||||
|
void submit_score(const s_hiscore_t *hi)
|
||||||
|
{
|
||||||
|
g_sub.status = LB_IDLE;
|
||||||
|
g_sub.rank = -1;
|
||||||
|
g_sub.total = -1;
|
||||||
|
if(!enabled() || !hi) return;
|
||||||
|
|
||||||
|
char name_esc[LEADERBOARD_NAME_MAX * 2 + 1];
|
||||||
|
char board_esc[LEADERBOARD_NAME_MAX * 2 + 1];
|
||||||
|
char id_esc[128];
|
||||||
|
const char *player_name = (hi->profile && hi->profile->name[0])
|
||||||
|
? hi->profile->name : "ANON";
|
||||||
|
json_escape(name_esc, sizeof(name_esc), player_name);
|
||||||
|
json_escape(board_esc, sizeof(board_esc), prefs->leaderboard_board);
|
||||||
|
json_escape(id_esc, sizeof(id_esc), prefs->client_id);
|
||||||
|
|
||||||
|
char body[1024];
|
||||||
|
int n = snprintf(body, sizeof(body),
|
||||||
|
"{\"board\":\"%s\",\"name\":\"%s\",\"client_id\":\"%s\","
|
||||||
|
"\"client\":\"kobodl/0.5.1\","
|
||||||
|
"\"score\":%u,\"skill\":%d,\"gametype\":%d,"
|
||||||
|
"\"end_scene\":%d,\"end_lives\":%d,\"end_health\":%d,"
|
||||||
|
"\"playtime\":%u,\"start_date\":%u,\"end_date\":%u}",
|
||||||
|
board_esc, name_esc, id_esc,
|
||||||
|
hi->score, hi->skill, hi->gametype,
|
||||||
|
hi->end_scene, hi->end_lives, hi->end_health,
|
||||||
|
hi->playtime, hi->start_date, hi->end_date);
|
||||||
|
if(n <= 0 || n >= (int)sizeof(body)) return;
|
||||||
|
|
||||||
|
char url[512];
|
||||||
|
snprintf(url, sizeof(url), "%s/api/scores", prefs->leaderboard_url);
|
||||||
|
|
||||||
|
g_sub.status = LB_INFLIGHT;
|
||||||
|
http_post_json(url, body, on_submit, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetch_board()
|
||||||
|
{
|
||||||
|
g_brw.status = LB_IDLE;
|
||||||
|
g_brw.count = 0;
|
||||||
|
g_brw.total = 0;
|
||||||
|
if(!enabled()) return;
|
||||||
|
|
||||||
|
strncpy(g_brw.board, prefs->leaderboard_board, sizeof(g_brw.board) - 1);
|
||||||
|
g_brw.board[sizeof(g_brw.board) - 1] = '\0';
|
||||||
|
|
||||||
|
char board_esc[LEADERBOARD_NAME_MAX * 2 + 1];
|
||||||
|
json_escape(board_esc, sizeof(board_esc), prefs->leaderboard_board);
|
||||||
|
|
||||||
|
char url[768];
|
||||||
|
snprintf(url, sizeof(url),
|
||||||
|
"%s/api/scores?board=%s&limit=%d",
|
||||||
|
prefs->leaderboard_url, board_esc, LEADERBOARD_BROWSE_MAX);
|
||||||
|
|
||||||
|
g_brw.status = LB_INFLIGHT;
|
||||||
|
http_get(url, on_browse, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaderboard_submission_t *submission() { return &g_sub; }
|
||||||
|
const leaderboard_browse_t *browse() { return &g_brw; }
|
||||||
|
|
||||||
|
void reset_submission()
|
||||||
|
{
|
||||||
|
g_sub.status = LB_IDLE;
|
||||||
|
g_sub.rank = -1;
|
||||||
|
g_sub.total = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace leaderboard
|
||||||
69
leaderboard.h
Normal file
69
leaderboard.h
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* leaderboard.h — global leaderboard client.
|
||||||
|
*
|
||||||
|
* Two flows:
|
||||||
|
*
|
||||||
|
* 1. submit_score() is called after each completed game (manage.cpp). It
|
||||||
|
* POSTs the run's s_hiscore_t to leaderboard_url + "/api/scores". The
|
||||||
|
* game-over screen polls submission() to render the in-flight UI.
|
||||||
|
*
|
||||||
|
* 2. fetch_board() is called when the in-game leaderboard browser opens.
|
||||||
|
* It GETs the top N for the current board. The browser state polls
|
||||||
|
* browse() to render the list.
|
||||||
|
*
|
||||||
|
* Both flows are no-ops when prefs->global_leaderboard is 0, or when the
|
||||||
|
* board name is empty, or when the URL is empty.
|
||||||
|
*/
|
||||||
|
#ifndef KOBO_LEADERBOARD_H
|
||||||
|
#define KOBO_LEADERBOARD_H
|
||||||
|
|
||||||
|
#include "score.h"
|
||||||
|
|
||||||
|
#define LEADERBOARD_BROWSE_MAX 50
|
||||||
|
#define LEADERBOARD_NAME_MAX 32
|
||||||
|
|
||||||
|
enum leaderboard_status_t {
|
||||||
|
LB_IDLE = 0,
|
||||||
|
LB_INFLIGHT,
|
||||||
|
LB_DONE,
|
||||||
|
LB_FAILED, // server error, network error, or feature disabled
|
||||||
|
LB_OFFLINE // transport failure (status 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
struct leaderboard_submission_t {
|
||||||
|
leaderboard_status_t status;
|
||||||
|
int rank; // valid when status == LB_DONE
|
||||||
|
int total; // valid when status == LB_DONE
|
||||||
|
};
|
||||||
|
|
||||||
|
struct leaderboard_entry_t {
|
||||||
|
char name[LEADERBOARD_NAME_MAX];
|
||||||
|
unsigned int score;
|
||||||
|
int end_scene;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct leaderboard_browse_t {
|
||||||
|
leaderboard_status_t status;
|
||||||
|
int count;
|
||||||
|
int total;
|
||||||
|
leaderboard_entry_t entries[LEADERBOARD_BROWSE_MAX];
|
||||||
|
char board[LEADERBOARD_NAME_MAX];
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace leaderboard {
|
||||||
|
// Called from manage.cpp after scorefile.record(). Safe to call always;
|
||||||
|
// it self-checks prefs and silently no-ops if disabled.
|
||||||
|
void submit_score(const s_hiscore_t *hi);
|
||||||
|
|
||||||
|
// Called from the browser state on enter. Replaces previous results.
|
||||||
|
void fetch_board();
|
||||||
|
|
||||||
|
// State accessors — game UI reads these every frame.
|
||||||
|
const leaderboard_submission_t *submission();
|
||||||
|
const leaderboard_browse_t *browse();
|
||||||
|
|
||||||
|
// Reset submission state to LB_IDLE (e.g. when starting a new game).
|
||||||
|
void reset_submission();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // KOBO_LEADERBOARD_H
|
||||||
16
manage.cpp
16
manage.cpp
@ -52,6 +52,8 @@
|
|||||||
#include "random.h"
|
#include "random.h"
|
||||||
#include "prof.h"
|
#include "prof.h"
|
||||||
#include "bench.h"
|
#include "bench.h"
|
||||||
|
#include "leaderboard.h"
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
#define GIGA 1000000000
|
#define GIGA 1000000000
|
||||||
|
|
||||||
@ -213,6 +215,8 @@ void _manage::game_start()
|
|||||||
{
|
{
|
||||||
_game_over = 0;
|
_game_over = 0;
|
||||||
hi.clear();
|
hi.clear();
|
||||||
|
hi.start_date = (unsigned int)time(NULL);
|
||||||
|
leaderboard::reset_submission();
|
||||||
|
|
||||||
game.set(GAME_SINGLE, (skill_levels_t)scorefile.profile()->skill);
|
game.set(GAME_SINGLE, (skill_levels_t)scorefile.profile()->skill);
|
||||||
|
|
||||||
@ -267,8 +271,20 @@ void _manage::game_stop()
|
|||||||
hi.score = score;
|
hi.score = score;
|
||||||
hi.end_scene = scene_num;
|
hi.end_scene = scene_num;
|
||||||
hi.end_health = myship.health();
|
hi.end_health = myship.health();
|
||||||
|
hi.end_date = (unsigned int)time(NULL);
|
||||||
|
// Profile name isn't part of s_hiscore_t's serialized form, but
|
||||||
|
// leaderboard::submit_score wants it. Sync it here so the
|
||||||
|
// payload picks it up.
|
||||||
|
s_profile_t *p = scorefile.profile();
|
||||||
|
if(p)
|
||||||
|
{
|
||||||
|
hi.profile = p;
|
||||||
|
strncpy(hi.name, p->name, sizeof(hi.name) - 1);
|
||||||
|
hi.name[sizeof(hi.name) - 1] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
scorefile.record(&hi);
|
scorefile.record(&hi);
|
||||||
|
leaderboard::submit_score(&hi);
|
||||||
}
|
}
|
||||||
ships = 0;
|
ships = 0;
|
||||||
ships_changed = 1;
|
ships_changed = 1;
|
||||||
|
|||||||
@ -458,6 +458,14 @@ void game_options_t::build()
|
|||||||
item("Off", 200);
|
item("Off", 200);
|
||||||
item("Low", 100);
|
item("Low", 100);
|
||||||
item("High", 50);
|
item("High", 50);
|
||||||
|
|
||||||
|
// Leaderboard toggle. Board name and URL live in the config file
|
||||||
|
// (the form toolkit has no free-text widget). When this is on but
|
||||||
|
// the board name is empty, submission no-ops silently.
|
||||||
|
space();
|
||||||
|
xoffs = 0.6;
|
||||||
|
onoff("Global Leaderboard", &prf->global_leaderboard, 0);
|
||||||
|
|
||||||
big();
|
big();
|
||||||
space();
|
space();
|
||||||
xoffs = 0.5;
|
xoffs = 0.5;
|
||||||
|
|||||||
12
prefs.cpp
12
prefs.cpp
@ -104,6 +104,18 @@ void prefs_t::init()
|
|||||||
key("sfx", sfxdir, ""); desc("Sound Data Path");
|
key("sfx", sfxdir, ""); desc("Sound Data Path");
|
||||||
key("scores", scoredir, ""); desc("Score File Path");
|
key("scores", scoredir, ""); desc("Score File Path");
|
||||||
|
|
||||||
|
comment("--- Global leaderboard ---------------------");
|
||||||
|
// Default off: this code is shipped dormant until the backend at
|
||||||
|
// leaderboard_url exists. Flip on once the server is live.
|
||||||
|
yesno("global_leaderboard", global_leaderboard, 0);
|
||||||
|
desc("Submit Scores to Global Leaderboard");
|
||||||
|
key("leaderboard_board", leaderboard_board, "");
|
||||||
|
desc("Leaderboard Board Name (shared with friends)");
|
||||||
|
key("leaderboard_url", leaderboard_url,
|
||||||
|
"https://leaderboard.lindholm.dev");
|
||||||
|
desc("Leaderboard Server URL");
|
||||||
|
key("client_id", client_id, ""); desc("Anonymous Per-Install ID");
|
||||||
|
|
||||||
// Obsolete stuff (not written into new files)
|
// Obsolete stuff (not written into new files)
|
||||||
key("size", o_size, 0, 0); desc("Screen Size (Obsolete)");
|
key("size", o_size, 0, 0); desc("Screen Size (Obsolete)");
|
||||||
key("wait", o_wait_msec, 30); desc("Game Speed (Obsolete)");
|
key("wait", o_wait_msec, 30); desc("Game Speed (Obsolete)");
|
||||||
|
|||||||
6
prefs.h
6
prefs.h
@ -95,6 +95,12 @@ class prefs_t : public config_parser_t
|
|||||||
cfg_string_t sfxdir; //Path to sfx/
|
cfg_string_t sfxdir; //Path to sfx/
|
||||||
cfg_string_t scoredir; //Path to scores/
|
cfg_string_t scoredir; //Path to scores/
|
||||||
|
|
||||||
|
//Global leaderboard
|
||||||
|
int global_leaderboard; //Submit scores to the server
|
||||||
|
cfg_string_t leaderboard_board; //Shared "room code"
|
||||||
|
cfg_string_t leaderboard_url; //Server endpoint base URL
|
||||||
|
cfg_string_t client_id; //Per-install UUID (auto-gen)
|
||||||
|
|
||||||
//Obsolete stuff (compatibility)
|
//Obsolete stuff (compatibility)
|
||||||
int o_size; //Screen scale
|
int o_size; //Screen scale
|
||||||
int o_wait_msec; //ms per control system frame
|
int o_wait_msec; //ms per control system frame
|
||||||
|
|||||||
145
states.cpp
145
states.cpp
@ -38,6 +38,9 @@
|
|||||||
#include "sound.h"
|
#include "sound.h"
|
||||||
#include "radar.h"
|
#include "radar.h"
|
||||||
#include "random.h"
|
#include "random.h"
|
||||||
|
#include "leaderboard.h"
|
||||||
|
#include "prefs.h"
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
|
||||||
gamestatemanager_t gsm;
|
gamestatemanager_t gsm;
|
||||||
@ -664,6 +667,41 @@ void st_game_over_t::post_render()
|
|||||||
int y = PIXEL2CS(100) + (int)floor(PIXEL2CS(15)*sin(ft * 6));
|
int y = PIXEL2CS(100) + (int)floor(PIXEL2CS(15)*sin(ft * 6));
|
||||||
wmain->center_fxp(y, "GAME OVER");
|
wmain->center_fxp(y, "GAME OVER");
|
||||||
|
|
||||||
|
const leaderboard_submission_t *sub = leaderboard::submission();
|
||||||
|
if(sub->status != LB_IDLE)
|
||||||
|
{
|
||||||
|
char buf[64];
|
||||||
|
const char *msg = NULL;
|
||||||
|
switch(sub->status)
|
||||||
|
{
|
||||||
|
case LB_INFLIGHT:
|
||||||
|
msg = "Submitting score…";
|
||||||
|
break;
|
||||||
|
case LB_DONE:
|
||||||
|
if(sub->rank > 0 && sub->total > 0)
|
||||||
|
{
|
||||||
|
snprintf(buf, sizeof(buf), "Rank #%d of %d",
|
||||||
|
sub->rank, sub->total);
|
||||||
|
msg = buf;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
msg = "Score submitted";
|
||||||
|
break;
|
||||||
|
case LB_OFFLINE:
|
||||||
|
msg = "(offline)";
|
||||||
|
break;
|
||||||
|
case LB_FAILED:
|
||||||
|
msg = "(submission failed)";
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if(msg)
|
||||||
|
{
|
||||||
|
wmain->font(B_NORMAL_FONT);
|
||||||
|
wmain->center_fxp(PIXEL2CS(140), msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
wradar->frame();
|
wradar->frame();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1281,6 +1319,8 @@ void main_menu_t::build()
|
|||||||
}
|
}
|
||||||
space();
|
space();
|
||||||
button("Options", 2);
|
button("Options", 2);
|
||||||
|
if(prefs->global_leaderboard && prefs->leaderboard_board[0])
|
||||||
|
button("Global Leaderboard", 200);
|
||||||
space();
|
space();
|
||||||
if(manage.game_stopped())
|
if(manage.game_stopped())
|
||||||
button("Return to Intro", 0);
|
button("Return to Intro", 0);
|
||||||
@ -1367,6 +1407,9 @@ void st_main_menu_t::select(int tag)
|
|||||||
case 3:
|
case 3:
|
||||||
gsm.push(&st_new_player);
|
gsm.push(&st_new_player);
|
||||||
break;
|
break;
|
||||||
|
case 200:
|
||||||
|
gsm.push(&st_global_leaderboard);
|
||||||
|
break;
|
||||||
case 4: // Player: Inc/Dec
|
case 4: // Player: Inc/Dec
|
||||||
sound.ui_tick();
|
sound.ui_tick();
|
||||||
prefs->changed = 1;
|
prefs->changed = 1;
|
||||||
@ -1997,3 +2040,105 @@ st_profile_audio_t st_profile_audio;
|
|||||||
|
|
||||||
#endif /*PROFILE_AUDIO*/
|
#endif /*PROFILE_AUDIO*/
|
||||||
|
|
||||||
|
|
||||||
|
/*----------------------------------------------------------
|
||||||
|
Global Leaderboard browser
|
||||||
|
----------------------------------------------------------*/
|
||||||
|
|
||||||
|
st_global_leaderboard_t::st_global_leaderboard_t()
|
||||||
|
{
|
||||||
|
name = "global_leaderboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
void st_global_leaderboard_t::enter()
|
||||||
|
{
|
||||||
|
start_time = (int)SDL_GetTicks();
|
||||||
|
leaderboard::fetch_board();
|
||||||
|
}
|
||||||
|
|
||||||
|
void st_global_leaderboard_t::press(int button)
|
||||||
|
{
|
||||||
|
switch(button)
|
||||||
|
{
|
||||||
|
case BTN_EXIT:
|
||||||
|
case BTN_NO:
|
||||||
|
case BTN_FIRE:
|
||||||
|
case BTN_START:
|
||||||
|
case BTN_SELECT:
|
||||||
|
case BTN_YES:
|
||||||
|
sound.ui_ok();
|
||||||
|
pop();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void st_global_leaderboard_t::frame()
|
||||||
|
{
|
||||||
|
manage.run_intro();
|
||||||
|
}
|
||||||
|
|
||||||
|
void st_global_leaderboard_t::post_render()
|
||||||
|
{
|
||||||
|
kobo_basestate_t::post_render();
|
||||||
|
|
||||||
|
const leaderboard_browse_t *b = leaderboard::browse();
|
||||||
|
|
||||||
|
wmain->font(B_BIG_FONT);
|
||||||
|
wmain->center(40, "GLOBAL LEADERBOARD");
|
||||||
|
|
||||||
|
char buf[96];
|
||||||
|
wmain->font(B_NORMAL_FONT);
|
||||||
|
if(b->board[0])
|
||||||
|
{
|
||||||
|
snprintf(buf, sizeof(buf), "Board: %s", b->board);
|
||||||
|
wmain->center(70, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch(b->status)
|
||||||
|
{
|
||||||
|
case LB_INFLIGHT:
|
||||||
|
wmain->center(120, "Loading…");
|
||||||
|
break;
|
||||||
|
case LB_OFFLINE:
|
||||||
|
wmain->center(120, "(offline)");
|
||||||
|
break;
|
||||||
|
case LB_FAILED:
|
||||||
|
wmain->center(120, "(server error)");
|
||||||
|
break;
|
||||||
|
case LB_IDLE:
|
||||||
|
wmain->center(120, "Leaderboard disabled.");
|
||||||
|
wmain->center(140, "Set leaderboard_board in config.");
|
||||||
|
break;
|
||||||
|
case LB_DONE:
|
||||||
|
{
|
||||||
|
if(b->count == 0)
|
||||||
|
{
|
||||||
|
wmain->center(120, "No scores yet.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Two-column layout: rank + name on the left, score + stage
|
||||||
|
// on the right. 18px line height matches the existing intro
|
||||||
|
// high-score screen feel.
|
||||||
|
int y = 100;
|
||||||
|
int rows = b->count > 16 ? 16 : b->count;
|
||||||
|
for(int i = 0; i < rows; ++i)
|
||||||
|
{
|
||||||
|
const leaderboard_entry_t &e = b->entries[i];
|
||||||
|
snprintf(buf, sizeof(buf), "%2d. %-10s",
|
||||||
|
i + 1, e.name);
|
||||||
|
wmain->string(40, y, buf);
|
||||||
|
snprintf(buf, sizeof(buf), "%9u st.%2d",
|
||||||
|
e.score, e.end_scene + 1);
|
||||||
|
wmain->string(160, y, buf);
|
||||||
|
y += 12;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
|
||||||
|
wmain->center(WSIZE - 30, "(Press any key to return)");
|
||||||
|
}
|
||||||
|
|
||||||
|
st_global_leaderboard_t st_global_leaderboard;
|
||||||
|
|
||||||
|
|||||||
17
states.h
17
states.h
@ -473,6 +473,22 @@ class st_ask_abort_game_t : public st_yesno_base_t
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/*----------------------------------------------------------
|
||||||
|
Global Leaderboard browser
|
||||||
|
----------------------------------------------------------*/
|
||||||
|
|
||||||
|
class st_global_leaderboard_t : public kobo_basestate_t
|
||||||
|
{
|
||||||
|
int start_time;
|
||||||
|
public:
|
||||||
|
st_global_leaderboard_t();
|
||||||
|
void enter();
|
||||||
|
void press(int button);
|
||||||
|
void frame();
|
||||||
|
void post_render();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/*----------------------------------------------------------
|
/*----------------------------------------------------------
|
||||||
Debug: Audio Engine Profiling
|
Debug: Audio Engine Profiling
|
||||||
----------------------------------------------------------*/
|
----------------------------------------------------------*/
|
||||||
@ -534,6 +550,7 @@ extern st_options_graphics_t st_options_graphics;
|
|||||||
extern st_options_audio_t st_options_audio;
|
extern st_options_audio_t st_options_audio;
|
||||||
extern st_options_control_t st_options_control;
|
extern st_options_control_t st_options_control;
|
||||||
extern st_options_game_t st_options_game;
|
extern st_options_game_t st_options_game;
|
||||||
|
extern st_global_leaderboard_t st_global_leaderboard;
|
||||||
extern st_error_t st_error;
|
extern st_error_t st_error;
|
||||||
#ifdef PROFILE_AUDIO
|
#ifdef PROFILE_AUDIO
|
||||||
extern st_profile_audio_t st_profile_audio;
|
extern st_profile_audio_t st_profile_audio;
|
||||||
|
|||||||
98
tools/mock-leaderboard.py
Executable file
98
tools/mock-leaderboard.py
Executable file
@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mock leaderboard server for local testing.
|
||||||
|
|
||||||
|
Stores submitted scores in memory, returns rank/total on POST, and serves
|
||||||
|
the top-N on GET. Sends permissive CORS so the web build can talk to it
|
||||||
|
from a different port.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./tools/mock-leaderboard.py # listens on :8765
|
||||||
|
./tools/mock-leaderboard.py 8080 # custom port
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
# In-memory store: {board_name: [score_record, ...]}
|
||||||
|
SCORES = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cors(handler):
|
||||||
|
handler.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
handler.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send_response(204)
|
||||||
|
_cors(self)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
url = urlparse(self.path)
|
||||||
|
if url.path != "/api/scores":
|
||||||
|
self.send_response(404); _cors(self); self.end_headers(); return
|
||||||
|
q = parse_qs(url.query)
|
||||||
|
board = (q.get("board") or [""])[0]
|
||||||
|
limit = int((q.get("limit") or ["50"])[0])
|
||||||
|
rows = sorted(SCORES.get(board, []),
|
||||||
|
key=lambda r: r["score"], reverse=True)[:limit]
|
||||||
|
body = json.dumps({
|
||||||
|
"board": board,
|
||||||
|
"total": len(SCORES.get(board, [])),
|
||||||
|
"scores": [
|
||||||
|
{"name": r["name"], "score": r["score"],
|
||||||
|
"end_scene": r["end_scene"]}
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
_cors(self)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if urlparse(self.path).path != "/api/scores":
|
||||||
|
self.send_response(404); _cors(self); self.end_headers(); return
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.rfile.read(length))
|
||||||
|
except Exception as e:
|
||||||
|
self.send_response(400); _cors(self); self.end_headers()
|
||||||
|
self.wfile.write(f"bad json: {e}".encode())
|
||||||
|
return
|
||||||
|
board = payload.get("board", "")
|
||||||
|
SCORES.setdefault(board, []).append(payload)
|
||||||
|
# Rank: 1 = best. Sort descending, find position.
|
||||||
|
ranked = sorted(SCORES[board], key=lambda r: r["score"], reverse=True)
|
||||||
|
# Identify *this* submission by reference equality.
|
||||||
|
rank = next((i + 1 for i, r in enumerate(ranked) if r is payload), -1)
|
||||||
|
body = json.dumps({"rank": rank, "total": len(SCORES[board])}).encode()
|
||||||
|
print(f"[POST] board={board} name={payload.get('name')} "
|
||||||
|
f"score={payload.get('score')} -> #{rank}/{len(SCORES[board])}")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
_cors(self)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
# Keep the noise down; we print our own line on POST.
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
|
||||||
|
srv = HTTPServer(("127.0.0.1", port), Handler)
|
||||||
|
print(f"mock-leaderboard listening on http://127.0.0.1:{port}")
|
||||||
|
try:
|
||||||
|
srv.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user