mirror of
https://github.com/armel/uv-k1-k5v3-firmware-custom.git
synced 2026-10-08 05:57:23 +00:00
413 lines
15 KiB
C
413 lines
15 KiB
C
/* Copyright 2026 Armel F4HWN
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*/
|
|
|
|
/*
|
|
* Rapid Roll-inspired game for the 128x64 monochrome LCD.
|
|
*
|
|
* Platforms rise towards a spiked ceiling: roll the ball left and right to
|
|
* drop from one platform to the next. Hollow pill floors are safe; the
|
|
* spiked ones, the ceiling and the bottom of the screen cost a life.
|
|
*
|
|
* The generator always leaves a way down: each safe platform plans the next
|
|
* one at most MAX_SHIFT pixels away, and spiked (or, from level 3, crumbling)
|
|
* platforms are kept out of the fall corridor between the two, with at most
|
|
* one such row in between. Crumbling floors are traps, never the way down.
|
|
*
|
|
* Controls: 4/6 or UP/DOWN roll, F pauses, MENU restarts after GAME OVER,
|
|
* EXIT quits.
|
|
*/
|
|
|
|
#include <stdint.h>
|
|
#include <stdbool.h>
|
|
#include <stddef.h>
|
|
#include "../app_api.h"
|
|
#include "rapidroll_assets.h" /* generated by gen_assets.py */
|
|
|
|
#define W 128
|
|
#define TICK_MS 40u
|
|
#define CEIL_Y 12 /* the ball dies when its top rises above this */
|
|
#define ROW 16 /* vertical distance between platform rows */
|
|
#define N_PLAT 5u /* 16-px rows: at most 5 on screen at once */
|
|
#define SAFE_W 28 /* widest safe platform (levels 1-5) */
|
|
#define SPIKE_W 26 /* 4 teeth, one at each end */
|
|
#define MAX_SHIFT 30 /* horizontal step between two safe platforms */
|
|
#define MARGIN 10 /* keeps spikes clear of the fall corridor */
|
|
#define X_MIN 3
|
|
#define X_MAX 125
|
|
#define BOOM 12u /* explosion length in ticks */
|
|
#define CRUMBLE_T 25u /* ticks a crumbling floor holds the ball */
|
|
#define LEVEL_ROWS 16u /* platform rows per level */
|
|
#define RISE_JUMP 1u /* pixels per scroll jump: 2 is sharper on the
|
|
slow LCD but choppier, 1 is smooth */
|
|
#define NONE 0xFFu
|
|
|
|
enum { ST_PLAY, ST_OVER, ST_TITLE };
|
|
enum { P_FREE, P_SAFE, P_SPIKE, P_CRUMBLE };
|
|
/* Countdowns, all decremented once per tick. */
|
|
enum { CD_BOOM, CD_BANNER, CD_WAIT, CD_INVUL, CD_SQUASH, CD_COUNT };
|
|
|
|
/* Texts, decimal places, sprites (column-major, LSB = top row) and the
|
|
* new-game values are read-only assets (gen_assets.py), fetched from external
|
|
* flash when used. */
|
|
|
|
/* Rows stay on screen (a platform is freed once above CEIL_Y), so y is an
|
|
* unsigned byte, which Thumb-1 loads with an immediate offset. */
|
|
typedef struct { uint8_t y, x, w, kind, heart; } plat_t; /* heart: crumble timer on P_CRUMBLE */
|
|
|
|
/* All the state in one struct: Thumb-1 code then reaches every field from a
|
|
* single base address (one literal per function instead of one per global),
|
|
* byte fields first so their offsets fit ldrb's 0..31 immediate. */
|
|
struct globals {
|
|
uint8_t prevKey, titleTick;
|
|
bool running, saver;
|
|
/* NEW_GAME's values, read whole by new_game(): same order and sizes */
|
|
uint8_t mode;
|
|
bool paused;
|
|
uint8_t lives, level, riseAcc, respawnCd;
|
|
uint8_t lastX, nextX; /* current and planned safe platform x */
|
|
bool spikeAllowed, alive;
|
|
uint8_t on; /* platform under the ball, or NONE */
|
|
uint8_t cd[CD_COUNT];
|
|
/* end of NEW_GAME */
|
|
uint8_t rows, vy;
|
|
uint8_t lastRowY; /* y of the most recently spawned row */
|
|
int32_t bx, by, boomX, boomY; /* ball and explosion, top-left: words,
|
|
loaded without a sign extension */
|
|
uint32_t score;
|
|
const app_api_t *api;
|
|
plat_t plats[N_PLAT];
|
|
};
|
|
static struct globals g;
|
|
#define A (g.api)
|
|
|
|
_Static_assert(ST_PLAY == 0, "NEW_GAME starts a game in ST_PLAY");
|
|
_Static_assert(offsetof(struct globals, cd) + CD_COUNT - offsetof(struct globals, mode)
|
|
== NEW_GAME_LEN, "new-game fields do not match NEW_GAME");
|
|
_Static_assert(offsetof(struct globals, lastRowY) < 32u, "byte fields out of ldrb range");
|
|
|
|
void *memset(void *dst, int value, size_t size)
|
|
{
|
|
uint8_t *p = dst;
|
|
while (size--) *p++ = (uint8_t)value;
|
|
return dst;
|
|
}
|
|
|
|
static uint32_t rnd(void)
|
|
{
|
|
return A->rand32() >> 8; /* resident PRNG, seeded by the loader */
|
|
}
|
|
|
|
static void pixel(int x, int y)
|
|
{
|
|
if ((unsigned)x >= W || (unsigned)y >= 64u) return;
|
|
uint8_t *p = y < 8 ? &A->status_line[x] : &A->fb[(y >> 3) - 1][x];
|
|
*p |= (uint8_t)(1u << (y & 7));
|
|
}
|
|
|
|
static void column(int x, int y, uint8_t v)
|
|
{
|
|
for (; v; v >>= 1, y++)
|
|
if (v & 1u) pixel(x, y);
|
|
}
|
|
|
|
/* Draw a column-major sprite (up to 8 rows high, LSB = top, w <= 8) read
|
|
* from the assets at offset art. */
|
|
static void blit(int x, int y, uint16_t art, uint8_t w)
|
|
{
|
|
uint8_t cols[8];
|
|
const uint8_t *col = cols;
|
|
A->asset_read(art, cols, w);
|
|
while (w--) column(x++, y, *col++);
|
|
}
|
|
|
|
/* A centred bold text from the assets on LCD line `line`. */
|
|
static void say(uint16_t off, uint8_t line)
|
|
{
|
|
char buf[TEXT_MAX];
|
|
A->asset_read(off, buf, TEXT_MAX);
|
|
A->print_bold(buf, 0, 127, line);
|
|
}
|
|
|
|
/* `width` digits of value (at most 5, no division) in tiny type on the HUD. */
|
|
static void hud_number(uint32_t value, uint8_t width, uint8_t x)
|
|
{
|
|
uint16_t place[5];
|
|
char buf[6];
|
|
A->asset_read(PLACE, place, sizeof(place));
|
|
const uint8_t first = (uint8_t)(5u - width);
|
|
for (uint8_t i = 0; i < width; i++) {
|
|
uint8_t digit = 0;
|
|
while (value >= place[first + i]) {
|
|
value -= place[first + i];
|
|
digit++;
|
|
}
|
|
buf[i] = (char)('0' + digit);
|
|
}
|
|
buf[width] = 0;
|
|
A->print_tiny(buf, x, 1, true, true);
|
|
}
|
|
|
|
/* Spawn one platform row at y. A spiked platform only goes outside the
|
|
* corridor between the current safe platform and the planned next one;
|
|
* otherwise the row gets that planned safe platform. */
|
|
static void spawn_row(int y)
|
|
{
|
|
plat_t *p = &g.plats[0];
|
|
for (uint8_t i = 0; i < N_PLAT; i++) if (!g.plats[i].kind) { p = &g.plats[i]; break; }
|
|
g.lastRowY = (uint8_t)y;
|
|
p->y = (uint8_t)y; p->heart = 0;
|
|
|
|
if (++g.rows >= LEVEL_ROWS && g.level < 9u) {
|
|
g.rows = 0; g.level++; g.cd[CD_BANNER] = 50u; /* the HUD level blinks */
|
|
}
|
|
|
|
if (g.spikeAllowed && (uint8_t)rnd() < 64u + g.level * 12u) {
|
|
const int left = (g.lastX < g.nextX ? g.lastX : g.nextX) - MARGIN - SPIKE_W; /* last x on the left */
|
|
const int right = (g.lastX > g.nextX ? g.lastX : g.nextX) + SAFE_W + MARGIN; /* first x on the right */
|
|
const uint8_t r = (uint8_t)rnd();
|
|
int x = -1;
|
|
if (left >= X_MIN && ((r & 1u) || right > X_MAX - SPIKE_W))
|
|
x = X_MIN + (((r >> 1) * (left - X_MIN + 1)) >> 7);
|
|
else if (right <= X_MAX - SPIKE_W)
|
|
x = right + (((r >> 1) * (X_MAX - SPIKE_W - right + 1)) >> 7);
|
|
if (x >= 0) {
|
|
p->x = (uint8_t)x; p->w = SPIKE_W; g.spikeAllowed = false;
|
|
p->kind = g.level >= 3u && (rnd() & 1u) ? P_CRUMBLE : P_SPIKE;
|
|
return;
|
|
}
|
|
}
|
|
|
|
/* Safe platforms shrink late in the game; the corridor above is still
|
|
* computed with the widest SAFE_W, so it only gets more conservative. */
|
|
p->x = g.nextX; p->w = g.level < 6u ? SAFE_W : g.level < 8u ? 24u : 20u;
|
|
p->kind = P_SAFE; g.spikeAllowed = true;
|
|
p->heart = g.lives < 5u && (rnd() & 15u) == 0u;
|
|
g.lastX = g.nextX;
|
|
int d = (int)(rnd() & 63u) - 31;
|
|
if (d > MAX_SHIFT) d = MAX_SHIFT;
|
|
if (d < -MAX_SHIFT) d = -MAX_SHIFT;
|
|
int n = g.lastX + d;
|
|
if (n < X_MIN) n = X_MIN;
|
|
if (n > X_MAX - SAFE_W) n = X_MAX - SAFE_W;
|
|
g.nextX = (uint8_t)n;
|
|
}
|
|
|
|
static void new_game(void)
|
|
{
|
|
memset(g.plats, 0, sizeof(g.plats));
|
|
/* mode, paused, lives, level, riseAcc, respawnCd, lastX, nextX,
|
|
spikeAllowed, alive, on and the countdowns (banner 50, wait 40) */
|
|
A->asset_read(NEW_GAME, &g.mode, NEW_GAME_LEN);
|
|
g.score = 0;
|
|
spawn_row(42); /* plats[0]: safe; the ball sits just below the LEVEL 1 banner */
|
|
spawn_row(42 + ROW);
|
|
g.rows = 0; g.plats[0].heart = 0;
|
|
g.bx = 50 + 10; g.by = 42 - 8;
|
|
}
|
|
|
|
static bool supports(const plat_t *p)
|
|
{
|
|
return g.bx + 6 > p->x && g.bx + 2 < p->x + p->w;
|
|
}
|
|
|
|
static void die(void)
|
|
{
|
|
g.alive = false; g.on = NONE;
|
|
g.cd[CD_BOOM] = BOOM; g.boomX = g.bx; g.boomY = g.by;
|
|
A->play_tone(200u, 60u);
|
|
if (--g.lives == 0u) { g.mode = ST_OVER; g.cd[CD_WAIT] = 30u; }
|
|
else g.respawnCd = 20u;
|
|
}
|
|
|
|
/* Put the ball back on the lowest safe platform that leaves time to react. */
|
|
static void respawn(void)
|
|
{
|
|
int bestY = 27;
|
|
for (uint8_t i = 0; i < N_PLAT; i++) {
|
|
const plat_t *p = &g.plats[i];
|
|
if (p->kind == P_SAFE && p->y > bestY && p->y <= 56) { g.on = i; bestY = p->y; }
|
|
}
|
|
if (bestY == 27) { g.respawnCd = 1u; return; } /* none yet: retry next tick */
|
|
g.bx = g.plats[g.on].x + 10; g.by = bestY - 8; g.alive = true; g.cd[CD_INVUL] = 25u;
|
|
}
|
|
|
|
static void rise_step(void)
|
|
{
|
|
g.lastRowY--;
|
|
for (uint8_t i = 0; i < N_PLAT; i++) {
|
|
plat_t *p = &g.plats[i];
|
|
if (p->kind && --p->y < CEIL_Y) {
|
|
p->kind = P_FREE;
|
|
if (g.on == i) g.on = NONE;
|
|
}
|
|
}
|
|
if (g.alive) {
|
|
g.score++;
|
|
if (g.on != NONE) g.by--;
|
|
}
|
|
if (g.lastRowY <= 64 - ROW) spawn_row(g.lastRowY + ROW);
|
|
}
|
|
|
|
static void tick(void)
|
|
{
|
|
for (uint8_t i = 0; i < CD_COUNT; i++)
|
|
if (g.cd[i]) g.cd[i]--;
|
|
if (g.mode != ST_PLAY) return;
|
|
|
|
if (!g.cd[CD_WAIT]) {
|
|
g.riseAcc += g.level < 5u ? 3u + g.level : 8u; /* eighths of a pixel per tick */
|
|
if (g.riseAcc >= 8u * RISE_JUMP) {
|
|
g.riseAcc -= 8u * RISE_JUMP;
|
|
for (uint8_t k = 0; k < RISE_JUMP; k++) rise_step();
|
|
}
|
|
}
|
|
if (!g.alive) {
|
|
if (g.respawnCd && --g.respawnCd == 0u) respawn();
|
|
return;
|
|
}
|
|
|
|
if (g.on != NONE) {
|
|
plat_t *p = &g.plats[g.on];
|
|
g.vy = 0;
|
|
if (p->kind == P_CRUMBLE && --p->heart == 0u) { p->kind = P_FREE; g.on = NONE; } /* gives way */
|
|
else if (!supports(p)) g.on = NONE;
|
|
else if (p->kind == P_SAFE && p->heart && g.bx + 8 > p->x + 10 && g.bx < p->x + 18) {
|
|
p->heart = 0; g.score += 50u;
|
|
if (g.lives < 5u) g.lives++;
|
|
A->play_tone(900u, 30u);
|
|
}
|
|
}
|
|
if (g.on == NONE) {
|
|
const int prev = g.by + 8;
|
|
if (g.vy < 3u) g.vy++; /* gravity: 1, 2, then 3 px per tick */
|
|
g.by += g.vy;
|
|
for (uint8_t i = 0; i < N_PLAT; i++) {
|
|
plat_t *p = &g.plats[i];
|
|
if (!p->kind || prev > p->y + (int)RISE_JUMP + 1 || g.by + 8 < p->y || !supports(p)) continue;
|
|
g.by = p->y - 8;
|
|
if (p->kind == P_SPIKE) { die(); return; }
|
|
if (p->kind == P_CRUMBLE && !p->heart) p->heart = CRUMBLE_T;
|
|
if (g.vy > 1u) g.cd[CD_SQUASH] = 3u;
|
|
g.on = i;
|
|
break;
|
|
}
|
|
}
|
|
if (g.by < CEIL_Y || g.by > 60) die();
|
|
}
|
|
|
|
static void draw(void)
|
|
{
|
|
A->display_clear(); A->status_clear();
|
|
|
|
/* Dotted side rails. They stay still: a 2-row pattern scrolled by one
|
|
* pixel would just flip every dot and flicker on the slow LCD. */
|
|
for (int y = 10; y < 64; y += 2) { pixel(0, y); pixel(W - 1, y); } /* x = 0/127 are tooth-free */
|
|
/* Ceiling: 7-px tooth period started at x = -5, so the 18 visible teeth
|
|
* span x = 2..125 and leave the same 2-px margin on both sides. */
|
|
for (int x = -5; x < W; x += 7) blit(x, 8, SPR_TOOTH, 7);
|
|
|
|
for (uint8_t i = 0; i < N_PLAT; i++) {
|
|
const plat_t *p = &g.plats[i];
|
|
if (p->kind == P_SAFE) {
|
|
for (uint8_t k = 0; k < p->w; k++) /* hollow 3-row pill with rounded ends */
|
|
column(p->x + k, p->y, (k == 0u || k == p->w - 1u) ? 0x02u
|
|
: (k == 1u || k == p->w - 2u) ? 0x07u : 0x05u);
|
|
if (p->heart && p->y >= CEIL_Y + 8) blit(p->x + 10, p->y - 8, SPR_HEART, 8);
|
|
} else if (p->kind == P_CRUMBLE) {
|
|
if (!(p->heart & 2u)) /* cracked pill, blinking once it starts to give way */
|
|
for (uint8_t k = 0; k < p->w; k++)
|
|
column(p->x + k, p->y, (k == 0u || k == p->w - 1u) ? 0x02u
|
|
: (k == 1u || k == p->w - 2u) ? 0x07u
|
|
: ((k & 3u) == 3u) ? 0x00u : 0x05u);
|
|
} else if (p->kind == P_SPIKE) {
|
|
uint8_t spike[SPR_SPIKE_LEN]; /* one tooth period, read once per row */
|
|
A->asset_read(SPR_SPIKE, spike, sizeof(spike));
|
|
for (uint8_t k = 0, j = 0; k < SPIKE_W; k++, j = j == 6u ? 0u : j + 1u)
|
|
column(p->x + k, p->y - 3, spike[j]);
|
|
}
|
|
}
|
|
if (g.alive && !(g.cd[CD_INVUL] & 2u)) { /* blinks after a respawn; the mark turns every 4 px */
|
|
if (g.cd[CD_SQUASH]) blit(g.bx, g.by + 2, SPR_SQUASH, 8);
|
|
else blit(g.bx, g.by, SPR_BALL + ((g.bx >> 2) & 3u) * 8u, 8);
|
|
}
|
|
if (g.cd[CD_BOOM]) blit(g.boomX, g.boomY, SPR_BOOM, 8);
|
|
|
|
for (uint8_t i = 0; i < g.lives; i++) blit(1 + i * 6, 1, SPR_LIFE, 5);
|
|
if (!(g.cd[CD_BANNER] & 4u)) { /* blinks for a while after a level change */
|
|
char buf[TEXT_MAX];
|
|
A->asset_read(T_LV, buf, TEXT_MAX);
|
|
A->print_tiny(buf, 52, 1, true, true);
|
|
hud_number(g.level, 1, 62);
|
|
}
|
|
hud_number(g.score > 99999u ? 99999u : g.score, 5, 107);
|
|
|
|
if (g.mode == ST_OVER) say(T_GAME_OVER, 3);
|
|
else if (g.paused) say(T_PAUSE, 3);
|
|
else if (g.cd[CD_BANNER] > 10u && g.level == 1u) say(T_LEVEL1, 2); /* start only: it overwrites its line */
|
|
}
|
|
|
|
/* Title: the 1 KiB picture is streamed from the assets into the LCD buffers,
|
|
* with a blinking prompt on the blank bottom page. */
|
|
static void draw_title(void)
|
|
{
|
|
g.titleTick++;
|
|
A->asset_read(ART_TITLE, A->status_line, W);
|
|
A->asset_read(ART_TITLE + W, A->fb[0], 7u * W);
|
|
if (g.titleTick & 16u)
|
|
say(T_PRESS, 6);
|
|
}
|
|
|
|
static void poll_key(void)
|
|
{
|
|
uint8_t key = A->get_key();
|
|
if (key == APP_KEY_SAVER) { g.saver = true; return; } /* freeze behind the saver */
|
|
if (key == APP_KEY_WAKE || key == APP_KEY_PTT) { g.saver = false; key = APP_KEY_INVALID; }
|
|
const bool press = key != g.prevKey;
|
|
g.prevKey = key;
|
|
if (key == APP_KEY_INVALID) return;
|
|
if (press && key == APP_KEY_EXIT) { g.running = false; return; }
|
|
|
|
if (g.mode != ST_PLAY) {
|
|
if (press && key == APP_KEY_MENU && !g.cd[CD_WAIT]) new_game();
|
|
return;
|
|
}
|
|
if (press && key == APP_KEY_F) { g.paused = !g.paused; return; }
|
|
if (g.paused || !g.alive) return;
|
|
|
|
int dx = 2 * A->nav_dir(key); /* 0 for any key but UP/DOWN */
|
|
if (key == APP_KEY_4) dx = -2;
|
|
if (key == APP_KEY_6) dx = 2;
|
|
g.bx += dx;
|
|
if (g.bx < 1) g.bx = 1;
|
|
if (g.bx > W - 9) g.bx = W - 9;
|
|
}
|
|
|
|
__attribute__((section(".text.entry"), used))
|
|
void app_main(const app_api_t *api)
|
|
{
|
|
A = api;
|
|
g.running = true; /* the rest of g starts zeroed (.bss) */
|
|
g.prevKey = A->get_key();
|
|
A->led(false); A->backlight_on();
|
|
g.mode = ST_TITLE; /* MENU starts a game, like after GAME OVER */
|
|
|
|
while (g.running) {
|
|
poll_key();
|
|
if (!g.running) break;
|
|
if (!g.saver) { /* the saver freezes the game */
|
|
if (g.mode == ST_TITLE) draw_title();
|
|
else { if (!g.paused) tick(); draw(); }
|
|
A->blit_status(); A->blit_full();
|
|
}
|
|
A->backlight_update();
|
|
A->delay_ms(TICK_MS);
|
|
}
|
|
A->led(false);
|
|
}
|