From 9a3a0bd9fd521ac7841415a42722ecfc7b8d4cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phillip=20K=C3=BChne?= Date: Sat, 3 Oct 2026 21:32:02 +0200 Subject: [PATCH] feat: add temporary hazardous barriers in hard mode --- AGENTS.md | 5 +- include/consts.h | 36 +++++- include/game_logic.h | 10 ++ include/game_render.h | 4 + include/game_state.h | 24 ++++ source/game_logic.cpp | 250 ++++++++++++++++++++++++++++++++++++++++- source/game_render.cpp | 91 ++++++++++++++- source/main.cpp | 63 ++++++++++- 8 files changed, 466 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a17784..c004871 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,8 +102,9 @@ The game operates in one of two scenes ([`Scene`](include/game_state.h#L9-L12)): ### 3.4 Game Modes & Progressive Speed Mechanics - **Normal Mode:** Starts with length 4, base timer frequency 5.0 Hz (`SPEED_BASE_NORMAL_MHZ = 5000`), scaling up to 13.0 Hz (`SPEED_MAX_NORMAL_MHZ = 13000`). -- **Hard Mode:** Starts with length 2, base timer frequency 9.0 Hz (`SPEED_BASE_HARD_MHZ = 9000`), scaling up to 17.0 Hz (`SPEED_MAX_HARD_MHZ = 17000`), and spawns rotten apples. -- **Progressive Speed Scaling:** Each consumed regular apple increases `state.applesEaten`. Game speed scales linearly with consumed apples up to `SPEED_APPLES_CAP = 40`, granting smooth +200 mHz acceleration per apple. Timer 0 reload registers are recomputed on the fly (`calculate_timer_reload()`). +- **Hard Mode:** Starts with length 2, base timer frequency 5.0 Hz (`SPEED_BASE_HARD_MHZ = 5000`, matching Normal Mode's starting speed), scaling up to 17.0 Hz (`SPEED_MAX_HARD_MHZ = 17000`), spawns rotten apples, and spawns temporary fatal barriers. +- **Temporary Fatal Barriers (Hard Mode):** Straight lines of blocks appear dynamically across the playfield without overlapping the snake or apples. Barriers only begin spawning after a few apples have been eaten (`BARRIER_START_APPLES = 3`). Their maximum possible length begins at 4 blocks (`BARRIER_BASE_MAX_LENGTH`) and gradually scales up to 10 blocks (`BARRIER_MAX_LENGTH`) later on in the game. At later stages of the game, multiple barriers can spawn simultaneously in a single spawn event (up to 2 at once starting at 16 apples, and up to 3 at once starting at 30 apples), synchronizing their warning and activation phases. Before becoming lethal, each barrier announces itself with a flashing warning reticle frame (`BARRIER_WARN_FRAMES = 120` frames / 2.0 s) whose pulse frequency accelerates and warning color intensifies from dim orange to incandescent yellow-white as solidification approaches. Once active, barriers turn into solid hazard-striped energy walls (`BARRIER_ACTIVE_FRAMES = 240` frames / 4.0 s) fatal to the snake's head on contact. As apples are eaten, spawn intervals accelerate from 9.0 s down to 2.0 s (`BARRIER_INTERVAL_MAX_FRAMES` to `BARRIER_INTERVAL_MIN_FRAMES`) and max concurrent barriers scale up from 1 to 4 (`BARRIER_MAX_COUNT`). +- **Progressive Speed Scaling:** Each consumed regular apple increases `state.applesEaten`. Game speed scales linearly with consumed apples up to `SPEED_APPLES_CAP = 40`, granting smooth +200 mHz acceleration per apple (or +300 mHz in Hard Mode). Timer 0 reload registers are recomputed on the fly (`calculate_timer_reload()`). - **Progressive Background Intensity Scaling:** Visual effects intensify at the exact same rate (`calculate_intensity_31()`): - Hardware drift scroll speeds accelerate on both top and bottom screens. - Celestial node oscillation & twinkling breathing frequencies increase via fixed-point phase accumulation (`anim_phase_fp`). diff --git a/include/consts.h b/include/consts.h index 34c02b2..a467f91 100644 --- a/include/consts.h +++ b/include/consts.h @@ -1,6 +1,7 @@ #ifndef CONSTS_H #define CONSTS_H -#endif + +#include const int START_LENGTH = 4; const int START_LENGTH_HARD = 2; @@ -14,9 +15,36 @@ const uint16_t CURSE_PAL_GLOW = 18; const uint16_t CURSE_PAL_CORE = 19; const uint16_t CURSE_PAL_LEAF = 20; +// Barrier palette indices in SPRITE_PALETTE_SUB (animated every VBlank) +const uint16_t BARRIER_PAL_EDGE = 21; +const uint16_t BARRIER_PAL_FILL = 22; +const uint16_t BARRIER_PAL_STRIPE = 23; +const uint16_t BARRIER_PAL_WARN = 24; + +// Hard-mode temporary fatal barriers (all timings in 60 Hz VBlank frames, +// so they stay constant in wall-clock time while the tick rate accelerates) +const int BARRIER_START_APPLES = 3; // Barriers only start appearing after eating a few apples +const int BARRIER_MULTI_SPAWN_APPLES_2 = 16; // Apples required to spawn up to 2 barriers at once +const int BARRIER_MULTI_SPAWN_APPLES_3 = 30; // Apples required to spawn up to 3 barriers at once +const int BARRIER_MAX_COUNT = 4; // Max simultaneous barriers (reached at SPEED_APPLES_CAP) +const int BARRIER_MIN_LENGTH = 2; +const int BARRIER_BASE_MAX_LENGTH = 4; // Max length when barriers first appear +const int BARRIER_MAX_LENGTH = 10; // Max length later in the game (reached at SPEED_APPLES_CAP) +const int BARRIER_OAM_SLOTS = BARRIER_MAX_COUNT * BARRIER_MAX_LENGTH; // Reserved OAM ids 0..39 +const int BARRIER_WARN_FRAMES = 120; // 2.0 s flashing announcement (harmless) +const int BARRIER_SOLIDIFY_FRAMES = 20; // Last part of warning: flickers to solid look +const int BARRIER_ACTIVE_FRAMES = 240; // 4.0 s fatal phase +const int BARRIER_FADE_FRAMES = 45; // Last part of active phase: slow blink (still fatal) +const int BARRIER_FIRST_SPAWN_FRAMES = 180; // 3.0 s grace period after reaching BARRIER_START_APPLES +const int BARRIER_INTERVAL_MAX_FRAMES = 540; // 9.0 s between spawns at BARRIER_START_APPLES +const int BARRIER_INTERVAL_MIN_FRAMES = 120; // 2.0 s between spawns at SPEED_APPLES_CAP +const int BARRIER_INTERVAL_JITTER = 60; // Random 0..1 s extra delay +const int BARRIER_RETRY_FRAMES = 30; // Retry delay if no valid spot was found +const int BARRIER_HEAD_SAFE_DISTANCE = 5; // Min wrapped Manhattan distance from the snake head + // Speed scaling configuration (gradual acceleration based on consumed apples) const int SPEED_BASE_NORMAL_MHZ = 5000; // 5.0 Hz (~200 ms per step) -const int SPEED_BASE_HARD_MHZ = 9000; // 9.0 Hz (~111 ms per step) +const int SPEED_BASE_HARD_MHZ = 5000; // 5.0 Hz: hard mode starts as slow as normal mode const int SPEED_MAX_NORMAL_MHZ = 13000; // 13.0 Hz (~77 ms per step) const int SPEED_MAX_HARD_MHZ = 17000; // 17.0 Hz (~59 ms per step) const int SPEED_APPLES_CAP = 40; // Apples consumed to reach max intensity & speed @@ -69,4 +97,6 @@ const char BITMAP_SEGMENT[] = { 5,6,6,6,6,6,6,5, 5,6,6,6,6,6,6,5, 0,5,5,5,5,5,5,0 - }; \ No newline at end of file + }; + +#endif // CONSTS_H \ No newline at end of file diff --git a/include/game_logic.h b/include/game_logic.h index 5574589..6db1933 100644 --- a/include/game_logic.h +++ b/include/game_logic.h @@ -17,4 +17,14 @@ uint8_t calculate_intensity_31(const GameState &state); uint16_t calculate_timer_freq_mhz(const GameState &state); uint16_t calculate_timer_reload(const GameState &state); +// Hard-mode temporary barriers +void reset_barriers(GameState &state); // Main thread (init_game) +void tick_barriers_frame(GameState &state); // VBlank IRQ: advances lifetimes +void spawn_barriers(GameState &state); // Timer IRQ: spawns when cooldown expired +bool check_collision_barriers(GameState &state); // Timer IRQ: head vs. active barrier +bool is_barrier_cell(const GameState &state, int x, int y, bool include_warning); +uint16_t calculate_barrier_interval_frames(const GameState &state); +uint8_t calculate_barrier_max_length(const GameState &state); +uint8_t calculate_barrier_max_batch(const GameState &state); + #endif diff --git a/include/game_render.h b/include/game_render.h index 9abb0ca..412a8dc 100644 --- a/include/game_render.h +++ b/include/game_render.h @@ -32,6 +32,8 @@ struct RenderAssets { u16 *gfx_tail_v[2] = {nullptr, nullptr}; u16 *gfx_apple_rotten = nullptr; u16 *gfx_apple_cursed = nullptr; + u16 *gfx_barrier = nullptr; // Solid, fatal barrier segment + u16 *gfx_barrier_warn = nullptr; // Dashed outline shown while announcing HudCache hudCache; }; @@ -41,5 +43,7 @@ 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 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); #endif diff --git a/include/game_state.h b/include/game_state.h index 89bb8c9..436396d 100644 --- a/include/game_state.h +++ b/include/game_state.h @@ -6,6 +6,8 @@ #include #include +#include "consts.h" + enum class Scene { Menu, Playing, @@ -13,6 +15,23 @@ enum class Scene { constexpr int MENU_ITEM_COUNT = 3; +enum class BarrierPhase : uint8_t { + Inactive, // Slot free + Warning, // Flashing announcement, harmless + Active, // Solid and fatal to the snake head +}; + +// Temporary hard-mode wall: a straight line of `length` cells starting at (x, y). +// Lifetime is tracked in VBlank frames (see tick_barriers_frame()). +struct Barrier { + BarrierPhase phase = BarrierPhase::Inactive; + int8_t x = 0; + int8_t y = 0; + bool vertical = false; + uint8_t length = 0; + uint16_t framesLeft = 0; // Frames remaining in the current phase +}; + struct GameState { volatile uint16_t ticks = 0; volatile uint16_t snakelength = 0; @@ -29,6 +48,11 @@ struct GameState { uint16_t curseTicksRemaining = 0; + // Fixed-size (no heap) because barriers are touched from both the VBlank + // and Timer 0 IRQs. Those IRQs don't nest, so no further locking is needed. + std::array barriers{}; + volatile uint16_t barrierSpawnCooldown = 0; // Frames until the next spawn attempt + std::deque> snake; std::vector> apples; std::vector> bad_apples; diff --git a/source/game_logic.cpp b/source/game_logic.cpp index e8567e1..7e4d275 100644 --- a/source/game_logic.cpp +++ b/source/game_logic.cpp @@ -114,8 +114,14 @@ bool check_collision_apples_cursed(GameState &state) { } void spawn_apples(GameState &state) { - if (state.apples.size() < 1) { + if (state.apples.empty()) { std::array apple = {rand() % (X_MAX + 1), rand() % (Y_MAX + 1)}; + // Don't place the apple inside a (pending) barrier, it would be unreachable + for (int attempt = 0; + attempt < 16 && is_barrier_cell(state, apple[0], apple[1], true); + attempt++) { + apple = {rand() % (X_MAX + 1), rand() % (Y_MAX + 1)}; + } state.apples.push_back(apple); } } @@ -172,3 +178,245 @@ uint16_t calculate_timer_reload(const GameState &state) { const uint32_t period_ticks = timer_clock_mhz / freq_mhz; return static_cast(-static_cast(period_ticks)); } + +// --------------------------------------------------------------------------- +// Hard-mode temporary barriers +// --------------------------------------------------------------------------- + +namespace { + +bool barrier_contains(const Barrier &barrier, int x, int y) { + if (barrier.vertical) { + return x == barrier.x && y >= barrier.y && y < barrier.y + barrier.length; + } + return y == barrier.y && x >= barrier.x && x < barrier.x + barrier.length; +} + +// Manhattan distance on the wrapping playfield +int wrapped_distance(int x1, int y1, int x2, int y2) { + int dx = std::abs(x1 - x2); + int dy = std::abs(y1 - y2); + dx = std::min(dx, (X_MAX + 1) - dx); + dy = std::min(dy, (Y_MAX + 1) - dy); + return dx + dy; +} + +bool cell_in_list(const std::vector> &list, int x, int y) { + return std::any_of(list.begin(), list.end(), + [&](const std::array &item) { + return item[0] == x && item[1] == 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) || + cell_in_list(state.cursed_apples, x, y)) { + return true; + } + if (!state.snake.empty()) { + const auto &head = state.snake.front(); + if (wrapped_distance(head[0], head[1], x, y) < BARRIER_HEAD_SAFE_DISTANCE) { + return true; + } + } + return std::any_of(state.snake.begin(), state.snake.end(), + [&](const std::array &seg) { + return seg[0] == x && seg[1] == y; + }); +} + +int max_concurrent_barriers(const GameState &state) { + if (state.applesEaten < BARRIER_START_APPLES) { + return 0; + } + const int apples = std::min(state.applesEaten, SPEED_APPLES_CAP); + const int apple_span = SPEED_APPLES_CAP - BARRIER_START_APPLES; + if (apple_span <= 0) { + return BARRIER_MAX_COUNT; + } + const int progress = apples - BARRIER_START_APPLES; + return 1 + ((progress * (BARRIER_MAX_COUNT - 1)) / apple_span); +} + +} // namespace + +void reset_barriers(GameState &state) { + // Called from the main thread; keep the VBlank/Timer IRQs out while resetting + const int old_ime = enterCriticalSection(); + for (Barrier &barrier : state.barriers) { + barrier = Barrier{}; + } + state.barrierSpawnCooldown = BARRIER_FIRST_SPAWN_FRAMES; + leaveCriticalSection(old_ime); +} + +bool is_barrier_cell(const GameState &state, int x, int y, + bool include_warning) { + return std::any_of( + state.barriers.begin(), state.barriers.end(), [&](const Barrier &b) { + const bool relevant = + (b.phase == BarrierPhase::Active) || + (include_warning && b.phase == BarrierPhase::Warning); + return relevant && barrier_contains(b, x, y); + }); +} + +uint8_t calculate_barrier_max_length(const GameState &state) { + if (state.applesEaten <= BARRIER_START_APPLES) { + return static_cast(BARRIER_BASE_MAX_LENGTH); + } + const int apples = std::min(state.applesEaten, SPEED_APPLES_CAP); + const int apple_span = SPEED_APPLES_CAP - BARRIER_START_APPLES; + if (apple_span <= 0) { + return static_cast(BARRIER_MAX_LENGTH); + } + const int length_span = BARRIER_MAX_LENGTH - BARRIER_BASE_MAX_LENGTH; + const int progress = apples - BARRIER_START_APPLES; + return static_cast(BARRIER_BASE_MAX_LENGTH + + ((progress * length_span) / apple_span)); +} + +uint8_t calculate_barrier_max_batch(const GameState &state) { + if (state.applesEaten >= BARRIER_MULTI_SPAWN_APPLES_3) { + return 3; + } + if (state.applesEaten >= BARRIER_MULTI_SPAWN_APPLES_2) { + return 2; + } + return 1; +} + +uint16_t calculate_barrier_interval_frames(const GameState &state) { + if (state.applesEaten <= BARRIER_START_APPLES) { + return BARRIER_INTERVAL_MAX_FRAMES; + } + const int apples = std::min(state.applesEaten, SPEED_APPLES_CAP); + const int apple_span = SPEED_APPLES_CAP - BARRIER_START_APPLES; + if (apple_span <= 0) { + return BARRIER_INTERVAL_MIN_FRAMES; + } + const int span = BARRIER_INTERVAL_MAX_FRAMES - BARRIER_INTERVAL_MIN_FRAMES; + const int progress = apples - BARRIER_START_APPLES; + return static_cast(BARRIER_INTERVAL_MAX_FRAMES - + ((progress * span) / apple_span)); +} + +void tick_barriers_frame(GameState &state) { + if (state.applesEaten < BARRIER_START_APPLES) { + state.barrierSpawnCooldown = BARRIER_FIRST_SPAWN_FRAMES; + return; + } + + if (state.barrierSpawnCooldown > 0) { + state.barrierSpawnCooldown = static_cast(state.barrierSpawnCooldown - 1U); + } + + for (Barrier &barrier : state.barriers) { + if (barrier.phase == BarrierPhase::Inactive) { + continue; + } + if (barrier.framesLeft > 0) { + barrier.framesLeft--; + continue; + } + if (barrier.phase == BarrierPhase::Warning) { + barrier.phase = BarrierPhase::Active; + barrier.framesLeft = BARRIER_ACTIVE_FRAMES; + } else { + barrier.phase = BarrierPhase::Inactive; + } + } +} + +void spawn_barriers(GameState &state) { + if (!state.hardMode || state.applesEaten < BARRIER_START_APPLES || + state.barrierSpawnCooldown > 0) { + 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 max_allowed = max_concurrent_barriers(state); + const int available = max_allowed - in_use; + if (available <= 0) { + state.barrierSpawnCooldown = BARRIER_RETRY_FRAMES; + return; + } + + const int max_batch = calculate_barrier_max_batch(state); + int to_spawn = 1; + if (max_batch > 1 && available > 1) { + const int max_possible = std::min(max_batch, available); + to_spawn = 1 + (rand() % max_possible); + } + + int spawned_count = 0; + const int current_max = calculate_barrier_max_length(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; }); + if (free_slot == state.barriers.end()) { + break; + } + + bool spawned = false; + for (int attempt = 0; attempt < 48; attempt++) { + Barrier candidate; + candidate.vertical = (rand() % 2) == 0; + 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; + candidate.x = static_cast(rand() % (max_x + 1)); + candidate.y = static_cast(rand() % (max_y + 1)); + + bool valid = true; + for (int i = 0; i < candidate.length && valid; i++) { + const int cx = candidate.x + (candidate.vertical ? 0 : i); + const int cy = candidate.y + (candidate.vertical ? i : 0); + valid = !cell_blocked_for_barrier(state, cx, cy); + } + + if (valid) { + candidate.phase = BarrierPhase::Warning; + candidate.framesLeft = BARRIER_WARN_FRAMES; + *free_slot = candidate; + spawned = true; + spawned_count++; + break; + } + } + + if (!spawned) { + break; + } + } + + if (spawned_count > 0) { + state.barrierSpawnCooldown = static_cast( + calculate_barrier_interval_frames(state) + + (rand() % (BARRIER_INTERVAL_JITTER + 1))); + } else { + state.barrierSpawnCooldown = BARRIER_RETRY_FRAMES; + } +} + +bool check_collision_barriers(GameState &state) { + if (state.gameOver || state.snake.empty()) { + return false; + } + const auto &head = state.snake.front(); + if (is_barrier_cell(state, head[0], head[1], false)) { + state.gameOver = true; + return true; + } + return false; +} diff --git a/source/game_render.cpp b/source/game_render.cpp index 951f250..18c5ba8 100644 --- a/source/game_render.cpp +++ b/source/game_render.cpp @@ -155,7 +155,8 @@ void draw_apples_cursed(const GameState &state, const RenderAssets &assets, void invalidate_hud(RenderAssets &assets) { assets.hudCache.dirty = true; } void draw_game(const GameState &state, const RenderAssets &assets) { - uint16_t oamId = 0; + // 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); @@ -180,6 +181,79 @@ void draw_game(const GameState &state, const RenderAssets &assets) { 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) { + oamClear(&oamSub, 0, BARRIER_OAM_SLOTS); + return; + } + + // Most imminent warning drives the shared warning colour intensity + uint16_t min_warn_frames = BARRIER_WARN_FRAMES; + + for (size_t b = 0; b < state.barriers.size(); b++) { + const Barrier &barrier = state.barriers.at(b); + const int base_slot = static_cast(b) * BARRIER_MAX_LENGTH; + + if (barrier.phase == BarrierPhase::Inactive) { + oamClear(&oamSub, base_slot, BARRIER_MAX_LENGTH); + continue; + } + + const uint16_t frames_left = barrier.framesLeft; + bool visible = true; + u16 *gfx = assets.gfx_barrier; + + if (barrier.phase == BarrierPhase::Warning) { + min_warn_frames = std::min(min_warn_frames, frames_left); + if (frames_left < BARRIER_SOLIDIFY_FRAMES) { + // About to become solid: rapid flicker between outline and solid look + gfx = (((frames_left >> 1U) & 1U) != 0U) ? assets.gfx_barrier + : assets.gfx_barrier_warn; + } else { + // Blink period shrinks from 24 to 4 frames as activation approaches + const int elapsed = BARRIER_WARN_FRAMES - frames_left; + const int span = BARRIER_WARN_FRAMES - BARRIER_SOLIDIFY_FRAMES; + const int period = std::max(4, 24 - ((elapsed * 20) / span)); + visible = (elapsed % period) < (period / 2); + gfx = assets.gfx_barrier_warn; + } + } else if (frames_left < BARRIER_FADE_FRAMES) { + // Still fatal, but slowly blinking to signal it's about to vanish + visible = ((frames_left >> 3U) & 1U) == 0U; + } + + for (int i = 0; i < BARRIER_MAX_LENGTH; i++) { + const int slot = base_slot + i; + if (i >= barrier.length) { + oamClear(&oamSub, slot, 1); + continue; + } + const int cx = barrier.x + (barrier.vertical ? 0 : i); + const int cy = barrier.y + (barrier.vertical ? i : 0); + oamSet(&oamSub, slot, cx * 8, cy * 8, 0, 0, SpriteSize_8x8, + SpriteColorFormat_256Color, gfx, -1, false, !visible, false, false, + false); + } + } + + // Palette animation (shared by all barriers) + const uint8_t pulse = (frame >> 1U) & 7U; + const int wave = (pulse < 4U) ? pulse : (7 - pulse); // 0..3..0 + SPRITE_PALETTE_SUB[BARRIER_PAL_EDGE] = static_cast(RGB15(12, 0, 3)); + SPRITE_PALETTE_SUB[BARRIER_PAL_FILL] = + static_cast(RGB15(22 + (wave * 3), 2 + wave, 4)); + SPRITE_PALETTE_SUB[BARRIER_PAL_STRIPE] = + static_cast(RGB15(31, 22 + (wave * 3), 6 + (wave * 4))); + + // Warning colour ramps from dim orange to hot yellow-white + const int urgency = + 31 - ((static_cast(min_warn_frames) * 31) / BARRIER_WARN_FRAMES); + SPRITE_PALETTE_SUB[BARRIER_PAL_WARN] = static_cast( + RGB15(18 + ((urgency * 13) / 31), 4 + ((urgency * 22) / 31), + 2 + ((urgency * 10) / 31))); +} + void draw_stats(const GameState &state, RenderAssets &assets, bool internals, bool musicEnabled, uint8_t frame) { if (internals) { @@ -196,7 +270,12 @@ void draw_stats(const GameState &state, RenderAssets &assets, bool internals, iprintf("\x1b[7;0HSpeed: %u.%01u Hz (%u%%) ", static_cast(freq_mhz / 1000U), static_cast((freq_mhz % 1000U) / 100U), - static_cast((static_cast(intensity) * 100U) / 31U)); + static_cast( + (static_cast(intensity) * 100U) / 31U)); + if (state.hardMode) { + iprintf("\x1b[8;0HBarriers: max len %u ", + static_cast(calculate_barrier_max_length(state))); + } } HudCache &cache = assets.hudCache; @@ -274,14 +353,14 @@ void draw_menu(const GameState &state, RenderAssets &assets, iprintf("\x1b[3;10HSNAKE PLUS"); iprintf("\x1b[5;5H---------------------"); - const char *hardModeLabel = state.hardMode ? "ON " : "OFF"; - const char *musicLabel = musicEnabled ? "ON " : "OFF"; + const char *hard_mode_label = state.hardMode ? "ON " : "OFF"; + const char *music_label = musicEnabled ? "ON " : "OFF"; iprintf("\x1b[8;7H%c Start Game", state.menuSelection == 0 ? '>' : ' '); iprintf("\x1b[10;7H%c Hard Mode: %s", state.menuSelection == 1 ? '>' : ' ', - hardModeLabel); + hard_mode_label); iprintf("\x1b[12;7H%c Music: %s", state.menuSelection == 2 ? '>' : ' ', - musicLabel); + music_label); iprintf("\x1b[19;3HUP/DOWN: Navigate"); iprintf("\x1b[20;3HA/START: Select"); diff --git a/source/main.cpp b/source/main.cpp index 7e741b8..28edaec 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -85,6 +85,8 @@ void setup() { renderAssets.gfx_tail_v[1] = oamAllocateGfx(&oamSub, SpriteSize_8x8, SpriteColorFormat_256Color); renderAssets.gfx_apple_rotten = oamAllocateGfx(&oamSub, SpriteSize_8x8, SpriteColorFormat_256Color); renderAssets.gfx_apple_cursed = oamAllocateGfx(&oamSub, SpriteSize_8x8, SpriteColorFormat_256Color); + renderAssets.gfx_barrier = oamAllocateGfx(&oamSub, SpriteSize_8x8, SpriteColorFormat_256Color); + renderAssets.gfx_barrier_warn = oamAllocateGfx(&oamSub, SpriteSize_8x8, SpriteColorFormat_256Color); dmaCopy(get_sprite_pointer(1), renderAssets.gfx_segment, 64); dmaCopy(get_sprite_pointer(2), renderAssets.gfx_segment_small, 64); @@ -109,6 +111,12 @@ void setup() { SPRITE_PALETTE_SUB[CURSE_PAL_CORE] = RGB15(31, 26, 31); // Glowing White-Pink Core SPRITE_PALETTE_SUB[CURSE_PAL_LEAF] = RGB15(2, 31, 10); // Toxic Radioactive Neon Green + // Setup initial colors for the hard-mode temporary fatal barriers + SPRITE_PALETTE_SUB[BARRIER_PAL_EDGE] = RGB15(12, 0, 3); + SPRITE_PALETTE_SUB[BARRIER_PAL_FILL] = RGB15(22, 2, 4); + SPRITE_PALETTE_SUB[BARRIER_PAL_STRIPE] = RGB15(31, 22, 6); + SPRITE_PALETTE_SUB[BARRIER_PAL_WARN] = RGB15(18, 4, 2); + // 8x8 256-color tile data for Cursed Apple (vibrant electric purple apple with radioactive green horn & glowing core) static const uint32_t CURSED_APPLE_TILE[16] = { 0x10000000, 0x00001414, @@ -121,11 +129,47 @@ void setup() { 0x10100000, 0x00101000, }; + // 8x8 256-color tile data for solid fatal barrier (bordered hazard stripe block) + static const uint32_t BARRIER_SOLID_TILE[16] = { + 0x15151515, 0x15151515, + 0x16171715, 0x15171716, + 0x16161715, 0x15161717, + 0x17161615, 0x15161617, + 0x17171615, 0x15171616, + 0x16171715, 0x15171716, + 0x16161715, 0x15161717, + 0x15151515, 0x15151515, + }; + + // 8x8 256-color tile data for barrier warning announcement (pulsing reticle frame) + static const uint32_t BARRIER_WARN_TILE[16] = { + 0x00001818, 0x18180000, + 0x18000018, 0x18000018, + 0x18180000, 0x00001818, + 0x00181800, 0x00181800, + 0x00181800, 0x00181800, + 0x18180000, 0x00001818, + 0x18000018, 0x18000018, + 0x00001818, 0x18180000, + }; + // Copy cursed apple tile directly into allocated VRAM (32 halfwords) - auto *destAppleCursed = static_cast(renderAssets.gfx_apple_cursed); - const auto *srcAppleCursed = reinterpret_cast(CURSED_APPLE_TILE); + auto *dest_apple_cursed = static_cast(renderAssets.gfx_apple_cursed); + const auto *src_apple_cursed = reinterpret_cast(CURSED_APPLE_TILE); for (size_t i = 0; i < 32; i++) { - destAppleCursed[i] = srcAppleCursed[i]; + dest_apple_cursed[i] = src_apple_cursed[i]; + } + + auto *dest_barrier = static_cast(renderAssets.gfx_barrier); + const auto *src_barrier = reinterpret_cast(BARRIER_SOLID_TILE); + for (size_t i = 0; i < 32; i++) { + dest_barrier[i] = src_barrier[i]; + } + + auto *dest_barrier_warn = static_cast(renderAssets.gfx_barrier_warn); + const auto *src_barrier_warn = reinterpret_cast(BARRIER_WARN_TILE); + for (size_t i = 0; i < 32; i++) { + dest_barrier_warn[i] = src_barrier_warn[i]; } audio_setup(audioContext); @@ -137,6 +181,10 @@ void handle_vblank() { if (gameState.scene == Scene::Playing) { consoleSelect(&topScreen); 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) @@ -157,7 +205,7 @@ void do_tick() { if (gameState.scene == Scene::Playing && !paused) { ticks++; if (!gameOver){ - const bool wasGameOver = gameOver; + const bool was_game_over = gameOver; /*if (hardMode) { timerStop(0); @@ -167,6 +215,9 @@ void do_tick() { update_snake(gameState); audio_play_plop(audioContext); check_collision_self(gameState); + if (check_collision_barriers(gameState)) { + audio_play_boom(audioContext); + } if (check_collision_apples(gameState)) { audio_play_pickup(audioContext); bg_trigger_pulse(bgContext); @@ -181,8 +232,9 @@ void do_tick() { spawn_apples(gameState); spawn_apples_rotten(gameState); spawn_apples_cursed(gameState); + spawn_barriers(gameState); tick_status_effects(gameState); - if (!wasGameOver && gameOver) { + if (!was_game_over && gameOver) { audio_stop_music(); audio_play_fail(audioContext); } @@ -194,6 +246,7 @@ void do_tick() { void init_game() { applesEaten = 0; + reset_barriers(gameState); if (hardMode) { timerStart(0, ClockDivider_1024, calculate_timer_reload(gameState), do_tick); setBackdropColorSub(RGB15(8, 3, 3));