1026 lines
31 KiB
C
1026 lines
31 KiB
C
/*
|
|
* 'Portal' outro recreation.
|
|
* Reproducing the 'Still Alive' terminal credits in pure c
|
|
* I really enjoyed this game and this ending!
|
|
* Made by: Sidharth "Siddhi" Sharma (sidharthify)
|
|
*/
|
|
|
|
#ifndef _WIN32
|
|
#define _GNU_SOURCE
|
|
#define _POSIX_C_SOURCE 200809L
|
|
#endif
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <stdint.h>
|
|
#include <stdbool.h>
|
|
#include <time.h>
|
|
#include <signal.h>
|
|
|
|
#ifdef _WIN32
|
|
#ifndef WIN32_LEAN_AND_MEAN
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#endif
|
|
#include <windows.h>
|
|
#include <mmsystem.h>
|
|
#include <conio.h>
|
|
#include <io.h>
|
|
|
|
#ifndef STDIN_FILENO
|
|
#define STDIN_FILENO 0
|
|
#endif
|
|
#ifndef STDOUT_FILENO
|
|
#define STDOUT_FILENO 1
|
|
#endif
|
|
#ifndef STDERR_FILENO
|
|
#define STDERR_FILENO 2
|
|
#endif
|
|
|
|
#else
|
|
#include <unistd.h>
|
|
#include <termios.h>
|
|
#include <sys/ioctl.h>
|
|
#include <sys/types.h>
|
|
#include <sys/wait.h>
|
|
#include <fcntl.h>
|
|
#include <errno.h>
|
|
#endif
|
|
|
|
#include "stillalive_data.h"
|
|
|
|
#define CHAR_DASH 0x2d
|
|
#define AUDIO_START_MS 6750
|
|
#define CREDITS_START_MS 9000
|
|
#define BLINK_MS 300
|
|
#define CREDIT_VELOCITY_MS 68.623562
|
|
|
|
#define MAX_GRID_ROWS 256
|
|
#define MAX_GRID_COLS 512
|
|
|
|
/*
|
|
* scaling modes for adapting to different terminal dimensions.
|
|
* aspect mode keeps the original four by three ratio and fills the height.
|
|
* fill mode stretches the boxes across all rows and columns.
|
|
* classic mode locks the window to the original ninety eight by thirty eight box.
|
|
*/
|
|
typedef enum {
|
|
SCALE_ASPECT = 0,
|
|
SCALE_FILL = 1,
|
|
SCALE_CLASSIC = 2,
|
|
SCALE_MAX
|
|
} scale_mode_t;
|
|
|
|
static scale_mode_t g_scale_mode = SCALE_ASPECT;
|
|
static volatile sig_atomic_t g_winch_flag = 0;
|
|
|
|
static bool g_raw_mode = false;
|
|
static bool g_running = true;
|
|
static bool g_paused = false;
|
|
|
|
#ifdef _WIN32
|
|
static DWORD g_orig_in_mode = 0;
|
|
static DWORD g_orig_out_mode = 0;
|
|
static UINT g_orig_cp = 0;
|
|
static UINT g_orig_out_cp = 0;
|
|
static bool g_audio_playing = false;
|
|
|
|
static void stop_audio(void) {
|
|
if (g_audio_playing) {
|
|
mciSendStringA("stop stillalive_bgm", NULL, 0, NULL);
|
|
mciSendStringA("close stillalive_bgm", NULL, 0, NULL);
|
|
g_audio_playing = false;
|
|
}
|
|
}
|
|
|
|
static void pause_audio(void) {
|
|
if (g_audio_playing) {
|
|
mciSendStringA("pause stillalive_bgm", NULL, 0, NULL);
|
|
}
|
|
}
|
|
|
|
static void resume_audio(void) {
|
|
if (g_audio_playing) {
|
|
mciSendStringA("resume stillalive_bgm", NULL, 0, NULL);
|
|
}
|
|
}
|
|
|
|
static void start_audio(const char *path, double start_sec) {
|
|
if (!path) return;
|
|
char full_path[MAX_PATH];
|
|
if (_fullpath(full_path, path, sizeof(full_path)) == NULL) {
|
|
strncpy(full_path, path, sizeof(full_path) - 1);
|
|
full_path[sizeof(full_path) - 1] = '\0';
|
|
}
|
|
char cmd[MAX_PATH + 64];
|
|
mciSendStringA("close stillalive_bgm", NULL, 0, NULL);
|
|
snprintf(cmd, sizeof(cmd), "open \"%s\" type mpegvideo alias stillalive_bgm", full_path);
|
|
if (mciSendStringA(cmd, NULL, 0, NULL) != 0) {
|
|
snprintf(cmd, sizeof(cmd), "open \"%s\" alias stillalive_bgm", full_path);
|
|
if (mciSendStringA(cmd, NULL, 0, NULL) != 0) {
|
|
return;
|
|
}
|
|
}
|
|
mciSendStringA("set stillalive_bgm time format milliseconds", NULL, 0, NULL);
|
|
uint32_t from_ms = (uint32_t)(start_sec * 1000.0);
|
|
snprintf(cmd, sizeof(cmd), "play stillalive_bgm from %u", from_ms);
|
|
mciSendStringA(cmd, NULL, 0, NULL);
|
|
g_audio_playing = true;
|
|
}
|
|
|
|
static BOOL WINAPI console_ctrl_handler(DWORD ctrl_type) {
|
|
(void)ctrl_type;
|
|
g_running = false;
|
|
return TRUE;
|
|
}
|
|
|
|
#else
|
|
static struct termios g_orig_termios;
|
|
static pid_t g_audio_pid = 0;
|
|
|
|
static void stop_audio(void) {
|
|
if (g_audio_pid > 0) {
|
|
kill(g_audio_pid, SIGTERM);
|
|
waitpid(g_audio_pid, NULL, WNOHANG);
|
|
g_audio_pid = 0;
|
|
}
|
|
}
|
|
|
|
static void pause_audio(void) {
|
|
if (g_audio_pid > 0) {
|
|
kill(g_audio_pid, SIGSTOP);
|
|
}
|
|
}
|
|
|
|
static void resume_audio(void) {
|
|
if (g_audio_pid > 0) {
|
|
kill(g_audio_pid, SIGCONT);
|
|
}
|
|
}
|
|
#endif
|
|
|
|
static uint32_t g_credit_starts[NUM_CREDITS];
|
|
static uint32_t g_credit_durs[NUM_CREDITS];
|
|
|
|
static int g_current_art_id = ART_NONE;
|
|
static bool g_getting_faster = false;
|
|
static uint32_t g_next_shuffle_ms = 0;
|
|
static uint32_t g_shuffle_interval_ms = 5000;
|
|
|
|
static char g_grid[MAX_GRID_ROWS][MAX_GRID_COLS];
|
|
|
|
/*
|
|
* when exiting or interrupted we need to make sure the terminal is left
|
|
* in a clean usable state. that means showing the cursor again, returning
|
|
* from the alternate screen buffer, and restoring canonical echo mode.
|
|
* any child player spawned for audio is also cleaned up here.
|
|
*/
|
|
static void write_all(int fd, const void *buf, size_t count){
|
|
#ifdef _WIN32
|
|
(void)fd;
|
|
const char *p = (const char *)buf;
|
|
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
|
|
while (count > 0){
|
|
DWORD to_write = (count > 65535) ? 65535 : (DWORD)count;
|
|
DWORD written = 0;
|
|
if (!WriteFile(hOut, p, to_write, &written, NULL) || written == 0){
|
|
break;
|
|
}
|
|
p += written;
|
|
count -= (size_t)written;
|
|
}
|
|
#else
|
|
const char *p = (const char *)buf;
|
|
while (count > 0){
|
|
ssize_t n = write(fd, p, count);
|
|
if (n < 0){
|
|
if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK){
|
|
struct timespec ts = { .tv_sec = 0, .tv_nsec = 500000 };
|
|
nanosleep(&ts, NULL);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
p += n;
|
|
count -= (size_t)n;
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static void restore_terminal(void){
|
|
stop_audio();
|
|
printf("\033[?2025l\033[?7h\033[?25h\033[?1049l\033[0m");
|
|
fflush(stdout);
|
|
|
|
#ifdef _WIN32
|
|
if (g_raw_mode){
|
|
HANDLE hIn = GetStdHandle(STD_INPUT_HANDLE);
|
|
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
|
|
if (g_orig_in_mode != 0) {
|
|
SetConsoleMode(hIn, g_orig_in_mode);
|
|
}
|
|
if (g_orig_out_mode != 0) {
|
|
SetConsoleMode(hOut, g_orig_out_mode);
|
|
}
|
|
if (g_orig_cp != 0) {
|
|
SetConsoleCP(g_orig_cp);
|
|
}
|
|
if (g_orig_out_cp != 0) {
|
|
SetConsoleOutputCP(g_orig_out_cp);
|
|
}
|
|
g_raw_mode = false;
|
|
}
|
|
#else
|
|
if (g_raw_mode){
|
|
tcsetattr(STDIN_FILENO, TCSANOW, &g_orig_termios);
|
|
g_raw_mode = false;
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static void handle_signal(int sig){
|
|
(void)sig;
|
|
g_running = false;
|
|
}
|
|
|
|
/*
|
|
* sigwinch fires whenever the user resizes their window or goes fullscreen.
|
|
* we record the event so the renderer can immediately issue a full wipe
|
|
* before rendering the newly scaled layout.
|
|
*/
|
|
#ifndef _WIN32
|
|
static void handle_winch(int sig) {
|
|
(void)sig;
|
|
g_winch_flag = 1;
|
|
}
|
|
#endif
|
|
|
|
/*
|
|
* raw mode turns off line buffering and echo so keypresses like spacebar
|
|
* or the quit key register immediately without waiting for enter.
|
|
*/
|
|
static void enable_raw_mode(void){
|
|
#ifdef _WIN32
|
|
HANDLE hIn = GetStdHandle(STD_INPUT_HANDLE);
|
|
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
|
|
|
|
g_orig_cp = GetConsoleCP();
|
|
g_orig_out_cp = GetConsoleOutputCP();
|
|
SetConsoleCP(CP_UTF8);
|
|
SetConsoleOutputCP(CP_UTF8);
|
|
|
|
if (GetConsoleMode(hIn, &g_orig_in_mode)) {
|
|
DWORD in_mode = g_orig_in_mode;
|
|
in_mode &= ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT);
|
|
SetConsoleMode(hIn, in_mode);
|
|
}
|
|
|
|
if (GetConsoleMode(hOut, &g_orig_out_mode)) {
|
|
DWORD out_mode = g_orig_out_mode;
|
|
out_mode |= (ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
|
|
SetConsoleMode(hOut, out_mode);
|
|
}
|
|
|
|
g_raw_mode = true;
|
|
#else
|
|
if (tcgetattr(STDIN_FILENO, &g_orig_termios) != 0){
|
|
return;
|
|
}
|
|
struct termios raw = g_orig_termios;
|
|
raw.c_lflag &= ~(ECHO | ICANON | ISIG);
|
|
raw.c_iflag &= ~(IXON | ICRNL);
|
|
raw.c_cc[VMIN] = 0;
|
|
raw.c_cc[VTIME] = 0;
|
|
if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) == 0) {
|
|
g_raw_mode = true;
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static uint64_t get_time_ms(void){
|
|
#ifdef _WIN32
|
|
static LARGE_INTEGER freq;
|
|
static int freq_init = 0;
|
|
if (!freq_init) {
|
|
QueryPerformanceFrequency(&freq);
|
|
freq_init = 1;
|
|
}
|
|
LARGE_INTEGER counter;
|
|
QueryPerformanceCounter(&counter);
|
|
return (uint64_t)((counter.QuadPart * 1000) / freq.QuadPart);
|
|
#else
|
|
struct timespec ts;
|
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
|
return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000;
|
|
#endif
|
|
}
|
|
|
|
static void sleep_ms(uint32_t ms){
|
|
#ifdef _WIN32
|
|
Sleep(ms);
|
|
#else
|
|
usleep(ms * 1000);
|
|
#endif
|
|
}
|
|
|
|
static bool read_input_char(char *ch){
|
|
#ifdef _WIN32
|
|
if (_isatty(0)) {
|
|
if (_kbhit()) {
|
|
int c = _getch();
|
|
if (c == 0 || c == 0xE0) {
|
|
if (_kbhit()) _getch();
|
|
return false;
|
|
}
|
|
if (c == 27) {
|
|
if (_kbhit()) {
|
|
while (_kbhit()) {
|
|
int esc_ch = _getch();
|
|
if (esc_ch >= 0x40 && esc_ch <= 0x7E) break;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
*ch = (char)c;
|
|
return true;
|
|
}
|
|
return false;
|
|
} else {
|
|
HANDLE hIn = GetStdHandle(STD_INPUT_HANDLE);
|
|
DWORD avail = 0;
|
|
if (PeekNamedPipe(hIn, NULL, 0, NULL, &avail, NULL) && avail > 0) {
|
|
DWORD read_bytes = 0;
|
|
if (ReadFile(hIn, ch, 1, &read_bytes, NULL) && read_bytes > 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
#else
|
|
char c = 0;
|
|
if (read(STDIN_FILENO, &c, 1) > 0) {
|
|
*ch = c;
|
|
return true;
|
|
}
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
static void get_terminal_size(int *rows, int *cols){
|
|
#ifdef _WIN32
|
|
CONSOLE_SCREEN_BUFFER_INFO csbi;
|
|
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
|
|
if (GetConsoleScreenBufferInfo(hOut, &csbi)) {
|
|
*cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
|
|
*rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
|
|
} else {
|
|
*cols = 100;
|
|
*rows = 40;
|
|
}
|
|
#else
|
|
struct winsize ws;
|
|
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) != 0 || ws.ws_col == 0 || ws.ws_row == 0) {
|
|
*rows = 40;
|
|
*cols = 100;
|
|
} else {
|
|
*rows = ws.ws_row;
|
|
*cols = ws.ws_col;
|
|
}
|
|
#endif
|
|
if (*rows <= 0) *rows = 40;
|
|
if (*cols <= 0) *cols = 100;
|
|
}
|
|
/*
|
|
* calculate the cumulative typing timestamps for the credits roll.
|
|
* the velocity is roughly sixty eight milliseconds per character based on
|
|
* the original source engine scripts, which keeps the roll perfectly in sync
|
|
* with the track lyrics.
|
|
*/
|
|
static void init_credits_timeline(void) {
|
|
double cur = (double)CREDITS_START_MS;
|
|
for (int i = 0; i < NUM_CREDITS; i += 1){
|
|
g_credit_starts[i] = (uint32_t)cur;
|
|
int len = (int)strlen(g_credits[i]);
|
|
double d = (len == 0 ? 1.0 : (double)len) * CREDIT_VELOCITY_MS;
|
|
g_credit_durs[i] = (uint32_t)d;
|
|
cur += d;
|
|
}
|
|
}
|
|
|
|
static const char *find_audio_file(void){
|
|
static const char *candidates[] = {
|
|
#ifdef _WIN32
|
|
"res/song/stillalive.mp3",
|
|
"res/song/stillalive.m4a",
|
|
"res/song/stillalive.ogg",
|
|
#else
|
|
"res/song/stillalive.ogg",
|
|
"res/song/stillalive.mp3",
|
|
"res/song/stillalive.m4a",
|
|
#endif
|
|
NULL
|
|
};
|
|
for (int i = 0; candidates[i] != NULL; i += 1) {
|
|
#ifdef _WIN32
|
|
if (_access(candidates[i], 0) == 0){
|
|
return candidates[i];
|
|
}
|
|
#else
|
|
if (access(candidates[i], R_OK) == 0){
|
|
return candidates[i];
|
|
}
|
|
#endif
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
#ifndef _WIN32
|
|
/*
|
|
* rather than linking a heavy audio framework, we fork an external player.
|
|
* pipewire, pulseaudio, sox, alsa, and ffmpeg CLI tools are probed in order
|
|
* so the audio works out of the box on almost any linux or bsd install.
|
|
*/
|
|
static pid_t launch_audio_player(const char *path, double start_sec) {
|
|
if (!path){
|
|
return 0;
|
|
}
|
|
static const char *players[] = {
|
|
"pw\055play",
|
|
"play",
|
|
"ffplay",
|
|
"afplay",
|
|
"aplay",
|
|
"mpv",
|
|
NULL
|
|
};
|
|
|
|
pid_t pid = fork();
|
|
if (pid < 0) {
|
|
return 0;
|
|
}
|
|
if (pid == 0){
|
|
int devnull = open("/dev/null", O_RDWR);
|
|
if (devnull >= 0) {
|
|
dup2(devnull, STDIN_FILENO);
|
|
dup2(devnull, STDOUT_FILENO);
|
|
dup2(devnull, STDERR_FILENO);
|
|
close(devnull);
|
|
}
|
|
|
|
char sec_buf[32];
|
|
snprintf(sec_buf, sizeof(sec_buf), "%.2f", start_sec);
|
|
|
|
char mpv_start[64];
|
|
snprintf(mpv_start, sizeof(mpv_start), "\055\055start=%.2f", start_sec);
|
|
|
|
for (int i = 0; players[i] != NULL; i += 1){
|
|
const char *p = players[i];
|
|
if (strcmp(p, "ffplay") == 0) {
|
|
if (start_sec > 0.0){
|
|
char *args[] = {(char *)p, "\055nodisp", "\055autoexit", "\055loglevel", "quiet", "\055ss", sec_buf, (char *)path, NULL};
|
|
execvp(p, args);
|
|
} else {
|
|
char *args[] = {(char *)p, "\055nodisp", "\055autoexit", "\055loglevel", "quiet", (char *)path, NULL};
|
|
execvp(p, args);
|
|
}
|
|
} else if (strcmp(p, "mpv") == 0){
|
|
if (start_sec > 0.0) {
|
|
char *args[] = {(char *)p, "\055\055no\055video", "\055\055really\055quiet", mpv_start, (char *)path, NULL};
|
|
execvp(p, args);
|
|
} else {
|
|
char *args[] = {(char *)p, "\055\055no\055video", "\055\055really\055quiet", (char *)path, NULL};
|
|
execvp(p, args);
|
|
}
|
|
} else if (strcmp(p, "play") == 0) {
|
|
if (start_sec > 0.0){
|
|
char *args[] = {(char *)p, (char *)path, "trim", sec_buf, NULL};
|
|
execvp(p, args);
|
|
} else {
|
|
char *args[] = {(char *)p, (char *)path, NULL};
|
|
execvp(p, args);
|
|
}
|
|
} else {
|
|
char *args[] = {(char *)p, (char *)path, NULL};
|
|
execvp(p, args);
|
|
}
|
|
}
|
|
_exit(1);
|
|
}
|
|
return pid;
|
|
}
|
|
|
|
static void start_audio(const char *path, double start_sec) {
|
|
g_audio_pid = launch_audio_player(path, start_sec);
|
|
}
|
|
#endif
|
|
|
|
/*
|
|
* the main rendering engine.
|
|
* it sizes the boxes according to the active scaling mode, clears the canvas,
|
|
* draws ascii borders, types lyrics character by character, scrolls the credits,
|
|
* centers the ascii art, and writes the entire screen buffer to stdout.
|
|
* every line is wiped with an ansi erase code so resizing leaves no artifacts.
|
|
*/
|
|
static void render_screen(uint32_t elapsed_ms, int term_rows, int term_cols){
|
|
static int s_last_cols = 0;
|
|
static int s_last_rows = 0;
|
|
static scale_mode_t s_last_mode = (scale_mode_t)-1;
|
|
bool needs_clear = false;
|
|
|
|
if (g_winch_flag || term_cols != s_last_cols || term_rows != s_last_rows || g_scale_mode != s_last_mode) {
|
|
needs_clear = true;
|
|
g_winch_flag = 0;
|
|
s_last_cols = term_cols;
|
|
s_last_rows = term_rows;
|
|
s_last_mode = g_scale_mode;
|
|
}
|
|
|
|
int min_w = 98;
|
|
int min_h = 38;
|
|
int frame_w = min_w;
|
|
int frame_h = min_h;
|
|
|
|
if (g_scale_mode == SCALE_FILL){
|
|
frame_w = term_cols >= min_w ? term_cols - 2 : min_w;
|
|
frame_h = term_rows >= min_h ? term_rows - 2 : min_h;
|
|
} else if (g_scale_mode == SCALE_ASPECT) {
|
|
int avail_h = term_rows >= min_h ? term_rows - 2 : min_h;
|
|
int avail_w = term_cols >= min_w ? term_cols - 2 : min_w;
|
|
double ratio = 98.0 / 38.0;
|
|
int h = avail_h;
|
|
int w = (int)((double)h * ratio);
|
|
if (w > avail_w){
|
|
w = avail_w;
|
|
h = (int)((double)w / ratio);
|
|
}
|
|
if (w < min_w) {
|
|
w = min_w;
|
|
}
|
|
if (h < min_h){
|
|
h = min_h;
|
|
}
|
|
frame_w = w;
|
|
frame_h = h;
|
|
} else {
|
|
frame_w = min_w;
|
|
frame_h = min_h;
|
|
}
|
|
|
|
if (frame_h > MAX_GRID_ROWS) {
|
|
frame_h = MAX_GRID_ROWS;
|
|
}
|
|
if (frame_w >= MAX_GRID_COLS){
|
|
frame_w = MAX_GRID_COLS - 1;
|
|
}
|
|
|
|
int left_w = (int)((double)frame_w * 47.0 / 98.0);
|
|
if (left_w < 24) {
|
|
left_w = 24;
|
|
}
|
|
if (left_w > frame_w - 20){
|
|
left_w = frame_w - 20;
|
|
}
|
|
|
|
int gap = 2;
|
|
int right_x = left_w + gap;
|
|
int right_w = frame_w - right_x;
|
|
if (right_w < 20) {
|
|
right_w = 20;
|
|
}
|
|
|
|
int credits_h = (int)((double)frame_h * 17.0 / 38.0);
|
|
if (credits_h < 6){
|
|
credits_h = 6;
|
|
}
|
|
if (credits_h > frame_h - 10) {
|
|
credits_h = frame_h - 10;
|
|
}
|
|
|
|
for (int r = 0; r < frame_h; r += 1){
|
|
for (int c = 0; c < frame_w; c += 1) {
|
|
g_grid[r][c] = ' ';
|
|
}
|
|
g_grid[r][frame_w] = '\0';
|
|
}
|
|
|
|
/* left box borders */
|
|
for (int c = 0; c < left_w; c += 1){
|
|
g_grid[0][c] = (char)CHAR_DASH;
|
|
g_grid[frame_h - 1][c] = (char)CHAR_DASH;
|
|
}
|
|
for (int r = 1; r < frame_h - 1; r += 1) {
|
|
g_grid[r][0] = '|';
|
|
g_grid[r][left_w - 1] = '|';
|
|
}
|
|
|
|
/* right box borders */
|
|
for (int c = right_x; c < right_x + right_w; c += 1){
|
|
g_grid[0][c] = (char)CHAR_DASH;
|
|
g_grid[credits_h][c] = (char)CHAR_DASH;
|
|
}
|
|
for (int r = 1; r < credits_h; r += 1) {
|
|
g_grid[r][right_x] = '|';
|
|
g_grid[r][right_x + right_w - 1] = '|';
|
|
}
|
|
|
|
/* find active container */
|
|
int active_c = 0;
|
|
for (int i = 0; i < NUM_CONTAINERS; i += 1){
|
|
if (elapsed_ms >= g_containers[i].start_ms) {
|
|
active_c = i;
|
|
}
|
|
}
|
|
|
|
const lyric_container_t *c_ptr = &g_containers[active_c];
|
|
uint32_t rel_ms = elapsed_ms - (*c_ptr).start_ms;
|
|
|
|
/*
|
|
* toward the end of the song the terminal enters celebrate mode,
|
|
* shuffling random aperture science ascii illustrations at an
|
|
* accelerating rate until the track concludes.
|
|
*/
|
|
if ((*c_ptr).is_celebrate){
|
|
if (g_next_shuffle_ms == 0) {
|
|
g_next_shuffle_ms = elapsed_ms + g_shuffle_interval_ms;
|
|
g_current_art_id = rand() % NUM_ASCII_ARTS;
|
|
} else if (elapsed_ms >= g_next_shuffle_ms){
|
|
g_current_art_id = rand() % NUM_ASCII_ARTS;
|
|
if (g_getting_faster) {
|
|
if (g_shuffle_interval_ms > 89){
|
|
g_shuffle_interval_ms -= 39;
|
|
} else {
|
|
g_shuffle_interval_ms = 50;
|
|
}
|
|
}
|
|
g_next_shuffle_ms = elapsed_ms + g_shuffle_interval_ms;
|
|
}
|
|
} else {
|
|
for (int i = 0; i < (*c_ptr).item_count; i += 1){
|
|
const lyric_item_t *item = &(*c_ptr).items[i];
|
|
if (!(*item).is_br && rel_ms >= (*item).start_ms) {
|
|
if ((*item).art_id != ART_NONE){
|
|
g_current_art_id = (*item).art_id;
|
|
}
|
|
if ((*item).is_play_game) {
|
|
g_getting_faster = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
* typewriter calculation for the lyrics pane on the left.
|
|
* text strings are revealed progressively according to their item duration.
|
|
*/
|
|
int max_l_rows = frame_h - 2;
|
|
if (max_l_rows > 64){
|
|
max_l_rows = 64;
|
|
}
|
|
int max_l_cols = left_w - 3;
|
|
if (max_l_cols > 120) {
|
|
max_l_cols = 120;
|
|
}
|
|
|
|
char l_lines[64][128];
|
|
int l_line_lens[64];
|
|
for (int i = 0; i < 64; i += 1){
|
|
l_lines[i][0] = '\0';
|
|
l_line_lens[i] = 0;
|
|
}
|
|
|
|
int cur_r = 0;
|
|
int cur_c = 0;
|
|
|
|
for (int i = 0; i < (*c_ptr).item_count; i += 1) {
|
|
const lyric_item_t *item = &(*c_ptr).items[i];
|
|
if ((*item).is_br){
|
|
if (rel_ms >= (*item).start_ms) {
|
|
if (cur_r < max_l_rows - 1){
|
|
cur_r += 1;
|
|
cur_c = 0;
|
|
}
|
|
}
|
|
} else {
|
|
if (rel_ms >= (*item).start_ms){
|
|
const char *txt = (*item).text;
|
|
int len = (int)strlen(txt);
|
|
int total_steps = len + ((*item).append_br ? 1 : 0);
|
|
double step_dur = total_steps == 0 ? 0.0 : (double)(*item).dur_ms / (double)total_steps;
|
|
uint32_t dt = rel_ms - (*item).start_ms;
|
|
int steps = step_dur <= 0.0 ? total_steps : (int)((double)dt / step_dur);
|
|
int num_chars = steps < len ? steps : len;
|
|
|
|
for (int ch_idx = 0; ch_idx < num_chars; ch_idx += 1){
|
|
if (cur_c < max_l_cols) {
|
|
l_lines[cur_r][cur_c] = txt[ch_idx];
|
|
cur_c += 1;
|
|
l_lines[cur_r][cur_c] = '\0';
|
|
l_line_lens[cur_r] = cur_c;
|
|
}
|
|
}
|
|
|
|
if ((*item).append_br && steps >= len){
|
|
if (cur_r < max_l_rows - 1) {
|
|
cur_r += 1;
|
|
cur_c = 0;
|
|
}
|
|
} else if (!(*item).append_br && steps >= len && (*item).add_space) {
|
|
if (cur_c < max_l_cols){
|
|
l_lines[cur_r][cur_c] = ' ';
|
|
cur_c += 1;
|
|
l_lines[cur_r][cur_c] = '\0';
|
|
l_line_lens[cur_r] = cur_c;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* copy lyrics lines into frame buffer with space padding */
|
|
for (int r = 0; r <= cur_r && r < max_l_rows; r += 1){
|
|
for (int c = 0; c < l_line_lens[r] && c < max_l_cols; c += 1) {
|
|
g_grid[r + 1][c + 2] = l_lines[r][c];
|
|
}
|
|
}
|
|
|
|
/* blinking underscore cursor */
|
|
bool blink_on = ((elapsed_ms / BLINK_MS) % 2) == 0;
|
|
if (blink_on && cur_r < max_l_rows && cur_c < max_l_cols){
|
|
g_grid[cur_r + 1][cur_c + 2] = '_';
|
|
}
|
|
|
|
/*
|
|
* the credits roll on the top right.
|
|
* past lines scroll upward while the latest credit types out at the bottom.
|
|
*/
|
|
int credit_cur = 0;
|
|
int c_rows = credits_h - 1;
|
|
if (elapsed_ms >= CREDITS_START_MS) {
|
|
while (credit_cur < NUM_CREDITS - 1 && elapsed_ms >= g_credit_starts[credit_cur + 1]){
|
|
credit_cur += 1;
|
|
}
|
|
|
|
for (int row_idx = 0; row_idx < c_rows - 1; row_idx += 1) {
|
|
int hist_k = credit_cur - ((c_rows - 1) - row_idx);
|
|
if (hist_k >= 0 && hist_k < NUM_CREDITS){
|
|
const char *ctext = g_credits[hist_k];
|
|
int clen = (int)strlen(ctext);
|
|
for (int c = 0; c < clen && (c + 2) < (right_w - 1); c += 1) {
|
|
g_grid[row_idx + 1][right_x + 2 + c] = ctext[c];
|
|
}
|
|
}
|
|
}
|
|
|
|
const char *act_text = g_credits[credit_cur];
|
|
int act_len = (int)strlen(act_text);
|
|
uint32_t dt = elapsed_ms - g_credit_starts[credit_cur];
|
|
double c_step_dur = act_len == 0 ? (double)g_credit_durs[credit_cur] : (double)g_credit_durs[credit_cur] / (double)act_len;
|
|
int act_chars = c_step_dur <= 0.0 ? act_len : (int)((double)dt / c_step_dur);
|
|
if (act_chars > act_len){
|
|
act_chars = act_len;
|
|
}
|
|
|
|
for (int c = 0; c < act_chars && (c + 2) < (right_w - 1); c += 1) {
|
|
g_grid[c_rows][right_x + 2 + c] = act_text[c];
|
|
}
|
|
if (blink_on && (act_chars + 2) < (right_w - 1)){
|
|
g_grid[c_rows][right_x + 2 + act_chars] = '_';
|
|
}
|
|
} else {
|
|
if (blink_on) {
|
|
g_grid[c_rows][right_x + 2] = '_';
|
|
}
|
|
}
|
|
|
|
/*
|
|
* ascii art sits inside the lower right compartment.
|
|
* we calculate centering offsets horizontally and vertically
|
|
* so drawings look well balanced regardless of window size.
|
|
*/
|
|
if (g_current_art_id >= 0 && g_current_art_id < NUM_ASCII_ARTS){
|
|
const ascii_art_t *art = &g_ascii_arts[g_current_art_id];
|
|
int art_avail_h = (frame_h - 1) - (credits_h + 1);
|
|
int art_max_w = 0;
|
|
for (int r = 0; r < (*art).line_count; r += 1) {
|
|
int len = (int)strlen((*art).lines[r]);
|
|
if (len > art_max_w){
|
|
art_max_w = len;
|
|
}
|
|
}
|
|
int art_pad_y = 0;
|
|
if (art_avail_h > (*art).line_count) {
|
|
art_pad_y = (art_avail_h - (*art).line_count) / 2;
|
|
}
|
|
int art_pad_x = 2;
|
|
if (right_w > art_max_w){
|
|
art_pad_x = (right_w - art_max_w) / 2;
|
|
}
|
|
for (int r = 0; r < (*art).line_count && (art_pad_y + r) < art_avail_h; r += 1) {
|
|
const char *aline = (*art).lines[r];
|
|
int alen = (int)strlen(aline);
|
|
int dest_r = credits_h + 1 + art_pad_y + r;
|
|
if (dest_r >= frame_h - 1){
|
|
break;
|
|
}
|
|
for (int c = 0; c < alen && (art_pad_x + c) < (right_w - 1); c += 1) {
|
|
g_grid[dest_r][right_x + art_pad_x + c] = aline[c];
|
|
}
|
|
}
|
|
}
|
|
|
|
/* center the rendered frame on the screen */
|
|
int off_x = 0;
|
|
int off_y = 0;
|
|
if (term_cols > frame_w){
|
|
off_x = (term_cols - frame_w) / 2;
|
|
}
|
|
if (term_rows > frame_h) {
|
|
off_y = (term_rows - frame_h) / 2;
|
|
}
|
|
|
|
static char out_buf[262144];
|
|
int pos = 0;
|
|
|
|
if (needs_clear){
|
|
pos += snprintf(out_buf + pos, sizeof(out_buf) - pos, "\033[?2025h\033[2J\033[H\033[38;2;222;190;95m");
|
|
} else {
|
|
pos += snprintf(out_buf + pos, sizeof(out_buf) - pos, "\033[?2025h\033[38;2;222;190;95m");
|
|
}
|
|
|
|
/*
|
|
* direct cursor positioning per line without newline characters.
|
|
* positioning with target row and column avoids redrawing blanks
|
|
* and avoids the line erasing escape that causes terminal tearing.
|
|
*/
|
|
for (int r = 0; r < frame_h; r += 1) {
|
|
int target_row = off_y + r + 1;
|
|
if (target_row > term_rows) {
|
|
break;
|
|
}
|
|
if (off_x > 0){
|
|
pos += snprintf(out_buf + pos, sizeof(out_buf) - pos, "\033[%d;%dH%s", target_row, off_x + 1, g_grid[r]);
|
|
} else {
|
|
pos += snprintf(out_buf + pos, sizeof(out_buf) - pos, "\033[%d;1H%s", target_row, g_grid[r]);
|
|
}
|
|
}
|
|
|
|
pos += snprintf(out_buf + pos, sizeof(out_buf) - pos, "\033[?2025l");
|
|
|
|
if (pos > 0){
|
|
write_all(STDOUT_FILENO, out_buf, (size_t)pos);
|
|
}
|
|
}
|
|
|
|
int main(int argc, char *argv[]) {
|
|
bool autoplay = false;
|
|
bool no_audio = false;
|
|
uint64_t initial_offset_ms = 0;
|
|
|
|
for (int i = 1; i < argc; i += 1){
|
|
if (strcmp(argv[i], "autoplay") == 0 || strcmp(argv[i], "\055a") == 0 || strcmp(argv[i], "\055\055autoplay") == 0) {
|
|
autoplay = true;
|
|
} else if (strcmp(argv[i], "silent") == 0 || strcmp(argv[i], "no\055audio") == 0 || strcmp(argv[i], "\055s") == 0){
|
|
no_audio = true;
|
|
} else if (strncmp(argv[i], "start=", 6) == 0) {
|
|
initial_offset_ms = (uint64_t)strtoull(argv[i] + 6, NULL, 10);
|
|
autoplay = true;
|
|
} else if (strcmp(argv[i], "classic") == 0){
|
|
g_scale_mode = SCALE_CLASSIC;
|
|
} else if (strcmp(argv[i], "fill") == 0) {
|
|
g_scale_mode = SCALE_FILL;
|
|
} else if (strcmp(argv[i], "aspect") == 0 || strcmp(argv[i], "fit") == 0){
|
|
g_scale_mode = SCALE_ASPECT;
|
|
} else if (strcmp(argv[i], "help") == 0 || strcmp(argv[i], "\055h") == 0 || strcmp(argv[i], "\055\055help") == 0) {
|
|
printf("usage: %s [autoplay] [silent] [start=<ms>] [aspect|fill|classic]\n", argv[0]);
|
|
printf("controls:\n");
|
|
printf(" space : pause / resume\n");
|
|
printf(" s : cycle scaling mode (aspect / fill / classic)\n");
|
|
printf(" r : restart\n");
|
|
printf(" q/esc : quit\n");
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
srand((unsigned int)time(NULL));
|
|
init_credits_timeline();
|
|
|
|
#ifdef _WIN32
|
|
SetConsoleCtrlHandler(console_ctrl_handler, TRUE);
|
|
signal(SIGINT, handle_signal);
|
|
signal(SIGTERM, handle_signal);
|
|
#else
|
|
signal(SIGINT, handle_signal);
|
|
signal(SIGTERM, handle_signal);
|
|
signal(SIGWINCH, handle_winch);
|
|
#endif
|
|
|
|
atexit(restore_terminal);
|
|
enable_raw_mode();
|
|
|
|
printf("\033[?1049h\033[?25l\033[?7l\033[2J");
|
|
fflush(stdout);
|
|
|
|
const char *audio_path = find_audio_file();
|
|
|
|
/* initial boot screen */
|
|
if (!autoplay){
|
|
printf("\033[H\033[38;2;222;190;95m");
|
|
printf("\n");
|
|
printf(" Loading Aperture Science Genetic Lifeform and Disk Operating System Singing module...\n\n");
|
|
printf(" FULL SCREEN RECOMMENDED!\n\n");
|
|
printf(" [ Press SPACE or ENTER to begin ]\n");
|
|
printf(" [ Press 's' to cycle scaling mode ]\n");
|
|
printf(" [ Press 'q' to quit ]\n");
|
|
fflush(stdout);
|
|
|
|
while (g_running) {
|
|
char ch = 0;
|
|
if (read_input_char(&ch)){
|
|
if (ch == 'q' || ch == 'Q') {
|
|
return 0;
|
|
}
|
|
if (ch == 's' || ch == 'S'){
|
|
g_scale_mode = (g_scale_mode + 1) % SCALE_MAX;
|
|
}
|
|
if (ch == ' ' || ch == '\n' || ch == '\r') {
|
|
break;
|
|
}
|
|
}
|
|
sleep_ms(15);
|
|
}
|
|
}
|
|
|
|
printf("\033[2J");
|
|
fflush(stdout);
|
|
g_winch_flag = 1;
|
|
|
|
uint64_t start_time = get_time_ms() - initial_offset_ms;
|
|
uint64_t pause_start = 0;
|
|
bool audio_started = false;
|
|
|
|
char ch_old = 0;
|
|
char ch = 0;
|
|
|
|
while (g_running){
|
|
/* check user input */
|
|
ch_old = ch;
|
|
if (read_input_char(&ch)) {
|
|
if (ch == 'q' || ch == 'Q'){
|
|
break;
|
|
} else if (ch == 's' || ch == 'S') {
|
|
g_scale_mode = (g_scale_mode + 1) % SCALE_MAX;
|
|
g_winch_flag = 1;
|
|
} else if (ch == ' '){
|
|
g_paused = !g_paused;
|
|
if (g_paused) {
|
|
pause_start = get_time_ms();
|
|
pause_audio();
|
|
} else {
|
|
uint64_t p_dur = get_time_ms() - pause_start;
|
|
start_time += p_dur;
|
|
resume_audio();
|
|
}
|
|
} else if (ch == 'r' || ch == 'R'){
|
|
stop_audio();
|
|
start_time = get_time_ms();
|
|
audio_started = false;
|
|
g_current_art_id = ART_NONE;
|
|
g_getting_faster = false;
|
|
g_next_shuffle_ms = 0;
|
|
g_shuffle_interval_ms = 5000;
|
|
g_paused = false;
|
|
g_winch_flag = 1;
|
|
}
|
|
}
|
|
|
|
if (ch == ch_old && ch == 27) {
|
|
break;
|
|
}
|
|
|
|
if (!g_paused) {
|
|
uint64_t now = get_time_ms();
|
|
uint32_t elapsed = (uint32_t)(now - start_time);
|
|
|
|
/* launch audio at exact sync timestamp */
|
|
if (!no_audio && !audio_started && elapsed >= AUDIO_START_MS){
|
|
double seek_s = 0.0;
|
|
if (elapsed > AUDIO_START_MS) {
|
|
seek_s = (double)(elapsed - AUDIO_START_MS) / 1000.0;
|
|
}
|
|
start_audio(audio_path, seek_s);
|
|
audio_started = true;
|
|
}
|
|
|
|
int term_rows = 40;
|
|
int term_cols = 100;
|
|
get_terminal_size(&term_rows, &term_cols);
|
|
|
|
render_screen(elapsed, term_rows, term_cols);
|
|
}
|
|
|
|
sleep_ms(16);
|
|
}
|
|
|
|
return 0; // finally
|
|
}
|