Fix #28: stash live stats for beforeunload so leaderboard gets real score #31
@@ -103,20 +103,39 @@ EM_JS(void, js_analytics_session_start, (), {
|
||||
});
|
||||
});
|
||||
|
||||
/* Stash current stats into Module._analyticsLastStats so the
|
||||
* beforeunload fallback has real data if the user closes the tab.
|
||||
* Called periodically from C (e.g. once per second). */
|
||||
EM_JS(void, js_analytics_stash_stats, (int score, int level_reached,
|
||||
int lives_used, int duration_secs), {
|
||||
if (!Module._analyticsUrl) return;
|
||||
Module._analyticsLastStats = JSON.stringify({
|
||||
score: score,
|
||||
level_reached: level_reached > 0 ? level_reached : 1,
|
||||
lives_used: lives_used,
|
||||
duration_seconds: duration_secs,
|
||||
end_reason: 'quit'
|
||||
});
|
||||
});
|
||||
|
||||
/* Internal helper: send the session-end POST (used by both the C wrapper
|
||||
* and the beforeunload fallback). */
|
||||
EM_JS(void, js_analytics_send_end, (int score, int level_reached,
|
||||
int lives_used, int duration_secs,
|
||||
const char *end_reason_ptr), {
|
||||
const char *end_reason_ptr,
|
||||
const char *player_name_ptr), {
|
||||
/* Helper that performs the actual end request given a session id. */
|
||||
function doEnd(sid, endReason, score, levelReached, livesUsed, durationSecs) {
|
||||
var body = JSON.stringify({
|
||||
function doEnd(sid, endReason, playerName, score, levelReached,
|
||||
livesUsed, durationSecs) {
|
||||
var payload = {
|
||||
score: score,
|
||||
level_reached: levelReached > 0 ? levelReached : 1,
|
||||
lives_used: livesUsed,
|
||||
duration_seconds: durationSecs,
|
||||
end_reason: endReason
|
||||
});
|
||||
};
|
||||
if (playerName) payload.player_name = playerName;
|
||||
var body = JSON.stringify(payload);
|
||||
|
||||
/* Stash stats for the beforeunload fallback */
|
||||
Module._analyticsLastStats = body;
|
||||
@@ -143,6 +162,7 @@ EM_JS(void, js_analytics_send_end, (int score, int level_reached,
|
||||
if (!Module._analyticsUrl) return;
|
||||
|
||||
var endReason = UTF8ToString(end_reason_ptr);
|
||||
var playerName = player_name_ptr ? UTF8ToString(player_name_ptr) : '';
|
||||
|
||||
/* If session start is still in-flight, wait for it before ending. */
|
||||
if (Module._analyticsStartPending) {
|
||||
@@ -153,7 +173,8 @@ EM_JS(void, js_analytics_send_end, (int score, int level_reached,
|
||||
var sid = Module._analyticsSessionId;
|
||||
if (sid) {
|
||||
Module._analyticsSessionId = null;
|
||||
doEnd(sid, endReason, score, level_reached, lives_used, duration_secs);
|
||||
doEnd(sid, endReason, playerName, score, level_reached,
|
||||
lives_used, duration_secs);
|
||||
}
|
||||
});
|
||||
return;
|
||||
@@ -164,7 +185,8 @@ EM_JS(void, js_analytics_send_end, (int score, int level_reached,
|
||||
var sid = Module._analyticsSessionId;
|
||||
/* Clear synchronously before the async request to prevent races */
|
||||
Module._analyticsSessionId = null;
|
||||
doEnd(sid, endReason, score, level_reached, lives_used, duration_secs);
|
||||
doEnd(sid, endReason, playerName, score, level_reached,
|
||||
lives_used, duration_secs);
|
||||
});
|
||||
|
||||
/* ── C wrappers ─────────────────────────────────────────────────── */
|
||||
@@ -177,14 +199,26 @@ void analytics_session_start(void) {
|
||||
js_analytics_session_start();
|
||||
}
|
||||
|
||||
void analytics_session_end(GameStats *stats, const char *end_reason) {
|
||||
void analytics_session_end(GameStats *stats, const char *end_reason,
|
||||
const char *player_name) {
|
||||
stats_update_score(stats);
|
||||
js_analytics_send_end(
|
||||
stats->score,
|
||||
stats->levels_completed > 0 ? stats->levels_completed : 1,
|
||||
stats->deaths,
|
||||
(int)stats->time_elapsed,
|
||||
end_reason
|
||||
end_reason,
|
||||
player_name
|
||||
);
|
||||
}
|
||||
|
||||
void analytics_stash_stats(GameStats *stats) {
|
||||
stats_update_score(stats);
|
||||
js_analytics_stash_stats(
|
||||
stats->score,
|
||||
stats->levels_completed > 0 ? stats->levels_completed : 1,
|
||||
stats->deaths,
|
||||
(int)stats->time_elapsed
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,9 +231,15 @@ void analytics_init(void) {
|
||||
|
||||
void analytics_session_start(void) {}
|
||||
|
||||
void analytics_session_end(GameStats *stats, const char *end_reason) {
|
||||
void analytics_session_end(GameStats *stats, const char *end_reason,
|
||||
const char *player_name) {
|
||||
(void)stats;
|
||||
(void)end_reason;
|
||||
(void)player_name;
|
||||
}
|
||||
|
||||
void analytics_stash_stats(GameStats *stats) {
|
||||
(void)stats;
|
||||
}
|
||||
|
||||
#endif /* __EMSCRIPTEN__ */
|
||||
|
||||
@@ -12,7 +12,14 @@ void analytics_session_start(void);
|
||||
|
||||
/* End the current analytics session with final stats.
|
||||
* Computes the composite score before sending.
|
||||
* end_reason: "death", "quit", "timeout", or "completed". */
|
||||
void analytics_session_end(GameStats *stats, const char *end_reason);
|
||||
* end_reason: "death", "quit", "timeout", or "completed".
|
||||
* player_name may be NULL or empty if no name was entered. */
|
||||
void analytics_session_end(GameStats *stats, const char *end_reason,
|
||||
const char *player_name);
|
||||
|
||||
/* Stash current stats into JS so the beforeunload fallback has
|
||||
* real data if the user closes the tab mid-game. Call periodically
|
||||
* (e.g. once per second or on level completion). No-op on native. */
|
||||
void analytics_stash_stats(GameStats *stats);
|
||||
|
||||
#endif /* JNR_ANALYTICS_H */
|
||||
|
||||
@@ -591,18 +591,14 @@ void level_update(Level *level, float dt) {
|
||||
/* Fallback direct exit zone check (for levels without ship exit) */
|
||||
check_exit_zones(level);
|
||||
|
||||
/* Check for player respawn (skip if player boarded exit ship) */
|
||||
/* Check for player death (skip if player boarded exit ship).
|
||||
* Don't auto-respawn — set a flag for main.c to handle lives. */
|
||||
if (!level->exit_ship_boarded) {
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) {
|
||||
stats_record_death();
|
||||
player_respawn(e, level->map.player_spawn);
|
||||
Vec2 center = vec2(
|
||||
e->body.pos.x + e->body.size.x * 0.5f,
|
||||
e->body.pos.y + e->body.size.y * 0.5f
|
||||
);
|
||||
camera_follow(&level->camera, center, vec2_zero(), dt);
|
||||
level->player_death_pending = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -759,6 +755,18 @@ void level_render(Level *level, float interpolation) {
|
||||
renderer_draw_rect(pos, size, heart_color, LAYER_HUD, cam);
|
||||
}
|
||||
|
||||
/* Draw lives counter next to hearts */
|
||||
{
|
||||
GameStats *st = stats_get_active();
|
||||
if (st && st->lives > 0) {
|
||||
char lives_buf[8];
|
||||
snprintf(lives_buf, sizeof(lives_buf), "x%d", st->lives);
|
||||
int lx = 8 + player->max_health * 14 + 4;
|
||||
font_draw_text(g_engine.renderer, lives_buf, lx, 8,
|
||||
(SDL_Color){200, 200, 200, 255});
|
||||
}
|
||||
}
|
||||
|
||||
/* Draw jetpack charge indicators */
|
||||
int charges, max_charges;
|
||||
float recharge_pct;
|
||||
@@ -817,6 +825,17 @@ void level_render(Level *level, float interpolation) {
|
||||
renderer_flush(cam);
|
||||
}
|
||||
|
||||
void level_respawn_player(Level *level) {
|
||||
level->player_death_pending = false;
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER) {
|
||||
player_respawn(e, level->map.player_spawn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void level_free(Level *level) {
|
||||
audio_stop_music();
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ typedef struct Level {
|
||||
bool exit_ship_spawned; /* exit spacecraft has been spawned */
|
||||
bool exit_ship_boarded; /* player has entered the ship */
|
||||
int exit_zone_idx; /* which exit zone the ship is for */
|
||||
|
||||
/* ── Death / game over ────────────────── */
|
||||
bool player_death_pending; /* player died, awaiting main.c */
|
||||
} Level;
|
||||
|
||||
bool level_load(Level *level, const char *path);
|
||||
@@ -39,4 +42,8 @@ void level_free(Level *level);
|
||||
* The target path is stored in level->exit_target. */
|
||||
bool level_exit_triggered(const Level *level);
|
||||
|
||||
/* Respawn the player at the level's spawn point. Called by main.c
|
||||
* after acknowledging a death (decrementing lives). */
|
||||
void level_respawn_player(Level *level);
|
||||
|
||||
#endif /* JNR_LEVEL_H */
|
||||
|
||||
@@ -16,6 +16,7 @@ typedef struct GameStats {
|
||||
int damage_taken; /* total HP lost */
|
||||
int damage_dealt; /* total HP dealt to enemies */
|
||||
float time_elapsed; /* wall-clock seconds */
|
||||
int lives; /* remaining lives (set by main) */
|
||||
} GameStats;
|
||||
|
||||
/* Reset all stats to zero (call at session start). */
|
||||
|
||||
199
src/main.c
199
src/main.c
@@ -27,6 +27,7 @@ typedef enum GameMode {
|
||||
MODE_EDITOR,
|
||||
MODE_PAUSED,
|
||||
MODE_TRANSITION,
|
||||
MODE_GAMEOVER,
|
||||
} GameMode;
|
||||
|
||||
static Level s_level;
|
||||
@@ -56,10 +57,22 @@ static int s_mars_depth = 0;
|
||||
static GameStats s_stats;
|
||||
static bool s_session_active = false;
|
||||
|
||||
/* ── Lives system ── */
|
||||
#define STARTING_LIVES 3
|
||||
static int s_lives = STARTING_LIVES;
|
||||
static float s_stash_timer = 0.0f; /* timer for periodic stats stash */
|
||||
#define STASH_INTERVAL 2.0f /* seconds between stats stashes */
|
||||
|
||||
/* ── Pause menu state ── */
|
||||
#define PAUSE_ITEM_COUNT 3
|
||||
static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */
|
||||
|
||||
/* ── Game over state ── */
|
||||
#define NAME_MAX_LEN 3
|
||||
static char s_go_name[NAME_MAX_LEN + 1] = "AAA"; /* player name for scoreboard */
|
||||
static int s_go_cursor = 0; /* which character is selected */
|
||||
static bool s_go_submitted = false; /* name has been submitted */
|
||||
|
||||
/* ── Level transition state ── */
|
||||
static TransitionState s_transition;
|
||||
static char s_pending_target[ASSET_PATH_MAX] = {0}; /* exit target stashed during transition */
|
||||
@@ -259,16 +272,19 @@ static void load_mars_base_level(void) {
|
||||
/* ── Analytics session helpers ── */
|
||||
static void begin_session(void) {
|
||||
stats_reset(&s_stats);
|
||||
s_lives = STARTING_LIVES;
|
||||
s_stats.lives = s_lives;
|
||||
s_stash_timer = 0.0f;
|
||||
stats_set_active(&s_stats);
|
||||
analytics_session_start();
|
||||
s_session_active = true;
|
||||
}
|
||||
|
||||
static void end_session(const char *reason) {
|
||||
static void end_session(const char *reason, const char *player_name) {
|
||||
if (!s_session_active) return;
|
||||
s_session_active = false;
|
||||
stats_set_active(NULL);
|
||||
analytics_session_end(&s_stats, reason);
|
||||
analytics_session_end(&s_stats, reason, player_name);
|
||||
}
|
||||
|
||||
/* ── Switch to editor mode ── */
|
||||
@@ -342,7 +358,7 @@ static void dispatch_level_load(const char *target) {
|
||||
if (target[0] == '\0') {
|
||||
/* Empty target = victory / end of game. */
|
||||
printf("Level complete! (no next level)\n");
|
||||
end_session("completed");
|
||||
end_session("completed", NULL);
|
||||
level_free(&s_level);
|
||||
s_station_depth = 0;
|
||||
s_mars_depth = 0;
|
||||
@@ -430,7 +446,7 @@ static void pause_update(void) {
|
||||
break;
|
||||
case 1: /* Restart */
|
||||
s_mode = MODE_PLAY;
|
||||
end_session("quit");
|
||||
end_session("quit", NULL);
|
||||
restart_level();
|
||||
begin_session();
|
||||
break;
|
||||
@@ -438,7 +454,7 @@ static void pause_update(void) {
|
||||
if (s_testing_from_editor) {
|
||||
return_to_editor();
|
||||
} else {
|
||||
end_session("quit");
|
||||
end_session("quit", NULL);
|
||||
g_engine.running = false;
|
||||
}
|
||||
break;
|
||||
@@ -462,11 +478,11 @@ static void game_update(float dt) {
|
||||
return;
|
||||
}
|
||||
|
||||
end_session("quit");
|
||||
end_session("quit", NULL);
|
||||
|
||||
/* Tear down whatever mode we are in. */
|
||||
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED
|
||||
|| s_mode == MODE_TRANSITION) {
|
||||
|| s_mode == MODE_TRANSITION || s_mode == MODE_GAMEOVER) {
|
||||
debuglog_set_level(NULL, NULL);
|
||||
transition_reset(&s_transition);
|
||||
level_free(&s_level);
|
||||
@@ -511,6 +527,11 @@ static void game_update(float dt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (s_mode == MODE_GAMEOVER) {
|
||||
gameover_update();
|
||||
return;
|
||||
}
|
||||
|
||||
if (s_mode == MODE_TRANSITION) {
|
||||
transition_update(&s_transition, dt, &s_level.camera);
|
||||
|
||||
@@ -566,7 +587,7 @@ static void game_update(float dt) {
|
||||
bool r_pressed = input_key_held(SDL_SCANCODE_R);
|
||||
if (r_pressed && !r_was_pressed) {
|
||||
printf("\n=== Regenerating level ===\n");
|
||||
end_session("quit");
|
||||
end_session("quit", NULL);
|
||||
debuglog_set_level(NULL, NULL);
|
||||
level_free(&s_level);
|
||||
s_gen_seed = (uint32_t)time(NULL);
|
||||
@@ -583,6 +604,25 @@ static void game_update(float dt) {
|
||||
s_stats.time_elapsed += dt;
|
||||
}
|
||||
|
||||
/* Check for player death — decrement lives or trigger game over */
|
||||
if (s_level.player_death_pending) {
|
||||
s_level.player_death_pending = false;
|
||||
s_lives--;
|
||||
s_stats.lives = s_lives;
|
||||
if (s_lives <= 0) {
|
||||
enter_gameover();
|
||||
return;
|
||||
}
|
||||
level_respawn_player(&s_level);
|
||||
}
|
||||
|
||||
/* Periodically stash stats so beforeunload has real data */
|
||||
s_stash_timer += dt;
|
||||
if (s_stash_timer >= STASH_INTERVAL && s_session_active) {
|
||||
analytics_stash_stats(&s_stats);
|
||||
s_stash_timer = 0.0f;
|
||||
}
|
||||
|
||||
/* Check for level exit transition */
|
||||
if (level_exit_triggered(&s_level)) {
|
||||
const char *target = s_level.exit_target;
|
||||
@@ -647,6 +687,140 @@ static void pause_render(void) {
|
||||
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
|
||||
}
|
||||
|
||||
/* ── Enter game over mode ── */
|
||||
static void enter_gameover(void) {
|
||||
s_mode = MODE_GAMEOVER;
|
||||
s_go_cursor = 0;
|
||||
s_go_submitted = false;
|
||||
memcpy(s_go_name, "AAA", NAME_MAX_LEN + 1);
|
||||
|
||||
/* Finalize score for display */
|
||||
stats_update_score(&s_stats);
|
||||
}
|
||||
|
||||
/* ── Game over: handle name entry input ── */
|
||||
static void gameover_update(void) {
|
||||
if (s_go_submitted) {
|
||||
/* After submission, wait for confirm to restart */
|
||||
bool confirm = input_pressed(ACTION_JUMP)
|
||||
|| input_key_pressed(SDL_SCANCODE_RETURN)
|
||||
|| input_key_pressed(SDL_SCANCODE_RETURN2);
|
||||
if (confirm) {
|
||||
end_session("death", s_go_name);
|
||||
debuglog_set_level(NULL, NULL);
|
||||
level_free(&s_level);
|
||||
s_station_depth = 0;
|
||||
s_mars_depth = 0;
|
||||
s_mode = MODE_PLAY;
|
||||
if (!load_level_file("assets/levels/moon01.lvl")) {
|
||||
g_engine.running = false;
|
||||
}
|
||||
begin_session();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* Cycle letter up/down */
|
||||
if (input_pressed(ACTION_UP)) {
|
||||
s_go_name[s_go_cursor]++;
|
||||
if (s_go_name[s_go_cursor] > 'Z') s_go_name[s_go_cursor] = 'A';
|
||||
}
|
||||
if (input_pressed(ACTION_DOWN)) {
|
||||
s_go_name[s_go_cursor]--;
|
||||
if (s_go_name[s_go_cursor] < 'A') s_go_name[s_go_cursor] = 'Z';
|
||||
}
|
||||
|
||||
/* Move cursor left/right */
|
||||
if (input_pressed(ACTION_LEFT)) {
|
||||
if (s_go_cursor > 0) s_go_cursor--;
|
||||
}
|
||||
if (input_pressed(ACTION_RIGHT)) {
|
||||
if (s_go_cursor < NAME_MAX_LEN - 1) s_go_cursor++;
|
||||
}
|
||||
|
||||
/* Confirm name */
|
||||
bool confirm = input_pressed(ACTION_JUMP)
|
||||
|| input_key_pressed(SDL_SCANCODE_RETURN)
|
||||
|| input_key_pressed(SDL_SCANCODE_RETURN2);
|
||||
if (confirm) {
|
||||
s_go_submitted = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Game over: render overlay ── */
|
||||
static void gameover_render(void) {
|
||||
SDL_Renderer *r = g_engine.renderer;
|
||||
|
||||
/* Dark overlay */
|
||||
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
|
||||
SDL_SetRenderDrawColor(r, 0, 0, 0, 180);
|
||||
SDL_Rect overlay = {0, 0, SCREEN_WIDTH, SCREEN_HEIGHT};
|
||||
SDL_RenderFillRect(r, &overlay);
|
||||
|
||||
SDL_Color col_title = {220, 50, 50, 255};
|
||||
SDL_Color col_text = {200, 200, 200, 255};
|
||||
SDL_Color col_score = {255, 220, 80, 255};
|
||||
SDL_Color col_dim = {120, 120, 130, 255};
|
||||
|
||||
int cy = SCREEN_HEIGHT / 2 - 50;
|
||||
|
||||
font_draw_text_centered(r, "GAME OVER", cy, SCREEN_WIDTH, col_title);
|
||||
cy += 20;
|
||||
|
||||
/* Show final score */
|
||||
{
|
||||
char score_buf[32];
|
||||
snprintf(score_buf, sizeof(score_buf), "SCORE: %d", s_stats.score);
|
||||
font_draw_text_centered(r, score_buf, cy, SCREEN_WIDTH, col_score);
|
||||
cy += 20;
|
||||
}
|
||||
|
||||
if (!s_go_submitted) {
|
||||
font_draw_text_centered(r, "ENTER YOUR NAME", cy, SCREEN_WIDTH, col_text);
|
||||
cy += 16;
|
||||
|
||||
/* Draw name characters with cursor highlight */
|
||||
{
|
||||
char name_display[16];
|
||||
snprintf(name_display, sizeof(name_display), "%c %c %c",
|
||||
s_go_name[0], s_go_name[1], s_go_name[2]);
|
||||
int nw = font_text_width(name_display);
|
||||
int nx = (SCREEN_WIDTH - nw) / 2;
|
||||
int spacing = nw / 3;
|
||||
|
||||
for (int i = 0; i < NAME_MAX_LEN; i++) {
|
||||
char ch[2] = { s_go_name[i], '\0' };
|
||||
SDL_Color c = (i == s_go_cursor) ? col_score : col_text;
|
||||
int cx = nx + i * spacing;
|
||||
font_draw_text(r, ch, cx, cy, c);
|
||||
|
||||
/* Draw cursor arrow above selected character */
|
||||
if (i == s_go_cursor) {
|
||||
font_draw_text(r, "^", cx, cy + 10, col_score);
|
||||
}
|
||||
}
|
||||
cy += 24;
|
||||
}
|
||||
|
||||
font_draw_text_centered(r, "UP/DOWN=LETTER LEFT/RIGHT=MOVE",
|
||||
cy, SCREEN_WIDTH, col_dim);
|
||||
cy += 12;
|
||||
font_draw_text_centered(r, "JUMP=CONFIRM", cy, SCREEN_WIDTH, col_dim);
|
||||
} else {
|
||||
/* Name submitted — show confirmation */
|
||||
{
|
||||
char msg[32];
|
||||
snprintf(msg, sizeof(msg), "NAME: %s", s_go_name);
|
||||
font_draw_text_centered(r, msg, cy, SCREEN_WIDTH, col_text);
|
||||
cy += 20;
|
||||
}
|
||||
font_draw_text_centered(r, "PRESS JUMP TO CONTINUE",
|
||||
cy, SCREEN_WIDTH, col_dim);
|
||||
}
|
||||
|
||||
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
|
||||
}
|
||||
|
||||
static void game_render(float interpolation) {
|
||||
if (s_mode == MODE_EDITOR) {
|
||||
editor_render(&s_editor, interpolation);
|
||||
@@ -658,20 +832,25 @@ static void game_render(float interpolation) {
|
||||
/* Render the level (frozen) with the transition overlay on top. */
|
||||
level_render(&s_level, interpolation);
|
||||
transition_render(&s_transition);
|
||||
} else if (s_mode == MODE_GAMEOVER) {
|
||||
/* Render frozen game frame with game over overlay. */
|
||||
level_render(&s_level, interpolation);
|
||||
gameover_render();
|
||||
} else {
|
||||
level_render(&s_level, interpolation);
|
||||
}
|
||||
}
|
||||
|
||||
static void game_shutdown(void) {
|
||||
end_session("quit");
|
||||
end_session("quit", NULL);
|
||||
debuglog_set_level(NULL, NULL);
|
||||
|
||||
/* Always free both — editor may have been initialized even if we're
|
||||
* currently in play mode (e.g. shutdown during test play). editor_free
|
||||
* and level_free are safe to call on zeroed/already-freed structs. */
|
||||
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED
|
||||
|| s_mode == MODE_TRANSITION || s_testing_from_editor) {
|
||||
|| s_mode == MODE_TRANSITION || s_mode == MODE_GAMEOVER
|
||||
|| s_testing_from_editor) {
|
||||
level_free(&s_level);
|
||||
}
|
||||
if (s_mode == MODE_EDITOR || s_use_editor) {
|
||||
|
||||
Reference in New Issue
Block a user