Files
uv-k1-k5v3-firmware-custom/App/apps/spaceimpact/spaceimpact_app.c
T

523 lines
17 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
*/
/*
* Space Impact-inspired scrolling shooter for the 128x64 monochrome LCD.
*
* The keypad reports a single key at a time, so the gun fires automatically
* and the keys are free for movement.
*
* Controls: 2/8 or UP/DOWN move the ship, 5/MENU launches a piercing
* missile, F pauses and EXIT quits.
*
* Texts, tables and artwork live in the app's read-only assets (built by
* gen_assets.py), not in the 4 KiB overlay: sprites are fetched from external
* flash as they are drawn and the title screen is streamed straight into the
* frame buffer.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#include "spaceimpact_assets.h" /* generated by gen_assets.py */
#define W 128
#define TICK_MS 40u
#define TOP 9 /* first playfield row, below the HUD */
#define PX 6 /* the ship only moves vertically */
#define Y_MAX 54 /* lowest sprite row, just above the hills */
#define N_ENEMY 6u
#define N_SHOT 5u
#define N_HOSTILE 6u
#define LEVEL_LEN 700u /* ticks of waves before the boss */
#define BOOM 10u /* explosion length in ticks */
enum { ST_PLAY, ST_OVER, ST_TITLE };
/* Countdowns, all decremented once per tick. */
enum { CD_FIRE, CD_HOSTILE, CD_INVUL, CD_BANNER, CD_WAIT, CD_COUNT };
/* Rows never go negative (TOP..63), so they are unsigned bytes, which Thumb-1
* loads with an immediate offset (signed ones need an extra step). */
typedef struct { int16_t x; uint8_t y; int8_t vx, vy; uint8_t live; } shot_t;
/* state: 0 free, 1 alive, >1 exploding while the counter decreases. */
typedef struct { int16_t x; uint8_t y, base, type, hp, state, phase; } enemy_t;
/* 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;
/* NEW_GAME's values, read whole by new_game(): same order and sizes */
uint8_t mode;
bool paused;
uint8_t kills, py, lives, level, missiles, spawnCd;
uint8_t cd[CD_COUNT];
uint8_t bossY, bossHp, bossMax;
int8_t bossDir;
bool running, boss, saver;
uint16_t distance;
uint16_t frame; /* 16-bit so the slow star layers wrap without a jump */
int32_t bossX; /* a word: loaded without a sign extension */
uint32_t score;
const app_api_t *api;
/* contiguous: new_game() clears the three pools at once */
enemy_t enemies[N_ENEMY];
shot_t shots[N_SHOT], hostile[N_HOSTILE];
};
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, saver) < 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 uint8_t asset_u8(uint16_t off)
{
uint8_t v;
A->asset_read(off, &v, 1);
return v;
}
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));
}
/* Draw a column-major sprite (up to 8 rows high, LSB = top, w <= 16) read
* from the assets at offset art. */
static void blit(int x, int y, uint16_t art, uint8_t w)
{
uint8_t cols[16];
const uint8_t *col = cols;
A->asset_read(art, cols, w);
for (; w; w--, x++) {
uint8_t v = *col++;
for (int yy = y; v; v >>= 1, yy++)
if (v & 1u) pixel(x, yy);
}
}
/* A centred bold text from the assets on LCD line `line`; a non-zero digit
* replaces its 7th character ("LEVEL n"). */
static void say(uint16_t off, uint8_t line, char digit)
{
char buf[TEXT_MAX];
A->asset_read(off, buf, TEXT_MAX);
if (digit) buf[6] = digit;
A->print_bold(buf, 0, 127, line);
}
static bool overlap(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh)
{
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
static bool hits_player(int x, int y, int w, int h)
{
return g.mode == ST_PLAY && overlap(x, y, w, h, PX + 1, g.py + 1, 8, 5);
}
static int aim(int y)
{
return y < g.py - 1 ? 1 : (y > g.py + 7 ? -1 : 0);
}
/* `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);
}
static shot_t *free_shot(shot_t *pool, uint8_t count)
{
for (; count; count--, pool++)
if (!pool->live) return pool;
return NULL;
}
/* A player shot (kind 1) or missile (kind 2) from the ship's nose row. */
static bool fire(int x, uint8_t kind)
{
shot_t *s = free_shot(g.shots, N_SHOT);
if (!s) return false;
s->x = (int16_t)x; s->y = (uint8_t)(g.py + 3); s->live = kind;
return true;
}
static void fire_hostile(int x, int y, int vx, int vy)
{
shot_t *s = free_shot(g.hostile, N_HOSTILE);
if (!s) return;
s->x = (int16_t)x; s->y = (uint8_t)y;
s->vx = (int8_t)vx; s->vy = (int8_t)vy; s->live = 1u;
}
/* Spawn an explosion, `delay` ticks later; recycles the last slot if full. */
static void boom(int x, int y, uint8_t delay)
{
enemy_t *e = g.enemies;
while (e->state && e < &g.enemies[N_ENEMY - 1u]) e++;
e->x = (int16_t)x; e->y = (uint8_t)y; e->state = (uint8_t)(BOOM + delay);
}
static void start_level(void)
{
g.distance = 0; g.cd[CD_BANNER] = 50u; g.spawnCd = 0; g.boss = false;
}
static void new_game(void)
{
memset((uint8_t *)&g + offsetof(struct globals, enemies), 0,
offsetof(struct globals, hostile) + sizeof(g.hostile)
- offsetof(struct globals, enemies)); /* the three pools */
A->asset_read(NEW_GAME, &g.mode, NEW_GAME_LEN);
g.score = 0;
start_level();
}
static void player_hit(void)
{
if (g.cd[CD_INVUL] || g.mode != ST_PLAY) return;
boom(PX + 1, g.py, 0);
A->play_tone(200u, 60u);
memset(g.hostile, 0, sizeof(g.hostile));
if (--g.lives == 0u) { g.mode = ST_OVER; g.cd[CD_WAIT] = 30u; return; }
g.cd[CD_INVUL] = 60u; g.py = 30u;
}
static void kill_enemy(enemy_t *e)
{
e->state = BOOM;
g.score += asset_u8(POINTS + e->type);
if ((++g.kills & 15u) == 0u && g.missiles < 9u) g.missiles++;
}
static void kill_boss(void)
{
g.boss = false;
g.score += 500u * g.level;
for (uint8_t i = 0; i < N_ENEMY; i++) { /* bursts all over the hull */
const uint32_t r = rnd();
boom(g.bossX + (int)(r & 7u), g.bossY + (int)((r >> 4) & 7u), (uint8_t)(i * 3u));
}
A->play_tone(700u, 150u);
memset(g.hostile, 0, sizeof(g.hostile));
if (g.level < 9u) g.level++;
if (g.lives < 5u) g.lives++;
if (g.missiles < 9u) g.missiles++;
start_level();
}
static void spawn_wave(void)
{
const uint32_t r = rnd();
const uint8_t type = (uint8_t)(r & 3u);
const uint8_t y = (uint8_t)(TOP + 2 + ((r >> 8) & 31u) + ((r >> 16) & 15u));
uint8_t n = type == 2u ? 1u : 3u;
for (uint8_t i = 0, k = 0; i < N_ENEMY && k < n; i++) {
enemy_t *e = &g.enemies[i];
if (e->state) continue;
e->x = (int16_t)(W + k * 12u); e->y = e->base = y;
e->type = type; e->hp = type == 2u ? 3u : 1u;
e->state = 1u; e->phase = (uint8_t)(k * 10u);
k++;
}
}
static bool enemies_alive(void)
{
for (uint8_t i = 0; i < N_ENEMY; i++) if (g.enemies[i].state == 1u) return true;
return false;
}
static void update_enemies(void)
{
for (uint8_t i = 0; i < N_ENEMY; i++) {
enemy_t *e = &g.enemies[i];
if (!e->state) continue;
if (e->state > 1u) { if (--e->state == 1u) e->state = 0; continue; }
const uint8_t t = e->type;
int y = e->y;
e->phase++;
if (t != 2u || (g.frame & 1u)) e->x -= asset_u8(SPEED + t); /* saucer: half speed */
if (t == 0u) {
const uint8_t p = (e->phase >> 1) & 31u;
y = e->base - 8 + (int)(p < 16u ? p : 31u - p); /* triangle wave */
} else if (t == 3u && (g.frame & 1u)) {
y += aim(y);
}
if (y < TOP) y = TOP;
if (y > Y_MAX) y = Y_MAX;
e->y = (uint8_t)y;
if (e->x < -8) { e->state = 0; continue; }
if (hits_player(e->x, e->y, 8, 7)) { kill_enemy(e); player_hit(); continue; }
if (!g.cd[CD_HOSTILE] && e->x < 118 && e->x > PX + 24) {
fire_hostile(e->x - 1, y + 3, g.level > 4u ? -3 : -2, aim(y));
g.cd[CD_HOSTILE] = (uint8_t)(38u - g.level * 3u);
}
}
}
static void update_boss(void)
{
if (!g.boss) return;
if (g.bossX > 108) {
g.bossX--;
} else if ((g.frame & 1u) || g.level > 3u) {
g.bossY = (uint8_t)(g.bossY + g.bossDir);
if (g.bossY <= TOP + 1 || g.bossY >= Y_MAX - 9) g.bossDir = (int8_t)-g.bossDir;
}
if (!g.cd[CD_HOSTILE] && g.bossX < 116) {
for (int vy = -1; vy <= 1; vy++)
fire_hostile(g.bossX, g.bossY + 9, -2, vy);
g.cd[CD_HOSTILE] = (uint8_t)(28u - g.level * 2u);
}
if (hits_player(g.bossX + 2, g.bossY, 14, 16)) player_hit();
}
static void update_shots(void)
{
for (uint8_t i = 0; i < N_SHOT; i++) {
shot_t *s = &g.shots[i];
if (!s->live) continue;
const bool missile = s->live == 2u;
s->x += missile ? 3 : 4;
if (s->x >= W) { s->live = 0; continue; }
if (g.boss && overlap(s->x, s->y, 6, 1, g.bossX + 1, g.bossY, 15, 16)) {
const uint8_t dmg = missile ? 6u : 1u;
s->live = 0;
if (g.bossHp > dmg) g.bossHp -= dmg; else kill_boss();
continue;
}
for (uint8_t j = 0; j < N_ENEMY; j++) {
enemy_t *e = &g.enemies[j];
if (e->state != 1u || !overlap(s->x, s->y, 6, 1, e->x, e->y, 8, 7)) continue;
if (missile || --e->hp == 0u) kill_enemy(e); /* missiles pierce */
if (!missile) { s->live = 0; break; }
}
}
for (uint8_t i = 0; i < N_HOSTILE; i++) {
shot_t *s = &g.hostile[i];
if (!s->live) continue;
s->x += s->vx; s->y = (uint8_t)(s->y + s->vy);
if (s->x < -2 || s->y < TOP || s->y > 62) { s->live = 0; continue; }
if (hits_player(s->x - 1, s->y - 1, 3, 3)) { s->live = 0; player_hit(); }
}
}
static void tick(void)
{
g.frame++;
for (uint8_t i = 0; i < CD_COUNT; i++)
if (g.cd[i]) g.cd[i]--;
if (g.mode == ST_PLAY && !g.cd[CD_FIRE] && fire(PX + 9, 1u))
g.cd[CD_FIRE] = 5u;
if (!g.boss && !g.cd[CD_BANNER]) {
if (g.distance < LEVEL_LEN) {
g.distance++;
if (g.spawnCd) g.spawnCd--;
else { spawn_wave(); g.spawnCd = (uint8_t)(52u - g.level * 3u + (rnd() & 31u)); }
} else if (!enemies_alive()) {
g.boss = true; g.bossX = W + 4; g.bossY = 24; g.bossDir = 1;
g.bossHp = g.bossMax = (uint8_t)(20u + g.level * 6u);
g.cd[CD_HOSTILE] = 30u;
}
}
update_enemies();
update_boss();
update_shots();
}
static void draw_hud(void)
{
for (uint8_t i = 0; i < g.lives; i++) blit(1 + i * 6, 1, SPR_HEART, 5);
blit(33, 1, SPR_MISS, 8);
hud_number(g.missiles, 1, 43);
hud_number(g.score > 99999u ? 99999u : g.score, 5, 107);
for (uint8_t x = 0; x < W; x += 2u) pixel(x, 8);
if (g.boss) {
A->draw_rect((app_fb_t)A->status_line, 49, 1, 90, 5, true);
for (uint8_t x = 0; x < 38u; x++)
if ((uint16_t)x * g.bossMax < 38u * g.bossHp) pixel(51 + x, 3);
}
}
/* Three-layer starfield computed from the frame counter (no RAM): 8 far
* stars at 1/4 px per tick, 4 at 1/2 and 4 near streaks at 1 px. Each star
* gets a new pseudo-random height every time it wraps around. */
static void draw_stars(void)
{
for (uint8_t i = 0; i < 16u; i++) {
const uint8_t sh = (uint8_t)((0x1Au >> ((i & 3u) * 2u)) & 3u); /* 2,2,1,0 */
const uint16_t pos = (uint16_t)(i * 53u + (g.frame >> sh));
const uint8_t h = (uint8_t)((pos >> 7) * 29u + i * 71u);
const int x = (int)(127u - (pos & 127u));
const int y = (int)(TOP + 2 + ((h * 40u) >> 8));
pixel(x, y);
if (!sh) pixel(x + 1, y);
}
}
/* Hilly ground: the ridge is the sum of two triangle waves whose periods
* divide 256, so it wraps seamlessly with the low byte of frame. Their
* steps fall on different columns, so a 1-pixel line stays continuous. */
static void draw_ground(void)
{
for (uint8_t x = 0; x < W; x++) {
const uint8_t wx = (uint8_t)(x + g.frame);
const uint8_t a = wx & 127u, b = (wx + 2u) & 31u; /* offset: steps never coincide */
const uint8_t y = (uint8_t)(62u - ((a < 64u ? a : 127u - a) >> 4)
- ((b < 16u ? b : 31u - b) >> 2));
const uint8_t h = (uint8_t)(wx * 37u); /* cheap per-column hash */
pixel(x, y);
if (wx & 1u) pixel(x, y + 3); /* dotted stratum */
if (h < 40u) pixel(x, y + 5 + (h & 1u)); /* scattered pebbles */
}
}
static void draw(void)
{
A->display_clear(); A->status_clear();
draw_stars();
draw_ground();
for (uint8_t i = 0; i < N_ENEMY; i++) {
const enemy_t *e = &g.enemies[i];
if (e->state == 1u && !(e->type == 2u && e->hp < 3u && (g.frame & 2u))) /* hurt saucer blinks */
blit(e->x, e->y, SPR_ENEMY + e->type * 8u, 8);
else if (e->state > 1u && e->state <= BOOM)
blit(e->x, e->y, SPR_BOOM + (e->state > 5u ? 0u : 8u), 8);
}
if (g.boss) {
const uint16_t spr = SPR_BOSS + ((g.level & 1u) ? 0u : 32u); /* alternate bosses */
blit(g.bossX, g.bossY, spr, 16);
blit(g.bossX, g.bossY + 8, spr + 16u, 16);
}
if (g.mode == ST_PLAY && !(g.cd[CD_INVUL] & 2u)) blit(PX, g.py, SPR_SHIP, 10);
for (uint8_t i = 0; i < N_SHOT; i++) {
const shot_t *s = &g.shots[i];
if (s->live == 2u) blit(s->x, s->y - 2, SPR_MISS, 8);
else if (s->live) for (uint8_t k = 0; k < 4u; k++) pixel(s->x + k, s->y);
}
for (uint8_t i = 0; i < N_HOSTILE; i++) {
const shot_t *s = &g.hostile[i];
if (!s->live) continue;
pixel(s->x, s->y - 1); pixel(s->x, s->y + 1);
pixel(s->x - 1, s->y); pixel(s->x, s->y); pixel(s->x + 1, s->y);
}
draw_hud();
if (g.mode == ST_OVER)
say(T_GAME_OVER, 3u, 0);
else if (g.paused)
say(T_PAUSE, 3u, 0);
else if (g.cd[CD_BANNER] > 10u)
say(T_LEVEL, 3u, (char)('0' + g.level)); /* "LEVEL n" */
}
/* Title: the 1 KiB picture is streamed from the assets into the LCD buffers
* every frame, the starfield scrolls over it and the prompt blinks. */
static void draw_title(void)
{
g.frame++;
A->asset_read(ART_TITLE, A->status_line, W);
A->asset_read(ART_TITLE + W, A->fb[0], 7u * W);
draw_stars();
if (g.frame & 16u)
say(T_PRESS, 6u, 0);
}
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;
const bool action = key == APP_KEY_5 || key == APP_KEY_MENU;
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) return;
if (action) {
if (press && g.missiles && fire(PX + 4, 2u))
g.missiles--;
} else { /* 2/8, UP/DOWN; 0 for any other key */
const int dy = key == APP_KEY_2 ? -2 : key == APP_KEY_8 ? 2 : -2 * A->nav_dir(key);
int ny = g.py + dy;
if (ny < TOP) ny = TOP;
if (ny > Y_MAX) ny = Y_MAX;
g.py = (uint8_t)ny;
}
}
__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);
}