pickups, native build, enemy/bullet/stage overhaul

- Add pickup system (bomb, spread, rapid, shield) with new sprites
- Replace Docker build with native SGDK compile via m68k-elf-gcc
- Rework enemy spawning, homing math, boss HP/number globals
- Expand chrome: score popups, minimap, pause/game over improvements
- Overhaul stage generation with threat-point system
- Add explosion sprites, shield sprite, powerup sprite
- Add tools/ for sprite downscaling utilities
This commit is contained in:
t. boddy 2026-04-15 08:19:29 -04:00
parent 3263b2597b
commit 073f96c9b1
25 changed files with 2320 additions and 1186 deletions

View file

@ -26,7 +26,7 @@ void loadBackground(){
VDP_loadTileSet(sky.tileset, BG_I + 64, DMA);
VDP_loadTileSet(ground.tileset, BG_I + 128, DMA);
VDP_loadTileSet(skyRed.tileset, BG_I + 192, DMA);
VDP_loadTileSet(door.tileset, BG_I + 256, DMA);
// VDP_loadTileSet(door.tileset, BG_I + 256, DMA);
// for(u8 y = 0; y < 14; y++){
// for(u8 x = 0; x < 64; x++){
@ -53,13 +53,13 @@ void loadBackground(){
}
}
// place one door per zone at a random position within each zone's unique col range
for(u8 d = 0; d < DOOR_COUNT; d++){
doorWorldX[d] = FIX32(d * 512 + 8 + (random() % 31) * 8);
u8 col = (u8)(F32_toInt(doorWorldX[d]) / 8 % 128);
VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, BG_I + 256), col, ZONE_BLOCK_ROW, 8, 8);
doorVisible[d] = TRUE;
}
// // place one door per zone at a random position within each zone's unique col range
// for(u8 d = 0; d < DOOR_COUNT; d++){
// doorWorldX[d] = FIX32(d * 512 + 8 + (random() % 31) * 8);
// u8 col = (u8)(F32_toInt(doorWorldX[d]) / 8 % 128);
// VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, BG_I + 256), col, ZONE_BLOCK_ROW, 8, 8);
// doorVisible[d] = TRUE;
// }
prevCamera = player.camera;
for(u8 i = 0; i < PARALLAX_COUNT; i++)
parallaxAccum[i] = F32_mul(player.camera + FIX32(256), parallaxMul[i]);
@ -97,19 +97,19 @@ void updateBackground(){
VDP_setHorizontalScrollTile(BG_B, 0, bgScroll, 28, DMA);
// show/hide each door based on proximity to camera center
for(u8 d = 0; d < DOOR_COUNT; d++){
fix32 dx = getWrappedDelta(doorWorldX[d] + FIX32(32), player.camera + FIX32(160));
bool shouldShow = (dx > FIX32(-212) && dx < FIX32(212));
u8 col = (u8)(F32_toInt(doorWorldX[d]) / 8 % 128);
if(shouldShow && !doorVisible[d]){
VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, BG_I + 256), col, ZONE_BLOCK_ROW, 8, 8);
doorVisible[d] = TRUE;
} else if(!shouldShow && doorVisible[d]){
VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, BG_I + 64), col, ZONE_BLOCK_ROW, 8, 8);
doorVisible[d] = FALSE;
}
}
// // show/hide each door based on proximity to camera center
// for(u8 d = 0; d < DOOR_COUNT; d++){
// fix32 dx = getWrappedDelta(doorWorldX[d] + FIX32(32), player.camera + FIX32(160));
// bool shouldShow = (dx > FIX32(-212) && dx < FIX32(212));
// u8 col = (u8)(F32_toInt(doorWorldX[d]) / 8 % 128);
// if(shouldShow && !doorVisible[d]){
// VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, BG_I + 256), col, ZONE_BLOCK_ROW, 8, 8);
// doorVisible[d] = TRUE;
// } else if(!shouldShow && doorVisible[d]){
// VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, BG_I + 64), col, ZONE_BLOCK_ROW, 8, 8);
// doorVisible[d] = FALSE;
// }
// }
}
#define BG_THEME_RED 0

View file

@ -211,6 +211,9 @@ static void updateBonusObj(u8 i){
if(distSq < collDist * collDist){
if(obj->isBomb){
sfxPlayerHit();
sfxExplosion();
spawnExplosion(player.camera + FIX32(bonusCursorX), FIX32(bonusCursorY), 3, TRUE);
XGM2_stop();
bonusExiting = TRUE;
bonusExitClock = 0;
if(bonusCursor) SPR_setVisibility(bonusCursor, HIDDEN);
@ -305,7 +308,7 @@ void loadBonus(u8 variant){
drawBonusStars();
#if MUSIC_VOLUME > 0
XGM2_play(treasureMusic);
XGM2_play(bossMusic);
#endif
}
@ -348,6 +351,10 @@ void updateBonus(){
bonusCl++;
// Clear "Entering Bonus Level" text after intro
if(bonusCl == 90)
VDP_clearText(10, 14, 20);
// Alternate bg variant every 60 frames
if(starAlternate && bonusCl % 30 == 0)
starVariant ^= 1;
@ -365,6 +372,9 @@ void updateBonus(){
VDP_drawText("Bonus", 14, 16);
VDP_drawText(bonusStr, 20, 16);
sfxCollectAllTreasures();
#if MUSIC_VOLUME > 0
XGM2_play(treasureMusic);
#endif
}
if(bonusExitClock == 220)
PAL_fadeOut(0, 31, 20, TRUE);
@ -381,8 +391,11 @@ void updateBonus(){
fix16 diff = targetAngle - bonusAngle;
if(diff > FIX16(180)) diff -= FIX16(360);
if(diff < FIX16(-180)) diff += FIX16(360);
// Determine target angular velocity from shortest-path direction
fix16 targetVel = (diff > 0) ? BONUS_ANGLE_SPEED : -BONUS_ANGLE_SPEED;
// Determine target angular velocity — proportional when close to prevent overshoot oscillation
fix16 targetVel;
if(diff > BONUS_ANGLE_SPEED) targetVel = BONUS_ANGLE_SPEED;
else if(diff < -BONUS_ANGLE_SPEED) targetVel = -BONUS_ANGLE_SPEED;
else targetVel = diff;
// Accelerate toward target velocity
if(bonusAngleVel < targetVel){
bonusAngleVel += BONUS_ANGLE_ACCEL;
@ -462,6 +475,8 @@ void clearBonus(){
}
bonusObjs[i].active = FALSE;
}
// Clear any active explosions
clearExplosions();
// Clear starfield tiles from BG_B
clearStarfield();
// Clear bonus text from BG_A

View file

@ -63,9 +63,8 @@ bool spawnBullet(struct bulletSpawner spawner, void(*updater)){
bullets[i].vel.y = F32_mul(F32_sin(spawner.angle), spawner.speed);
}
bullets[i].updater = updater;
bullets[i].explosion = FALSE;
bullets[i].grazed = FALSE;
bullets[i].dist = bullets[i].player ? 24 : (spawner.anim == 0 ? 4 : 7);
bullets[i].dist = bullets[i].player ? 32 : (spawner.anim == 0 ? 4 : 7);
// zero out ints array
for(s16 j = 0; j < PROP_COUNT; j++) bullets[i].ints[j] = spawner.ints[j];
@ -103,8 +102,10 @@ static void collideWithEnemy(u8 i){
deltaX >= -BULLET_CHECK && deltaX <= BULLET_CHECK){
bulletDist = getApproximatedDistance(F32_toInt(deltaX), F32_toInt(deltaY));
if(bulletDist <= bullets[i].dist){
score += (enemies[j].ints[3] >= 0) ? 512 : 256;
killBullet(i, TRUE);
u32 pts = (enemies[j].carriedTreasure >= 0) ? 512 : 256;
score += pts;
spawnPopup(enemies[j].pos.x, enemies[j].pos.y, pts);
killBullet(i, enemies[j].hp > 1);
killEnemy(j);
sfxExplosion();
}
@ -122,44 +123,42 @@ static void collideWithPlayer(u8 i){
F32_toInt(deltaX),
F32_toInt(deltaY));
if(dist <= 4){
// kill enemy bullet, then spawn a fresh player bullet explosion
// kill enemy bullet, then spawn big explosion at player position
u8 expAnim = getBulletExplosionAnim(i);
killBullet(i, FALSE);
s16 expSlot = -1;
for(s16 j = 0; j < BULLET_COUNT; j++) if(!bullets[j].active){ expSlot = j; break; }
if(expSlot >= 0){
bullets[expSlot].active = TRUE;
bullets[expSlot].player = TRUE;
bullets[expSlot].explosion = TRUE;
bullets[expSlot].pos.x = player.pos.x;
bullets[expSlot].pos.y = player.pos.y;
bullets[expSlot].vel.x = 0;
bullets[expSlot].vel.y = 0;
bullets[expSlot].clock = 0;
bullets[expSlot].frame = 0;
bullets[expSlot].image = SPR_addSprite(&pBulletSprite, -32, -32, TILE_ATTR(PAL0, 0, 0, 0));
if(bullets[expSlot].image){
SPR_setDepth(bullets[expSlot].image, 5);
SPR_setAnim(bullets[expSlot].image, 1);
SPR_setFrame(bullets[expSlot].image, 0);
SPR_setHFlip(bullets[expSlot].image, random() & 1);
} else {
bullets[expSlot].active = FALSE;
}
}
spawnExplosion(player.pos.x, player.pos.y, expAnim, TRUE);
sfxExplosion();
if(!isAttract){
player.lives--;
if(player.lives == 0){
gameOver = TRUE;
XGM2_stop();
sfxGameOver();
} else {
sfxPlayerHit();
player.respawnClock = 120;
SPR_setVisibility(player.image, HIDDEN);
if(player.hasShield){
// shield absorbs hit
player.hasShield = FALSE;
player.shieldClock = 0;
removeShieldVisual();
player.recoveringClock = 60;
player.recoverFlash = TRUE;
killBullets = TRUE;
hitMessageClock = 120;
hitMessageBullet = TRUE;
} else {
player.lives--;
if(player.lives == 0){
gameOver = TRUE;
XGM2_stop();
sfxGameOver();
} else {
sfxPlayerHit();
levelPerfect = FALSE;
player.respawnClock = 120;
player.activePowerup = 0;
player.powerupClock = 0;
player.hasShield = FALSE;
player.shieldClock = 0;
removeShieldVisual();
SPR_setVisibility(player.image, HIDDEN);
killBullets = TRUE;
hitMessageClock = 120;
hitMessageBullet = TRUE;
}
}
}
} else if(dist <= GRAZE_RADIUS && !bullets[i].grazed){
@ -170,27 +169,7 @@ static void collideWithPlayer(u8 i){
}
}
static void updateBulletExplosion(u8 i){
bullets[i].clock++;
if(bullets[i].clock & 1){
bullets[i].frame++;
if(bullets[i].frame >= 5){
killBullet(i, FALSE);
return;
}
SPR_setFrame(bullets[i].image, bullets[i].frame);
}
s16 sx = getScreenX(bullets[i].pos.x, player.camera);
s16 sy = F32_toInt(bullets[i].pos.y);
u8 off = BULLET_OFF;
SPR_setPosition(bullets[i].image, sx - off, sy - off);
}
static void updateBullet(u8 i){
if(bullets[i].explosion){
updateBulletExplosion(i);
return;
}
bullets[i].pos.x += bullets[i].vel.x - (player.vel.x >> 3);
bullets[i].pos.y += bullets[i].vel.y - (playerScrollVelY >> 3);
@ -234,7 +213,7 @@ void updateBullets(){
if(killBullets){
killBullets = FALSE;
for(s16 i = 0; i < BULLET_COUNT; i++)
if(bullets[i].active && !bullets[i].player && !bullets[i].explosion) killBullet(i, TRUE);
if(bullets[i].active && !bullets[i].player) killBullet(i, TRUE);
}
for(s16 i = 0; i < BULLET_COUNT; i++) if(bullets[i].active)
updateBullet(i);

View file

@ -8,6 +8,66 @@
u16 hudPal = PAL0;
#define POPUP_COUNT 4
struct scorePopup {
bool active;
u8 clock;
u8 len;
s16 tileX, tileY;
char text[6];
};
struct scorePopup popups[POPUP_COUNT];
void spawnPopup(fix32 worldX, fix32 worldY, u32 value){
s16 slot = -1;
for(s16 i = 0; i < POPUP_COUNT; i++) if(!popups[i].active){ slot = i; break; }
if(slot == -1) return;
s16 screenX = getScreenX(worldX, player.camera);
s16 screenY = F32_toInt(worldY);
s16 tX = screenX / 8;
s16 tY = screenY / 8;
tX--;
if(tX < 0) tX = 0;
if(tX > 38) tX = 38;
if(tY < 6) tY = 6;
if(tY > 25) tY = 25;
popups[slot].tileX = tX;
popups[slot].tileY = tY;
uintToStr(value, popups[slot].text, 1);
popups[slot].len = strlen(popups[slot].text);
popups[slot].clock = 0;
popups[slot].active = TRUE;
bigText(popups[slot].text, tX, tY, TRUE);
}
static void updatePopups(){
for(s16 i = 0; i < POPUP_COUNT; i++){
if(!popups[i].active) continue;
popups[i].clock++;
if(popups[i].clock >= 24){
VDP_clearTileMapRect(BG_A, popups[i].tileX, popups[i].tileY, popups[i].len, 2);
popups[i].active = FALSE;
continue;
}
if(popups[i].clock % 8 == 0){
VDP_clearTileMapRect(BG_A, popups[i].tileX, popups[i].tileY, popups[i].len, 2);
popups[i].tileY--;
if(popups[i].tileY < 6) popups[i].tileY = 6;
bigText(popups[i].text, popups[i].tileX, popups[i].tileY, TRUE);
}
}
}
void clearPopups(){
for(s16 i = 0; i < POPUP_COUNT; i++){
if(popups[i].active){
VDP_clearTileMapRect(BG_A, popups[i].tileX, popups[i].tileY, popups[i].len, 2);
popups[i].active = FALSE;
}
}
}
#define FONT_BIG_I 340
void bigText(char* str, u16 x, u16 y, bool shadow){
@ -185,6 +245,10 @@ static void updateMap(){
VDP_setTileMapXY(BG_A, MAP_PLAYER_TILE, MAP_X + MAP_W / 2, MAP_Y + pRow);
}
// pickup HUD tracking
s16 lastBombCount = -1;
s16 lastPowerupState = -1; // 0=none, 1=spread, 2=rapid, 3=shield (composite)
u8 phraseIndex[4];
s16 lastLevel;
@ -221,11 +285,42 @@ static void repaintMap(){
VDP_setTileMapXY(BG_A, MAP_PLAYER_TILE, MAP_X + MAP_W / 2, MAP_Y + mapPlayerRow);
}
static void drawBombCount(){
if(isAttract) return;
if(player.bombCount > 0){
char bStr[4] = "B:";
char numStr[2];
uintToStr(player.bombCount, numStr, 1);
bStr[2] = numStr[0];
bStr[3] = 0;
VDP_drawText(bStr, 1, 7);
} else {
VDP_clearText(1, 7, 3);
}
lastBombCount = player.bombCount;
}
static void drawPowerupIndicator(){
if(isAttract) return;
VDP_clearText(1, 8, 6);
if(player.hasShield)
VDP_drawText("SH", 1, 8);
else if(player.activePowerup == 1)
VDP_drawText("SPREAD", 1, 8);
else if(player.activePowerup == 2)
VDP_drawText("RAPID", 1, 8);
s16 state = player.activePowerup;
if(player.hasShield) state = 3;
lastPowerupState = state;
}
static void repaintHud(){
bigText(scoreStr, SCORE_X, SCORE_Y, FALSE);
drawLives();
repaintMap();
drawLevel();
drawBombCount();
drawPowerupIndicator();
}
void loadChrome(){
@ -263,7 +358,7 @@ static void doGameOver(){
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
if(bullets[j].active
&& bullets[j].pos.x == treasures[i].pos.x && bullets[j].pos.y == treasures[i].pos.y){
killBullet(j, TRUE);
break;
@ -273,6 +368,7 @@ static void doGameOver(){
killTreasure(i);
}
SPR_releaseSprite(player.image);
clearPickups();
// clear lives
VDP_clearTileMapRect(BG_A, LIVES_X, LIVES_Y, 1, 16);
@ -297,6 +393,7 @@ static void showPause(){
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 < TREASURE_COUNT; i++) if(treasures[i].active) SPR_setPalette(treasures[i].image, PAL1);
for(s16 i = 0; i < PICKUP_COUNT; i++) if(pickups[i].active) SPR_setPalette(pickups[i].image, PAL1);
SPR_setPalette(player.image, PAL1);
hudPal = PAL1;
hudPal = PAL1;
@ -309,6 +406,7 @@ static void clearPause(){
for(s16 i = 0; i < BULLET_COUNT; i++) if(bullets[i].active) SPR_setPalette(bullets[i].image, PAL0);
for(s16 i = 0; i < ENEMY_COUNT; i++) if(enemies[i].active) SPR_setPalette(enemies[i].image, PAL0);
for(s16 i = 0; i < TREASURE_COUNT; i++) if(treasures[i].active) SPR_setPalette(treasures[i].image, PAL0);
for(s16 i = 0; i < PICKUP_COUNT; i++) if(pickups[i].active) SPR_setPalette(pickups[i].image, PAL0);
SPR_setPalette(player.image, PAL0);
hudPal = PAL0;
repaintHud();
@ -345,12 +443,13 @@ static void updatePause(){
}
#define TRANSITION_TREASURE_X 10
#define TRANSITION_TREASURE_Y 13
#define TRANSITION_TREASURE_Y 15
#define TRANSITION_LEVEL_X 12
#define TRANSITION_LEVEL_Y 15
#define TRANSITION_LEVEL_Y 13
void updateChrome(){
updatePopups();
updatePause();
if(gameOver && !didGameOver) doGameOver();
if(didGameOver){
@ -396,12 +495,31 @@ void updateChrome(){
else
VDP_drawText("Lives Left", TRANSITION_LEVEL_X + 2, TRANSITION_LEVEL_Y + 5);
if(grazeCount > 0){
char grazeStr[8];
char grazePtsStr[12];
uintToStr(grazeCount, grazeStr, 1);
uintToStr(grazeCount * 64, grazePtsStr, 1);
VDP_drawText("Grazes", TRANSITION_LEVEL_X, TRANSITION_LEVEL_Y + 7);
VDP_drawText(grazeStr, TRANSITION_LEVEL_X + 7, TRANSITION_LEVEL_Y + 7);
VDP_drawText(grazePtsStr, TRANSITION_LEVEL_X + 7 + strlen(grazeStr) + 1, TRANSITION_LEVEL_Y + 7);
VDP_drawText("pts", TRANSITION_LEVEL_X + 7 + strlen(grazeStr) + 1 + strlen(grazePtsStr) + 1, TRANSITION_LEVEL_Y + 7);
}
if(levelPerfect){
score += 4096;
lastScore = score;
VDP_drawText("PERFECT! +4096", 13, TRANSITION_LEVEL_Y + 9);
}
}
if(levelClearClock >= 230){
VDP_clearText(0, TRANSITION_TREASURE_Y, 40);
VDP_clearText(0, TRANSITION_LEVEL_Y, 40);
VDP_clearText(0, TRANSITION_LEVEL_Y + 3, 40);
VDP_clearText(0, TRANSITION_LEVEL_Y + 5, 40);
VDP_clearText(0, TRANSITION_LEVEL_Y + 7, 40);
VDP_clearText(0, TRANSITION_LEVEL_Y + 9, 40);
}
return;
}
@ -430,6 +548,7 @@ void updateChrome(){
}
if(allDone && collectedCount > 0){
allTreasureCollected = TRUE;
score += 4096;
VDP_drawText("All Treasure Found!", 11, 5);
} else {
const char* mirrorPhrases[] = {"Reflect the Depths", "Dig Deeper Within", "See What Shines Below", "Mirror of the Mine", "Look Back, Strike Back"};
@ -466,5 +585,12 @@ void updateChrome(){
allTreasureCollected = FALSE;
VDP_drawText("All Enemies Down!", 12, 5);
}
// pickup HUD
if(!isAttract){
if(lastBombCount != player.bombCount) drawBombCount();
s16 curPowerup = player.activePowerup;
if(player.hasShield) curPowerup = 3;
if(lastPowerupState != curPowerup) drawPowerupIndicator();
}
if(clock % 4 == 0) updateMap();
}

View file

@ -42,11 +42,13 @@ void spawnEnemy(u8 type, u8 zone){
enemies[i].pos.x = randX;
enemies[i].pos.y = randY;
// Default sprite — load functions can override via SPR_setDefinition()
static const SpriteDefinition* bossSpriteDefs[4] = { &boss1Sprite, &boss2Sprite, &boss3Sprite, &boss4Sprite };
SpriteDefinition const* spriteDef;
if(type == ENEMY_TYPE_DRONE) spriteDef = &eyeBigSprite;
else if(type == ENEMY_TYPE_BOSS) spriteDef = bossSpriteDefs[pendingBossNum % 4];
if(type == ENEMY_TYPE_BOSS) spriteDef = bossSpriteDefs[pendingBossNum % 4];
else spriteDef = &fairySprite;
enemies[i].off = (type == ENEMY_TYPE_BOSS) ? 24 : 16;
enemies[i].image = SPR_addSprite(spriteDef,
getScreenX(enemies[i].pos.x, player.camera) - enemies[i].off, F32_toInt(enemies[i].pos.y) - enemies[i].off, TILE_ATTR(PAL0, 0, 0, 0));
@ -61,23 +63,62 @@ void spawnEnemy(u8 type, u8 zone){
enemies[i].ints[j] = 0;
enemies[i].fixes[j] = 0;
}
enemies[i].ints[3] = -1;
enemies[i].canGrabTreasure = FALSE;
enemies[i].homesOnPlayer = FALSE;
enemies[i].canShoot = FALSE;
enemies[i].canFlipH = FALSE;
enemies[i].useBigSprite = FALSE;
enemies[i].carriedTreasure = -1;
enemies[i].targetTreasure = -1;
enemies[i].anim = 0;
switch(enemies[i].type){
case ENEMY_TYPE_TEST:
case ENEMY_TYPE_ONE:
loadEnemyOne(i);
break;
case ENEMY_TYPE_DRONE:
loadDrone(i);
case ENEMY_TYPE_TWO:
loadEnemyTwo(i);
break;
case ENEMY_TYPE_GUNNER:
loadGunner(i);
case ENEMY_TYPE_THREE:
loadEnemyThree(i);
break;
case ENEMY_TYPE_HUNTER:
loadHunter(i);
case ENEMY_TYPE_FOUR:
loadEnemyFour(i);
break;
case ENEMY_TYPE_BUILDER:
loadBuilder(i);
case ENEMY_TYPE_FIVE:
loadEnemyFive(i);
break;
case ENEMY_TYPE_SIX:
loadEnemySix(i);
break;
case ENEMY_TYPE_SEVEN:
loadEnemySeven(i);
break;
case ENEMY_TYPE_EIGHT:
loadEnemyEight(i);
break;
case ENEMY_TYPE_NINE:
loadEnemyNine(i);
break;
case ENEMY_TYPE_TEN:
loadEnemyTen(i);
break;
case ENEMY_TYPE_ELEVEN:
loadEnemyEleven(i);
break;
case ENEMY_TYPE_TWELVE:
loadEnemyTwelve(i);
break;
case ENEMY_TYPE_THIRTEEN:
loadEnemyThirteen(i);
break;
case ENEMY_TYPE_FOURTEEN:
loadEnemyFourteen(i);
break;
case ENEMY_TYPE_FIFTEEN:
loadEnemyFifteen(i);
break;
case ENEMY_TYPE_SIXTEEN:
loadEnemySixteen(i);
break;
case ENEMY_TYPE_BOSS:
loadBoss(i);
@ -89,11 +130,11 @@ void spawnEnemy(u8 type, u8 zone){
}
static void boundsEnemy(u8 i){
if((enemies[i].type == ENEMY_TYPE_TEST || enemies[i].type == ENEMY_TYPE_BUILDER) && enemies[i].ints[3] >= 0){
s16 h = enemies[i].ints[3];
if(enemies[i].canGrabTreasure && enemies[i].carriedTreasure >= 0){
s16 h = enemies[i].carriedTreasure;
// if the treasure was collected by player or gone, kill this enemy
if(!treasures[h].active || treasures[h].state == TREASURE_COLLECTED){
enemies[i].ints[3] = -1;
enemies[i].carriedTreasure = -1;
treasureBeingCarried = FALSE;
killEnemy(i);
return;
@ -108,17 +149,13 @@ static void boundsEnemy(u8 i){
treasures[h].vel.x = 0;
treasures[h].vel.y = FIX32(3);
}
enemies[i].ints[3] = -1;
enemies[i].carriedTreasure = -1;
treasureBeingCarried = FALSE;
enemies[i].vel.y = FIX32(1);
} else {
if(treasures[h].active) killTreasure(h);
enemies[i].ints[3] = -1;
enemies[i].carriedTreasure = -1;
treasureBeingCarried = FALSE;
if(enemies[i].type == ENEMY_TYPE_BUILDER){
u8 zone = F32_toInt(enemies[i].pos.x) / 512;
spawnEnemy(ENEMY_TYPE_GUNNER, zone);
}
enemies[i].hp = 0;
killEnemy(i);
}
@ -143,6 +180,64 @@ static void boundsEnemy(u8 i){
}
}
static void enemySeekTreasure(u8 i){
// carrying: steer upward
if(enemies[i].carriedTreasure >= 0){
enemies[i].angle = FIX16(248 + (random() % 45));
enemies[i].vel.x = F32_mul(F32_cos(enemies[i].angle), enemies[i].speed);
enemies[i].vel.y = F32_mul(F32_sin(enemies[i].angle), enemies[i].speed);
return;
}
// cancel target if a treasure is already being carried
if(treasureBeingCarried && enemies[i].targetTreasure >= 0){
enemies[i].targetTreasure = -1;
}
// scan for nearest walking treasure every 30 frames
if(!treasureBeingCarried && enemies[i].clock % 30 == 0){
s16 bestTreasure = -1;
fix32 bestDist = FIX32(9999);
for(s16 j = 0; j < TREASURE_COUNT; j++){
if(!treasures[j].active || treasures[j].state != TREASURE_WALKING) continue;
fix32 dx = getWrappedDelta(enemies[i].pos.x, treasures[j].pos.x);
fix32 dy = enemies[i].pos.y - treasures[j].pos.y;
fix32 dist = (dx < 0 ? -dx : dx) + (dy < 0 ? -dy : dy);
if(dist < bestDist && dist < FIX32(256)){
bestDist = dist;
bestTreasure = j;
}
}
enemies[i].targetTreasure = bestTreasure;
}
// steer toward target treasure
if(enemies[i].targetTreasure >= 0){
s16 t = enemies[i].targetTreasure;
if(!treasures[t].active || treasures[t].state != TREASURE_WALKING){
enemies[i].targetTreasure = -1;
} else {
fix32 dx = getWrappedDelta(treasures[t].pos.x, enemies[i].pos.x);
fix32 dy = treasures[t].pos.y - enemies[i].pos.y;
fix16 angle = getAngle(dx, dy);
enemies[i].vel.x = F32_mul(F32_cos(angle), enemies[i].speed);
enemies[i].vel.y = F32_mul(F32_sin(angle), enemies[i].speed);
// grab check: within 16px
fix32 adx = dx < 0 ? -dx : dx;
fix32 ady = dy < 0 ? -dy : dy;
if(adx < FIX32(16) && ady < FIX32(16)){
enemies[i].carriedTreasure = t;
enemies[i].targetTreasure = -1;
treasureBeingCarried = TRUE;
treasures[t].state = TREASURE_CARRIED;
treasures[t].carriedBy = i;
}
}
}
}
static void updateEnemy(u8 i){
enemies[i].pos.x += enemies[i].vel.x - (player.vel.x >> 3);
enemies[i].pos.y += enemies[i].vel.y - (playerScrollVelY >> 3);
@ -153,21 +248,67 @@ static void updateEnemy(u8 i){
fix32 dx = getWrappedDelta(enemies[i].pos.x, player.pos.x);
enemies[i].onScreen = (dx >= -CULL_LIMIT && dx <= CULL_LIMIT);
// flag-based treasure seeking (before type-specific update)
if(enemies[i].canGrabTreasure){
enemySeekTreasure(i);
}
// flag-based homing
if(enemies[i].homesOnPlayer){
enemies[i].angle = enemyHoneAngle(i);
if(player.respawnClock > 0) enemies[i].angle = F16_normalizeAngle(enemies[i].angle + FIX16(180));
enemies[i].vel.x = F32_mul(F32_cos(enemies[i].angle), enemies[i].speed);
enemies[i].vel.y = F32_mul(F32_sin(enemies[i].angle), enemies[i].speed);
}
switch(enemies[i].type){
case ENEMY_TYPE_TEST:
case ENEMY_TYPE_ONE:
updateEnemyOne(i);
break;
case ENEMY_TYPE_DRONE:
updateDrone(i);
case ENEMY_TYPE_TWO:
updateEnemyTwo(i);
break;
case ENEMY_TYPE_GUNNER:
updateGunner(i);
case ENEMY_TYPE_THREE:
updateEnemyThree(i);
break;
case ENEMY_TYPE_HUNTER:
updateHunter(i);
case ENEMY_TYPE_FOUR:
updateEnemyFour(i);
break;
case ENEMY_TYPE_BUILDER:
updateBuilder(i);
case ENEMY_TYPE_FIVE:
updateEnemyFive(i);
break;
case ENEMY_TYPE_SIX:
updateEnemySix(i);
break;
case ENEMY_TYPE_SEVEN:
updateEnemySeven(i);
break;
case ENEMY_TYPE_EIGHT:
updateEnemyEight(i);
break;
case ENEMY_TYPE_NINE:
updateEnemyNine(i);
break;
case ENEMY_TYPE_TEN:
updateEnemyTen(i);
break;
case ENEMY_TYPE_ELEVEN:
updateEnemyEleven(i);
break;
case ENEMY_TYPE_TWELVE:
updateEnemyTwelve(i);
break;
case ENEMY_TYPE_THIRTEEN:
updateEnemyThirteen(i);
break;
case ENEMY_TYPE_FOURTEEN:
updateEnemyFourteen(i);
break;
case ENEMY_TYPE_FIFTEEN:
updateEnemyFifteen(i);
break;
case ENEMY_TYPE_SIXTEEN:
updateEnemySixteen(i);
break;
case ENEMY_TYPE_BOSS:
updateBoss(i);
@ -180,46 +321,43 @@ static void updateEnemy(u8 i){
fix32 edy = enemies[i].pos.y - player.pos.y;
if(edx >= FIX32(-16) && edx <= FIX32(16) && edy >= FIX32(-16) && edy <= FIX32(16)){
sfxExplosion();
// spawn explosion at player position
s16 expSlot = -1;
for(s16 j = 0; j < BULLET_COUNT; j++) if(!bullets[j].active){ expSlot = j; break; }
if(expSlot >= 0){
bullets[expSlot].active = TRUE;
bullets[expSlot].player = TRUE;
bullets[expSlot].explosion = TRUE;
bullets[expSlot].pos.x = player.pos.x;
bullets[expSlot].pos.y = player.pos.y;
bullets[expSlot].vel.x = 0;
bullets[expSlot].vel.y = 0;
bullets[expSlot].clock = 0;
bullets[expSlot].frame = 0;
bullets[expSlot].image = SPR_addSprite(&pBulletSprite, -32, -32, TILE_ATTR(PAL0, 0, 0, 0));
if(bullets[expSlot].image){
SPR_setDepth(bullets[expSlot].image, 5);
SPR_setAnim(bullets[expSlot].image, 1);
SPR_setFrame(bullets[expSlot].image, 0);
SPR_setHFlip(bullets[expSlot].image, random() & 1);
} else {
bullets[expSlot].active = FALSE;
}
}
// spawn big explosion at player position
spawnExplosion(player.pos.x, player.pos.y, 3, TRUE); // yellow
if(enemies[i].type != ENEMY_TYPE_BOSS){
enemies[i].hp = 0;
killEnemy(i);
}
if(!isAttract){
player.lives--;
if(player.lives == 0){
gameOver = TRUE;
XGM2_stop();
sfxGameOver();
} else {
sfxPlayerHit();
player.respawnClock = 120;
SPR_setVisibility(player.image, HIDDEN);
if(player.hasShield){
// shield absorbs hit
player.hasShield = FALSE;
player.shieldClock = 0;
removeShieldVisual();
player.recoveringClock = 60;
player.recoverFlash = TRUE;
killBullets = TRUE;
hitMessageClock = 120;
hitMessageBullet = FALSE;
} else {
player.lives--;
if(player.lives == 0){
gameOver = TRUE;
XGM2_stop();
sfxGameOver();
} else {
sfxPlayerHit();
levelPerfect = FALSE;
player.respawnClock = 120;
player.activePowerup = 0;
player.powerupClock = 0;
player.hasShield = FALSE;
player.shieldClock = 0;
removeShieldVisual();
SPR_setVisibility(player.image, HIDDEN);
killBullets = TRUE;
hitMessageClock = 120;
hitMessageBullet = FALSE;
}
}
}
}
@ -228,7 +366,7 @@ static void updateEnemy(u8 i){
s16 sx = getScreenX(enemies[i].pos.x, player.camera);
s16 sy = F32_toInt(enemies[i].pos.y);
SPR_setVisibility(enemies[i].image, enemies[i].onScreen ? VISIBLE : HIDDEN);
if(enemies[i].type != ENEMY_TYPE_DRONE && enemies[i].type != ENEMY_TYPE_BOSS)
if(enemies[i].canFlipH)
SPR_setHFlip(enemies[i].image, enemies[i].vel.x > 0);
SPR_setPosition(enemies[i].image, sx - enemies[i].off, sy - enemies[i].off);

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@ void sfxCollectAllTreasures();
void loadMap();
void loadGame();
#define SKIP_START 0
#define SKIP_START 1
#define SKIP_TO_BONUS 0 // 1 = boot straight into bonus stage for testing (0 for release)
u32 clock;
@ -21,19 +21,20 @@ u32 clock;
#define GAME_H_F FIX32(224)
#define SECTION_SIZE FIX32(512)
#define SECTION_COUNT 4
#define SECTION_COUNT 3
#define GAME_WRAP (SECTION_SIZE * SECTION_COUNT)
#define CULL_LIMIT FIX32(240)
#define SCREEN_LIMIT FIX32(208) // max player-to-screen-edge distance (320 - CAMERA_X)
// #define MUSIC_VOLUME 50
#define MUSIC_VOLUME 0
#define MUSIC_VOLUME 50
u32 score;
u32 highScore;
u32 tempHighScore;
u32 grazeCount;
bool levelPerfect;
u32 nextExtendScore;
#define EXTEND_SCORE 25000
#define SCORE_LENGTH 8
@ -118,6 +119,11 @@ struct playerStruct {
u8 lives, recoveringClock, respawnClock;
bool recoverFlash; // TRUE only after death, not on level-start grace
bool pendingShow; // show sprite after next position update (avoids 1-frame position flicker)
u8 bombCount; // 0-2
u8 activePowerup; // 0=none, 1=spread, 2=rapid
u16 powerupClock; // countdown
bool hasShield;
u16 shieldClock; // countdown (max 1800)
fix32 camera;
Sprite* image;
};
@ -138,7 +144,7 @@ struct bulletSpawner {
};
struct bullet {
fix32 speed;
bool active, player, vFlip, hFlip, explosion, grazed;
bool active, player, vFlip, hFlip, grazed;
Vect2D_f32 pos, vel;
Sprite* image;
s16 clock, angle, anim, frame;
@ -152,12 +158,66 @@ struct bullet bullets[BULLET_COUNT];
// enemies
#define ENEMY_COUNT 24
#define ENEMY_TYPE_TEST 0
#define ENEMY_TYPE_DRONE 1
#define ENEMY_TYPE_GUNNER 2
#define ENEMY_TYPE_HUNTER 3
#define ENEMY_TYPE_BUILDER 4
#define ENEMY_TYPE_BOSS 5
#define ENEMY_TYPE_ONE 0
#define ENEMY_TYPE_TWO 1
#define ENEMY_TYPE_THREE 2
#define ENEMY_TYPE_FOUR 3
#define ENEMY_TYPE_FIVE 4
#define ENEMY_TYPE_SIX 5
#define ENEMY_TYPE_SEVEN 6
#define ENEMY_TYPE_EIGHT 7
#define ENEMY_TYPE_NINE 8
#define ENEMY_TYPE_TEN 9
#define ENEMY_TYPE_ELEVEN 10
#define ENEMY_TYPE_TWELVE 11
#define ENEMY_TYPE_THIRTEEN 12
#define ENEMY_TYPE_FOURTEEN 13
#define ENEMY_TYPE_FIFTEEN 14
#define ENEMY_TYPE_SIXTEEN 15
#define ENEMY_TYPE_BOSS 16
#define ENEMY_TYPE_COUNT 16 // number of shoppable types (excludes boss)
typedef struct {
u8 type; // ENEMY_TYPE_* constant
u8 cost; // TP cost
u8 weight; // shopping probability weight
u8 maxCount; // max per level
u8 minCount; // guaranteed minimum per level
u8 unlockLevel; // first level index where this type can appear
} EnemyTypeDef;
// cost: how many threat points this enemy costs to place (higher = fewer spawned)
// weight: how likely this type is to be picked each shopping roll (higher = more common)
// max: hard cap per level (won't exceed this even with remaining budget)
// min: guaranteed spawns before shopping starts (cost deducted from budget)
// unlock: first level index where this type enters the pool (0 = available immediately)
static const EnemyTypeDef enemyTypeDefs[ENEMY_TYPE_COUNT] = {
// cost weight max min unlock
{ ENEMY_TYPE_ONE, 1, 10, 2, 0, 5 },
{ ENEMY_TYPE_TWO, 1, 10, 2, 0, 5 },
{ ENEMY_TYPE_THREE, 1, 10, 2, 0, 5 },
{ ENEMY_TYPE_FOUR, 1, 10, 2, 0, 5 },
{ ENEMY_TYPE_FIVE, 1, 10, 2, 0, 5 },
{ ENEMY_TYPE_SIX, 1, 10, 5, 0, 5 },
{ ENEMY_TYPE_SEVEN, 1, 10, 5, 0, 5 },
{ ENEMY_TYPE_EIGHT, 1, 10, 5, 0, 0 },
{ ENEMY_TYPE_NINE, 1, 10, 5, 0, 5 },
{ ENEMY_TYPE_TEN, 1, 10, 5, 0, 5 },
{ ENEMY_TYPE_ELEVEN, 1, 10, 5, 0, 5 },
{ ENEMY_TYPE_TWELVE, 1, 10, 5, 0, 5 },
{ ENEMY_TYPE_THIRTEEN, 1, 10, 5, 0, 5 },
{ ENEMY_TYPE_FOURTEEN, 1, 10, 5, 0, 5 },
{ ENEMY_TYPE_FIFTEEN, 1, 10, 5, 0, 5 },
{ ENEMY_TYPE_SIXTEEN, 1, 10, 5, 0, 5 },
};
// Threat point budget formula: base + (lvl * linear) + (lvl * lvl / quadratic)
// Then randomized to 90-110%. Boss levels get bossPercent% of that.
#define TP_BASE 8
#define TP_LINEAR 4
#define TP_QUADRATIC 5
#define TP_BOSS_PCT 40
struct enemy {
bool active, onScreen;
@ -168,6 +228,13 @@ struct enemy {
fix32 speed;
Vect2D_f32 vel, pos;
Sprite* image;
bool canGrabTreasure;
bool homesOnPlayer;
bool canShoot;
bool canFlipH;
bool useBigSprite;
s16 carriedTreasure;
s16 targetTreasure;
s16 ints[PROP_COUNT];
fix16 fixes[PROP_COUNT];
};
@ -196,57 +263,36 @@ u16 levelEnemiesKilled;
u16 statEnemiesKilled;
s16 statTreasures;
void killTreasure(u8 i){
if(treasures[i].state == TREASURE_CARRIED && treasures[i].carriedBy >= 0){
enemies[treasures[i].carriedBy].ints[3] = -1;
treasureBeingCarried = FALSE;
}
treasures[i].active = FALSE;
SPR_releaseSprite(treasures[i].image);
void removeShieldVisual(){
SPR_setDefinition(player.image, &momoyoSprite);
}
void killBullet(u8 i, bool explode){
if(explode){
s16 a = bullets[i].anim;
s16 explosionAnim;
if(bullets[i].player){
explosionAnim = 16;
} else if(a < FIRST_ROTATING_BULLET){
explosionAnim = 13 + bullets[i].frame;
} else {
s16 mod = a % 3;
explosionAnim = 13 + mod;
}
SPR_setAnim(bullets[i].image, explosionAnim);
bullets[i].clock = 0;
bullets[i].frame = 0;
bullets[i].explosion = TRUE;
SPR_setFrame(bullets[i].image, 0);
SPR_setHFlip(bullets[i].image, random() & 1);
// SPR_setVFlip(bullets[i].image, random() & 1);
} else {
bullets[i].active = FALSE;
SPR_releaseSprite(bullets[i].image);
}
}
// pickups
#define PICKUP_COUNT 1
#define PICKUP_TYPE_BOMB 0
#define PICKUP_TYPE_SPREAD 1
#define PICKUP_TYPE_RAPID 2
#define PICKUP_TYPE_SHIELD 3
#define PICKUP_SPAWN_INTERVAL 450 // ~15 sec (called on even frames only)
#define PICKUP_LIFETIME 300 // ~10 sec (called on even frames only)
#define PICKUP_BLINK_START 30 // blink final ~1 sec (even frames only)
#define BOMB_MAX 2
#define BOMB_DAMAGE 8
#define BOMB_BOSS_DAMAGE 4
#define BOMB_IFRAMES 60
#define BOMB_BULLET_SCORE 32
#define POWERUP_DURATION 600
#define SHIELD_TIMEOUT 600
#define PICKUP_OFF 8
void killEnemy(u8 i){
if(isAttract) return;
enemies[i].hp--;
if(enemies[i].hp > 0) return;
if(enemies[i].ints[3] >= 0){
s16 h = enemies[i].ints[3];
if(treasures[h].active){
treasures[h].state = TREASURE_FALLING;
treasures[h].carriedBy = -1;
treasures[h].vel.x = 0;
treasures[h].vel.y = FIX32(3);
}
treasureBeingCarried = FALSE;
}
enemies[i].active = FALSE;
SPR_releaseSprite(enemies[i].image);
levelEnemiesKilled++;
struct pickup { bool active; u8 type; u16 lifeClock; Vect2D_f32 pos; Sprite* image; };
struct pickup pickups[PICKUP_COUNT];
u16 pickupSpawnClock;
void killPickup(u8 i){
if(!pickups[i].active) return;
pickups[i].active = FALSE;
SPR_releaseSprite(pickups[i].image);
}
static fix32 getWrappedDelta(fix32 a, fix32 b) {
@ -269,6 +315,122 @@ static s16 getScreenX(fix32 worldX, fix32 camera) {
return F32_toInt(screenX);
}
void killTreasure(u8 i){
if(treasures[i].state == TREASURE_CARRIED && treasures[i].carriedBy >= 0){
enemies[treasures[i].carriedBy].carriedTreasure = -1;
treasureBeingCarried = FALSE;
}
treasures[i].active = FALSE;
SPR_releaseSprite(treasures[i].image);
}
// explosion pool (shared by all explosions: bullet, enemy, player death)
#define EXPLOSION_COUNT 12
struct explosion {
bool active, big;
u8 frame, clock;
fix32 x, y;
Sprite* image;
};
struct explosion explosions[EXPLOSION_COUNT];
void spawnExplosion(fix32 x, fix32 y, u8 anim, bool big){
fix32 dx = getWrappedDelta(x, player.pos.x);
if(dx < -CULL_LIMIT || dx > CULL_LIMIT) return;
s16 slot = -1;
for(s16 j = 0; j < EXPLOSION_COUNT; j++) if(!explosions[j].active){ slot = j; break; }
if(slot < 0) return;
explosions[slot].active = TRUE;
explosions[slot].big = big;
explosions[slot].x = x;
explosions[slot].y = y;
explosions[slot].frame = 0;
explosions[slot].clock = 0;
SpriteDefinition const* def = big ? &explosionBigSprite : &explosionsSprite;
explosions[slot].image = SPR_addSprite(def, -64, -64, TILE_ATTR(PAL0, 0, 0, 0));
if(!explosions[slot].image){
explosions[slot].active = FALSE;
return;
}
SPR_setDepth(explosions[slot].image, big ? 0 : 5);
SPR_setAnim(explosions[slot].image, anim);
SPR_setFrame(explosions[slot].image, 0);
SPR_setHFlip(explosions[slot].image, random() & 1);
s16 sx = getScreenX(x, player.camera);
s16 sy = F32_toInt(y);
u8 off = big ? 32 : 16;
SPR_setPosition(explosions[slot].image, sx - off, sy - off);
}
void updateExplosions(){
for(s16 i = 0; i < EXPLOSION_COUNT; i++){
if(!explosions[i].active) continue;
explosions[i].clock++;
if(explosions[i].clock % 4 == 0){
explosions[i].frame++;
if(explosions[i].frame >= 5){
SPR_releaseSprite(explosions[i].image);
explosions[i].active = FALSE;
continue;
}
SPR_setFrame(explosions[i].image, explosions[i].frame);
}
s16 sx = getScreenX(explosions[i].x, player.camera);
s16 sy = F32_toInt(explosions[i].y);
u8 off = explosions[i].big ? 32 : 16;
SPR_setPosition(explosions[i].image, sx - off, sy - off);
}
}
void clearExplosions(){
for(s16 i = 0; i < EXPLOSION_COUNT; i++){
if(explosions[i].active){
SPR_releaseSprite(explosions[i].image);
explosions[i].active = FALSE;
}
}
}
static u8 getBulletExplosionAnim(u8 i){
if(bullets[i].player) return 3; // yellow
if(bullets[i].anim < FIRST_ROTATING_BULLET) return bullets[i].frame; // 0=blue, 1=red, 2=green
return (bullets[i].anim - FIRST_ROTATING_BULLET) % 3; // rotating: 0=blue, 1=red, 2=green
}
void killBullet(u8 i, bool explode){
if(explode){
spawnExplosion(bullets[i].pos.x, bullets[i].pos.y, getBulletExplosionAnim(i), FALSE);
}
bullets[i].active = FALSE;
SPR_releaseSprite(bullets[i].image);
}
void killEnemy(u8 i){
if(isAttract) return;
enemies[i].hp--;
if(enemies[i].hp > 0){
// enemy hit but not dead — small yellow explosion
spawnExplosion(enemies[i].pos.x, enemies[i].pos.y, 3, FALSE);
return;
}
// enemy killed — big explosion
spawnExplosion(enemies[i].pos.x, enemies[i].pos.y, 3, TRUE);
if(enemies[i].carriedTreasure >= 0){
s16 h = enemies[i].carriedTreasure;
if(treasures[h].active){
treasures[h].state = TREASURE_FALLING;
treasures[h].carriedBy = -1;
treasures[h].vel.x = 0;
treasures[h].vel.y = FIX32(3);
}
treasureBeingCarried = FALSE;
}
enemies[i].active = FALSE;
SPR_releaseSprite(enemies[i].image);
levelEnemiesKilled++;
}
// homing -- degree-based using SGDK F16_atan2 (returns fix16 degrees)
static fix16 getAngle(fix32 dx, fix32 dy){

View file

@ -6,6 +6,7 @@
#include "bullets.h"
#include "enemies.h"
#include "treasure.h"
#include "pickup.h"
#include "player.h"
#include "stage.h"
#include "chrome.h"
@ -44,6 +45,10 @@ void clearLevel(){
if(enemies[i].active){ enemies[i].hp = 0; killEnemy(i); }
for(s16 i = 0; i < TREASURE_COUNT; i++)
if(treasures[i].active) killTreasure(i);
clearExplosions();
clearPickups();
clearPopups();
removeShieldVisual();
treasureBeingCarried = FALSE;
collectedCount = 0;
allTreasureCollected = FALSE;
@ -64,12 +69,20 @@ void loadGame(){
loadChrome();
loadLevel(isAttract ? ATTRACT_LEVEL : START_LEVEL);
#if MUSIC_VOLUME > 0
if(!isAttract) XGM2_play(isBossLevel(level) ? bossMusic : stageMusic);
if(!isAttract) XGM2_play(stageMusic);
#endif
player.recoveringClock = 240;
player.recoverFlash = FALSE;
killBullets = TRUE;
attractEnding = FALSE;
player.bombCount = 0;
player.activePowerup = 0;
player.powerupClock = 0;
player.hasShield = FALSE;
player.shieldClock = 0;
pickupSpawnClock = PICKUP_SPAWN_INTERVAL;
bombUsing = FALSE;
bombFlashClock = 0;
started = TRUE;
#if SKIP_TO_BONUS
clearLevel();
@ -110,6 +123,7 @@ static void updateGame(){
// Bonus stage branch (after boss fights)
if(bonusStage){
updateBonus();
updateExplosions();
if(!bonusActive){
bonusStage = FALSE;
clearBonus();
@ -117,6 +131,12 @@ static void updateGame(){
player.pos.y = FIX32(112);
player.camera = player.pos.x - FIX32(160);
playerVelX = 0;
// Re-allocate player sprite (released by loadBonus)
player.image = SPR_addSprite(&momoyoSprite,
-48, -48,
TILE_ATTR(PAL0, 0, 0, 0));
SPR_setDepth(player.image, 0);
SPR_setVisibility(player.image, HIDDEN);
loadBackground();
loadChrome();
loadLevel(level + 1);
@ -125,9 +145,14 @@ static void updateGame(){
player.recoveringClock = 240;
player.recoverFlash = FALSE;
killBullets = TRUE;
player.activePowerup = 0;
player.powerupClock = 0;
player.hasShield = FALSE;
player.shieldClock = 0;
removeShieldVisual();
XGM2_stop();
#if MUSIC_VOLUME > 0
if(!isAttract) XGM2_play(isBossLevel(level) ? bossMusic : stageMusic);
if(!isAttract) XGM2_play(stageMusic);
#endif
}
return;
@ -140,6 +165,7 @@ static void updateGame(){
if(isBossLevel(level) && !isAttract){
loadBonus(level % 3);
bonusStage = TRUE;
VDP_drawText("Entering Bonus Level", 10, 14);
} else {
loadStarfield(level % 3);
}
@ -164,12 +190,17 @@ static void updateGame(){
player.recoveringClock = 240;
player.recoverFlash = FALSE;
killBullets = TRUE;
player.activePowerup = 0;
player.powerupClock = 0;
player.hasShield = FALSE;
player.shieldClock = 0;
XGM2_stop();
#if MUSIC_VOLUME > 0
if(!isAttract) XGM2_play(isBossLevel(level) ? bossMusic : stageMusic);
if(!isAttract) XGM2_play(stageMusic);
#endif
}
if(levelClearing && !bonusStage) updateStarfield();
updateExplosions();
return;
}
if(levelWaitClock > 0){
@ -205,10 +236,12 @@ static void updateGame(){
}
}
updateTreasures();
updatePickups();
} else {
updateBullets();
}
}
updateExplosions();
}
int main(bool hardReset){

154
src/pickup.h Normal file
View file

@ -0,0 +1,154 @@
static u8 pickPickupType(){
u8 weights[4];
u8 total;
if(player.bombCount < BOMB_MAX){
weights[0] = 2; weights[1] = 4; weights[2] = 4; weights[3] = 3;
total = 13;
} else {
weights[0] = 0; weights[1] = 5; weights[2] = 5; weights[3] = 3;
total = 13;
}
u8 roll = random() % total;
u8 accum = 0;
for(u8 t = 0; t < 4; t++){
accum += weights[t];
if(roll < accum) return t;
}
return PICKUP_TYPE_SPREAD;
}
static void spawnPickup(){
s16 slot = -1;
for(s16 i = 0; i < PICKUP_COUNT; i++) if(!pickups[i].active){ slot = i; break; }
if(slot < 0) return;
// random on-screen position with 48px margin
s16 screenPosX = 48 + (random() % (320 - 96));
s16 screenPosY = 48 + (random() % (224 - 96));
// convert screen coords to world coords
fix32 worldX = player.camera + FIX32(screenPosX);
if(worldX >= GAME_WRAP) worldX -= GAME_WRAP;
if(worldX < 0) worldX += GAME_WRAP;
pickups[slot].active = TRUE;
pickups[slot].type = pickPickupType();
pickups[slot].lifeClock = PICKUP_LIFETIME;
pickups[slot].pos.x = worldX;
pickups[slot].pos.y = FIX32(screenPosY);
pickups[slot].image = SPR_addSprite(&pickupSprite,
screenPosX - PICKUP_OFF, screenPosY - PICKUP_OFF,
TILE_ATTR(PAL0, 0, 0, 0));
if(!pickups[slot].image){
pickups[slot].active = FALSE;
return;
}
SPR_setDepth(pickups[slot].image, 1);
SPR_setFrame(pickups[slot].image, pickups[slot].type);
}
static void collectPickup(u8 i){
if(isAttract){
killPickup(i);
return;
}
switch(pickups[i].type){
case PICKUP_TYPE_BOMB:
if(player.bombCount >= BOMB_MAX){
score += 2048;
spawnPopup(pickups[i].pos.x, pickups[i].pos.y, 2048);
} else {
player.bombCount++;
}
break;
case PICKUP_TYPE_SPREAD:
player.activePowerup = 1;
player.powerupClock = POWERUP_DURATION;
break;
case PICKUP_TYPE_RAPID:
player.activePowerup = 2;
player.powerupClock = POWERUP_DURATION;
break;
case PICKUP_TYPE_SHIELD:
player.hasShield = TRUE;
player.shieldClock = SHIELD_TIMEOUT;
SPR_setDefinition(player.image, &shieldSprite);
break;
}
sfxPickup();
killPickup(i);
}
static void updatePickup(u8 i){
pickups[i].lifeClock--;
// blink in final frames
if(pickups[i].lifeClock <= PICKUP_BLINK_START){
SPR_setVisibility(pickups[i].image,
(pickups[i].lifeClock / 4) % 2 == 0 ? HIDDEN : VISIBLE);
}
if(pickups[i].lifeClock == 0){
killPickup(i);
return;
}
// scroll with world
pickups[i].pos.x -= (player.vel.x >> 3);
pickups[i].pos.y -= (playerScrollVelY >> 3);
// X wrap
if(pickups[i].pos.x >= GAME_WRAP) pickups[i].pos.x -= GAME_WRAP;
if(pickups[i].pos.x < 0) pickups[i].pos.x += GAME_WRAP;
// collection check (32px box, same as treasure)
if(!isAttract && player.respawnClock == 0){
fix32 dx = getWrappedDelta(pickups[i].pos.x, player.pos.x);
fix32 dy = pickups[i].pos.y - player.pos.y;
if(dx >= FIX32(-32) && dx <= FIX32(32) && dy >= FIX32(-32) && dy <= FIX32(32)){
collectPickup(i);
return;
}
}
// update sprite position + visibility
s16 sx = getScreenX(pickups[i].pos.x, player.camera);
s16 sy = F32_toInt(pickups[i].pos.y);
fix32 ddx = getWrappedDelta(pickups[i].pos.x, player.pos.x);
bool onScreen = (ddx >= -CULL_LIMIT && ddx <= CULL_LIMIT);
if(onScreen){
SPR_setPosition(pickups[i].image, sx - PICKUP_OFF, sy - PICKUP_OFF);
// only override visibility if not already blinking
if(pickups[i].lifeClock > PICKUP_BLINK_START)
SPR_setVisibility(pickups[i].image, VISIBLE);
} else {
SPR_setVisibility(pickups[i].image, HIDDEN);
}
}
void updatePickups(){
// check if any pickup is active
bool anyActive = FALSE;
for(s16 i = 0; i < PICKUP_COUNT; i++){
if(pickups[i].active){
anyActive = TRUE;
updatePickup(i);
}
}
// spawn timer
if(!anyActive && !levelClearing && levelWaitClock == 0 && !gameOver){
if(pickupSpawnClock > 0) pickupSpawnClock--;
if(pickupSpawnClock == 0){
spawnPickup();
pickupSpawnClock = PICKUP_SPAWN_INTERVAL;
}
}
}
void clearPickups(){
for(s16 i = 0; i < PICKUP_COUNT; i++)
if(pickups[i].active) killPickup(i);
}

View file

@ -18,6 +18,12 @@ fix32 screenX;
fix32 playerSpeed;
fix32 playerVelX;
bool bombUsing;
u8 bombFlashClock;
u16 storedPal0[16];
u16 storedPal1[16];
u16 storedPal2[16];
static void movePlayer(){
player.vel.y = 0;
@ -93,8 +99,8 @@ static void cameraPlayer(){
}
static void shootPlayer(){
s16 interval = (player.activePowerup == 2) ? (SHOT_INTERVAL / 2) : SHOT_INTERVAL;
if(ctrl.a && shotClock == 0){
// fix32 bulletVelX = (player.shotAngle == 0 ? PLAYER_SHOT_SPEED : -PLAYER_SHOT_SPEED) + (player.vel.x * 3);
struct bulletSpawner spawner = {
.x = player.pos.x,
.y = player.pos.y,
@ -109,11 +115,67 @@ static void shootPlayer(){
if(bullets[i].clock == 4) killBullet(i, TRUE);
}
spawnBullet(spawner, updater);
// spread shot: 2 additional bullets at ±15°
if(player.activePowerup == 1){
for(s16 s = -1; s <= 1; s += 2){
s16 spreadAngle = F16_normalizeAngle(player.shotAngle + FIX16(15) * s);
struct bulletSpawner sp = {
.x = player.pos.x,
.y = player.pos.y,
.anim = 12,
.speed = PLAYER_SHOT_SPEED,
.angle = spreadAngle,
.player = TRUE
};
sp.ints[5] = F32_toInt(F32_mul(F32_cos(spreadAngle), PLAYER_SHOT_SPEED));
void spreadUpdater(s16 j){
bullets[j].vel.x = FIX32(bullets[j].ints[5]) + (player.vel.x << 2);
if(bullets[j].clock == 4) killBullet(j, TRUE);
}
spawnBullet(sp, spreadUpdater);
}
}
sfxPlayerShot();
shotClock = SHOT_INTERVAL;
shotClock = interval;
} else if(shotClock > 0) shotClock--;
}
static void activateBomb(){
// explode all on-screen enemy bullets
for(s16 i = 0; i < BULLET_COUNT; i++){
if(!bullets[i].active || bullets[i].player) continue;
fix32 dx = getWrappedDelta(bullets[i].pos.x, player.pos.x);
if(dx >= -CULL_LIMIT && dx <= CULL_LIMIT){
score += BOMB_BULLET_SCORE;
killBullet(i, TRUE);
}
}
// damage on-screen enemies
for(s16 i = 0; i < ENEMY_COUNT; i++){
if(!enemies[i].active || !enemies[i].onScreen) continue;
s16 dmg = (enemies[i].type == ENEMY_TYPE_BOSS) ? BOMB_BOSS_DAMAGE : BOMB_DAMAGE;
for(s16 d = 0; d < dmg; d++){
if(!enemies[i].active) break;
killEnemy(i);
}
}
// i-frames (no flash)
player.recoveringClock = BOMB_IFRAMES;
player.recoverFlash = FALSE;
// screen flash
bombFlashClock = 4;
// save current palettes for restore
memcpy(storedPal0, font.palette->data, 16 * sizeof(u16));
memcpy(storedPal1, shadow.palette->data, 16 * sizeof(u16));
memcpy(storedPal2, bgPal, 16 * sizeof(u16));
u16 whitePal[48];
for(s16 i = 0; i < 48; i++) whitePal[i] = 0x0EEE;
PAL_setColors(0, whitePal, 48, CPU);
sfxExplosion();
}
static s16 attractXClock = 0;
static s16 attractXState = 0; // 0=moving, 1=paused
static s16 attractXDir = 1; // 1=right, -1=left
@ -186,7 +248,36 @@ void updatePlayer(){
waitForRelease = FALSE;
return;
}
// bomb flash restore
if(bombFlashClock > 0){
bombFlashClock--;
if(bombFlashClock == 0){
PAL_setColors(0, storedPal0, 16, CPU);
PAL_setColors(16, storedPal1, 16, CPU);
PAL_setColors(32, storedPal2, 16, CPU);
}
}
// powerup timer tick
if(player.powerupClock > 0){
player.powerupClock--;
if(player.powerupClock == 0) player.activePowerup = 0;
}
if(player.shieldClock > 0){
player.shieldClock--;
if(player.shieldClock == 0){
player.hasShield = FALSE;
removeShieldVisual();
}
}
if(!gameOver){
// bomb activation
if(ctrl.b && !bombUsing && player.bombCount > 0 && player.respawnClock == 0){
bombUsing = TRUE;
player.bombCount--;
activateBomb();
}
if(!ctrl.b) bombUsing = FALSE;
if(player.respawnClock > 0){
// kill momentum
playerVelX = 0;
@ -203,6 +294,7 @@ void updatePlayer(){
s16 sx = getScreenX(player.pos.x, player.camera);
s16 sy = F32_toInt(player.pos.y);
SPR_setPosition(player.image, sx - PLAYER_OFF, sy - PLAYER_OFF);
// shield visual is part of player.image now, hidden along with it
player.respawnClock--;
if(player.respawnClock == 0){
SPR_setVisibility(player.image, VISIBLE);
@ -227,6 +319,11 @@ void updatePlayer(){
s16 sx = getScreenX(player.pos.x, player.camera);
s16 sy = F32_toInt(player.pos.y);
SPR_setPosition(player.image, sx - PLAYER_OFF, sy - PLAYER_OFF);
// shield blink: alternate between shield and momoyo sprites in final 150 frames
if(player.hasShield && player.shieldClock <= 150){
SpriteDefinition const* def = (player.shieldClock / 6) % 2 == 0 ? &momoyoSprite : &shieldSprite;
SPR_setDefinition(player.image, def);
}
if(player.pendingShow){
SPR_setVisibility(player.image, VISIBLE);
player.pendingShow = FALSE;

View file

@ -13,42 +13,9 @@
// =============================================================================
#define LEVEL_COUNT 15
#define TP_POOL_SIZE 5
// pool index -> enemy type mapping
static const u8 poolTypeMap[TP_POOL_SIZE] = {
ENEMY_TYPE_TEST, // 0: Fairy
ENEMY_TYPE_DRONE, // 1: Drone
ENEMY_TYPE_GUNNER, // 2: Gunner
ENEMY_TYPE_HUNTER, // 3: Hunter
ENEMY_TYPE_BUILDER, // 4: Builder
};
// TP costs per pool index
static const u8 typeCost[TP_POOL_SIZE] = { 5, 2, 4, 3, 3 };
static const u8 typeWeight[TP_POOL_SIZE] = { 2, 8, 4, 3, 3 };
static const u8 typeMaxCount[TP_POOL_SIZE] = { 3, 16, 6, 6, 2 };
static const u8 typeMinCount[TP_POOL_SIZE] = { 0, 2, 0, 0, 0 };
// Boss HP per boss number (0-4)
static const s16 bossHpTable[5] = { 24, 50, 75, 100, 125 };
// Returns bitmask of unlocked pool indices for a given level
static u8 getUnlockedTypes(u8 lvl){
u8 mask = 0;
// Drone always unlocked
mask |= (1 << 1);
// Gunner from L2 (index 1)
if(lvl >= 1) mask |= (1 << 2);
// Fairy + Builder from L4 (index 3)
if(lvl >= 3){
mask |= (1 << 0);
mask |= (1 << 4);
}
// Hunter from L7 (index 6)
if(lvl >= 6) mask |= (1 << 3);
return mask;
}
static const s16 bossHpTable[5] = { 20, 40, 60, 80, 120 };
static u8 getTreasureCount(u8 lvl){
if(lvl == 0) return 4;
@ -56,22 +23,6 @@ static u8 getTreasureCount(u8 lvl){
return 8;
}
static void assignGunnerPatterns(u8 lvl){
u8 pat;
if(lvl < 3) pat = 0; // Cycle 1: radial burst
else if(lvl < 6) pat = 1; // Cycle 2: aimed fan
else pat = 2; // Cycle 3+: mix
for(s16 i = 0; i < ENEMY_COUNT; i++){
if(enemies[i].active && enemies[i].type == ENEMY_TYPE_GUNNER){
if(pat == 2)
enemies[i].ints[0] = random() % 2;
else
enemies[i].ints[0] = pat;
}
}
}
static void distributeEnemies(u8 type, u8 count){
for(u8 i = 0; i < count; i++){
u8 zone = i % SECTION_COUNT;
@ -79,34 +30,28 @@ static void distributeEnemies(u8 type, u8 count){
}
}
// Generate enemy counts into the provided array (indexed by pool index)
// Generate enemy counts into the provided array (indexed by enemyTypeDefs index)
static void generateLevel(u8 lvl, u8* counts){
for(u8 i = 0; i < TP_POOL_SIZE; i++) counts[i] = 0;
// L1 special case: 4 non-shooting drones
if(lvl == 0){
counts[1] = 4;
return;
}
for(u8 i = 0; i < ENEMY_TYPE_COUNT; i++) counts[i] = 0;
// Compute budget
u16 budget = 8 + (lvl * 4) + (lvl * lvl / 5);
u16 budget = TP_BASE + (lvl * TP_LINEAR) + (lvl * lvl / TP_QUADRATIC);
budget = (budget * (90 + (random() % 21))) / 100;
// Boss levels get 40% escort budget
// Boss levels get reduced escort budget
if(isBossLevel(lvl)){
budget = (budget * 40) / 100;
budget = (budget * TP_BOSS_PCT) / 100;
if(budget < 4) budget = 4;
}
u8 unlocked = getUnlockedTypes(lvl);
u8 maxTotal = isBossLevel(lvl) ? ENEMY_COUNT - 1 : ENEMY_COUNT; // reserve 1 slot for boss
bool boss = isBossLevel(lvl);
u8 maxTotal = boss ? ENEMY_COUNT - 1 : ENEMY_COUNT; // reserve 1 slot for boss
// Apply minimum guarantees
for(u8 i = 0; i < TP_POOL_SIZE; i++){
if((unlocked & (1 << i)) && typeMinCount[i] > 0){
counts[i] = typeMinCount[i];
u16 cost = typeMinCount[i] * typeCost[i];
for(u8 i = 0; i < ENEMY_TYPE_COUNT; i++){
if(lvl >= enemyTypeDefs[i].unlockLevel && enemyTypeDefs[i].minCount > 0){
counts[i] = enemyTypeDefs[i].minCount;
u16 cost = enemyTypeDefs[i].minCount * enemyTypeDefs[i].cost;
if(cost <= budget) budget -= cost;
else budget = 0;
}
@ -115,22 +60,18 @@ static void generateLevel(u8 lvl, u8* counts){
// Shopping loop
u16 safety = 0;
u8 totalEnemies = 0;
for(u8 i = 0; i < TP_POOL_SIZE; i++) totalEnemies += counts[i];
for(u8 i = 0; i < ENEMY_TYPE_COUNT; i++) totalEnemies += counts[i];
while(budget >= 2 && totalEnemies < maxTotal && safety < 100){
safety++;
// Boss escort restriction: only drones + builders
u8 escortMask = isBossLevel(lvl) ? ((1 << 1) | (1 << 4)) : 0xFF;
// Build weighted pool of affordable, unlocked, non-maxed types
u16 totalWeight = 0;
for(u8 i = 0; i < TP_POOL_SIZE; i++){
if(!(unlocked & (1 << i))) continue;
if(!(escortMask & (1 << i))) continue;
if(counts[i] >= typeMaxCount[i]) continue;
if(typeCost[i] > budget) continue;
totalWeight += typeWeight[i];
for(u8 i = 0; i < ENEMY_TYPE_COUNT; i++){
if(lvl < enemyTypeDefs[i].unlockLevel) continue;
if(counts[i] >= enemyTypeDefs[i].maxCount) continue;
if(enemyTypeDefs[i].cost > budget) continue;
totalWeight += enemyTypeDefs[i].weight;
}
if(totalWeight == 0) break;
@ -138,12 +79,12 @@ static void generateLevel(u8 lvl, u8* counts){
u16 roll = random() % totalWeight;
u16 accum = 0;
u8 picked = 0xFF;
for(u8 i = 0; i < TP_POOL_SIZE; i++){
if(!(unlocked & (1 << i))) continue;
if(!(escortMask & (1 << i))) continue;
if(counts[i] >= typeMaxCount[i]) continue;
if(typeCost[i] > budget) continue;
accum += typeWeight[i];
for(u8 i = 0; i < ENEMY_TYPE_COUNT; i++){
if(lvl < enemyTypeDefs[i].unlockLevel) continue;
if(boss && enemyTypeDefs[i].type != ENEMY_TYPE_TWO && enemyTypeDefs[i].type != ENEMY_TYPE_FIVE) continue;
if(counts[i] >= enemyTypeDefs[i].maxCount) continue;
if(enemyTypeDefs[i].cost > budget) continue;
accum += enemyTypeDefs[i].weight;
if(roll < accum){
picked = i;
break;
@ -152,7 +93,7 @@ static void generateLevel(u8 lvl, u8* counts){
if(picked == 0xFF) break;
counts[picked]++;
budget -= typeCost[picked];
budget -= enemyTypeDefs[picked].cost;
totalEnemies++;
}
}
@ -161,20 +102,18 @@ void loadLevel(u8 lvl){
if(lvl >= LEVEL_COUNT) lvl = LEVEL_COUNT - 1;
level = lvl;
grazeCount = 0;
levelPerfect = TRUE;
// Generate enemy composition
u8 counts[TP_POOL_SIZE];
u8 counts[ENEMY_TYPE_COUNT];
generateLevel(lvl, counts);
// Spawn enemies by type
for(u8 i = 0; i < TP_POOL_SIZE; i++){
for(u8 i = 0; i < ENEMY_TYPE_COUNT; i++){
if(counts[i] > 0)
distributeEnemies(poolTypeMap[i], counts[i]);
distributeEnemies(enemyTypeDefs[i].type, counts[i]);
}
// Assign gunner patterns
assignGunnerPatterns(lvl);
// Boss spawn
if(isBossLevel(lvl)){
pendingBossNum = lvl / 3;
@ -183,13 +122,15 @@ void loadLevel(u8 lvl){
spawnEnemy(ENEMY_TYPE_BOSS, 1);
}
// Spawn treasures
u8 treasureToSpawn = getTreasureCount(lvl);
for(u8 zone = 0; zone < SECTION_COUNT && treasureToSpawn > 0; zone++){
u8 perZone = treasureToSpawn >= 4 ? 2 : 1;
for(u8 h = 0; h < perZone && treasureToSpawn > 0; h++){
spawnTreasure(zone);
treasureToSpawn--;
// Spawn treasures (not on boss levels)
if(!isBossLevel(lvl)){
u8 treasureToSpawn = getTreasureCount(lvl);
for(u8 zone = 0; zone < SECTION_COUNT && treasureToSpawn > 0; zone++){
u8 perZone = treasureToSpawn >= 4 ? 2 : 1;
for(u8 h = 0; h < perZone && treasureToSpawn > 0; h++){
spawnTreasure(zone);
treasureToSpawn--;
}
}
}

View file

@ -115,7 +115,9 @@ static void updateTreasure(u8 i){
if(treasures[i].state != TREASURE_CARRIED && treasures[i].state != TREASURE_COLLECTED){
fix32 dy = treasures[i].pos.y - player.pos.y;
if(dx >= FIX32(-32) && dx <= FIX32(32) && dy >= FIX32(-32) && dy <= FIX32(32)){
score += (treasures[i].state == TREASURE_FALLING) ? 4096 : 1024;
u32 pts = (treasures[i].state == TREASURE_FALLING) ? 4096 : 1024;
score += pts;
spawnPopup(treasures[i].pos.x, treasures[i].pos.y, pts);
// check if this is the last treasure (all others inactive or collected)
bool willBeLast = TRUE;
for(s16 j = 0; j < TREASURE_COUNT; j++){