cenotaph/src/global.h
Trevor Boddy 073f96c9b1 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
2026-04-15 08:19:29 -04:00

483 lines
No EOL
13 KiB
C

// forward declarations (defined in sfx.h, chrome.h, main.c)
void sfxPlayerShot();
void sfxEnemyShotA();
void sfxEnemyShotB();
void sfxEnemyShotC();
void sfxExplosion();
void sfxPickup();
void sfxGraze();
void sfxPlayerHit();
void sfxCollectAllTreasures();
void loadMap();
void loadGame();
#define SKIP_START 1
#define SKIP_TO_BONUS 0 // 1 = boot straight into bonus stage for testing (0 for release)
u32 clock;
#define CLOCK_LIMIT 32000
#define PROP_COUNT 8
#define GAME_H_F FIX32(224)
#define SECTION_SIZE FIX32(512)
#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 50
u32 score;
u32 highScore;
u32 tempHighScore;
u32 grazeCount;
bool levelPerfect;
u32 nextExtendScore;
#define EXTEND_SCORE 25000
#define SCORE_LENGTH 8
#define GRAZE_RADIUS 16
#define SCORE_SRAM 0x0033
void getHighScore(){
SRAM_enable();
tempHighScore = SRAM_readLong(SCORE_SRAM);
if(tempHighScore > 0 && tempHighScore < 1000000) highScore = tempHighScore;
SRAM_disable();
}
static void saveHighScore(){
SRAM_enable();
SRAM_writeLong(SCORE_SRAM, highScore);
SRAM_disable();
}
#define FIRST_ROTATING_BULLET 3
#define MAP_X 1
#define MAP_Y 1
#define MAP_W 38
#define MAP_H 3
#define MAP_SCALE (F32_toInt(GAME_WRAP) / MAP_W)
void EMPTY(s16 i){(void)i;}
bool started;
bool gameOver;
bool paused, isPausing;
bool isAttract;
u16 attractClock;
#define ATTRACT_LIMIT 900 // frames of title idle before attract triggers
#define ATTRACT_DURATION 1800 // 30 seconds of demo gameplay
#define ATTRACT_LEVEL 1 // level index for attract mode (L12: Boss 4, 3 gunners)
#define START_LEVEL 0 // offset added to starting level (0 = normal start)
// #define START_LEVEL 0 // offset added to starting level (0 = normal start)
s16 enemyCount, bulletCount;
u8 level;
s16 pendingBossHp;
s16 pendingBossNum;
bool waitForRelease;
s16 treasureCollectedClock;
u8 treasureCollectedType;
bool allTreasureCollected;
u8 hitMessageClock;
bool hitMessageBullet; // TRUE = blasted, FALSE = smashed
bool levelClearing;
u32 levelClearClock;
u8 levelWaitClock;
bool bonusStage;
// controls
struct controls {
bool left, right, up, down, a, b, c, start;
};
struct controls ctrl;
struct controls ctrlHW; // hardware-only copy — never overridden by AI
void updateControls(u16 joy, u16 changed, u16 state){
(void)changed; // Unused parameter
if(joy == JOY_1){
ctrl.left = (state & BUTTON_LEFT);
ctrl.right = (state & BUTTON_RIGHT);
ctrl.up = (state & BUTTON_UP);
ctrl.down = (state & BUTTON_DOWN);
ctrl.a = (state & BUTTON_A);
ctrl.b = (state & BUTTON_B);
ctrl.c = (state & BUTTON_C);
ctrl.start = (state & BUTTON_START);
ctrlHW = ctrl;
}
}
// player
struct playerStruct {
Vect2D_f32 pos, vel;
s16 shotAngle;
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;
};
struct playerStruct player;
fix32 playerScrollVelY; // player.vel.y zeroed when clamped at top/bottom bound
bool killBullets;
// bullets
#define BULLET_COUNT 70
struct bulletSpawner {
fix32 x, y, speed;
Vect2D_f32 vel;
s16 angle, anim, frame;
s16 ints[PROP_COUNT];
bool top, player;
};
struct bullet {
fix32 speed;
bool active, player, vFlip, hFlip, grazed;
Vect2D_f32 pos, vel;
Sprite* image;
s16 clock, angle, anim, frame;
s16 dist;
s16 ints[PROP_COUNT];
void (*updater)(s16);
};
struct bullet bullets[BULLET_COUNT];
// enemies
#define ENEMY_COUNT 24
#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;
u8 type;
s16 hp, frame, anim;
s16 angle, off;
u32 clock;
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];
};
struct enemy enemies[ENEMY_COUNT];
// treasure
#define TREASURE_COUNT 8
#define TREASURE_WALKING 0
#define TREASURE_CARRIED 1
#define TREASURE_FALLING 2
#define TREASURE_COLLECTED 3
struct treasure {
bool active;
u8 state;
u8 type;
s16 carriedBy;
s16 trailIndex;
Vect2D_f32 pos, vel;
Sprite* image;
};
struct treasure treasures[TREASURE_COUNT];
bool treasureBeingCarried;
s16 collectedCount;
u16 levelEnemiesKilled;
u16 statEnemiesKilled;
s16 statTreasures;
void removeShieldVisual(){
SPR_setDefinition(player.image, &momoyoSprite);
}
// 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
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) {
fix32 delta = a - b;
if (delta > GAME_WRAP / 2) {
delta -= GAME_WRAP;
} else if (delta < -GAME_WRAP / 2) {
delta += GAME_WRAP;
}
return delta;
}
static s16 getScreenX(fix32 worldX, fix32 camera) {
fix32 screenX = worldX - camera;
if (screenX < -(GAME_WRAP / 2)) {
screenX += GAME_WRAP;
} else if (screenX > (GAME_WRAP / 2)) {
screenX -= GAME_WRAP;
}
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){
s16 ix = (s16)(F32_toInt(dx) >> 2);
s16 iy = (s16)(F32_toInt(dy) >> 2);
if(ix == 0 && iy == 0) return 0;
return F16_normalizeAngle(F16_atan2(FIX16(iy), FIX16(ix)));
}
// safe angle accumulation -- keeps angle in [-180, 180) so adding up to 180° can't overflow s16
static s16 angleAdd(s16 a, s16 step){
if(a >= FIX16(180)) a -= FIX16(360);
return a + step;
}
static bool isBossLevel(u8 lvl){
return (lvl >= 2) && ((lvl + 1) % 3 == 0);
}
#define FONT_THEME_RED 0
#define FONT_THEME_GREEN 1
#define FONT_THEME_BLUE 2
u16 fontPal[16];
void loadFontPalette(u8 theme) {
u16 coloredPalette[16];
u8 i;
for(i = 0; i < 16; i++) {
u16 color = font.palette->data[i];
u16 r = color & 0xF;
u16 g = (color >> 4) & 0xF;
u16 b = (color >> 8) & 0xF;
switch(theme) {
case FONT_THEME_GREEN:
coloredPalette[i] = (b << 8) | (r << 4) | g;
break;
case FONT_THEME_BLUE: {
u16 newB = r > b ? r : b;
coloredPalette[i] = (newB << 8) | (g << 4) | (r >> 1);
break;
}
default: // FONT_THEME_RED
coloredPalette[i] = color;
break;
}
}
memcpy(fontPal, coloredPalette, 16 * sizeof(u16));
PAL_setPalette(PAL3, coloredPalette, DMA_QUEUE);
VDP_setTextPalette(PAL3);
}