diff --git a/.claude/skills/snes-developer/SKILL.md b/.claude/skills/snes-developer/SKILL.md index 0e4f3c3..c8f4f3c 100644 --- a/.claude/skills/snes-developer/SKILL.md +++ b/.claude/skills/snes-developer/SKILL.md @@ -23,6 +23,8 @@ export PVSNESLIB_HOME=/c/tools/pvsneslib # if unset export PATH=$PVSNESLIB_HOME/devkitsnes/bin:$PVSNESLIB_HOME/devkitsnes/tools:$PATH ``` +Do not use node.js to build things, use python for tooling around graphics or sound assets. The build process relies on the PVSnesLib toolchain and Makefiles, not JavaScript-based tools. + ## Building PVSnesLib projects use a `Makefile` that includes the SDK's `snes_rules`. From an MSYS2 shell in the project dir: diff --git a/.gitignore b/.gitignore index 691cabb..93dcb34 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,10 @@ linkfile # Helper-script generated backup/source cache tools/sprites_src.bmp +# Python bytecode cache (helper scripts in tools/) +__pycache__/ +*.pyc + # Keep hand-written assembly tracked !hdr.asm !data.asm diff --git a/Makefile b/Makefile index 8e4ae7d..dff40c4 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,20 @@ +#--------------------------------------------------------------------------------- +# snesgame2 - PVSnesLib build (Linux / WSL) +# +# Requires PVSNESLIB_HOME to point at your PVSnesLib install. Set it once in your +# shell profile (~/.bashrc), using a Unix-style path: +# +# export PVSNESLIB_HOME=/opt/pvsneslib +# +# Targets: +# make build everything (assets + ROM) -> snesgame2.sfc +# make all same as `make` +# make artifacts convert source art/sound into SNES data, without linking the ROM +# make clean remove the ROM, build intermediates, and generated asset data +#--------------------------------------------------------------------------------- + ifeq ($(strip $(PVSNESLIB_HOME)),) -$(error "Please create an environment variable PVSNESLIB_HOME pointing at /c/tools/pvsneslib") +$(error PVSNESLIB_HOME is not set. Point it at your PVSnesLib install with a Unix-style path, e.g. `export PVSNESLIB_HOME=/opt/pvsneslib`) endif # BEFORE including snes_rules: @@ -8,56 +23,77 @@ endif AUDIOFILES := res/sfx.it export SOUNDBANK := res/soundbank -include ${PVSNESLIB_HOME}/devkitsnes/snes_rules - -# snes_rules' Windows lib-path math mangles the dir into a broken "C::\..." -# (double colon) under MSYS, so the standard library objects get dropped from -# the linkfile and the link fails. Point it straight at the forward-slash lib -# dir, which both `ls` and wlalink.exe accept. -LIBDIRSOBJSW := $(LIBDIRSOBJS) - -.PHONY: bitmaps musics brrsound all +include $(PVSNESLIB_HOME)/devkitsnes/snes_rules -#--------------------------------------------------------------------------------- -# ROMNAME is used in snes_rules file +# ROMNAME is used by snes_rules to name the linked ROM. export ROMNAME := snesgame2 # smconv flags: -s strip, -o output, -V verbose, -b 5 = place the bank in ROM bank 5 SMCONVFLAGS := -s -o $(SOUNDBANK) -V -b 5 -musics: $(SOUNDBANK).obj -all: musics brrsound bitmaps $(ROMNAME).sfc +.PHONY: all artifacts bitmaps musics brrsound clean + +#--------------------------------------------------------------------------------- +# Top-level targets +#--------------------------------------------------------------------------------- +# Everything: convert the assets, then compile + link the ROM. +all: artifacts $(ROMNAME).sfc + +# Just the converted SNES data: graphics, the sound bank, and the BRR sample. +# Useful to refresh assets (or sanity-check conversions) without a full link. +artifacts: bitmaps musics brrsound + +# The ROM embeds the converted assets, so make sure they exist before linking +# (order-only: regenerated assets don't force a needless relink). +$(ROMNAME).sfc: | artifacts +# Remove build results and generated graphics/audio (rules from snes_rules). clean: cleanBuildRes cleanRom cleanGfx cleanAudio #--------------------------------------------------------------------------------- -# Convert the bundled console font (8x8 tiles) -> .pic + .pal +# Graphics: convert source images -> .pic + .pal (+ .inc / _data.as) +#--------------------------------------------------------------------------------- +# Bundled console font (8x8 tiles) pvsneslibfont.pic: pvsneslibfont.png @echo convert font ... $(notdir $@) $(GFXCONV) -s 8 -o 16 -u 16 -p -e 0 -i $< -# Convert the 32x32 sprite sheet -> .pic + .pal (+ .inc / _data.as) +# Player sprite sheet (32x32) sprites.pic: sprites.bmp @echo convert sprite ... $(notdir $@) $(GFXCONV) -s 32 -o 16 -u 16 -t bmp -i $< -# Convert the 32x32 Sega Genesis enemy -> .pic + .pal +# Sega Genesis enemy (32x32) enemy.pic: enemy.bmp @echo convert enemy ... $(notdir $@) $(GFXCONV) -s 32 -o 16 -u 16 -t bmp -i $< -# Convert the 8x8 player bullet -> .pic + .pal +# Player bullet (8x8) bullet.pic: bullet.bmp @echo convert bullet ... $(notdir $@) $(GFXCONV) -s 8 -o 16 -u 16 -t bmp -i $< -bitmaps: pvsneslibfont.pic sprites.pic enemy.pic bullet.pic +# Bomb power-up pickup (32x32) +powerup.pic: powerup.bmp + @echo convert powerup ... $(notdir $@) + $(GFXCONV) -s 32 -o 16 -u 16 -t bmp -i $< + +# TAC-2 joystick, the tough 3-hit enemy (32x32) +tac2.pic: tac2.bmp + @echo convert tac2 ... $(notdir $@) + $(GFXCONV) -s 32 -o 16 -u 16 -t bmp -i $< + +bitmaps: pvsneslibfont.pic sprites.pic enemy.pic bullet.pic powerup.pic tac2.pic #--------------------------------------------------------------------------------- +# Sound: music/effects bank (smconv) + gun-fire BRR sample (snesbrr) +#--------------------------------------------------------------------------------- +musics: $(SOUNDBANK).obj + # Gun-fire effect: snesbrr-encode the synthesized WAV into a BRR sample. -# res/gunshot.wav is produced by tools/make_gunshot.js (Node) -- regenerate it -# with `node tools/make_gunshot.js` when tweaking the sound; the build itself -# only needs snesbrr so it has no Node dependency. +# res/gunshot.wav is produced by tools/make_gunshot.py -- regenerate it with +# `python3 tools/make_gunshot.py` when tweaking the sound; the build itself only +# needs snesbrr, so it has no Python dependency. res/gunshot.brr: res/gunshot.wav @echo convert gunshot wav -> brr ... $(notdir $@) $(BRCONV) -e $< $@ diff --git a/README.md b/README.md index 0b146bb..592fdd7 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,14 @@ Small SNES homebrew project built with PVSnesLib. The game ROM is generated as ` - C for game logic in `src/main.c` - PVSnesLib for the SNES SDK and build rules -- MSYS2 to provide the shell environment used for builds on Windows +- GNU Make under a Linux or WSL shell - PVSnesLib graphics conversion tools during `make` -- PowerShell helper scripts in `tools/` for regenerating source BMP art +- Python helper scripts in `tools/` for regenerating source BMP art ## Project Layout - `src/main.c`: main game code - `Makefile`: build rules and graphics conversion steps -- `build.sh`: Windows-friendly wrapper that sets `PVSNESLIB_HOME` and runs `make` - `tools/`: helper scripts to regenerate some sprite BMP assets - `*.bmp` / `pvsneslibfont.png`: authored source graphics used by the build - `*.pic`, `*.pal`, `*.inc`, `*_data.as`: generated SNES graphics data consumed by the game @@ -22,39 +21,27 @@ Small SNES homebrew project built with PVSnesLib. The game ROM is generated as ` ## Required Tools -This repository assumes a Windows setup matching the current build scripts. +The build targets a Linux or WSL shell with GNU Make. -### 1. MSYS2 +### 1. PVSnesLib -Expected location: +Install PVSnesLib and set `PVSNESLIB_HOME` to point at it, using a Unix-style +path. Add it to your shell profile (e.g. `~/.bashrc`) so every shell has it: -```powershell -C:/msys64 -``` - -The README commands below use: - -```powershell -C:/msys64/usr/bin/bash.exe -``` - -### 2. PVSnesLib - -Expected location: - -```powershell -C:/tools/pvsneslib +```sh +export PVSNESLIB_HOME=/opt/pvsneslib ``` -The build expects `PVSNESLIB_HOME` to point there. The provided `build.sh` sets it automatically. +The Makefile reads `PVSNESLIB_HOME` and stops with an error if it isn't set. -### 3. PowerShell +### 2. Python 3 Needed to run the helper scripts in `tools/` that regenerate some BMP assets. +They use only the standard library (no `pip install` required). -### 4. SNES Emulator +### 3. SNES Emulator -Any emulator that can load `.sfc` ROMs should work. The local project notes target ZSNES on Windows. +Any emulator that can load `.sfc` ROMs should work. ## Build Process @@ -66,56 +53,45 @@ The build works in two stages: The Makefile target flow is: ```make -all: bitmaps snesgame2.sfc +all: artifacts snesgame2.sfc ``` +`artifacts` groups the asset-conversion steps (`bitmaps`, `musics`, `brrsound`). The `bitmaps` target regenerates SNES-ready graphics data from the source images: - `pvsneslibfont.png` -> `pvsneslibfont.pic`, `pvsneslibfont.pal` - `sprites.bmp` -> `sprites.pic`, `sprites.pal` - `enemy.bmp` -> `enemy.pic`, `enemy.pal` +- `tac2.bmp` -> `tac2.pic`, `tac2.pal` - `bullet.bmp` -> `bullet.pic`, `bullet.pal` From those assets, the PVSnesLib build rules generate the final ROM. ## Build Commands -### Recommended: use the wrapper script +With `PVSNESLIB_HOME` set (see Required Tools), build from the project directory: -From PowerShell: - -```powershell -& C:/msys64/usr/bin/bash.exe -lc "sh /c/dev/snesgame2/build.sh" +```sh +make ``` -This script does the environment setup for you: - -- sets `PVSNESLIB_HOME=C:/tools/pvsneslib` -- adds the PVSnesLib tools to `PATH` -- changes into `/c/dev/snesgame2` -- runs `make` +To rebuild just the converted assets without linking the ROM: -### Clean build artifacts - -```powershell -& C:/msys64/usr/bin/bash.exe -lc "sh /c/dev/snesgame2/build.sh clean" +```sh +make artifacts ``` -### Alternative: build manually inside MSYS2 - -If you want to invoke `make` directly, you need the same environment variables: +To remove all build output and generated asset data: ```sh -export PVSNESLIB_HOME=C:/tools/pvsneslib -export PATH=$PVSNESLIB_HOME/devkitsnes/bin:$PVSNESLIB_HOME/devkitsnes/tools:$PATH -cd /c/dev/snesgame2 -make +make clean ``` ## Make Targets -- `make`: build graphics, compile code, and produce `snesgame2.sfc` -- `make clean`: remove build results and generated graphics outputs through the PVSnesLib clean rules +- `make` / `make all`: convert assets, compile code, and produce `snesgame2.sfc` +- `make artifacts`: convert source art/sound into SNES data, without linking the ROM +- `make clean`: remove the ROM, build intermediates, and generated asset data through the PVSnesLib clean rules ## Asset Workflow @@ -124,7 +100,8 @@ This project keeps source art in standard image formats and converts them during ### Source assets used by `make` - `sprites.bmp`: player sprite sheet -- `enemy.bmp`: enemy sprite sheet +- `enemy.bmp`: enemy sprite sheet (Genesis console; one hit to destroy) +- `tac2.bmp`: tough enemy sprite (TAC-2 joystick; takes three hits to destroy) - `bullet.bmp`: bullet sprite - `pvsneslibfont.png`: font image @@ -141,9 +118,9 @@ These generated files are already present in the repository, but they can be reg ## Helper Scripts -The PowerShell scripts in `tools/` are not part of the normal `make` target. They are helper utilities for regenerating the source BMP files before running the build. +The Python scripts in `tools/` are not part of the normal `make` target. They are helper utilities for regenerating the source BMP files before running the build. They share `tools/snesbmp.py` (a small standard-library helper that draws onto an indexed canvas and writes the 8bpp BMP), and they use only the Python standard library, so they run the same on Windows, Linux, and WSL. -### `tools/make_player_bmp.ps1` +### `tools/make_player_bmp.py` - Rebuilds `sprites.bmp` - Preserves an original backup at `tools/sprites_src.bmp` @@ -151,11 +128,11 @@ The PowerShell scripts in `tools/` are not part of the normal `make` target. The Run it with: -```powershell -& ./tools/make_player_bmp.ps1 +```sh +python3 tools/make_player_bmp.py ``` -### `tools/make_enemy_bmp.ps1` +### `tools/make_enemy_bmp.py` - Generates `enemy.bmp` - Creates an 8bpp `32x32` enemy sprite with a fixed palette @@ -163,19 +140,32 @@ Run it with: Run it with: -```powershell -& ./tools/make_enemy_bmp.ps1 +```sh +python3 tools/make_enemy_bmp.py ``` -### `tools/make_bullet_bmp.ps1` +### `tools/make_tac2_bmp.py` + +- Generates `tac2.bmp` +- Creates an 8bpp `32x32` sprite of a Suncom TAC-2 arcade joystick (red ball-top stick, chrome shaft, black base with a red fire button) +- Draws the art in the top-left `21x21` of the `32x32` cell so it lines up with the enemy hit-box +- This is the tougher enemy: it takes three hits to destroy (a screen-clearing bomb still flattens it in one), descends more slowly, and is worth more points + +Run it with: + +```sh +python3 tools/make_tac2_bmp.py +``` + +### `tools/make_bullet_bmp.py` - Generates `bullet.bmp` - Creates an 8bpp `8x8` bullet sprite with a small fixed palette Run it with: -```powershell -& ./tools/make_bullet_bmp.ps1 +```sh +python3 tools/make_bullet_bmp.py ``` After running any of these scripts, rebuild the project so the `.bmp` changes are converted into SNES asset data. @@ -194,26 +184,27 @@ Other intermediate files such as object files, symbol files, and converted graph ### `PVSNESLIB_HOME` is not set -If `make` fails immediately with a message about `PVSNESLIB_HOME`, use the provided wrapper command instead of running `make` directly, or export the variable manually before building. - -### `bash.exe` or MSYS2 path not found +If `make` stops immediately with a message about `PVSNESLIB_HOME`, export the +variable so it points at your PVSnesLib install (a Unix-style path), e.g. +`export PVSNESLIB_HOME=/opt/pvsneslib`, and add it to your shell profile so it +persists. -Install MSYS2 in `C:/msys64`, or adjust the commands in this README to match your installation path. +### PVSnesLib path errors -### PVSnesLib not found - -Install PVSnesLib at `C:/tools/pvsneslib`, or update `build.sh` and your environment to match the actual location. +`PVSNESLIB_HOME` must be a Unix-style path (PVSnesLib's build rules reject paths +containing a backslash). Under WSL, point it at a Linux path or a `/mnt/c/...` +mount, not a `C:\...` path. ### Asset changes do not appear in the ROM If you edited or regenerated a BMP or the font PNG, rebuild the ROM so the graphics conversion step runs again. -## Typical Windows Workflow +## Typical Workflow -```powershell -& ./tools/make_enemy_bmp.ps1 -& ./tools/make_bullet_bmp.ps1 -& C:/msys64/usr/bin/bash.exe -lc "sh /c/dev/snesgame2/build.sh" +```sh +python3 tools/make_enemy_bmp.py +python3 tools/make_bullet_bmp.py +make ``` -If you did not change the source art, only the build command is needed. +If you did not change the source art, only `make` is needed. diff --git a/build.sh b/build.sh deleted file mode 100644 index 349a568..0000000 --- a/build.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -# Windows-form path so the generated linkfile uses C:/... which the native -# wlalink.exe can open (a /c/... linkfile path fails at link time). -export PVSNESLIB_HOME=C:/tools/pvsneslib -export PATH=$PVSNESLIB_HOME/devkitsnes/bin:$PVSNESLIB_HOME/devkitsnes/tools:$PATH -cd /c/dev/snesgame2 -make "$@" diff --git a/data.asm b/data.asm index 1b401a2..4b3e0a7 100644 --- a/data.asm +++ b/data.asm @@ -44,6 +44,32 @@ bullet_palend: .ends +; --- Bomb power-up graphics (generated from powerup.bmp by gfx4snes) --- +.section ".ropowerup" superfree + +powerup_til: +.incbin "powerup.pic" +powerup_tilend: + +powerup_pal: +.incbin "powerup.pal" +powerup_palend: + +.ends + +; --- TAC-2 joystick enemy graphics (generated from tac2.bmp by gfx4snes) --- +.section ".rotac2" superfree + +tac2_til: +.incbin "tac2.pic" +tac2_tilend: + +tac2_pal: +.incbin "tac2.pal" +tac2_palend: + +.ends + ; --- Gun-fire sound effect (BRR sample, snesbrr-encoded from res/gunshot.wav) --- .section ".robrr" superfree diff --git a/powerup.bmp b/powerup.bmp new file mode 100644 index 0000000..76e3ea8 --- /dev/null +++ b/powerup.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49e3becb8e61bf6f5be4a61c277cafdec96e2654fdebc09487f7945661b808df +size 2102 diff --git a/res/gunshot.brr b/res/gunshot.brr index 5fe3f71..65e4e9d 100644 Binary files a/res/gunshot.brr and b/res/gunshot.brr differ diff --git a/res/gunshot.wav b/res/gunshot.wav index d772bb6..6ac62fb 100644 --- a/res/gunshot.wav +++ b/res/gunshot.wav @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:422ee57911771b0a660d7fa73b755d3546513742880d2978c4e57066d7a8e8da +oid sha256:f703612ba270657561ba27e4f765feef7c43a5c331ef40ce53c3f97ecfc17ec3 size 10284 diff --git a/src/main.c b/src/main.c index f718dc6..a4daf9f 100644 --- a/src/main.c +++ b/src/main.c @@ -12,6 +12,8 @@ #include "sprites.inc" // player: sprites_til/_tilend, sprites_pal/_palend #include "enemy.inc" // enemy: enemy_til/_tilend, enemy_pal/_palend #include "bullet.inc" // bullet: bullet_til/_tilend, bullet_pal/_palend +#include "powerup.inc" // bomb pickup: powerup_til/_tilend, powerup_pal/_palend +#include "tac2.inc" // tough enemy: tac2_til/_tilend, tac2_pal/_palend #include "res/soundbank.h" // soundbank module ids (generated by smconv) @@ -44,50 +46,120 @@ brrsamples gunSound; #define sfxFire() spcPlaySound(0) // Enemy hit: a clean high marimba "ding" to confirm the kill. #define sfxEnemyHit() spcEffect(8, SFX_MARIMBA, 12 * 16 + PAN_CENTER) +// Tough enemy chipped but not killed: a duller, lower marimba "thunk". +#define sfxTankHit() spcEffect(4, SFX_MARIMBA, 10 * 16 + PAN_CENTER) // Player collision: a low, hard metallic cowbell whack at max volume -- the // percussive sample gives it a sharp attack (punch) and the low pitch makes it // harsh rather than the softer piano thud it used to be. #define sfxCollide() spcEffect(2, SFX_COWBELL, 15 * 16 + PAN_CENTER) // Game over: deep, slow strings = mournful. #define sfxGameOver() spcEffect(1, SFX_STRINGS, 15 * 16 + PAN_CENTER) +// Bomb power-up collected: a bright, high "tada" flourish. +#define sfxPowerup() spcEffect(8, SFX_TADA, 14 * 16 + PAN_CENTER) + +// --- Title-screen fanfare ------------------------------------------------------ +// A short, hard-driving riff played on the percussive cowbell sample (sharp +// attack = punch) at full volume while the splash screen is up. spcEffect pitch +// indices map to playback rates of 4*index kHz, so the indices 4:6:8 sound that +// same frequency ratio -- root, fifth and octave. The riff leans on the low root +// (4) on the beat with high stabs (8) for an aggressive, galloping feel. +#define JINGLE_STEP_FRAMES 6 // frames each note is held (~10 notes/sec, driving) +const unsigned char jingleNotes[] = { + 8, 4, 8, 6, 4, 8, 6, 4 // fast, accented arpeggio -- punchy low root on every beat +}; +#define JINGLE_LEN (sizeof(jingleNotes)) #define SCREEN_W 256 #define ENEMY_SIZE 21 // art is 2/3 of the 32x32 cell (rest transparent) #define PLAYER_SIZE 21 #define BULLET_SIZE 8 -#define NUM_ENEMIES 5 +#define MAX_ENEMIES 12 // hard cap: sizes the enemy arrays + reserved OAM slots +#define START_ENEMIES 3 // active enemies on screen at score 0 +#define ENEMIES_PER_LEVEL 1000 // +1 active enemy per this many points; slow ramp to MAX_ENEMIES + // (SCORE_PER_KILL is 100, so ~10 kills per extra enemy) #define NUM_BULLETS 4 +#define NUM_STARS 24 // falling background starfield #define BULLET_SPEED 4 #define FLASH_FRAMES 12 // how long a hit console blinks before it disappears -#define OFFSCREEN_Y 240 // any y >= 224 is below the visible 224-line screen +#define OFFSCREEN_Y 224 // park spot for hidden sprites. Must clear the 224-line + // screen (y >= 224) AND keep a 32-tall sprite from wrapping: + // SNES sprite Y is 8-bit, so y+31 must stay <= 255 or the + // sprite's lower rows reappear at the top of the screen + // (the upper-left "ghost"). y = 224 is the only value that + // satisfies both for our 32x32 player/enemy sprites. #define PLAYFIELD_TOP 24 // top 3 tile-rows (y 0..23) are the stats bar #define SCORE_PER_KILL 100 +#define TANK_SCORE 300 // a 3-hit TAC-2 joystick is worth triple a console +#define TANK_HEALTH 3 // hits to destroy the tough enemy +#define TANK_CHANCE 5 // ~1 in this many spawns is a tough enemy +#define HURT_FRAMES 8 // brief flicker after a non-lethal hit +// Enemy kinds (per-slot): a normal Genesis console, or the tough TAC-2 joystick. +#define ENEMY_NORMAL 0 +#define ENEMY_TANK 1 #define START_LIVES 5 +#define START_BOMBS 1 +#define MAX_BOMBS 9 // inventory is shown as a single HUD digit +#define POWERUP_SIZE 16 // bomb-pickup hit-box. The bomb art is drawn at half + // size (16x16) in the top-left of its 32x32 sprite cell, + // so the catch box is the upper-left 16x16 of the cell. +#define POWERUP_SPEED 1 // pixels/frame the dropped bomb falls #define PLAYER_FLASH_FRAMES 60 // ~1s of blink + invulnerability after a hit // Each gfx4snes 32x32 sprite is a 64-tile (0x400-word) block; lay them end to end. #define VRAM_PLAYER 0x0000 #define VRAM_ENEMY 0x0400 #define VRAM_BULLET 0x0800 +#define VRAM_POWERUP 0x0C00 // free VRAM gap (0x0C00..0x0FFF), below the starfield +// The TAC-2 enemy block sits in the free 0x400-word gap between the starfield +// map (ends 0x1BFF) and BG0 gfx (0x2000) -- still inside the sprite name range. +#define VRAM_TANK 0x1C00 #define GFX_PLAYER 0x0000 -#define GFX_ENEMY 0x0040 // VRAM_ENEMY / 16 -#define GFX_BULLET 0x0080 // VRAM_BULLET / 16 +#define GFX_ENEMY 0x0040 // VRAM_ENEMY / 16 +#define GFX_BULLET 0x0080 // VRAM_BULLET / 16 +#define GFX_POWERUP 0x00C0 // VRAM_POWERUP / 16 +#define GFX_TANK 0x01C0 // VRAM_TANK / 16 // Sprite palettes live at CGRAM 128, 16 entries apart. -#define PAL_ENEMY_CG (128 + 1 * 16) -#define PAL_BULLET_CG (128 + 2 * 16) +#define PAL_ENEMY_CG (128 + 1 * 16) +#define PAL_BULLET_CG (128 + 2 * 16) +#define PAL_POWERUP_CG (128 + 3 * 16) +#define PAL_TANK_CG (128 + 4 * 16) +#define PAL_TANK_SP 4 // sprite palette number for the TAC-2 (CGRAM 192..207) // OAM sprite ids are the sprite index * 4. #define OAM_PLAYER 0 #define OAM_ENEMY(i) (((i) + 1) * 4) -#define OAM_BULLET(j) (((j) + 1 + NUM_ENEMIES) * 4) +#define OAM_BULLET(j) (((j) + 1 + MAX_ENEMIES) * 4) +#define OAM_POWERUP ((1 + MAX_ENEMIES + NUM_BULLETS) * 4) + +// --- Dark blue night-sky backdrop (CGRAM entry 0) --- +#define SKY_COLOR RGB5(2, 4, 12) + +// --- Scrolling starfield on BG2 (the 4-color/2bpp layer in mode 1) --- +// A static tilemap of single-pixel "stars" that we just scroll vertically each +// frame: one register write, no per-star sprite work and no scanline pressure. +// BG char base must be 0x1000-word aligned; map base 0x400-aligned. Both live in +// the free VRAM gap above the sprite tiles (0x0000..0x0BFF) and below BG0 (0x2000). +#define STAR_GFX_VRAM 0x1000 // tile gfx, char base (0x1000..0x11FF used) +#define STAR_MAP_VRAM 0x1800 // 32x32 map = 0x400 words (0x1800..0x1BFF) +#define NUM_STARS 32 // a sparse scatter of distinct stars over 256x256 +#define STAR_PAL 4 // BG sub-palette -> CGRAM 16..19, clear of the font (0..15) +#define STAR_SCROLL_PX 1 // pixels the field drifts down per frame // --- Enemy state --- -short enemyX[NUM_ENEMIES]; -short enemyY[NUM_ENEMIES]; // logical y (pixels); negative = sliding in from the top -short enemySpeed[NUM_ENEMIES]; // descent speed in 1/256ths of a pixel per frame -unsigned char enemyFrac[NUM_ENEMIES]; // sub-pixel accumulator for slow movement -unsigned char enemyFlash[NUM_ENEMIES]; // >0 while playing the hit/blink animation +short enemyX[MAX_ENEMIES]; +short enemyY[MAX_ENEMIES]; // logical y (pixels); negative = sliding in from the top +short enemySpeed[MAX_ENEMIES]; // descent speed in 1/256ths of a pixel per frame +unsigned char enemyFrac[MAX_ENEMIES]; // sub-pixel accumulator for slow movement +unsigned char enemyFlash[MAX_ENEMIES]; // >0 while playing the death/blink animation +unsigned char enemyHurt[MAX_ENEMIES]; // >0 = brief flicker after a non-lethal hit +unsigned char enemyType[MAX_ENEMIES]; // ENEMY_NORMAL (console) or ENEMY_TANK (joystick) +unsigned char enemyHealth[MAX_ENEMIES]; // hits remaining before destruction +unsigned char activeEnemies = START_ENEMIES; // how many enemies are live right now; + // grows with score up to MAX_ENEMIES +unsigned short nextEnemyScore = ENEMIES_PER_LEVEL; // score at which the next enemy joins + // (a running threshold -- avoids a + // per-frame divide) // --- Score --- unsigned short score = 0; @@ -101,11 +173,31 @@ unsigned char playerFlash = 0; // >0 = recently hit: blinking + invuln unsigned char gameOver = 0; char livesStr[4]; +// --- Bomb inventory --- +unsigned char bombs = START_BOMBS; +unsigned char lastBombs = 0xFF; // force first HUD draw +char bombStr[4]; + // --- Bullet state --- short bulletX[NUM_BULLETS]; short bulletY[NUM_BULLETS]; unsigned char bulletActive[NUM_BULLETS]; +// --- Bomb power-up drop --- +// At most one bomb pickup exists at a time: it falls straight down from a +// destroyed console, and catching it adds a bomb to the inventory. +short powerupX, powerupY; +unsigned char powerupActive = 0; +unsigned char killsSinceDrop = 0; // kills counted toward the next drop +unsigned char dropThreshold = 10; // drop after ~this many kills (jittered) + +// --- Falling starfield (BG2 layer, behind everything) --- +// Tile 0 is blank; tiles 1..NUM_STARS each carry exactly one star pixel, so every +// star is its own crisp point with no repeating tile pattern. +unsigned char starTiles[(NUM_STARS + 1) * 16]; // 2bpp tile gfx, built at startup +unsigned short starMap[32 * 32]; // 32x32 tilemap, built at startup +unsigned short starScroll = 0; // current vertical scroll offset + // --- 16-bit xorshift PRNG --- unsigned short rngState = 0xACE1; unsigned short rnd(void) @@ -116,27 +208,116 @@ unsigned short rnd(void) return rngState; } +// Point an enemy's OAM slot at the right gfx + palette for its kind. The console +// and the TAC-2 joystick live in separate VRAM/palette slots, so this must run +// whenever a slot's type changes (i.e. on every spawn). Position is set per-frame +// by the draw loop, so park it off-screen here. +void setEnemyAppearance(unsigned char i) +{ + if (enemyType[i] == ENEMY_TANK) + oamSet(OAM_ENEMY(i), 0, OFFSCREEN_Y, 2, 0, 0, GFX_TANK, PAL_TANK_SP); + else + oamSet(OAM_ENEMY(i), 0, OFFSCREEN_Y, 2, 0, 0, GFX_ENEMY, 1); + oamSetEx(OAM_ENEMY(i), OBJ_LARGE, OBJ_SHOW); +} + // (Re)spawn an enemy above the top of the screen at a random column/speed. +// Roughly 1 in TANK_CHANCE is a tough TAC-2 joystick: 3 hits to kill, and it +// descends more slowly to offset the extra effort it takes to destroy. void spawnEnemy(unsigned char i) { short nx = (short)(rnd() & 0xFF); if (nx > SCREEN_W - ENEMY_SIZE) nx -= ENEMY_SIZE; // keep fully on-screen (0..224) enemyX[i] = nx; enemyY[i] = -ENEMY_SIZE - (short)(rnd() & 0x7F); // staggered above the screen - enemySpeed[i] = 100 + (rnd() & 0x3F); // ~0.39..0.64 px/frame (was 1..2) enemyFrac[i] = 0; enemyFlash[i] = 0; + enemyHurt[i] = 0; + + if ((rnd() % TANK_CHANCE) == 0) + { + enemyType[i] = ENEMY_TANK; + enemyHealth[i] = TANK_HEALTH; + enemySpeed[i] = 60 + (rnd() & 0x1F); // ~0.23..0.36 px/frame (heavier) + } + else + { + enemyType[i] = ENEMY_NORMAL; + enemyHealth[i] = 1; + enemySpeed[i] = 100 + (rnd() & 0x3F); // ~0.39..0.64 px/frame + } + setEnemyAppearance(i); +} + +// Build the starfield once. Each star gets its own tile holding a single pixel +// and is dropped into its own (empty) map cell, so the result is sparse, evenly +// spread, distinct points -- not a repeating, noisy texture. After this the +// layer is static data; we only ever change its scroll offset. +void buildStarfield(void) +{ + unsigned short i, cell; + unsigned char s, row, col, plane; + + for (i = 0; i < (NUM_STARS + 1) * 16; i++) starTiles[i] = 0; // all tiles blank + for (i = 0; i < 32 * 32; i++) starMap[i] = 0; // empty field + + for (s = 1; s <= NUM_STARS; s++) + { + do { cell = rnd() & 0x3FF; } while (starMap[cell] != 0); // one star per cell + row = rnd() & 7; + col = rnd() & 7; + // Mostly dim (color 2); roughly one in four is a bright (color 1) star. + plane = ((rnd() & 3) == 0) ? 0 : 1; + starTiles[s * 16 + row * 2 + plane] = 1 << (7 - col); + starMap[cell] = (unsigned short)s | (STAR_PAL << 10); + } +} + +// Drop a bomb power-up at (sx, sy) -- where a console was just destroyed -- +// unless one is already falling. Clamp into the playfield so it stays visible. +void spawnPowerup(short sx, short sy) +{ + if (powerupActive) return; + if (sy < PLAYFIELD_TOP) sy = PLAYFIELD_TOP; + powerupX = sx; + powerupY = sy; + powerupActive = 1; } -// Start the hit animation on an enemy that isn't already reacting, and score it. -void hitEnemy(unsigned char i) +// Destroy an enemy: start the death animation, score it, and maybe drop a bomb. +// A no-op if it's already dying. Tougher TAC-2 joysticks are worth more points. +void killEnemy(unsigned char i) { if (enemyFlash[i] == 0) { enemyFlash[i] = FLASH_FRAMES; - score += SCORE_PER_KILL; + score += (enemyType[i] == ENEMY_TANK) ? TANK_SCORE : SCORE_PER_KILL; sfxEnemyHit(); + + // Roughly every ~10 kills, drop a bomb power-up where this enemy died. + if (++killsSinceDrop >= dropThreshold && !powerupActive) + { + spawnPowerup(enemyX[i], enemyY[i]); + killsSinceDrop = 0; + dropThreshold = 8 + (rnd() % 5); // 8..12 kills until the next one + } + } +} + +// Apply one bullet's worth of damage to an enemy. A normal console dies in one +// hit; the tough TAC-2 joystick soaks up HURT hits (flickering each time) and +// only dies on the blow that drops its health to zero. +void damageEnemy(unsigned char i) +{ + if (enemyFlash[i] > 0) return; // already dying + if (enemyHealth[i] > 1) + { + enemyHealth[i]--; + enemyHurt[i] = HURT_FRAMES; // brief "chipped" flicker + sfxTankHit(); + return; } + killEnemy(i); } // Fire a bullet from the player's top-centre, if a slot is free. @@ -161,7 +342,7 @@ void drawScore(void) if (score == lastScore) return; lastScore = score; sprintf(scoreStr, "%05u", score); - consoleDrawText(7, 2, scoreStr); + consoleDrawText(7, 1, scoreStr); } // Lose SCORE_PER_KILL points (the value an un-hit kill would have earned), clamped at 0. @@ -177,7 +358,64 @@ void drawLives(void) if (lives == lastLives) return; lastLives = lives; sprintf(livesStr, "%u", (unsigned short)lives); // promote: 816-tcc won't widen char varargs - consoleDrawText(24, 2, livesStr); + consoleDrawText(19, 1, livesStr); +} + +// Redraw the bomb count in the stats bar, only when it changed. +void drawBombs(void) +{ + if (bombs == lastBombs) return; + lastBombs = bombs; + sprintf(bombStr, "%u", (unsigned short)bombs); + consoleDrawText(27, 1, bombStr); +} + +// Intro splash screen: title + a "START GAME" menu item over the drifting +// starfield, with a looping marimba fanfare. Blocks until the player presses +// START, then wipes its text so nothing bleeds into the playfield. Assumes the +// SPC700 is already booted and the starfield/BG layers are up. +void showTitleScreen(void) +{ + unsigned short down0; + unsigned char step = 0, frame = 0, note; + + // Keep the player sprite out of sight while the splash is up. + oamSet(OAM_PLAYER, 0, OFFSCREEN_Y, 3, 0, 0, GFX_PLAYER, 0); + + // Title + menu item, roughly centred on the 32-column screen. + consoleDrawText(11, 9, "MEGA DRIVER"); + consoleDrawText(12, 11, "INVADERS"); + consoleDrawText( 9, 16, "> START GAME <"); + consoleDrawText(10, 20, "PRESS START"); + + while (1) + { + down0 = padsDown(0); + + // Let the starfield keep drifting under the title. + starScroll -= STAR_SCROLL_PX; + bgSetScroll(2, 0, starScroll); + + // Step the fanfare: one note (or rest) every JINGLE_STEP_FRAMES, looping. + if (frame == 0) + { + note = jingleNotes[step]; + if (note) spcEffect(note, SFX_COWBELL, 15 * 16 + PAN_CENTER); + if (++step >= JINGLE_LEN) step = 0; + } + if (++frame >= JINGLE_STEP_FRAMES) frame = 0; + + if (down0 & KEY_START) break; + + spcProcess(); + WaitForVBlank(); + } + + // Wipe the splash text (overwrite each line with matching-length blanks). + consoleDrawText(11, 9, " "); + consoleDrawText(12, 11, " "); + consoleDrawText( 9, 16, " "); + consoleDrawText(10, 20, " "); } int main(void) @@ -208,15 +446,29 @@ int main(void) bgSetMapPtr(0, 0x6800, SC_32x32); setMode(BG_MODE1, 0); bgSetDisable(1); - bgSetDisable(2); + + // Dark blue night sky instead of the default black backdrop. + setPaletteColor(0, SKY_COLOR); + + // --- Scrolling starfield on BG2 (built once, then only scrolled) --- + buildStarfield(); + dmaCopyVram(starTiles, STAR_GFX_VRAM, sizeof(starTiles)); + dmaCopyVram((u8 *)starMap, STAR_MAP_VRAM, sizeof(starMap)); + bgSetGfxPtr(2, STAR_GFX_VRAM); + bgSetMapPtr(2, STAR_MAP_VRAM, SC_32x32); + setPaletteColor(STAR_PAL * 4 + 1, RGB5(31, 31, 31)); // bright star + setPaletteColor(STAR_PAL * 4 + 2, RGB5(15, 17, 26)); // dim star + bgSetEnable(2); // --- Stats bar: top 3 rows (y 0..23), kept clear of gameplay --- - // Row 0 lands in the overscan ZSNES crops, so keep HUD text on rows 1-2. - consoleDrawText(6, 1, "MEGA DRIVER INVADERS"); - consoleDrawText(1, 2, "SCORE"); - consoleDrawText(18, 2, "LIVES"); + // Row 0 lands in the overscan ZSNES crops, so the HUD sits on row 1 (the + // topmost fully-visible row). + consoleDrawText(1, 1, "SCORE"); + consoleDrawText(13, 1, "LIVES"); + consoleDrawText(21, 1, "BOMBS"); drawScore(); drawLives(); + drawBombs(); // --- Player gfx -> VRAM 0x0000, palette slot 0. Sizes: small 8, large 32. --- oamInitGfxSet(&sprites_til, (&sprites_tilend - &sprites_til), @@ -226,19 +478,32 @@ int main(void) // --- Enemy + bullet gfx/palettes (loaded while the screen is still off) --- dmaCopyVram(&enemy_til, VRAM_ENEMY, (&enemy_tilend - &enemy_til)); dmaCopyVram(&bullet_til, VRAM_BULLET, (&bullet_tilend - &bullet_til)); + dmaCopyVram(&powerup_til, VRAM_POWERUP, (&powerup_tilend - &powerup_til)); + dmaCopyVram(&tac2_til, VRAM_TANK, (&tac2_tilend - &tac2_til)); dmaCopyCGram(&enemy_pal, PAL_ENEMY_CG, (&enemy_palend - &enemy_pal)); dmaCopyCGram(&bullet_pal, PAL_BULLET_CG, (&bullet_palend - &bullet_pal)); + dmaCopyCGram(&powerup_pal, PAL_POWERUP_CG, (&powerup_palend - &powerup_pal)); + dmaCopyCGram(&tac2_pal, PAL_TANK_CG, (&tac2_palend - &tac2_pal)); // Player: sprite 0, priority 3 (in front), large (32x32), palette 0 oamSet(OAM_PLAYER, x, y, 3, 0, 0, GFX_PLAYER, 0); oamSetEx(OAM_PLAYER, OBJ_LARGE, OBJ_SHOW); - // Enemies: large (32x32), palette 1 - for (i = 0; i < NUM_ENEMIES; i++) + // Enemies: large (32x32), palette 1. Reserve all MAX_ENEMIES OAM slots (parked + // off-screen) so the dormant ones stay hidden until the score ramp activates + // them; only the START_ENEMIES active ones get a starting position. + for (i = 0; i < MAX_ENEMIES; i++) { - spawnEnemy(i); - oamSet(OAM_ENEMY(i), 0, OFFSCREEN_Y, 2, 0, 0, GFX_ENEMY, 1); - oamSetEx(OAM_ENEMY(i), OBJ_LARGE, OBJ_SHOW); + enemyType[i] = ENEMY_NORMAL; + enemyHealth[i] = 1; + enemyHurt[i] = 0; + if (i < activeEnemies) + spawnEnemy(i); // sets position + the right gfx/palette + else + { + oamSet(OAM_ENEMY(i), 0, OFFSCREEN_Y, 2, 0, 0, GFX_ENEMY, 1); + oamSetEx(OAM_ENEMY(i), OBJ_LARGE, OBJ_SHOW); + } } // Bullets: small (8x8), palette 2, parked off-screen until fired @@ -249,16 +514,57 @@ int main(void) oamSetEx(OAM_BULLET(j), OBJ_SMALL, OBJ_SHOW); } + // Bomb power-up: large (32x32), palette 3, parked off-screen until dropped + oamSet(OAM_POWERUP, 0, OFFSCREEN_Y, 2, 0, 0, GFX_POWERUP, 3); + oamSetEx(OAM_POWERUP, OBJ_LARGE, OBJ_SHOW); + setScreenOn(); + // Intro splash: title + "START GAME" menu, with a fanfare. Returns once the + // player presses START, dropping straight into the action below. + showTitleScreen(); + while (1) { pad0 = padsCurrent(0); down0 = padsDown(0); - // Out of lives: freeze the field (sprites hold their last positions). + // --- Starfield: scroll the whole BG2 layer down one notch. Done here, + // during the post-VBlank window, so it stays tear-free, and before + // the game-over check so the sky keeps drifting while frozen. --- + starScroll -= STAR_SCROLL_PX; + bgSetScroll(2, 0, starScroll); + + // Out of lives: freeze the field (sprites hold their last positions) + // until START is pressed, then wipe state and drop back into the action. if (gameOver) { + if (down0 & KEY_START) + { + score = 0; lastScore = 0xFFFF; // force HUD redraw + lives = START_LIVES; lastLives = 0xFF; + bombs = START_BOMBS; lastBombs = 0xFF; + playerFlash = 0; + powerupActive = 0; + killsSinceDrop = 0; dropThreshold = 10; + x = 112; y = 170; // player back at the start + activeEnemies = START_ENEMIES; // back to the gentle opening wave + nextEnemyScore = ENEMIES_PER_LEVEL; // reset the difficulty ramp + for (i = 0; i < MAX_ENEMIES; i++) + { + enemyType[i] = ENEMY_NORMAL; + enemyHealth[i] = 1; + enemyHurt[i] = 0; + if (i < activeEnemies) + spawnEnemy(i); // fresh active enemies (sets gfx/palette) + else + oamSet(OAM_ENEMY(i), 0, OFFSCREEN_Y, 2, 0, 0, GFX_ENEMY, 1); // park the rest + } + for (j = 0; j < NUM_BULLETS; j++) bulletActive[j] = 0; + consoleDrawText(11, 13, " "); // erase "GAME OVER" + consoleDrawText(10, 15, " "); // erase "PRESS START" + gameOver = 0; + } spcProcess(); WaitForVBlank(); continue; @@ -271,16 +577,22 @@ int main(void) if (down0 & (KEY_A | KEY_B)) { fireBullet(x, y); sfxFire(); } - // Y = screen-clearing bomb: detonate every console at once. - if (down0 & KEY_Y) - for (i = 0; i < NUM_ENEMIES; i++) hitEnemy(i); + // Y = screen-clearing bomb: spend one from inventory to detonate every + // console at once. Does nothing when the player is out of bombs. + if ((down0 & KEY_Y) && bombs > 0) + { + bombs--; + for (i = 0; i < activeEnemies; i++) killEnemy(i); // a bomb flattens even tanks + } // Draw the player; while flashing (recently hit) blink on/off every 4 frames. + // Per-frame updates use oamSetXY (position only) -- the attributes were set + // once at init, so there's no need to rewrite them every frame. if (playerFlash > 0) playerFlash--; if (playerFlash > 0 && ((playerFlash >> 2) & 1)) - oamSet(OAM_PLAYER, 0, OFFSCREEN_Y, 3, 0, 0, GFX_PLAYER, 0); + oamSetXY(OAM_PLAYER, 0, OFFSCREEN_Y); else - oamSet(OAM_PLAYER, x, y, 3, 0, 0, GFX_PLAYER, 0); + oamSetXY(OAM_PLAYER, x, y); // --- Move bullets up; deactivate once they leave the top edge --- for (j = 0; j < NUM_BULLETS; j++) @@ -292,13 +604,26 @@ int main(void) } } + // --- Difficulty ramp: the more the score climbs, the more consoles are in + // play at once -- one extra each time the score passes the next + // ENEMIES_PER_LEVEL threshold, capped at MAX_ENEMIES. A slow, steady + // build, never a sudden swarm. Each newly activated enemy drops in from + // the top like a normal spawn. The while handles a bomb that crosses + // several thresholds in one frame. --- + while (activeEnemies < MAX_ENEMIES && score >= nextEnemyScore) + { + spawnEnemy(activeEnemies++); + nextEnemyScore += ENEMIES_PER_LEVEL; + } + // --- Update enemies: flashing ones freeze then respawn, others descend --- - for (i = 0; i < NUM_ENEMIES; i++) + for (i = 0; i < activeEnemies; i++) { + if (enemyHurt[i] > 0) enemyHurt[i]--; // chipped flicker fades (keeps descending) if (enemyFlash[i] > 0) { enemyFlash[i]--; - if (enemyFlash[i] == 0) spawnEnemy(i); // hit console gone, new one drops in + if (enemyFlash[i] == 0) spawnEnemy(i); // destroyed enemy gone, new one drops in } else { @@ -318,7 +643,7 @@ int main(void) for (j = 0; j < NUM_BULLETS; j++) { if (!bulletActive[j]) continue; - for (i = 0; i < NUM_ENEMIES; i++) + for (i = 0; i < activeEnemies; i++) { if (enemyFlash[i] > 0) continue; // already hit if (bulletX[j] + BULLET_SIZE > enemyX[i] && @@ -327,7 +652,7 @@ int main(void) bulletY[j] < enemyY[i] + ENEMY_SIZE) { bulletActive[j] = 0; // bullet spent - hitEnemy(i); // start flash-then-disappear + damageEnemy(i); // chip a tank, or destroy a console break; } } @@ -336,7 +661,7 @@ int main(void) // --- Collisions: player (21x21) vs enemy. Costs a life + flash; brief i-frames --- if (playerFlash == 0) { - for (i = 0; i < NUM_ENEMIES; i++) + for (i = 0; i < activeEnemies; i++) { if (enemyFlash[i] > 0 || enemyY[i] < PLAYFIELD_TOP) continue; // not a live target if (x + PLAYER_SIZE > enemyX[i] && @@ -350,6 +675,7 @@ int main(void) { gameOver = 1; consoleDrawText(11, 13, "GAME OVER"); + consoleDrawText(10, 15, "PRESS START"); sfxGameOver(); // mournful strings } else @@ -362,29 +688,54 @@ int main(void) } } + // --- Bomb power-up: fall, get caught by the player, or drop off the bottom --- + if (powerupActive) + { + powerupY += POWERUP_SPEED; + if (powerupY >= 224) + powerupActive = 0; // missed -- fell off-screen + else if (x + PLAYER_SIZE > powerupX && + x < powerupX + POWERUP_SIZE && + y + PLAYER_SIZE > powerupY && + y < powerupY + POWERUP_SIZE) + { + powerupActive = 0; + if (bombs < MAX_BOMBS) bombs++; // collected: stock a bomb + sfxPowerup(); + } + } + // --- Draw enemies --- - for (i = 0; i < NUM_ENEMIES; i++) + for (i = 0; i < activeEnemies; i++) { // Hidden while still above the stats bar (keeps the top row clear), - // or on the "off" beat of the hit blink. + // on the "off" beat of the death blink, or mid chipped-hit flicker. if (enemyY[i] < PLAYFIELD_TOP || - (enemyFlash[i] > 0 && ((enemyFlash[i] >> 1) & 1))) - oamSet(OAM_ENEMY(i), 0, OFFSCREEN_Y, 2, 0, 0, GFX_ENEMY, 1); + (enemyFlash[i] > 0 && ((enemyFlash[i] >> 1) & 1)) || + (enemyHurt[i] > 0 && (enemyHurt[i] & 1))) + oamSetXY(OAM_ENEMY(i), 0, OFFSCREEN_Y); else - oamSet(OAM_ENEMY(i), enemyX[i], (unsigned char)enemyY[i], 2, 0, 0, GFX_ENEMY, 1); + oamSetXY(OAM_ENEMY(i), enemyX[i], (unsigned char)enemyY[i]); } // --- Draw bullets --- for (j = 0; j < NUM_BULLETS; j++) { if (bulletActive[j]) - oamSet(OAM_BULLET(j), bulletX[j], (unsigned char)bulletY[j], 2, 0, 0, GFX_BULLET, 2); + oamSetXY(OAM_BULLET(j), bulletX[j], (unsigned char)bulletY[j]); else - oamSet(OAM_BULLET(j), 0, OFFSCREEN_Y, 2, 0, 0, GFX_BULLET, 2); + oamSetXY(OAM_BULLET(j), 0, OFFSCREEN_Y); } + // --- Draw the bomb power-up --- + if (powerupActive) + oamSetXY(OAM_POWERUP, powerupX, (unsigned char)powerupY); + else + oamSetXY(OAM_POWERUP, 0, OFFSCREEN_Y); + drawScore(); drawLives(); + drawBombs(); spcProcess(); // feed queued sound messages to the SPC700 each frame WaitForVBlank(); diff --git a/tac2.bmp b/tac2.bmp new file mode 100644 index 0000000..24b8c99 --- /dev/null +++ b/tac2.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80269656dc03421466c22a8e45bc37ef73b1f94ea3c62b1b1ac3d1ab2fc14361 +size 2102 diff --git a/tools/make_bullet_bmp.ps1 b/tools/make_bullet_bmp.ps1 deleted file mode 100644 index b7b9d99..0000000 --- a/tools/make_bullet_bmp.ps1 +++ /dev/null @@ -1,78 +0,0 @@ -# Generates bullet.bmp - an 8x8, 8bpp energy-shot sprite -# Palette: index 0 = magenta (transparent) -$W = 8; $H = 8 -$g = New-Object 'byte[,]' $H, $W - -# 1=white core, 2=yellow, 3=orange edge -$art = @( - '..3333..', - '.32222 3.'.Replace(' ', ''), - '3211123', - '3211123', - '3211123', - '3211123', - '.32223.', - '..3333..' -) -# Normalise to 8 chars per row -for ($y = 0; $y -lt $H; $y++) { - $row = $art[$y] - while ($row.Length -lt $W) { $row += '.' } - for ($x = 0; $x -lt $W; $x++) { - $ch = $row[$x] - switch ($ch) { - '1' { $g[$y, $x] = 1 } - '2' { $g[$y, $x] = 2 } - '3' { $g[$y, $x] = 3 } - default { $g[$y, $x] = 0 } - } - } -} - -$pal = @( - @(248, 0, 248), # 0 transparent - @(255, 255, 255), # 1 white core - @(255, 230, 40), # 2 yellow - @(255, 130, 20) # 3 orange -) - -$dataOffset = 54 + 256 * 4 -$rowSize = $W # 8 -> 4-byte aligned -$imgSize = $rowSize * $H -$fileSize = $dataOffset + $imgSize -$bytes = New-Object 'byte[]' $fileSize - -function PutI32($arr, $off, $val) { - $arr[$off] = $val -band 0xFF - $arr[$off+1] = ($val -shr 8) -band 0xFF - $arr[$off+2] = ($val -shr 16) -band 0xFF - $arr[$off+3] = ($val -shr 24) -band 0xFF -} - -$bytes[0] = [byte][char]'B'; $bytes[1] = [byte][char]'M' -PutI32 $bytes 2 $fileSize -PutI32 $bytes 10 $dataOffset -PutI32 $bytes 14 40 -PutI32 $bytes 18 $W -PutI32 $bytes 22 $H -$bytes[26] = 1; $bytes[28] = 8 -PutI32 $bytes 34 $imgSize - -for ($i = 0; $i -lt 256; $i++) { - $o = 54 + $i * 4 - if ($i -lt $pal.Count) { - $bytes[$o] = [byte]$pal[$i][2] - $bytes[$o+1] = [byte]$pal[$i][1] - $bytes[$o+2] = [byte]$pal[$i][0] - } -} - -for ($y = 0; $y -lt $H; $y++) { - $srcY = $H - 1 - $y - for ($x = 0; $x -lt $W; $x++) { - $bytes[$dataOffset + $y * $rowSize + $x] = $g[$srcY, $x] - } -} - -[System.IO.File]::WriteAllBytes('C:\dev\snesgame2\bullet.bmp', $bytes) -Write-Output "Wrote bullet.bmp ($fileSize bytes)" diff --git a/tools/make_bullet_bmp.py b/tools/make_bullet_bmp.py new file mode 100644 index 0000000..d1eb548 --- /dev/null +++ b/tools/make_bullet_bmp.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# Generates bullet.bmp - an 8x8, 8bpp energy-shot sprite. +# Palette: index 0 = magenta (transparent). +import os + +from snesbmp import Canvas, save_bmp + +W = H = 8 + +# 1=white core, 2=yellow, 3=orange edge +art = [ + "..3333..", + ".32222 3.".replace(" ", ""), + "3211123", + "3211123", + "3211123", + "3211123", + ".32223.", + "..3333..", +] + +c = Canvas(W, H) +for y in range(H): + row = art[y] + row += "." * (W - len(row)) # normalise to 8 chars per row + for x in range(W): + ch = row[x] + c.px(x, y, int(ch) if ch in "123" else 0) + +pal = [ + (248, 0, 248), # 0 transparent + (255, 255, 255), # 1 white core + (255, 230, 40), # 2 yellow + (255, 130, 20), # 3 orange +] + +out = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "bullet.bmp")) +size = save_bmp(out, c, pal) +print(f"Wrote bullet.bmp ({size} bytes)") diff --git a/tools/make_enemy_bmp.ps1 b/tools/make_enemy_bmp.ps1 deleted file mode 100644 index d72a69d..0000000 --- a/tools/make_enemy_bmp.ps1 +++ /dev/null @@ -1,122 +0,0 @@ -# Generates enemy.bmp - a 32x32, 8bpp Sega Genesis console sprite -# Palette: index 0 = magenta (transparent, matches player sheet convention) -$W = 32; $H = 32 -$g = New-Object 'byte[,]' $H, $W # [y,x], initialised to 0 (transparent) - -function Rect($x0, $y0, $x1, $y1, $c) { - for ($y = $y0; $y -le $y1; $y++) { - for ($x = $x0; $x -le $x1; $x++) { - if ($x -ge 0 -and $x -lt $W -and $y -ge 0 -and $y -lt $H) { $g[$y, $x] = $c } - } - } -} -function Px($x, $y, $c) { if ($x -ge 0 -and $x -lt $W -and $y -ge 0 -and $y -lt $H) { $g[$y, $x] = $c } } - -# ---- Console body (black box, rows 3..28) ---- -Rect 3 3 28 28 1 -# round the corners -Px 3 3 0; Px 4 3 0; Px 3 4 0 -Px 28 3 0; Px 27 3 0; Px 28 4 0 -Px 3 28 0; Px 4 28 0; Px 3 27 0 -Px 28 28 0; Px 27 28 0; Px 28 27 0 - -# ---- Top surface (3/4 view), mid grey ---- -Rect 5 4 26 11 3 -Rect 5 4 26 4 4 # top highlight edge -# cartridge slot (recessed) -Rect 7 6 17 10 2 -Rect 8 7 16 9 1 -# power switch (white) + red reset, top right -Rect 20 6 23 8 5 -Rect 24 6 25 7 6 -Px 21 10 8 # power LED (blue) - -# ---- Front face ---- -# red signature stripe -Rect 4 14 27 16 6 -Rect 4 15 27 15 7 # darker red shading line -# SEGA-style badge, front left -Rect 5 19 12 21 4 -Rect 6 20 11 20 5 -# vents/expansion lines, front right -Rect 16 19 26 19 3 -Rect 16 21 26 21 3 -Rect 16 23 26 23 3 -# controller ports, bottom -Rect 6 24 11 26 2 -Rect 7 25 10 25 1 -Rect 20 24 25 26 2 -Rect 21 25 24 25 1 -# bottom shadow -Rect 4 27 27 27 2 - -# ---- Palette (RGB) ---- -$pal = @( - @(248, 0, 248), # 0 transparent (magenta) - @(16, 16, 16), # 1 near-black - @(44, 44, 48), # 2 dark grey - @(90, 92, 96), # 3 mid grey - @(150, 152, 156), # 4 light grey - @(235, 235, 235), # 5 white - @(210, 30, 30), # 6 red - @(120, 16, 16), # 7 dark red - @(30, 60, 140) # 8 blue LED -) - -# ---- Write 8bpp BMP (bottom-up) ---- -$dataOffset = 54 + 256 * 4 -$rowSize = $W # 32 -> already 4-byte aligned -$imgSize = $rowSize * $H -$fileSize = $dataOffset + $imgSize -$bytes = New-Object 'byte[]' $fileSize - -function PutI32($arr, $off, $val) { - $arr[$off] = $val -band 0xFF - $arr[$off+1] = ($val -shr 8) -band 0xFF - $arr[$off+2] = ($val -shr 16) -band 0xFF - $arr[$off+3] = ($val -shr 24) -band 0xFF -} - -$bytes[0] = [byte][char]'B'; $bytes[1] = [byte][char]'M' -PutI32 $bytes 2 $fileSize -PutI32 $bytes 10 $dataOffset -PutI32 $bytes 14 40 # DIB header size -PutI32 $bytes 18 $W -PutI32 $bytes 22 $H -$bytes[26] = 1; $bytes[27] = 0 # planes -$bytes[28] = 8; $bytes[29] = 0 # bpp -PutI32 $bytes 34 $imgSize - -# palette (BGRA), 256 entries -for ($i = 0; $i -lt 256; $i++) { - $o = 54 + $i * 4 - if ($i -lt $pal.Count) { - $bytes[$o] = [byte]$pal[$i][2] # B - $bytes[$o+1] = [byte]$pal[$i][1] # G - $bytes[$o+2] = [byte]$pal[$i][0] # R - } - $bytes[$o+3] = 0 -} - -# ---- Downscale art to 2/3 size, anchored top-left (sprite stays a 32x32 cell) ---- -$DST = 21 # 32 * 2/3 ~= 21 -$o = New-Object 'byte[,]' $H, $W # transparent (index 0) padding -for ($dy = 0; $dy -lt $DST; $dy++) { - $sy = [int][math]::Floor($dy * $H / $DST) - for ($dx = 0; $dx -lt $DST; $dx++) { - $sx = [int][math]::Floor($dx * $W / $DST) - $o[$dy, $dx] = $g[$sy, $sx] - } -} - -# pixels, bottom-up -for ($y = 0; $y -lt $H; $y++) { - $srcY = $H - 1 - $y - for ($x = 0; $x -lt $W; $x++) { - $bytes[$dataOffset + $y * $rowSize + $x] = $o[$srcY, $x] - } -} - -$out = Join-Path $PSScriptRoot '..\enemy.bmp' -[System.IO.File]::WriteAllBytes((Resolve-Path -LiteralPath (Split-Path $out)).Path + '\enemy.bmp', $bytes) -Write-Output "Wrote enemy.bmp ($fileSize bytes)" diff --git a/tools/make_enemy_bmp.py b/tools/make_enemy_bmp.py new file mode 100644 index 0000000..02eb27f --- /dev/null +++ b/tools/make_enemy_bmp.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# Generates enemy.bmp - a 32x32, 8bpp Sega Genesis console sprite. +# Palette: index 0 = magenta (transparent, matches the player sheet convention). +# The detailed art is drawn at full 32x32 then downscaled to ~2/3 (21x21) in the +# top-left of the cell, so it lines up with main.c's ENEMY_SIZE hit-box. +import os + +from snesbmp import Canvas, save_bmp + +W = H = 32 +c = Canvas(W, H) + +# ---- Console body (black box, rows 3..28) ---- +c.rect(3, 3, 28, 28, 1) +# round the corners +c.px(3, 3, 0); c.px(4, 3, 0); c.px(3, 4, 0) +c.px(28, 3, 0); c.px(27, 3, 0); c.px(28, 4, 0) +c.px(3, 28, 0); c.px(4, 28, 0); c.px(3, 27, 0) +c.px(28, 28, 0); c.px(27, 28, 0); c.px(28, 27, 0) + +# ---- Top surface (3/4 view), mid grey ---- +c.rect(5, 4, 26, 11, 3) +c.rect(5, 4, 26, 4, 4) # top highlight edge +# cartridge slot (recessed) +c.rect(7, 6, 17, 10, 2) +c.rect(8, 7, 16, 9, 1) +# power switch (white) + red reset, top right +c.rect(20, 6, 23, 8, 5) +c.rect(24, 6, 25, 7, 6) +c.px(21, 10, 8) # power LED (blue) + +# ---- Front face ---- +# red signature stripe +c.rect(4, 14, 27, 16, 6) +c.rect(4, 15, 27, 15, 7) # darker red shading line +# SEGA-style badge, front left +c.rect(5, 19, 12, 21, 4) +c.rect(6, 20, 11, 20, 5) +# vents/expansion lines, front right +c.rect(16, 19, 26, 19, 3) +c.rect(16, 21, 26, 21, 3) +c.rect(16, 23, 26, 23, 3) +# controller ports, bottom +c.rect(6, 24, 11, 26, 2) +c.rect(7, 25, 10, 25, 1) +c.rect(20, 24, 25, 26, 2) +c.rect(21, 25, 24, 25, 1) +# bottom shadow +c.rect(4, 27, 27, 27, 2) + +pal = [ + (248, 0, 248), # 0 transparent (magenta) + (16, 16, 16), # 1 near-black + (44, 44, 48), # 2 dark grey + (90, 92, 96), # 3 mid grey + (150, 152, 156), # 4 light grey + (235, 235, 235), # 5 white + (210, 30, 30), # 6 red + (120, 16, 16), # 7 dark red + (30, 60, 140), # 8 blue LED +] + +# ---- Downscale art to 2/3 size, anchored top-left (cell stays 32x32) ---- +c.downscale_into(21) # 32 * 2/3 ~= 21 + +out = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "enemy.bmp")) +size = save_bmp(out, c, pal) +print(f"Wrote enemy.bmp ({size} bytes)") diff --git a/tools/make_gunshot.js b/tools/make_gunshot.js deleted file mode 100644 index 50788e2..0000000 --- a/tools/make_gunshot.js +++ /dev/null @@ -1,82 +0,0 @@ -// Synthesize a short, punchy gunshot as a 32 kHz / 16-bit mono PCM WAV. -// -// A gunshot is a noise transient, not a pitched instrument, so we build it from: -// * a hard initial click (~2 ms full-scale noise) for the sharp attack, -// * a lowpassed white-noise body with a fast exponential decay (the "crack"), -// * a low sine "thump" (~95 Hz) with its own decay for the punch/boom. -// The pieces are summed and soft-limited (tanh). snesbrr (-e) encodes to BRR. -// -// Output: res/gunshot.wav -const fs = require("fs"); -const path = require("path"); - -const RATE = 32000; -const DUR = 0.16; // seconds; short so rapid fire stays responsive -const N = Math.floor(RATE * DUR); - -// Small deterministic PRNG so rebuilds produce identical output. -let seed = 1234 >>> 0; -function rand() { - // xorshift32 -> [-1, 1) - seed ^= seed << 13; seed >>>= 0; - seed ^= seed >> 17; - seed ^= seed << 5; seed >>>= 0; - return (seed / 0xffffffff) * 2 - 1; -} - -const samples = new Float64Array(N); -let prev = 0; // one-pole lowpass state for the noise body -for (let i = 0; i < N; i++) { - const t = i / RATE; - - // noise body: white noise -> one-pole lowpass -> fast decay ("crack") - const white = rand(); - prev = prev + 0.45 * (white - prev); // lowpass: tames hiss, adds body - const body = prev * Math.exp(-t / 0.028); // ~28 ms decay - - // sharp attack click: a couple ms of unfiltered full-scale noise - let click = 0; - if (t < 0.0025) click = rand() * (1 - t / 0.0025); - - // low thump for punch - const thump = Math.sin(2 * Math.PI * 95 * t) * Math.exp(-t / 0.045); - - const s = 0.85 * body + 0.45 * click + 0.55 * thump; - samples[i] = Math.tanh(s * 1.4); // soft limiter keeps the transient hot -} - -// normalize to ~95% full scale -let peak = 1e-6; -for (const s of samples) peak = Math.max(peak, Math.abs(s)); -const scale = (0.95 / peak) * 32767; - -const dataBytes = N * 2; -const buf = Buffer.alloc(44 + dataBytes); -// RIFF header -buf.write("RIFF", 0, "ascii"); -buf.writeUInt32LE(36 + dataBytes, 4); -buf.write("WAVE", 8, "ascii"); -// fmt chunk (PCM, mono, 16-bit) -buf.write("fmt ", 12, "ascii"); -buf.writeUInt32LE(16, 16); -buf.writeUInt16LE(1, 20); // PCM -buf.writeUInt16LE(1, 22); // channels -buf.writeUInt32LE(RATE, 24); // sample rate -buf.writeUInt32LE(RATE * 2, 28); // byte rate -buf.writeUInt16LE(2, 32); // block align -buf.writeUInt16LE(16, 34); // bits/sample -// data chunk -buf.write("data", 36, "ascii"); -buf.writeUInt32LE(dataBytes, 40); -for (let i = 0; i < N; i++) { - let v = Math.round(samples[i] * scale); - if (v > 32767) v = 32767; - if (v < -32768) v = -32768; - buf.writeInt16LE(v, 44 + i * 2); -} - -const out = path.normpath - ? path.normpath(path.join(__dirname, "..", "res", "gunshot.wav")) - : path.join(__dirname, "..", "res", "gunshot.wav"); -fs.writeFileSync(out, buf); -console.log(`wrote ${out}: ${N} samples (${(DUR * 1000).toFixed(0)} ms)`); diff --git a/tools/make_player_bmp.ps1 b/tools/make_player_bmp.ps1 deleted file mode 100644 index beb868c..0000000 --- a/tools/make_player_bmp.ps1 +++ /dev/null @@ -1,50 +0,0 @@ -# Rebuilds sprites.bmp (player) at 2/3 size, anchored in the top-left of the -# 32x32 sprite cell. SNES sprites can't scale in hardware, so we shrink the art -# itself and pad the rest of the cell with the transparent index (0). -# -# The original 32x32 art is backed up once to tools/sprites_src.bmp and always -# read from there, so re-running never double-shrinks. -$W = 32; $H = 32 -$dataOffset = 54 + 256 * 4 -$rowSize = $W -$SCALE = 21 # 32 * 2/3 ~= 21 - -$root = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path -$dst = Join-Path $root 'sprites.bmp' -$src = Join-Path $PSScriptRoot 'sprites_src.bmp' - -if (-not (Test-Path -LiteralPath $src)) { - Copy-Item -LiteralPath $dst -Destination $src # preserve pristine original -} - -$bytes = [System.IO.File]::ReadAllBytes($src) - -# Read source pixel indices (BMP is stored bottom-up); g[y,x] with y=0 at top. -$g = New-Object 'byte[,]' $H, $W -for ($y = 0; $y -lt $H; $y++) { - $srcY = $H - 1 - $y - for ($x = 0; $x -lt $W; $x++) { - $g[$y, $x] = $bytes[$dataOffset + $srcY * $rowSize + $x] - } -} - -# Nearest-neighbour downscale into the top-left, rest transparent (0). -$o = New-Object 'byte[,]' $H, $W -for ($dy = 0; $dy -lt $SCALE; $dy++) { - $sy = [int][math]::Floor($dy * $H / $SCALE) - for ($dx = 0; $dx -lt $SCALE; $dx++) { - $sx = [int][math]::Floor($dx * $W / $SCALE) - $o[$dy, $dx] = $g[$sy, $sx] - } -} - -# Write pixels back into the byte buffer (bottom-up); header + palette untouched. -for ($y = 0; $y -lt $H; $y++) { - $srcY = $H - 1 - $y - for ($x = 0; $x -lt $W; $x++) { - $bytes[$dataOffset + $y * $rowSize + $x] = $o[$srcY, $x] - } -} - -[System.IO.File]::WriteAllBytes($dst, $bytes) -Write-Output "Wrote sprites.bmp (player art scaled to ${SCALE}x${SCALE}, top-left of 32x32 cell)" diff --git a/tools/make_player_bmp.py b/tools/make_player_bmp.py new file mode 100644 index 0000000..2796c98 --- /dev/null +++ b/tools/make_player_bmp.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# Rebuilds sprites.bmp (player) at 2/3 size, anchored in the top-left of the +# 32x32 sprite cell. SNES sprites can't scale in hardware, so we shrink the art +# itself and pad the rest of the cell with the transparent index (0). +# +# The original 32x32 art is backed up once to tools/sprites_src.bmp and always +# read from there, so re-running never double-shrinks. +import os +import shutil + +from snesbmp import read_indexed_bmp, save_bmp + +SCALE = 21 # 32 * 2/3 ~= 21 + +here = os.path.dirname(__file__) +dst = os.path.normpath(os.path.join(here, "..", "sprites.bmp")) +src = os.path.join(here, "sprites_src.bmp") + +if not os.path.exists(src): + shutil.copyfile(dst, src) # preserve the pristine original + +w, h, palette, canvas = read_indexed_bmp(src) +canvas.downscale_into(SCALE) # nearest-neighbour into the top-left +save_bmp(dst, canvas, palette) + +print(f"Wrote sprites.bmp (player art scaled to {SCALE}x{SCALE}, top-left of 32x32 cell)") diff --git a/tools/make_powerup_bmp.py b/tools/make_powerup_bmp.py new file mode 100644 index 0000000..1d7b13d --- /dev/null +++ b/tools/make_powerup_bmp.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# Generates powerup.bmp - a 32x32, 8bpp "bomb" pickup sprite. +# Palette index 0 = magenta (transparent), like the other sprites. +# The bomb is drawn at HALF size (a 16x16 figure in the top-left of the 32x32 +# cell) so it reads as a small pickup on screen; main.c's POWERUP_SIZE hit-box +# matches that upper-left 16x16. A round dark bomb body with a highlight + shine, +# a metal cap, and a lit fuse spark on top. +import os + +from snesbmp import Canvas, save_bmp + +W = H = 32 +c = Canvas(W, H) + +# --- Body: filled circle, centre (7,9) radius 5 -> color 1 (dark body) --- +cx, cy, r = 7, 9, 5 +for y in range(16): + for x in range(16): + dx = x - cx + dy = y - cy + d2 = dx * dx + dy * dy + if d2 <= r * r: + c.px(x, y, 1) + # Soft highlight arc on the upper-left quadrant -> color 2 + if d2 <= (r - 1) * (r - 1) and d2 >= (r - 3) * (r - 3) and dx < 0 and dy < 0: + c.px(x, y, 2) + +# --- Specular shine: a small bright blob on the upper-left -> color 5 (white) --- +for y, x in ((6, 5), (6, 6), (7, 5)): + c.px(x, y, 5) + +# --- Metal cap: short stub on top of the body -> color 3 --- +for y in range(3, 5): + for x in range(6, 9): + c.px(x, y, 3) + +# --- Fuse cord rising from the cap, curling up-right -> color 3 --- +for y, x in ((2, 9), (1, 10), (1, 11)): + c.px(x, y, 3) + +# --- Lit spark at the tip of the fuse -> color 4 (yellow) + 5 (white core) --- +c.px(11, 0, 4); c.px(12, 0, 4); c.px(12, 1, 4); c.px(10, 0, 4) +c.px(11, 0, 5) + +pal = [ + (248, 0, 248), # 0 transparent + (30, 30, 44), # 1 dark body + (80, 80, 110), # 2 body highlight + (120, 120, 135), # 3 metal cap / fuse + (255, 215, 40), # 4 spark yellow + (255, 255, 255), # 5 white shine +] + +out = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "powerup.bmp")) +size = save_bmp(out, c, pal) +print(f"Wrote powerup.bmp ({size} bytes)") diff --git a/tools/make_tac2_bmp.py b/tools/make_tac2_bmp.py new file mode 100644 index 0000000..70b0017 --- /dev/null +++ b/tools/make_tac2_bmp.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# Generates tac2.bmp - a 32x32, 8bpp "tougher enemy" sprite shaped like a +# Suncom TAC-2 arcade joystick: a red ball-top stick on a chrome shaft rising +# from a chunky black base with a red fire button. +# +# Like enemy.bmp, the art is drawn in the TOP-LEFT 21x21 of the 32x32 cell so it +# lines up with main.c's ENEMY_SIZE (21) hit-box; the rest of the cell is +# transparent (palette index 0 = magenta, matching the other sprite sheets). +import os + +from snesbmp import Canvas, save_bmp + +W = H = 32 +c = Canvas(W, H) + +# ---- Base: chunky black slab with a chrome top edge, rows 13..20 ---- +c.rect(1, 14, 19, 20, 1) # black base body +c.rect(1, 13, 19, 13, 3) # chrome top lip +c.rect(1, 20, 19, 20, 2) # bottom shadow line +# round the base corners +c.px(1, 13, 0); c.px(19, 13, 0); c.px(1, 20, 0); c.px(19, 20, 0) + +# ---- Fire button: red dome on the left of the base ---- +c.disc(5, 16, 2, 4) +c.px(4, 15, 6) # button highlight (white) +c.px(5, 17, 5) # button shading (dark red) + +# ---- Shaft: chrome stick rising from base centre, rows 7..13 ---- +c.rect(9, 7, 11, 13, 3) # silver shaft +c.rect(11, 7, 11, 13, 2) # right-side shading +c.rect(9, 7, 9, 13, 6) # left-edge highlight + +# ---- Ball top: red knob, centred at (10,4) ---- +c.disc(10, 4, 3, 4) # red ball +c.px(8, 2, 6); c.px(9, 2, 6) # upper-left specular highlight (white) +c.px(12, 5, 5); c.px(11, 6, 5) # lower-right shading (dark red) + +pal = [ + (248, 0, 248), # 0 transparent (magenta) + (16, 16, 18), # 1 black base + (40, 40, 44), # 2 dark grey shadow + (170, 174, 182), # 3 chrome / silver + (220, 40, 36), # 4 red + (130, 18, 16), # 5 dark red shading + (245, 245, 245), # 6 white highlight +] + +out = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "tac2.bmp")) +size = save_bmp(out, c, pal) +print(f"Wrote tac2.bmp ({size} bytes)") diff --git a/tools/snesbmp.py b/tools/snesbmp.py new file mode 100644 index 0000000..4886bc6 --- /dev/null +++ b/tools/snesbmp.py @@ -0,0 +1,139 @@ +"""Shared helpers for the tools/ art generators. + +The SNES build's source sprites are authored as 8bpp (256-colour) BMPs whose +palette index 0 is magenta = transparent, matching gfx4snes' convention. These +generators build a grid of palette indices and write a minimal 8bpp BMP by hand +(no image-library dependency); this module holds the drawing canvas and the BMP +read/write plumbing that every generator shares. + +Pure standard library, so it runs unchanged on Windows, Linux and WSL. +""" +import struct + +# 14-byte BITMAPFILEHEADER + 40-byte BITMAPINFOHEADER, then a full 256-entry +# palette (4 bytes each), then the pixel data. +_HEADER_SIZE = 54 +_PALETTE_ENTRIES = 256 +DATA_OFFSET = _HEADER_SIZE + _PALETTE_ENTRIES * 4 # 1078 + + +def _row_size(w): + """BMP rows are padded to a 4-byte boundary.""" + return (w + 3) & ~3 + + +class Canvas: + """A width x height grid of palette indices, addressed [y][x], origin top-left. + + Starts filled with index 0 (transparent). Drawing ops are bounds-checked, so + off-canvas pixels are silently dropped (matching the .ps1 Px/Rect guards). + """ + + def __init__(self, w, h): + self.w = w + self.h = h + self.g = [[0] * w for _ in range(h)] + + def px(self, x, y, c): + if 0 <= x < self.w and 0 <= y < self.h: + self.g[y][x] = c + + def rect(self, x0, y0, x1, y1, c): + for y in range(y0, y1 + 1): + for x in range(x0, x1 + 1): + self.px(x, y, c) + + def disc(self, cx, cy, r, c): + """Filled disc, centre (cx, cy), radius r.""" + for y in range(cy - r, cy + r + 1): + for x in range(cx - r, cx + r + 1): + dx = x - cx + dy = y - cy + if dx * dx + dy * dy <= r * r: + self.px(x, y, c) + + def downscale_into(self, scale): + """Nearest-neighbour shrink of the whole grid into the top-left + scale x scale region; the rest of the cell becomes transparent (0). + + Uses integer floor division to match PowerShell's [math]::Floor. + """ + out = [[0] * self.w for _ in range(self.h)] + for dy in range(scale): + sy = (dy * self.h) // scale + for dx in range(scale): + sx = (dx * self.w) // scale + out[dy][dx] = self.g[sy][sx] + self.g = out + + +def save_bmp(path, canvas, palette): + """Write a Canvas as an 8bpp BMP. + + palette is a list of (r, g, b) tuples; entries beyond its length are left + black. Pixel rows are stored bottom-up, as BMP requires. + """ + w, h = canvas.w, canvas.h + row_size = _row_size(w) + img_size = row_size * h + file_size = DATA_OFFSET + img_size + + b = bytearray(file_size) + b[0:2] = b"BM" + struct.pack_into("