diff --git a/AGENTS.md b/AGENTS.md index c004871..7927dc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,8 +96,8 @@ The game operates in one of two scenes ([`Scene`](include/game_state.h#L9-L12)): ### 3.3 Game Elements & Apples - **Snake Body:** Represented as `std::deque>` where each entry is `{x, y, direction}`. Alternating segments use regular and small sprite tiles. Head and tail sprites flip dynamically according to direction. - **Normal Apple (Red):** Spawns if count < 1. Awards +10 score, increases length by 1, plays pickup sound. Flashes/blinks every 4 ticks. -- **Rotten Apple (Brown/Green):** 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. -- **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). +- **Rotten Apple (Brown/Green):** 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. Despawns after `BAD_APPLE_LIFETIME_TICKS` (80 ticks); flashes during its last `EVIL_ITEM_BLINK_TICKS` (20 ticks, accelerating to rapid strobe during the final 8 ticks) to signal expiration. +- **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) with the same warning blink, clearing the way for new cursed apples to spawn if previously avoided. - **Reverse-Direction Denial:** Pressing the D-Pad in the exact opposite direction (i.e. into the snake's own neck) is silently denied instead of causing an immediate Game Over. The game computes the would-be next head position (including edge-wrapping) and compares it to the neck segment; if they match, the direction change is rejected and a subtle "denied" thud SFX plays. ### 3.4 Game Modes & Progressive Speed Mechanics @@ -226,6 +226,10 @@ The repository includes preconfigured tasks in [`.vscode/tasks.json`](.vscode/ta - Be mindful of OAM hardware limitations (Sub engine supports up to 128 sprites; current game allocates sprite slots per apple and snake segment). 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: + - **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. 3. **Hardware Registers & Memory Banking:** - Graphic tiles and palettes must be transferred using DMA (`dmaCopy`) or direct memory copies into allocated VRAM / OAM memory banks. - Do not modify VRAM allocation configurations without ensuring compatibility with console text generators and OAM sub-engines. diff --git a/README.md b/README.md index 5b1d2e2..ba2d227 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ - **Classic Gameplay with a Twist:** - Classic grid-based snake action wrapped on a 32×24 playfield with edge-wrapping. - **Normal Apples:** Boost length and score (+10), with flashing animation and pickup sounds. - - **Rotten Apples:** Shrink snake length by 1; shrinking below length 2 results in Game Over! - - **Cursed Apples:** Invert steering controls (Up ↔ Down, Left ↔ Right) for 40 ticks, requiring quick reflexes and adapted steering. + - **Rotten Apples:** Shrink snake length by 1; shrinking below length 2 results in Game Over! Despawn after 80 ticks with a flashing warning animation. + - **Cursed Apples:** Invert steering controls (Up ↔ Down, Left ↔ Right) for 40 ticks, requiring quick reflexes and adapted steering. Despawn after 90 ticks if avoided, allowing new curses to spawn later. - **Dual-Screen Layout:** - **Top Screen:** Real-time scoreboard, status alerts (*"CONTROLS INVERTED!"*), pause overlay, and optional debug stats. - **Bottom Screen:** Smooth 2D hardware sprite rendering using DS Sub-engine OAM. diff --git a/include/consts.h b/include/consts.h index a467f91..1c4baed 100644 --- a/include/consts.h +++ b/include/consts.h @@ -8,6 +8,12 @@ const int START_LENGTH_HARD = 2; const int APPLE_SCORE = 10; const int CURSE_DURATION_TICKS = 40; +// Evil item lifetimes (in game ticks, so density stays balanced across speed scaling) +const int BAD_APPLE_LIFETIME_TICKS = 80; // Rotten apple lasts 80 ticks (~8 spawn cycles) +const int CURSED_APPLE_LIFETIME_TICKS = 90; // Cursed apple lasts 90 ticks before despawning +const int EVIL_ITEM_BLINK_TICKS = 20; // Last 20 ticks: flashes to warn player of imminent despawn +const int EVIL_ITEM_FAST_BLINK_TICKS = 8; // Last 8 ticks: rapid strobe before disappearing + // Cursed apple palette indices in SPRITE_PALETTE_SUB const uint16_t CURSE_PAL_OUTLINE = 16; const uint16_t CURSE_PAL_FILL = 17; diff --git a/include/game_logic.h b/include/game_logic.h index 6db1933..a05b66c 100644 --- a/include/game_logic.h +++ b/include/game_logic.h @@ -12,6 +12,7 @@ void spawn_apples(GameState &state); void spawn_apples_rotten(GameState &state); void spawn_apples_cursed(GameState &state); void tick_status_effects(GameState &state); +void tick_evil_items(GameState &state); uint8_t calculate_intensity_31(const GameState &state); uint16_t calculate_timer_freq_mhz(const GameState &state); diff --git a/include/game_state.h b/include/game_state.h index 436396d..384216c 100644 --- a/include/game_state.h +++ b/include/game_state.h @@ -32,6 +32,13 @@ struct Barrier { uint16_t framesLeft = 0; // Frames remaining in the current phase }; +// Evil item (rotten apple or cursed apple) with position and lifetime in game ticks +struct EvilItem { + int8_t x = 0; + int8_t y = 0; + uint16_t ticksRemaining = 0; +}; + struct GameState { volatile uint16_t ticks = 0; volatile uint16_t snakelength = 0; @@ -55,8 +62,8 @@ struct GameState { std::deque> snake; std::vector> apples; - std::vector> bad_apples; - std::vector> cursed_apples; + std::vector bad_apples; + std::vector cursed_apples; }; #endif diff --git a/source/game_logic.cpp b/source/game_logic.cpp index 7e4d275..bbdcba2 100644 --- a/source/game_logic.cpp +++ b/source/game_logic.cpp @@ -3,8 +3,8 @@ #include #include -#include #include +#include void update_snake(GameState &state) { std::array headpos = state.snake.front(); @@ -79,8 +79,8 @@ bool check_collision_apples_rotten(GameState &state) { auto it = std::find_if(state.bad_apples.begin(), state.bad_apples.end(), - [&](const std::array &apple) { - return apple[0] == headpos[0] && apple[1] == headpos[1]; + [&](const EvilItem &apple) { + return apple.x == headpos[0] && apple.y == headpos[1]; }); if (it == state.bad_apples.end()) { @@ -100,8 +100,8 @@ bool check_collision_apples_cursed(GameState &state) { auto it = std::find_if(state.cursed_apples.begin(), state.cursed_apples.end(), - [&](const std::array &apple) { - return apple[0] == headpos[0] && apple[1] == headpos[1]; + [&](const EvilItem &apple) { + return apple.x == headpos[0] && apple.y == headpos[1]; }); if (it == state.cursed_apples.end()) { @@ -127,19 +127,64 @@ void spawn_apples(GameState &state) { } void spawn_apples_rotten(GameState &state) { - if ((state.ticks % 10 == 0) && state.hardMode && ((rand() % 10) < 10) && + if ((state.ticks % 10 == 0) && state.hardMode && (state.bad_apples.size() < 20)) { - std::array badApple = {rand() % (X_MAX + 1), rand() % (Y_MAX + 1)}; - state.bad_apples.push_back(badApple); + int x = rand() % (X_MAX + 1); + int y = rand() % (Y_MAX + 1); + for (int attempt = 0; attempt < 16 && (is_barrier_cell(state, x, y, true) || + (!state.snake.empty() && + state.snake.front()[0] == x && + state.snake.front()[1] == y)); + attempt++) { + x = rand() % (X_MAX + 1); + y = rand() % (Y_MAX + 1); + } + state.bad_apples.push_back({static_cast(x), static_cast(y), + BAD_APPLE_LIFETIME_TICKS}); } } void spawn_apples_cursed(GameState &state) { if (state.cursed_apples.empty() && (state.ticks % 20 == 0) && ((rand() % 5) == 0)) { - std::array cursedApple = {rand() % (X_MAX + 1), - rand() % (Y_MAX + 1)}; - state.cursed_apples.push_back(cursedApple); + int x = rand() % (X_MAX + 1); + int y = rand() % (Y_MAX + 1); + for (int attempt = 0; attempt < 16 && (is_barrier_cell(state, x, y, true) || + (!state.snake.empty() && + state.snake.front()[0] == x && + state.snake.front()[1] == y)); + attempt++) { + x = rand() % (X_MAX + 1); + y = rand() % (Y_MAX + 1); + } + state.cursed_apples.push_back({static_cast(x), + static_cast(y), + CURSED_APPLE_LIFETIME_TICKS}); + } +} + +void tick_evil_items(GameState &state) { + for (auto it = state.bad_apples.begin(); it != state.bad_apples.end();) { + if (it->ticksRemaining > 0) { + it->ticksRemaining--; + } + if (it->ticksRemaining == 0) { + it = state.bad_apples.erase(it); + } else { + ++it; + } + } + + for (auto it = state.cursed_apples.begin(); + it != state.cursed_apples.end();) { + if (it->ticksRemaining > 0) { + it->ticksRemaining--; + } + if (it->ticksRemaining == 0) { + it = state.cursed_apples.erase(it); + } else { + ++it; + } } } @@ -151,8 +196,8 @@ void tick_status_effects(GameState &state) { uint8_t calculate_intensity_31(const GameState &state) { const uint16_t current_apples = state.applesEaten; - const uint16_t apples = - std::min(current_apples, static_cast(SPEED_APPLES_CAP)); + const uint16_t apples = std::min( + current_apples, static_cast(SPEED_APPLES_CAP)); return static_cast((static_cast(apples) * 31U) / static_cast(SPEED_APPLES_CAP)); } @@ -163,12 +208,12 @@ uint16_t calculate_timer_freq_mhz(const GameState &state) { const uint16_t max_mhz = state.hardMode ? SPEED_MAX_HARD_MHZ : SPEED_MAX_NORMAL_MHZ; const uint16_t current_apples = state.applesEaten; - const uint16_t apples = - std::min(current_apples, static_cast(SPEED_APPLES_CAP)); + const uint16_t apples = std::min( + current_apples, static_cast(SPEED_APPLES_CAP)); const uint32_t diff = static_cast(max_mhz - base_mhz); - return static_cast( - base_mhz + ((static_cast(apples) * diff) / - static_cast(SPEED_APPLES_CAP))); + return static_cast(base_mhz + + ((static_cast(apples) * diff) / + static_cast(SPEED_APPLES_CAP))); } uint16_t calculate_timer_reload(const GameState &state) { @@ -208,11 +253,18 @@ bool cell_in_list(const std::vector> &list, int x, int y) { }); } +bool cell_in_list(const std::vector &list, int x, int y) { + return std::any_of(list.begin(), list.end(), [&](const EvilItem &item) { + return item.x == x && item.y == y; + }); +} + bool cell_blocked_for_barrier(const GameState &state, int x, int y) { if (is_barrier_cell(state, x, y, true)) { return true; } - if (cell_in_list(state.apples, x, y) || cell_in_list(state.bad_apples, x, y) || + if (cell_in_list(state.apples, x, y) || + cell_in_list(state.bad_apples, x, y) || cell_in_list(state.cursed_apples, x, y)) { return true; } @@ -311,7 +363,8 @@ void tick_barriers_frame(GameState &state) { } if (state.barrierSpawnCooldown > 0) { - state.barrierSpawnCooldown = static_cast(state.barrierSpawnCooldown - 1U); + state.barrierSpawnCooldown = + static_cast(state.barrierSpawnCooldown - 1U); } for (Barrier &barrier : state.barriers) { @@ -337,9 +390,9 @@ void spawn_barriers(GameState &state) { return; } - const int in_use = static_cast( - std::count_if(state.barriers.begin(), state.barriers.end(), - [](const Barrier &b) { return b.phase != BarrierPhase::Inactive; })); + const int in_use = static_cast(std::count_if( + state.barriers.begin(), state.barriers.end(), + [](const Barrier &b) { return b.phase != BarrierPhase::Inactive; })); const int max_allowed = max_concurrent_barriers(state); const int available = max_allowed - in_use; if (available <= 0) { @@ -359,9 +412,9 @@ void spawn_barriers(GameState &state) { const int length_range = current_max - BARRIER_MIN_LENGTH + 1; for (int spawn_idx = 0; spawn_idx < to_spawn; spawn_idx++) { - auto free_slot = - std::find_if(state.barriers.begin(), state.barriers.end(), - [](const Barrier &b) { return b.phase == BarrierPhase::Inactive; }); + auto free_slot = std::find_if( + state.barriers.begin(), state.barriers.end(), + [](const Barrier &b) { return b.phase == BarrierPhase::Inactive; }); if (free_slot == state.barriers.end()) { break; } @@ -370,11 +423,13 @@ void spawn_barriers(GameState &state) { for (int attempt = 0; attempt < 48; attempt++) { Barrier candidate; candidate.vertical = (rand() % 2) == 0; - candidate.length = static_cast( - BARRIER_MIN_LENGTH + (rand() % length_range)); + candidate.length = + static_cast(BARRIER_MIN_LENGTH + (rand() % length_range)); // Keep the whole line on screen (barriers never wrap around edges) - const int max_x = candidate.vertical ? X_MAX : (X_MAX + 1 - candidate.length); - const int max_y = candidate.vertical ? (Y_MAX + 1 - candidate.length) : Y_MAX; + const int max_x = + candidate.vertical ? X_MAX : (X_MAX + 1 - candidate.length); + const int max_y = + candidate.vertical ? (Y_MAX + 1 - candidate.length) : Y_MAX; candidate.x = static_cast(rand() % (max_x + 1)); candidate.y = static_cast(rand() % (max_y + 1)); @@ -401,9 +456,9 @@ void spawn_barriers(GameState &state) { } if (spawned_count > 0) { - state.barrierSpawnCooldown = static_cast( - calculate_barrier_interval_frames(state) + - (rand() % (BARRIER_INTERVAL_JITTER + 1))); + state.barrierSpawnCooldown = + static_cast(calculate_barrier_interval_frames(state) + + (rand() % (BARRIER_INTERVAL_JITTER + 1))); } else { state.barrierSpawnCooldown = BARRIER_RETRY_FRAMES; } diff --git a/source/game_render.cpp b/source/game_render.cpp index 18c5ba8..2d2c141 100644 --- a/source/game_render.cpp +++ b/source/game_render.cpp @@ -125,13 +125,18 @@ void draw_apples(const GameState &state, const RenderAssets &assets, void draw_apples_rotten(const GameState &state, const RenderAssets &assets, uint16_t &oamId) { - for (const std::array &apple : state.bad_apples) { + for (const EvilItem &apple : state.bad_apples) { if (oamId >= 128) { return; } - oamSet(&oamSub, oamId, apple[0] * 8, apple[1] * 8, 0, 0, SpriteSize_8x8, + const bool blink = (apple.ticksRemaining <= EVIL_ITEM_BLINK_TICKS); + const bool is_hidden = + blink && ((apple.ticksRemaining <= EVIL_ITEM_FAST_BLINK_TICKS) + ? ((state.ticks % 2) == 0) + : (((state.ticks / 2) % 2) == 0)); + oamSet(&oamSub, oamId, apple.x * 8, apple.y * 8, 0, 0, SpriteSize_8x8, SpriteColorFormat_256Color, assets.gfx_apple_rotten, -1, false, - false, false, false, false); + is_hidden, false, false, false); oamId++; } } @@ -139,13 +144,18 @@ void draw_apples_rotten(const GameState &state, const RenderAssets &assets, void draw_apples_cursed(const GameState &state, const RenderAssets &assets, uint16_t &oamId) { const bool hflip = ((state.ticks % 6) < 3); - for (const std::array &apple : state.cursed_apples) { + for (const EvilItem &apple : state.cursed_apples) { if (oamId >= 128) { return; } - oamSet(&oamSub, oamId, apple[0] * 8, apple[1] * 8, 0, 0, SpriteSize_8x8, + const bool blink = (apple.ticksRemaining <= EVIL_ITEM_BLINK_TICKS); + const bool is_hidden = + blink && ((apple.ticksRemaining <= EVIL_ITEM_FAST_BLINK_TICKS) + ? ((state.ticks % 2) == 0) + : (((state.ticks / 2) % 2) == 0)); + oamSet(&oamSub, oamId, apple.x * 8, apple.y * 8, 0, 0, SpriteSize_8x8, SpriteColorFormat_256Color, assets.gfx_apple_cursed, -1, false, - false, hflip, false, false); + is_hidden, hflip, false, false); oamId++; } } diff --git a/source/main.cpp b/source/main.cpp index 28edaec..c82815f 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -37,7 +37,7 @@ bool &hardMode = gameState.hardMode; std::deque> &snake = gameState.snake; std::vector> &apples = gameState.apples; -std::vector> &bad_apples = gameState.bad_apples; +std::vector &bad_apples = gameState.bad_apples; // 31x23 PrintConsole topScreen; @@ -49,6 +49,7 @@ AudioContext audioContext; RenderAssets renderAssets; BackgroundContext bgContext; +InputContext inputContext; const void * get_sprite_pointer(uint16_t spriteIndex) { return &tilemapTiles[spriteIndex*16]; @@ -172,6 +173,11 @@ void setup() { dest_barrier_warn[i] = src_barrier_warn[i]; } + // Pre-reserve capacities to eliminate dynamic heap allocations inside Timer 0 ISR (do_tick) + gameState.bad_apples.reserve(32); + gameState.cursed_apples.reserve(4); + gameState.apples.reserve(4); + audio_setup(audioContext); } @@ -229,6 +235,7 @@ void do_tick() { if (check_collision_apples_cursed(gameState)) { audio_play_boom(audioContext); } + tick_evil_items(gameState); spawn_apples(gameState); spawn_apples_rotten(gameState); spawn_apples_cursed(gameState); @@ -245,51 +252,41 @@ void do_tick() { } void init_game() { + timerStop(0); applesEaten = 0; reset_barriers(gameState); + gameState.bad_apples.reserve(32); + gameState.cursed_apples.reserve(4); + gameState.apples.reserve(4); + score = 0; + ticks = 0; + gameOver = false; + snakeDirection = 1; + gameState.curseTicksRemaining = 0; + apples.clear(); + bad_apples.clear(); + gameState.cursed_apples.clear(); + if (hardMode) { - timerStart(0, ClockDivider_1024, calculate_timer_reload(gameState), do_tick); setBackdropColorSub(RGB15(8, 3, 3)); setBackdropColor(RGB15(6, 2, 2)); - score = 0; snakelength = START_LENGTH_HARD; snake = { {2,1,1}, {1,1,1} }; - apples.clear(); - bad_apples.clear(); - snakeDirection = 1; - ticks = 0; - gameOver=false; - gameState.curseTicksRemaining = 0; - gameState.cursed_apples.clear(); - consoleSelect(&topScreen); - consoleClear(); - invalidate_hud(renderAssets); - consoleSelect(&bottomScreen); - consoleClear(); - oamClear(&oamSub,0,128); - audio_start_music(audioContext); - } - else - { - timerStart(0, ClockDivider_1024, calculate_timer_reload(gameState), do_tick); + } else { setBackdropColorSub(RGB15(3, 4, 7)); setBackdropColor(RGB15(2, 3, 6)); - score = 0; snakelength = START_LENGTH; snake = { {4,1,1}, {3,1,1}, {2,1,1}, {1,1,1} }; - snakeDirection = 1; - ticks = 0; - gameOver=false; - gameState.curseTicksRemaining = 0; - gameState.cursed_apples.clear(); - consoleSelect(&topScreen); - consoleClear(); - invalidate_hud(renderAssets); - consoleSelect(&bottomScreen); - consoleClear(); - oamClear(&oamSub,0,128); - audio_start_music(audioContext); } + + consoleSelect(&topScreen); + consoleClear(); + invalidate_hud(renderAssets); + consoleSelect(&bottomScreen); + consoleClear(); + oamClear(&oamSub, 0, 128); + audio_start_music(audioContext); + timerStart(0, ClockDivider_1024, calculate_timer_reload(gameState), do_tick); } int main(void) { @@ -302,7 +299,6 @@ int main(void) { setBackdropColorSub(RGB15(2, 2, 6)); draw_menu(gameState, renderAssets, audio_is_music_enabled(audioContext)); - InputContext inputContext; inputContext.state = &gameState; inputContext.renderAssets = &renderAssets; inputContext.audio = &audioContext; @@ -310,12 +306,10 @@ int main(void) { inputContext.topScreen = &topScreen; inputContext.init_game = init_game; - while(1) { if (!handle_input_frame(inputContext)) { break; } - } return 0;