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:
parent
3263b2597b
commit
073f96c9b1
25 changed files with 2320 additions and 1186 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -17,4 +17,5 @@ dist/
|
||||||
**/**.psd
|
**/**.psd
|
||||||
*.pdf
|
*.pdf
|
||||||
**/**.pdf
|
**/**.pdf
|
||||||
docs/
|
docs/
|
||||||
|
sgdk/
|
||||||
13
build.sh
13
build.sh
|
|
@ -1,7 +1,6 @@
|
||||||
rm -rf res/resources.o res/resources.h out/*
|
#!/bin/bash
|
||||||
# make
|
set -e
|
||||||
# ./blastem/blastem out.bin
|
|
||||||
#dgen out.bin
|
# Build and launch emulator
|
||||||
docker run --rm -v $PWD:/m68k -t registry.gitlab.com/doragasu/docker-sgdk:v2.11
|
./compile.sh
|
||||||
# /Applications/Genesis\ Plus.app/Contents/MacOS/Genesis\ Plus out/rom.bin
|
/Applications/Genesis\ Plus.app/Contents/MacOS/Genesis\ Plus out/rom.bin
|
||||||
/Applications/ares.app/Contents/MacOS/ares out/rom.bin --system "Mega Drive"
|
|
||||||
|
|
|
||||||
60
compile.sh
60
compile.sh
|
|
@ -1,2 +1,58 @@
|
||||||
rm -rf res/resources.o res/resources.h out/*
|
#!/bin/bash
|
||||||
docker run --rm -v $PWD:/m68k -t registry.gitlab.com/doragasu/docker-sgdk:v2.11
|
set -e
|
||||||
|
|
||||||
|
# Native SGDK compile script (no Docker)
|
||||||
|
# Requires: brew install m68k-elf-gcc, java
|
||||||
|
|
||||||
|
GDK=sgdk
|
||||||
|
BIN=$GDK/bin
|
||||||
|
LIB=$GDK/lib
|
||||||
|
INC=$GDK/inc
|
||||||
|
RES_LIB=$GDK/res
|
||||||
|
SRC_LIB=$GDK/src
|
||||||
|
|
||||||
|
CC=m68k-elf-gcc
|
||||||
|
OBJCPY=m68k-elf-objcopy
|
||||||
|
NM=m68k-elf-nm
|
||||||
|
JAVA=java
|
||||||
|
RESCOMP="$JAVA -jar $BIN/rescomp.jar"
|
||||||
|
SIZEBND="$JAVA -jar $BIN/sizebnd.jar"
|
||||||
|
|
||||||
|
INCS="-I. -Iinc -Isrc -Ires -Iout -I$INC -I$RES_LIB"
|
||||||
|
FLAGS="-DSGDK_GCC -m68000 -Wall -Wextra -Wno-shift-negative-value -Wno-main -Wno-unused-parameter -Wno-implicit-function-declaration -fno-builtin -ffunction-sections -fdata-sections -fms-extensions $INCS -B$BIN"
|
||||||
|
RELEASE_FLAGS="$FLAGS -O3 -fuse-linker-plugin -fno-web -fno-gcse -fomit-frame-pointer -flto -flto=auto -ffat-lto-objects"
|
||||||
|
|
||||||
|
# Clean
|
||||||
|
rm -rf res/resources.rs res/resources.h res/resources.o out/*
|
||||||
|
mkdir -p out
|
||||||
|
|
||||||
|
echo "==> Compiling resources..."
|
||||||
|
$RESCOMP res/resources.res out/resources.rs -dep out/resources.d
|
||||||
|
|
||||||
|
echo "==> Compiling rom_head..."
|
||||||
|
$CC $FLAGS -c src/boot/rom_head.c -o out/rom_head.o
|
||||||
|
$OBJCPY -O binary out/rom_head.o out/rom_head.bin
|
||||||
|
|
||||||
|
echo "==> Compiling sega.s..."
|
||||||
|
$CC -x assembler-with-cpp -Wa,--register-prefix-optional,--bitwise-or $RELEASE_FLAGS -c src/boot/sega.s -o out/sega.o
|
||||||
|
|
||||||
|
echo "==> Compiling resources assembly..."
|
||||||
|
$CC -x assembler-with-cpp -Wa,--register-prefix-optional,--bitwise-or $RELEASE_FLAGS -c out/resources.rs -o out/resources.o
|
||||||
|
|
||||||
|
echo "==> Compiling main.c..."
|
||||||
|
$CC $RELEASE_FLAGS -c src/main.c -o out/main.o
|
||||||
|
|
||||||
|
echo "==> Linking..."
|
||||||
|
$CC -m68000 -B$BIN -n -T $GDK/md.ld -nostdlib out/sega.o out/resources.o out/main.o $LIB/libmd.a -lgcc -o out/rom.out -Wl,--gc-sections -flto -flto=auto -ffat-lto-objects
|
||||||
|
|
||||||
|
echo "==> Extracting ROM..."
|
||||||
|
$OBJCPY -O binary out/rom.out out/rom.bin
|
||||||
|
|
||||||
|
echo "==> Generating symbol table..."
|
||||||
|
$NM -n -l out/rom.out > out/symbol.txt
|
||||||
|
|
||||||
|
echo "==> Padding ROM..."
|
||||||
|
$SIZEBND out/rom.bin -sizealign 131072 -checksum
|
||||||
|
|
||||||
|
echo "==> Done! out/rom.bin"
|
||||||
|
ls -la out/rom.bin
|
||||||
|
|
|
||||||
BIN
res/explosions.png
Normal file
BIN
res/explosions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
res/explosionsbig.png
Normal file
BIN
res/explosionsbig.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
res/powerup.png
Normal file
BIN
res/powerup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
|
|
@ -21,7 +21,8 @@ IMAGE door "door.png" NONE NONE
|
||||||
SPRITE momoyoSprite "momoyo.png" 6 6 NONE 0
|
SPRITE momoyoSprite "momoyo.png" 6 6 NONE 0
|
||||||
|
|
||||||
SPRITE bulletsSprite "bullets.png" 2 2 NONE 0
|
SPRITE bulletsSprite "bullets.png" 2 2 NONE 0
|
||||||
SPRITE pBulletSprite "pbullet.png" 4 4 NONE 0
|
SPRITE explosionsSprite "explosions.png" 4 4 NONE 0
|
||||||
|
SPRITE explosionBigSprite "explosionsbig.png" 8 8 NONE 0
|
||||||
|
|
||||||
SPRITE fairySprite "fairy2.png" 4 4 NONE 8
|
SPRITE fairySprite "fairy2.png" 4 4 NONE 8
|
||||||
SPRITE eyeBigSprite "eyebig.png" 4 4 NONE 0
|
SPRITE eyeBigSprite "eyebig.png" 4 4 NONE 0
|
||||||
|
|
@ -32,6 +33,8 @@ SPRITE boss4Sprite "enemies/boss4.png" 6 6 NONE 0
|
||||||
SPRITE treasureSprite "treasure.png" 4 4 NONE 0
|
SPRITE treasureSprite "treasure.png" 4 4 NONE 0
|
||||||
|
|
||||||
SPRITE bonusObjSprite "bonus.png" 2 2 NONE 0
|
SPRITE bonusObjSprite "bonus.png" 2 2 NONE 0
|
||||||
|
SPRITE pickupSprite "powerup.png" 2 2 NONE 0
|
||||||
|
SPRITE shieldSprite "shield.png" 6 6 NONE 0
|
||||||
TILESET bonusBgTiles "bonusbg.png" NONE
|
TILESET bonusBgTiles "bonusbg.png" NONE
|
||||||
|
|
||||||
IMAGE mapIndicator "mapindicator.png" NONE NONE
|
IMAGE mapIndicator "mapindicator.png" NONE NONE
|
||||||
|
|
|
||||||
BIN
res/shield.png
Normal file
BIN
res/shield.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
4
run.sh
4
run.sh
|
|
@ -1,2 +1,2 @@
|
||||||
#/Applications/Genesis\ Plus.app/Contents/MacOS/Genesis\ Plus out/rom.bin
|
/Applications/Genesis\ Plus.app/Contents/MacOS/Genesis\ Plus out/rom.bin
|
||||||
/Applications/ares.app/Contents/MacOS/ares out/rom.bin --system "Mega Drive"
|
# /Applications/ares.app/Contents/MacOS/ares out/rom.bin --system "Mega Drive"
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
./blastem/blastem out/rom.bin
|
|
||||||
|
|
@ -26,7 +26,7 @@ void loadBackground(){
|
||||||
VDP_loadTileSet(sky.tileset, BG_I + 64, DMA);
|
VDP_loadTileSet(sky.tileset, BG_I + 64, DMA);
|
||||||
VDP_loadTileSet(ground.tileset, BG_I + 128, DMA);
|
VDP_loadTileSet(ground.tileset, BG_I + 128, DMA);
|
||||||
VDP_loadTileSet(skyRed.tileset, BG_I + 192, 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 y = 0; y < 14; y++){
|
||||||
// for(u8 x = 0; x < 64; x++){
|
// 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
|
// // place one door per zone at a random position within each zone's unique col range
|
||||||
for(u8 d = 0; d < DOOR_COUNT; d++){
|
// for(u8 d = 0; d < DOOR_COUNT; d++){
|
||||||
doorWorldX[d] = FIX32(d * 512 + 8 + (random() % 31) * 8);
|
// doorWorldX[d] = FIX32(d * 512 + 8 + (random() % 31) * 8);
|
||||||
u8 col = (u8)(F32_toInt(doorWorldX[d]) / 8 % 128);
|
// 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);
|
// VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, BG_I + 256), col, ZONE_BLOCK_ROW, 8, 8);
|
||||||
doorVisible[d] = TRUE;
|
// doorVisible[d] = TRUE;
|
||||||
}
|
// }
|
||||||
prevCamera = player.camera;
|
prevCamera = player.camera;
|
||||||
for(u8 i = 0; i < PARALLAX_COUNT; i++)
|
for(u8 i = 0; i < PARALLAX_COUNT; i++)
|
||||||
parallaxAccum[i] = F32_mul(player.camera + FIX32(256), parallaxMul[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);
|
VDP_setHorizontalScrollTile(BG_B, 0, bgScroll, 28, DMA);
|
||||||
|
|
||||||
// show/hide each door based on proximity to camera center
|
// // show/hide each door based on proximity to camera center
|
||||||
for(u8 d = 0; d < DOOR_COUNT; d++){
|
// for(u8 d = 0; d < DOOR_COUNT; d++){
|
||||||
fix32 dx = getWrappedDelta(doorWorldX[d] + FIX32(32), player.camera + FIX32(160));
|
// fix32 dx = getWrappedDelta(doorWorldX[d] + FIX32(32), player.camera + FIX32(160));
|
||||||
bool shouldShow = (dx > FIX32(-212) && dx < FIX32(212));
|
// bool shouldShow = (dx > FIX32(-212) && dx < FIX32(212));
|
||||||
u8 col = (u8)(F32_toInt(doorWorldX[d]) / 8 % 128);
|
// u8 col = (u8)(F32_toInt(doorWorldX[d]) / 8 % 128);
|
||||||
if(shouldShow && !doorVisible[d]){
|
// if(shouldShow && !doorVisible[d]){
|
||||||
VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, BG_I + 256), col, ZONE_BLOCK_ROW, 8, 8);
|
// VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, BG_I + 256), col, ZONE_BLOCK_ROW, 8, 8);
|
||||||
doorVisible[d] = TRUE;
|
// doorVisible[d] = TRUE;
|
||||||
} else if(!shouldShow && doorVisible[d]){
|
// } 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);
|
// VDP_fillTileMapRectInc(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, BG_I + 64), col, ZONE_BLOCK_ROW, 8, 8);
|
||||||
doorVisible[d] = FALSE;
|
// doorVisible[d] = FALSE;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
#define BG_THEME_RED 0
|
#define BG_THEME_RED 0
|
||||||
|
|
|
||||||
21
src/bonus.h
21
src/bonus.h
|
|
@ -211,6 +211,9 @@ static void updateBonusObj(u8 i){
|
||||||
if(distSq < collDist * collDist){
|
if(distSq < collDist * collDist){
|
||||||
if(obj->isBomb){
|
if(obj->isBomb){
|
||||||
sfxPlayerHit();
|
sfxPlayerHit();
|
||||||
|
sfxExplosion();
|
||||||
|
spawnExplosion(player.camera + FIX32(bonusCursorX), FIX32(bonusCursorY), 3, TRUE);
|
||||||
|
XGM2_stop();
|
||||||
bonusExiting = TRUE;
|
bonusExiting = TRUE;
|
||||||
bonusExitClock = 0;
|
bonusExitClock = 0;
|
||||||
if(bonusCursor) SPR_setVisibility(bonusCursor, HIDDEN);
|
if(bonusCursor) SPR_setVisibility(bonusCursor, HIDDEN);
|
||||||
|
|
@ -305,7 +308,7 @@ void loadBonus(u8 variant){
|
||||||
drawBonusStars();
|
drawBonusStars();
|
||||||
|
|
||||||
#if MUSIC_VOLUME > 0
|
#if MUSIC_VOLUME > 0
|
||||||
XGM2_play(treasureMusic);
|
XGM2_play(bossMusic);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,6 +351,10 @@ void updateBonus(){
|
||||||
|
|
||||||
bonusCl++;
|
bonusCl++;
|
||||||
|
|
||||||
|
// Clear "Entering Bonus Level" text after intro
|
||||||
|
if(bonusCl == 90)
|
||||||
|
VDP_clearText(10, 14, 20);
|
||||||
|
|
||||||
// Alternate bg variant every 60 frames
|
// Alternate bg variant every 60 frames
|
||||||
if(starAlternate && bonusCl % 30 == 0)
|
if(starAlternate && bonusCl % 30 == 0)
|
||||||
starVariant ^= 1;
|
starVariant ^= 1;
|
||||||
|
|
@ -365,6 +372,9 @@ void updateBonus(){
|
||||||
VDP_drawText("Bonus", 14, 16);
|
VDP_drawText("Bonus", 14, 16);
|
||||||
VDP_drawText(bonusStr, 20, 16);
|
VDP_drawText(bonusStr, 20, 16);
|
||||||
sfxCollectAllTreasures();
|
sfxCollectAllTreasures();
|
||||||
|
#if MUSIC_VOLUME > 0
|
||||||
|
XGM2_play(treasureMusic);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
if(bonusExitClock == 220)
|
if(bonusExitClock == 220)
|
||||||
PAL_fadeOut(0, 31, 20, TRUE);
|
PAL_fadeOut(0, 31, 20, TRUE);
|
||||||
|
|
@ -381,8 +391,11 @@ void updateBonus(){
|
||||||
fix16 diff = targetAngle - bonusAngle;
|
fix16 diff = targetAngle - bonusAngle;
|
||||||
if(diff > FIX16(180)) diff -= FIX16(360);
|
if(diff > FIX16(180)) diff -= FIX16(360);
|
||||||
if(diff < FIX16(-180)) diff += FIX16(360);
|
if(diff < FIX16(-180)) diff += FIX16(360);
|
||||||
// Determine target angular velocity from shortest-path direction
|
// Determine target angular velocity — proportional when close to prevent overshoot oscillation
|
||||||
fix16 targetVel = (diff > 0) ? BONUS_ANGLE_SPEED : -BONUS_ANGLE_SPEED;
|
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
|
// Accelerate toward target velocity
|
||||||
if(bonusAngleVel < targetVel){
|
if(bonusAngleVel < targetVel){
|
||||||
bonusAngleVel += BONUS_ANGLE_ACCEL;
|
bonusAngleVel += BONUS_ANGLE_ACCEL;
|
||||||
|
|
@ -462,6 +475,8 @@ void clearBonus(){
|
||||||
}
|
}
|
||||||
bonusObjs[i].active = FALSE;
|
bonusObjs[i].active = FALSE;
|
||||||
}
|
}
|
||||||
|
// Clear any active explosions
|
||||||
|
clearExplosions();
|
||||||
// Clear starfield tiles from BG_B
|
// Clear starfield tiles from BG_B
|
||||||
clearStarfield();
|
clearStarfield();
|
||||||
// Clear bonus text from BG_A
|
// Clear bonus text from BG_A
|
||||||
|
|
|
||||||
|
|
@ -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].vel.y = F32_mul(F32_sin(spawner.angle), spawner.speed);
|
||||||
}
|
}
|
||||||
bullets[i].updater = updater;
|
bullets[i].updater = updater;
|
||||||
bullets[i].explosion = FALSE;
|
|
||||||
bullets[i].grazed = 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
|
// zero out ints array
|
||||||
for(s16 j = 0; j < PROP_COUNT; j++) bullets[i].ints[j] = spawner.ints[j];
|
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){
|
deltaX >= -BULLET_CHECK && deltaX <= BULLET_CHECK){
|
||||||
bulletDist = getApproximatedDistance(F32_toInt(deltaX), F32_toInt(deltaY));
|
bulletDist = getApproximatedDistance(F32_toInt(deltaX), F32_toInt(deltaY));
|
||||||
if(bulletDist <= bullets[i].dist){
|
if(bulletDist <= bullets[i].dist){
|
||||||
score += (enemies[j].ints[3] >= 0) ? 512 : 256;
|
u32 pts = (enemies[j].carriedTreasure >= 0) ? 512 : 256;
|
||||||
killBullet(i, TRUE);
|
score += pts;
|
||||||
|
spawnPopup(enemies[j].pos.x, enemies[j].pos.y, pts);
|
||||||
|
killBullet(i, enemies[j].hp > 1);
|
||||||
killEnemy(j);
|
killEnemy(j);
|
||||||
sfxExplosion();
|
sfxExplosion();
|
||||||
}
|
}
|
||||||
|
|
@ -122,44 +123,42 @@ static void collideWithPlayer(u8 i){
|
||||||
F32_toInt(deltaX),
|
F32_toInt(deltaX),
|
||||||
F32_toInt(deltaY));
|
F32_toInt(deltaY));
|
||||||
if(dist <= 4){
|
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);
|
killBullet(i, FALSE);
|
||||||
s16 expSlot = -1;
|
spawnExplosion(player.pos.x, player.pos.y, expAnim, TRUE);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sfxExplosion();
|
sfxExplosion();
|
||||||
if(!isAttract){
|
if(!isAttract){
|
||||||
player.lives--;
|
if(player.hasShield){
|
||||||
if(player.lives == 0){
|
// shield absorbs hit
|
||||||
gameOver = TRUE;
|
player.hasShield = FALSE;
|
||||||
XGM2_stop();
|
player.shieldClock = 0;
|
||||||
sfxGameOver();
|
removeShieldVisual();
|
||||||
} else {
|
player.recoveringClock = 60;
|
||||||
sfxPlayerHit();
|
player.recoverFlash = TRUE;
|
||||||
player.respawnClock = 120;
|
|
||||||
SPR_setVisibility(player.image, HIDDEN);
|
|
||||||
killBullets = TRUE;
|
killBullets = TRUE;
|
||||||
hitMessageClock = 120;
|
hitMessageClock = 120;
|
||||||
hitMessageBullet = TRUE;
|
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){
|
} 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){
|
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.x += bullets[i].vel.x - (player.vel.x >> 3);
|
||||||
bullets[i].pos.y += bullets[i].vel.y - (playerScrollVelY >> 3);
|
bullets[i].pos.y += bullets[i].vel.y - (playerScrollVelY >> 3);
|
||||||
|
|
||||||
|
|
@ -234,7 +213,7 @@ void updateBullets(){
|
||||||
if(killBullets){
|
if(killBullets){
|
||||||
killBullets = FALSE;
|
killBullets = FALSE;
|
||||||
for(s16 i = 0; i < BULLET_COUNT; i++)
|
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)
|
for(s16 i = 0; i < BULLET_COUNT; i++) if(bullets[i].active)
|
||||||
updateBullet(i);
|
updateBullet(i);
|
||||||
|
|
|
||||||
132
src/chrome.h
132
src/chrome.h
|
|
@ -8,6 +8,66 @@
|
||||||
|
|
||||||
u16 hudPal = PAL0;
|
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
|
#define FONT_BIG_I 340
|
||||||
|
|
||||||
void bigText(char* str, u16 x, u16 y, bool shadow){
|
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);
|
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];
|
u8 phraseIndex[4];
|
||||||
|
|
||||||
s16 lastLevel;
|
s16 lastLevel;
|
||||||
|
|
@ -221,11 +285,42 @@ static void repaintMap(){
|
||||||
VDP_setTileMapXY(BG_A, MAP_PLAYER_TILE, MAP_X + MAP_W / 2, MAP_Y + mapPlayerRow);
|
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(){
|
static void repaintHud(){
|
||||||
bigText(scoreStr, SCORE_X, SCORE_Y, FALSE);
|
bigText(scoreStr, SCORE_X, SCORE_Y, FALSE);
|
||||||
drawLives();
|
drawLives();
|
||||||
repaintMap();
|
repaintMap();
|
||||||
drawLevel();
|
drawLevel();
|
||||||
|
drawBombCount();
|
||||||
|
drawPowerupIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadChrome(){
|
void loadChrome(){
|
||||||
|
|
@ -263,7 +358,7 @@ static void doGameOver(){
|
||||||
void noop(s16 j){ (void)j; }
|
void noop(s16 j){ (void)j; }
|
||||||
spawnBullet(spawner, noop);
|
spawnBullet(spawner, noop);
|
||||||
for(s16 j = BULLET_COUNT - 1; j >= 0; j--){
|
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){
|
&& bullets[j].pos.x == treasures[i].pos.x && bullets[j].pos.y == treasures[i].pos.y){
|
||||||
killBullet(j, TRUE);
|
killBullet(j, TRUE);
|
||||||
break;
|
break;
|
||||||
|
|
@ -273,6 +368,7 @@ static void doGameOver(){
|
||||||
killTreasure(i);
|
killTreasure(i);
|
||||||
}
|
}
|
||||||
SPR_releaseSprite(player.image);
|
SPR_releaseSprite(player.image);
|
||||||
|
clearPickups();
|
||||||
|
|
||||||
// clear lives
|
// clear lives
|
||||||
VDP_clearTileMapRect(BG_A, LIVES_X, LIVES_Y, 1, 16);
|
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 < 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 < 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 < 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);
|
SPR_setPalette(player.image, PAL1);
|
||||||
hudPal = PAL1;
|
hudPal = 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 < 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 < 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 < 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);
|
SPR_setPalette(player.image, PAL0);
|
||||||
hudPal = PAL0;
|
hudPal = PAL0;
|
||||||
repaintHud();
|
repaintHud();
|
||||||
|
|
@ -345,12 +443,13 @@ static void updatePause(){
|
||||||
}
|
}
|
||||||
|
|
||||||
#define TRANSITION_TREASURE_X 10
|
#define TRANSITION_TREASURE_X 10
|
||||||
#define TRANSITION_TREASURE_Y 13
|
#define TRANSITION_TREASURE_Y 15
|
||||||
|
|
||||||
#define TRANSITION_LEVEL_X 12
|
#define TRANSITION_LEVEL_X 12
|
||||||
#define TRANSITION_LEVEL_Y 15
|
#define TRANSITION_LEVEL_Y 13
|
||||||
|
|
||||||
void updateChrome(){
|
void updateChrome(){
|
||||||
|
updatePopups();
|
||||||
updatePause();
|
updatePause();
|
||||||
if(gameOver && !didGameOver) doGameOver();
|
if(gameOver && !didGameOver) doGameOver();
|
||||||
if(didGameOver){
|
if(didGameOver){
|
||||||
|
|
@ -396,12 +495,31 @@ void updateChrome(){
|
||||||
else
|
else
|
||||||
VDP_drawText("Lives Left", TRANSITION_LEVEL_X + 2, TRANSITION_LEVEL_Y + 5);
|
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){
|
if(levelClearClock >= 230){
|
||||||
VDP_clearText(0, TRANSITION_TREASURE_Y, 40);
|
VDP_clearText(0, TRANSITION_TREASURE_Y, 40);
|
||||||
VDP_clearText(0, TRANSITION_LEVEL_Y, 40);
|
VDP_clearText(0, TRANSITION_LEVEL_Y, 40);
|
||||||
VDP_clearText(0, TRANSITION_LEVEL_Y + 3, 40);
|
VDP_clearText(0, TRANSITION_LEVEL_Y + 3, 40);
|
||||||
VDP_clearText(0, TRANSITION_LEVEL_Y + 5, 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -430,6 +548,7 @@ void updateChrome(){
|
||||||
}
|
}
|
||||||
if(allDone && collectedCount > 0){
|
if(allDone && collectedCount > 0){
|
||||||
allTreasureCollected = TRUE;
|
allTreasureCollected = TRUE;
|
||||||
|
score += 4096;
|
||||||
VDP_drawText("All Treasure Found!", 11, 5);
|
VDP_drawText("All Treasure Found!", 11, 5);
|
||||||
} else {
|
} else {
|
||||||
const char* mirrorPhrases[] = {"Reflect the Depths", "Dig Deeper Within", "See What Shines Below", "Mirror of the Mine", "Look Back, Strike Back"};
|
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;
|
allTreasureCollected = FALSE;
|
||||||
VDP_drawText("All Enemies Down!", 12, 5);
|
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();
|
if(clock % 4 == 0) updateMap();
|
||||||
}
|
}
|
||||||
264
src/enemies.h
264
src/enemies.h
|
|
@ -42,11 +42,13 @@ void spawnEnemy(u8 type, u8 zone){
|
||||||
|
|
||||||
enemies[i].pos.x = randX;
|
enemies[i].pos.x = randX;
|
||||||
enemies[i].pos.y = randY;
|
enemies[i].pos.y = randY;
|
||||||
|
|
||||||
|
// Default sprite — load functions can override via SPR_setDefinition()
|
||||||
static const SpriteDefinition* bossSpriteDefs[4] = { &boss1Sprite, &boss2Sprite, &boss3Sprite, &boss4Sprite };
|
static const SpriteDefinition* bossSpriteDefs[4] = { &boss1Sprite, &boss2Sprite, &boss3Sprite, &boss4Sprite };
|
||||||
SpriteDefinition const* spriteDef;
|
SpriteDefinition const* spriteDef;
|
||||||
if(type == ENEMY_TYPE_DRONE) spriteDef = &eyeBigSprite;
|
if(type == ENEMY_TYPE_BOSS) spriteDef = bossSpriteDefs[pendingBossNum % 4];
|
||||||
else if(type == ENEMY_TYPE_BOSS) spriteDef = bossSpriteDefs[pendingBossNum % 4];
|
|
||||||
else spriteDef = &fairySprite;
|
else spriteDef = &fairySprite;
|
||||||
|
|
||||||
enemies[i].off = (type == ENEMY_TYPE_BOSS) ? 24 : 16;
|
enemies[i].off = (type == ENEMY_TYPE_BOSS) ? 24 : 16;
|
||||||
enemies[i].image = SPR_addSprite(spriteDef,
|
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));
|
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].ints[j] = 0;
|
||||||
enemies[i].fixes[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;
|
enemies[i].anim = 0;
|
||||||
switch(enemies[i].type){
|
switch(enemies[i].type){
|
||||||
case ENEMY_TYPE_TEST:
|
case ENEMY_TYPE_ONE:
|
||||||
loadEnemyOne(i);
|
loadEnemyOne(i);
|
||||||
break;
|
break;
|
||||||
case ENEMY_TYPE_DRONE:
|
case ENEMY_TYPE_TWO:
|
||||||
loadDrone(i);
|
loadEnemyTwo(i);
|
||||||
break;
|
break;
|
||||||
case ENEMY_TYPE_GUNNER:
|
case ENEMY_TYPE_THREE:
|
||||||
loadGunner(i);
|
loadEnemyThree(i);
|
||||||
break;
|
break;
|
||||||
case ENEMY_TYPE_HUNTER:
|
case ENEMY_TYPE_FOUR:
|
||||||
loadHunter(i);
|
loadEnemyFour(i);
|
||||||
break;
|
break;
|
||||||
case ENEMY_TYPE_BUILDER:
|
case ENEMY_TYPE_FIVE:
|
||||||
loadBuilder(i);
|
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;
|
break;
|
||||||
case ENEMY_TYPE_BOSS:
|
case ENEMY_TYPE_BOSS:
|
||||||
loadBoss(i);
|
loadBoss(i);
|
||||||
|
|
@ -89,11 +130,11 @@ void spawnEnemy(u8 type, u8 zone){
|
||||||
}
|
}
|
||||||
|
|
||||||
static void boundsEnemy(u8 i){
|
static void boundsEnemy(u8 i){
|
||||||
if((enemies[i].type == ENEMY_TYPE_TEST || enemies[i].type == ENEMY_TYPE_BUILDER) && enemies[i].ints[3] >= 0){
|
if(enemies[i].canGrabTreasure && enemies[i].carriedTreasure >= 0){
|
||||||
s16 h = enemies[i].ints[3];
|
s16 h = enemies[i].carriedTreasure;
|
||||||
// if the treasure was collected by player or gone, kill this enemy
|
// if the treasure was collected by player or gone, kill this enemy
|
||||||
if(!treasures[h].active || treasures[h].state == TREASURE_COLLECTED){
|
if(!treasures[h].active || treasures[h].state == TREASURE_COLLECTED){
|
||||||
enemies[i].ints[3] = -1;
|
enemies[i].carriedTreasure = -1;
|
||||||
treasureBeingCarried = FALSE;
|
treasureBeingCarried = FALSE;
|
||||||
killEnemy(i);
|
killEnemy(i);
|
||||||
return;
|
return;
|
||||||
|
|
@ -108,17 +149,13 @@ static void boundsEnemy(u8 i){
|
||||||
treasures[h].vel.x = 0;
|
treasures[h].vel.x = 0;
|
||||||
treasures[h].vel.y = FIX32(3);
|
treasures[h].vel.y = FIX32(3);
|
||||||
}
|
}
|
||||||
enemies[i].ints[3] = -1;
|
enemies[i].carriedTreasure = -1;
|
||||||
treasureBeingCarried = FALSE;
|
treasureBeingCarried = FALSE;
|
||||||
enemies[i].vel.y = FIX32(1);
|
enemies[i].vel.y = FIX32(1);
|
||||||
} else {
|
} else {
|
||||||
if(treasures[h].active) killTreasure(h);
|
if(treasures[h].active) killTreasure(h);
|
||||||
enemies[i].ints[3] = -1;
|
enemies[i].carriedTreasure = -1;
|
||||||
treasureBeingCarried = FALSE;
|
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;
|
enemies[i].hp = 0;
|
||||||
killEnemy(i);
|
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){
|
static void updateEnemy(u8 i){
|
||||||
enemies[i].pos.x += enemies[i].vel.x - (player.vel.x >> 3);
|
enemies[i].pos.x += enemies[i].vel.x - (player.vel.x >> 3);
|
||||||
enemies[i].pos.y += enemies[i].vel.y - (playerScrollVelY >> 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);
|
fix32 dx = getWrappedDelta(enemies[i].pos.x, player.pos.x);
|
||||||
enemies[i].onScreen = (dx >= -CULL_LIMIT && dx <= CULL_LIMIT);
|
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){
|
switch(enemies[i].type){
|
||||||
case ENEMY_TYPE_TEST:
|
case ENEMY_TYPE_ONE:
|
||||||
updateEnemyOne(i);
|
updateEnemyOne(i);
|
||||||
break;
|
break;
|
||||||
case ENEMY_TYPE_DRONE:
|
case ENEMY_TYPE_TWO:
|
||||||
updateDrone(i);
|
updateEnemyTwo(i);
|
||||||
break;
|
break;
|
||||||
case ENEMY_TYPE_GUNNER:
|
case ENEMY_TYPE_THREE:
|
||||||
updateGunner(i);
|
updateEnemyThree(i);
|
||||||
break;
|
break;
|
||||||
case ENEMY_TYPE_HUNTER:
|
case ENEMY_TYPE_FOUR:
|
||||||
updateHunter(i);
|
updateEnemyFour(i);
|
||||||
break;
|
break;
|
||||||
case ENEMY_TYPE_BUILDER:
|
case ENEMY_TYPE_FIVE:
|
||||||
updateBuilder(i);
|
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;
|
break;
|
||||||
case ENEMY_TYPE_BOSS:
|
case ENEMY_TYPE_BOSS:
|
||||||
updateBoss(i);
|
updateBoss(i);
|
||||||
|
|
@ -180,46 +321,43 @@ static void updateEnemy(u8 i){
|
||||||
fix32 edy = enemies[i].pos.y - player.pos.y;
|
fix32 edy = enemies[i].pos.y - player.pos.y;
|
||||||
if(edx >= FIX32(-16) && edx <= FIX32(16) && edy >= FIX32(-16) && edy <= FIX32(16)){
|
if(edx >= FIX32(-16) && edx <= FIX32(16) && edy >= FIX32(-16) && edy <= FIX32(16)){
|
||||||
sfxExplosion();
|
sfxExplosion();
|
||||||
// spawn explosion at player position
|
// spawn big explosion at player position
|
||||||
s16 expSlot = -1;
|
spawnExplosion(player.pos.x, player.pos.y, 3, TRUE); // yellow
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(enemies[i].type != ENEMY_TYPE_BOSS){
|
if(enemies[i].type != ENEMY_TYPE_BOSS){
|
||||||
enemies[i].hp = 0;
|
enemies[i].hp = 0;
|
||||||
killEnemy(i);
|
killEnemy(i);
|
||||||
}
|
}
|
||||||
if(!isAttract){
|
if(!isAttract){
|
||||||
player.lives--;
|
if(player.hasShield){
|
||||||
if(player.lives == 0){
|
// shield absorbs hit
|
||||||
gameOver = TRUE;
|
player.hasShield = FALSE;
|
||||||
XGM2_stop();
|
player.shieldClock = 0;
|
||||||
sfxGameOver();
|
removeShieldVisual();
|
||||||
} else {
|
player.recoveringClock = 60;
|
||||||
sfxPlayerHit();
|
player.recoverFlash = TRUE;
|
||||||
player.respawnClock = 120;
|
|
||||||
SPR_setVisibility(player.image, HIDDEN);
|
|
||||||
killBullets = TRUE;
|
killBullets = TRUE;
|
||||||
hitMessageClock = 120;
|
hitMessageClock = 120;
|
||||||
hitMessageBullet = FALSE;
|
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 sx = getScreenX(enemies[i].pos.x, player.camera);
|
||||||
s16 sy = F32_toInt(enemies[i].pos.y);
|
s16 sy = F32_toInt(enemies[i].pos.y);
|
||||||
SPR_setVisibility(enemies[i].image, enemies[i].onScreen ? VISIBLE : HIDDEN);
|
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_setHFlip(enemies[i].image, enemies[i].vel.x > 0);
|
||||||
SPR_setPosition(enemies[i].image, sx - enemies[i].off, sy - enemies[i].off);
|
SPR_setPosition(enemies[i].image, sx - enemies[i].off, sy - enemies[i].off);
|
||||||
|
|
||||||
|
|
|
||||||
1617
src/enemytypes.h
1617
src/enemytypes.h
File diff suppressed because it is too large
Load diff
278
src/global.h
278
src/global.h
|
|
@ -11,7 +11,7 @@ void sfxCollectAllTreasures();
|
||||||
void loadMap();
|
void loadMap();
|
||||||
void loadGame();
|
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)
|
#define SKIP_TO_BONUS 0 // 1 = boot straight into bonus stage for testing (0 for release)
|
||||||
|
|
||||||
u32 clock;
|
u32 clock;
|
||||||
|
|
@ -21,19 +21,20 @@ u32 clock;
|
||||||
#define GAME_H_F FIX32(224)
|
#define GAME_H_F FIX32(224)
|
||||||
|
|
||||||
#define SECTION_SIZE FIX32(512)
|
#define SECTION_SIZE FIX32(512)
|
||||||
#define SECTION_COUNT 4
|
#define SECTION_COUNT 3
|
||||||
#define GAME_WRAP (SECTION_SIZE * SECTION_COUNT)
|
#define GAME_WRAP (SECTION_SIZE * SECTION_COUNT)
|
||||||
|
|
||||||
#define CULL_LIMIT FIX32(240)
|
#define CULL_LIMIT FIX32(240)
|
||||||
#define SCREEN_LIMIT FIX32(208) // max player-to-screen-edge distance (320 - CAMERA_X)
|
#define SCREEN_LIMIT FIX32(208) // max player-to-screen-edge distance (320 - CAMERA_X)
|
||||||
|
|
||||||
// #define MUSIC_VOLUME 50
|
// #define MUSIC_VOLUME 50
|
||||||
#define MUSIC_VOLUME 0
|
#define MUSIC_VOLUME 50
|
||||||
|
|
||||||
u32 score;
|
u32 score;
|
||||||
u32 highScore;
|
u32 highScore;
|
||||||
u32 tempHighScore;
|
u32 tempHighScore;
|
||||||
u32 grazeCount;
|
u32 grazeCount;
|
||||||
|
bool levelPerfect;
|
||||||
u32 nextExtendScore;
|
u32 nextExtendScore;
|
||||||
#define EXTEND_SCORE 25000
|
#define EXTEND_SCORE 25000
|
||||||
#define SCORE_LENGTH 8
|
#define SCORE_LENGTH 8
|
||||||
|
|
@ -118,6 +119,11 @@ struct playerStruct {
|
||||||
u8 lives, recoveringClock, respawnClock;
|
u8 lives, recoveringClock, respawnClock;
|
||||||
bool recoverFlash; // TRUE only after death, not on level-start grace
|
bool recoverFlash; // TRUE only after death, not on level-start grace
|
||||||
bool pendingShow; // show sprite after next position update (avoids 1-frame position flicker)
|
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;
|
fix32 camera;
|
||||||
Sprite* image;
|
Sprite* image;
|
||||||
};
|
};
|
||||||
|
|
@ -138,7 +144,7 @@ struct bulletSpawner {
|
||||||
};
|
};
|
||||||
struct bullet {
|
struct bullet {
|
||||||
fix32 speed;
|
fix32 speed;
|
||||||
bool active, player, vFlip, hFlip, explosion, grazed;
|
bool active, player, vFlip, hFlip, grazed;
|
||||||
Vect2D_f32 pos, vel;
|
Vect2D_f32 pos, vel;
|
||||||
Sprite* image;
|
Sprite* image;
|
||||||
s16 clock, angle, anim, frame;
|
s16 clock, angle, anim, frame;
|
||||||
|
|
@ -152,12 +158,66 @@ struct bullet bullets[BULLET_COUNT];
|
||||||
// enemies
|
// enemies
|
||||||
#define ENEMY_COUNT 24
|
#define ENEMY_COUNT 24
|
||||||
|
|
||||||
#define ENEMY_TYPE_TEST 0
|
#define ENEMY_TYPE_ONE 0
|
||||||
#define ENEMY_TYPE_DRONE 1
|
#define ENEMY_TYPE_TWO 1
|
||||||
#define ENEMY_TYPE_GUNNER 2
|
#define ENEMY_TYPE_THREE 2
|
||||||
#define ENEMY_TYPE_HUNTER 3
|
#define ENEMY_TYPE_FOUR 3
|
||||||
#define ENEMY_TYPE_BUILDER 4
|
#define ENEMY_TYPE_FIVE 4
|
||||||
#define ENEMY_TYPE_BOSS 5
|
#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 {
|
struct enemy {
|
||||||
bool active, onScreen;
|
bool active, onScreen;
|
||||||
|
|
@ -168,6 +228,13 @@ struct enemy {
|
||||||
fix32 speed;
|
fix32 speed;
|
||||||
Vect2D_f32 vel, pos;
|
Vect2D_f32 vel, pos;
|
||||||
Sprite* image;
|
Sprite* image;
|
||||||
|
bool canGrabTreasure;
|
||||||
|
bool homesOnPlayer;
|
||||||
|
bool canShoot;
|
||||||
|
bool canFlipH;
|
||||||
|
bool useBigSprite;
|
||||||
|
s16 carriedTreasure;
|
||||||
|
s16 targetTreasure;
|
||||||
s16 ints[PROP_COUNT];
|
s16 ints[PROP_COUNT];
|
||||||
fix16 fixes[PROP_COUNT];
|
fix16 fixes[PROP_COUNT];
|
||||||
};
|
};
|
||||||
|
|
@ -196,57 +263,36 @@ u16 levelEnemiesKilled;
|
||||||
u16 statEnemiesKilled;
|
u16 statEnemiesKilled;
|
||||||
s16 statTreasures;
|
s16 statTreasures;
|
||||||
|
|
||||||
void killTreasure(u8 i){
|
void removeShieldVisual(){
|
||||||
if(treasures[i].state == TREASURE_CARRIED && treasures[i].carriedBy >= 0){
|
SPR_setDefinition(player.image, &momoyoSprite);
|
||||||
enemies[treasures[i].carriedBy].ints[3] = -1;
|
|
||||||
treasureBeingCarried = FALSE;
|
|
||||||
}
|
|
||||||
treasures[i].active = FALSE;
|
|
||||||
SPR_releaseSprite(treasures[i].image);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void killBullet(u8 i, bool explode){
|
// pickups
|
||||||
if(explode){
|
#define PICKUP_COUNT 1
|
||||||
s16 a = bullets[i].anim;
|
#define PICKUP_TYPE_BOMB 0
|
||||||
s16 explosionAnim;
|
#define PICKUP_TYPE_SPREAD 1
|
||||||
if(bullets[i].player){
|
#define PICKUP_TYPE_RAPID 2
|
||||||
explosionAnim = 16;
|
#define PICKUP_TYPE_SHIELD 3
|
||||||
} else if(a < FIRST_ROTATING_BULLET){
|
#define PICKUP_SPAWN_INTERVAL 450 // ~15 sec (called on even frames only)
|
||||||
explosionAnim = 13 + bullets[i].frame;
|
#define PICKUP_LIFETIME 300 // ~10 sec (called on even frames only)
|
||||||
} else {
|
#define PICKUP_BLINK_START 30 // blink final ~1 sec (even frames only)
|
||||||
s16 mod = a % 3;
|
#define BOMB_MAX 2
|
||||||
explosionAnim = 13 + mod;
|
#define BOMB_DAMAGE 8
|
||||||
}
|
#define BOMB_BOSS_DAMAGE 4
|
||||||
SPR_setAnim(bullets[i].image, explosionAnim);
|
#define BOMB_IFRAMES 60
|
||||||
bullets[i].clock = 0;
|
#define BOMB_BULLET_SCORE 32
|
||||||
bullets[i].frame = 0;
|
#define POWERUP_DURATION 600
|
||||||
bullets[i].explosion = TRUE;
|
#define SHIELD_TIMEOUT 600
|
||||||
SPR_setFrame(bullets[i].image, 0);
|
#define PICKUP_OFF 8
|
||||||
SPR_setHFlip(bullets[i].image, random() & 1);
|
|
||||||
// SPR_setVFlip(bullets[i].image, random() & 1);
|
|
||||||
} else {
|
|
||||||
bullets[i].active = FALSE;
|
|
||||||
SPR_releaseSprite(bullets[i].image);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void killEnemy(u8 i){
|
struct pickup { bool active; u8 type; u16 lifeClock; Vect2D_f32 pos; Sprite* image; };
|
||||||
if(isAttract) return;
|
struct pickup pickups[PICKUP_COUNT];
|
||||||
enemies[i].hp--;
|
u16 pickupSpawnClock;
|
||||||
if(enemies[i].hp > 0) return;
|
|
||||||
if(enemies[i].ints[3] >= 0){
|
void killPickup(u8 i){
|
||||||
s16 h = enemies[i].ints[3];
|
if(!pickups[i].active) return;
|
||||||
if(treasures[h].active){
|
pickups[i].active = FALSE;
|
||||||
treasures[h].state = TREASURE_FALLING;
|
SPR_releaseSprite(pickups[i].image);
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static fix32 getWrappedDelta(fix32 a, fix32 b) {
|
static fix32 getWrappedDelta(fix32 a, fix32 b) {
|
||||||
|
|
@ -269,6 +315,122 @@ static s16 getScreenX(fix32 worldX, fix32 camera) {
|
||||||
return F32_toInt(screenX);
|
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)
|
// homing -- degree-based using SGDK F16_atan2 (returns fix16 degrees)
|
||||||
static fix16 getAngle(fix32 dx, fix32 dy){
|
static fix16 getAngle(fix32 dx, fix32 dy){
|
||||||
|
|
|
||||||
39
src/main.c
39
src/main.c
|
|
@ -6,6 +6,7 @@
|
||||||
#include "bullets.h"
|
#include "bullets.h"
|
||||||
#include "enemies.h"
|
#include "enemies.h"
|
||||||
#include "treasure.h"
|
#include "treasure.h"
|
||||||
|
#include "pickup.h"
|
||||||
#include "player.h"
|
#include "player.h"
|
||||||
#include "stage.h"
|
#include "stage.h"
|
||||||
#include "chrome.h"
|
#include "chrome.h"
|
||||||
|
|
@ -44,6 +45,10 @@ void clearLevel(){
|
||||||
if(enemies[i].active){ enemies[i].hp = 0; killEnemy(i); }
|
if(enemies[i].active){ enemies[i].hp = 0; killEnemy(i); }
|
||||||
for(s16 i = 0; i < TREASURE_COUNT; i++)
|
for(s16 i = 0; i < TREASURE_COUNT; i++)
|
||||||
if(treasures[i].active) killTreasure(i);
|
if(treasures[i].active) killTreasure(i);
|
||||||
|
clearExplosions();
|
||||||
|
clearPickups();
|
||||||
|
clearPopups();
|
||||||
|
removeShieldVisual();
|
||||||
treasureBeingCarried = FALSE;
|
treasureBeingCarried = FALSE;
|
||||||
collectedCount = 0;
|
collectedCount = 0;
|
||||||
allTreasureCollected = FALSE;
|
allTreasureCollected = FALSE;
|
||||||
|
|
@ -64,12 +69,20 @@ void loadGame(){
|
||||||
loadChrome();
|
loadChrome();
|
||||||
loadLevel(isAttract ? ATTRACT_LEVEL : START_LEVEL);
|
loadLevel(isAttract ? ATTRACT_LEVEL : START_LEVEL);
|
||||||
#if MUSIC_VOLUME > 0
|
#if MUSIC_VOLUME > 0
|
||||||
if(!isAttract) XGM2_play(isBossLevel(level) ? bossMusic : stageMusic);
|
if(!isAttract) XGM2_play(stageMusic);
|
||||||
#endif
|
#endif
|
||||||
player.recoveringClock = 240;
|
player.recoveringClock = 240;
|
||||||
player.recoverFlash = FALSE;
|
player.recoverFlash = FALSE;
|
||||||
killBullets = TRUE;
|
killBullets = TRUE;
|
||||||
attractEnding = FALSE;
|
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;
|
started = TRUE;
|
||||||
#if SKIP_TO_BONUS
|
#if SKIP_TO_BONUS
|
||||||
clearLevel();
|
clearLevel();
|
||||||
|
|
@ -110,6 +123,7 @@ static void updateGame(){
|
||||||
// Bonus stage branch (after boss fights)
|
// Bonus stage branch (after boss fights)
|
||||||
if(bonusStage){
|
if(bonusStage){
|
||||||
updateBonus();
|
updateBonus();
|
||||||
|
updateExplosions();
|
||||||
if(!bonusActive){
|
if(!bonusActive){
|
||||||
bonusStage = FALSE;
|
bonusStage = FALSE;
|
||||||
clearBonus();
|
clearBonus();
|
||||||
|
|
@ -117,6 +131,12 @@ static void updateGame(){
|
||||||
player.pos.y = FIX32(112);
|
player.pos.y = FIX32(112);
|
||||||
player.camera = player.pos.x - FIX32(160);
|
player.camera = player.pos.x - FIX32(160);
|
||||||
playerVelX = 0;
|
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();
|
loadBackground();
|
||||||
loadChrome();
|
loadChrome();
|
||||||
loadLevel(level + 1);
|
loadLevel(level + 1);
|
||||||
|
|
@ -125,9 +145,14 @@ static void updateGame(){
|
||||||
player.recoveringClock = 240;
|
player.recoveringClock = 240;
|
||||||
player.recoverFlash = FALSE;
|
player.recoverFlash = FALSE;
|
||||||
killBullets = TRUE;
|
killBullets = TRUE;
|
||||||
|
player.activePowerup = 0;
|
||||||
|
player.powerupClock = 0;
|
||||||
|
player.hasShield = FALSE;
|
||||||
|
player.shieldClock = 0;
|
||||||
|
removeShieldVisual();
|
||||||
XGM2_stop();
|
XGM2_stop();
|
||||||
#if MUSIC_VOLUME > 0
|
#if MUSIC_VOLUME > 0
|
||||||
if(!isAttract) XGM2_play(isBossLevel(level) ? bossMusic : stageMusic);
|
if(!isAttract) XGM2_play(stageMusic);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -140,6 +165,7 @@ static void updateGame(){
|
||||||
if(isBossLevel(level) && !isAttract){
|
if(isBossLevel(level) && !isAttract){
|
||||||
loadBonus(level % 3);
|
loadBonus(level % 3);
|
||||||
bonusStage = TRUE;
|
bonusStage = TRUE;
|
||||||
|
VDP_drawText("Entering Bonus Level", 10, 14);
|
||||||
} else {
|
} else {
|
||||||
loadStarfield(level % 3);
|
loadStarfield(level % 3);
|
||||||
}
|
}
|
||||||
|
|
@ -164,12 +190,17 @@ static void updateGame(){
|
||||||
player.recoveringClock = 240;
|
player.recoveringClock = 240;
|
||||||
player.recoverFlash = FALSE;
|
player.recoverFlash = FALSE;
|
||||||
killBullets = TRUE;
|
killBullets = TRUE;
|
||||||
|
player.activePowerup = 0;
|
||||||
|
player.powerupClock = 0;
|
||||||
|
player.hasShield = FALSE;
|
||||||
|
player.shieldClock = 0;
|
||||||
XGM2_stop();
|
XGM2_stop();
|
||||||
#if MUSIC_VOLUME > 0
|
#if MUSIC_VOLUME > 0
|
||||||
if(!isAttract) XGM2_play(isBossLevel(level) ? bossMusic : stageMusic);
|
if(!isAttract) XGM2_play(stageMusic);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
if(levelClearing && !bonusStage) updateStarfield();
|
if(levelClearing && !bonusStage) updateStarfield();
|
||||||
|
updateExplosions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(levelWaitClock > 0){
|
if(levelWaitClock > 0){
|
||||||
|
|
@ -205,10 +236,12 @@ static void updateGame(){
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateTreasures();
|
updateTreasures();
|
||||||
|
updatePickups();
|
||||||
} else {
|
} else {
|
||||||
updateBullets();
|
updateBullets();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updateExplosions();
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(bool hardReset){
|
int main(bool hardReset){
|
||||||
|
|
|
||||||
154
src/pickup.h
Normal file
154
src/pickup.h
Normal 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);
|
||||||
|
}
|
||||||
101
src/player.h
101
src/player.h
|
|
@ -18,6 +18,12 @@ fix32 screenX;
|
||||||
fix32 playerSpeed;
|
fix32 playerSpeed;
|
||||||
fix32 playerVelX;
|
fix32 playerVelX;
|
||||||
|
|
||||||
|
bool bombUsing;
|
||||||
|
u8 bombFlashClock;
|
||||||
|
u16 storedPal0[16];
|
||||||
|
u16 storedPal1[16];
|
||||||
|
u16 storedPal2[16];
|
||||||
|
|
||||||
static void movePlayer(){
|
static void movePlayer(){
|
||||||
player.vel.y = 0;
|
player.vel.y = 0;
|
||||||
|
|
||||||
|
|
@ -93,8 +99,8 @@ static void cameraPlayer(){
|
||||||
}
|
}
|
||||||
|
|
||||||
static void shootPlayer(){
|
static void shootPlayer(){
|
||||||
|
s16 interval = (player.activePowerup == 2) ? (SHOT_INTERVAL / 2) : SHOT_INTERVAL;
|
||||||
if(ctrl.a && shotClock == 0){
|
if(ctrl.a && shotClock == 0){
|
||||||
// fix32 bulletVelX = (player.shotAngle == 0 ? PLAYER_SHOT_SPEED : -PLAYER_SHOT_SPEED) + (player.vel.x * 3);
|
|
||||||
struct bulletSpawner spawner = {
|
struct bulletSpawner spawner = {
|
||||||
.x = player.pos.x,
|
.x = player.pos.x,
|
||||||
.y = player.pos.y,
|
.y = player.pos.y,
|
||||||
|
|
@ -109,11 +115,67 @@ static void shootPlayer(){
|
||||||
if(bullets[i].clock == 4) killBullet(i, TRUE);
|
if(bullets[i].clock == 4) killBullet(i, TRUE);
|
||||||
}
|
}
|
||||||
spawnBullet(spawner, updater);
|
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();
|
sfxPlayerShot();
|
||||||
shotClock = SHOT_INTERVAL;
|
shotClock = interval;
|
||||||
} else if(shotClock > 0) shotClock--;
|
} 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 attractXClock = 0;
|
||||||
static s16 attractXState = 0; // 0=moving, 1=paused
|
static s16 attractXState = 0; // 0=moving, 1=paused
|
||||||
static s16 attractXDir = 1; // 1=right, -1=left
|
static s16 attractXDir = 1; // 1=right, -1=left
|
||||||
|
|
@ -186,7 +248,36 @@ void updatePlayer(){
|
||||||
waitForRelease = FALSE;
|
waitForRelease = FALSE;
|
||||||
return;
|
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){
|
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){
|
if(player.respawnClock > 0){
|
||||||
// kill momentum
|
// kill momentum
|
||||||
playerVelX = 0;
|
playerVelX = 0;
|
||||||
|
|
@ -203,6 +294,7 @@ void updatePlayer(){
|
||||||
s16 sx = getScreenX(player.pos.x, player.camera);
|
s16 sx = getScreenX(player.pos.x, player.camera);
|
||||||
s16 sy = F32_toInt(player.pos.y);
|
s16 sy = F32_toInt(player.pos.y);
|
||||||
SPR_setPosition(player.image, sx - PLAYER_OFF, sy - PLAYER_OFF);
|
SPR_setPosition(player.image, sx - PLAYER_OFF, sy - PLAYER_OFF);
|
||||||
|
// shield visual is part of player.image now, hidden along with it
|
||||||
player.respawnClock--;
|
player.respawnClock--;
|
||||||
if(player.respawnClock == 0){
|
if(player.respawnClock == 0){
|
||||||
SPR_setVisibility(player.image, VISIBLE);
|
SPR_setVisibility(player.image, VISIBLE);
|
||||||
|
|
@ -227,6 +319,11 @@ void updatePlayer(){
|
||||||
s16 sx = getScreenX(player.pos.x, player.camera);
|
s16 sx = getScreenX(player.pos.x, player.camera);
|
||||||
s16 sy = F32_toInt(player.pos.y);
|
s16 sy = F32_toInt(player.pos.y);
|
||||||
SPR_setPosition(player.image, sx - PLAYER_OFF, sy - PLAYER_OFF);
|
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){
|
if(player.pendingShow){
|
||||||
SPR_setVisibility(player.image, VISIBLE);
|
SPR_setVisibility(player.image, VISIBLE);
|
||||||
player.pendingShow = FALSE;
|
player.pendingShow = FALSE;
|
||||||
|
|
|
||||||
135
src/stage.h
135
src/stage.h
|
|
@ -13,42 +13,9 @@
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
#define LEVEL_COUNT 15
|
#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)
|
// Boss HP per boss number (0-4)
|
||||||
static const s16 bossHpTable[5] = { 24, 50, 75, 100, 125 };
|
static const s16 bossHpTable[5] = { 20, 40, 60, 80, 120 };
|
||||||
|
|
||||||
// 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 u8 getTreasureCount(u8 lvl){
|
static u8 getTreasureCount(u8 lvl){
|
||||||
if(lvl == 0) return 4;
|
if(lvl == 0) return 4;
|
||||||
|
|
@ -56,22 +23,6 @@ static u8 getTreasureCount(u8 lvl){
|
||||||
return 8;
|
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){
|
static void distributeEnemies(u8 type, u8 count){
|
||||||
for(u8 i = 0; i < count; i++){
|
for(u8 i = 0; i < count; i++){
|
||||||
u8 zone = i % SECTION_COUNT;
|
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){
|
static void generateLevel(u8 lvl, u8* counts){
|
||||||
for(u8 i = 0; i < TP_POOL_SIZE; i++) counts[i] = 0;
|
for(u8 i = 0; i < ENEMY_TYPE_COUNT; i++) counts[i] = 0;
|
||||||
|
|
||||||
// L1 special case: 4 non-shooting drones
|
|
||||||
if(lvl == 0){
|
|
||||||
counts[1] = 4;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute budget
|
// 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;
|
budget = (budget * (90 + (random() % 21))) / 100;
|
||||||
|
|
||||||
// Boss levels get 40% escort budget
|
// Boss levels get reduced escort budget
|
||||||
if(isBossLevel(lvl)){
|
if(isBossLevel(lvl)){
|
||||||
budget = (budget * 40) / 100;
|
budget = (budget * TP_BOSS_PCT) / 100;
|
||||||
if(budget < 4) budget = 4;
|
if(budget < 4) budget = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
u8 unlocked = getUnlockedTypes(lvl);
|
bool boss = isBossLevel(lvl);
|
||||||
u8 maxTotal = isBossLevel(lvl) ? ENEMY_COUNT - 1 : ENEMY_COUNT; // reserve 1 slot for boss
|
u8 maxTotal = boss ? ENEMY_COUNT - 1 : ENEMY_COUNT; // reserve 1 slot for boss
|
||||||
|
|
||||||
// Apply minimum guarantees
|
// Apply minimum guarantees
|
||||||
for(u8 i = 0; i < TP_POOL_SIZE; i++){
|
for(u8 i = 0; i < ENEMY_TYPE_COUNT; i++){
|
||||||
if((unlocked & (1 << i)) && typeMinCount[i] > 0){
|
if(lvl >= enemyTypeDefs[i].unlockLevel && enemyTypeDefs[i].minCount > 0){
|
||||||
counts[i] = typeMinCount[i];
|
counts[i] = enemyTypeDefs[i].minCount;
|
||||||
u16 cost = typeMinCount[i] * typeCost[i];
|
u16 cost = enemyTypeDefs[i].minCount * enemyTypeDefs[i].cost;
|
||||||
if(cost <= budget) budget -= cost;
|
if(cost <= budget) budget -= cost;
|
||||||
else budget = 0;
|
else budget = 0;
|
||||||
}
|
}
|
||||||
|
|
@ -115,22 +60,18 @@ static void generateLevel(u8 lvl, u8* counts){
|
||||||
// Shopping loop
|
// Shopping loop
|
||||||
u16 safety = 0;
|
u16 safety = 0;
|
||||||
u8 totalEnemies = 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){
|
while(budget >= 2 && totalEnemies < maxTotal && safety < 100){
|
||||||
safety++;
|
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
|
// Build weighted pool of affordable, unlocked, non-maxed types
|
||||||
u16 totalWeight = 0;
|
u16 totalWeight = 0;
|
||||||
for(u8 i = 0; i < TP_POOL_SIZE; i++){
|
for(u8 i = 0; i < ENEMY_TYPE_COUNT; i++){
|
||||||
if(!(unlocked & (1 << i))) continue;
|
if(lvl < enemyTypeDefs[i].unlockLevel) continue;
|
||||||
if(!(escortMask & (1 << i))) continue;
|
if(counts[i] >= enemyTypeDefs[i].maxCount) continue;
|
||||||
if(counts[i] >= typeMaxCount[i]) continue;
|
if(enemyTypeDefs[i].cost > budget) continue;
|
||||||
if(typeCost[i] > budget) continue;
|
totalWeight += enemyTypeDefs[i].weight;
|
||||||
totalWeight += typeWeight[i];
|
|
||||||
}
|
}
|
||||||
if(totalWeight == 0) break;
|
if(totalWeight == 0) break;
|
||||||
|
|
||||||
|
|
@ -138,12 +79,12 @@ static void generateLevel(u8 lvl, u8* counts){
|
||||||
u16 roll = random() % totalWeight;
|
u16 roll = random() % totalWeight;
|
||||||
u16 accum = 0;
|
u16 accum = 0;
|
||||||
u8 picked = 0xFF;
|
u8 picked = 0xFF;
|
||||||
for(u8 i = 0; i < TP_POOL_SIZE; i++){
|
for(u8 i = 0; i < ENEMY_TYPE_COUNT; i++){
|
||||||
if(!(unlocked & (1 << i))) continue;
|
if(lvl < enemyTypeDefs[i].unlockLevel) continue;
|
||||||
if(!(escortMask & (1 << i))) continue;
|
if(boss && enemyTypeDefs[i].type != ENEMY_TYPE_TWO && enemyTypeDefs[i].type != ENEMY_TYPE_FIVE) continue;
|
||||||
if(counts[i] >= typeMaxCount[i]) continue;
|
if(counts[i] >= enemyTypeDefs[i].maxCount) continue;
|
||||||
if(typeCost[i] > budget) continue;
|
if(enemyTypeDefs[i].cost > budget) continue;
|
||||||
accum += typeWeight[i];
|
accum += enemyTypeDefs[i].weight;
|
||||||
if(roll < accum){
|
if(roll < accum){
|
||||||
picked = i;
|
picked = i;
|
||||||
break;
|
break;
|
||||||
|
|
@ -152,7 +93,7 @@ static void generateLevel(u8 lvl, u8* counts){
|
||||||
if(picked == 0xFF) break;
|
if(picked == 0xFF) break;
|
||||||
|
|
||||||
counts[picked]++;
|
counts[picked]++;
|
||||||
budget -= typeCost[picked];
|
budget -= enemyTypeDefs[picked].cost;
|
||||||
totalEnemies++;
|
totalEnemies++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -161,20 +102,18 @@ void loadLevel(u8 lvl){
|
||||||
if(lvl >= LEVEL_COUNT) lvl = LEVEL_COUNT - 1;
|
if(lvl >= LEVEL_COUNT) lvl = LEVEL_COUNT - 1;
|
||||||
level = lvl;
|
level = lvl;
|
||||||
grazeCount = 0;
|
grazeCount = 0;
|
||||||
|
levelPerfect = TRUE;
|
||||||
|
|
||||||
// Generate enemy composition
|
// Generate enemy composition
|
||||||
u8 counts[TP_POOL_SIZE];
|
u8 counts[ENEMY_TYPE_COUNT];
|
||||||
generateLevel(lvl, counts);
|
generateLevel(lvl, counts);
|
||||||
|
|
||||||
// Spawn enemies by type
|
// 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)
|
if(counts[i] > 0)
|
||||||
distributeEnemies(poolTypeMap[i], counts[i]);
|
distributeEnemies(enemyTypeDefs[i].type, counts[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign gunner patterns
|
|
||||||
assignGunnerPatterns(lvl);
|
|
||||||
|
|
||||||
// Boss spawn
|
// Boss spawn
|
||||||
if(isBossLevel(lvl)){
|
if(isBossLevel(lvl)){
|
||||||
pendingBossNum = lvl / 3;
|
pendingBossNum = lvl / 3;
|
||||||
|
|
@ -183,13 +122,15 @@ void loadLevel(u8 lvl){
|
||||||
spawnEnemy(ENEMY_TYPE_BOSS, 1);
|
spawnEnemy(ENEMY_TYPE_BOSS, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn treasures
|
// Spawn treasures (not on boss levels)
|
||||||
u8 treasureToSpawn = getTreasureCount(lvl);
|
if(!isBossLevel(lvl)){
|
||||||
for(u8 zone = 0; zone < SECTION_COUNT && treasureToSpawn > 0; zone++){
|
u8 treasureToSpawn = getTreasureCount(lvl);
|
||||||
u8 perZone = treasureToSpawn >= 4 ? 2 : 1;
|
for(u8 zone = 0; zone < SECTION_COUNT && treasureToSpawn > 0; zone++){
|
||||||
for(u8 h = 0; h < perZone && treasureToSpawn > 0; h++){
|
u8 perZone = treasureToSpawn >= 4 ? 2 : 1;
|
||||||
spawnTreasure(zone);
|
for(u8 h = 0; h < perZone && treasureToSpawn > 0; h++){
|
||||||
treasureToSpawn--;
|
spawnTreasure(zone);
|
||||||
|
treasureToSpawn--;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,9 @@ static void updateTreasure(u8 i){
|
||||||
if(treasures[i].state != TREASURE_CARRIED && treasures[i].state != TREASURE_COLLECTED){
|
if(treasures[i].state != TREASURE_CARRIED && treasures[i].state != TREASURE_COLLECTED){
|
||||||
fix32 dy = treasures[i].pos.y - player.pos.y;
|
fix32 dy = treasures[i].pos.y - player.pos.y;
|
||||||
if(dx >= FIX32(-32) && dx <= FIX32(32) && dy >= FIX32(-32) && dy <= FIX32(32)){
|
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)
|
// check if this is the last treasure (all others inactive or collected)
|
||||||
bool willBeLast = TRUE;
|
bool willBeLast = TRUE;
|
||||||
for(s16 j = 0; j < TREASURE_COUNT; j++){
|
for(s16 j = 0; j < TREASURE_COUNT; j++){
|
||||||
|
|
|
||||||
540
tools/downscale_sprite.py
Normal file
540
tools/downscale_sprite.py
Normal file
|
|
@ -0,0 +1,540 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Content-adaptive sprite downscaler (Kopf-Shamir-Peers 2013).
|
||||||
|
|
||||||
|
Downscales hand-drawn sprites into Genesis-compatible indexed-color pixel art
|
||||||
|
using bilateral EM kernels that respect edges and color boundaries.
|
||||||
|
|
||||||
|
Dependencies: pip install numpy scikit-image Pillow
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import numpy as np
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GENESIS_LEVELS = np.array([0, 36, 72, 109, 145, 182, 218, 255], dtype=np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Color utilities
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def rgb_to_lab(rgb: np.ndarray) -> np.ndarray:
|
||||||
|
"""Convert float RGB [0,1] image to CIELAB. Uses skimage."""
|
||||||
|
from skimage.color import rgb2lab
|
||||||
|
return rgb2lab(rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def lab_to_rgb(lab: np.ndarray) -> np.ndarray:
|
||||||
|
"""Convert CIELAB image to float RGB [0,1]. Uses skimage."""
|
||||||
|
from skimage.color import lab2rgb
|
||||||
|
return lab2rgb(lab)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_lab(lab: np.ndarray) -> np.ndarray:
|
||||||
|
"""Normalize CIELAB to roughly [0,1] per channel (matching paper)."""
|
||||||
|
out = np.empty_like(lab)
|
||||||
|
out[..., 0] = lab[..., 0] / 100.0
|
||||||
|
out[..., 1] = (lab[..., 1] + 87.0) / 186.0
|
||||||
|
out[..., 2] = (lab[..., 2] + 108.0) / 203.0
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def denormalize_lab(nlab: np.ndarray) -> np.ndarray:
|
||||||
|
"""Undo normalize_lab."""
|
||||||
|
out = np.empty_like(nlab)
|
||||||
|
out[..., 0] = nlab[..., 0] * 100.0
|
||||||
|
out[..., 1] = nlab[..., 1] * 186.0 - 87.0
|
||||||
|
out[..., 2] = nlab[..., 2] * 203.0 - 108.0
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Image I/O
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_input(path: str, bg_color=None):
|
||||||
|
"""Load PNG, return (normalized_lab, alpha_mask)."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img = Image.open(path)
|
||||||
|
if img.mode == "RGBA":
|
||||||
|
arr = np.array(img, dtype=np.float64)
|
||||||
|
alpha_mask = arr[:, :, 3] > 127
|
||||||
|
rgb01 = arr[:, :, :3] / 255.0
|
||||||
|
elif img.mode == "RGB":
|
||||||
|
arr = np.array(img, dtype=np.float64)
|
||||||
|
rgb01 = arr / 255.0
|
||||||
|
if bg_color is not None:
|
||||||
|
bg = np.array(bg_color, dtype=np.float64) / 255.0
|
||||||
|
alpha_mask = ~np.all(np.abs(rgb01 - bg) < 1e-3, axis=-1)
|
||||||
|
else:
|
||||||
|
alpha_mask = np.ones(rgb01.shape[:2], dtype=bool)
|
||||||
|
else:
|
||||||
|
img = img.convert("RGBA")
|
||||||
|
arr = np.array(img, dtype=np.float64)
|
||||||
|
alpha_mask = arr[:, :, 3] > 127
|
||||||
|
rgb01 = arr[:, :, :3] / 255.0
|
||||||
|
|
||||||
|
lab = rgb_to_lab(rgb01)
|
||||||
|
nlab = normalize_lab(lab)
|
||||||
|
return nlab, alpha_mask
|
||||||
|
|
||||||
|
|
||||||
|
def load_palette(path: str):
|
||||||
|
"""Load a 16-color palette from an indexed PNG.
|
||||||
|
|
||||||
|
Returns (16, 3) uint8 RGB array.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img = Image.open(path)
|
||||||
|
if img.mode != "P":
|
||||||
|
print(f"Error: palette image must be indexed-color (mode P), got {img.mode}",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
raw_pal = img.getpalette()
|
||||||
|
if raw_pal is None:
|
||||||
|
print("Error: palette image has no palette data", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
pal = np.array(raw_pal[:48], dtype=np.uint8).reshape(16, 3)
|
||||||
|
return pal
|
||||||
|
|
||||||
|
|
||||||
|
def save_indexed_png(path: str, pixels: np.ndarray, palette: np.ndarray):
|
||||||
|
"""Write indexed-color PNG with the palette exactly as provided."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img = Image.fromarray(pixels, mode="P")
|
||||||
|
flat_pal = palette.flatten().tolist()
|
||||||
|
flat_pal.extend([0] * (768 - len(flat_pal)))
|
||||||
|
img.putpalette(flat_pal)
|
||||||
|
img.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EM core
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _invert_2x2(m):
|
||||||
|
"""Invert a single 2x2 matrix."""
|
||||||
|
a, b, c, d = m[0, 0], m[0, 1], m[1, 0], m[1, 1]
|
||||||
|
det = a * d - b * c
|
||||||
|
if abs(det) < 1e-15:
|
||||||
|
det = 1e-15
|
||||||
|
return np.array([[d, -b], [-c, a]], dtype=np.float64) / det
|
||||||
|
|
||||||
|
|
||||||
|
def run_downscale(nlab_image, alpha_mask, out_h, out_w, n_iters=30,
|
||||||
|
verbose=False):
|
||||||
|
"""Run full EM downscale with proper per-pixel normalization.
|
||||||
|
|
||||||
|
nlab_image: (H, W, 3) normalized CIELAB [0,1]
|
||||||
|
alpha_mask: (H, W) bool
|
||||||
|
|
||||||
|
Returns (output_nlab, output_alpha).
|
||||||
|
"""
|
||||||
|
in_h, in_w = nlab_image.shape[:2]
|
||||||
|
K = out_h * out_w
|
||||||
|
N = in_h * in_w
|
||||||
|
rx = in_w / out_w
|
||||||
|
ry = in_h / out_h
|
||||||
|
|
||||||
|
# Flatten input for indexing
|
||||||
|
nlab_flat = nlab_image.reshape(N, 3)
|
||||||
|
alpha_flat = alpha_mask.ravel()
|
||||||
|
|
||||||
|
# Precompute all input pixel coordinates (row, col) in input space
|
||||||
|
grid_y, grid_x = np.mgrid[:in_h, :in_w]
|
||||||
|
coords_flat = np.stack([grid_x.ravel(), grid_y.ravel()], axis=-1).astype(np.float64)
|
||||||
|
|
||||||
|
# --- Initialization ---
|
||||||
|
|
||||||
|
# Output pixel centers in input coordinates
|
||||||
|
oy, ox = np.meshgrid(
|
||||||
|
(np.arange(out_h) + 0.5) * ry,
|
||||||
|
(np.arange(out_w) + 0.5) * rx,
|
||||||
|
indexing="ij",
|
||||||
|
)
|
||||||
|
centers = np.stack([ox.ravel(), oy.ravel()], axis=-1) # (K, 2)
|
||||||
|
|
||||||
|
mu = centers.copy()
|
||||||
|
# Covariance: diag(rx/3, ry/3) — NOT squared, matching paper
|
||||||
|
Sigma = np.zeros((K, 2, 2), dtype=np.float64)
|
||||||
|
Sigma[:, 0, 0] = rx / 3.0
|
||||||
|
Sigma[:, 1, 1] = ry / 3.0
|
||||||
|
|
||||||
|
# Color mean: local average from input
|
||||||
|
nu = np.full((K, 3), 0.5, dtype=np.float64)
|
||||||
|
for k in range(K):
|
||||||
|
cx, cy = centers[k]
|
||||||
|
x0 = max(0, int(cx - rx / 2))
|
||||||
|
x1 = min(in_w, int(cx + rx / 2) + 1)
|
||||||
|
y0 = max(0, int(cy - ry / 2))
|
||||||
|
y1 = min(in_h, int(cy + ry / 2) + 1)
|
||||||
|
patch = nlab_image[y0:y1, x0:x1]
|
||||||
|
mask_patch = alpha_mask[y0:y1, x0:x1]
|
||||||
|
if mask_patch.any():
|
||||||
|
nu[k] = patch[mask_patch].mean(axis=0)
|
||||||
|
|
||||||
|
# Color variance (scalar), matching paper init
|
||||||
|
sigma_c = np.full(K, 0.0001, dtype=np.float64)
|
||||||
|
|
||||||
|
# Precompute R(k): set of input pixel indices within 2*rx of each kernel
|
||||||
|
R = [None] * K
|
||||||
|
for k in range(K):
|
||||||
|
cx, cy = centers[k]
|
||||||
|
x0 = max(0, int(cx - 2 * rx))
|
||||||
|
x1 = min(in_w, int(cx + 2 * rx))
|
||||||
|
y0 = max(0, int(cy - 2 * ry))
|
||||||
|
y1 = min(in_h, int(cy + 2 * ry))
|
||||||
|
yy, xx = np.mgrid[y0:y1, x0:x1]
|
||||||
|
R[k] = (yy.ravel() * in_w + xx.ravel()).astype(np.int32)
|
||||||
|
|
||||||
|
# Eigenvalue clamp bounds, scaled with downscale ratio
|
||||||
|
# For rx=2 matches paper's [0.05, 0.1]; scales quadratically
|
||||||
|
ev_min = 0.0125 * rx * rx
|
||||||
|
ev_max = 0.025 * rx * rx
|
||||||
|
|
||||||
|
# --- EM iterations ---
|
||||||
|
prev_nu = None
|
||||||
|
|
||||||
|
for it in range(n_iters):
|
||||||
|
# ===================== E-STEP =====================
|
||||||
|
# Compute bilateral weights w_k(i) and per-pixel sums for normalization
|
||||||
|
|
||||||
|
# Per-pixel accumulator: sum of w_k(i) across all kernels k
|
||||||
|
pixel_wsum = np.zeros(N, dtype=np.float64)
|
||||||
|
|
||||||
|
# Store per-kernel weights (sparse: only pixels in R(k))
|
||||||
|
kernel_w = [None] * K
|
||||||
|
|
||||||
|
for k in range(K):
|
||||||
|
idx = R[k]
|
||||||
|
pi = coords_flat[idx] # (M, 2)
|
||||||
|
ci = nlab_flat[idx] # (M, 3)
|
||||||
|
ai = alpha_flat[idx] # (M,) bool
|
||||||
|
|
||||||
|
# Spatial Gaussian: Mahalanobis distance
|
||||||
|
diff_s = pi - mu[k]
|
||||||
|
Si = _invert_2x2(Sigma[k])
|
||||||
|
mahal = np.sum(diff_s @ Si * diff_s, axis=-1)
|
||||||
|
f_k = np.exp(-0.5 * mahal)
|
||||||
|
|
||||||
|
# Color Gaussian
|
||||||
|
diff_c = ci - nu[k]
|
||||||
|
color_dist_sq = np.sum(diff_c ** 2, axis=-1)
|
||||||
|
sc2 = max(sigma_c[k] * sigma_c[k], 1e-15)
|
||||||
|
g_k = np.exp(-color_dist_sq / (2.0 * sc2))
|
||||||
|
|
||||||
|
# Bilateral weight
|
||||||
|
w = f_k * g_k
|
||||||
|
w[~ai] = 0.0
|
||||||
|
|
||||||
|
# Per-kernel normalization (numerical stability)
|
||||||
|
wsum = w.sum()
|
||||||
|
if wsum > 0:
|
||||||
|
w /= wsum
|
||||||
|
|
||||||
|
kernel_w[k] = w
|
||||||
|
pixel_wsum[idx] += w
|
||||||
|
|
||||||
|
# ===================== COMPUTE GAMMA & M-STEP =====================
|
||||||
|
# gamma_k(i) = w_k(i) / sum_n w_n(i) [per-pixel normalization]
|
||||||
|
|
||||||
|
new_mu = np.zeros((K, 2), dtype=np.float64)
|
||||||
|
new_Sigma = np.zeros((K, 2, 2), dtype=np.float64)
|
||||||
|
new_nu = np.zeros((K, 3), dtype=np.float64)
|
||||||
|
|
||||||
|
# Also store gamma for shape constraint overlap check
|
||||||
|
kernel_gamma = [None] * K
|
||||||
|
|
||||||
|
for k in range(K):
|
||||||
|
idx = R[k]
|
||||||
|
w = kernel_w[k]
|
||||||
|
pi = coords_flat[idx]
|
||||||
|
ci = nlab_flat[idx]
|
||||||
|
|
||||||
|
# Per-pixel normalization
|
||||||
|
denom = pixel_wsum[idx]
|
||||||
|
denom = np.where(denom > 0, denom, 1.0)
|
||||||
|
gamma = w / denom
|
||||||
|
|
||||||
|
kernel_gamma[k] = gamma
|
||||||
|
|
||||||
|
gamma_sum = gamma.sum()
|
||||||
|
if gamma_sum < 1e-12:
|
||||||
|
new_mu[k] = centers[k]
|
||||||
|
new_Sigma[k] = Sigma[k]
|
||||||
|
new_nu[k] = nu[k]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# M-step
|
||||||
|
new_mu[k] = (gamma[:, None] * pi).sum(axis=0) / gamma_sum
|
||||||
|
diff_s = pi - new_mu[k]
|
||||||
|
new_Sigma[k] = (gamma[:, None, None] *
|
||||||
|
(diff_s[:, :, None] * diff_s[:, None, :])).sum(axis=0) / gamma_sum
|
||||||
|
new_nu[k] = (gamma[:, None] * ci).sum(axis=0) / gamma_sum
|
||||||
|
|
||||||
|
mu = new_mu
|
||||||
|
Sigma = new_Sigma
|
||||||
|
nu = new_nu
|
||||||
|
|
||||||
|
# ===================== CORRECTION STEP =====================
|
||||||
|
|
||||||
|
# 1. Spatial bias: blend mu toward 4-neighbor average, clamp to box
|
||||||
|
mu_grid = mu.reshape(out_h, out_w, 2)
|
||||||
|
neighbor_sum = np.zeros_like(mu_grid)
|
||||||
|
neighbor_cnt = np.zeros((out_h, out_w, 1), dtype=np.float64)
|
||||||
|
|
||||||
|
neighbor_sum[1:, :] += mu_grid[:-1, :]
|
||||||
|
neighbor_cnt[1:, :] += 1
|
||||||
|
neighbor_sum[:-1, :] += mu_grid[1:, :]
|
||||||
|
neighbor_cnt[:-1, :] += 1
|
||||||
|
neighbor_sum[:, 1:] += mu_grid[:, :-1]
|
||||||
|
neighbor_cnt[:, 1:] += 1
|
||||||
|
neighbor_sum[:, :-1] += mu_grid[:, 1:]
|
||||||
|
neighbor_cnt[:, :-1] += 1
|
||||||
|
|
||||||
|
neighbor_cnt = np.maximum(neighbor_cnt, 1)
|
||||||
|
mu_bar = neighbor_sum / neighbor_cnt
|
||||||
|
|
||||||
|
mu_grid = 0.5 * mu_grid + 0.5 * mu_bar
|
||||||
|
|
||||||
|
centers_grid = centers.reshape(out_h, out_w, 2)
|
||||||
|
mu_grid[..., 0] = np.clip(mu_grid[..., 0],
|
||||||
|
centers_grid[..., 0] - rx / 4.0,
|
||||||
|
centers_grid[..., 0] + rx / 4.0)
|
||||||
|
mu_grid[..., 1] = np.clip(mu_grid[..., 1],
|
||||||
|
centers_grid[..., 1] - ry / 4.0,
|
||||||
|
centers_grid[..., 1] + ry / 4.0)
|
||||||
|
mu = mu_grid.reshape(K, 2)
|
||||||
|
|
||||||
|
# 2. Constrain spatial covariance via SVD eigenvalue clamping
|
||||||
|
for k in range(K):
|
||||||
|
U, s, Vt = np.linalg.svd(Sigma[k])
|
||||||
|
s = np.clip(s, ev_min, ev_max)
|
||||||
|
Sigma[k] = U @ np.diag(s) @ Vt
|
||||||
|
|
||||||
|
# 3. Shape constraint: check directional variance AND kernel overlap
|
||||||
|
ky_all = np.arange(K) // out_w
|
||||||
|
kx_all = np.arange(K) % out_w
|
||||||
|
|
||||||
|
for k in range(K):
|
||||||
|
ky, kx = ky_all[k], kx_all[k]
|
||||||
|
gamma_k = kernel_gamma[k]
|
||||||
|
idx_k = R[k]
|
||||||
|
|
||||||
|
for dy in range(-1, 2):
|
||||||
|
for dx in range(-1, 2):
|
||||||
|
if dy == 0 and dx == 0:
|
||||||
|
continue
|
||||||
|
ny, nx = ky + dy, kx + dx
|
||||||
|
if not (0 <= ny < out_h and 0 <= nx < out_w):
|
||||||
|
continue
|
||||||
|
nk = ny * out_w + nx
|
||||||
|
|
||||||
|
# Directional variance check
|
||||||
|
d = mu[nk] - mu[k]
|
||||||
|
d_norm = np.linalg.norm(d)
|
||||||
|
if d_norm > 1e-8:
|
||||||
|
d_hat = d / d_norm
|
||||||
|
# Weighted directional variance
|
||||||
|
pi = coords_flat[idx_k]
|
||||||
|
proj = np.maximum(0, (pi - mu[k]) @ d_hat)
|
||||||
|
s = (gamma_k * proj * proj).sum()
|
||||||
|
else:
|
||||||
|
s = 0.0
|
||||||
|
|
||||||
|
# Kernel overlap check
|
||||||
|
# Find common pixels between R(k) and R(nk)
|
||||||
|
idx_n = R[nk]
|
||||||
|
gamma_n = kernel_gamma[nk]
|
||||||
|
common, k_pos, n_pos = np.intersect1d(idx_k, idx_n,
|
||||||
|
return_indices=True)
|
||||||
|
if len(common) > 0:
|
||||||
|
f = (gamma_k[k_pos] * gamma_n[n_pos]).sum()
|
||||||
|
else:
|
||||||
|
f = 0.0
|
||||||
|
|
||||||
|
if s > 0.2 * rx or f < 0.08:
|
||||||
|
sigma_c[k] *= 1.1
|
||||||
|
sigma_c[nk] *= 1.1
|
||||||
|
|
||||||
|
# Convergence check (after 30 iterations, check color stability)
|
||||||
|
if prev_nu is not None and it >= 30:
|
||||||
|
max_delta = np.max(np.abs(nu - prev_nu))
|
||||||
|
if verbose:
|
||||||
|
print(f" iter {it+1}/{n_iters}: max color delta = {max_delta:.6f}")
|
||||||
|
if max_delta < 0.002:
|
||||||
|
if verbose:
|
||||||
|
print(f" Converged at iteration {it+1}")
|
||||||
|
break
|
||||||
|
elif verbose:
|
||||||
|
mu_shift = np.linalg.norm(mu - centers, axis=-1).mean()
|
||||||
|
print(f" iter {it+1}/{n_iters}: mean spatial shift = {mu_shift:.4f} px")
|
||||||
|
|
||||||
|
prev_nu = nu.copy()
|
||||||
|
|
||||||
|
# --- Build output ---
|
||||||
|
output_nlab = nu.reshape(out_h, out_w, 3)
|
||||||
|
|
||||||
|
# Transparency: check opaque fraction per cell
|
||||||
|
output_alpha = np.ones((out_h, out_w), dtype=bool)
|
||||||
|
for k in range(K):
|
||||||
|
ky, kx = ky_all[k], kx_all[k]
|
||||||
|
cx, cy = centers[k]
|
||||||
|
x0 = max(0, int(cx - rx / 2))
|
||||||
|
x1 = min(in_w, int(cx + rx / 2) + 1)
|
||||||
|
y0 = max(0, int(cy - ry / 2))
|
||||||
|
y1 = min(in_h, int(cy + ry / 2) + 1)
|
||||||
|
patch_alpha = alpha_mask[y0:y1, x0:x1]
|
||||||
|
total = patch_alpha.size
|
||||||
|
opaque = patch_alpha.sum()
|
||||||
|
if total > 0 and (opaque / total) < 0.3:
|
||||||
|
output_alpha[ky, kx] = False
|
||||||
|
|
||||||
|
return output_nlab, output_alpha
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Palette assignment
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def assign_to_palette(output_nlab, output_alpha, palette_rgb):
|
||||||
|
"""Assign each output pixel to the nearest color in the provided palette.
|
||||||
|
|
||||||
|
Matches in CIELAB space. Palette is used exactly as-is.
|
||||||
|
"""
|
||||||
|
H, W = output_nlab.shape[:2]
|
||||||
|
|
||||||
|
# Convert palette to normalized CIELAB for comparison
|
||||||
|
pal_rgb01 = palette_rgb.astype(np.float64) / 255.0
|
||||||
|
pal_lab = rgb_to_lab(pal_rgb01.reshape(1, -1, 3)).reshape(16, 3)
|
||||||
|
pal_nlab = normalize_lab(pal_lab)
|
||||||
|
|
||||||
|
# Denormalize output for comparison in same space
|
||||||
|
indices = np.zeros((H, W), dtype=np.uint8)
|
||||||
|
for y in range(H):
|
||||||
|
for x in range(W):
|
||||||
|
if not output_alpha[y, x]:
|
||||||
|
indices[y, x] = 0
|
||||||
|
else:
|
||||||
|
d = np.sum((output_nlab[y, x] - pal_nlab) ** 2, axis=-1)
|
||||||
|
indices[y, x] = np.argmin(d)
|
||||||
|
|
||||||
|
return indices
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def parse_args(argv=None):
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Content-adaptive sprite downscaler for Genesis pixel art.",
|
||||||
|
)
|
||||||
|
p.add_argument("input", help="Input PNG path")
|
||||||
|
p.add_argument("output", help="Output indexed PNG path")
|
||||||
|
p.add_argument("--size", required=True,
|
||||||
|
help="Output size WxH (multiples of 8)")
|
||||||
|
p.add_argument("--palette", required=True,
|
||||||
|
help="Indexed PNG to use as the 16-color palette")
|
||||||
|
p.add_argument("--iters", type=int, default=50,
|
||||||
|
help="Max EM iterations (default 50, converges early)")
|
||||||
|
p.add_argument("--bg-color",
|
||||||
|
help="Treat this R,G,B color as transparent (for RGB inputs)")
|
||||||
|
p.add_argument("--seed", type=int, default=None,
|
||||||
|
help="RNG seed for reproducibility")
|
||||||
|
p.add_argument("--verbose", action="store_true",
|
||||||
|
help="Print per-iteration convergence stats")
|
||||||
|
return p.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
args = parse_args(argv)
|
||||||
|
|
||||||
|
# Parse size
|
||||||
|
try:
|
||||||
|
w_str, h_str = args.size.lower().split("x")
|
||||||
|
out_w, out_h = int(w_str), int(h_str)
|
||||||
|
except ValueError:
|
||||||
|
print(f"Error: --size must be WxH, got '{args.size}'", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if out_w % 8 != 0 or out_h % 8 != 0:
|
||||||
|
print("Error: output dimensions must be multiples of 8", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Load palette
|
||||||
|
palette_path = Path(args.palette)
|
||||||
|
if not palette_path.exists():
|
||||||
|
print(f"Error: palette file not found: {args.palette}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
ext_palette = load_palette(args.palette)
|
||||||
|
|
||||||
|
# Parse bg color
|
||||||
|
bg_color = None
|
||||||
|
if args.bg_color:
|
||||||
|
try:
|
||||||
|
parts = [int(x.strip()) for x in args.bg_color.split(",")]
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise ValueError
|
||||||
|
bg_color = tuple(parts)
|
||||||
|
except ValueError:
|
||||||
|
print("Error: --bg-color must be R,G,B", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Load input
|
||||||
|
input_path = Path(args.input)
|
||||||
|
if not input_path.exists():
|
||||||
|
print(f"Error: input file not found: {args.input}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Loading {args.input}...")
|
||||||
|
nlab_image, alpha_mask = load_input(args.input, bg_color)
|
||||||
|
in_h, in_w = nlab_image.shape[:2]
|
||||||
|
|
||||||
|
if out_w >= in_w or out_h >= in_h:
|
||||||
|
print(f"Error: output ({out_w}x{out_h}) must be smaller than input ({in_w}x{in_h})",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Input: {in_w}x{in_h}")
|
||||||
|
print(f"Output: {out_w}x{out_h}")
|
||||||
|
print(f"Palette: {args.palette}")
|
||||||
|
print(f"Max EM iterations: {args.iters}")
|
||||||
|
|
||||||
|
# Run EM downscale
|
||||||
|
print("Running EM downscale...")
|
||||||
|
output_nlab, output_alpha = run_downscale(
|
||||||
|
nlab_image, alpha_mask, out_h, out_w,
|
||||||
|
n_iters=args.iters, verbose=args.verbose,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assign to provided palette
|
||||||
|
print("Assigning to palette...")
|
||||||
|
indices = assign_to_palette(output_nlab, output_alpha, ext_palette)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
print(f"Saving {args.output}...")
|
||||||
|
save_indexed_png(args.output, indices, ext_palette)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
n_opaque = output_alpha.sum()
|
||||||
|
n_transparent = (~output_alpha).sum()
|
||||||
|
colors_used = len(np.unique(indices[output_alpha])) if n_opaque > 0 else 0
|
||||||
|
print(f"\nDone!")
|
||||||
|
print(f" Opaque pixels: {n_opaque}")
|
||||||
|
print(f" Transparent pixels: {n_transparent}")
|
||||||
|
print(f" Colors used: {colors_used} / 15")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
tools/suika.png
Normal file
BIN
tools/suika.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
tools/suikasm.png
Normal file
BIN
tools/suikasm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Loading…
Add table
Add a link
Reference in a new issue