diff --git a/flower.fur b/flower.fur index 84c0ab4..96d55e3 100644 Binary files a/flower.fur and b/flower.fur differ diff --git a/res/fadebottom.png b/res/fadebottom.png index a2a204f..b01a660 100644 Binary files a/res/fadebottom.png and b/res/fadebottom.png differ diff --git a/res/fadetop.png b/res/fadetop.png index c697e2a..494b862 100644 Binary files a/res/fadetop.png and b/res/fadetop.png differ diff --git a/res/ground.png b/res/ground.png index c69bc69..ef08f9c 100644 Binary files a/res/ground.png and b/res/ground.png differ diff --git a/res/koakuma.png b/res/koakuma.png index c930e29..aef3941 100644 Binary files a/res/koakuma.png and b/res/koakuma.png differ diff --git a/res/pbullet.png b/res/pbullet.png index 7e26b48..d1b92a7 100644 Binary files a/res/pbullet.png and b/res/pbullet.png differ diff --git a/res/resources.res b/res/resources.res index a734309..3fc9525 100644 --- a/res/resources.res +++ b/res/resources.res @@ -30,4 +30,6 @@ IMAGE mapIndicator "mapindicator.png" NONE NONE IMAGE imageFontBig "fontbig.png" NONE NONE IMAGE imageFontBigShadow "fontbigshadow.png" NONE NONE IMAGE imageChromeLife "life.png" NONE NONE -IMAGE imageChromeLife2 "life2.png" NONE NONE \ No newline at end of file +IMAGE imageChromeLife2 "life2.png" NONE NONE + +XGM2 stageMusic "stage.vgm" \ No newline at end of file diff --git a/res/stage.vgm b/res/stage.vgm new file mode 100644 index 0000000..9fe2c6d Binary files /dev/null and b/res/stage.vgm differ diff --git a/src/background.h b/src/background.h index d1df8a3..680ce4f 100644 --- a/src/background.h +++ b/src/background.h @@ -1,26 +1,86 @@ #define BG_I 8 -// #define FADE_TOP_I BG_I + 64 -// #define FADE_BOTTOM_I FADE_TOP_I + 64 -#define BG_OFF 24 +// zone-unique block: 64x64px ground block in sky area, only visible in zone 0 +// world X=256 = tile col 32, placed in sky row block 1 (tile row 8) +#define ZONE_BLOCK_WORLD_X 256 +#define ZONE_BLOCK_COL ((ZONE_BLOCK_WORLD_X / 8) % 128) +#define ZONE_BLOCK_ROW 16 +bool zoneBlockVisible; +fix32 prevCamera; +#define PARALLAX_COUNT 8 +fix32 parallaxAccum[PARALLAX_COUNT]; +static const fix32 parallaxMul[PARALLAX_COUNT] = { + FIX32(0.8), FIX32(0.7), FIX32(0.6), FIX32(0.5), FIX32(0.4), FIX32(0.3), FIX32(0.2), FIX32(0.1) +}; + +s16 bgScroll[28]; void loadBackground(){ + VDP_setScrollingMode(HSCROLL_TILE, VSCROLL_PLANE); + VDP_setVerticalScroll(BG_B, 32); VDP_loadTileSet(sky.tileset, BG_I, DMA); VDP_loadTileSet(ground.tileset, BG_I + 64, DMA); - // VDP_loadTileSet(fadeTop.tileset, FADE_TOP_I, DMA); - // VDP_loadTileSet(fadeBottom.tileset, FADE_BOTTOM_I, DMA); + VDP_loadTileSet(fadeBottom.tileset, BG_I + 64 + 64, DMA); + VDP_loadTileSet(fadeTop.tileset, BG_I + 64 + 64 + 4, DMA); for(u8 y = 0; y < 4; y++){ for(u8 x = 0; x < 16; x++){ VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL1, 0, 0, 0, BG_I + (y > 2 ? 64 : 0)), x * 8, y * 8, 8, 8); } } - // for(u8 x = 0; x < 5; x++){ - // VDP_fillTileMapRectInc(BG_A, TILE_ATTR_FULL(PAL1, 0, 0, 0, FADE_TOP_I), x * 8, 0, 8, 8); - // VDP_fillTileMapRectInc(BG_A, TILE_ATTR_FULL(PAL1, 0, 0, 0, FADE_BOTTOM_I), x * 8, 20, 8, 8); - // } + // place 64x64 ground block in sky area (zone 0 only) + VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL1, 0, 0, 0, BG_I + 64), ZONE_BLOCK_COL, ZONE_BLOCK_ROW, 8, 8); + zoneBlockVisible = TRUE; + prevCamera = player.camera; + for(u8 i = 0; i < PARALLAX_COUNT; i++) + parallaxAccum[i] = fix32Mul(player.camera + FIX32(256), parallaxMul[i]); + // write initial scroll values so first frame has correct parallax + s16 initScroll = fix32ToInt(-player.camera); + for(u8 i = 0; i < 20; i++) + bgScroll[i] = initScroll; + for(u8 i = 0; i < 8; i++) + bgScroll[27 - i] = (initScroll - fix32ToInt(parallaxAccum[i])); + VDP_setHorizontalScrollTile(BG_B, 0, bgScroll, 28, DMA); + + // fade + // for(u8 x = 0; x < 20; x++) + // VDP_fillTileMapRectInc(BG_A, TILE_ATTR_FULL(PAL1, 0, 0, 0, BG_I + 64 + 64), x * 2, 26, 2, 2); + // for(u8 x = 0; x < 10; x++) + // VDP_fillTileMapRectInc(BG_A, TILE_ATTR_FULL(PAL1, 0, 0, 0, BG_I + 64 + 64 + 4), x * 4, 22, 4, 4); } void updateBackground(){ - VDP_setHorizontalScroll(BG_B, fix32ToInt(-player.camera)); - VDP_setVerticalScroll(BG_B, (fix32ToInt(player.pos.y) - BG_OFF) >> 3); -} \ No newline at end of file + s16 scrollVal = fix32ToInt(-player.camera); + + // accumulate parallax from camera delta (not absolute position) + // this avoids discontinuities at world wrap boundaries + // GAME_WRAP is already fix32, so use directly (no FIX32 wrapper) + fix32 delta = player.camera - prevCamera; + if(delta > GAME_WRAP / 2) delta -= GAME_WRAP; + else if(delta < -(GAME_WRAP / 2)) delta += GAME_WRAP; + prevCamera = player.camera; + + // update accumulators once, reuse for top and bottom + for(u8 i = 0; i < PARALLAX_COUNT; i++){ + parallaxAccum[i] += fix32Mul(delta, parallaxMul[i]); + if(parallaxAccum[i] > FIX32(1024)) parallaxAccum[i] -= FIX32(1024); + else if(parallaxAccum[i] < FIX32(-1024)) parallaxAccum[i] += FIX32(1024); + } + + for(u8 i = 0; i < 20; i++) + bgScroll[i] = scrollVal; + for(u8 i = 0; i < 8; i++) + bgScroll[27 - i] = (scrollVal - fix32ToInt(parallaxAccum[i])); + + VDP_setHorizontalScrollTile(BG_B, 0, bgScroll, 28, DMA); + + // show ground block only when zone 0 copy of these columns is on screen + fix32 dx = getWrappedDelta(FIX32(ZONE_BLOCK_WORLD_X + 32), player.camera + FIX32(160)); + bool shouldShow = (dx > FIX32(-212) && dx < FIX32(212)); + if(shouldShow && !zoneBlockVisible){ + VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL1, 0, 0, 0, BG_I + 64), ZONE_BLOCK_COL, ZONE_BLOCK_ROW, 8, 8); + zoneBlockVisible = TRUE; + } else if(!shouldShow && zoneBlockVisible){ + VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL1, 0, 0, 0, BG_I), ZONE_BLOCK_COL, ZONE_BLOCK_ROW, 8, 8); + zoneBlockVisible = FALSE; + } +} diff --git a/src/bullets.h b/src/bullets.h index 4540bef..a23be4e 100644 --- a/src/bullets.h +++ b/src/bullets.h @@ -144,11 +144,17 @@ static void collideWithPlayer(u8 i){ fix32ToInt(deltaX), fix32ToInt(deltaY)); if(dist <= 4){ + // convert enemy bullet to player bullet explosion in-place + SPR_setDefinition(bullets[i].image, &pBulletSprite); + bullets[i].player = TRUE; + bullets[i].pos.x = player.pos.x; + bullets[i].pos.y = player.pos.y; killBullet(i, TRUE); sfxExplosion(); player.lives--; if(player.lives == 0){ gameOver = TRUE; + XGM2_stop(); } else { player.recoveringClock = 120; killBullets = TRUE; diff --git a/src/chrome.h b/src/chrome.h index d17a457..dc0ad08 100644 --- a/src/chrome.h +++ b/src/chrome.h @@ -174,6 +174,15 @@ static void updateMap(){ VDP_setTileMapXY(BG_A, MAP_PLAYER_TILE, MAP_X + MAP_W / 2, MAP_Y + pRow); } +s16 lastLevel; +static void drawLevel(){ + char lvlStr[4]; + uintToStr(level + 1, lvlStr, 1); + VDP_drawText("L", 1, 26); + VDP_drawText(lvlStr, 2, 26); + lastLevel = level; +} + void loadChrome(){ VDP_loadTileSet(imageFontBig.tileset, FONT_BIG_I, DMA); VDP_loadTileSet(imageFontBigShadow.tileset, FONT_BIG_I + 32, DMA); @@ -183,6 +192,7 @@ void loadChrome(){ lastScore = 1; drawScore(); drawLives(); + drawLevel(); } bool didGameOver; @@ -191,7 +201,25 @@ static void doGameOver(){ didGameOver = TRUE; for(s16 i = 0; i < BULLET_COUNT; i++) if(bullets[i].active) SPR_setPalette(bullets[i].image, PAL1); for(s16 i = 0; i < ENEMY_COUNT; i++) if(enemies[i].active) SPR_setPalette(enemies[i].image, PAL1); - for(s16 i = 0; i < HUMAN_COUNT; i++) if(humans[i].active) SPR_setPalette(humans[i].image, PAL1); + for(s16 i = 0; i < HUMAN_COUNT; i++) if(humans[i].active){ + if(humans[i].state == HUMAN_COLLECTED){ + // spawn player bullet explosion at carried human position + struct bulletSpawner spawner = { + .x = humans[i].pos.x, .y = humans[i].pos.y, + .anim = 0, .speed = 0, .angle = 0, .player = TRUE + }; + void noop(s16 j){ (void)j; } + spawnBullet(spawner, noop); + for(s16 j = BULLET_COUNT - 1; j >= 0; j--){ + if(bullets[j].active && !bullets[j].explosion + && bullets[j].pos.x == humans[i].pos.x && bullets[j].pos.y == humans[i].pos.y){ + killBullet(j, TRUE); + break; + } + } + } + killHuman(i); + } SPR_releaseSprite(player.image); // clear minimap VDP_clearTileMapRect(BG_A, MAP_X, MAP_Y, MAP_W, MAP_H); @@ -210,6 +238,7 @@ static void showPause(){ for(s16 i = 0; i < ENEMY_COUNT; i++) if(enemies[i].active) SPR_setPalette(enemies[i].image, PAL1); for(s16 i = 0; i < HUMAN_COUNT; i++) if(humans[i].active) SPR_setPalette(humans[i].image, PAL1); SPR_setPalette(player.image, PAL1); + XGM2_pause(); VDP_drawText("PAUSE", 17, 13); } @@ -218,6 +247,7 @@ static void clearPause(){ for(s16 i = 0; i < ENEMY_COUNT; i++) if(enemies[i].active) SPR_setPalette(enemies[i].image, PAL0); for(s16 i = 0; i < HUMAN_COUNT; i++) if(humans[i].active) SPR_setPalette(humans[i].image, PAL0); SPR_setPalette(player.image, PAL0); + XGM2_resume(); VDP_clearText(17, 13, 5); } @@ -276,5 +306,6 @@ void updateChrome(){ drawScore(); } if(lastLives != player.lives) drawLives(); + if(lastLevel != level) drawLevel(); if(clock % 4 == 0) updateMap(); } \ No newline at end of file diff --git a/src/enemies.h b/src/enemies.h index d7b6936..7fc269d 100644 --- a/src/enemies.h +++ b/src/enemies.h @@ -49,10 +49,12 @@ void spawnEnemy(u8 type, u8 zone){ enemies[i].active = FALSE; return; } + SPR_setVisibility(enemies[i].image, HIDDEN); enemies[i].hp = 1; for(u8 j = 0; j < PROP_COUNT; j++){ enemies[i].ints[j] = 0; } + enemies[i].ints[3] = -1; switch(enemies[i].type){ case ENEMY_TYPE_TEST: loadEnemyOne(i); @@ -146,9 +148,31 @@ static void updateEnemy(u8 i){ break; } + // enemy->player collision + if(enemies[i].onScreen && !gameOver && player.recoveringClock == 0){ + fix32 edx = getWrappedDelta(enemies[i].pos.x, player.pos.x); + fix32 edy = enemies[i].pos.y - player.pos.y; + if(edx >= FIX32(-16) && edx <= FIX32(16) && edy >= FIX32(-16) && edy <= FIX32(16)){ + sfxExplosion(); + if(enemies[i].type != ENEMY_TYPE_BOSS){ + enemies[i].hp = 0; + killEnemy(i); + } + player.lives--; + if(player.lives == 0){ + gameOver = TRUE; + XGM2_stop(); + } else { + player.recoveringClock = 120; + killBullets = TRUE; + } + } + } + s16 sx = getScreenX(enemies[i].pos.x, player.camera); s16 sy = fix32ToInt(enemies[i].pos.y); SPR_setVisibility(enemies[i].image, enemies[i].onScreen ? VISIBLE : HIDDEN); + SPR_setHFlip(enemies[i].image, enemies[i].vel.x > 0); SPR_setPosition(enemies[i].image, sx - enemies[i].off, sy - enemies[i].off); enemies[i].clock++; diff --git a/src/enemytypes.h b/src/enemytypes.h index 588d0b0..61f0ee4 100644 --- a/src/enemytypes.h +++ b/src/enemytypes.h @@ -1,4 +1,26 @@ -// test enemy -- for testing out bullet stress +// ============================================================================= +// --- Type 0: Test / Fairy (EnemyOne) --- +// ============================================================================= +// The original enemy type. A fairy that shoots 8-bullet circular bursts and +// also seeks/abducts humans (same carry behavior as Builder). +// +// Behavior: +// - When NOT carrying: drifts at speed 2, shoots 8 bullets in a circle +// every 20 frames (only when on screen). Also scans for nearby walking +// humans every 30 frames and steers toward the closest one within 256px. +// Grabs the human when within 16px. +// - When carrying: flies upward (angle 704-832, roughly up-left to up-right) +// at speed 2. Skips all shooting. boundsEnemy() handles reaching the top +// (kills human, self-destructs -- does NOT spawn a Gunner unlike Builder). +// +// ints[0] = random shot timer offset (0-59), desynchronizes shooting from +// other enemies so they don't all fire on the same frame +// ints[2] = target human index (-1 = no target) +// ints[3] = carried human index (-1 = not carrying) +// +// Speed: 2 HP: 1 Shoots: yes (8-bullet radial, every 20 frames) +// Abducts: yes (only 1 human globally at a time via humanBeingCarried flag) +// ============================================================================= void loadEnemyOne(u8 i){ enemies[i].ints[0] = random() % 60; enemies[i].ints[2] = -1; // target human index @@ -90,9 +112,28 @@ void updateEnemyOne(u8 i){ } } +// ============================================================================= // --- Type 1: Drone --- -// Pressure enemy. Homes toward player, simple aimed shots. -// ints[0] = random shot offset, ints[1] = recalc timer +// ============================================================================= +// Bread-and-butter pressure enemy. Periodically recalculates heading toward +// the player and fires single aimed bullets. The main "fodder" type -- use +// high counts (8-16) to create constant movement pressure without overwhelming +// bullet density. +// +// Behavior: +// - Recalculates heading toward player every 30 frames (ints[1] counter). +// Between recalcs, travels in a straight line at speed 2. +// - Fires 1 aimed bullet at player every 40 frames. Only shoots when: +// a) on screen, AND b) level index >= 1 (i.e. L2+, so L1 drones are +// harmless). This is hardcoded, separate from LevelDef.dronesShoot. +// - Bounces off top/bottom screen edges. +// +// ints[0] = random shot timer offset (0-59), prevents synchronized volleys +// ints[1] = heading recalculation timer (counts up to 30) +// +// Speed: 2 HP: 1 Shoots: yes (1 aimed bullet, every 40 frames, L2+ only) +// Abducts: no +// ============================================================================= void loadDrone(u8 i){ enemies[i].ints[0] = random() % 60; enemies[i].ints[1] = 0; @@ -132,9 +173,32 @@ void updateDrone(u8 i){ } } +// ============================================================================= // --- Type 2: Gunner --- -// Bullet geometry. Slow drift, patterned danmaku. -// ints[0] = pattern type (0=radial, 1=aimed fan), ints[1] = shot timer offset, ints[2] = angle accumulator +// ============================================================================= +// Danmaku / bullet geometry enemy. Drifts very slowly (speed 0.5), acting more +// like a turret than a chaser. Only fires when on screen. The bullet pattern is +// set by LevelDef.gunnerPattern (written to ints[0] by loadLevel). +// +// Behavior: +// - Drifts at speed 0.5 in a random initial direction. Bounces off top/bottom. +// - Does nothing when off screen (early return). +// - Pattern 0 (Radial Burst): fires 8 bullets in a circle every 60 frames. +// Start angle rotates by 24 each volley, creating a spiral-over-time effect. +// Bullet speed 3. Predictable, good for learning dodge patterns. +// - Pattern 1 (Aimed Fan): fires 5 bullets aimed at player, spread across +// +-64 angle units, every 45 frames. Bullet speed 3. More aggressive and +// targeted -- harder to dodge at close range. +// - Pattern is set per-gunner at level load. gunnerPattern=2 means each +// gunner randomly picks 0 or 1, creating mixed bullet fields. +// +// ints[0] = pattern type (0=radial, 1=aimed fan). Set by loadLevel, not random. +// ints[1] = random shot timer offset (0-59) +// ints[2] = angle accumulator (radial pattern only, rotates start angle) +// +// Speed: 0.5 HP: 1 Shoots: yes (pattern-dependent, see above) +// Abducts: no +// ============================================================================= void loadGunner(u8 i){ enemies[i].ints[0] = random() % 2; enemies[i].ints[1] = random() % 60; @@ -187,8 +251,30 @@ void updateGunner(u8 i){ } } +// ============================================================================= // --- Type 3: Hunter --- -// Fast chaser. Homes toward player every frame. No shooting. +// ============================================================================= +// Fast, relentless chaser. Homes toward the player EVERY frame with speed 5 +// (matching player's normal speed). Never shoots -- pure body-collision threat. +// Forces the player to keep moving and use focus mode / diagonal movement to +// outrun. Very dangerous in groups. +// +// Behavior: +// - Recalculates angle toward player every frame (no delay like Drone). +// - Moves at speed 5 (player normal speed = 6, focus = 3.5). +// - Bounces off top/bottom screen edges. +// - No shooting, no abduction. Just chases. +// +// No custom ints used. +// +// Speed: 5 HP: 1 Shoots: no Abducts: no +// +// Design notes: +// - 2-3 hunters alongside gunners is very hard (dodge bullets while fleeing) +// - 4+ hunters with no gunners is a pure movement/positioning challenge +// - 6 hunters is near-impossible -- only for late-game punishment +// - Hunters are the anti-camping enemy: you can't sit still +// ============================================================================= void loadHunter(u8 i){ enemies[i].angle = random() % 1024; enemies[i].speed = FIX32(5); @@ -203,9 +289,39 @@ void updateHunter(u8 i){ enemies[i].vel.y = fix32Mul(fix16ToFix32(sinFix16(enemies[i].angle)), enemies[i].speed); } +// ============================================================================= // --- Type 4: Builder (Abductor) --- -// Seeks and abducts humans. On reaching top with human, spawns a Gunner. -// ints[0] = scan offset, ints[2] = target human, ints[3] = carried human +// ============================================================================= +// Human abductor. Drifts slowly, scans for walking humans, grabs one, and +// flies upward. If it reaches the top of the screen with a human, the human +// is killed and a Gunner spawns at that position -- punishing the player for +// not intercepting. Only 1 human can be globally carried at a time +// (humanBeingCarried flag). +// +// Behavior: +// - When NOT carrying: drifts at speed 0.7. Scans for nearest walking human +// within 256px every 30 frames. If a target is found, switches to seeking +// speed (1.4) and homes toward it. Grabs when within 16px. +// - When carrying: flies upward (angle 704-832) at speed 1.4. +// boundsEnemy() handles reaching the top: kills the human, spawns a Gunner +// at the builder's position, then self-destructs. +// - If the carried human gets collected by the player while being carried, +// boundsEnemy() detects this and kills the builder (enemy dies, human safe). +// - Cancels its target if another enemy is already carrying a human. +// - No shooting at all. +// +// ints[0] = random scan timer offset (0-59) +// ints[2] = target human index (-1 = no target) +// ints[3] = carried human index (-1 = not carrying) +// +// Speed: 0.7 (drift), 1.4 (seeking/carrying) HP: 1 Shoots: no +// Abducts: yes (kills human + spawns Gunner if it reaches the top) +// +// Design notes: +// - 1 builder adds light urgency. 2 builders is stressful. +// - Pairs well with drones to split the player's attention. +// - The Gunner spawn on success makes ignoring builders snowball badly. +// ============================================================================= void loadBuilder(u8 i){ enemies[i].ints[0] = random() % 60; enemies[i].ints[2] = -1; @@ -274,62 +390,243 @@ void updateBuilder(u8 i){ } } +// ============================================================================= // --- Type 5: Boss --- -// High HP, alternates 2 patterns. hp set by level data via ints[0]. -// ints[0] = initial hp (set by stage), ints[1] = pattern timer, ints[2] = current pattern +// ============================================================================= +// High-HP enemy with phase-based attack patterns. Boss number is set via +// pendingBossNum global before spawn (because spawnEnemy zeroes all ints[]). +// HP is set via pendingBossHp. Both are auto-configured by loadLevel() based +// on the level's bossHp field and level index. +// +// Behavior: +// - Speed 1, bounces off top/bottom. Only attacks when on screen. +// - Attack pattern changes based on remaining HP. HP range is divided into +// N equal phases. Phase 0 = full health, highest phase = near death. +// Later bosses have more phases and higher HP, so they cycle through +// more varied and increasingly aggressive patterns. +// - Each boss (1-5) has a unique updateBoss function with different pattern +// selections and timings per phase. +// +// ints[0] = boss number (0-4), selects which updateBoss variant runs +// ints[4] = max HP (stored at load for phase calculation) +// +// Boss 1 (L4): 25 HP, 2 phases -- radial, then aimed fan +// Boss 2 (L8): 50 HP, 3 phases -- radial, aimed fan, spiral +// Boss 3 (L12): 75 HP, 4 phases -- radial, spiral, aimed fan, double radial +// Boss 4 (L16): 100 HP, 5 phases -- adds wide spray +// Boss 5 (L20): 125 HP, 6 phases -- adds ring burst (all patterns used) +// +// Available boss attack patterns: +// Radial: N bullets in even circle, random start angle. Predictable. +// Aimed Fan: N bullets in arc aimed at player. Targeted. +// Spiral: N bullets in circle, start angle rotates with clock. Mesmerizing. +// Double Radial: Two offset rings (outer fast, inner slow). Dense. +// Wide Spray: N bullets in random spread toward player. Chaotic. +// Ring Burst: N bullets in even circle (like radial but different anim). Climactic. +// +// Speed: 1 HP: varies Shoots: yes (phase-dependent) Abducts: no +// ============================================================================= + +// shared pattern functions +static void bossPatternRadial(u8 i, u8 count, fix32 speed){ + sfxEnemyShotB(); + s16 baseAngle = random() % 1024; + struct bulletSpawner spawner = { + .x = enemies[i].pos.x, .y = enemies[i].pos.y, + .anim = 3 + (random() % 3), .speed = speed, .angle = baseAngle, + }; + s16 step = 1024 / count; + for(u8 j = 0; j < count; j++){ + spawnBullet(spawner, EMPTY); + spawner.angle += step; + } +} + +static void bossPatternAimedFan(u8 i, u8 count, s16 spread, fix32 speed){ + sfxEnemyShotA(); + fix32 dx = getWrappedDelta(player.pos.x, enemies[i].pos.x); + fix32 dy = player.pos.y - enemies[i].pos.y; + s16 aimAngle = honeAngle( + fix32ToFix16(enemies[i].pos.x), fix32ToFix16(enemies[i].pos.x) + fix32ToFix16(dx), + fix32ToFix16(enemies[i].pos.y), fix32ToFix16(enemies[i].pos.y) + fix32ToFix16(dy)); + s16 step = (count > 1) ? (spread * 2) / (count - 1) : 0; + struct bulletSpawner spawner = { + .x = enemies[i].pos.x, .y = enemies[i].pos.y, + .anim = 9 + (random() % 3), .speed = speed, .angle = aimAngle - spread, + }; + for(u8 j = 0; j < count; j++){ + spawnBullet(spawner, EMPTY); + spawner.angle += step; + } +} + +static void bossPatternSpiral(u8 i, u8 count, fix32 speed){ + sfxEnemyShotC(); + s16 baseAngle = (enemies[i].clock * 17) % 1024; + struct bulletSpawner spawner = { + .x = enemies[i].pos.x, .y = enemies[i].pos.y, + .anim = 6 + (random() % 3), .speed = speed, .angle = baseAngle, + }; + s16 step = 1024 / count; + for(u8 j = 0; j < count; j++){ + spawnBullet(spawner, EMPTY); + spawner.angle += step; + } +} + +static void bossPatternDoubleRadial(u8 i, u8 count, fix32 speed){ + sfxEnemyShotB(); + s16 baseAngle = random() % 1024; + s16 step = 1024 / count; + struct bulletSpawner spawner = { + .x = enemies[i].pos.x, .y = enemies[i].pos.y, + .anim = 3 + (random() % 3), .speed = speed, .angle = baseAngle, + }; + for(u8 j = 0; j < count; j++){ + spawnBullet(spawner, EMPTY); + spawner.angle += step; + } + spawner.angle = baseAngle + step / 2; + spawner.speed = speed - FIX32(1); + spawner.anim = 6 + (random() % 3); + for(u8 j = 0; j < count; j++){ + spawnBullet(spawner, EMPTY); + spawner.angle += step; + } +} + +static void bossPatternWideSpray(u8 i, u8 count, fix32 speed){ + sfxEnemyShotA(); + fix32 dx = getWrappedDelta(player.pos.x, enemies[i].pos.x); + fix32 dy = player.pos.y - enemies[i].pos.y; + s16 aimAngle = honeAngle( + fix32ToFix16(enemies[i].pos.x), fix32ToFix16(enemies[i].pos.x) + fix32ToFix16(dx), + fix32ToFix16(enemies[i].pos.y), fix32ToFix16(enemies[i].pos.y) + fix32ToFix16(dy)); + struct bulletSpawner spawner = { + .x = enemies[i].pos.x, .y = enemies[i].pos.y, + .anim = 3 + (random() % 9), .speed = speed, .angle = aimAngle, + }; + for(u8 j = 0; j < count; j++){ + spawner.angle = aimAngle - 128 + (random() % 256); + spawner.speed = speed - FIX32(random() % 2); + spawnBullet(spawner, EMPTY); + } +} + +static void bossPatternRingBurst(u8 i, u8 count, fix32 speed){ + sfxEnemyShotB(); + sfxEnemyShotC(); + s16 baseAngle = random() % 1024; + s16 step = 1024 / count; + struct bulletSpawner spawner = { + .x = enemies[i].pos.x, .y = enemies[i].pos.y, + .anim = 9 + (random() % 3), .speed = speed, .angle = baseAngle, + }; + for(u8 j = 0; j < count; j++){ + spawnBullet(spawner, EMPTY); + spawner.angle += step; + } +} + +// get current phase based on HP. phase 0 = full health, higher = more damaged +static u8 getBossPhase(u8 i, u8 numPhases){ + s16 maxHp = enemies[i].ints[4]; + if(maxHp <= 0) return 0; + s16 lost = maxHp - enemies[i].hp; + if(lost < 0) lost = 0; + u8 phase = (lost * numPhases) / maxHp; + if(phase >= numPhases) phase = numPhases - 1; + return phase; +} + void loadBoss(u8 i){ - enemies[i].hp = pendingBossHp > 0 ? pendingBossHp : 10; + enemies[i].hp = pendingBossHp > 0 ? pendingBossHp : 25; pendingBossHp = 0; + enemies[i].ints[0] = pendingBossNum; + pendingBossNum = 0; enemies[i].ints[1] = 0; - enemies[i].ints[2] = 0; + enemies[i].ints[4] = enemies[i].hp; enemies[i].angle = random() % 1024; enemies[i].speed = FIX32(1); } + +// Boss 1 (L6): 2 patterns, 25 HP +static void updateBossOne(u8 i){ + u8 phase = getBossPhase(i, 2); + if(phase == 0){ + if(enemies[i].clock % 50 == 0) bossPatternRadial(i, 10, FIX32(3)); + } else { + if(enemies[i].clock % 40 == 0) bossPatternAimedFan(i, 6, 80, FIX32(3)); + } +} + +// Boss 2 (L12): 3 patterns, 50 HP +static void updateBossTwo(u8 i){ + u8 phase = getBossPhase(i, 3); + if(phase == 0){ + if(enemies[i].clock % 50 == 0) bossPatternRadial(i, 12, FIX32(3)); + } else if(phase == 1){ + if(enemies[i].clock % 40 == 0) bossPatternAimedFan(i, 8, 96, FIX32(3)); + } else { + if(enemies[i].clock % 35 == 0) bossPatternSpiral(i, 6, FIX32(4)); + } +} + +// Boss 3 (L18): 4 patterns, 75 HP +static void updateBossThree(u8 i){ + u8 phase = getBossPhase(i, 4); + if(phase == 0){ + if(enemies[i].clock % 50 == 0) bossPatternRadial(i, 12, FIX32(3)); + } else if(phase == 1){ + if(enemies[i].clock % 40 == 0) bossPatternSpiral(i, 8, FIX32(3)); + } else if(phase == 2){ + if(enemies[i].clock % 35 == 0) bossPatternAimedFan(i, 10, 112, FIX32(4)); + } else { + if(enemies[i].clock % 30 == 0) bossPatternDoubleRadial(i, 8, FIX32(4)); + } +} + +// Boss 4 (L24): 5 patterns, 100 HP +static void updateBossFour(u8 i){ + u8 phase = getBossPhase(i, 5); + if(phase == 0){ + if(enemies[i].clock % 50 == 0) bossPatternRadial(i, 14, FIX32(3)); + } else if(phase == 1){ + if(enemies[i].clock % 40 == 0) bossPatternAimedFan(i, 8, 96, FIX32(3)); + } else if(phase == 2){ + if(enemies[i].clock % 35 == 0) bossPatternSpiral(i, 8, FIX32(4)); + } else if(phase == 3){ + if(enemies[i].clock % 30 == 0) bossPatternDoubleRadial(i, 10, FIX32(4)); + } else { + if(enemies[i].clock % 25 == 0) bossPatternWideSpray(i, 12, FIX32(4)); + } +} + +// Boss 5 (L30): 6 patterns, 125 HP +static void updateBossFive(u8 i){ + u8 phase = getBossPhase(i, 6); + if(phase == 0){ + if(enemies[i].clock % 50 == 0) bossPatternRadial(i, 16, FIX32(3)); + } else if(phase == 1){ + if(enemies[i].clock % 40 == 0) bossPatternAimedFan(i, 10, 112, FIX32(3)); + } else if(phase == 2){ + if(enemies[i].clock % 35 == 0) bossPatternSpiral(i, 10, FIX32(4)); + } else if(phase == 3){ + if(enemies[i].clock % 30 == 0) bossPatternDoubleRadial(i, 10, FIX32(4)); + } else if(phase == 4){ + if(enemies[i].clock % 25 == 0) bossPatternWideSpray(i, 14, FIX32(5)); + } else { + if(enemies[i].clock % 20 == 0) bossPatternRingBurst(i, 16, FIX32(5)); + } +} + void updateBoss(u8 i){ if(!enemies[i].onScreen) return; - enemies[i].ints[1]++; - // alternate patterns every 180 frames - if(enemies[i].ints[1] >= 180){ - enemies[i].ints[1] = 0; - enemies[i].ints[2] = 1 - enemies[i].ints[2]; - } - if(enemies[i].ints[2] == 0){ - // Pattern A: Radial burst - 12 bullets every 50 frames - if(enemies[i].ints[1] % 50 == 0){ - sfxEnemyShotB(); - s16 baseAngle = random() % 1024; - struct bulletSpawner spawner = { - .x = enemies[i].pos.x, - .y = enemies[i].pos.y, - .anim = 3 + (random() % 3), - .speed = FIX32(3), - .angle = baseAngle, - }; - for(u8 j = 0; j < 12; j++){ - spawnBullet(spawner, EMPTY); - spawner.angle += 85; - } - } - } else { - // Pattern B: Aimed wide fan - 8 bullets every 40 frames - if(enemies[i].ints[1] % 40 == 0){ - sfxEnemyShotA(); - fix32 dx = getWrappedDelta(player.pos.x, enemies[i].pos.x); - fix32 dy = player.pos.y - enemies[i].pos.y; - s16 aimAngle = honeAngle( - fix32ToFix16(enemies[i].pos.x), fix32ToFix16(enemies[i].pos.x) + fix32ToFix16(dx), - fix32ToFix16(enemies[i].pos.y), fix32ToFix16(enemies[i].pos.y) + fix32ToFix16(dy)); - struct bulletSpawner spawner = { - .x = enemies[i].pos.x, - .y = enemies[i].pos.y, - .anim = 9 + (random() % 3), - .speed = FIX32(3), - .angle = aimAngle - 112, - }; - for(u8 j = 0; j < 8; j++){ - spawnBullet(spawner, EMPTY); - spawner.angle += 32; - } - } + switch(enemies[i].ints[0]){ + case 0: updateBossOne(i); break; + case 1: updateBossTwo(i); break; + case 2: updateBossThree(i); break; + case 3: updateBossFour(i); break; + case 4: updateBossFive(i); break; } } \ No newline at end of file diff --git a/src/global.h b/src/global.h index f64d9a4..fed9c96 100644 --- a/src/global.h +++ b/src/global.h @@ -10,6 +10,9 @@ u32 clock; #define CULL_LIMIT FIX32(240) +// #define MUSIC_VOLUME 50 +#define MUSIC_VOLUME 0 + u32 score; #define SCORE_LENGTH 8 @@ -29,6 +32,7 @@ bool paused, isPausing; s16 enemyCount, bulletCount; u8 level; s16 pendingBossHp; +s16 pendingBossNum; bool waitForRelease; bool levelClearing; u32 levelClearClock; diff --git a/src/humans.h b/src/humans.h index db3d57a..acb136f 100644 --- a/src/humans.h +++ b/src/humans.h @@ -25,6 +25,7 @@ void spawnHuman(u8 zone){ humans[i].active = FALSE; return; } + SPR_setVisibility(humans[i].image, HIDDEN); } static void updateHuman(u8 i){ @@ -116,9 +117,16 @@ static void updateHuman(u8 i){ s16 sx = getScreenX(humans[i].pos.x, player.camera); s16 sy = fix32ToInt(humans[i].pos.y); - bool visible = (dx >= -CULL_LIMIT && dx <= CULL_LIMIT); + bool visible = (humans[i].state == HUMAN_COLLECTED) || (dx >= -CULL_LIMIT && dx <= CULL_LIMIT); SPR_setVisibility(humans[i].image, visible ? VISIBLE : HIDDEN); SPR_setPosition(humans[i].image, sx - HUMAN_OFF, sy - HUMAN_OFF); + // manually animate flash only when walking or falling + if(humans[i].state == HUMAN_WALKING || humans[i].state == HUMAN_FALLING) + SPR_setFrame(humans[i].image, (clock / 15) & 1); + else if(humans[i].state == HUMAN_COLLECTED) + SPR_setAnim(humans[i].image, 1); + else + SPR_setFrame(humans[i].image, 0); } void updateHumans(){ diff --git a/src/main.c b/src/main.c index 9d62b8d..03efb46 100644 --- a/src/main.c +++ b/src/main.c @@ -43,6 +43,11 @@ void loadGame(){ loadPlayer(); loadChrome(); loadLevel(0); + XGM2_play(stageMusic); + XGM2_setFMVolume(MUSIC_VOLUME); + XGM2_setPSGVolume(MUSIC_VOLUME); + player.recoveringClock = 120; + killBullets = TRUE; started = TRUE; } @@ -59,25 +64,30 @@ static void updateGame(){ loadBackground(); loadChrome(); loadLevel(level + 1); + XGM2_play(stageMusic); SPR_setVisibility(player.image, VISIBLE); + player.recoveringClock = 120; + killBullets = TRUE; } return; } if(!paused){ updatePlayer(); + updateBackground(); if(clock % 2 == 0){ updateEnemies(); if(!gameOver && enemyCount == 0){ if(level >= LEVEL_COUNT - 1){ gameOver = TRUE; + XGM2_stop(); } else { levelClearing = TRUE; levelClearClock = 0; + XGM2_stop(); } } updateHumans(); } else { - updateBackground(); updateBullets(); } } diff --git a/src/player.h b/src/player.h index e91efe1..511365e 100644 --- a/src/player.h +++ b/src/player.h @@ -1,9 +1,9 @@ -#define PLAYER_SPEED FIX32(5) +#define PLAYER_SPEED FIX32(6) -#define PLAYER_SPEED_FOCUS FIX32(3) +#define PLAYER_SPEED_FOCUS FIX32(3.5) -#define PLAYER_ACCEL PLAYER_SPEED >> 3 -#define PLAYER_ACCEL_FOCUS PLAYER_SPEED_FOCUS >> 3 +#define PLAYER_ACCEL PLAYER_SPEED >> 4 +#define PLAYER_ACCEL_FOCUS PLAYER_SPEED_FOCUS >> 4 #define PLAYER_OFF 24 #define PLAYER_BOUND_Y FIX32(PLAYER_OFF) diff --git a/src/stage.h b/src/stage.h index ce32a17..7ca21c9 100644 --- a/src/stage.h +++ b/src/stage.h @@ -1,55 +1,137 @@ +// ============================================================================= +// LEVEL DESIGN GUIDE +// ============================================================================= +// +// Each level is a single struct defining what spawns. The level ends when all +// enemies are killed (enemyCount == 0). Humans are bonus -- they don't affect +// level completion. +// +// --- STRUCT FIELDS --- +// +// drones Number of Drone enemies (type 1). Bulk pressure enemy. +// Speed 2, homes toward player every 30 frames. +// Fires 1 aimed bullet every 40 frames (only on L2+, i.e. index >= 1). +// Use dronesShoot=FALSE on L1 to introduce them without bullets. +// Good range: 6-16. Above 14 gets chaotic. +// +// gunners Number of Gunner enemies (type 2). Danmaku / bullet geometry. +// Speed 0.5 (slow drift), only shoots when on screen. +// Pattern controlled by gunnerPattern field (see below). +// Good range: 0-6. Even 2-3 gunners create significant bullet density. +// +// hunters Number of Hunter enemies (type 3). Fast chaser, no shooting. +// Speed 5 (matches player!). Homes every frame. Pure body pressure. +// Very dangerous -- 2-3 is oppressive, 6 is near-impossible. +// Good range: 0-6. Introduce after players learn movement. +// +// builders Number of Builder enemies (type 4). Human abductor. +// Speed 0.7 (drift), 1.4 (seeking/carrying). Grabs walking humans +// and flies upward. If it reaches the top, the human dies and a +// Gunner spawns in its place. Only 1 human can be carried at a time. +// Creates urgency -- player must choose between killing enemies +// and saving humans. Good range: 0-2. +// +// bossHp If > 0, spawns a Boss enemy (type 5) with this many HP. +// Boss number is auto-calculated from level index (lvl / 4). +// Set to 0 for non-boss levels. Boss speed is 1, bounces around. +// Boss has multiple attack phases based on remaining HP. +// Typical values: 25, 50, 75, 100, 125. +// Other enemies can coexist with the boss (adds pressure). +// +// humans Number of humans (koakuma) to spawn. Distributed across 4 zones +// (2 per zone if >= 4 humans, then 1 each for remainder). +// Walk along the ground, can be collected by player for 1000 pts +// (2000 if caught mid-fall after enemy drops them). +// Max 8 (HUMAN_COUNT). Usually just set to 8. +// +// gunnerPattern Controls what bullet pattern gunners use: +// 0 = Radial Burst: 8 bullets in a circle every 60 frames. +// Rotating start angle. Steady, predictable pressure. +// 1 = Aimed Fan: 5 bullets aimed at player, spread +-64, +// every 45 frames. More targeted/aggressive. +// 2 = Mix: each gunner randomly picks 0 or 1 at spawn. +// Creates varied, less predictable bullet fields. +// +// dronesShoot TRUE = drones fire aimed bullets (normal behavior on L2+). +// FALSE = drones still home toward player but never shoot. +// Only meaningful for the very first level as a gentle intro. +// (Drone shooting is also gated by level >= 1 in code, so +// L1 drones never shoot regardless of this flag.) +// +// --- LIMITS --- +// +// Total enemies: 24 slots (ENEMY_COUNT). drones+gunners+hunters+builders+boss +// must not exceed 24. If it does, excess enemies silently fail to spawn. +// +// Total humans: 8 slots (HUMAN_COUNT). +// +// Bullet slots: 70. Heavy gunner/boss levels can fill this up. Player bullets +// get priority and evict enemy bullets when full. +// +// --- SPAWNING --- +// +// Enemies are distributed across 4 zones (each 512px of the 2048px world). +// Enemy i spawns in zone (i % 4). They never spawn within 240px of the player +// and maintain 64px minimum spacing from each other. +// +// Boss always spawns in zone 1. +// +// --- DESIGN TIPS --- +// +// - Drone-heavy levels (12-16) create constant movement pressure +// - Gunner-heavy levels (4-6) create bullet reading / dodging challenges +// - Hunter levels force the player to keep moving (anti-camping) +// - Builder levels force tough choices: kill builders or save humans? +// - Combining hunters + gunners is very hard (dodge bullets while fleeing) +// - Boss levels with escort enemies (drones/gunners alongside boss) are +// harder than solo boss fights +// - A "farm" level (lots of drones, no gunners) gives score-building breathers +// - gunnerPattern 0 (radial) is easier to dodge than 1 (aimed fan) +// +// ============================================================================= + struct LevelDef { u8 drones, gunners, hunters, builders; u8 bossHp; u8 humans; - u8 gunnerPattern; // 0=radial, 1=aimed fan, 2=mix + u8 gunnerPattern; bool dronesShoot; }; -// dr gn hn bl boss hum pat shoot -const struct LevelDef levels[30] = { - // Phase 1: "Immediate danger" (L1-L6) +// dr gn hn bl boss hum pat shoot +const struct LevelDef levels[20] = { + // Phase 1: "Immediate danger" (L1-L4) { 8, 1, 0, 0, 0, 8, 0, FALSE }, // L1 { 10, 2, 0, 0, 0, 8, 0, TRUE }, // L2 - { 12, 2, 0, 0, 0, 8, 0, TRUE }, // L3 - { 10, 3, 0, 0, 0, 8, 1, TRUE }, // L4 - { 14, 3, 0, 0, 0, 8, 1, TRUE }, // L5 - { 8, 0, 0, 0, 8, 8, 0, TRUE }, // L6 BOSS + { 12, 3, 0, 0, 0, 8, 1, TRUE }, // L3 + { 8, 0, 0, 0, 25, 8, 0, TRUE }, // L4 BOSS 1 - // Phase 2: "You can't save everything" (L7-L12) - { 10, 0, 0, 1, 0, 8, 0, TRUE }, // L7 - { 10, 2, 0, 1, 0, 8, 0, TRUE }, // L8 - { 12, 0, 0, 2, 0, 8, 0, TRUE }, // L9 - { 14, 3, 0, 1, 0, 8, 1, TRUE }, // L10 WALL - { 10, 2, 0, 2, 0, 8, 2, TRUE }, // L11 - { 8, 0, 0, 1, 12, 8, 0, TRUE }, // L12 BOSS + // Phase 2: "You can't save everything" (L5-L8) + { 10, 2, 0, 1, 0, 8, 0, TRUE }, // L5 + { 14, 3, 0, 1, 0, 8, 1, TRUE }, // L6 + { 10, 2, 0, 2, 0, 8, 2, TRUE }, // L7 + { 8, 0, 0, 1, 50, 8, 0, TRUE }, // L8 BOSS 2 - // Phase 3: "Geometry matters" (L13-L18) - { 8, 0, 4, 0, 0, 8, 0, TRUE }, // L13 - { 8, 3, 2, 0, 0, 8, 1, TRUE }, // L14 - { 16, 0, 0, 0, 0, 8, 0, TRUE }, // L15 FARM - { 10, 2, 4, 0, 0, 8, 2, TRUE }, // L16 - { 12, 3, 3, 0, 0, 8, 1, TRUE }, // L17 - { 0, 2, 2, 0, 15, 8, 2, TRUE }, // L18 BOSS + // Phase 3: "Geometry matters" (L9-L12) + { 8, 3, 4, 0, 0, 8, 1, TRUE }, // L9 + { 10, 2, 4, 0, 0, 8, 2, TRUE }, // L10 + { 12, 3, 3, 0, 0, 8, 1, TRUE }, // L11 + { 0, 2, 2, 0, 75, 8, 2, TRUE }, // L12 BOSS 3 - // Phase 4: "Suffocation" (L19-L24) - { 12, 4, 0, 0, 0, 8, 2, TRUE }, // L19 - { 14, 4, 0, 2, 0, 8, 2, TRUE }, // L20 WALL - { 10, 0, 6, 0, 0, 8, 0, TRUE }, // L21 - { 12, 4, 2, 0, 0, 8, 1, TRUE }, // L22 - { 14, 4, 0, 2, 0, 8, 2, TRUE }, // L23 - { 0, 3, 0, 1, 20, 8, 2, TRUE }, // L24 BOSS + // Phase 4: "Suffocation" (L13-L16) + { 14, 4, 0, 2, 0, 8, 2, TRUE }, // L13 + { 10, 0, 6, 0, 0, 8, 0, TRUE }, // L14 + { 12, 4, 2, 0, 0, 8, 1, TRUE }, // L15 + { 0, 3, 0, 1, 100, 8, 2, TRUE }, // L16 BOSS 4 - // Phase 5: "Arcade cruelty" (L25-L30) - { 16, 0, 4, 0, 0, 8, 0, TRUE }, // L25 - { 12, 6, 0, 0, 0, 8, 2, TRUE }, // L26 - { 14, 2, 4, 0, 0, 8, 1, TRUE }, // L27 - { 16, 4, 0, 2, 0, 8, 2, TRUE }, // L28 - { 6, 2, 2, 1, 10, 8, 2, TRUE }, // L29 MINI-BOSS - { 4, 2, 2, 1, 30, 8, 2, TRUE }, // L30 FINAL + // Phase 5: "Arcade cruelty" (L17-L20) + { 16, 0, 4, 0, 0, 8, 0, TRUE }, // L17 + { 14, 4, 4, 2, 0, 8, 2, TRUE }, // L18 + { 6, 2, 2, 1, 50, 8, 2, TRUE }, // L19 MINI-BOSS + { 4, 2, 2, 1, 125, 8, 2, TRUE }, // L20 BOSS 5 FINAL }; -#define LEVEL_COUNT 30 +#define LEVEL_COUNT 20 static void distributeEnemies(u8 type, u8 count){ for(u8 i = 0; i < count; i++){ @@ -80,6 +162,9 @@ void loadLevel(u8 lvl){ if(def->bossHp > 0){ pendingBossHp = def->bossHp; + pendingBossNum = lvl / 4; // L3=0, L7=1, L11=2, L15=3, L18+=4 + if(pendingBossNum > 4) pendingBossNum = 4; + if(lvl == 18) pendingBossNum = 1; // L19 mini-boss reuses boss 2 spawnEnemy(ENEMY_TYPE_BOSS, 1); }