embedded-systems-labs/wormz/Src/game.c
2025-12-25 11:09:27 +03:00

759 lines
21 KiB
C
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file game.c
* @brief Tiny “Worms-like” artillery game for a 128x64 monochrome OLED.
*
* Coordinate system:
* - (0,0) is the top-left corner of the OLED framebuffer.
* - X increases to the right, Y increases downward.
*
* Gameplay (very simple):
* - Two worms stand on a 1D heightfield terrain (ground_y[x]).
* - On your turn you can move left/right, adjust aim angle, choose weapon, then shoot.
* - First press on FIRE enters “power charge” mode (power oscillates 0..1).
* - Second press on FIRE fires the shot with the current power.
* - Projectile flies with gravity (and optional wind), explodes on terrain or worm hit.
* - Explosion creates a crater and deals radial damage.
* - When a player reaches 0 HP the game shows GAME OVER.
*
* Controls:
* - 2: aim up (increase angle)
* - 8: aim down (decrease angle)
* - 4: move left
* - 6: move right
* - 5: FIRE (first press -> start charging, second press -> shoot)
* - *: next weapon (only while aiming)
* - #: previous weapon (only while aiming)
* - 0: restart (always available)
*
* Timing:
* - Runs as a FreeRTOS task at a fixed frame period (FRAME_MS).
* - Physics integration uses dt = FRAME_MS / 1000.0f (simple Euler integration).
*/
#include "game.h"
#include "main.h"
#include "oled.h"
#include "fonts.h"
#include "kb.h"
#include "FreeRTOS.h"
#include "task.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
#ifndef OLED_W
#define OLED_W OLED_WIDTH
#endif
#ifndef OLED_H
#define OLED_H OLED_HEIGHT
#endif
/* -------------------------------------------------------------------------- */
/* Key reading */
/* -------------------------------------------------------------------------- */
/**
* @brief Read keypad and translate to a single “key id”.
*
* kb.c returns a 3-bit pattern for each scanned row (0x04/0x02/0x01),
* where only one of the three bits can become “active” for a pressed key.
*
* @return
* - 1..9, 0 for digits
* - -1 for '*', -2 for '#'
* - -100 if nothing is pressed
*/
static int8_t get_key(void)
{
uint8_t k;
k = Check_Row(ROW1);
if (k == 0x04) return 1;
if (k == 0x02) return 2;
if (k == 0x01) return 3;
k = Check_Row(ROW2);
if (k == 0x04) return 4;
if (k == 0x02) return 5;
if (k == 0x01) return 6;
k = Check_Row(ROW3);
if (k == 0x04) return 7;
if (k == 0x02) return 8;
if (k == 0x01) return 9;
k = Check_Row(ROW4);
if (k == 0x04) return -1; /* '*' */
if (k == 0x02) return 0; /* '0' */
if (k == 0x01) return -2; /* '#' */
return -100;
}
/* -------------------------------------------------------------------------- */
/* Game model */
/* -------------------------------------------------------------------------- */
typedef enum {
GS_AIM = 0, /* player can move/aim/select weapon */
GS_CHARGE, /* power bar oscillates until second FIRE */
GS_FLIGHT, /* projectile is flying */
GS_EXPLOSION, /* explosion ring animation */
GS_GAMEOVER
} GameState;
typedef struct {
const char *name;
uint8_t blast_radius; /* pixels */
uint8_t max_damage; /* HP at distance 0 */
float vmin, vmax; /* projectile speed range (px/s) */
} Weapon;
static const Weapon weapons[] = {
{ "NORM", 10, 40, 35.0f, 70.0f },
{ "BIG ", 14, 60, 30.0f, 65.0f },
{ "MINI", 8, 25, 40.0f, 80.0f },
};
#define WEAPON_COUNT ((uint8_t)(sizeof(weapons) / sizeof(weapons[0])))
typedef struct {
int x; /* pixels */
int y; /* pixels (worm “body center”) */
int hp; /* 0..100 */
int angle_deg; /* 0..180, where 0 = right, 90 = up, 180 = left */
uint8_t weapon_id;
} Worm;
/* -------------------------------------------------------------------------- */
/* World state */
/* -------------------------------------------------------------------------- */
/**
* ground_y[x] = first ground pixel Y at column x.
* Everything with y >= ground_y[x] is filled as terrain.
*/
static uint8_t ground_y[OLED_W];
static Worm worms[2];
static uint8_t current_player = 0;
static GameState gs = GS_AIM;
/* Charge state (used only in GS_CHARGE) */
static float charge_power = 0.0f; /* 0..1 */
static float charge_dir = 1.0f; /* +1 or -1, causes oscillation */
/* Projectile state (used only in GS_FLIGHT) */
static float proj_x, proj_y;
static float proj_vx, proj_vy;
static uint8_t proj_owner;
static float proj_age; /* seconds since fired */
/* Explosion state (used only in GS_EXPLOSION) */
static int expl_x, expl_y;
static uint8_t expl_r, expl_rmax;
/* Physics parameters (in “pixel units”) */
static const float GRAVITY = 90.0f; /* px/s^2 */
static float WIND = 0.0f; /* px/s^2, applied to vx each update */
/* Fixed frame period */
#define FRAME_MS 30
static const float PI = 3.1415926f;
/* -------------------------------------------------------------------------- */
/* Drawing helpers */
/* -------------------------------------------------------------------------- */
static inline void px(int x, int y, uint8_t color)
{
if (x < 0 || x >= OLED_W || y < 0 || y >= OLED_H) return;
oled_DrawPixel((uint8_t)x, (uint8_t)y, color);
}
/* Integer Bresenham line */
static void line(int x0, int y0, int x1, int y1, uint8_t color)
{
int dx = (x1 > x0) ? (x1 - x0) : (x0 - x1);
int sx = (x0 < x1) ? 1 : -1;
int dy = (y1 > y0) ? (y0 - y1) : (y1 - y0);
int sy = (y0 < y1) ? 1 : -1;
int err = dx + dy;
while (1) {
px(x0, y0, color);
if (x0 == x1 && y0 == y1) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
/* Midpoint circle outline */
static void circle(int cx, int cy, int r, uint8_t color)
{
int x = r, y = 0, err = 0;
while (x >= y) {
px(cx + x, cy + y, color);
px(cx + y, cy + x, color);
px(cx - y, cy + x, color);
px(cx - x, cy + y, color);
px(cx - x, cy - y, color);
px(cx - y, cy - x, color);
px(cx + y, cy - x, color);
px(cx + x, cy - y, color);
y++;
err += 1 + 2 * y;
if (2 * (err - x) + 1 > 0) {
x--;
err += 1 - 2 * x;
}
}
}
/* Small filled circle (O(r^2), OK for small r) */
static void filled_circle(int cx, int cy, int r, uint8_t color)
{
for (int yy = -r; yy <= r; yy++) {
for (int xx = -r; xx <= r; xx++) {
if (xx * xx + yy * yy <= r * r) px(cx + xx, cy + yy, color);
}
}
}
/* -------------------------------------------------------------------------- */
/* Utilities */
/* -------------------------------------------------------------------------- */
static int clampi(int v, int lo, int hi)
{
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
/* -------------------------------------------------------------------------- */
/* Terrain */
/* -------------------------------------------------------------------------- */
/**
* @brief Generate a simple “random walk” heightfield.
*
* The terrain is represented as a height per X column. This is cheap to draw and
* cheap to crater (modify only affected columns).
*/
static void terrain_generate(void)
{
int y = 50;
uint32_t s = 1234567u; /* fixed seed: deterministic terrain each boot */
for (int x = 0; x < OLED_W; x++) {
s = s * 1103515245u + 12345u;
int n = (int)((s >> 28) & 0x0F) - 7; /* [-7..8] */
/* Turn into a small step up/down/flat to keep terrain smooth-ish */
y += (n > 0 ? 1 : (n < 0 ? -1 : 0));
/* Clamp to keep playable area */
if (y < 35) y = 35;
if (y > 58) y = 58;
ground_y[x] = (uint8_t)y;
}
}
/**
* @brief Place worm at X and snap its Y a few pixels above ground.
*/
static void place_worm(uint8_t id, int x)
{
x = clampi(x, 2, OLED_W - 3);
int y = (int)ground_y[x] - 3; /* stand above ground */
worms[id].x = x;
worms[id].y = y;
}
/**
* @brief Create a crater by pushing the terrain surface downward.
*
* This does not “remove” ground pixels (it increases ground_y = lower surface),
* which visually creates a hole.
*/
static void crater(int cx, int r)
{
int x0 = clampi(cx - r, 0, OLED_W - 1);
int x1 = clampi(cx + r, 0, OLED_W - 1);
for (int x = x0; x <= x1; x++) {
int dx = x - cx;
int rr2 = r * r;
int d2 = dx * dx;
if (d2 > rr2) continue;
/* Circular profile, scaled a bit to keep crater shallow */
int drop = (int)(sqrtf((float)(rr2 - d2)) * 0.55f);
int ny = (int)ground_y[x] + drop;
if (ny > (OLED_H - 1)) ny = (OLED_H - 1);
ground_y[x] = (uint8_t)ny;
}
}
/**
* @brief Apply radial damage to worms inside explosion radius.
*
* Damage linearly decreases with distance:
* dmg = max_damage * (1 - dist/r)
*/
static void apply_damage(int cx, int cy, uint8_t r, uint8_t maxdmg)
{
for (int i = 0; i < 2; i++) {
int dx = worms[i].x - cx;
int dy = worms[i].y - cy;
float dist = sqrtf((float)(dx * dx + dy * dy));
if (dist > (float)r) continue;
float k = 1.0f - (dist / (float)r);
int dmg = (int)(k * (float)maxdmg);
if (dmg < 0) dmg = 0;
worms[i].hp -= dmg;
if (worms[i].hp < 0) worms[i].hp = 0;
}
}
/* -------------------------------------------------------------------------- */
/* Rendering */
/* -------------------------------------------------------------------------- */
static void draw_terrain(void)
{
for (int x = 0; x < OLED_W; x++) {
for (int y = ground_y[x]; y < OLED_H; y++) px(x, y, White);
}
}
static void draw_worm(int x, int y, uint8_t active)
{
/* Active worm is filled so its easy to see whose turn it is */
if (active) {
filled_circle(x, y, 2, White);
px(x, y - 3, White);
} else {
circle(x, y, 2, White);
}
}
/**
* @brief Draw a short dotted trajectory preview.
*
* This is intentionally short and sparse (like the classic Worms preview),
* so it fits the small screen and doesnt clutter.
*/
static void draw_short_trajectory_preview(const Worm *w, float pwr01)
{
const int PREVIEW_PTS = 14;
const float dt = 0.06f;
const Weapon *wp = &weapons[w->weapon_id];
float speed = wp->vmin + pwr01 * (wp->vmax - wp->vmin);
float rad = (float)w->angle_deg * PI / 180.0f;
float vx = cosf(rad) * speed;
float vy = -sinf(rad) * speed;
float x = (float)w->x;
float y = (float)w->y;
for (int i = 0; i < PREVIEW_PTS; i++) {
x += vx * dt;
y += vy * dt;
vy += GRAVITY * dt;
int xi = (int)(x + 0.5f);
int yi = (int)(y + 0.5f);
if (xi < 0 || xi >= OLED_W || yi < 0 || yi >= OLED_H) break;
if (yi >= (int)ground_y[xi]) break;
if ((i & 1) == 0) px(xi, yi, White);
}
}
static void draw_ui(void)
{
char s[32];
oled_SetCursor(0, 0);
sprintf(s, "P1:%3d", worms[0].hp);
oled_WriteString(s, Font_7x10, White);
oled_SetCursor(64, 0);
sprintf(s, "P2:%3d", worms[1].hp);
oled_WriteString(s, Font_7x10, White);
const Worm *w = &worms[current_player];
oled_SetCursor(0, 12);
sprintf(s, "W:%s A:%3d", weapons[w->weapon_id].name, w->angle_deg);
oled_WriteString(s, Font_7x10, White);
/* Power bar: only shown while charging */
if (gs == GS_CHARGE) {
int bar_x = 4, bar_y = 56, bar_w = 120, bar_h = 6;
line(bar_x, bar_y, bar_x + bar_w, bar_y, White);
line(bar_x, bar_y + bar_h, bar_x + bar_w, bar_y + bar_h, White);
line(bar_x, bar_y, bar_x, bar_y + bar_h, White);
line(bar_x + bar_w, bar_y, bar_x + bar_w, bar_y + bar_h, White);
int fill = (int)(charge_power * (float)(bar_w - 2));
fill = clampi(fill, 0, bar_w - 2);
for (int x = 0; x < fill; x++) {
for (int y = 0; y < bar_h - 1; y++) {
px(bar_x + 1 + x, bar_y + 1 + y, White);
}
}
}
if (gs == GS_GAMEOVER) {
oled_SetCursor(20, 30);
if (worms[0].hp == 0 && worms[1].hp == 0) {
oled_WriteString("DRAW", Font_11x18, White);
} else if (worms[0].hp == 0) {
oled_WriteString("P2 WINS", Font_11x18, White);
} else {
oled_WriteString("P1 WINS", Font_11x18, White);
}
oled_SetCursor(6, 52);
oled_WriteString("0=restart", Font_7x10, White);
}
}
static void render(void)
{
oled_Fill(Black);
draw_terrain();
draw_worm(worms[0].x, worms[0].y, (current_player == 0 && gs != GS_GAMEOVER));
draw_worm(worms[1].x, worms[1].y, (current_player == 1 && gs != GS_GAMEOVER));
/* Aim line + trajectory preview while aiming/charging */
if (gs == GS_AIM || gs == GS_CHARGE) {
Worm *w = &worms[current_player];
float rad = (float)w->angle_deg * PI / 180.0f;
int x2 = w->x + (int)(cosf(rad) * 10.0f);
int y2 = w->y - (int)(sinf(rad) * 10.0f);
line(w->x, w->y, x2, y2, White);
/* During AIM we preview with a “typical” mid power; during CHARGE show real power */
float p = (gs == GS_CHARGE) ? charge_power : 0.55f;
draw_short_trajectory_preview(w, p);
}
if (gs == GS_FLIGHT) {
px((int)(proj_x + 0.5f), (int)(proj_y + 0.5f), White);
}
if (gs == GS_EXPLOSION) {
circle(expl_x, expl_y, expl_r, White);
}
draw_ui();
oled_UpdateScreen();
}
/* -------------------------------------------------------------------------- */
/* Game flow */
/* -------------------------------------------------------------------------- */
static void next_player(void)
{
current_player ^= 1u;
gs = GS_AIM;
/* Reset charge state for the next turn */
charge_power = 0.0f;
charge_dir = 1.0f;
}
/**
* @brief Start explosion: apply crater + damage instantly, then animate ring.
*/
static void explode_at(int x, int y)
{
Worm *w = &worms[current_player];
const Weapon *wp = &weapons[w->weapon_id];
expl_x = clampi(x, 0, OLED_W - 1);
expl_y = clampi(y, 0, OLED_H - 1);
expl_r = 1;
expl_rmax = wp->blast_radius;
crater(expl_x, wp->blast_radius);
apply_damage(expl_x, expl_y, wp->blast_radius, wp->max_damage);
if (worms[0].hp == 0 || worms[1].hp == 0) {
gs = GS_GAMEOVER;
return;
}
gs = GS_EXPLOSION;
}
static void start_shot(void)
{
Worm *w = &worms[current_player];
const Weapon *wp = &weapons[w->weapon_id];
float speed = wp->vmin + charge_power * (wp->vmax - wp->vmin);
float rad = (float)w->angle_deg * PI / 180.0f;
/* Spawn projectile from the “muzzle”, not the worm center.
* This avoids immediate self-collision due to rounding.
*/
const float MUZZLE = 7.0f;
proj_x = (float)w->x + cosf(rad) * MUZZLE;
proj_y = (float)w->y - sinf(rad) * MUZZLE;
proj_vx = cosf(rad) * speed;
proj_vy = -sinf(rad) * speed;
proj_owner = current_player;
proj_age = 0.0f;
gs = GS_FLIGHT;
}
static void update_charge(void)
{
/* Power oscillates between 0 and 1, so the player “times” the second FIRE. */
const float step = 0.035f;
charge_power += charge_dir * step;
if (charge_power >= 1.0f) { charge_power = 1.0f; charge_dir = -1.0f; }
if (charge_power <= 0.0f) { charge_power = 0.0f; charge_dir = 1.0f; }
}
static void update_flight(float dt)
{
proj_age += dt;
/* Simple Euler integration */
proj_vx += WIND * dt;
proj_vy += GRAVITY * dt;
proj_x += proj_vx * dt;
proj_y += proj_vy * dt;
int xi = (int)(proj_x + 0.5f);
int yi = (int)(proj_y + 0.5f);
/* If we leave the visible screen, explode at the nearest edge point */
if (xi < 0 || xi >= OLED_W || yi < 0 || yi >= OLED_H) {
explode_at(clampi(xi, 0, OLED_W - 1), clampi(yi, 0, OLED_H - 1));
return;
}
/* Terrain hit: explode right on the terrain surface at this column */
if (yi >= (int)ground_y[xi]) {
explode_at(xi, (int)ground_y[xi]);
return;
}
/* Worm collision: treat each worm as a small circle around (x,y) */
for (int i = 0; i < 2; i++) {
/* Ignore the shooter for a short time to avoid instant self-hit from rounding.
* (Especially important because the projectile is rendered as a single pixel.)
*/
if ((uint8_t)i == proj_owner && proj_age < 0.12f) continue;
int dx = worms[i].x - xi;
int dy = worms[i].y - yi;
if (dx * dx + dy * dy <= 9) { /* radius ~3 px */
explode_at(xi, yi);
return;
}
}
}
static void update_explosion(void)
{
/* Expand ring until max radius, then hand the turn to the other player */
if (expl_r < expl_rmax) {
expl_r += 2;
if (expl_r > expl_rmax) expl_r = expl_rmax;
} else {
next_player();
}
}
/* -------------------------------------------------------------------------- */
/* Weapons */
/* -------------------------------------------------------------------------- */
static void weapon_next(void)
{
Worm *w = &worms[current_player];
w->weapon_id = (uint8_t)((w->weapon_id + 1u) % WEAPON_COUNT);
}
static void weapon_prev(void)
{
Worm *w = &worms[current_player];
w->weapon_id = (uint8_t)((w->weapon_id + WEAPON_COUNT - 1u) % WEAPON_COUNT);
}
/* -------------------------------------------------------------------------- */
/* Input handling */
/* -------------------------------------------------------------------------- */
static void handle_key_press(int8_t key)
{
/* Restart is always allowed */
if (key == 0) {
terrain_generate();
worms[0].hp = 100; worms[1].hp = 100;
worms[0].angle_deg = 45; worms[1].angle_deg = 135;
worms[0].weapon_id = 0; worms[1].weapon_id = 0;
place_worm(0, 12);
place_worm(1, OLED_W - 13);
current_player = 0;
gs = GS_AIM;
charge_power = 0.0f;
charge_dir = 1.0f;
return;
}
if (gs == GS_GAMEOVER) return;
/* Weapon switch only makes sense when not charging/flying/exploding */
if (key == -1) { /* '*' */
if (gs == GS_AIM) weapon_next();
return;
}
if (key == -2) { /* '#' */
if (gs == GS_AIM) weapon_prev();
return;
}
if (gs == GS_AIM) {
Worm *w = &worms[current_player];
if (key == 2) { /* aim up */
w->angle_deg += 2;
if (w->angle_deg > 180) w->angle_deg = 180;
} else if (key == 8) { /* aim down */
w->angle_deg -= 2;
if (w->angle_deg < 0) w->angle_deg = 0;
} else if (key == 4) { /* move left */
place_worm(current_player, w->x - 1);
} else if (key == 6) { /* move right */
place_worm(current_player, w->x + 1);
} else if (key == 5) { /* FIRE: first press enters charge mode */
gs = GS_CHARGE;
charge_power = 0.0f;
charge_dir = 1.0f;
}
} else if (gs == GS_CHARGE) {
/* FIRE again to commit the power and shoot */
if (key == 5) {
start_shot();
}
}
}
/* -------------------------------------------------------------------------- */
/* FreeRTOS task */
/* -------------------------------------------------------------------------- */
/**
* @brief Main game loop task.
*
* Input model:
* - Uses edge detection: a key press is handled once per press (no repeat).
* - If you want auto-repeat for movement/aim, you can add a repeat timer.
*/
static void GameTask(void *argument)
{
(void)argument;
int8_t last_key = -100;
TickType_t last_wake = xTaskGetTickCount();
const TickType_t period = pdMS_TO_TICKS(FRAME_MS);
for (;;) {
/* Edge detect: only handle transitions to a pressed key */
int8_t key = get_key();
if (key != last_key && key != -100) {
handle_key_press(key);
}
last_key = key;
/* Update state machine */
if (gs == GS_CHARGE) {
update_charge();
} else if (gs == GS_FLIGHT) {
update_flight((float)FRAME_MS / 1000.0f);
} else if (gs == GS_EXPLOSION) {
update_explosion();
}
render();
vTaskDelayUntil(&last_wake, period);
}
}
/* -------------------------------------------------------------------------- */
/* Public API */
/* -------------------------------------------------------------------------- */
void Game_Init(void)
{
terrain_generate();
worms[0].hp = 100;
worms[1].hp = 100;
worms[0].angle_deg = 45;
worms[1].angle_deg = 135;
worms[0].weapon_id = 0;
worms[1].weapon_id = 0;
place_worm(0, 12);
place_worm(1, OLED_W - 13);
current_player = 0;
gs = GS_AIM;
charge_power = 0.0f;
charge_dir = 1.0f;
/* Wind is supported by the physics, currently disabled (0). */
WIND = 0.0f;
render();
(void)xTaskCreate(GameTask, "Game", 512, NULL, tskIDLE_PRIORITY + 2, NULL);
}