fix: decouple VBlank ISR and main loop to prevent IRQ stack overflow

- Make handle_vblank strictly increment frame counter (frame++)
- Move heavy frame logic (background updates, OAM updates, palette animations, and console stats) to update_frame() on the main thread synchronized with swiWaitForVBlank()
- Eliminate 256-byte ARM9 IRQ stack (sp_irq) overflow caused by bg_update and iprintf that was corrupting DTCM stack and causing data aborts
- Preserve all playfield entities on game over with top-screen defeat summary and smooth background deceleration

Co-authored-by: Antigravity <antigravity@google.com>
This commit is contained in:
phillipandAntigravity committed 2026-10-04 00:21:09 +02:00
1 parent 91908d1f15
commit 496de1a31f
10 files changed
+159 -69

No files matched your search

+9 -7
View File
@@ -91,11 +91,11 @@ The game operates in one of two scenes ([`Scene`](include/game_state.h#L9-L12)):
- Navigated via D-Pad Up/Down; toggled/started via **A** or **START**.
2. **`Scene::Playing`**:
- Driven by hardware **Timer 0** executing [`do_tick()`](source/main.cpp#L127-L163).
- Interrupt handler [`handle_vblank()`](source/main.cpp#L117-L125) handles VBlank frame counting, OAM updates (`oamUpdate(&oamSub)`), and top-screen stat refreshes.
- Interrupt handler [`handle_vblank()`](source/main.cpp) handles VBlank frame counting (`frame++`), while the main thread synchronizes via `swiWaitForVBlank()` to execute `update_frame()` (OAM updates via `oamUpdate(&oamSub)`, palette animations, background scrolling, barrier ticking, and top-screen stat refreshes) and [`handle_input_frame()`](source/game_input.cpp) at 60 FPS without exceeding the 256-byte IRQ stack limit.
### 3.3 Game Elements & Apples
- **Snake Body:** Represented as `std::deque<std::array<int, 3>>` where each entry is `{x, y, direction}`. Alternating segments use regular and small sprite tiles. Head and tail sprites flip dynamically according to direction.
- **Immediate Head Rotation Feedback:** When the player presses a D-Pad direction while moving, `handle_input_frame()` validates that the input is not a reverse into the neck. If valid, `state.snakeDirection` is immediately updated and `draw_game()` is called on the main thread before the next movement tick. In `draw_snake_head()`, the head sprite tile pointer (`gfx_head_v` vs `gfx_head_h`) and hardware flip flags (`hflip`, `vflip`) are selected directly from `state.snakeDirection` and written to shadow OAM (`oamSub`). On the subsequent 60 FPS VBlank (~16.6 ms), `handle_vblank()` commits shadow OAM to hardware registers via `oamUpdate(&oamSub)`, providing instantaneous visual feedback that the snake has turned to face the new heading before the slower Timer 0 tick (5.0–17.0 Hz) advances grid coordinates via `update_snake()`.
- **Immediate Head Rotation Feedback:** When the player presses a D-Pad direction while moving, `handle_input_frame()` validates that the input is not a reverse into the neck. If valid, `state.snakeDirection` is immediately updated and `draw_game()` is called on the main thread before the next movement tick. In `draw_snake_head()`, the head sprite tile pointer (`gfx_head_v` vs `gfx_head_h`) and hardware flip flags (`hflip`, `vflip`) are selected directly from `state.snakeDirection` and written to shadow OAM (`oamSub`). On the subsequent 60 FPS VBlank (~16.6 ms), `update_frame()` commits shadow OAM to hardware registers via `oamUpdate(&oamSub)`, providing instantaneous visual feedback that the snake has turned to face the new heading before the slower Timer 0 tick (5.0–17.0 Hz) advances grid coordinates via `update_snake()`.
- **Normal Apple (Red):** Spawns if count < 1. Awards +10 score, increases length by 1, plays pickup sound. Flashes/blinks every 4 ticks.
- **Rotten Apple (Bruised Amber & Toxic Green Mold):** Spawns in Hard Mode (periodic chance up to 20 on screen). Decreases length by 1, plays explosion SFX. If length drops below 2, triggers Game Over. Rendered using a dedicated handcrafted 8x8 sprite with bruised golden-amber decaying skin, sunken dark necrotic rot cavities, a withered dead twig stem, and festering green mold colonies with radiant toxic spore pustules. Uses high-contrast dedicated palette entries (`ROTTEN_PAL_OUTLINE`, `ROTTEN_PAL_SKIN`, `ROTTEN_PAL_DECAY`, `ROTTEN_PAL_MOLD`, `ROTTEN_PAL_SPORE`, `ROTTEN_PAL_STEM`) specifically tuned for high visibility and contrast against the Hard Mode crimson background. Its mold and spore pustules organically breathe and fester with toxic bioluminescence every frame at 60 FPS in [`handle_vblank()`](source/main.cpp), and sprites alternate horizontal orientation based on grid coordinates (`hflip`) in [`draw_apples_rotten()`](source/game_render.cpp) for natural visual variety across the playfield. Despawns after `BAD_APPLE_LIFETIME_TICKS` (80 ticks).
- **Cursed Apple (Electric Neon Purple & Radioactive Green):** Spawns occasionally. Inverts player steering controls (Up ↔ Down, Left ↔ Right) for `CURSE_DURATION_TICKS` (40 ticks). Rendered using a dedicated 8x8 sprite with a glowing core rune and toxic green horn, using high-contrast dedicated palette entries (`CURSE_PAL_OUTLINE`, `CURSE_PAL_FILL`, `CURSE_PAL_GLOW`, `CURSE_PAL_CORE`, `CURSE_PAL_LEAF`). Its palette dynamically breathes and pulses with radioactive energy every frame at 60 FPS in [`handle_vblank()`](source/main.cpp#L134-L153), and its sprite sways/twitches with mystical instability in [`draw_apples_cursed()`](source/game_render.cpp#L139-L151). Despawns after `CURSED_APPLE_LIFETIME_TICKS` (90 ticks), clearing the way for new cursed apples to spawn if previously avoided.
@@ -129,8 +129,9 @@ The game operates in one of two scenes ([`Scene`](include/game_state.h#L9-L12)):
| **Paused** | B | Return to Main Menu |
| **Paused** | X | Toggle background music on/off |
| **Paused** | Y | Toggle Normal/Hard mode and restart |
| **Game Over** | START | Restart game |
| **Game Over** | A / START | Restart game |
| **Game Over** | B | Return to Main Menu |
| **Game Over** | R | Swap LCD displays (`lcdSwap()`) |
---
@@ -194,8 +195,8 @@ The repository includes preconfigured tasks in [`.vscode/tasks.json`](.vscode/ta
- [`gfx/tilemap.png`](gfx/tilemap.png) + [`gfx/tilemap.grit`](gfx/tilemap.grit) are processed by `grit` into `${CMAKE_CURRENT_BINARY_DIR}/tilemap.s` and `tilemap.h`.
- Sprites are referenced by index: tile 1 (segment), 2 (small segment), 3 (head horizontal), 4 (head vertical), 5/6 (apple flash frames), 7/8/9/10 (tail variations). Special entities (Rotten Apple, Cursed Apple, Barrier solid, and Barrier warning reticle) use dedicated handcrafted 8x8 256-color tile data copied directly into VRAM at startup. All sprites are preloaded once into VRAM; directional, orientation, and blinking variants are rendered via pointer selection and hardware flipping flags without mid-frame DMA transfers.
- Active OAM sprites are clamped at hardware limit (128) and trailing unused sprites are hidden every frame via `oamClear(&oamSub, oamId, 128 - oamId)`.
- **Decoupled Steering & Render Flow:** Steering inputs update `state.snakeDirection` and invoke `draw_game()` immediately on the main thread, updating the snake head's sprite pointer and flipping flags in shadow OAM (`oamSub`). Display updates are synchronized via VBlank (`handle_vblank()`), which flushes `oamSub` to hardware registers with `oamUpdate(&oamSub)` at 60 FPS (~16.6 ms) independently of the slower Timer 0 movement ticks (5.0–17.0 Hz).
- Console text HUD rendering in `draw_stats()` is throttled via dirty-flag caching (`HudCache`), eliminating redundant `iprintf` calls on VBlank.
- **Decoupled Steering & Render Flow:** Steering inputs update `state.snakeDirection` and invoke `draw_game()` immediately on the main thread, updating the snake head's sprite pointer and flipping flags in shadow OAM (`oamSub`). The main thread synchronizes via `swiWaitForVBlank()` and flushes `oamSub` to hardware registers with `oamUpdate(&oamSub)` at 60 FPS (~16.6 ms) inside `update_frame()`, providing instantaneous visual feedback that the snake has turned to face the new heading before the slower Timer 0 tick (5.0–17.0 Hz) advances grid coordinates via `update_snake()`.
- Console text HUD rendering in `draw_stats()` and `draw_game_over_screen()` is throttled via dirty-flag caching (`HudCache`), eliminating redundant `iprintf` calls on VBlank.
- **Audio Pipeline:**
- Audio files in [`sounds/`](sounds/) are compiled by `mmutil` into `soundbank.bin` and header `soundbank.h`.
- `bin2s` embeds `soundbank.bin` into assembly source `soundbank_bin.S` and header `soundbank_bin.h`.
@@ -205,14 +206,14 @@ The repository includes preconfigured tasks in [`.vscode/tasks.json`](.vscode/ta
- **Dual-Engine Tiled Layers:** Both Main and Sub 2D engines allocate Background Layer 2 (`BgType_Text4bpp`, `BgSize_T_256x256`, `mapBase = 28`, `tileBase = 1`).
- **Main Engine:** BG2 renders a dynamic cyber horizon, corner brackets, and starfield behind the top-screen console HUD and menu options (`bgSetPriority(topScreen.bgId, 0)`, `bgSetPriority(bgMainId, 3)`).
- **Sub Engine:** BG2 renders an animated retro cyber-grid with 8x8 cell alignment dots, ambient celestial nodes, and circuit pathways behind the snake and apple sprites (`bgSetPriority(bottomScreen.bgId, 1)`, `bgSetPriority(bgSubId, 3)`, sprites at priority 0).
- **Smooth 60 FPS Hardware Scrolling:** Both background layers use fixed-point 8.8 subpixel accumulators with `bgSetScroll()` and `bgUpdate()` during VBlank for silky smooth drift at 0% CPU cost. Drift speed accelerates dynamically as apples are consumed.
- **Smooth 60 FPS Hardware Scrolling:** Both background layers use fixed-point 8.8 subpixel accumulators with `bgSetScroll()` and `bgUpdate()` during `update_frame()` for silky smooth drift at 0% CPU cost. Drift speed accelerates dynamically as apples are consumed.
- **Dynamic VBlank Palette Animation & Progressive Intensity:**
- **Normal Mode:** Neon cyan / teal / midnight blue theme with oscillating sine-wave twinkling stars and breathing grid nodes, transitioning smoothly towards blazing electric plasma cyan at higher apple counts.
- **Hard Mode:** Fiery amber / crimson cyber grid with faster pulse rate, transitioning towards volcanic hyper-crimson and incandescent solar white-gold.
- **Cursed Mode:** Radioactive violet / magenta glitch strobe and high-speed warp drift during inverted controls (further intensified with apple count).
- **Apple Pickup Wave:** Eating an apple triggers a glowing shockwave pulse (`bg_trigger_pulse()`) that ripples through the grid with luminance scaled by current intensity.
- **Deep-Space Backdrop Glow:** Screen backdrops illuminate in sync with intensity levels.
- **Game Over:** Palette dims to a somber dark charcoal / muted red tone with gentle drift deceleration.
- **Game Over:** Playfield sprites (snake, obstacles, apples) remain fully rendered in their exact final state so the player can see where the collision happened. The background smoothly decelerates and dims into a somber dark charcoal / muted red tone over ~0.5s (`game_over_fade`), while the top screen console presents a dedicated Game Over summary detailing the final score, apples eaten, length, difficulty, and cause of defeat.
### 6.4 Static Analysis & Tooling
- **Clang-Tidy:** Configured in [`.clang-tidy`](.clang-tidy). Enforces modern C++ guidelines, bugprone checks, readability conventions, and snake_case / CamelCase identifiers.
@@ -229,6 +230,7 @@ The repository includes preconfigured tasks in [`.vscode/tasks.json`](.vscode/ta
2. **Interrupt & Concurrency Safety:**
- Variables accessed across both the main loop, timer ticks, and VBlank IRQ (such as `ticks`, `frame`, `snakelength`, `snakeDirection`, `internals`) must maintain appropriate `volatile` qualifiers or thread-safe synchronization.
- **IRQ Stack Limit (256 bytes):** In `calico/lib/ds9.ld`, the ARM9 IRQ stack (`sp_irq`) has a total budget of only 256 bytes (`__sp_irq = __sp_usr + 0x100`), growing downwards directly into the user stack area. Therefore:
- **Minimal VBlank ISR:** `handle_vblank()` must strictly do lightweight bookkeeping (`frame++`). All heavy lifting (`bg_update`, `draw_stats`, `iprintf`, `oamUpdate`, barrier frame ticking, dynamic palette math) must run on the main thread in `update_frame()` synchronized with `swiWaitForVBlank()`, utilizing the 16 KB DTCM user stack instead of overflowing the 256-byte IRQ stack.
- **Zero dynamic allocations in ISRs:** Never perform dynamic heap allocation (`malloc`, `operator new`, vector reallocation) inside hardware interrupt handlers (`do_tick`, `handle_vblank`). Dynamic vectors like `bad_apples`, `cursed_apples`, and `apples` must pre-reserve capacity upfront on the main thread in `setup()` / `init_game()`.
- **Context objects in `.bss`:** Large structs such as `InputContext` must be allocated in `.bss` (global or static) rather than on `main()`'s DTCM stack frame to avoid stack-smash corruption if an interrupt handler pushes registers.
- **Safe state re-initialization:** In `init_game()`, call `timerStop(0)` before resetting game state or clearing containers, and only call `timerStart(0, ...)` at the very end when all state members and HUD caches are cleanly initialized.
+2 -1
View File
@@ -42,8 +42,9 @@
| **Paused** | **B** | Return to Main Menu |
| **Paused** | **X** | Toggle background music ON/OFF |
| **Paused** | **Y** | Toggle Normal / Hard mode and restart |
| **Game Over** | **START** | Restart game |
| **Game Over** | **A** or **START** | Restart game |
| **Game Over** | **B** | Return to Main Menu |
| **Game Over** | **R Trigger** | Swap top and bottom screens (`lcdSwap`) |
---
+1
View File
@@ -23,6 +23,7 @@ struct BackgroundContext {
bool hard_mode = false;
bool is_cursed = false;
bool game_over = false;
uint8_t game_over_fade = 0;
Scene scene = Scene::Menu;
};
+1
View File
@@ -42,6 +42,7 @@ void invalidate_hud(RenderAssets &assets);
void draw_game(const GameState &state, const RenderAssets &assets);
void draw_stats(const GameState &state, RenderAssets &assets, bool internals, bool musicEnabled, uint8_t frame);
void draw_menu(const GameState &state, RenderAssets &assets, bool musicEnabled);
void draw_game_over_screen(const GameState &state);
void blink_apples(const GameState &state, u16 *gfx_apple);
// Called every VBlank: owns OAM slots [0, BARRIER_OAM_SLOTS) and animates barrier palette entries
void draw_barriers(const GameState &state, const RenderAssets &assets, uint8_t frame);
+8
View File
@@ -15,6 +15,13 @@ enum class Scene {
constexpr int MENU_ITEM_COUNT = 3;
enum class GameOverReason : uint8_t {
None = 0,
SelfCollision,
BarrierCollision,
RottenAppleDepleted,
};
enum class BarrierPhase : uint8_t {
Inactive, // Slot free
Warning, // Flashing announcement, harmless
@@ -47,6 +54,7 @@ struct GameState {
volatile uint16_t applesEaten = 0;
bool gameOver = false;
GameOverReason gameOverReason = GameOverReason::None;
bool paused = false;
bool hardMode = false;
+64 -38
View File
@@ -323,6 +323,14 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
const bool hard = state.hardMode;
const bool over = state.gameOver;
if (over) {
if (bg_ctx.game_over_fade < 31) {
bg_ctx.game_over_fade++;
}
} else {
bg_ctx.game_over_fade = 0;
}
// 1. Hardware Scrolling Update
if (state.scene == Scene::Menu) {
// Menu: Majestic downward drift for the cyber-horizon feel
@@ -337,11 +345,7 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
static_cast<uint16_t>(bg_ctx.scroll_sub_x_fp + 24U);
} else {
// In-Game Playfield:
if (over) {
// Gentle halt on game over
bg_ctx.scroll_sub_x_fp =
static_cast<uint16_t>(bg_ctx.scroll_sub_x_fp + 4U);
} else if (cursed) {
if (cursed && !over) {
// High-speed warp and glitch jitter when cursed!
const int16_t jitter =
(bg_ctx.anim_phase % 4U == 0U) ? 0x0180 : -0x0100;
@@ -353,7 +357,7 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
bg_ctx.scroll_sub_y_fp = static_cast<uint16_t>(
bg_ctx.scroll_sub_y_fp + 0x00E0U + (warp_intensity >> 1U));
} else {
// Smooth ambient drift scaling gradually with intensity
// Smooth ambient drift scaling gradually with intensity (decelerating gently if game over)
const uint16_t base_x = hard ? 24U : 16U;
const uint16_t base_y = hard ? 20U : 12U;
const uint16_t max_x = hard ? 76U : 56U;
@@ -362,17 +366,34 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
base_x + ((static_cast<uint32_t>(intensity_31) * (max_x - base_x)) / 31U));
const uint16_t speed_y = static_cast<uint16_t>(
base_y + ((static_cast<uint32_t>(intensity_31) * (max_y - base_y)) / 31U));
uint16_t sub_step_x = speed_x;
uint16_t sub_step_y = speed_y;
if (bg_ctx.game_over_fade > 0) {
const uint8_t fade = bg_ctx.game_over_fade;
sub_step_x = static_cast<uint16_t>(
speed_x + ((static_cast<int>(4U - speed_x) * fade) / 31));
sub_step_y = static_cast<uint16_t>(
speed_y - ((static_cast<int>(speed_y) * fade) / 31));
}
bg_ctx.scroll_sub_x_fp =
static_cast<uint16_t>(bg_ctx.scroll_sub_x_fp + speed_x);
static_cast<uint16_t>(bg_ctx.scroll_sub_x_fp + sub_step_x);
bg_ctx.scroll_sub_y_fp =
static_cast<uint16_t>(bg_ctx.scroll_sub_y_fp + speed_y);
static_cast<uint16_t>(bg_ctx.scroll_sub_y_fp + sub_step_y);
}
// Top screen HUD / horizon drift scaling with intensity
const uint16_t speed_main_y = static_cast<uint16_t>(
// Top screen HUD / horizon drift scaling with intensity (halting on game over)
uint16_t speed_main_y = static_cast<uint16_t>(
16U + ((static_cast<uint32_t>(intensity_31) * 24U) / 31U));
const uint16_t speed_main_x = static_cast<uint16_t>(
uint16_t speed_main_x = static_cast<uint16_t>(
8U + ((static_cast<uint32_t>(intensity_31) * 12U) / 31U));
if (bg_ctx.game_over_fade > 0) {
const uint8_t fade = bg_ctx.game_over_fade;
speed_main_y = static_cast<uint16_t>(
speed_main_y - ((static_cast<int>(speed_main_y) * fade) / 31));
speed_main_x = static_cast<uint16_t>(
speed_main_x - ((static_cast<int>(speed_main_x) * fade) / 31));
}
bg_ctx.scroll_main_y_fp =
static_cast<uint16_t>(bg_ctx.scroll_main_y_fp + speed_main_y);
bg_ctx.scroll_main_x_fp =
@@ -401,18 +422,7 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
uint16_t col_frame = 0;
uint16_t col_frame_accent = 0;
if (over) {
// Somber dark charcoal / muted red
col_faint = make_rgb15(3, 2, 2);
col_subtle = make_rgb15(6, 3, 3);
col_primary = make_rgb15(10, 4, 4);
col_star_dim = make_rgb15(5, 2, 2);
col_star_med = make_rgb15(8, 3, 3);
col_star_bright = make_rgb15(12, 4, 4);
col_star_flare = make_rgb15(16, 6, 6);
col_frame = make_rgb15(8, 3, 3);
col_frame_accent = make_rgb15(12, 4, 4);
} else if (cursed) {
if (cursed && !over) {
// Radioactive neon purple / violet storm
const bool strobe = (bg_ctx.anim_phase % 6U < 3U);
col_faint = make_rgb15(8, 1, 10);
@@ -488,27 +498,43 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
col_frame_accent = lerp_color(c_frame_accent_0, c_frame_accent_max, intensity_31);
}
// Deep space backdrop color dynamically illuminates with intensity
if (in_game && !over) {
if (hard) {
BG_PALETTE_SUB[0] = lerp_color(make_rgb15(3, 1, 1), make_rgb15(6, 2, 2), intensity_31);
BG_PALETTE[0] = lerp_color(make_rgb15(2, 1, 1), make_rgb15(5, 1, 2), intensity_31);
} else {
BG_PALETTE_SUB[0] = lerp_color(make_rgb15(3, 4, 7), make_rgb15(5, 7, 12), intensity_31);
BG_PALETTE[0] = lerp_color(make_rgb15(2, 3, 6), make_rgb15(3, 5, 10), intensity_31);
// Smoothly blend toward somber dark charcoal / muted red tone if game over
if (bg_ctx.game_over_fade > 0) {
const uint8_t fade = bg_ctx.game_over_fade;
col_faint = lerp_color(col_faint, make_rgb15(3, 2, 2), fade);
col_subtle = lerp_color(col_subtle, make_rgb15(6, 3, 3), fade);
col_primary = lerp_color(col_primary, make_rgb15(10, 4, 4), fade);
col_star_dim = lerp_color(col_star_dim, make_rgb15(5, 2, 2), fade);
col_star_med = lerp_color(col_star_med, make_rgb15(8, 3, 3), fade);
col_star_bright = lerp_color(col_star_bright, make_rgb15(12, 4, 4), fade);
col_star_flare = lerp_color(col_star_flare, make_rgb15(16, 6, 6), fade);
col_frame = lerp_color(col_frame, make_rgb15(8, 3, 3), fade);
col_frame_accent = lerp_color(col_frame_accent, make_rgb15(12, 4, 4), fade);
}
} else if (over) {
BG_PALETTE_SUB[0] = make_rgb15(3, 2, 2);
BG_PALETTE[0] = make_rgb15(2, 1, 1);
} else {
// Deep space backdrop color dynamically illuminates with intensity
if (in_game) {
uint16_t bg_sub_target = 0;
uint16_t bg_main_target = 0;
if (hard) {
BG_PALETTE_SUB[0] = make_rgb15(3, 1, 1);
BG_PALETTE[0] = make_rgb15(2, 1, 1);
bg_sub_target = lerp_color(make_rgb15(3, 1, 1), make_rgb15(6, 2, 2), intensity_31);
bg_main_target = lerp_color(make_rgb15(2, 1, 1), make_rgb15(5, 1, 2), intensity_31);
} else {
bg_sub_target = lerp_color(make_rgb15(3, 4, 7), make_rgb15(5, 7, 12), intensity_31);
bg_main_target = lerp_color(make_rgb15(2, 3, 6), make_rgb15(3, 5, 10), intensity_31);
}
if (bg_ctx.game_over_fade > 0) {
BG_PALETTE_SUB[0] = lerp_color(bg_sub_target, make_rgb15(3, 2, 2), bg_ctx.game_over_fade);
BG_PALETTE[0] = lerp_color(bg_main_target, make_rgb15(2, 1, 1), bg_ctx.game_over_fade);
} else {
BG_PALETTE_SUB[0] = bg_sub_target;
BG_PALETTE[0] = bg_main_target;
}
} else {
BG_PALETTE_SUB[0] = make_rgb15(2, 2, 6);
BG_PALETTE[0] = make_rgb15(2, 2, 6);
}
}
// Animate twinkling stars using sine oscillations
const uint16_t anim_dim = lerp_color(col_star_dim, col_star_med, sine_val);
+3
View File
@@ -65,6 +65,7 @@ bool handle_input_frame(InputContext &ctx) {
if ((ctx.state->paused || ctx.state->gameOver) && (keys != 0U) && ((keys & KEY_B) != 0U)) {
ctx.state->scene = Scene::Menu;
ctx.state->paused = false;
ctx.state->gameOver = false;
setBackdropColor(RGB15(2, 2, 6));
setBackdropColorSub(RGB15(2, 2, 6));
draw_menu(*ctx.state, *ctx.renderAssets, audio_is_music_enabled(*ctx.audio));
@@ -182,6 +183,8 @@ bool handle_input_frame(InputContext &ctx) {
} else {
ctx.init_game();
}
} else if (ctx.state->gameOver && (keys != 0U) && ((keys & KEY_A) != 0U)) {
ctx.init_game();
}
return true;
+3
View File
@@ -47,6 +47,7 @@ bool check_collision_self(GameState &state) {
for (auto it = state.snake.begin() + 1; it != state.snake.end(); ++it) {
if ((*it)[0] == headpos[0] && (*it)[1] == headpos[1]) {
state.gameOver = true;
state.gameOverReason = GameOverReason::SelfCollision;
return true;
}
}
@@ -91,6 +92,7 @@ bool check_collision_apples_rotten(GameState &state) {
state.bad_apples.erase(it);
if (state.snakelength < 2) {
state.gameOver = true;
state.gameOverReason = GameOverReason::RottenAppleDepleted;
}
return true;
}
@@ -471,6 +473,7 @@ bool check_collision_barriers(GameState &state) {
const auto &head = state.snake.front();
if (is_barrier_cell(state, head[0], head[1], false)) {
state.gameOver = true;
state.gameOverReason = GameOverReason::BarrierCollision;
return true;
}
return false;
+43 -16
View File
@@ -153,13 +153,47 @@ void draw_apples_cursed(const GameState &state, const RenderAssets &assets,
} // namespace
void draw_game_over_screen(const GameState &state) {
iprintf("\x1b[1;2H============================");
iprintf("\x1b[2;2H * GAME OVER * ");
iprintf("\x1b[3;2H============================");
iprintf("\x1b[5;4HFinal Score: %5i", state.score);
iprintf("\x1b[6;4HApples Eaten: %5i", state.applesEaten);
iprintf("\x1b[7;4HSnake Length: %5i", state.snakelength);
iprintf("\x1b[8;4HDifficulty: %s", state.hardMode ? "Hard " : "Normal");
iprintf("\x1b[10;4HCause of Defeat:");
const char *cause = "Collision";
switch (state.gameOverReason) {
case GameOverReason::SelfCollision:
cause = "Bite Collision (Hit Body)";
break;
case GameOverReason::BarrierCollision:
cause = "Energy Barrier Impact";
break;
case GameOverReason::RottenAppleDepleted:
cause = "Depleted by Rotten Apple";
break;
default:
cause = "Collision";
break;
}
iprintf("\x1b[11;4H %s", cause);
iprintf("\x1b[14;2H----------------------------");
iprintf("\x1b[16;4HA / START : Play Again");
iprintf("\x1b[17;4HB : Main Menu");
iprintf("\x1b[18;4HR : Swap Screens");
iprintf("\x1b[20;2H============================");
}
void invalidate_hud(RenderAssets &assets) { assets.hudCache.dirty = true; }
void draw_game(const GameState &state, const RenderAssets &assets) {
// OAM slots below BARRIER_OAM_SLOTS are owned by draw_barriers() (VBlank)
uint16_t oamId = BARRIER_OAM_SLOTS;
if (!state.gameOver) {
draw_apples(state, assets, oamId);
draw_apples_rotten(state, assets, oamId);
draw_apples_cursed(state, assets, oamId);
@@ -170,21 +204,11 @@ void draw_game(const GameState &state, const RenderAssets &assets) {
if (oamId < 128) {
oamClear(&oamSub, oamId, 128 - oamId);
}
return;
}
// On Game Over, hide all sprites so none are left dangling
oamClear(&oamSub, 0, 128);
iprintf("\x1b[11;12HGAME OVER!");
consoleSelect(assets.topScreen);
iprintf("\x1b[10;10H Score: %i ", state.score);
iprintf("\x1b[11;12HGAME OVER!");
}
void draw_barriers(const GameState &state, const RenderAssets &assets,
uint8_t frame) {
if (state.scene != Scene::Playing || state.gameOver || !state.hardMode) {
if (state.scene != Scene::Playing || !state.hardMode) {
oamClear(&oamSub, 0, BARRIER_OAM_SLOTS);
return;
}
@@ -257,6 +281,12 @@ void draw_barriers(const GameState &state, const RenderAssets &assets,
void draw_stats(const GameState &state, RenderAssets &assets, bool internals,
bool musicEnabled, uint8_t frame) {
if (state.gameOver) {
return;
}
HudCache &cache = assets.hudCache;
if (internals) {
iprintf("\x1b[0;0HFrame = %i\nTick = %i\ndir = %i", frame, state.ticks,
state.snakeDirection);
@@ -279,7 +309,6 @@ void draw_stats(const GameState &state, RenderAssets &assets, bool internals,
}
}
HudCache &cache = assets.hudCache;
const bool cursed = (state.curseTicksRemaining > 0);
const bool needs_score_update = cache.dirty || (cache.score != state.score);
@@ -305,9 +334,7 @@ void draw_stats(const GameState &state, RenderAssets &assets, bool internals,
if (needs_pause_update) {
if (!state.paused) {
if (state.gameOver) {
iprintf("\x1b[23;0HB = Main Menu");
} else if (cache.paused) {
if (cache.paused) {
// Clear any leftover pause strings if previously paused
iprintf("\x1b[21;0H ");
iprintf("\x1b[22;0H ");
+25 -7
View File
@@ -205,19 +205,33 @@ void setup() {
}
void handle_vblank() {
frame++;
}
void update_frame() {
bg_update(bgContext, gameState, frame);
if (gameState.scene == Scene::Playing) {
consoleSelect(&topScreen);
if (gameState.gameOver) {
HudCache &cache = renderAssets.hudCache;
if (cache.dirty || (cache.gameOver != gameState.gameOver)) {
consoleClear();
draw_game_over_screen(gameState);
cache.gameOver = gameState.gameOver;
cache.dirty = false;
}
} else {
draw_stats(gameState, renderAssets, internals,
audio_is_music_enabled(audioContext), frame);
if (!gameState.paused) {
tick_barriers_frame(gameState);
}
}
draw_barriers(gameState, renderAssets, frame);
}
// Dynamic pulsing radioactive glow for Cursed Apples (smooth 60fps breathing)
const uint8_t pulse = (frame >> 2U) & 7U; // 8-step cycle
const auto pulse = static_cast<uint8_t>(
(static_cast<uint32_t>(frame) >> 2U) & 7U); // 8-step cycle
const uint8_t wave =
(pulse < 4U) ? pulse
: static_cast<uint8_t>(7U - pulse); // 0, 1, 2, 3, 3, 2, 1, 0
@@ -233,8 +247,9 @@ void handle_vblank() {
// Dynamic festering mold & spore respiration for Rotten Apples (smooth 60fps
// organic pulse)
const uint8_t rot_pulse = ((frame + 3U) >> 2U) &
7U; // 8-step cycle, phase-shifted from cursed apple
const auto rot_pulse = static_cast<uint8_t>(
(static_cast<uint32_t>(frame + 3U) >> 2U) &
7U); // 8-step cycle, phase-shifted from cursed apple
const uint8_t rot_wave =
(rot_pulse < 4U)
? rot_pulse
@@ -292,6 +307,7 @@ void do_tick() {
if (!was_game_over && gameOver) {
audio_stop_music();
audio_play_fail(audioContext);
invalidate_hud(renderAssets);
}
// consoleSelect(&bottomScreen);
draw_game(gameState, renderAssets);
@@ -309,6 +325,7 @@ void init_game() {
score = 0;
ticks = 0;
gameOver = false;
gameState.gameOverReason = GameOverReason::None;
snakeDirection = 1;
gameState.curseTicksRemaining = 0;
apples.clear();
@@ -338,10 +355,6 @@ void init_game() {
}
int main(void) {
// Set up VBlank handler
irqSet(IRQ_VBLANK, handle_vblank);
setup();
setBackdropColor(RGB15(2, 2, 6));
setBackdropColorSub(RGB15(2, 2, 6));
@@ -354,7 +367,12 @@ int main(void) {
inputContext.topScreen = &topScreen;
inputContext.init_game = init_game;
// Set up VBlank handler only after all subsystems, assets, and contexts are initialized
irqSet(IRQ_VBLANK, handle_vblank);
while (1) {
swiWaitForVBlank();
update_frame();
if (!handle_input_frame(inputContext)) {
break;
}