mirror of
https://github.com/armel/uv-k1-k5v3-firmware-custom.git
synced 2026-10-08 14:07:25 +00:00
475 lines
14 KiB
C
475 lines
14 KiB
C
/* Copyright 2026 mrkusypl https://github.com/mrkusypl
|
|
* Armel F4HWN https://github.com/armel
|
|
*
|
|
* 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
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
/*
|
|
* Snake - compact overlay game for the 128x64 monochrome LCD.
|
|
*
|
|
* The playfield is a large 31x13 grid covering most of the display,
|
|
* with game status displayed in a single line at the top.
|
|
* Features connected snake body segments in classic Nokia 3310 style.
|
|
*
|
|
* Controls: 2 move up, 4 move left, 6 move right, 8 move down
|
|
* MENU / STAR restarts after GAME OVER, F pauses and EXIT quits
|
|
*/
|
|
|
|
#include <stdint.h>
|
|
#include <stdbool.h>
|
|
#include <stddef.h>
|
|
#include "../app_api.h"
|
|
#include "snake_assets.h" /* generated by gen_assets.py */
|
|
|
|
#define W 128u
|
|
|
|
#define COLS 31u
|
|
#define ROWS 13u
|
|
|
|
/* Board content coordinates */
|
|
#define BOARD_X 2u
|
|
#define BOARD_Y 10u
|
|
|
|
#define TICK_MS 20u
|
|
#define RING_SIZE 512u /* Power of 2 for zero-branch modulo */
|
|
#define RING_MASK 511u
|
|
#define MOVE_INTERVAL 120u
|
|
#define SCORE_PER_FOOD 10u
|
|
#define CFG_MAGIC 0x534Eu
|
|
|
|
typedef struct {
|
|
uint16_t magic;
|
|
uint16_t version;
|
|
uint32_t best;
|
|
} config_t;
|
|
|
|
typedef struct {
|
|
uint8_t x;
|
|
uint8_t y;
|
|
} segment_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. The 1 KiB
|
|
* body ring lives on app_main's stack, outside the 4 KiB overlay. */
|
|
struct globals {
|
|
uint8_t foodX, foodY, direction, nextDirection, previousKey, titleTick;
|
|
bool paused, gameOver, running, newBest, saverPaused, saverActive, title;
|
|
uint16_t head_idx, tail_idx, repeatMs;
|
|
int32_t moveTimer;
|
|
uint32_t best, initial_best;
|
|
const app_api_t *api;
|
|
segment_t *snake; /* RING_SIZE segments, app_main's stack */
|
|
uint32_t board_map[ROWS];
|
|
};
|
|
static struct globals g;
|
|
#define A (g.api)
|
|
|
|
_Static_assert(offsetof(struct globals, title) < 32u, "byte fields out of ldrb range");
|
|
|
|
/* Texts, decimal places, direction steps and sprites are read-only assets
|
|
* (gen_assets.py). The sprite sets and the direction map are copied onto the
|
|
* stack once per frame by draw_snake(). */
|
|
|
|
/* Sprite index in draw_snake()'s copy of the SPR_HEAD..SPR_TAIL block. */
|
|
#define SPR_INDEX(item) (((item) - SPR_HEAD) / 2u)
|
|
#define SPR_BLOCK_LEN (SPR_TAIL + SPR_TAIL_LEN - SPR_HEAD)
|
|
|
|
/* Macro for compact direction evaluation (dir_mask: draw_snake()'s stack copy) */
|
|
#define GET_DIR(n_idx, s_x, s_y) dir_mask[(((body[n_idx].x & 0x7F) - (s_x)) + ((body[n_idx].y - (s_y)) << 1)) & 7]
|
|
|
|
static void new_game(void);
|
|
|
|
/* GCC may lower aggregate clears to memset even for this freestanding blob. */
|
|
void *memset(void *dst, int value, size_t size)
|
|
{
|
|
uint8_t *p = dst;
|
|
while (size--)
|
|
*p++ = (uint8_t)value;
|
|
return dst;
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Utilities & RNG */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
/* 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, W - 1, line);
|
|
}
|
|
|
|
/* One grid step (dx, dy) for a direction: up, right, down, left. */
|
|
static void step_of(uint32_t dir, int8_t d[2])
|
|
{
|
|
A->asset_read(DXY + dir * 2u, d, 2);
|
|
}
|
|
|
|
static void place_food(void)
|
|
{
|
|
for (uint32_t tries = 0; tries < 128u; tries++) {
|
|
const uint32_t r = A->rand32(); /* resident PRNG, seeded by the loader */
|
|
const uint32_t x = ((r >> 24) * COLS) >> 8;
|
|
const uint32_t y = (((r >> 16) & 0xFFu) * ROWS) >> 8;
|
|
|
|
if (!(g.board_map[y] & (1UL << x))) {
|
|
g.foodX = (uint8_t)x;
|
|
g.foodY = (uint8_t)y;
|
|
return;
|
|
}
|
|
}
|
|
for (uint32_t y = 0; y < ROWS; y++) {
|
|
for (uint32_t x = 0; x < COLS; x++) {
|
|
if (!(g.board_map[y] & (1UL << x))) {
|
|
g.foodX = (uint8_t)x;
|
|
g.foodY = (uint8_t)y;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
g.gameOver = true;
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Hardware Rendering */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
static void draw_sprite(uint32_t bx, uint32_t by, uint32_t sprite)
|
|
{
|
|
for (uint32_t i = 0; sprite; i++, sprite >>= 1) {
|
|
if (sprite & 1u) {
|
|
uint32_t py = by + (i >> 2u);
|
|
A->fb[(py >> 3) - 1u][bx + (i & 3u)] |= (1u << (py & 7u));
|
|
}
|
|
}
|
|
}
|
|
|
|
static void draw_snake(void)
|
|
{
|
|
uint16_t spr[SPR_BLOCK_LEN / 2u];
|
|
uint8_t dir_mask[DIR_MASK_LEN];
|
|
A->asset_read(SPR_HEAD, spr, sizeof(spr));
|
|
A->asset_read(DIR_MASK, dir_mask, sizeof(dir_mask));
|
|
|
|
const segment_t *body = g.snake;
|
|
uint32_t idx = g.head_idx;
|
|
uint32_t next_idx = 0;
|
|
uint32_t len = (g.head_idx - g.tail_idx + 1u) & RING_MASK;
|
|
|
|
for (uint32_t i = 0; i < len; i++) {
|
|
uint32_t sx = body[idx].x & 0x7F;
|
|
uint32_t sy = body[idx].y;
|
|
uint32_t sprite = 0;
|
|
|
|
if (i == 0) {
|
|
int8_t d[2];
|
|
step_of(g.direction, d);
|
|
bool open = ((sx + d[0]) == g.foodX && (sy + d[1]) == g.foodY);
|
|
sprite = spr[(open ? SPR_INDEX(SPR_HEAD_OPEN) : SPR_INDEX(SPR_HEAD)) + g.direction];
|
|
} else if (body[idx].x & 0x80) {
|
|
sprite = 0x6FF6;
|
|
} else if (i == len - 1u) {
|
|
sprite = spr[SPR_INDEX(SPR_TAIL) + GET_DIR(next_idx, sx, sy)];
|
|
} else {
|
|
uint32_t prev_idx = (idx - 1u) & RING_MASK;
|
|
sprite = spr[SPR_INDEX(SPR_BODY) + (GET_DIR(next_idx, sx, sy) | GET_DIR(prev_idx, sx, sy))];
|
|
}
|
|
|
|
draw_sprite(BOARD_X + (sx << 2), BOARD_Y + (sy << 2), sprite);
|
|
next_idx = idx;
|
|
idx = (idx - 1u) & RING_MASK;
|
|
}
|
|
}
|
|
|
|
/* A status-line label from the assets at lx and a 5-digit value (capped at
|
|
* 99999, no division) at vx. */
|
|
static void label_value(uint16_t label, uint8_t lx, uint32_t value, uint8_t vx)
|
|
{
|
|
char buf[TEXT_MAX];
|
|
uint16_t place[4];
|
|
A->asset_read(label, buf, TEXT_MAX);
|
|
A->print_tiny(buf, lx, 1, true, true);
|
|
|
|
A->asset_read(PLACE, place, sizeof(place));
|
|
if (value > 99999u)
|
|
value = 99999u;
|
|
|
|
for (uint32_t i = 0; i < 4u; i++) {
|
|
uint32_t p = place[i];
|
|
uint32_t digit = '0';
|
|
|
|
while (value >= p) {
|
|
value -= p;
|
|
digit++;
|
|
}
|
|
|
|
buf[i] = (char)digit;
|
|
}
|
|
|
|
buf[4] = (char)('0' + value);
|
|
buf[5] = '\0';
|
|
A->print_tiny(buf, vx, 1, true, true);
|
|
}
|
|
|
|
static void render(void)
|
|
{
|
|
A->display_clear();
|
|
A->status_clear();
|
|
|
|
A->draw_rect(A->fb, 1, 1, 126, 54, true);
|
|
|
|
if (!g.gameOver)
|
|
draw_sprite(BOARD_X + (g.foodX << 2), BOARD_Y + (g.foodY << 2), 0x6996);
|
|
|
|
draw_snake();
|
|
|
|
/* Score computed dynamically based on current length */
|
|
label_value(T_SCORE, 2, (((g.head_idx - g.tail_idx + 1u) & RING_MASK) - 3u) * SCORE_PER_FOOD, 32);
|
|
label_value(T_BEST, 81, g.best, 107);
|
|
|
|
if (g.gameOver)
|
|
say(g.newBest ? T_NEW_SCORE : T_GAME_OVER, 3);
|
|
else if (g.paused)
|
|
say(T_PAUSE, 3);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Game Logic */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
static void move_snake(void)
|
|
{
|
|
segment_t *body = g.snake;
|
|
/* Casting to unsigned handles both upper limit and negative underflow in one instruction */
|
|
int8_t d[2];
|
|
step_of(g.nextDirection, d);
|
|
uint32_t nx = (body[g.head_idx].x & 0x7F) + d[0];
|
|
uint32_t ny = body[g.head_idx].y + d[1];
|
|
|
|
if (nx >= COLS || ny >= ROWS) {
|
|
g.gameOver = true;
|
|
return;
|
|
}
|
|
|
|
bool ate = (nx == g.foodX && ny == g.foodY);
|
|
uint32_t tail_x = body[g.tail_idx].x & 0x7F;
|
|
uint32_t tail_y = body[g.tail_idx].y;
|
|
|
|
if (!ate)
|
|
g.board_map[tail_y] &= ~(1UL << tail_x);
|
|
|
|
if (g.board_map[ny] & (1UL << nx)) {
|
|
if (!ate)
|
|
g.board_map[tail_y] |= (1UL << tail_x);
|
|
|
|
g.gameOver = true;
|
|
return;
|
|
}
|
|
|
|
g.direction = g.nextDirection;
|
|
g.board_map[ny] |= (1UL << nx);
|
|
|
|
uint32_t new_head = (g.head_idx + 1u) & RING_MASK;
|
|
|
|
if (!ate)
|
|
g.tail_idx = (uint16_t)((g.tail_idx + 1u) & RING_MASK);
|
|
|
|
body[new_head].x = (uint8_t)(nx | (ate ? 0x80 : 0));
|
|
body[new_head].y = (uint8_t)ny;
|
|
g.head_idx = (uint16_t)new_head;
|
|
|
|
if (ate) {
|
|
uint32_t new_score = (((new_head - g.tail_idx + 1u) & RING_MASK) - 3u) * SCORE_PER_FOOD;
|
|
if (new_score > g.best) {
|
|
g.best = new_score;
|
|
g.newBest = true;
|
|
}
|
|
place_food();
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Input & Loop */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
/* 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 & 32u)
|
|
say(T_PRESS, 6);
|
|
}
|
|
|
|
static void key_action(uint32_t key, bool repeat)
|
|
{
|
|
if (key == APP_KEY_EXIT) {
|
|
g.running = false;
|
|
return;
|
|
}
|
|
|
|
if (g.title || g.gameOver) { /* the game-over restart keys also leave the title */
|
|
if (!repeat && (key == APP_KEY_MENU || key == APP_KEY_STAR || key == APP_KEY_0)) {
|
|
g.title = false;
|
|
new_game();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!repeat && key == APP_KEY_F) {
|
|
g.paused = !g.paused;
|
|
return;
|
|
}
|
|
|
|
if (g.paused)
|
|
return;
|
|
|
|
uint32_t newDir = 4;
|
|
if (key == APP_KEY_2 || key == APP_KEY_3) newDir = 0u;
|
|
else if (key == APP_KEY_6 || key == APP_KEY_0) newDir = 1u;
|
|
else if (key == APP_KEY_8 || key == APP_KEY_9) newDir = 2u;
|
|
else if (key == APP_KEY_4 || key == APP_KEY_5) newDir = 3u;
|
|
|
|
if (newDir < 4 && (((g.direction + 2u) & 3u) != newDir))
|
|
g.nextDirection = (uint8_t)newDir;
|
|
}
|
|
|
|
static void poll_key(void)
|
|
{
|
|
uint8_t key = A->get_key();
|
|
|
|
if (key == APP_KEY_SAVER) {
|
|
if (!g.paused && !g.gameOver) {
|
|
g.paused = true;
|
|
g.saverPaused = true;
|
|
}
|
|
g.saverActive = true;
|
|
g.previousKey = APP_KEY_INVALID;
|
|
g.repeatMs = 0;
|
|
return;
|
|
}
|
|
|
|
if (key == APP_KEY_WAKE || key == APP_KEY_PTT) {
|
|
if (g.saverPaused)
|
|
g.paused = false;
|
|
g.saverPaused = false;
|
|
g.saverActive = false;
|
|
key = APP_KEY_INVALID;
|
|
}
|
|
|
|
if (key == APP_KEY_INVALID) {
|
|
g.previousKey = APP_KEY_INVALID;
|
|
g.repeatMs = 0;
|
|
return;
|
|
}
|
|
|
|
if (key != g.previousKey) {
|
|
key_action(key, false);
|
|
g.repeatMs = 260u;
|
|
} else if (g.repeatMs > TICK_MS) {
|
|
g.repeatMs -= TICK_MS;
|
|
} else {
|
|
key_action(key, true);
|
|
g.repeatMs = 80u;
|
|
}
|
|
g.previousKey = key;
|
|
}
|
|
|
|
static void new_game(void)
|
|
{
|
|
segment_t *body = g.snake;
|
|
g.head_idx = 2;
|
|
g.tail_idx = 0;
|
|
|
|
for (uint32_t i = 0; i < ROWS; i++)
|
|
g.board_map[i] = 0;
|
|
|
|
for (uint32_t i = 0; i < 3; i++) {
|
|
body[i].x = (uint8_t)(13u + i);
|
|
body[i].y = 6u;
|
|
g.board_map[6] |= (1UL << (13u + i));
|
|
}
|
|
|
|
g.direction = 1u;
|
|
g.nextDirection = 1u;
|
|
g.moveTimer = MOVE_INTERVAL;
|
|
|
|
g.paused = false;
|
|
g.gameOver = false;
|
|
g.newBest = false;
|
|
g.saverPaused = false;
|
|
g.saverActive = false;
|
|
|
|
place_food();
|
|
}
|
|
|
|
__attribute__((section(".text.entry"), used))
|
|
void app_main(const app_api_t *api)
|
|
{
|
|
segment_t body[RING_SIZE]; /* 1 KiB, on the stack: not in the overlay */
|
|
config_t cfg;
|
|
A = api;
|
|
g.snake = body;
|
|
|
|
A->cfg_load((uint8_t *)&cfg, sizeof(cfg));
|
|
g.best = (cfg.magic == CFG_MAGIC && cfg.version == 1u) ? cfg.best : 0u;
|
|
g.initial_best = g.best;
|
|
|
|
g.previousKey = APP_KEY_INVALID;
|
|
g.running = true; /* the rest of g starts zeroed (.bss) */
|
|
|
|
A->led(false);
|
|
A->backlight_on();
|
|
|
|
new_game();
|
|
g.title = true;
|
|
|
|
while (g.running) {
|
|
poll_key();
|
|
if (!g.running)
|
|
break;
|
|
|
|
if (!g.saverActive) { /* the saver freezes the game */
|
|
if (!g.title && !g.paused && !g.gameOver) {
|
|
if (g.moveTimer <= 0) {
|
|
g.moveTimer = MOVE_INTERVAL;
|
|
move_snake();
|
|
} else {
|
|
g.moveTimer -= (int32_t)TICK_MS;
|
|
}
|
|
}
|
|
|
|
if (g.title) draw_title();
|
|
else render();
|
|
A->blit_status();
|
|
A->blit_full();
|
|
}
|
|
A->backlight_update();
|
|
A->delay_ms(TICK_MS);
|
|
}
|
|
|
|
/* Save to NVRAM only if a new record was established compared to boot up */
|
|
if (g.best > g.initial_best) {
|
|
cfg.magic = CFG_MAGIC;
|
|
cfg.version = 1u;
|
|
cfg.best = g.best;
|
|
A->cfg_save((const uint8_t *)&cfg, sizeof(cfg));
|
|
}
|
|
|
|
A->led(false);
|
|
}
|