lots of work

This commit is contained in:
t. boddy 2026-02-18 17:58:41 -05:00
parent 4036b5f07e
commit 06648c2dc1
18 changed files with 640 additions and 113 deletions

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

View file

@ -31,3 +31,5 @@ IMAGE imageFontBig "fontbig.png" NONE NONE
IMAGE imageFontBigShadow "fontbigshadow.png" NONE NONE
IMAGE imageChromeLife "life.png" NONE NONE
IMAGE imageChromeLife2 "life2.png" NONE NONE
XGM2 stageMusic "stage.vgm"

BIN
res/stage.vgm Normal file

Binary file not shown.

View file

@ -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);
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;
}
}

View file

@ -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;

View file

@ -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();
}

View file

@ -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++;

View file

@ -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,45 +390,112 @@ 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
void loadBoss(u8 i){
enemies[i].hp = pendingBossHp > 0 ? pendingBossHp : 10;
pendingBossHp = 0;
enemies[i].ints[1] = 0;
enemies[i].ints[2] = 0;
enemies[i].angle = random() % 1024;
enemies[i].speed = FIX32(1);
}
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){
// =============================================================================
// 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 = FIX32(3),
.angle = baseAngle,
.x = enemies[i].pos.x, .y = enemies[i].pos.y,
.anim = 3 + (random() % 3), .speed = speed, .angle = baseAngle,
};
for(u8 j = 0; j < 12; j++){
s16 step = 1024 / count;
for(u8 j = 0; j < count; j++){
spawnBullet(spawner, EMPTY);
spawner.angle += 85;
spawner.angle += step;
}
}
} else {
// Pattern B: Aimed wide fan - 8 bullets every 40 frames
if(enemies[i].ints[1] % 40 == 0){
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;
@ -320,16 +503,130 @@ void updateBoss(u8 i){
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,
.x = enemies[i].pos.x, .y = enemies[i].pos.y,
.anim = 3 + (random() % 9), .speed = speed, .angle = aimAngle,
};
for(u8 j = 0; j < 8; j++){
for(u8 j = 0; j < count; j++){
spawner.angle = aimAngle - 128 + (random() % 256);
spawner.speed = speed - FIX32(random() % 2);
spawnBullet(spawner, EMPTY);
spawner.angle += 32;
}
}
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 : 25;
pendingBossHp = 0;
enemies[i].ints[0] = pendingBossNum;
pendingBossNum = 0;
enemies[i].ints[1] = 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;
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;
}
}

View file

@ -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;

View file

@ -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(){

View file

@ -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();
}
}

View file

@ -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)

View file

@ -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)
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);
}