Files
ZephCore/zephcore/helpers/ui/doom_game.c
T
2026-10-06 21:43:35 +02:00

1860 lines
47 KiB
C
Raw 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.
/*
* ZephCore - Doom Raycaster Easter Egg
* Copyright (c) 2025 ZephCore
* SPDX-License-Identifier: MIT
*
* Wolf3D-style raycasting engine merged into a single file.
* Runs on k_work_delayable at 20 FPS on the system work queue.
* Writes directly to display via display_write() (bypasses CFB).
*
* Two levels:
* L1 — kill 2 monsters (imp + demon); a portal spawns at a random open tile.
* L2 — open arena against a 300-HP boss that waits in a random corner.
*
* The L2 fight is not a damage race. The boss wears armour that soaks 7/8 of
* every shot, and only drops it while its jaw is open — during the wind-up
* before it spits a fireball, and on the frames its claws are out in melee.
* Jaw open is drawn and computed from one predicate, so what you see is
* exactly what you can hurt. Below half HP it enrages: twice the speed, a
* shorter cast cycle. Shots and fireballs both need line of sight, which is
* what makes the arena pillars and the centre block real cover.
*
* ~2.0 KB RAM, ~7 KB flash when enabled.
*/
#include "doom_game.h"
#include "display.h"
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/display.h>
#include <zephyr/drivers/pwm.h>
#include <zephyr/dt-bindings/input/input-event-codes.h>
#include <zephyr/sys/atomic.h>
#include <string.h>
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
#include "buzzer.h"
#endif
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(doom_game, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL);
/* ========== CONSTANTS ========== */
#define SCREEN_W 128
#define SCREEN_H 64
#define MAP_W 16
#define MAP_H 16
#define FB_SIZE (SCREEN_W * (SCREEN_H / 8))
#define FP_SHIFT 16
#define FP_ONE (1 << FP_SHIFT)
#define FP_HALF (FP_ONE >> 1)
#define TARGET_FPS 20
#define FRAME_MS (1000 / TARGET_FPS)
#define MOVE_SPEED (FP_ONE / 8) /* player */
#define ROT_SPEED (FP_ONE / 12)
#define COLLISION_R (FP_ONE / 4)
#define MAX_ENEMIES 8
#define ENEMY_SPEED (FP_ONE / 16) /* imp / demon — half the player's pace */
#define BOSS_SPEED (FP_ONE / 32) /* boss phase 1 — a quarter of the player */
#define BOSS_SPEED_RAGE (FP_ONE / 16) /* boss phase 2 — doubles, still outrunnable */
#define BOSS_HP 300
#define SHOT_DAMAGE 20
/*
* Armour divisor: shots land for SHOT_DAMAGE/8 = 2 while the jaw is shut.
* Sized so that holding the trigger down cannot win. A held trigger lands
* ~2.4 of its 14 shots per cast cycle inside the window by luck, for ~71 dmg
* — 300 HP would take 4.2 cycles and 59 rounds, and the magazine holds ~45
* by the time L2 starts. Playing the tell needs 15 rounds.
*/
#define BOSS_GUARD_DIV 8
/*
* Boss cast cycle. Every fireball is telegraphed: the boss plants its feet and
* opens its jaw for BOSS_WINDUP frames first, and that window is the only time
* at range when it takes full damage.
*/
#define BOSS_WINDUP 12 /* 0.6 s of open jaw before the spit */
#define BOSS_CAST_PERIOD 70 /* 3.5 s between casts, phase 1 */
#define BOSS_CAST_PERIOD_RAGE 45 /* 2.25 s, phase 2 */
#define BOSS_CAST_MIN_DIST (FP_ONE * 5 / 2) /* nearer than this it swings */
/*
* Frames the boss backs off after a melee swing, so the cast cycle can
* start again instead of a melee grind.
*/
#define BOSS_RETREAT 50
#define MAX_FIREBALLS 3
#define FIREBALL_SPEED (FP_ONE / 6) /* ~3.3 tiles/s — outruns a straight retreat */
#define FIREBALL_DMG 18
#define FIREBALL_LIFE 110 /* frames before it burns out */
#define FIREBALL_HIT_R FP_HALF
/* Input bitflags */
#define DINPUT_FWD BIT(0)
#define DINPUT_BACK BIT(1)
#define DINPUT_LEFT BIT(2)
#define DINPUT_RIGHT BIT(3)
#define DINPUT_FIRE BIT(4)
/* ========== FIXED-POINT MATH ========== */
typedef int32_t fixed_t;
static inline fixed_t fp_mul(fixed_t a, fixed_t b)
{
return (fixed_t)(((int64_t)a * b) >> FP_SHIFT);
}
static inline fixed_t fp_div(fixed_t a, fixed_t b)
{
if (b == 0) return a > 0 ? INT32_MAX : INT32_MIN;
return (fixed_t)(((int64_t)a << FP_SHIFT) / b);
}
static inline fixed_t fp_from_int(int x)
{
return x << FP_SHIFT;
}
static inline int fp_to_int(fixed_t x)
{
return x >> FP_SHIFT;
}
static inline fixed_t fp_abs(fixed_t x)
{
return x < 0 ? -x : x;
}
/*
* |(dx,dy)| by alpha-max-plus-beta-min (alpha 1, beta 3/8): ~7% worst-case
* error, no sqrt and no division. Chase and aim vectors normalise with this.
* The code this replaced divided by the *squared* distance, which made every
* enemy crawl exponentially slower the further away it stood — the boss ran
* at roughly a twentieth of its nominal speed at normal engagement range.
*/
static inline fixed_t fp_dist(fixed_t dx, fixed_t dy)
{
fixed_t ax = fp_abs(dx);
fixed_t ay = fp_abs(dy);
fixed_t hi = ax > ay ? ax : ay;
fixed_t lo = ax > ay ? ay : ax;
return hi + (lo >> 2) + (lo >> 3);
}
/* ========== GAME TYPES ========== */
enum enemy_type {
ENEMY_NONE = 0,
ENEMY_IMP,
ENEMY_DEMON,
ENEMY_BOSS,
};
enum enemy_state {
ESTATE_IDLE,
ESTATE_CHASE,
ESTATE_ATTACK,
ESTATE_DYING,
ESTATE_DEAD,
};
struct doom_player {
fixed_t x, y;
fixed_t dir_x, dir_y;
fixed_t plane_x, plane_y;
int health;
int ammo;
bool firing;
};
struct doom_enemy {
enum enemy_type type;
enum enemy_state state;
fixed_t x, y;
int health;
int anim_tick;
int cast_tick; /* boss only: frames left in the current cast cycle */
int retreat_tick; /* boss only: frames left disengaging after a swing */
};
struct doom_fireball {
bool active;
fixed_t x, y;
fixed_t vx, vy;
int life;
};
struct doom_state {
struct doom_player player;
struct doom_enemy enemies[MAX_ENEMIES];
struct doom_fireball fireballs[MAX_FIREBALLS];
int num_enemies;
int level;
int score;
bool game_over;
bool won;
bool portal_active;
int portal_map_x, portal_map_y;
int portal_anim;
uint32_t frame_count;
};
/* ========== STATIC STATE ========== */
static bool running;
static struct doom_state game;
static uint8_t render_fb[FB_SIZE];
static atomic_t input_state;
static fixed_t zbuffer[SCREEN_W];
static int fire_cooldown;
static struct k_work_delayable game_tick_work;
/* ========== SOUND ========== */
#if DT_HAS_ALIAS(buzzer)
static const struct pwm_dt_spec doom_buzzer = PWM_DT_SPEC_GET(DT_ALIAS(buzzer));
#define HAS_DOOM_BUZZER 1
#else
#define HAS_DOOM_BUZZER 0
#endif
struct tone {
uint16_t freq;
uint16_t dur_ms;
};
static const struct tone snd_shoot[] = {
{800, 30}, {400, 30}, {200, 40}, {0, 0}
};
static const struct tone snd_hit[] = {
{300, 50}, {150, 80}, {0, 0}
};
static const struct tone snd_death[] = {
{500, 80}, {400, 80}, {300, 100}, {200, 120}, {100, 150}, {0, 0}
};
/* Rising arpeggio — plays when portal opens and again on victory */
static const struct tone snd_portal[] = {
{400, 40}, {600, 40}, {800, 40}, {1100, 80}, {0, 0}
};
/* Boss spits — descending whoosh, the audio half of the tell */
static const struct tone snd_fireball[] = {
{900, 25}, {700, 25}, {500, 35}, {0, 0}
};
/* Boss crosses half HP and enrages */
static const struct tone snd_rage[] = {
{200, 60}, {160, 60}, {220, 60}, {160, 140}, {0, 0}
};
static const struct tone *current_sound;
static struct k_work_delayable sound_work;
static bool sound_allowed(void)
{
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
return !buzzer_is_quiet();
#else
return true;
#endif
}
static void doom_sound_work_handler(struct k_work *work)
{
ARG_UNUSED(work);
#if HAS_DOOM_BUZZER
if (!current_sound || current_sound->freq == 0) {
pwm_set_pulse_dt(&doom_buzzer, 0);
current_sound = NULL;
return;
}
uint32_t period = 1000000000U / current_sound->freq;
pwm_set_dt(&doom_buzzer, period, period / 2);
uint16_t dur = current_sound->dur_ms;
current_sound++;
k_work_reschedule(&sound_work, K_MSEC(dur));
#endif
}
static void doom_play_sound(const struct tone *seq)
{
#if HAS_DOOM_BUZZER
if (!sound_allowed()) {
return;
}
current_sound = seq;
k_work_reschedule(&sound_work, K_NO_WAIT);
#else
ARG_UNUSED(seq);
#endif
}
static void doom_sound_off(void)
{
#if HAS_DOOM_BUZZER
k_work_cancel_delayable(&sound_work);
if (pwm_is_ready_dt(&doom_buzzer)) {
pwm_set_pulse_dt(&doom_buzzer, 0);
}
current_sound = NULL;
#endif
}
/* ========== MAPS ========== */
/*
* Level 1: corridors and alcoves.
* Player starts south-centre facing north.
* Two monsters spawn NW and NE.
*/
static const uint8_t level_1[MAP_H][MAP_W] = {
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,2,2,2,0,0,0,0,3,3,3,0,0,1},
{1,0,0,2,0,0,0,0,0,0,0,0,3,0,0,1},
{1,0,0,2,0,0,0,0,0,0,0,0,3,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,4,4,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,4,4,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,3,0,0,0,0,0,0,0,0,2,0,0,1},
{1,0,0,3,0,0,0,0,0,0,0,0,2,0,0,1},
{1,0,0,3,3,3,0,0,0,0,2,2,2,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
};
/*
* Level 2: open boss arena with corner pillars and a hollow centre block.
* Long sight-lines for kiting. Player enters south-centre; boss spawns in one
* of four far corners chosen pseudo-randomly.
*/
static const uint8_t level_2[MAP_H][MAP_W] = {
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,2,0,0,0,0,0,0,0,0,2,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,3,3,3,3,0,0,0,0,0,1},
{1,0,0,0,0,0,3,0,0,3,0,0,0,0,0,1},
{1,0,0,0,0,0,3,0,0,3,0,0,0,0,0,1},
{1,0,0,0,0,0,3,3,3,3,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,2,0,0,0,0,0,0,0,0,2,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
};
static const uint8_t *current_map;
static void doom_map_init(void)
{
current_map = (game.level == 2)
? &level_2[0][0]
: &level_1[0][0];
}
static uint32_t isqrt64(uint64_t v)
{
uint64_t r = 0;
uint64_t bit = 1ULL << 62;
while (bit > v) {
bit >>= 2;
}
while (bit) {
if (v >= r + bit) {
v -= r + bit;
r = (r >> 1) + bit;
} else {
r >>= 1;
}
bit >>= 2;
}
return (uint32_t)r;
}
/* Turn by sin_r (signed) and put the view back on the unit circle: the
* small-angle cosine, then dir renormalised to unit length and plane rebuilt
* perpendicular to it. Without the renormalisation the vectors shrink. */
static void rotate_view(struct doom_player *p, fixed_t sin_r)
{
fixed_t cos_r = FP_ONE - fp_mul(sin_r, sin_r) / 2;
fixed_t dx = fp_mul(p->dir_x, cos_r) - fp_mul(p->dir_y, sin_r);
fixed_t dy = fp_mul(p->dir_x, sin_r) + fp_mul(p->dir_y, cos_r);
uint32_t len = isqrt64((uint64_t)((int64_t)dx * dx) + (uint64_t)((int64_t)dy * dy));
if (len == 0) {
dx = 0;
dy = -FP_ONE;
len = FP_ONE;
}
p->dir_x = (fixed_t)(((int64_t)dx << FP_SHIFT) / len);
p->dir_y = (fixed_t)(((int64_t)dy << FP_SHIFT) / len);
p->plane_x = -(p->dir_y * 2) / 3;
p->plane_y = (p->dir_x * 2) / 3;
}
static uint8_t doom_map_get(int x, int y)
{
if (x < 0 || x >= MAP_W || y < 0 || y >= MAP_H) {
return 1;
}
return current_map[y * MAP_W + x];
}
/*
* True when nothing solid sits on the segment (x0,y0)-(x1,y1). Sampled every
* 1/8 tile; walls are whole tiles, so the walk cannot tunnel one. Gates both
* the player's hitscan and the boss's fireball — this is what promotes the L2
* pillars and centre block from scenery to cover.
*/
static bool has_line_of_sight(fixed_t x0, fixed_t y0, fixed_t x1, fixed_t y1)
{
fixed_t dx = x1 - x0;
fixed_t dy = y1 - y0;
int steps = (int)(fp_dist(dx, dy) >> (FP_SHIFT - 3));
if (steps <= 0) return true;
if (steps > MAP_W * 8) steps = MAP_W * 8;
fixed_t sx = dx / steps;
fixed_t sy = dy / steps;
fixed_t cx = x0;
fixed_t cy = y0;
for (int i = 0; i < steps; i++) {
cx += sx;
cy += sy;
if (doom_map_get(fp_to_int(cx), fp_to_int(cy)) != 0) {
return false;
}
}
return true;
}
/* ========== WALL TEXTURES (8x8 1-bit) ========== */
static const uint8_t tex_brick[8] = {
0xFF, 0x88, 0xFF, 0xFF, 0x22, 0xFF, 0xFF, 0x88,
};
static const uint8_t tex_stone[8] = {
0xEE, 0xBB, 0xFF, 0x77, 0xFF, 0xDD, 0xBB, 0xFF,
};
static const uint8_t tex_metal[8] = {
0xFF, 0x81, 0xA5, 0x81, 0x81, 0xA5, 0x81, 0xFF,
};
static const uint8_t tex_pillar[8] = {
0xB6, 0xB6, 0xB6, 0xFF, 0x6D, 0x6D, 0x6D, 0xFF,
};
static const uint8_t *wall_textures[5] = {
tex_brick, tex_brick, tex_stone, tex_metal, tex_pillar,
};
/* ========== BAYER 4x4 DITHERING ========== */
static const uint8_t bayer4x4[4][4] = {
{ 0, 8, 2, 10},
{12, 4, 14, 6},
{ 3, 11, 1, 9},
{15, 7, 13, 5},
};
static int dist_to_brightness(fixed_t dist)
{
int d = fp_to_int(dist);
if (d < 1) return 15;
if (d >= 8) return 1;
return 15 - (d * 2);
}
static bool textured_pixel(int screen_x, int screen_y,
int tex_x, int tex_y,
uint8_t wall_type, bool side_hit,
fixed_t perp_dist)
{
const uint8_t *tex = wall_textures[wall_type < 5 ? wall_type : 0];
bool tex_on = (tex[tex_y & 7] & (0x80 >> (tex_x & 7))) != 0;
if (!tex_on) return false;
int brightness = dist_to_brightness(perp_dist);
if (side_hit) {
brightness = (brightness * 3) / 4;
}
uint8_t threshold = bayer4x4[screen_y & 3][screen_x & 3];
return brightness > threshold;
}
static bool floor_pixel(int x, int y)
{
int dy = y - (SCREEN_H / 2);
if (dy <= 0) return false;
int brightness;
if (dy < 4) brightness = 6;
else if (dy < 8) brightness = 4;
else if (dy < 16) brightness = 3;
else brightness = 2;
uint8_t threshold = bayer4x4[y & 3][x & 3];
return brightness > threshold;
}
static bool ceiling_pixel(int x, int y)
{
int dy = (SCREEN_H / 2) - y;
if (dy <= 0) return false;
int brightness;
if (dy < 4) brightness = 3;
else if (dy < 8) brightness = 2;
else brightness = 1;
uint8_t threshold = bayer4x4[y & 3][x & 3];
return brightness > threshold;
}
/* ========== SPRITES (16x16 1-bit) ========== */
static const uint16_t spr_imp_idle[16] = {
0x07C0, 0x0FE0, 0x1BB0, 0x1FF0, 0x0FE0, 0x07C0, 0x27C8, 0x77DC,
0xFFFE, 0xEFEE, 0x47C4, 0x07C0, 0x06C0, 0x0EE0, 0x0C60, 0x1C70,
};
static const uint16_t spr_imp_attack[16] = {
0x07C0, 0x0FE0, 0x1BB0, 0x1FF0, 0x0FE0, 0x07C0, 0x07C0, 0xFFFE,
0xFFFE, 0xE7CE, 0x07C0, 0x07C0, 0x06C0, 0x0EE0, 0x0C60, 0x1C70,
};
static const uint16_t spr_demon_idle[16] = {
0x4004, 0x600C, 0x783C, 0x7FFC, 0xFFFE, 0xFBBE, 0xFFFE, 0x7FFC,
0x7FFC, 0x7FFC, 0xFFFE, 0xFFFE, 0xF83E, 0xE82E, 0xC826, 0xC826,
};
static const uint16_t spr_demon_attack[16] = {
0x4004, 0x600C, 0x783C, 0x7FFC, 0xFFFE, 0xF03E, 0xFBBE, 0x787C,
0x7FFC, 0x7FFC, 0xFFFE, 0xFFFE, 0xF83E, 0xE82E, 0xC826, 0xC826,
};
/*
* Boss: hulking demon — very wide, almost fully filled, wide-open jaw on attack.
* Distinct from the regular demon by the solid torso and broader silhouette.
*/
static const uint16_t spr_boss_idle[16] = {
0x2004, 0x300C, 0x7FFE, 0xFFFF,
0xE3C7, 0xFFFF, 0xDBBD, 0xFFFF,
0xFFFF, 0xFFFF, 0x7FFE, 0x3FFC,
0x1FF8, 0x0FF0, 0x0660, 0x0FF0,
};
static const uint16_t spr_boss_attack[16] = {
0x2004, 0x300C, 0x7FFE, 0xFFFF,
0xC003, 0xE7E7, 0x8001, 0xFFFF,
0xFFFF, 0xFFFF, 0x7FFE, 0x3FFC,
0x1FF8, 0x0FF0, 0x0660, 0x0FF0,
};
static const uint16_t spr_dying[16] = {
0x0000, 0x0000, 0x0000, 0x0000, 0x0200, 0x1290, 0x0840, 0x27C8,
0x1FF0, 0x3FF8, 0x7FFC, 0x7FFC, 0xFFFE, 0xFFFE, 0xFFFE, 0xFFFE,
};
static const uint16_t spr_dead[16] = {
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
0x0000, 0x0000, 0x0000, 0x0000, 0x4488, 0x2B54, 0x7FFC, 0xFFFE,
};
/*
* Portal sprites — two frames that alternate every 8 ticks (~0.4 s each):
* frame A: diamond outline (converging tips at top/bottom)
* frame B: X pattern (diverging to corners)
* The visual swap reads like a spinning / pulsing energy field.
*/
static const uint16_t spr_portal_a[16] = {
0x0180, 0x0240, 0x0420, 0x0810, 0x1008, 0x2004, 0x4002, 0x8001,
0x8001, 0x4002, 0x2004, 0x1008, 0x0810, 0x0420, 0x0240, 0x0180,
};
static const uint16_t spr_portal_b[16] = {
0x8001, 0x4002, 0x2004, 0x1008, 0x0810, 0x0420, 0x0240, 0x0180,
0x0180, 0x0240, 0x0420, 0x0810, 0x1008, 0x2004, 0x4002, 0x8001,
};
/*
* Fireball — a solid ball confined to the middle 8 rows of the 16×16 cell, so
* it renders at eye level, at roughly half an enemy's apparent size, through
* the shared blitter at scale 100. Solid, unlike the outlined portal
* diamond, so the two never read as the same thing.
*/
static const uint16_t spr_fireball[16] = {
0x0000, 0x0000, 0x0000, 0x0000,
0x0180, 0x03C0, 0x07E0, 0x0FF0,
0x0FF0, 0x07E0, 0x03C0, 0x0180,
0x0000, 0x0000, 0x0000, 0x0000,
};
/* Boss phase 2: below half HP it moves twice as fast and casts more often. */
static inline bool boss_enraged(const struct doom_enemy *e)
{
return e->type == ENEMY_BOSS && e->health <= BOSS_HP / 2;
}
/*
* The boss's one tell. It is fully vulnerable exactly while its jaw is open:
* during the fireball wind-up, and on the claws-out frames of a melee swing.
* Both the renderer and the damage path call this, so the open-jaw sprite on
* screen is a literal readout of the damage window rather than decoration.
*/
static bool boss_jaw_open(const struct doom_enemy *e)
{
if (e->type != ENEMY_BOSS) return false;
if (e->state == ESTATE_ATTACK) {
return (e->anim_tick & 4) != 0;
}
if (e->state != ESTATE_CHASE) {
return false;
}
return e->cast_tick > 0 && e->cast_tick <= BOSS_WINDUP;
}
static const uint16_t *get_sprite_16(const struct doom_enemy *e)
{
if (e->state == ESTATE_DEAD) return spr_dead;
if (e->state == ESTATE_DYING) return spr_dying;
switch (e->type) {
case ENEMY_IMP:
return (e->state == ESTATE_ATTACK && (e->anim_tick & 4))
? spr_imp_attack : spr_imp_idle;
case ENEMY_DEMON:
return (e->state == ESTATE_ATTACK && (e->anim_tick & 4))
? spr_demon_attack : spr_demon_idle;
case ENEMY_BOSS:
return boss_jaw_open(e) ? spr_boss_attack : spr_boss_idle;
default:
return spr_imp_idle;
}
}
/* ========== RAYCASTER ========== */
/* The sprite texel under screen pixel (sc, sr) of a sprite drawn at
* [x0, x0 + w) x [y0, y0 + h). */
static inline bool sprite_texel_on(const uint16_t *sprite, int sc, int sr,
int x0, int y0, int w, int h)
{
int tex_x = (sc - x0) * 16 / w;
int tex_y = (sr - y0) * 16 / h;
if (tex_x > 15) tex_x = 15;
if (tex_y > 15) tex_y = 15;
return (sprite[tex_y] & (0x8000 >> tex_x)) != 0;
}
static inline void clear_px(int x, int y)
{
render_fb[(y / 8) * SCREEN_W + x] &= (uint8_t)~(1U << (y & 7));
}
/*
* Render one 16x16 sprite at world position (wx, wy), scale_pct percent of a
* wall's height with its feet on the floor. Solid with a one-pixel black
* halo; both passes honour the zbuffer.
*/
static void render_one_sprite(const uint16_t *sprite,
fixed_t wx, fixed_t wy,
fixed_t inv_det,
struct doom_player *p,
int scale_pct)
{
fixed_t sx = wx - p->x;
fixed_t sy = wy - p->y;
fixed_t transform_x = fp_mul(inv_det,
fp_mul(p->dir_y, sx) - fp_mul(p->dir_x, sy));
fixed_t transform_y = fp_mul(inv_det,
fp_mul(-p->plane_y, sx) + fp_mul(p->plane_x, sy));
if (transform_y <= 0) return;
int sprite_screen_x = (SCREEN_W / 2) +
fp_to_int(fp_div(fp_mul(transform_x,
fp_from_int(SCREEN_W)), transform_y));
int base_h = fp_to_int(fp_abs(fp_div(
fp_from_int(SCREEN_H), transform_y)));
if (base_h > SCREEN_H * 2) base_h = SCREEN_H * 2;
int sprite_h = base_h * scale_pct / 100;
if (sprite_h < 3) return;
int draw_end_y = (SCREEN_H + base_h) / 2; /* the floor line */
int draw_start_y = draw_end_y - sprite_h;
int draw_start_x = sprite_screen_x - sprite_h / 2;
int draw_end_x = sprite_screen_x + sprite_h / 2;
int sprite_w = draw_end_x - draw_start_x;
int x_lo = draw_start_x < 0 ? 0 : draw_start_x;
int x_hi = draw_end_x > SCREEN_W ? SCREEN_W : draw_end_x;
int y_lo = draw_start_y < 0 ? 0 : draw_start_y;
int y_hi = draw_end_y > SCREEN_H ? SCREEN_H : draw_end_y;
for (int pass = 0; pass < 2; pass++) {
for (int sc = x_lo; sc < x_hi; sc++) {
if (transform_y >= zbuffer[sc]) continue;
for (int sr = y_lo; sr < y_hi; sr++) {
if (!sprite_texel_on(sprite, sc, sr,
draw_start_x, draw_start_y,
sprite_w, sprite_h)) {
continue;
}
if (pass == 1) {
render_fb[(sr / 8) * SCREEN_W + sc] |=
(1U << (sr & 7));
continue;
}
for (int hx = sc - 1; hx <= sc + 1; hx++) {
if (hx < 0 || hx >= SCREEN_W ||
transform_y >= zbuffer[hx]) {
continue;
}
for (int hy = sr - 1; hy <= sr + 1; hy++) {
if (hy >= 0 && hy < SCREEN_H) {
clear_px(hx, hy);
}
}
}
}
}
}
}
static void render_sprites(void)
{
struct doom_player *p = &game.player;
fixed_t det = fp_mul(p->plane_x, p->dir_y) -
fp_mul(p->dir_x, p->plane_y);
if (det == 0) return;
fixed_t inv_det = fp_div(FP_ONE, det);
/* Portal renders behind enemies (drawn first, no depth-sort needed) */
if (game.portal_active) {
const uint16_t *pspr = (game.portal_anim & 8)
? spr_portal_b : spr_portal_a;
render_one_sprite(pspr,
fp_from_int(game.portal_map_x) + FP_HALF,
fp_from_int(game.portal_map_y) + FP_HALF,
inv_det, p, 100);
}
/* Collect visible enemies and sort farthest-first */
struct sprite_order {
int idx;
fixed_t dist;
} order[MAX_ENEMIES];
int visible = 0;
for (int i = 0; i < game.num_enemies; i++) {
struct doom_enemy *e = &game.enemies[i];
if (e->type == ENEMY_NONE || e->state == ESTATE_DEAD) continue;
fixed_t dx = e->x - p->x;
fixed_t dy = e->y - p->y;
order[visible].idx = i;
order[visible].dist = fp_mul(dx, dx) + fp_mul(dy, dy);
visible++;
}
for (int i = 1; i < visible; i++) {
struct sprite_order tmp = order[i];
int j = i - 1;
while (j >= 0 && order[j].dist < tmp.dist) {
order[j + 1] = order[j];
j--;
}
order[j + 1] = tmp;
}
for (int i = 0; i < visible; i++) {
struct doom_enemy *e = &game.enemies[order[i].idx];
render_one_sprite(get_sprite_16(e), e->x, e->y, inv_det, p,
e->type == ENEMY_BOSS ? 150 : 100);
}
/*
* Fireballs draw last, over the enemies. They fly toward the player so
* they are almost always the nearest sprite anyway, and the zbuffer
* still hides them behind walls — which is the part that matters for
* reading whether you are actually in cover.
*/
for (int i = 0; i < MAX_FIREBALLS; i++) {
struct doom_fireball *f = &game.fireballs[i];
if (!f->active) continue;
render_one_sprite(spr_fireball, f->x, f->y, inv_det, p, 100);
}
}
static void raycaster_render(void)
{
struct doom_player *p = &game.player;
for (int x = 0; x < SCREEN_W; x++) {
fixed_t camera_x = fp_div(fp_from_int(2 * x - SCREEN_W),
fp_from_int(SCREEN_W));
fixed_t ray_dir_x = p->dir_x + fp_mul(p->plane_x, camera_x);
fixed_t ray_dir_y = p->dir_y + fp_mul(p->plane_y, camera_x);
int map_x = fp_to_int(p->x);
int map_y = fp_to_int(p->y);
/* 1/|ray_dir|, capped: near an axis it exceeds int32 and the DDA
* below adds it up. 1024 tiles is far past any ray in a 16x16 map. */
fixed_t delta_dist_x = (fp_abs(ray_dir_x) < (FP_ONE >> 10))
? fp_from_int(1024)
: fp_abs(fp_div(FP_ONE, ray_dir_x));
fixed_t delta_dist_y = (fp_abs(ray_dir_y) < (FP_ONE >> 10))
? fp_from_int(1024)
: fp_abs(fp_div(FP_ONE, ray_dir_y));
int step_x, step_y;
fixed_t side_dist_x, side_dist_y;
if (ray_dir_x < 0) {
step_x = -1;
side_dist_x = fp_mul(p->x - fp_from_int(map_x),
delta_dist_x);
} else {
step_x = 1;
side_dist_x = fp_mul(fp_from_int(map_x + 1) - p->x,
delta_dist_x);
}
if (ray_dir_y < 0) {
step_y = -1;
side_dist_y = fp_mul(p->y - fp_from_int(map_y),
delta_dist_y);
} else {
step_y = 1;
side_dist_y = fp_mul(fp_from_int(map_y + 1) - p->y,
delta_dist_y);
}
bool hit = false;
bool side = false;
uint8_t wall_type = 0;
for (int i = 0; i < 64; i++) {
if (side_dist_x < side_dist_y) {
side_dist_x += delta_dist_x;
map_x += step_x;
side = false;
} else {
side_dist_y += delta_dist_y;
map_y += step_y;
side = true;
}
wall_type = doom_map_get(map_x, map_y);
if (wall_type > 0) {
hit = true;
break;
}
}
fixed_t perp_dist;
if (!side) {
perp_dist = side_dist_x - delta_dist_x;
} else {
perp_dist = side_dist_y - delta_dist_y;
}
if (perp_dist < (FP_ONE / 16)) {
perp_dist = FP_ONE / 16;
}
zbuffer[x] = perp_dist;
if (!hit) {
for (int y = 0; y < SCREEN_H / 2; y++) {
if (ceiling_pixel(x, y)) {
render_fb[(y / 8) * SCREEN_W + x] |=
(1U << (y & 7));
}
}
for (int y = SCREEN_H / 2; y < SCREEN_H; y++) {
if (floor_pixel(x, y)) {
render_fb[(y / 8) * SCREEN_W + x] |=
(1U << (y & 7));
}
}
continue;
}
int line_height = fp_to_int(fp_div(fp_from_int(SCREEN_H),
perp_dist));
/* A wall farther than SCREEN_H rounds to 0 lines, and the texture
* row below divides by this: a usage fault (divide by zero) that
* rebooted the node mid-game. */
if (line_height < 1) {
line_height = 1;
}
int draw_start = (SCREEN_H - line_height) / 2;
int draw_end = draw_start + line_height;
fixed_t wall_x;
if (!side) {
wall_x = p->y + fp_mul(perp_dist, ray_dir_y);
} else {
wall_x = p->x + fp_mul(perp_dist, ray_dir_x);
}
wall_x -= fp_from_int(fp_to_int(wall_x));
int tex_x = fp_to_int(fp_mul(wall_x, fp_from_int(8)));
if (tex_x < 0) tex_x = 0;
if (tex_x > 7) tex_x = 7;
if ((!side && ray_dir_x > 0) || (side && ray_dir_y < 0)) {
tex_x = 7 - tex_x;
}
int actual_start = draw_start < 0 ? 0 : draw_start;
int actual_end = draw_end >= SCREEN_H ? SCREEN_H - 1 : draw_end;
for (int y = actual_start; y <= actual_end; y++) {
int tex_y = ((y - draw_start) * 8) / line_height;
if (tex_y < 0) tex_y = 0;
if (tex_y > 7) tex_y = 7;
if (textured_pixel(x, y, tex_x, tex_y,
wall_type, side, perp_dist)) {
render_fb[(y / 8) * SCREEN_W + x] |=
(1U << (y & 7));
}
}
for (int y = 0; y < actual_start; y++) {
if (ceiling_pixel(x, y)) {
render_fb[(y / 8) * SCREEN_W + x] |=
(1U << (y & 7));
}
}
for (int y = actual_end + 1; y < SCREEN_H; y++) {
if (floor_pixel(x, y)) {
render_fb[(y / 8) * SCREEN_W + x] |=
(1U << (y & 7));
}
}
}
render_sprites();
}
/* ========== PIXEL HELPERS ========== */
static inline void set_px(int x, int y)
{
if (x >= 0 && x < SCREEN_W && y >= 0 && y < SCREEN_H) {
render_fb[(y / 8) * SCREEN_W + x] |= (1U << (y & 7));
}
}
/* ========== 4x5 DIGIT FONT ========== */
static const uint8_t digits[10][5] = {
{0xF, 0x9, 0x9, 0x9, 0xF},
{0x2, 0x6, 0x2, 0x2, 0x7},
{0xF, 0x1, 0xF, 0x8, 0xF},
{0xF, 0x1, 0xF, 0x1, 0xF},
{0x9, 0x9, 0xF, 0x1, 0x1},
{0xF, 0x8, 0xF, 0x1, 0xF},
{0xF, 0x8, 0xF, 0x9, 0xF},
{0xF, 0x1, 0x2, 0x4, 0x4},
{0xF, 0x9, 0xF, 0x9, 0xF},
{0xF, 0x9, 0xF, 0x1, 0xF},
};
static const uint8_t glyph_H[5] = {0x9, 0x9, 0xF, 0x9, 0x9};
static const uint8_t glyph_P[5] = {0xE, 0x9, 0xE, 0x8, 0x8};
static const uint8_t glyph_A[5] = {0x6, 0x9, 0xF, 0x9, 0x9};
static const uint8_t glyph_M[5] = {0x9, 0xF, 0xF, 0x9, 0x9};
static const uint8_t glyph_G[5] = {0x7, 0x8, 0xB, 0x9, 0x7};
static const uint8_t glyph_E[5] = {0xF, 0x8, 0xE, 0x8, 0xF};
static const uint8_t glyph_O[5] = {0x6, 0x9, 0x9, 0x9, 0x6};
static const uint8_t glyph_V[5] = {0x9, 0x9, 0x9, 0x6, 0x4};
static const uint8_t glyph_R[5] = {0xE, 0x9, 0xE, 0xC, 0x9};
static const uint8_t glyph_L[5] = {0x8, 0x8, 0x8, 0x8, 0xF};
static void draw_glyph(int dx, int dy, const uint8_t *glyph)
{
for (int row = 0; row < 5; row++) {
for (int col = 0; col < 4; col++) {
if (glyph[row] & (8 >> col)) {
set_px(dx + col, dy + row);
}
}
}
}
static void draw_digit(int dx, int dy, int d)
{
if (d < 0 || d > 9) return;
draw_glyph(dx, dy, digits[d]);
}
static void draw_number(int x, int y, int val)
{
if (val < 0) val = 0;
if (val > 999) val = 999;
if (val >= 100) {
draw_digit(x, y, val / 100);
x += 5;
}
if (val >= 10) {
draw_digit(x, y, (val / 10) % 10);
x += 5;
}
draw_digit(x, y, val % 10);
}
/* ========== HUD ========== */
static void draw_health_bar(int x, int y, int hp)
{
int bar_w = 20;
int filled = (hp * bar_w) / 100;
if (filled < 0) filled = 0;
if (filled > bar_w) filled = bar_w;
for (int bx = x - 1; bx <= x + bar_w; bx++) {
set_px(bx, y - 1);
set_px(bx, y + 3);
}
for (int by = y; by < y + 3; by++) {
set_px(x - 1, by);
set_px(x + bar_w, by);
}
for (int bx = x; bx < x + filled; bx++) {
for (int by = y; by < y + 3; by++) {
set_px(bx, by);
}
}
}
static void draw_hud(void)
{
memset(&render_fb[7 * SCREEN_W], 0, SCREEN_W);
/* Separator line at y=57 */
for (int x = 0; x < SCREEN_W; x++) {
render_fb[7 * SCREEN_W + x] |= 0x01;
}
/* HP bar */
draw_glyph(1, 58, glyph_H);
draw_glyph(6, 58, glyph_P);
draw_health_bar(12, 58, game.player.health);
/* Ammo */
draw_glyph(48, 58, glyph_A);
draw_glyph(53, 58, glyph_M);
draw_number(59, 58, game.player.ammo);
/* Level indicator: "LV1" / "LV2" */
draw_glyph(76, 58, glyph_L);
draw_glyph(81, 58, glyph_V);
draw_number(87, 58, game.level);
/* Score */
draw_number(100, 58, game.score);
}
/*
* L2 only: the boss's HP as a two-pixel bar across the top of the viewport.
* Drawn after the raycaster so it sits over the ceiling dither. It blinks once
* the boss is enraged, which is the only other signal that phase 2 started.
*/
static void draw_boss_bar(void)
{
if (game.level != 2 || game.num_enemies < 1) return;
const struct doom_enemy *e = &game.enemies[0];
if (e->type != ENEMY_BOSS || e->state >= ESTATE_DYING) return;
/* Clear rows 0-2: the blank third row separates bar from ceiling */
for (int x = 0; x < SCREEN_W; x++) {
render_fb[x] &= (uint8_t)~0x07;
}
if (boss_enraged(e) && (game.frame_count & 4)) return;
int hp = e->health < 0 ? 0 : e->health;
int w = (hp * (SCREEN_W - 4)) / BOSS_HP;
for (int x = 2; x < 2 + w; x++) {
render_fb[x] |= 0x03;
}
}
/* ========== GUN SPRITE ========== */
static const uint16_t gun_idle[12] = {
0x0180, 0x0180, 0x03C0, 0x03C0, 0x07E0, 0x0FF0,
0x1FF8, 0x1FF8, 0x3FFC, 0x398C, 0x7186, 0x6186,
};
static const uint16_t gun_fire[12] = {
0x0990, 0x1248, 0x07E0, 0x0FF0, 0x0FF0, 0x1FF8,
0x3FFC, 0x3FFC, 0x7FFE, 0x798E, 0xF187, 0xE187,
};
static void draw_gun(bool firing)
{
const uint16_t *sprite = firing ? gun_fire : gun_idle;
int cx = SCREEN_W / 2 - 8;
int by = SCREEN_H - 12 - 8;
if (firing) by -= 1;
for (int row = 0; row < 12; row++) {
for (int col = 0; col < 16; col++) {
if (sprite[row] & (0x8000 >> col)) {
set_px(cx + col, by + row);
}
}
}
int ch_x = SCREEN_W / 2;
int ch_y = SCREEN_H / 2 - 4;
set_px(ch_x, ch_y - 2);
set_px(ch_x, ch_y - 1);
set_px(ch_x, ch_y + 1);
set_px(ch_x, ch_y + 2);
set_px(ch_x - 2, ch_y);
set_px(ch_x - 1, ch_y);
set_px(ch_x + 1, ch_y);
set_px(ch_x + 2, ch_y);
}
/* ========== END SCREENS ========== */
static const uint8_t letter_D[8] = {
0xFC, 0xFE, 0x86, 0x86, 0x86, 0x86, 0xFE, 0xFC
};
static const uint8_t letter_E[8] = {
0xFE, 0xFE, 0x80, 0xF8, 0xF8, 0x80, 0xFE, 0xFE
};
static const uint8_t letter_A[8] = {
0x7C, 0xFE, 0x86, 0x86, 0xFE, 0xFE, 0x86, 0x86
};
static const uint8_t letter_W[8] = {
0x82, 0x82, 0x82, 0x92, 0xAA, 0xC6, 0x82, 0x00
};
static const uint8_t letter_I[8] = {
0xFE, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0xFE
};
static const uint8_t letter_N[8] = {
0x82, 0xC2, 0xA2, 0x92, 0x8A, 0x86, 0x82, 0x00
};
static void blit_letter(int lx, int ly, const uint8_t *data)
{
for (int row = 0; row < 8; row++) {
for (int col = 0; col < 8; col++) {
if (data[row] & (0x80 >> col)) {
int px = lx + col;
int py = ly + row;
if (px >= 0 && px < SCREEN_W &&
py >= 0 && py < SCREEN_H) {
render_fb[(py/8)*SCREEN_W + px] |=
(1U << (py & 7));
}
}
}
}
}
static void draw_game_over(void)
{
memset(render_fb, 0, FB_SIZE);
if (game.won) {
/* "WIN" + score */
int sx = (SCREEN_W - 29) / 2;
int sy = 18;
blit_letter(sx, sy, letter_W);
blit_letter(sx + 10, sy, letter_I);
blit_letter(sx + 20, sy, letter_N);
/* Sub-line: score */
draw_number((SCREEN_W - 15) / 2, sy + 14, game.score);
} else {
/* "DEAD" */
int sx = (SCREEN_W - 38) / 2;
int sy = 20;
blit_letter(sx, sy, letter_D);
blit_letter(sx + 10, sy, letter_E);
blit_letter(sx + 20, sy, letter_A);
blit_letter(sx + 30, sy, letter_D);
int go_x = (SCREEN_W - 40) / 2;
int go_y = sy + 14;
draw_glyph(go_x, go_y, glyph_G);
draw_glyph(go_x + 5, go_y, glyph_A);
draw_glyph(go_x + 10, go_y, glyph_M);
draw_glyph(go_x + 15, go_y, glyph_E);
draw_glyph(go_x + 22, go_y, glyph_O);
draw_glyph(go_x + 27, go_y, glyph_V);
draw_glyph(go_x + 32, go_y, glyph_E);
draw_glyph(go_x + 37, go_y, glyph_R);
}
}
/* ========== DISPLAY ========== */
static void doom_flush_fb(void)
{
const struct device *dev = mc_display_get_device();
if (!dev) return;
struct display_buffer_descriptor desc = {
.buf_size = FB_SIZE,
.width = SCREEN_W,
.height = SCREEN_H,
.pitch = SCREEN_W,
};
display_write(dev, 0, 0, &desc, render_fb);
}
/* ========== GAME LOGIC ========== */
static void game_init(void)
{
memset(&game, 0, sizeof(game));
game.player.x = fp_from_int(8) + FP_HALF;
game.player.y = fp_from_int(13) + FP_HALF;
game.player.health = 100;
game.player.ammo = 50;
game.player.dir_x = 0;
game.player.dir_y = -FP_ONE;
game.player.plane_x = (FP_ONE * 2) / 3;
game.player.plane_y = 0;
/* Level 1: exactly two monsters — one imp (NW), one demon (NE) */
game.enemies[0] = (struct doom_enemy){
.type = ENEMY_IMP,
.state = ESTATE_IDLE,
.x = fp_from_int(4) + FP_HALF,
.y = fp_from_int(4) + FP_HALF,
.health = 30,
};
/* x = 11, mirroring the imp: (12,4) is inside the NE wall block, where
* line of sight never reached the demon, so it could not be shot and the
* portal never opened. */
game.enemies[1] = (struct doom_enemy){
.type = ENEMY_DEMON,
.state = ESTATE_IDLE,
.x = fp_from_int(11) + FP_HALF,
.y = fp_from_int(4) + FP_HALF,
.health = 60,
};
game.num_enemies = 2;
game.level = 1;
game.score = 0;
game.game_over = false;
game.won = false;
game.portal_active = false;
fire_cooldown = 0;
atomic_set(&input_state, 0);
doom_map_init();
}
static bool can_move(fixed_t new_x, fixed_t new_y)
{
int checks[][2] = {
{fp_to_int(new_x + COLLISION_R), fp_to_int(new_y + COLLISION_R)},
{fp_to_int(new_x - COLLISION_R), fp_to_int(new_y + COLLISION_R)},
{fp_to_int(new_x + COLLISION_R), fp_to_int(new_y - COLLISION_R)},
{fp_to_int(new_x - COLLISION_R), fp_to_int(new_y - COLLISION_R)},
};
for (int i = 0; i < 4; i++) {
if (doom_map_get(checks[i][0], checks[i][1]) > 0) {
return false;
}
}
return true;
}
static void handle_movement(uint32_t input)
{
struct doom_player *p = &game.player;
if (input & DINPUT_FWD) {
fixed_t nx = p->x + fp_mul(p->dir_x, MOVE_SPEED);
fixed_t ny = p->y + fp_mul(p->dir_y, MOVE_SPEED);
if (can_move(nx, p->y)) p->x = nx;
if (can_move(p->x, ny)) p->y = ny;
}
if (input & DINPUT_BACK) {
fixed_t nx = p->x - fp_mul(p->dir_x, MOVE_SPEED);
fixed_t ny = p->y - fp_mul(p->dir_y, MOVE_SPEED);
if (can_move(nx, p->y)) p->x = nx;
if (can_move(p->x, ny)) p->y = ny;
}
if (input & DINPUT_LEFT) {
rotate_view(p, ROT_SPEED);
}
if (input & DINPUT_RIGHT) {
rotate_view(p, -ROT_SPEED);
}
}
static void handle_shooting(uint32_t input)
{
struct doom_player *p = &game.player;
if (!(input & DINPUT_FIRE) || p->ammo <= 0) {
p->firing = false;
return;
}
if (p->firing) return;
p->firing = true;
p->ammo--;
doom_play_sound(snd_shoot);
for (int i = 0; i < game.num_enemies; i++) {
struct doom_enemy *e = &game.enemies[i];
if (e->type == ENEMY_NONE || e->state >= ESTATE_DYING) continue;
fixed_t dx = e->x - p->x;
fixed_t dy = e->y - p->y;
if (fp_dist(dx, dy) > fp_from_int(10)) continue;
fixed_t dot = fp_mul(dx, p->dir_x) + fp_mul(dy, p->dir_y);
if (dot <= 0) continue;
fixed_t cross = fp_abs(fp_mul(dx, p->dir_y) -
fp_mul(dy, p->dir_x));
if (cross >= FP_HALF) continue;
/* Walls stop bullets — cover works both ways now */
if (!has_line_of_sight(p->x, p->y, e->x, e->y)) continue;
bool was_above_half = e->health > BOSS_HP / 2;
int dmg = SHOT_DAMAGE;
if (e->type == ENEMY_BOSS && !boss_jaw_open(e)) {
dmg /= BOSS_GUARD_DIV;
}
e->health -= dmg;
doom_play_sound(snd_hit);
if (e->health <= 0) {
e->state = ESTATE_DYING;
e->anim_tick = 0;
game.score += (e->type == ENEMY_BOSS) ? 1000 :
(e->type == ENEMY_DEMON) ? 200 : 100;
if (e->type == ENEMY_BOSS) {
/* Don't let a fireball already in the air rob
* a won run during the death animation */
memset(game.fireballs, 0,
sizeof(game.fireballs));
}
} else {
/*
* Only wake sleepers. Promoting unconditionally, as
* this did before, cancelled an in-progress swing —
* which under the new rules would also slam the jaw
* shut on the player's own damage window.
*/
if (e->state == ESTATE_IDLE) {
e->state = ESTATE_CHASE;
}
if (e->type == ENEMY_BOSS && was_above_half &&
e->health <= BOSS_HP / 2) {
doom_play_sound(snd_rage);
}
}
break;
}
}
/*
* Scan the current map for open floor tiles that have open neighbours on all
* four cardinal sides and are at least 4 tiles (squared) from the player.
* Pick pseudo-randomly using frame_count as a seed.
*/
static void find_portal_spawn(int *out_x, int *out_y)
{
int cand_x[32], cand_y[32];
int count = 0;
for (int y = 1; y < MAP_H - 1 && count < 32; y++) {
for (int x = 1; x < MAP_W - 1 && count < 32; x++) {
if (doom_map_get(x, y ) != 0) continue;
if (doom_map_get(x - 1, y ) != 0) continue;
if (doom_map_get(x + 1, y ) != 0) continue;
if (doom_map_get(x, y - 1) != 0) continue;
if (doom_map_get(x, y + 1) != 0) continue;
int pdx = x - fp_to_int(game.player.x);
int pdy = y - fp_to_int(game.player.y);
if (pdx * pdx + pdy * pdy < 16) continue;
cand_x[count] = x;
cand_y[count] = y;
count++;
}
}
if (count == 0) {
/* Fallback — should never trigger on the defined maps */
*out_x = 8;
*out_y = 2;
return;
}
int idx = (int)(game.frame_count % (uint32_t)count);
*out_x = cand_x[idx];
*out_y = cand_y[idx];
}
/* If all L1 enemies are dead, open a portal at a random floor tile. */
static void check_level_complete(void)
{
if (game.portal_active || game.level != 1) return;
for (int i = 0; i < game.num_enemies; i++) {
if (game.enemies[i].type != ENEMY_NONE &&
game.enemies[i].state != ESTATE_DEAD) {
return;
}
}
int px, py;
find_portal_spawn(&px, &py);
game.portal_active = true;
game.portal_map_x = px;
game.portal_map_y = py;
game.portal_anim = 0;
doom_play_sound(snd_portal);
}
/*
* Transition to level 2.
* Player keeps current health and ammo.
* Boss spawns in one of four far corners, chosen pseudo-randomly.
*/
static void advance_to_level2(void)
{
game.level = 2;
game.portal_active = false;
current_map = &level_2[0][0];
/* Player respawns south-centre, facing north */
game.player.x = fp_from_int(8) + FP_HALF;
game.player.y = fp_from_int(13) + FP_HALF;
game.player.dir_x = 0;
game.player.dir_y = -FP_ONE;
game.player.plane_x = (FP_ONE * 2) / 3;
game.player.plane_y = 0;
static const int8_t boss_spots[4][2] = {
{2, 2}, {13, 2}, {2, 7}, {13, 7}
};
int bi = (int)(game.frame_count % 4);
memset(game.enemies, 0, sizeof(game.enemies));
memset(game.fireballs, 0, sizeof(game.fireballs));
game.enemies[0] = (struct doom_enemy){
.type = ENEMY_BOSS,
.state = ESTATE_CHASE, /* immediately aggressive */
.x = fp_from_int(boss_spots[bi][0]) + FP_HALF,
.y = fp_from_int(boss_spots[bi][1]) + FP_HALF,
.health = BOSS_HP,
.cast_tick = BOSS_CAST_PERIOD,
};
game.num_enemies = 1;
}
/*
* Launch a fireball from the boss toward wherever the player stands at the
* moment of release. It does not lead the target, so a sidestep started when
* the jaw opens always clears it — standing still never does.
*/
static void boss_spit_fireball(const struct doom_enemy *e)
{
struct doom_player *p = &game.player;
for (int i = 0; i < MAX_FIREBALLS; i++) {
struct doom_fireball *f = &game.fireballs[i];
if (f->active) continue;
fixed_t dx = p->x - e->x;
fixed_t dy = p->y - e->y;
fixed_t d = fp_dist(dx, dy);
if (d < FP_ONE / 8) d = FP_ONE / 8;
f->vx = fp_mul(fp_div(dx, d), FIREBALL_SPEED);
f->vy = fp_mul(fp_div(dy, d), FIREBALL_SPEED);
f->x = e->x + f->vx;
f->y = e->y + f->vy;
f->life = FIREBALL_LIFE;
f->active = true;
doom_play_sound(snd_fireball);
return;
}
}
/*
* One frame of the boss's cast cycle, run while chasing. Returns true while
* the boss is winding up — jaw open, feet planted, fully vulnerable — which
* is the opening the fight is built around. It only arms at range and with
* line of sight, so a player who closes to melee or ducks behind the centre
* block fights the claws instead of the fire.
*/
static bool boss_run_cast_cycle(struct doom_enemy *e, fixed_t dist)
{
struct doom_player *p = &game.player;
int period = boss_enraged(e) ? BOSS_CAST_PERIOD_RAGE
: BOSS_CAST_PERIOD;
if (dist < BOSS_CAST_MIN_DIST ||
!has_line_of_sight(e->x, e->y, p->x, p->y)) {
/* Hold the timer above the tell so the jaw never opens dry */
if (e->cast_tick <= BOSS_WINDUP) {
e->cast_tick = BOSS_WINDUP + 1;
}
return false;
}
/* Enraging shortens the period; don't strand the timer above it */
if (e->cast_tick > period) e->cast_tick = period;
if (e->cast_tick > 0) e->cast_tick--;
if (e->cast_tick == 0) {
boss_spit_fireball(e);
e->cast_tick = period;
return false;
}
return e->cast_tick <= BOSS_WINDUP;
}
static void update_fireballs(void)
{
struct doom_player *p = &game.player;
for (int i = 0; i < MAX_FIREBALLS; i++) {
struct doom_fireball *f = &game.fireballs[i];
if (!f->active) continue;
f->x += f->vx;
f->y += f->vy;
if (--f->life <= 0 ||
doom_map_get(fp_to_int(f->x), fp_to_int(f->y)) != 0) {
f->active = false;
continue;
}
if (fp_dist(p->x - f->x, p->y - f->y) >= FIREBALL_HIT_R) {
continue;
}
f->active = false;
p->health -= FIREBALL_DMG;
doom_play_sound(snd_hit);
if (p->health <= 0) {
game.game_over = true;
doom_play_sound(snd_death);
}
}
}
static void update_enemies(void)
{
struct doom_player *p = &game.player;
for (int i = 0; i < game.num_enemies; i++) {
struct doom_enemy *e = &game.enemies[i];
if (e->type == ENEMY_NONE) continue;
fixed_t spd = (e->type != ENEMY_BOSS) ? ENEMY_SPEED :
boss_enraged(e) ? BOSS_SPEED_RAGE : BOSS_SPEED;
switch (e->state) {
case ESTATE_IDLE: {
if (fp_dist(e->x - p->x, e->y - p->y) <
fp_from_int(6)) {
e->state = ESTATE_CHASE;
}
break;
}
case ESTATE_CHASE: {
fixed_t dx = p->x - e->x;
fixed_t dy = p->y - e->y;
fixed_t dist = fp_dist(dx, dy);
bool back = false;
if (e->retreat_tick > 0) {
e->retreat_tick--;
back = true; /* disengaging after a swing */
} else if (dist < FP_ONE + FP_HALF) {
e->state = ESTATE_ATTACK;
e->anim_tick = 0;
break;
}
/* A winding-up boss plants its feet for the tell */
if (e->type == ENEMY_BOSS &&
boss_run_cast_cycle(e, dist)) {
break;
}
/* Retreating skips the range check above, so floor the
* divisor rather than trust dist to be 1.5+ tiles */
if (dist < FP_ONE / 4) dist = FP_ONE / 4;
fixed_t step = back ? -spd : spd;
fixed_t move_x = fp_mul(fp_div(dx, dist), step);
fixed_t move_y = fp_mul(fp_div(dy, dist), step);
fixed_t nx = e->x + move_x;
fixed_t ny = e->y + move_y;
if (doom_map_get(fp_to_int(nx), fp_to_int(ny)) == 0) {
e->x = nx;
e->y = ny;
}
break;
}
case ESTATE_ATTACK:
e->anim_tick++;
if (e->anim_tick >= 20) {
int dmg = (e->type == ENEMY_BOSS) ? 25 :
(e->type == ENEMY_DEMON) ? 15 : 8;
p->health -= dmg;
doom_play_sound(snd_hit);
if (p->health <= 0) {
game.game_over = true;
doom_play_sound(snd_death);
}
e->anim_tick = 0;
e->state = ESTATE_CHASE;
/* Boss disengages after every swing so its
* ranged tell comes back around */
if (e->type == ENEMY_BOSS) {
e->retreat_tick = BOSS_RETREAT;
}
}
break;
case ESTATE_DYING:
e->anim_tick++;
if (e->anim_tick >= 10) {
e->state = ESTATE_DEAD;
if (e->type == ENEMY_BOSS) {
game.game_over = true;
game.won = true;
doom_play_sound(snd_portal);
}
}
break;
case ESTATE_DEAD:
break;
}
}
}
/* ========== GAME TICK (work handler) ========== */
static void game_tick_handler(struct k_work *work)
{
ARG_UNUSED(work);
if (!running) return;
uint32_t input = (uint32_t)atomic_get(&input_state);
if (game.game_over) {
draw_game_over();
doom_flush_fb();
if (input & DINPUT_FIRE) {
game_init();
doom_sound_off();
}
k_work_reschedule(&game_tick_work, K_MSEC(100));
return;
}
handle_movement(input);
if (fire_cooldown > 0) {
fire_cooldown--;
} else if (input & DINPUT_FIRE) {
handle_shooting(input);
fire_cooldown = 4;
}
if (!(input & DINPUT_FIRE)) {
game.player.firing = false;
}
update_enemies();
update_fireballs();
/* L1: spawn portal once both monsters are dead */
check_level_complete();
/* Portal: flicker and check if player stepped into it */
if (game.portal_active) {
game.portal_anim++;
fixed_t pdx = fp_from_int(game.portal_map_x) + FP_HALF
- game.player.x;
fixed_t pdy = fp_from_int(game.portal_map_y) + FP_HALF
- game.player.y;
/* Trigger within ~0.7 tiles (squared distance < 0.5) */
if (fp_mul(pdx, pdx) + fp_mul(pdy, pdy) < (FP_ONE / 2)) {
advance_to_level2();
}
}
memset(render_fb, 0, FB_SIZE);
raycaster_render();
draw_boss_bar();
draw_gun(game.player.firing && fire_cooldown > 2);
draw_hud();
doom_flush_fb();
game.frame_count++;
k_work_reschedule(&game_tick_work, K_MSEC(FRAME_MS));
}
/* ========== PUBLIC API ========== */
void doom_game_start(void)
{
if (running) return;
LOG_INF("Doom easter egg starting!");
const struct device *dev = mc_display_get_device();
if (dev) {
display_blanking_off(dev);
}
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
buzzer_stop();
#endif
game_init();
running = true;
k_work_init_delayable(&game_tick_work, game_tick_handler);
k_work_init_delayable(&sound_work, doom_sound_work_handler);
k_work_reschedule(&game_tick_work, K_NO_WAIT);
}
void doom_game_stop(void)
{
if (!running) return;
LOG_INF("Doom easter egg stopping");
running = false;
k_work_cancel_delayable(&game_tick_work);
doom_sound_off();
const struct device *dev = mc_display_get_device();
if (dev) {
memset(render_fb, 0, FB_SIZE);
struct display_buffer_descriptor desc = {
.buf_size = FB_SIZE,
.width = SCREEN_W,
.height = SCREEN_H,
.pitch = SCREEN_W,
};
display_write(dev, 0, 0, &desc, render_fb);
}
}
bool doom_game_is_running(void)
{
return running;
}
void doom_game_input(uint16_t code, int32_t value)
{
uint32_t bit = 0;
switch (code) {
case INPUT_KEY_UP:
bit = DINPUT_FWD;
break;
case INPUT_KEY_DOWN:
bit = DINPUT_BACK;
break;
case INPUT_KEY_LEFT:
bit = DINPUT_RIGHT;
break;
case INPUT_KEY_RIGHT:
bit = DINPUT_LEFT;
break;
case INPUT_KEY_ENTER:
/* Not INPUT_KEY_0: on the Wio that is the user button, whose tap is
* the way out (KEY_1 via the multi-tap filter); firing on it too
* shot on every exit. */
bit = DINPUT_FIRE;
break;
default:
return;
}
if (value) {
atomic_or(&input_state, bit);
} else {
atomic_and(&input_state, ~bit);
}
}