diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 03a0c171..b06d2573 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,6 +88,7 @@ jobs: make -C games/stickmanhook host test make -C games/coreball host test make -C games/uno host test + make -C games/fnf host test BUILD_DIR=../../build/data/games/fnf diff --git a/Makefile b/Makefile index 8a91de02..eeaec82f 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ PY ?= python3 CARGO ?= cargo B = build/data # Conventional games honor a configurable output directory; special layouts are kept local. -GAMES_REDIRECT = balatro blockblast breakout buckshot celeste championisland chess connectfour cookieclicker coreball dinorun donkeykong doodlejump doom dune finalearth2 flappybird flappydunk fnaf g2048 hillclimb hollowknight invaders isaac jetpackjoyride leveldevil minesweeper monkeymart numblocks numpowder numvisuals pacman portal pvz snake sokoban solitaire sonic stickmanhook subway sudoku suika tombofthemask towerblock twoplayer undertale uno uttt +GAMES_REDIRECT = balatro blockblast breakout buckshot celeste championisland chess connectfour cookieclicker coreball dinorun donkeykong doodlejump doom dune finalearth2 flappybird flappydunk fnaf fnf g2048 hillclimb hollowknight invaders isaac jetpackjoyride leveldevil minesweeper monkeymart numblocks numpowder numvisuals pacman portal pvz snake sokoban solitaire sonic stickmanhook subway sudoku suika tombofthemask towerblock twoplayer undertale uno uttt GAMES_OUT = dinorun GAME_REDIRECT_ARG = $(if $(filter $(1),$(GAMES_OUT)),OUT=../../$(B)/games/$(1),BUILD_DIR=../../$(B)/games/$(1)) # the RAM the oldest calculator software that runs apps gives them (23.2: 148928 bytes; 25.2 gives 153676). @@ -29,7 +29,7 @@ RAM_LIMIT = 148928 # custom builds of Epsilon 25.2's source give apps less: 133652 bytes; every game fits there too, in NumPlay and on # its own (their big buffers are on the stack, 32 KB apart from that RAM, or in the RAM the software has left) CUSTOM_RAM = 133652 -BIG_APPS = Celeste HollowKnight Undertale ChampionIsland FinalEarth2 NumPowder NumBlocks NumDrive SubwaySurfers FiveNightsAtFreddys Doom BindingOfIsaac HillClimbRacing PlantsVsZombies SonicTheHedgehog CookieClicker LevelDevil JetpackJoyride StickmanHook DonkeyKong Dune MonkeyMart TombOfTheMask UNO +BIG_APPS = Celeste HollowKnight Undertale ChampionIsland FinalEarth2 NumPowder NumBlocks NumDrive SubwaySurfers FiveNightsAtFreddys Doom BindingOfIsaac HillClimbRacing PlantsVsZombies SonicTheHedgehog CookieClicker LevelDevil JetpackJoyride StickmanHook DonkeyKong Dune MonkeyMart TombOfTheMask UNO FridayNightFunkin # the calculator's space for installed apps: from the end of its software (64 KiB aligned) to 64 KiB before the # end of its 4 MiB slot of flash, 0x903F0000. The software's size sets the start: 0x90200000 is the latest a # NumPlay counts on (1.94 MB, APP_SPACE); Epsilon 26.4 on an N0120 ends at 0x90180000 (2.44 MB, the @@ -47,7 +47,7 @@ XL_APP_SPACE = 6291456 # the spaces the customizer website offers (kit.json "spaces"; tools/kit.py links a NumPlay at each) SPACES = std:$(APP_START):$(APP_END) e26:$(APP_START_26):$(APP_END) xl:$(APP_START):$(XL_END) xl26:$(APP_START_26):$(XL_END) # big games that must install on their own without Enlarged Extra Apps: make check holds them to APP_SPACE -APP_SPACE_APPS = HillClimbRacing +APP_SPACE_APPS = HillClimbRacing FridayNightFunkin # Upsilon, the N0110 and N0115's custom software, gives .nwa apps less RAM: NumPlay-Upsilon.nwa has # the games that fit it (NumPlay draws their text there: see launcher/src/compat.c) UPSILON_RAM = 107674 @@ -322,6 +322,7 @@ APP_dune = $(B)/games/dune/dune.nwa:Dune.nwa APP_monkeymart = $(B)/games/monkeymart/monkeymart.nwa:MonkeyMart.nwa APP_tombofthemask = $(B)/games/tombofthemask/tombofthemask.nwa:TombOfTheMask.nwa APP_uno = $(B)/games/uno/uno.nwa:UNO.nwa +APP_fnf = $(B)/games/fnf/fnf.nwa:FridayNightFunkin.nwa # the big games' modules are made from these (the kit) $(foreach g,$(XL_GAMES),$(eval MOD_$(g) = $$(word 1,$$(subst :, ,$$(APP_$(g)))))) @@ -356,6 +357,7 @@ apps: | $(B) $(MAKE) -C games/monkeymart build NWLINK="$(NWLINK)" BUILD_DIR=../../$(B)/games/monkeymart $(MAKE) -C games/tombofthemask build NWLINK="$(NWLINK)" BUILD_DIR=../../$(B)/games/tombofthemask $(MAKE) -C games/uno build NWLINK="$(NWLINK)" BUILD_DIR=../../$(B)/games/uno + $(MAKE) -C games/fnf build NWLINK="$(NWLINK)" BUILD_DIR=../../$(B)/games/fnf cd games/tetris/tetris && NWLINK="$(NWLINK)" $(CARGO) build --release --quiet cp $(word 1,$(subst :, ,$(APP_numdash))) $(B)/apps/$(word 2,$(subst :, ,$(APP_numdash))) cp $(word 1,$(subst :, ,$(APP_crossyroad))) $(B)/apps/$(word 2,$(subst :, ,$(APP_crossyroad))) @@ -388,6 +390,7 @@ apps: | $(B) cp $(word 1,$(subst :, ,$(APP_monkeymart))) $(B)/apps/$(word 2,$(subst :, ,$(APP_monkeymart))) cp $(word 1,$(subst :, ,$(APP_tombofthemask))) $(B)/apps/$(word 2,$(subst :, ,$(APP_tombofthemask))) cp $(word 1,$(subst :, ,$(APP_uno))) $(B)/apps/$(word 2,$(subst :, ,$(APP_uno))) + cp $(word 1,$(subst :, ,$(APP_fnf))) $(B)/apps/$(word 2,$(subst :, ,$(APP_fnf))) arm-none-eabi-strip --strip-unneeded $(B)/apps/Tetris.nwa STD_APPS = $(foreach g,$(STD_GAMES),$(B)/apps/$(NWA_$(g))) @@ -460,5 +463,6 @@ clean: -$(MAKE) -C games/monkeymart clean -$(MAKE) -C games/tombofthemask clean -$(MAKE) -C games/uno clean + -$(MAKE) -C games/fnf clean -cd games/tetris/tetris && $(CARGO) clean -$(foreach g,$(STD_GAMES),$(MAKE) -C games/$(g) clean;) diff --git a/README.md b/README.md index b460e28b..38b93d9d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

29 free games for your NumWorks calculator, in one app.
- Plus 22 big games that install on their own. For the N0110, N0115 and N0120, in English, French and Chinese. + Plus 23 big games that install on their own. For the N0110, N0115 and N0120, in English, French and Chinese.

@@ -131,10 +131,11 @@ These are too big to fit next to NumPlay, so each one is its own app. Install it UNO
UNO

The card game against one to three bots, to 500 points
Download + Friday Night Funkin'
Friday Night Funkin'

Every week and its songs, on the beat, without the sound
Download Super Mario 64
In a repository of its own: NumWorks-Super-Mario-64 - Super Mario Bros.
All 32 stages, in a repository of its own: NumWorks-Super-Mario-Bros + Super Mario Bros.
All 32 stages, in a repository of its own: NumWorks-Super-Mario-Bros More on the way
Minecraft 1.18.2 and more: see the roadmap @@ -278,6 +279,7 @@ Read **[Make your own NumWorks game](docs/make-a-game.md)** to go from there, an - [x] Monkey Mart - [x] Tomb of the Mask - [x] UNO +- [x] Friday Night Funkin' - [x] [NumPlay Studio](https://mason363.github.io/NumPlay/customize/), the website where you pick the games in your NumPlay, with Enlarged Extra Apps to fit several big games - [ ] Full Minecraft 1.18.2 as a standalone app - [ ] Among Us, against bots @@ -306,7 +308,6 @@ Read **[Make your own NumWorks game](docs/make-a-game.md)** to go from there, an - [ ] More of Retro-Go: Game & Watch, SNES, Mega Drive, SG-1000 and ColecoVision - [ ] Five Nights at Freddy's 2, 3, 4 and FNAF World - [ ] Brawl Stars, against bots -- [ ] Friday Night Funkin' ## Credits @@ -342,6 +343,7 @@ NumPlay is a fan project, made out of love for these games and for the NumWorks - **Monkey Mart** by TinyDobbins inspired NumPlay's Monkey Mart, which follows the rules, timings and numbers of the game's web version and uses its pictures, its monkeys and animals and the **Riffic** font from it. **[Bobydeluxe](https://github.com/Bobydeluxe)** brought it to NumPlay. Not affiliated with TinyDobbins. - **Tomb of the Mask** by Playgendary inspired NumPlay's Tomb of the Mask, which follows the rules of the game's web version and uses its stages, pictures and pixel font. **[Bobydeluxe](https://github.com/Bobydeluxe)** brought it to NumPlay. Not affiliated with Playgendary. - **UNO** by Mattel inspired NumPlay's UNO, which plays by the official rules with cards drawn for the calculator. **[Bobydeluxe](https://github.com/Bobydeluxe)** brought it to NumPlay. Not affiliated with Mattel. +- **Friday Night Funkin'** by the Funkin' Crew (ninjamuffin99, PhantomArcade, evilsk8r, Kawai Sprite) inspired NumPlay's Friday Night Funkin', which follows the game's open source code (Apache-2.0) and builds its charts and pictures from the game's own files when it's built. **[Bobydeluxe](https://github.com/Bobydeluxe)** brought it to NumPlay. Not affiliated with the Funkin' Crew. - **[Level Devil](https://store.steampowered.com/app/3242750/)** by Unept: NumPlay's Level Devil is written in C with no code from the game, but its 80 rooms (their tiles, traps and timing) are the game's own, taken from its web build by games/leveldevil/tools/fetch.py, and its runner is drawn from the game's sprites. - **Pong** by Atari (1972) inspired NumPlay's 2 Players, with air hockey, tanks in a maze in the spirit of Tank Trouble and the light cycles of Tron. All four are written in C for NumPlay and drawn from shapes, with no code or art from them. @@ -370,7 +372,7 @@ NumPlay is a fan project, made out of love for these games and for the NumWorks NumPlay is licensed under the [GNU General Public License v3.0](LICENSE). Copyright (c) 2026 Mason Chen. Code and fonts from others keep their own licenses: the `UNLICENSE` files in the games' folders and the [LICENSES](LICENSES) folder. The names, artwork and music of the games NumPlay is inspired by belong to their makers and are not covered by it. -Unofficial fan games, not affiliated with NumWorks, RobTop Games (Geometry Dash), Hipster Whale (Crossy Road), the makers of Drive Mad, LocalThunk or Playstack (Balatro), Mike Klubnika or Critical Reflex (Buckshot Roulette), Hungry Studio (Block Blast!), Aladdin X (Suika Game), Voodoo (Flappy Dunk), Lima Sky (Doodle Jump), Mojang or Microsoft (Minecraft), Maddy Makes Games (Celeste), Team Cherry (Hollow Knight), Toby Fox (Undertale), id Software (Doom), Edmund McMillen, Florian Himsl or Nicalis (The Binding of Isaac), PopCap Games or Electronic Arts (Plants vs. Zombies), Scott Cawthon (Five Nights at Freddy's), SEGA (Sonic the Hedgehog, Master System, Game Gear), SYBO or Kiloo (Subway Surfers), Fingersoft (Hill Climb Racing), Unept (Level Devil), Halfbrick (Jetpack Joyride), Madbox (Stickman Hook), Nintendo (Donkey Kong, Game Boy, NES), Team Lava (Dune!), Inlogic Software (Dune Rider), TinyDobbins (Monkey Mart), Playgendary (Tomb of the Mask), Mattel (UNO), Atari (Pong), Disney (Tron), Orteil or DashNet (Cookie Clicker), Taito or Midway (Space Invaders), Thinking Rabbit or Falcon (Sokoban), Florian van Strien (The Final Earth 2), The Powder Toy's developers (The Powder Toy), MateoConLechuga (Portal Returns), Valve (Portal), .GEARS (Flappy Bird), Bandai Namco (Pac-Man), Hasbro (Connect Four), Google (Snake, Solitaire, Block Breaker, Chrome Dino, Doodle Champion Island Games), STUDIO4°C or Microsoft (Minesweeper), ducalex (Retro-Go). Tetris is a trademark of The Tetris Company, Chrome of Google LLC, Minecraft of Mojang Synergies AB, Pac-Man of Bandai Namco, Connect Four of Hasbro, DOOM of ZeniMax Media, Sonic the Hedgehog, Master System and Game Gear of SEGA, Space Invaders of Taito, UNO of Mattel, and Donkey Kong, Game Boy, Game Boy Color and NES of Nintendo. +Unofficial fan games, not affiliated with NumWorks, RobTop Games (Geometry Dash), Hipster Whale (Crossy Road), the makers of Drive Mad, LocalThunk or Playstack (Balatro), Mike Klubnika or Critical Reflex (Buckshot Roulette), Hungry Studio (Block Blast!), Aladdin X (Suika Game), Voodoo (Flappy Dunk), Lima Sky (Doodle Jump), Mojang or Microsoft (Minecraft), Maddy Makes Games (Celeste), Team Cherry (Hollow Knight), Toby Fox (Undertale), id Software (Doom), Edmund McMillen, Florian Himsl or Nicalis (The Binding of Isaac), PopCap Games or Electronic Arts (Plants vs. Zombies), Scott Cawthon (Five Nights at Freddy's), SEGA (Sonic the Hedgehog, Master System, Game Gear), SYBO or Kiloo (Subway Surfers), Fingersoft (Hill Climb Racing), Unept (Level Devil), Halfbrick (Jetpack Joyride), Madbox (Stickman Hook), Nintendo (Donkey Kong, Game Boy, NES), Team Lava (Dune!), Inlogic Software (Dune Rider), TinyDobbins (Monkey Mart), Playgendary (Tomb of the Mask), Mattel (UNO), the Funkin' Crew (Friday Night Funkin'), Atari (Pong), Disney (Tron), Orteil or DashNet (Cookie Clicker), Taito or Midway (Space Invaders), Thinking Rabbit or Falcon (Sokoban), Florian van Strien (The Final Earth 2), The Powder Toy's developers (The Powder Toy), MateoConLechuga (Portal Returns), Valve (Portal), .GEARS (Flappy Bird), Bandai Namco (Pac-Man), Hasbro (Connect Four), Google (Snake, Solitaire, Block Breaker, Chrome Dino, Doodle Champion Island Games), STUDIO4°C or Microsoft (Minesweeper), ducalex (Retro-Go). Tetris is a trademark of The Tetris Company, Chrome of Google LLC, Minecraft of Mojang Synergies AB, Pac-Man of Bandai Namco, Connect Four of Hasbro, DOOM of ZeniMax Media, Sonic the Hedgehog, Master System and Game Gear of SEGA, Space Invaders of Taito, UNO of Mattel, and Donkey Kong, Game Boy, Game Boy Color and NES of Nintendo. ## Gallery @@ -460,4 +462,7 @@ NumPlay is licensed under the [GNU General Public License v3.0](LICENSE). Copyri Tomb of the Mask: dashing up the tomb ahead of the rising flood
Tomb of the Mask UNO: a hand against three bots, a Draw Two and a colour called
UNO + + Friday Night Funkin': Ready, Set, Go, then the first notes of a song
Friday Night Funkin' + diff --git a/docs/media/fnf.gif b/docs/media/fnf.gif new file mode 100644 index 00000000..117820ff Binary files /dev/null and b/docs/media/fnf.gif differ diff --git a/docs/media/thumb_fnf.png b/docs/media/thumb_fnf.png new file mode 100644 index 00000000..a35c1bbb Binary files /dev/null and b/docs/media/thumb_fnf.png differ diff --git a/docs/play.md b/docs/play.md index b1c076ba..6112bc70 100644 --- a/docs/play.md +++ b/docs/play.md @@ -581,6 +581,20 @@ Match the top card by colour or number, or play a Wild. Skip, Reverse, Draw Two
+## Friday Night Funkin' + +Friday Night Funkin' + +| Key | What it does | +| --- | --- | +| **Left, Down, Up, Right** (or **7, 8, (, )**) | Hit the arrows | +| **OK** | Choose | +| **Back** | Pause (with Restart, Options and Quit) | + +Press each arrow as it reaches the line at the top. Sick, Good, Bad and Shit depend on how close you are; misses cost health, and the song is lost when it runs out. Play the weeks in story mode or any song in freeplay. There's no sound: the characters and the camera keep the beat. Your scores are saved. + +
+ ## Portal Returns Portal Returns: falling through a portal and flying across a test chamber diff --git a/games/fnf/.gitignore b/games/fnf/.gitignore new file mode 100644 index 00000000..643bd909 --- /dev/null +++ b/games/fnf/.gitignore @@ -0,0 +1,3 @@ +output/ +build/ +__pycache__/ diff --git a/games/fnf/Makefile b/games/fnf/Makefile new file mode 100644 index 00000000..b1937c26 --- /dev/null +++ b/games/fnf/Makefile @@ -0,0 +1,79 @@ +# Friday Night Funkin' on the NumWorks calculator. +# make output/fnf.nwa +# make check link it like the calculator does (output/fnf.bin) +# make host build/play: the game on a computer, for tests and pictures +# make test the rules against the game's own numbers (PBOT1, health, holds, ghost misses), every chart +# played by a bot to a full combo, random keys, saves (with the sanitizers) +# +# The charts and pictures are the Funkin' Crew's, and are not in this repository: tools/fetch.py fetches the files +# tools/ref.txt lists from their funkin.assets repository, at its pinned commit, into REF (git, nothing run), and +# tools/data.py packs them into $(BUILD_DIR)/gen (Python 3 with Pillow and numpy; zopfli packs them smaller). +# REF=DIR uses a copy of funkin.assets already there (its files are checked against tools/ref.txt). +Q ?= @ +CC = arm-none-eabi-gcc +NWLINK ?= npx --yes -- nwlink@1.0.0 +PYTHON ?= python3 +BUILD_DIR = output +REF ?= $(BUILD_DIR)/ref +GEN = $(BUILD_DIR)/gen +L = ../../launcher/src + +SRC = src/main.c src/app.c src/game.c src/draw.c src/screens.c src/save.c src/inflate.c src/plat_eadk.c \ + $(L)/gfx.c $(L)/font_data.c $(L)/xfont.c $(GEN)/data.c +HEADERS = $(wildcard src/*.h) $(GEN)/ids.h $(L)/gfx.h $(L)/font.h $(L)/np.h ../common/epsilon_app.h \ + ../common/epsilon_files.h + +CFLAGS = -std=gnu11 $(shell $(NWLINK) eadk-cflags-device) -DPLATFORM_DEVICE=1 -DEPSILON_XL=1 -I$(L) -Isrc -I$(GEN) +CFLAGS += -Os -Wall -Wextra -Wno-unused-parameter -fno-math-errno -fsingle-precision-constant +CFLAGS += -fno-tree-loop-distribute-patterns -flto -fno-fat-lto-objects -fwhole-program -fvisibility=internal +CFLAGS += -ffunction-sections -fdata-sections +LDFLAGS = -Wl,--relocatable -nostartfiles --specs=nano.specs +LDFLAGS += -Wl,-e,main -Wl,-u,eadk_app_name -Wl,-u,eadk_app_icon -Wl,-u,eadk_api_level +LDFLAGS += -Wl,--gc-sections -flinker-output=nolto-rel + +.PHONY: build check clean host test data fetch +build: $(BUILD_DIR)/fnf.nwa + +# apps get 133652 bytes of RAM on custom builds of Epsilon 25.2 (148928 or more on official ones) +check: $(BUILD_DIR)/fnf.nwa + $(Q) $(NWLINK) nwa-bin --ram-length 133652 $< $(BUILD_DIR)/fnf.bin + @echo "BIN $(BUILD_DIR)/fnf.bin: $$(wc -c < $(BUILD_DIR)/fnf.bin) bytes" + +$(BUILD_DIR)/fnf.nwa: $(SRC) $(HEADERS) $(BUILD_DIR)/icon.o + @echo "LD $@" + $(Q) $(CC) $(CFLAGS) $(LDFLAGS) $(SRC) $(BUILD_DIR)/icon.o -lm -o $@ + $(Q) arm-none-eabi-strip --strip-unneeded $@ + +$(BUILD_DIR)/icon.o: src/icon.png | $(BUILD_DIR) + @echo "ICON $<" + $(Q) $(NWLINK) png-icon-o $< $@ + +$(BUILD_DIR): + $(Q) mkdir -p $@ + +# the game's files: fetched (or checked), then packed +$(GEN)/ref.ok: tools/fetch.py tools/ref.txt | $(BUILD_DIR) + @echo "FETCH $(REF)" + $(Q) $(PYTHON) -I tools/fetch.py $(REF) + $(Q) mkdir -p $(GEN) && touch $@ +$(GEN)/data.c: tools/data.py tools/atlas.py $(GEN)/ref.ok + @echo "DATA $(GEN)" + $(Q) $(PYTHON) -I tools/data.py $(REF) $(GEN) +$(GEN)/ids.h: $(GEN)/data.c +fetch: $(GEN)/ref.ok +data: $(GEN)/data.c + +# on a computer: tests/play.c checks the rules, plays every chart with a bot, random keys, saves, and takes pictures +HOST_SRC = tests/play.c src/app.c src/game.c src/draw.c src/screens.c src/save.c src/inflate.c \ + $(L)/gfx.c $(L)/font_data.c $(L)/xfont.c $(GEN)/data.c +HOST_FLAGS = -std=gnu11 -O2 -g -Wall -Wextra -Wno-unused-parameter -ffp-contract=off -Isrc -I$(L) -I$(GEN) -DHOST +host: build/play +build/play: $(HOST_SRC) src/main.c $(wildcard src/*.h) $(GEN)/ids.h + $(Q) mkdir -p build + $(Q) cc $(HOST_FLAGS) -fsanitize=address,undefined -fno-sanitize-recover=undefined $(HOST_SRC) -lm -o $@ + @echo "HOST $@" +test: build/play + build/play test build + +clean: + $(Q) rm -rf $(BUILD_DIR) build diff --git a/games/fnf/README.md b/games/fnf/README.md new file mode 100644 index 00000000..6b1ed9e5 --- /dev/null +++ b/games/fnf/README.md @@ -0,0 +1,91 @@ +> **Friday Night Funkin' is a [NumPlay](../../README.md) game that comes on its own.** It is too big to fit next to NumPlay on the calculator. Get `FridayNightFunkin.nwa` from the [latest release](https://github.com/Mason363/NumPlay/releases/latest). + +

+ Friday Night Funkin' icon +

+ +

Friday Night Funkin'

+ +

+ Win the rap battles, note by note, on the NumWorks calculator.
+ Every week of the game, from the Tutorial to Weekend 1, with its charts, characters and stages. +

+ +

+ Download FridayNightFunkin.nwa +  ·  Controls +

+ +

+ Friday Night Funkin': Ready, Set, Go, then Daddy Dearest sings and Boyfriend hits the notes back on the beat +

+ +## Highlights + +* **Every song and chart:** the Tutorial, Weeks 1 to 7, Weekend 1 and LE SSERAFIM: 27 songs with their Erect and Pico remixes, 166 charts with every note, at the game's own timing. +* **Story mode and freeplay:** a week's songs one after the other, for the week's score, or any song at any difficulty, from Boyfriend's songs or Pico's. +* **The game's own scoring:** points for how close each hit is, Sick, Good, Bad and Shit, the health bar, combos, long notes, misses, and the ranks from Loss to Perfect. +* **The beat, without the music:** the characters bop and sing, the camera and the health icons bump on the beat, and Ready, Set, Go counts you in. +* **The game's look:** its own characters, stages, notes, ratings and letters, with Week 6 in pixels. +* **Saved as you go:** Home saves and quits. Your scores are also copied into `fnf_saves.py`, so installing the app again doesn't erase them. + + + + + + + + + + + + + + + + + + + + + + +
Bopeebo: Boyfriend against Daddy Dearest
Week 1: Daddy Dearest
Senpai at the school
Week 6, in pixels
Cocoa at the mall
Week 5: the mall at Christmas
Ugh against Tankman
Week 7: Tankman
Philly Nice, Pico's remix
Pico's remixes, in freeplay
Freeplay
Freeplay: your best score and rank
Story mode
Story mode, week by week
The results
The results
The pause menu
The pause menu
The title screen
The title screen
+ +## How to play + +The notes scroll up to the arrows at the top of the screen. Press each note's key as it reaches its arrow: **Left**, **Down**, **Up** or **Right**, or **7**, **8**, **(** and **)**. Keep the key down to the end of a long note. The closer to the beat, the better: Sick, Good, Bad or Shit, and Sick and Good keep your combo going. + +A missed note, or a key pressed with no note to hit, costs health: when the green side of the bar runs out, it's game over. The singer on the left sings first, then it's your turn: watch them, and the characters bopping, to keep the beat. + +## Controls + +| Key | What it does | +| --- | --- | +| Left, Down, Up, Right | Hit the notes (7, 8, ( and ) too) | +| Up and Down | Choose a week or a song | +| Left and Right | Choose the difficulty | +| shift | In freeplay, Boyfriend's songs or Pico's | +| OK | Choose | +| Back | Pause (Resume, Restart song, Exit to menu, Options, How to play, Quit game) | +| Home | Save and quit | + +## Good to know + +* **On its own:** Friday Night Funkin' is too big to fit next to NumPlay. Installing it from the NumWorks website swaps out NumPlay and the other apps, and installing NumPlay again swaps it back out. +* **Without sound:** the calculator has no speaker. The notes still come at the songs' exact times, and the countdown, the characters and the camera keep the beat. If your hits come out early or late, change the note offset in Options. +* **Made to fit:** the camera shows the whole stage instead of following the singer, the stages are still pictures, the characters have fewer frames, and the cutscenes, the dialogue and the freeplay DJ are left out. + +## Credits + +Inspired by [Friday Night Funkin'](https://github.com/FunkinCrew/Funkin) by the Funkin' Crew (ninjamuffin99, PhantomArcade, evilsk8r and Kawai Sprite), not affiliated with them. Go play the original: it's free on itch.io and Newgrounds, with the music. + +* The charts, characters, stages, notes, ratings and letters come from the game's own files ([funkin.assets](https://github.com/FunkinCrew/funkin.assets)), which keep the Funkin' Crew's terms: they are not in this repository, and are fetched when the game is built. The rules and numbers follow the game's own code, under the Apache License 2.0. +* The calculator adaptation is by **[Bobydeluxe](https://github.com/Bobydeluxe)**, who brought it to NumPlay. +* The text uses NumPlay's **Nunito** font (SIL Open Font License). + +Made by Mason Chen as part of NumPlay. + +## License + +The game's code is licensed under the GNU General Public License v3.0: see the `LICENSE` file at the root of the repository. The game's charts and pictures keep the Funkin' Crew's own terms (see Credits). "Friday Night Funkin'" is a trademark of The Funkin' Crew Inc. diff --git a/games/fnf/docs/freeplay.png b/games/fnf/docs/freeplay.png new file mode 100644 index 00000000..8376c7e3 Binary files /dev/null and b/games/fnf/docs/freeplay.png differ diff --git a/games/fnf/docs/mall.png b/games/fnf/docs/mall.png new file mode 100644 index 00000000..2be708f3 Binary files /dev/null and b/games/fnf/docs/mall.png differ diff --git a/games/fnf/docs/pause.png b/games/fnf/docs/pause.png new file mode 100644 index 00000000..6b2afd75 Binary files /dev/null and b/games/fnf/docs/pause.png differ diff --git a/games/fnf/docs/pico.png b/games/fnf/docs/pico.png new file mode 100644 index 00000000..d7ae3d5d Binary files /dev/null and b/games/fnf/docs/pico.png differ diff --git a/games/fnf/docs/results.png b/games/fnf/docs/results.png new file mode 100644 index 00000000..c8b66c5c Binary files /dev/null and b/games/fnf/docs/results.png differ diff --git a/games/fnf/docs/school.png b/games/fnf/docs/school.png new file mode 100644 index 00000000..b44c580a Binary files /dev/null and b/games/fnf/docs/school.png differ diff --git a/games/fnf/docs/shot.png b/games/fnf/docs/shot.png new file mode 100644 index 00000000..b2885e16 Binary files /dev/null and b/games/fnf/docs/shot.png differ diff --git a/games/fnf/docs/story.png b/games/fnf/docs/story.png new file mode 100644 index 00000000..c70185a8 Binary files /dev/null and b/games/fnf/docs/story.png differ diff --git a/games/fnf/docs/tankman.png b/games/fnf/docs/tankman.png new file mode 100644 index 00000000..f2316f43 Binary files /dev/null and b/games/fnf/docs/tankman.png differ diff --git a/games/fnf/docs/title.png b/games/fnf/docs/title.png new file mode 100644 index 00000000..93c4662c Binary files /dev/null and b/games/fnf/docs/title.png differ diff --git a/games/fnf/src/app.c b/games/fnf/src/app.c new file mode 100644 index 00000000..c0f15e7d --- /dev/null +++ b/games/fnf/src/app.c @@ -0,0 +1,65 @@ +/* The songs on offer (app.h): story mode plays a week's songs as the game made them (the default variation, or the + * Erect one for Erect and Nightmare); freeplay lists Boyfriend's songs or Pico's, as the game's two freeplay + * characters do (data/players: the characters each one owns). */ +#include "app.h" + +const char *const DIFF_NAME[FNF_DIFFS] = {"EASY", "NORMAL", "HARD", "ERECT", "NIGHTMARE"}; + +int app_owner(unsigned chr) { + if (chr >= FNF_CHARS) return 0; + return fnf_char[chr].flags & FNF_CHAR_BF ? 1 : fnf_char[chr].flags & FNF_CHAR_PICO ? 2 : 0; +} + +static int chart_in(const fnf_var_t *v, int diff) { + for (int c = v->chart; c < v->chart + v->ncharts; c++) + if (fnf_chart[c].diff == diff) return c; + return -1; +} + +int app_chart(unsigned song, int list, int diff) { + if (song >= FNF_SONGS) return -1; + const fnf_song_t *s = &fnf_song[song]; + for (int i = s->var; i < s->var + s->nvar; i++) { + const fnf_var_t *v = &fnf_var[i]; + int owner = app_owner(v->chr[0]); + /* Boyfriend's list has his songs and those of nobody's characters (LE SSERAFIM's), Pico's has his */ + if (list == LIST_PICO ? owner != 2 : owner == 2) continue; + int c = chart_in(v, diff); + if (c >= 0) return c; + } + return -1; +} + +int app_story_chart(unsigned song, int diff) { + if (song >= FNF_SONGS) return -1; + const fnf_song_t *s = &fnf_song[song]; + for (int i = s->var; i < s->var + s->nvar; i++) { + const fnf_var_t *v = &fnf_var[i]; + if (v->kind != FNF_VAR_DEFAULT && v->kind != FNF_VAR_ERECT) continue; + int c = chart_in(v, diff); + if (c >= 0) return c; + } + return -1; +} + +/* Level.getDifficulties: those every song of the week has */ +unsigned app_story_diffs(unsigned level) { + unsigned bits = 0; + for (int d = 0; d < FNF_DIFFS; d++) { + bool all = true; + for (int i = 0; i < fnf_level[level].n; i++) + if (app_story_chart(fnf_level[level].song[i], d) < 0) all = false; + if (all) bits |= 1u << d; + } + return bits ? bits : 1u << FNF_DIFF_NORMAL; +} + +void app_build_list(app_t *a) { + a->list_n = 0; + for (unsigned s = 0; s < FNF_SONGS; s++) + for (int d = 0; d < FNF_DIFFS; d++) + if (app_chart(s, a->p->list, d) >= 0) { + a->list[a->list_n++] = (uint8_t)s; + break; + } +} diff --git a/games/fnf/src/app.h b/games/fnf/src/app.h new file mode 100644 index 00000000..248c66f7 --- /dev/null +++ b/games/fnf/src/app.h @@ -0,0 +1,44 @@ +/* The app: its screens (main.c moves between them, screens.c draws them). */ +#ifndef FNF_APP_H +#define FNF_APP_H +#include "game.h" +#include "save.h" + +enum { V_TITLE, V_MAIN, V_STORY, V_FREEPLAY, V_OPTIONS, V_HELP, V_CREDITS, V_PLAY, V_PAUSE, V_QUIT, V_RESULTS, + V_GAMEOVER, V_ERROR }; +enum { MAIN_STORY, MAIN_FREEPLAY, MAIN_OPTIONS, MAIN_HELP, MAIN_CREDITS, MAIN_COUNT }; +enum { PAUSE_RESUME, PAUSE_RESTART, PAUSE_EXIT, PAUSE_OPTIONS, PAUSE_HELP, PAUSE_QUIT, PAUSE_COUNT }; +enum { OPT_DOWNSCROLL, OPT_OFFSET, OPT_COUNT }; +#define HELP_PAGES 3 +enum { LIST_BF, LIST_PICO }; + +typedef struct { + uint8_t view, under; /* the screen, and what a pause, options or how to play sits on */ + uint8_t sel; /* the menu's choice */ + uint8_t page, first_run; /* how to play; first_run: before the first song (OK starts it) */ + uint8_t story, story_song; /* a week being played, and which of its songs */ + uint8_t list_n; /* freeplay: the songs in the list */ + uint8_t list[FNF_SONGS]; + int32_t campaign; /* the week's score so far */ + uint16_t week_tally[5], week_total, week_max_combo; + uint8_t result_rank, result_new; /* the results screen: its rank, a new best */ + int32_t result_score, result_completion; + uint16_t result_tally[5], result_total, result_max_combo; + uint32_t clock; /* ms, for the menus' beat */ + uint32_t menu_at; /* when the screen came up */ + uint32_t play_t0; /* when the song's screen came up (its arrows fade in) */ + fnf_progress_t *p; + fnf_game_t *g; +} app_t; + +/* the songs and charts on offer */ +int app_owner(unsigned chr); /* 0 nobody's, 1 Boyfriend's, 2 Pico's */ +int app_chart(unsigned song, int list, int diff); /* freeplay: a chart, or -1 */ +int app_story_chart(unsigned song, int diff); /* story mode: a chart, or -1 */ +unsigned app_story_diffs(unsigned level); /* the difficulties a week has (bits) */ +void app_build_list(app_t *a); + +void screens_draw(app_t *a); +const char *app_keys(const app_t *a); +extern const char *const DIFF_NAME[FNF_DIFFS]; +#endif diff --git a/games/fnf/src/data.h b/games/fnf/src/data.h new file mode 100644 index 00000000..4ba65fe5 --- /dev/null +++ b/games/fnf/src/data.h @@ -0,0 +1,126 @@ +/* The game's charts and pictures, as tools/data.py packs them from the Funkin' Crew's files (data.c and ids.h are + * made at build time, in the build folder: they are not in this repository). */ +#ifndef FNF_DATA_H +#define FNF_DATA_H +#include +#include "ids.h" + +#define FNF_NONE 0xFFFF + +/* A picture: 4 bits a pixel (0 transparent, unless it's a stage's background), rows of (w + 1) / 2 bytes, + * DEFLATE-packed at fnf_blob + at; (x, y): its top left from its anchor (a character's feet, a note's middle). */ +typedef struct { + uint16_t w, h; + int16_t x, y; + uint16_t pal, flags; + uint32_t at, size; +} fnf_sprite_t; + +/* An animation: n frames (sprites sprite.. sprite + n - 1), frame i shown from the original's frame at[i] + * (of len, at fps). */ +enum { FNF_AF_LOOP = 1, FNF_AF_SING = 2, FNF_AF_MISS = 4, FNF_AF_DANCE = 8 }; +typedef struct { + uint16_t sprite; + uint8_t n, len, fps, flags; + uint8_t at[6]; +} fnf_anim_t; + +enum { FNF_SLOT_DANCE_L, FNF_SLOT_DANCE_R, FNF_SLOT_SING, FNF_SLOT_MISS = FNF_SLOT_SING + 4, FNF_SLOTS = FNF_SLOT_MISS + 4 }; +enum { FNF_CHAR_ALTERNATE = 1, FNF_CHAR_PIXEL = 2, FNF_CHAR_BF = 4, FNF_CHAR_PICO = 8, FNF_CHAR_ICON_FLIP = 16 }; +/* A character: its animations fnf_anim[anim .. anim + count - 1] (their names: fnf_anim_name), which of them dance, + * sing and miss (slot: an animation of its own, 255 none); it dances every `dance` steps and stops singing after + * `sing` steps. */ +typedef struct { + uint16_t anim; + uint8_t count, dance, sing, flags, icon; + uint8_t slot[FNF_SLOTS]; + uint16_t frame_max; /* its biggest frame, unpacked (bytes) */ +} fnf_char_t; + +/* A note kind a character sings its own way: the animation for each direction (255: the usual one, 254: none). */ +typedef struct { + uint8_t chr, kind; + uint8_t anim[4]; +} fnf_kind_t; + +/* A stage: its background (320x240), the pictures in front of the characters (after the first, second and third of + * them in `order`, FNF_NONE none), where the player's, the opponent's and Girlfriend's feet are on the screen (and + * LE SSERAFIM's other members'), and its scale (world units to pixels, x 10000). */ +typedef struct { + uint16_t bg, front[3]; + int16_t feet[3][2], extra[3][2]; + uint8_t order[3]; + uint16_t scale; +} fnf_stage_t; + +/* A change of tempo: from time t (us), a step lasts step / 256 us; steps0: the steps before it (16.16). */ +typedef struct { + int32_t t; + uint32_t step; + int32_t steps0; +} fnf_tempo_t; + +/* A song event (FNF_EV_...). */ +typedef struct { + int32_t t; + uint8_t type, a, b, c; + int32_t v, d; +} fnf_event_t; + +/* A chart: one difficulty of a variation; its notes DEFLATE-packed (raw bytes once unpacked): per note, LEB128 + * numbers: the time since the last note (us), lane (0-3 the player's, 4-7 the opponent's) | hold << 3 | kind << 4, + * and for a hold its length (us). total: the player's notes that count; speed: scroll speed x 1000. */ +typedef struct { + uint16_t var; + uint8_t diff, pad; + uint32_t at; + uint16_t size, raw, notes, total, speed; +} fnf_chart_t; + +enum { FNF_DIFF_EASY, FNF_DIFF_NORMAL, FNF_DIFF_HARD, FNF_DIFF_ERECT, FNF_DIFF_NIGHTMARE, FNF_DIFFS }; +enum { FNF_VAR_DEFAULT, FNF_VAR_ERECT, FNF_VAR_PICO, FNF_VAR_BF }; +/* A variation of a song: its stage, note style (0 Funkin', 1 pixel), characters (player, opponent, Girlfriend; + * 255 none), tempo, events, charts and length (ms). extra: LE SSERAFIM's other three members; flags: + * FNF_VF_OPPONENT_AT_GF (Girlfriend sings against you, from her place: the Tutorial), FNF_VF_SOLO (only your arrows, + * in the middle: Blazin', as its script does). */ +enum { FNF_VF_OPPONENT_AT_GF = 1, FNF_VF_SOLO = 2 }; +typedef struct { + uint8_t song, kind, stage, style; + uint8_t chr[3], extra; + uint16_t tempo, ntempo, event, nevent, chart; + uint8_t ncharts, flags; + uint32_t length; +} fnf_var_t; + +typedef struct { + const char *name; + uint8_t level, var, nvar, icon; +} fnf_song_t; + +typedef struct { + const char *name; + uint8_t song[4], n; +} fnf_level_t; + +/* A letter of the menus' bold font: its sprite (FNF_NONE: none) and how far it moves the pen. */ +typedef struct { + uint16_t sprite, advance; +} fnf_glyph_t; + +extern const uint16_t fnf_palette[FNF_PALETTES][16]; +extern const fnf_sprite_t fnf_sprite[FNF_SPRITES]; +extern const fnf_anim_t fnf_anim[FNF_ANIMS]; +extern const uint8_t fnf_anim_name[FNF_ANIMS]; +extern const fnf_char_t fnf_char[FNF_CHARS]; +extern const fnf_kind_t fnf_kind[FNF_KINDS]; +extern const uint16_t fnf_icon[FNF_ICONS][3]; +extern const fnf_stage_t fnf_stage[FNF_STAGES]; +extern const fnf_tempo_t fnf_tempo[FNF_TEMPOS]; +extern const fnf_event_t fnf_event[FNF_EVENTS]; +extern const fnf_chart_t fnf_chart[FNF_CHARTS]; +extern const fnf_var_t fnf_var[FNF_VARS]; +extern const fnf_song_t fnf_song[FNF_SONGS]; +extern const fnf_level_t fnf_level[FNF_LEVELS]; +extern const fnf_glyph_t fnf_bold[95]; +extern const uint8_t fnf_blob[FNF_BLOB]; +#endif diff --git a/games/fnf/src/draw.c b/games/fnf/src/draw.c new file mode 100644 index 00000000..a814a4d0 --- /dev/null +++ b/games/fnf/src/draw.c @@ -0,0 +1,540 @@ +/* Pictures and the song's screen (draw.h). The game's 1280 x 720 screen is drawn at a quarter across and a third + * down: the notes cross the screen in the same time as in the game. */ +#include +#include +#include "draw.h" +#include "inflate.h" + +void plat_push(int x, int y, int w, int h, const uint16_t *px); +void np_push(int x, int y, int w, int h, const color_t *px) { plat_push(x, y, w, h, px); } + +/* ------------------------------------------------------------------------------------------------ RAM */ +/* the bold letters (kept), then a song's or the menus' pictures */ +#define POOL (FNF_BOLD_BYTES + FNF_CAST_MAX + FNF_HUD_MAX + 2 * FNF_ICON_MAX + FNF_TRANSIENT_MAX + 1024) +static uint8_t bg_px[FNF_BG_BYTES]; +static uint8_t pool[POOL]; +static uint32_t used, base; +static color_t *strip; /* (on main's stack: draw_strip_buffer) */ +void draw_strip_buffer(color_t *p) { strip = p; } + +void ram_reset(void) { used = base; } + +uint8_t *ram_take(uint32_t n) { + n = (n + 3) & ~3u; + if (used + n > POOL) return 0; + uint8_t *p = pool + used; + used += n; + return p; +} + +uint8_t *bg_pixels(void) { return bg_px; } + +bool pic_load(pic_t *p, unsigned id, uint8_t *dst, uint32_t cap) { + if (id >= FNF_SPRITES || !dst) return false; + const fnf_sprite_t *s = &fnf_sprite[id]; + uint32_t stride = (s->w + 1u) / 2, n = stride * s->h; + if (n > cap || fnf_inflate(fnf_blob + s->at, s->size, dst, cap) != (int32_t)n) return false; + p->id = (uint16_t)id, p->w = s->w, p->h = s->h, p->stride = (uint16_t)stride, p->x = s->x, p->y = s->y; + p->pal = fnf_palette[s->pal], p->px = dst; + return true; +} + +bool pic_new(pic_t *p, unsigned id) { + if (id >= FNF_SPRITES) return false; + const fnf_sprite_t *s = &fnf_sprite[id]; + uint32_t n = (s->w + 1u) / 2 * s->h; + uint8_t *d = ram_take(n); + return d && pic_load(p, id, d, n); +} + +static pic_t bg; +bool bg_load(unsigned id) { return pic_load(&bg, id, bg_px, sizeof bg_px) && bg.w == SCREEN_W && bg.h == SCREEN_H; } + +/* ------------------------------------------------------------------------------------------------ drawing */ +void push_strip(void) { np_push(0, gfx_y0, SCREEN_W, gfx_y1 - gfx_y0, gfx_buf); } + +void blit(const pic_t *p, int x, int y, int alpha, bool flip_y) { + if (!p->px || alpha <= 0) return; + int y0 = y < gfx_y0 ? gfx_y0 : y, y1 = y + p->h > gfx_y1 ? gfx_y1 : y + p->h; + int x0 = x < 0 ? 0 : x, x1 = x + p->w > SCREEN_W ? SCREEN_W : x + p->w; + if (y0 >= y1 || x0 >= x1) return; + const uint16_t *pal = p->pal; + for (int r = y0; r < y1; r++) { + int sy = flip_y ? p->h - 1 - (r - y) : r - y; + const uint8_t *src = p->px + sy * p->stride; + color_t *dst = gfx_buf + (r - gfx_y0) * SCREEN_W; + for (int c = x0; c < x1; c++) { + int sx = c - x; + unsigned v = src[sx >> 1]; + if (!v) { /* two clear pixels */ + if (!(sx & 1)) c++; + continue; + } + v = sx & 1 ? v >> 4 : v & 15; + if (!v) continue; + dst[c] = alpha >= 32 ? pal[v] : gfx_mix(dst[c], pal[v], alpha); + } + } +} + +void blit_at(const pic_t *p, int ax, int ay, int alpha) { blit(p, ax + p->x, ay + p->y, alpha, false); } + +void blit_tinted(const pic_t *p, int x, int y, color_t tint, int t32) { + if (!p->px) return; + int y0 = y < gfx_y0 ? gfx_y0 : y, y1 = y + p->h > gfx_y1 ? gfx_y1 : y + p->h; + int x0 = x < 0 ? 0 : x, x1 = x + p->w > SCREEN_W ? SCREEN_W : x + p->w; + for (int r = y0; r < y1; r++) { + const uint8_t *src = p->px + (r - y) * p->stride; + color_t *dst = gfx_buf + (r - gfx_y0) * SCREEN_W; + for (int c = x0; c < x1; c++) { + int sx = c - x; + unsigned v = src[sx >> 1]; + v = sx & 1 ? v >> 4 : v & 15; + if (v) dst[c] = gfx_mix(p->pal[v], tint, t32); + } + } +} + +static void blit_scaled_flip(const pic_t *p, int x, int y, int w, int h, int alpha, bool flip); +void blit_scaled(const pic_t *p, int x, int y, int w, int h, int alpha) { blit_scaled_flip(p, x, y, w, h, alpha, false); } +static void blit_scaled_flip(const pic_t *p, int x, int y, int w, int h, int alpha, bool flip) { + if (!p->px || w <= 0 || h <= 0 || alpha <= 0) return; + int y0 = y < gfx_y0 ? gfx_y0 : y, y1 = y + h > gfx_y1 ? gfx_y1 : y + h; + int x0 = x < 0 ? 0 : x, x1 = x + w > SCREEN_W ? SCREEN_W : x + w; + for (int r = y0; r < y1; r++) { + int sy = (r - y) * p->h / h; + const uint8_t *src = p->px + sy * p->stride; + color_t *dst = gfx_buf + (r - gfx_y0) * SCREEN_W; + for (int c = x0; c < x1; c++) { + int sx = (c - x) * p->w / w; + if (flip) sx = p->w - 1 - sx; + unsigned v = src[sx >> 1]; + v = sx & 1 ? v >> 4 : v & 15; + if (v) dst[c] = alpha >= 32 ? p->pal[v] : gfx_mix(dst[c], p->pal[v], alpha); + } + } +} + +/* the background, 16 colours (darkened by dark1000 thousandths), zoomed round the screen's middle */ +void draw_bg(const uint16_t *pal, int dark1000, float zoom) { + color_t lut[16]; + int t = dark1000 * 32 / 1000; + for (int i = 0; i < 16; i++) lut[i] = t > 0 ? gfx_mix(pal[i], 0, t) : pal[i]; + if (zoom <= 1.0005f) { + for (int r = gfx_y0; r < gfx_y1; r++) { + const uint8_t *src = bg_px + r * (SCREEN_W / 2); + uint32_t *dst = (uint32_t *)(gfx_buf + (r - gfx_y0) * SCREEN_W); + for (int i = 0; i < SCREEN_W / 2; i++) { + unsigned v = src[i]; + dst[i] = lut[v & 15] | (uint32_t)lut[v >> 4] << 16; + } + } + return; + } + int32_t step = (int32_t)(65536.0f / zoom); + int32_t sx0 = (int32_t)((160.0f - 160.0f / zoom) * 65536.0f); + for (int r = gfx_y0; r < gfx_y1; r++) { + int sy = (int)(120.0f + (r - 120) / zoom); + sy = sy < 0 ? 0 : sy > SCREEN_H - 1 ? SCREEN_H - 1 : sy; + const uint8_t *src = bg_px + sy * (SCREEN_W / 2); + color_t *dst = gfx_buf + (r - gfx_y0) * SCREEN_W; + int32_t sx = sx0; + for (int c = 0; c < SCREEN_W; c++, sx += step) { + int x = sx >> 16; + unsigned v = src[x >> 1]; + dst[c] = lut[x & 1 ? v >> 4 : v & 15]; + } + } +} + +/* ------------------------------------------------------------------------------------------------ the bold font */ +static pic_t bold[95]; +static bool bold_ready; + +bool bold_load(void) { + used = base = 0; + bold_ready = true; + for (int i = 0; i < 95; i++) { + bold[i].px = 0; + for (int j = 0; j < i && !bold[i].px; j++) /* (lower case: the same letters as the capitals) */ + if (fnf_bold[j].sprite == fnf_bold[i].sprite) bold[i] = bold[j]; + if (!bold[i].px && fnf_bold[i].sprite != FNF_NONE && !pic_new(&bold[i], fnf_bold[i].sprite)) bold_ready = false; + } + base = used; + return bold_ready; +} + +int bold_width(const char *s, int scale256) { + int w = 0; + for (; *s; s++) { + unsigned c = (unsigned char)*s; + if (c >= 32 && c < 127) w += fnf_bold[c - 32].advance; + } + return w * scale256 / 256; +} + +void bold_text(int x, int y, const char *s, int alpha, int scale256) { + for (; *s; s++) { + unsigned c = (unsigned char)*s; + if (c < 32 || c >= 127) continue; + const pic_t *g = &bold[c - 32]; + if (g->px) { + if (scale256 == 256) blit(g, x + g->x, y + g->y, alpha, false); + else blit_scaled(g, x + g->x * scale256 / 256, y + g->y * scale256 / 256, g->w * scale256 / 256, + g->h * scale256 / 256, alpha); + } + x += fnf_bold[c - 32].advance * scale256 / 256; + } +} + +/* ------------------------------------------------------------------------------------------------ the song */ +#define HUD_X(x) ((x) / 4) /* the game's HUD, 1280 x 720 */ +#define HUD_Y(y) ((y) / 3) +#define PX_PER_MS 0.15f /* Constants.PIXELS_PER_MS (0.45) a third down */ + +static struct { + pic_t actor[FNF_ACTORS]; /* each character's frame now */ + uint32_t actor_cap[FNF_ACTORS]; + pic_t front[3]; + pic_t note[4], strum[3][4], hold[4], end[4], rating[4], digit[10]; + pic_t icon[2][3]; + uint8_t icon_id[2]; + pic_t trans; /* the countdown's picture, or the four splashes */ + pic_t splash[4]; + uint8_t *trans_px; + uint16_t splash_id[4]; + bool splashes; + bool ok; +} S; + +static const uint16_t *sprite_pal(unsigned id) { return fnf_palette[fnf_sprite[id].pal]; } + +static bool style_pic(pic_t *p, const uint16_t *ids, int i) { return pic_new(p, ids[i]); } + +#define STYLE(name) (g->style ? FNF_SPR_PIXEL_##name : FNF_SPR_FUNKIN_##name) + +static bool load_icons(const fnf_game_t *g, int who) { + S.icon_id[who] = g->icon[who]; + for (int f = 0; f < 3; f++) { + unsigned id = fnf_icon[g->icon[who]][f]; + if (!S.icon[who][f].px) return false; + uint32_t cap = FNF_ICON_MAX / 3; + if (!pic_load(&S.icon[who][f], id, S.icon[who][f].px, cap)) return false; + } + return true; +} + +void play_icons(const fnf_game_t *g) { + for (int w = 0; w < 2; w++) + if (S.icon_id[w] != g->icon[w]) load_icons(g, w); +} + +bool play_load(const fnf_game_t *g) { + const fnf_var_t *v = &fnf_var[g->var]; + const fnf_stage_t *st = &fnf_stage[v->stage]; + memset(&S, 0, sizeof S); + ram_reset(); + bool ok = bg_load(st->bg); + for (int i = 0; i < FNF_ACTORS; i++) { + const fnf_actor_t *a = &g->actor[i]; + if (a->chr == 255) continue; + S.actor_cap[i] = fnf_char[a->chr].frame_max; + S.actor[i].px = ram_take(S.actor_cap[i]); + S.actor[i].id = FNF_NONE; + ok &= S.actor[i].px != 0; + } + for (int k = 0; k < 3; k++) + if (st->front[k] != FNF_NONE) ok &= pic_new(&S.front[k], st->front[k]); + static const uint16_t funkin[] = { + FNF_SPR_FUNKIN_NOTE0, FNF_SPR_FUNKIN_NOTE1, FNF_SPR_FUNKIN_NOTE2, FNF_SPR_FUNKIN_NOTE3, + FNF_SPR_FUNKIN_STATIC0, FNF_SPR_FUNKIN_STATIC1, FNF_SPR_FUNKIN_STATIC2, FNF_SPR_FUNKIN_STATIC3, + FNF_SPR_FUNKIN_PRESS0, FNF_SPR_FUNKIN_PRESS1, FNF_SPR_FUNKIN_PRESS2, FNF_SPR_FUNKIN_PRESS3, + FNF_SPR_FUNKIN_CONFIRM0, FNF_SPR_FUNKIN_CONFIRM1, FNF_SPR_FUNKIN_CONFIRM2, FNF_SPR_FUNKIN_CONFIRM3, + FNF_SPR_FUNKIN_HOLD0, FNF_SPR_FUNKIN_HOLD1, FNF_SPR_FUNKIN_HOLD2, FNF_SPR_FUNKIN_HOLD3, + FNF_SPR_FUNKIN_END0, FNF_SPR_FUNKIN_END1, FNF_SPR_FUNKIN_END2, FNF_SPR_FUNKIN_END3, + FNF_SPR_FUNKIN_SICK, FNF_SPR_FUNKIN_GOOD, FNF_SPR_FUNKIN_BAD, FNF_SPR_FUNKIN_SHIT, + FNF_SPR_FUNKIN_NUM0, FNF_SPR_FUNKIN_NUM1, FNF_SPR_FUNKIN_NUM2, FNF_SPR_FUNKIN_NUM3, FNF_SPR_FUNKIN_NUM4, + FNF_SPR_FUNKIN_NUM5, FNF_SPR_FUNKIN_NUM6, FNF_SPR_FUNKIN_NUM7, FNF_SPR_FUNKIN_NUM8, FNF_SPR_FUNKIN_NUM9, + FNF_SPR_FUNKIN_SPLASH0_0, FNF_SPR_FUNKIN_SPLASH1_0, FNF_SPR_FUNKIN_SPLASH2_0, FNF_SPR_FUNKIN_SPLASH3_0}; + static const uint16_t pixel[] = { + FNF_SPR_PIXEL_NOTE0, FNF_SPR_PIXEL_NOTE1, FNF_SPR_PIXEL_NOTE2, FNF_SPR_PIXEL_NOTE3, + FNF_SPR_PIXEL_STATIC0, FNF_SPR_PIXEL_STATIC1, FNF_SPR_PIXEL_STATIC2, FNF_SPR_PIXEL_STATIC3, + FNF_SPR_PIXEL_PRESS0, FNF_SPR_PIXEL_PRESS1, FNF_SPR_PIXEL_PRESS2, FNF_SPR_PIXEL_PRESS3, + FNF_SPR_PIXEL_CONFIRM0, FNF_SPR_PIXEL_CONFIRM1, FNF_SPR_PIXEL_CONFIRM2, FNF_SPR_PIXEL_CONFIRM3, + FNF_SPR_PIXEL_HOLD0, FNF_SPR_PIXEL_HOLD1, FNF_SPR_PIXEL_HOLD2, FNF_SPR_PIXEL_HOLD3, + FNF_SPR_PIXEL_END0, FNF_SPR_PIXEL_END1, FNF_SPR_PIXEL_END2, FNF_SPR_PIXEL_END3, + FNF_SPR_PIXEL_SICK, FNF_SPR_PIXEL_GOOD, FNF_SPR_PIXEL_BAD, FNF_SPR_PIXEL_SHIT, + FNF_SPR_PIXEL_NUM0, FNF_SPR_PIXEL_NUM1, FNF_SPR_PIXEL_NUM2, FNF_SPR_PIXEL_NUM3, FNF_SPR_PIXEL_NUM4, + FNF_SPR_PIXEL_NUM5, FNF_SPR_PIXEL_NUM6, FNF_SPR_PIXEL_NUM7, FNF_SPR_PIXEL_NUM8, FNF_SPR_PIXEL_NUM9, + FNF_SPR_PIXEL_SPLASH0_0, FNF_SPR_PIXEL_SPLASH1_0, FNF_SPR_PIXEL_SPLASH2_0, FNF_SPR_PIXEL_SPLASH3_0}; + const uint16_t *ids = g->style ? pixel : funkin; + for (int d = 0; d < 4; d++) { + ok &= style_pic(&S.note[d], ids, d); + for (int s = 0; s < 3; s++) ok &= style_pic(&S.strum[s][d], ids, 4 + 4 * s + d); + ok &= style_pic(&S.hold[d], ids, 16 + d); + ok &= style_pic(&S.end[d], ids, 20 + d); + ok &= style_pic(&S.rating[d], ids, 24 + d); + S.splash_id[d] = ids[38 + d]; + } + for (int n = 0; n < 10; n++) ok &= style_pic(&S.digit[n], ids, 28 + n); + for (int w = 0; w < 2; w++) + for (int f = 0; f < 3; f++) { + S.icon[w][f].px = ram_take(FNF_ICON_MAX / 3); + ok &= S.icon[w][f].px != 0; + } + ok &= load_icons(g, 0) && load_icons(g, 1); + S.trans_px = ram_take(FNF_TRANSIENT_MAX); + S.trans.id = FNF_NONE; + ok &= S.trans_px != 0; + S.ok = ok; + (void)sprite_pal; + return ok; +} + +/* the countdown's picture and the splashes take turns in the same RAM */ +static void trans_show(unsigned id) { + if (S.trans.id == id && S.trans.px) return; + S.splashes = false; + if (!pic_load(&S.trans, id, S.trans_px, FNF_TRANSIENT_MAX)) S.trans.px = 0, S.trans.id = FNF_NONE; +} + +static bool splashes_show(void) { + if (S.splashes) return true; + S.trans.id = FNF_NONE; + uint32_t at = 0; + for (int d = 0; d < 4; d++) { + const fnf_sprite_t *s = &fnf_sprite[S.splash_id[d]]; + uint32_t n = (s->w + 1u) / 2 * s->h; + if (!pic_load(&S.splash[d], S.splash_id[d], S.trans_px + at, FNF_TRANSIENT_MAX - at)) return false; + at += n; + } + return S.splashes = true; +} + +static float ease_cube_in_out(float x) { return x < 0.5f ? 4 * x * x * x : 1 - powf(-2 * x + 2, 3) / 2; } +static float ease_circ_out(float x) { return sqrtf(1 - (x - 1) * (x - 1)); } + +/* a camera bop's leftover: lerp(1, m, 0.95^(dt x 60)) (PlayState.update) */ +static float decay(int32_t dt) { return dt <= 0 ? 1.0f : powf(0.95f, dt * 60e-6f); } + +static int hud_tx(int x, float z) { return (int)lroundf(160 + (x - 160) * z); } +static int hud_ty(int y, float z) { return (int)lroundf(120 + (y - 120) * z); } + +static color_t RED, GREEN, BLACK; + +typedef struct { + const fnf_game_t *g; + bool down; + int32_t since; + float cam, hud; +} scene_t; + +static void strip_scene(const scene_t *sc) { + const fnf_game_t *g = sc->g; + const fnf_var_t *v = &fnf_var[g->var]; + const fnf_stage_t *st = &fnf_stage[v->stage]; + int32_t now = g->now; + /* LE SSERAFIM's dimming */ + int dark = 0; + if (g->dark_t1 > g->dark_t0 || g->dark_to) { + float x = g->dark_t1 > g->dark_t0 ? (float)(now - g->dark_t0) / (float)(g->dark_t1 - g->dark_t0) : 1; + x = x < 0 ? 0 : x > 1 ? 1 : x; + x = -(cosf(3.14159265f * x) - 1) / 2; /* sineInOut */ + dark = (int)(g->dark_from + (g->dark_to - g->dark_from) * x); + } + float z = sc->cam; + draw_bg(bg.pal, dark, z); + /* the characters, in the stage's order, and what stands in front of them */ + for (int k = 0; k < 3; k++) { + int i = st->order[k]; + const fnf_actor_t *a = &g->actor[i]; + if (a->chr != 255 && a->visible && S.actor[i].px && S.actor[i].id != FNF_NONE) { + int fx = hud_tx(a->x, z), fy = hud_ty(a->y, z); + if (dark) blit_tinted(&S.actor[i], fx + S.actor[i].x, fy + S.actor[i].y, 0, dark * 32 / 1000); + else blit(&S.actor[i], fx + S.actor[i].x, fy + S.actor[i].y, 32, false); + } + if (S.front[k].px) blit(&S.front[k], hud_tx(S.front[k].x, z), hud_ty(S.front[k].y, z), 32, false); + } + for (int i = 3; i < FNF_ACTORS; i++) { /* LE SSERAFIM's other members, in front */ + const fnf_actor_t *a = &g->actor[i]; + if (a->chr == 255 || !a->visible || !S.actor[i].px || S.actor[i].id == FNF_NONE) continue; + int fx = hud_tx(a->x, z), fy = hud_ty(a->y, z); + if (dark) blit_tinted(&S.actor[i], fx + S.actor[i].x, fy + S.actor[i].y, 0, dark * 32 / 1000); + else blit(&S.actor[i], fx + S.actor[i].x, fy + S.actor[i].y, 32, false); + } + /* ---- the HUD */ + float h = sc->hud; + bool down = sc->down; + int strum_y = down ? 219 : 21; + float speed = g->speed / 1000.0f; + bool solo = (v->flags & FNF_VF_SOLO) != 0; + int px0 = solo ? 118 : 185; /* your arrows' first (centred when alone) */ + /* the arrows fade in, one after the other, as the song comes up (Strumline.fadeInArrow) */ + for (int lane = 0; lane < 8; lane++) { + int d = lane & 3; + if (solo && lane >= 4) continue; + int cx = lane < 4 ? px0 + 28 * d : 25 + 28 * d; + int alpha = 32, dy = 0; + float ft = (sc->since - 500000 - 200000 * d) / 1e6f; + if (ft < 1) { + float e = ft <= 0 ? 0 : ease_circ_out(ft); + alpha = (int)(e * 32), dy = (int)lroundf((1 - e) * -3); + } + const pic_t *p = &S.strum[g->strum[lane]][d]; + blit_at(p, hud_tx(cx, h), hud_ty(strum_y + dy, h), alpha); + } + /* holds, then notes */ + for (int pass = 0; pass < 2; pass++) + for (int i = 0; i < FNF_ACTIVE; i++) { + const fnf_note_t *n = &g->note[i]; + if (n->state == NOTE_GONE && n->hold != HOLD_ON) continue; + if (solo && n->lane >= 4) continue; + int d = n->lane & 3; + int cx = hud_tx(n->lane < 4 ? px0 + 28 * d : 25 + 28 * d, h); + float dist = (n->t - now) / 1000.0f * PX_PER_MS * speed; + int y = down ? strum_y - (int)lroundf(dist) : strum_y + (int)lroundf(dist); + if (pass == 0) { + if (n->len <= 0 || n->hold == HOLD_DONE || n->hold == HOLD_DROPPED) continue; + float tail = (n->t + n->len - now) / 1000.0f * PX_PER_MS * speed; + int ye = down ? strum_y - (int)lroundf(tail) : strum_y + (int)lroundf(tail); + int ys = n->hold == HOLD_ON ? strum_y : y; /* held: from the arrow on */ + if (n->hold == HOLD_ON && (down ? ye > ys : ye < ys)) continue; + ys = hud_ty(ys, h), ye = hud_ty(ye, h); + const pic_t *body = &S.hold[d], *end = &S.end[d]; + int top = down ? ye : ys, bottom = down ? ys : ye; + int x0 = cx + body->x; + /* the body down to the end's start, then the end */ + int body_top = down ? top + end->h : top, body_bottom = down ? bottom : bottom - end->h; + for (int r = body_top < gfx_y0 ? gfx_y0 : body_top; r < body_bottom && r < gfx_y1; r++) { + pic_t row = *body; + row.h = 1; + blit(&row, x0, r, 32, false); + } + if (down) blit(end, cx + end->x, top, 32, true); + else blit(end, cx + end->x, bottom - end->h, 32, false); + } else if (n->state != NOTE_GONE) { + if (y < -20 || y > 260) continue; + blit_at(&S.note[d], cx, hud_ty(y, h), n->state == NOTE_FADED ? 16 : 32); + } + } + /* splashes on Sick hits (167 ms, at 80%) */ + int32_t cd_age; + bool counting = fnf_countdown(g, &cd_age) != 0; + for (int d = 0; d < 4 && !counting; d++) { + if (!g->splash_at[d] || now - g->splash_at[d] > 167000 || now < g->splash_at[d]) continue; + if (splashes_show()) blit_at(&S.splash[d], hud_tx(px0 + 28 * d, h), hud_ty(strum_y, h), 26); + } + /* the health bar: red for the opponent on the left, green for you on the right */ + int bar_y = down ? 24 : 216; + int bx = hud_tx(85, h), bw = (int)lroundf(150 * h), by = hud_ty(bar_y - 3, h), bh = 6; + gfx_fill(bx - 1, by - 1, bw + 2, bh + 2, BLACK); + int split = bw - (int)((int64_t)g->health_shown * bw / FNF_HEALTH_MAX); + gfx_fill(bx, by, split, bh, RED); + gfx_fill(bx + split, by, bw - split, bh, GREEN); + /* the icons, bopping on the beat: 1.2 times wider, back over min(a step x 0.002 s, 0.175 s) */ + int32_t bop_len = fnf_step_len(g, now) * 2 / 1000; + if (bop_len > 175000) bop_len = 175000; + float grow = 1; + if (g->icon_at && now >= g->icon_at && now - g->icon_at < bop_len) grow = 1.2f - 0.2f * (now - g->icon_at) / bop_len; + for (int w = 0; w < 2; w++) { + if (w == 1 && g->icon_hidden) continue; + int32_t hp = w == 0 ? g->health_shown : FNF_HEALTH_MAX - g->health_shown; + int frame = hp < 400000 ? 1 : hp > 1600000 && S.icon[w][2].id != S.icon[w][0].id ? 2 : 0; + const pic_t *ic = &S.icon[w][frame]; + int size = (int)lroundf(37.5f * grow); + int cx = bx + split; + /* (iconP1 from 26 left of the split, iconP2 ending 26 right of it, both on the bar's middle) */ + int x = w == 0 ? cx - 6 : cx + 6 - size; + int y = by + bh / 2 - size / 2; + int sx = ic->w * size / 38, sy = ic->h * size / 38; + /* (mirrored: the picture's place in its square mirrors too) */ + int left = g->icon_flip[w] ? 38 - (ic->x + 19) - ic->w : ic->x + 19; + blit_scaled_flip(ic, x + left * size / 38, y + (ic->y + 19) * size / 38, sx, sy, 32, g->icon_flip[w]); + } + /* Score: 12,345 */ + char buf[32], *p = buf + sizeof buf - 1; + int32_t sc_ = fnf_score(g); + bool neg = sc_ < 0; + uint32_t u = (uint32_t)(neg ? -sc_ : sc_); + *p = 0; + int digits = 0; + do { + if (digits && digits % 3 == 0) *--p = ','; + *--p = (char)('0' + u % 10), u /= 10, digits++; + } while (u); + if (neg) *--p = '-'; + char line[48] = "Score: "; + strcat(line, p); + int tx = hud_tx(85 + 150 - 48, h), ty = hud_ty(bar_y + 11, h); + for (int oy = -1; oy <= 1; oy++) + for (int ox = -1; ox <= 1; ox++) + if (ox || oy) gfx_text(&np_font_small, tx + ox, ty + oy, line, BLACK, 256); + gfx_text(&np_font_small, tx, ty, line, 0xFFFF, 256); + /* the ratings and combo popping up */ + for (int i = 0; i < FNF_POPS; i++) { + const fnf_pop_t *pp = &g->pop[i]; + if (pp->what == 255 || !pp->at || now < pp->at || now > pp->fade + 200000) continue; + float t = (now - pp->at) / 1e6f; + int x = (int)lroundf(pp->x + pp->vx * t), y = (int)lroundf(pp->y + pp->vy * t + pp->ay * t * t / 2); + int alpha = now < pp->fade ? 32 : (int)(32 - 32 * (now - pp->fade) / 200000); + if (g->style && alpha < 32) alpha = alpha >= 16 ? 32 : 0; /* (pixel: stepped(2)) */ + const pic_t *pic = pp->what < 4 ? &S.rating[pp->what] : &S.digit[pp->what - 10]; + blit_at(pic, hud_tx(x, h), hud_ty(y, h), alpha); + } + /* the countdown: Ready, Set, Go, each fading over its beat */ + int32_t age; + int cd = fnf_countdown(g, &age); + if (cd) { + static const uint16_t funkin_cd[3] = {FNF_SPR_FUNKIN_COUNTTWO, FNF_SPR_FUNKIN_COUNTONE, FNF_SPR_FUNKIN_COUNTGO}; + static const uint16_t pixel_cd[3] = {FNF_SPR_PIXEL_COUNTTWO, FNF_SPR_PIXEL_COUNTONE, FNF_SPR_PIXEL_COUNTGO}; + trans_show((g->style ? pixel_cd : funkin_cd)[cd - 1]); + float x = age / (float)(fnf_step_len(g, now) * 4); + x = x < 0 ? 0 : x > 1 ? 1 : x; + float a = g->style ? 1 - floorf(x * 8) / 8 : 1 - ease_cube_in_out(x); + if (S.trans.px) blit_at(&S.trans, hud_tx(160, h), hud_ty(120, h), (int)(a * 32)); + } +} + +static scene_t scene; + +static void actor_frames(const fnf_game_t *g) { + for (int i = 0; i < FNF_ACTORS; i++) { + if (g->actor[i].chr == 255 || !S.actor[i].px) continue; + unsigned id = fnf_actor_sprite(g, i); + if (id == FNF_NONE || id == S.actor[i].id) continue; + uint8_t *px = S.actor[i].px; + if (!pic_load(&S.actor[i], id, px, S.actor_cap[i])) S.actor[i].px = px, S.actor[i].id = FNF_NONE; + } +} + +void (*draw_between)(void); /* (main.c: the keyboard, read between strips) */ + +void draw_frame(void (*strip_fn)(void *), void *ctx) { + gfx_buf = strip; + for (int y = 0; y < SCREEN_H; y += STRIP_H) { + gfx_y0 = y, gfx_y1 = y + STRIP_H; + strip_fn(ctx); + push_strip(); + if (draw_between) draw_between(); + } +} + +void play_prepare(const fnf_game_t *g, bool downscroll, int32_t since_start) { + RED = HEX(0xFF0000), GREEN = HEX(0x66FF33), BLACK = 0; + play_icons(g); + actor_frames(g); + scene.g = g, scene.down = downscroll, scene.since = since_start; + float cd = decay(g->now - g->bop_at); + scene.cam = g->bop_at ? 1 + g->bop_cam / 1e6f * cd : 1; + scene.hud = g->bop_at ? 1 + g->bop_hud / 1e6f * cd : 1; +} + +void play_strip(void) { strip_scene(&scene); } + +/* one character alone, on black (the game over) */ +void play_actor_strip(const fnf_game_t *g, int i) { + gfx_fill(0, gfx_y0, SCREEN_W, gfx_y1 - gfx_y0, 0); + const fnf_actor_t *a = &g->actor[i]; + if (a->chr != 255 && S.actor[i].px && S.actor[i].id != FNF_NONE) + blit(&S.actor[i], a->x + S.actor[i].x, a->y + S.actor[i].y, 32, false); +} diff --git a/games/fnf/src/draw.h b/games/fnf/src/draw.h new file mode 100644 index 00000000..c6e34598 --- /dev/null +++ b/games/fnf/src/draw.h @@ -0,0 +1,49 @@ +/* Drawing: the game's pictures unpacked into RAM and drawn in strips of the screen, with NumPlay's launcher graphics + * for text and boxes (gfx.h). */ +#ifndef FNF_DRAW_H +#define FNF_DRAW_H +#include +#include "gfx.h" +#include "game.h" + +/* an unpacked picture */ +typedef struct { + uint16_t id, w, h, stride; + int16_t x, y; + const uint16_t *pal; + uint8_t *px; +} pic_t; + +/* RAM for pictures: a song's (its stage, characters, notes...) or the menus'. */ +void ram_reset(void); +uint8_t *ram_take(uint32_t n); /* NULL when full */ +bool pic_load(pic_t *p, unsigned sprite, uint8_t *dst, uint32_t cap); +bool pic_new(pic_t *p, unsigned sprite); /* into RAM taken for it */ +uint8_t *bg_pixels(void); /* the 320x240 background's (38400 bytes) */ +bool bg_load(unsigned sprite); + +/* drawing into the strip (gfx_y0..gfx_y1): (x, y) the picture's top left; alpha 0-32 */ +void blit(const pic_t *p, int x, int y, int alpha, bool flip_y); +void blit_at(const pic_t *p, int ax, int ay, int alpha); /* at its anchor */ +void blit_scaled(const pic_t *p, int x, int y, int w, int h, int alpha); +void blit_tinted(const pic_t *p, int x, int y, color_t tint, int t32); +void draw_bg(const uint16_t *pal, int dark1000, float zoom); +void push_strip(void); + +/* the menus' bold letters (white, outlined): their width, and drawn with their top at y */ +bool bold_load(void); +int bold_width(const char *s, int scale256); +void bold_text(int x, int y, const char *s, int alpha, int scale256); + +void draw_strip_buffer(color_t *p); /* SCREEN_W x STRIP_H, for the frames */ +/* a frame: strip_fn draws each strip in turn (gfx_y0..gfx_y1), which is sent to the screen */ +void draw_frame(void (*strip_fn)(void *), void *ctx); +extern void (*draw_between)(void); /* called between strips (to read the keys) */ + +/* a song: what it shows, unpacked when it starts; then each frame, prepared and drawn strip by strip */ +bool play_load(const fnf_game_t *g); +void play_prepare(const fnf_game_t *g, bool downscroll, int32_t since_start); +void play_strip(void); +void play_actor_strip(const fnf_game_t *g, int i); +void play_icons(const fnf_game_t *g); /* (after a SetHealthIcon) */ +#endif diff --git a/games/fnf/src/game.c b/games/fnf/src/game.c new file mode 100644 index 00000000..33dfbde6 --- /dev/null +++ b/games/fnf/src/game.c @@ -0,0 +1,744 @@ +/* The song (game.h): Friday Night Funkin's rules, after its own code (FunkinCrew/Funkin, Apache-2.0: PlayState, + * Scoring, Strumline, BaseCharacter, Conductor, Countdown, PopUpStuff, Constants). */ +#include +#include +#include "game.h" +#include "inflate.h" + +/* PBOT1's points for a press |ms| off: 500 under 5 ms, then int(500 * (1 - 1 / (1 + e^(-0.08 (ms - 54.99)))) + 9) + * (Scoring.scoreNotePBOT1, worked out in double precision as the game does) */ +static const int16_t PBOT1[161] = { + 500, 500, 500, 500, 500, 499, 499, 498, 497, 496, 495, 494, 493, 492, 490, 489, + 487, 486, 484, 482, 480, 478, 475, 473, 470, 467, 464, 460, 457, 453, 449, 445, + 440, 435, 430, 424, 419, 413, 406, 400, 393, 385, 378, 370, 362, 353, 345, 336, + 327, 317, 308, 298, 288, 278, 268, 258, 248, 238, 229, 219, 209, 200, 190, 181, + 172, 163, 155, 147, 139, 131, 124, 117, 111, 104, 98, 92, 87, 82, 77, 72, + 68, 64, 60, 57, 53, 50, 47, 44, 42, 39, 37, 35, 33, 31, 30, 28, + 27, 25, 24, 23, 22, 21, 20, 19, 18, 17, 17, 16, 16, 15, 15, 14, + 14, 13, 13, 13, 12, 12, 12, 11, 11, 11, 11, 11, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, +}; + +/* health (millionths of the bar's 2.0): Constants.HEALTH_* */ +#define H_SICK 30000 /* +1.5% of the bar */ +#define H_GOOD 15000 +#define H_SHIT (-20000) +#define H_MISS (-80000) /* -4%, a ghost miss too */ +#define MISS_POINTS 100 +#define GHOST_POINTS 10 +#define HOLD_DROP_MIN 160000 /* a hold let go of with more than this left costs points */ + +int fnf_judge(int32_t ms) { + int32_t a = ms < 0 ? -ms : ms; + return a <= 45 ? J_SICK : a <= 90 ? J_GOOD : a <= 135 ? J_BAD : a <= 160 ? J_SHIT : J_MISS; +} + +int fnf_points(int32_t ms) { + int32_t a = ms < 0 ? -ms : ms; + return a > 160 ? -MISS_POINTS : PBOT1[a]; +} + +int32_t fnf_completion(const fnf_game_t *g) { + if (!g->total) return 0; + int32_t n = (int32_t)g->tally[J_SICK] + g->tally[J_GOOD] - g->tally[J_MISS]; + if (n <= 0) return 0; + int32_t c = (int32_t)((int64_t)n * 10000 / g->total); + return c > 10000 ? 10000 : c; +} + +int fnf_rank(const fnf_game_t *g) { + if (!g->total) return 0; + if (g->tally[J_SICK] == g->total) return 5; + /* (Scoring.tallyCompletion is a float: 0.9 is reached at 90%, so compare exactly) */ + int64_t n = (int64_t)g->tally[J_SICK] + g->tally[J_GOOD] - g->tally[J_MISS], t = g->total; + if (n >= t) return 4; + if (n * 10 >= t * 9) return 3; + if (n * 10 >= t * 8) return 2; + if (n * 10 >= t * 6) return 1; + return 0; +} + +int32_t fnf_score(const fnf_game_t *g) { return (int32_t)(g->score / 1000); } /* (Std.int: toward 0) */ + +/* ------------------------------------------------------------------------------------------------ the beat */ +static int64_t floor_div(int64_t a, int64_t b) { return a >= 0 ? a / b : -((-a + b - 1) / b); } + +static const fnf_tempo_t *tempo_at(const fnf_game_t *g, int32_t t) { + const fnf_var_t *v = &fnf_var[g->var]; + const fnf_tempo_t *tp = &fnf_tempo[v->tempo]; + for (int i = 1; i < v->ntempo; i++) + if (fnf_tempo[v->tempo + i].t <= t) tp = &fnf_tempo[v->tempo + i]; + return tp; +} + +int32_t fnf_steps(const fnf_game_t *g, int32_t t) { + const fnf_tempo_t *tp = tempo_at(g, t); + return tp->steps0 + (int32_t)floor_div((int64_t)(t - tp->t) * (1 << 24), tp->step); +} + +int32_t fnf_step_len(const fnf_game_t *g, int32_t t) { return (int32_t)(tempo_at(g, t)->step >> 8); } + +/* when step k starts */ +static int32_t step_time(const fnf_game_t *g, int32_t k) { + const fnf_var_t *v = &fnf_var[g->var]; + const fnf_tempo_t *tp = &fnf_tempo[v->tempo]; + int64_t s = (int64_t)k * 65536; + for (int i = 1; i < v->ntempo; i++) + if (fnf_tempo[v->tempo + i].steps0 <= s) tp = &fnf_tempo[v->tempo + i]; + int64_t num = (s - tp->steps0) * (int64_t)tp->step; + return tp->t + (int32_t)-floor_div(-num, (int64_t)1 << 24); /* (rounded up: the first microsecond in it) */ +} + +int32_t fnf_start_time(const fnf_game_t *g) { + /* the countdown starts 5 beats before the song (Countdown.performCountdown), after half a second of the notes + * coming in (PlayState's vwoosh) */ + return step_time(g, -20) - 500000; +} + +/* ------------------------------------------------------------------------------------------------ characters */ +static const fnf_anim_t *anim_of(const fnf_actor_t *a) { + return a->anim == 255 ? 0 : &fnf_anim[fnf_char[a->chr].anim + a->anim]; +} + +static bool finished(const fnf_actor_t *a, int32_t t) { + const fnf_anim_t *an = anim_of(a); + if (!an) return true; + if (an->flags & FNF_AF_LOOP) return false; + return (int64_t)(t - a->since) * an->fps >= (int64_t)an->len * 1000000; +} + +/* Bopper.playAnimation */ +static void play(fnf_actor_t *a, int anim, bool restart, bool exclusive, int32_t t) { + if (a->chr == 255 || anim == 255) return; + if (a->locked && finished(a, t)) a->locked = 0; + if (a->locked && !(anim == a->anim && restart)) return; + if (!restart && anim == a->anim && !finished(a, t)) return; + a->anim = (uint8_t)anim, a->since = t; + const fnf_anim_t *an = anim_of(a); + a->singing = an->flags & FNF_AF_SING ? (an->flags & FNF_AF_MISS ? 2 : 1) : 0; + if (exclusive) a->locked = 1; +} + +/* BaseCharacter.dance */ +static void dance(fnf_actor_t *a, bool force, int32_t t) { + if (a->chr == 255) return; + const fnf_char_t *c = &fnf_char[a->chr]; + if (!force) { + if (a->singing) return; + const fnf_anim_t *an = anim_of(a); + if (an && !(an->flags & FNF_AF_DANCE) && !finished(a, t)) return; + } + if (c->flags & FNF_CHAR_ALTERNATE) { + play(a, c->slot[a->danced ? FNF_SLOT_DANCE_R : FNF_SLOT_DANCE_L], false, false, t); + a->danced ^= 1; + } else { + play(a, c->slot[FNF_SLOT_DANCE_L], false, false, t); + } +} + +static int find_anim(const fnf_actor_t *a, int name) { + const fnf_char_t *c = &fnf_char[a->chr]; + for (int i = 0; i < c->count; i++) + if (fnf_anim_name[c->anim + i] == name) return i; + return 255; +} + +/* singing a note (BaseCharacter.onNoteHit, and the characters' scripts for note kinds) */ +static void sing(fnf_actor_t *a, int dir, int kind, int32_t t) { + if (a->chr == 255) return; + const fnf_char_t *c = &fnf_char[a->chr]; + int anim = c->slot[FNF_SLOT_SING + dir]; + if (kind) { + for (int i = 0; i < FNF_KINDS; i++) + if (fnf_kind[i].chr == a->chr && fnf_kind[i].kind == kind) { + int k = fnf_kind[i].anim[dir]; + if (k == 254) return; /* noanim */ + if (k != 255) { + if (!(fnf_anim[c->anim + k].flags & FNF_AF_SING)) { /* "ugh", "hehPrettyGood", a punch... */ + play(a, k, true, true, t); + return; + } + anim = k; + } + break; + } + } + play(a, anim, true, false, t); + a->hold = t; +} + +static void miss_anim(fnf_actor_t *a, int dir, int32_t t) { + if (a->chr == 255) return; + bool was = a->singing; + play(a, fnf_char[a->chr].slot[FNF_SLOT_MISS + dir], true, false, t); + if (!was) a->hold = t; /* (the hold timer only runs while singing) */ +} + +/* Girlfriend cheering a combo, or crying over a lost one (BaseCharacter.playComboAnimation, playComboDropAnimation) */ +static void gf_combo(fnf_game_t *g, unsigned combo, bool drop, int32_t t) { + for (int i = 0; i < FNF_ACTORS; i++) { + fnf_actor_t *a = &g->actor[i]; + if (a->chr == 255 || a->side != 2) continue; + int best = 255; + if (!drop) { + int name = combo == 50 ? FNF_ANIM_COMBO50 : combo == 200 ? FNF_ANIM_COMBO200 : -1; + if (name >= 0) best = find_anim(a, name); + } else if (combo >= 70) { + best = find_anim(a, FNF_ANIM_DROP70); + } + if (best != 255) play(a, best, true, true, t); + } +} + +static bool any_held(const fnf_game_t *g) { return (g->held & 15) != 0; } + +/* the hold timer: singing stops singTime steps after the last note (twice that for a miss), unless the player + * still holds a key (BaseCharacter.onUpdate) */ +static void stop_singing(fnf_game_t *g, int32_t t) { + int32_t step = fnf_step_len(g, t); + for (int i = 0; i < FNF_ACTORS; i++) { + fnf_actor_t *a = &g->actor[i]; + if (a->chr == 255 || !a->singing) continue; + int64_t limit = (int64_t)fnf_char[a->chr].sing * step * (a->singing == 2 ? 2 : 1); + if (t - a->hold <= limit) continue; + if (a->side == 0 && any_held(g)) continue; + a->singing = 0; + a->locked = 0; + dance(a, true, t); + } +} + +/* ------------------------------------------------------------------------------------------------ pop ups */ +static uint32_t rnd(fnf_game_t *g, uint32_t lo, uint32_t hi) { /* lo..hi, as FlxG.random.int */ + g->rng = g->rng * 1664525u + 1013904223u; + return lo + (g->rng >> 8) % (hi - lo + 1); +} + +static fnf_pop_t *new_pop(fnf_game_t *g) { + fnf_pop_t *p = &g->pop[g->popn]; + g->popn = (uint8_t)((g->popn + 1) % FNF_POPS); + return p; +} + +/* PopUpStuff, in screen pixels: the HUD's 1280 x 720 at a quarter across and a third down */ +static void pop_rating(fnf_game_t *g, int j, int32_t t) { + fnf_pop_t *p = new_pop(g); + p->what = (uint8_t)j, p->at = t; + p->x = (int16_t)(1280 * 474 / 1000 / 4), p->y = (int16_t)((720 * 45 / 100 - 60) / 3); + p->ay = 550 / 3; + p->vy = (int16_t)-((int32_t)rnd(g, 140, 175) / 3); + p->vx = (int16_t)-((int32_t)rnd(g, 0, 10) / 4); + p->fade = t + fnf_step_len(g, t) * 4; +} + +static void pop_combo(fnf_game_t *g, unsigned combo, int32_t t) { + int digits[6], n = 0; + unsigned c = combo; + while (c && n < 6) digits[n++] = (int)(c % 10), c /= 10; + while (n < 3) digits[n++] = 0; + for (int i = 0; i < n; i++) { + fnf_pop_t *p = new_pop(g); + p->what = (uint8_t)(10 + digits[i]), p->at = t; + p->x = (int16_t)((1280 * 507 / 1000 - 36 * (i + 1) - 65 + 47 * 45 / 100) / 4); + p->y = (int16_t)((720 * 44 / 100 + 119 * 45 / 200) / 3); + p->ay = (int16_t)(rnd(g, 250, 300) / 3); + p->vy = (int16_t)-((int32_t)rnd(g, 130, 150) / 3); + p->vx = (int16_t)(((int32_t)rnd(g, 0, 10) - 5) / 4); + p->fade = t + fnf_step_len(g, t) * 8; + } +} + +/* ------------------------------------------------------------------------------------------------ scoring */ +static void health(fnf_game_t *g, int32_t d) { + g->health += d; + if (g->health > FNF_HEALTH_MAX) g->health = FNF_HEALTH_MAX; + if (g->health <= 0) g->health = 0; +} + +/* PlayState.applyScore */ +static void apply(fnf_game_t *g, int32_t points_milli, int j, int32_t dh, bool breaks, int32_t t) { + if (j >= 0) g->tally[j]++; + health(g, dh); + if (breaks) { + if (g->combo >= 10) pop_combo(g, 0, t); + g->combo = 0; + } else { + g->combo++; + if (g->combo > g->max_combo) g->max_combo = g->combo; + } + g->score += points_milli; +} + +static void actors_on(fnf_game_t *g, int side, int dir, int kind, bool miss, int32_t t) { + for (int i = 0; i < FNF_ACTORS; i++) { + fnf_actor_t *a = &g->actor[i]; + if (a->chr == 255 || a->side != side) continue; + if (miss) miss_anim(a, dir, t); + else sing(a, dir, kind, t); + } +} + +static bool scoreable(const fnf_note_t *n) { return n->kind != FNF_KIND_NON_SCOREABLE; } + +static bool holding(const fnf_note_t *n) { return n->hold == HOLD_ON; } + +static void hit(fnf_game_t *g, fnf_note_t *n, int32_t judged) { + int32_t t = g->now; + int32_t ms = (judged - n->t) / 1000; /* (Std.int: toward 0) */ + int j = fnf_judge(ms); + int32_t dh = j == J_SICK ? H_SICK : j == J_GOOD ? H_GOOD : j == J_SHIT ? H_SHIT : 0; + bool breaks = j == J_BAD || j == J_SHIT; + unsigned gf_count = scoreable(n) ? g->combo + 1u : g->combo; + actors_on(g, 0, n->lane & 3, n->kind, false, t); + if (scoreable(n)) gf_combo(g, gf_count, breaks, t); + g->strum[n->lane] = STRUM_CONFIRM, g->strum_at[n->lane] = t; + if (j == J_SICK) g->splash_at[n->lane & 3] = t; + n->state = breaks ? NOTE_FADED : NOTE_GONE; /* (Strumline.hitNote: a combo break leaves it, faded) */ + n->hit = t; + if (n->len > 0) n->hold = HOLD_ON; + if (scoreable(n)) { + g->hits++; + apply(g, fnf_points(ms) * 1000, j, dh, breaks, t); + pop_rating(g, j, t); + if (g->combo >= 10) pop_combo(g, g->combo, t); + } +} + +static void miss(fnf_game_t *g, fnf_note_t *n, int32_t t) { + if (!scoreable(n)) { /* (NonScoreableNoteKind: hidden, no penalty) */ + n->state = NOTE_GONE; + return; + } + unsigned before = g->combo; + n->state = NOTE_MISSED; + actors_on(g, 0, n->lane & 3, 0, true, t); + gf_combo(g, before, true, t); + apply(g, -MISS_POINTS * 1000, J_MISS, H_MISS, true, t); + /* its hold goes with it: never held, its whole length counts */ + if (n->len > 0) n->hold = HOLD_MISSED; + if (n->len > HOLD_DROP_MIN) { + g->score -= (int64_t)n->len / 8; /* -125 points a second left */ + if (g->combo >= 10) pop_combo(g, 0, t); + g->combo = 0; + actors_on(g, 0, n->lane & 3, 0, true, t); + } +} + +/* a hold let go of at time t (Strumline: noticed once the note's time has passed) */ +static void drop(fnf_game_t *g, fnf_note_t *n, int32_t t) { + int32_t left = n->t + n->len - t; + n->hold = HOLD_DROPPED; + if (!scoreable(n) || left <= HOLD_DROP_MIN) return; + g->score -= (int64_t)left / 8; + unsigned before = g->combo; + if (g->combo >= 10) pop_combo(g, 0, t); + g->combo = 0; + actors_on(g, 0, n->lane & 3, 0, true, t); + gf_combo(g, before, true, t); +} + +/* ------------------------------------------------------------------------------------------------ the notes */ +static uint32_t leb(fnf_game_t *g, bool *ok) { + uint32_t v = 0; + for (int s = 0; s < 35; s += 7) { + if (g->pos >= g->end) { + *ok = false; + return 0; + } + uint8_t b = g->notes[g->pos++]; + v |= (uint32_t)(b & 127) << s; + if (!(b & 128)) return v; + } + *ok = false; + return 0; +} + +/* the next note out of the chart into a free place */ +static bool next_note(fnf_game_t *g, fnf_note_t *out) { + bool ok = true; + uint32_t dt = leb(g, &ok), info = leb(g, &ok); + uint32_t len = info & 8 ? leb(g, &ok) : 0; + if (!ok || (info >> 4) >= FNF_NOTE_KINDS || dt > 600000000u || len > 600000000u) return false; + out->t = g->next + (int32_t)dt; + out->len = (int32_t)len; + out->lane = (uint8_t)(info & 7), out->kind = (uint8_t)(info >> 4); + out->state = NOTE_WAIT, out->hold = HOLD_NONE, out->hit = 0; + return true; +} + +/* a place a note no longer needs: gone with no hold going on, or scrolled off the screen */ +static bool slot_free(const fnf_note_t *n, int32_t now) { + if (n->state == NOTE_WAIT || n->hold == HOLD_ON) return false; + if (n->state == NOTE_GONE) return n->len == 0 || n->hold != HOLD_MISSED; + return now - (n->t + n->len) > 2000000; +} + +/* notes come on when they are this far ahead: the screen's height at the slowest speed they may scroll at */ +static int32_t lookahead(const fnf_game_t *g) { + int32_t s = g->speed < g->speed_to ? g->speed : g->speed_to; + if (s < 300) s = 300; + return (int32_t)(1700000LL * 1000 / s); +} + +static void bring_notes(fnf_game_t *g) { + int32_t ahead = lookahead(g); + while (g->pos < g->end) { + /* (peek: the next note's time) */ + uint32_t save = g->pos; + bool ok = true; + uint32_t dt = leb(g, &ok); + g->pos = save; + if (!ok) return; + if ((int64_t)g->next + dt > (int64_t)g->now + ahead) return; + int free = -1; + for (int i = 0; i < FNF_ACTIVE; i++) + if (slot_free(&g->note[i], g->now)) { + free = i; + break; + } + if (free < 0) return; /* (never: 128 places, the busiest charts use 70) */ + fnf_note_t n; + if (!next_note(g, &n)) { + g->pos = g->end; + return; + } + g->next = n.t; + g->note[free] = n; + unsigned busy = 0; + for (int i = 0; i < FNF_ACTIVE; i++) busy += !slot_free(&g->note[i], g->now); + if (busy > g->peak) g->peak = (uint16_t)busy; + } +} + +/* ------------------------------------------------------------------------------------------------ events */ +static void event(fnf_game_t *g, const fnf_event_t *e) { + int32_t t = e->t; + switch (e->type) { + case FNF_EV_SETCAMERABOP: /* SetCameraBopSongEvent */ + g->bop_intensity = e->v, g->bop_rate = (uint16_t)e->d, g->bop_offset = e->b; + break; + case FNF_EV_PLAYANIMATION: + if (e->a < 3) { + fnf_actor_t *a = &g->actor[e->a]; + if (a->chr != 255) { + int k = find_anim(a, e->b); + if (k != 255) { + play(a, k, e->c, e->c, t); + } + } + } + break; + case FNF_EV_SCROLLSPEED: { + int32_t to = e->a ? e->v : (int32_t)((int64_t)fnf_chart[g->chart].speed * e->v / 1000); + int32_t dur = (int32_t)((int64_t)e->d * fnf_step_len(g, t) / 1000); + g->speed_from = g->speed, g->speed_to = to, g->speed_t0 = t, g->speed_t1 = t + dur; + g->speed_ease = e->b; + if ((e->b & 15) == 1 || dur <= 0) g->speed = to, g->speed_t1 = t; /* INSTANT */ + break; + } + case FNF_EV_SETHEALTHICON: + if (e->a < 2) g->icon[e->a] = e->b, g->icon_flip[e->a] = (uint8_t)(e->c ^ (e->a == 0)); + break; + case FNF_EV_SSERAFIMSING: { /* (sserafim.hxc: yunjin, kazuha, chaewon, eunchae, sakura, gf) */ + static const uint8_t who[6] = {3, 1, 4, 5, 0, 2}; + for (int i = 0; i < 6; i++) g->actor[who[i]].side = (uint8_t)(e->a >> i & 1 ? 0 : 1); + break; + } + case FNF_EV_SSERAFIMSHOW: { + static const uint8_t who[5] = {3, 1, 4, 5, 0}; + for (int i = 0; i < 5; i++) g->actor[who[i]].visible = (uint8_t)(e->a >> i & 1); + break; + } + case FNF_EV_SSERAFIMDARK: { + int32_t now_dark = g->dark_to; /* (where it was heading: close enough as a start) */ + g->dark_from = now_dark, g->dark_to = e->v, g->dark_t0 = t, g->dark_t1 = t + e->d * 1000; + break; + } + default: break; + } +} + +/* ------------------------------------------------------------------------------------------------ a step */ +static void step_hit(fnf_game_t *g, int32_t k, int32_t t) { + stop_singing(g, t); + /* the camera and the HUD bop (PlayState.stepHit) */ + if (g->bop_rate && (k + g->bop_offset) % g->bop_rate == 0) { + /* what's left of the HUD's last bop: lerp(1, zoom, 0.95^(dt x 60)) each frame; no bop past 1.35 */ + float left = g->bop_at ? g->bop_hud * powf(0.95f, (float)(t - g->bop_at) * 60e-6f) : 0; + if (left < 350000) { + g->bop_at = t; + g->bop_cam = g->bop_intensity * 15; /* (Constants.DEFAULT_BOP_INTENSITY - 1) x intensity */ + g->bop_hud = (int32_t)left + g->bop_intensity * 30; /* twice that, added to the HUD's zoom */ + } + } + if (k % 4 == 0) g->icon_at = t; /* HealthIcon.onStepHit: every beat */ + for (int i = 0; i < FNF_ACTORS; i++) { + fnf_actor_t *a = &g->actor[i]; + if (a->chr == 255) continue; + int every = fnf_char[a->chr].dance; + if (every && k % every == 0) dance(a, false, t); + } +} + +/* ------------------------------------------------------------------------------------------------ time passing */ +static void bonus(fnf_game_t *g, int32_t from, int32_t to) { + /* holds held: +250 points and +6% of the bar a second (Constants.SCORE_HOLD_BONUS_PER_SECOND, ...) */ + for (int i = 0; i < FNF_ACTIVE; i++) { + fnf_note_t *n = &g->note[i]; + if (!holding(n) || n->lane >= 4 || !scoreable(n)) continue; + int32_t a = from > n->hit ? from : n->hit, b = to < n->t + n->len ? to : n->t + n->len; + if (b <= a) continue; + int32_t d = b - a; + /* (the game adds these as fractions, each frame: kept whole here, the leftovers carried on) */ + int64_t sc = (int64_t)d + g->score_rest; + g->score += sc / 4; + g->score_rest = (int32_t)(sc % 4); + int64_t h = (int64_t)d * 12 + g->bonus_rest; + g->bonus_rest = (int32_t)(h % 100); + health(g, (int32_t)(h / 100)); + } +} + +/* the earliest thing to happen after the clock's now: a step, an event or a note's deadline */ +static int32_t next_thing(fnf_game_t *g) { + int32_t t = step_time(g, g->step + 1); + const fnf_var_t *v = &fnf_var[g->var]; + if (g->event < v->event + v->nevent && fnf_event[g->event].t < t) t = fnf_event[g->event].t; + for (int i = 0; i < FNF_ACTIVE; i++) { + const fnf_note_t *n = &g->note[i]; + int32_t d = INT32_MAX; + if (n->state == NOTE_WAIT) d = n->lane >= 4 ? n->t : n->t + FNF_WINDOW + g->offset + 1; + else if (holding(n)) { + d = n->t + n->len; + if (n->lane < 4 && !(g->held >> n->lane & 1) && n->t + 1 < d) d = n->t + 1 > g->now ? n->t + 1 : g->now; + } + if (d < t) t = d; + } + if (g->length < t) t = g->length; + return t; +} + +static void at(fnf_game_t *g, int32_t t) { + bonus(g, g->now, t); + g->now = t; +} + +static void settle(fnf_game_t *g) { + int32_t t = g->now; + const fnf_var_t *v = &fnf_var[g->var]; + while (g->event < v->event + v->nevent && fnf_event[g->event].t <= t) event(g, &fnf_event[g->event++]); + for (int32_t k = (int32_t)floor_div(fnf_steps(g, t), 1 << 16); g->step < k;) { + g->step++; + step_hit(g, g->step, step_time(g, g->step)); + } + for (int i = 0; i < FNF_ACTIVE; i++) { + fnf_note_t *n = &g->note[i]; + if (n->state == NOTE_WAIT) { + if (n->lane >= 4) { + if (t >= n->t) { /* the opponent hits its notes on time */ + actors_on(g, 1, n->lane & 3, n->kind, false, t); + g->strum[n->lane] = STRUM_CONFIRM, g->strum_at[n->lane] = t; + n->hit = t; + n->state = NOTE_GONE; + if (n->len > 0) n->hold = HOLD_ON; + } + } else if (t - g->offset > n->t + FNF_WINDOW) { + miss(g, n, t); + } + } else if (holding(n)) { + int32_t end = n->t + n->len; + if (n->lane >= 4) { + for (int k = 0; k < FNF_ACTORS; k++) /* the opponent sings on through its holds */ + if (g->actor[k].side == 1 && g->actor[k].singing) g->actor[k].hold = t < end ? t : end; + if (t >= end) n->hold = HOLD_DONE; + } else if (t >= end) { + n->hold = HOLD_DONE; + } else if (t > n->t && !(g->held >> n->lane & 1)) { + drop(g, n, t); + } + } + } + if (g->health <= 0 && g->over == FNF_PLAYING) g->over = FNF_DIED, g->over_at = t; + if (t >= g->length && g->over == FNF_PLAYING) g->over = FNF_WON, g->over_at = t; +} + +static void visuals(fnf_game_t *g, int32_t t) { + /* the health bar catches up: lerp(shown, health, 0.15) each frame at 60 a second */ + if (t > g->shown_at) { + float k = 1 - powf(0.85f, (float)(t - g->shown_at) * 60e-6f); + g->health_shown += (int32_t)((g->health - g->health_shown) * k); + g->shown_at = t; + } + /* the scroll speed's change */ + if (g->speed_t1 > g->speed_t0 && t < g->speed_t1) { + int64_t x = (int64_t)(t - g->speed_t0) * 1000 / (g->speed_t1 - g->speed_t0); + if (x < 0) x = 0; + g->speed = g->speed_from + (int32_t)((g->speed_to - g->speed_from) * x / 1000); /* (linear; drawn eased) */ + } else { + g->speed = g->speed_to; + } + stop_singing(g, t); + for (int i = 0; i < FNF_ACTORS; i++) + if (g->actor[i].locked && finished(&g->actor[i], t)) g->actor[i].locked = 0; + /* the opponent's arrows light up 0.15 s (after the strum's confirm), the player's go back after theirs if held */ + for (int l = 0; l < 8; l++) { + if (g->strum[l] != STRUM_CONFIRM) continue; + bool on = false; + for (int i = 0; i < FNF_ACTIVE; i++) + if (g->note[i].lane == l && holding(&g->note[i])) on = true; + if (on) continue; + int32_t limit = l >= 4 ? 150000 : 167000 + 150000; /* (confirm: 4 frames at 24 fps) */ + if (t - g->strum_at[l] >= limit) g->strum[l] = l < 4 && (g->held >> l & 1) ? STRUM_PRESS : STRUM_STATIC; + } +} + +void fnf_advance(fnf_game_t *g, int32_t t) { + if (t < g->now) return; + while (g->over == FNF_PLAYING) { + bring_notes(g); + int32_t n = next_thing(g); + if (n > t) break; + if (n < g->now) n = g->now; + at(g, n); + settle(g); + if (n == t) break; + } + if (g->over == FNF_PLAYING && g->now < t) { + at(g, t); + settle(g); + } else if (g->now < t) { + g->now = t; + } + bring_notes(g); + visuals(g, g->now); +} + +/* ------------------------------------------------------------------------------------------------ keys */ +void fnf_press(fnf_game_t *g, int lane, int32_t t) { + if (lane < 0 || lane > 3 || (g->held >> lane & 1)) return; + fnf_advance(g, t); + if (g->over != FNF_PLAYING) return; + g->held |= (uint8_t)(1 << lane); + for (int i = 0; i < FNF_ACTORS; i++) + if (g->actor[i].side == 0 && g->actor[i].singing) g->actor[i].hold = t; /* (justPressedNote) */ + int32_t judged = t - g->offset; + fnf_note_t *best = 0; + for (int i = 0; i < FNF_ACTIVE; i++) { + fnf_note_t *n = &g->note[i]; + if (n->state != NOTE_WAIT || n->lane != lane) continue; + int32_t d = judged - n->t; + if (d < -FNF_WINDOW || d > FNF_WINDOW) continue; + if (!best || n->t < best->t) best = n; + } + if (best) { + hit(g, best, judged); + } else { /* a ghost miss (no ghost tapping in the game: PlayState.ghostNoteMiss) */ + health(g, H_MISS); + g->score -= GHOST_POINTS * 1000; + actors_on(g, 0, lane, 0, true, t); + g->strum[lane] = STRUM_PRESS, g->strum_at[lane] = t; + } + settle(g); + visuals(g, g->now); +} + +void fnf_release(fnf_game_t *g, int lane, int32_t t) { + if (lane < 0 || lane > 3 || !(g->held >> lane & 1)) return; + fnf_advance(g, t); + g->held &= (uint8_t)~(1 << lane); + g->strum[lane] = STRUM_STATIC, g->strum_at[lane] = t; + if (g->over == FNF_PLAYING) settle(g); + visuals(g, g->now); +} + +/* ------------------------------------------------------------------------------------------------ the start */ +static bool check(fnf_game_t *g, const fnf_chart_t *c) { + /* every note readable, in order, as many as the chart says */ + unsigned count = 0; + int32_t t = 0; + g->pos = 0; + while (g->pos < g->end) { + fnf_note_t n; + g->next = t; + if (!next_note(g, &n)) return false; + t = n.t; + count++; + } + g->pos = 0, g->next = 0; + return count == c->notes; +} + +bool fnf_start(fnf_game_t *g, unsigned chart, uint8_t *buf, int32_t offset_ms, bool story) { + memset(g, 0, sizeof *g); + if (chart >= FNF_CHARTS) return false; + const fnf_chart_t *c = &fnf_chart[chart]; + const fnf_var_t *v = &fnf_var[c->var]; + const fnf_stage_t *st = &fnf_stage[v->stage]; + if (c->raw > FNF_CHART_RAW_MAX || + fnf_inflate(fnf_blob + c->at, c->size, buf, FNF_CHART_RAW_MAX) != (int32_t)c->raw) + return false; + g->chart = (uint16_t)chart, g->var = (uint8_t)c->var, g->style = v->style, g->story = story; + g->notes = buf, g->pos = 0, g->end = c->raw; + if (!check(g, c)) return false; + for (int i = 0; i < FNF_ACTIVE; i++) g->note[i].state = NOTE_GONE; + g->offset = offset_ms * 1000; + g->total = c->total; + g->health = g->health_shown = FNF_HEALTH_MAX / 2; + g->length = (int32_t)(v->length * 1000); + g->tempo = v->tempo, g->event = v->event; + g->speed = g->speed_from = g->speed_to = c->speed; + g->bop_intensity = 1000, g->bop_rate = 16, g->bop_offset = 0; + g->rng = 0x9E3779B9u ^ chart; + for (int i = 0; i < FNF_ACTORS; i++) g->actor[i].chr = 255, g->actor[i].anim = 255; + for (int r = 0; r < 3; r++) { + fnf_actor_t *a = &g->actor[r]; + a->chr = v->chr[r], a->side = (uint8_t)r, a->visible = 1; + int place = r == ROLE_OPPONENT && (v->flags & FNF_VF_OPPONENT_AT_GF) ? ROLE_GF : r; + a->x = st->feet[place][0], a->y = st->feet[place][1]; + } + for (int w = 0; w < 2; w++) { + unsigned c = v->chr[w]; + g->icon[w] = c != 255 ? fnf_char[c].icon : 0; + g->icon_flip[w] = (uint8_t)((c != 255 && (fnf_char[c].flags & FNF_CHAR_ICON_FLIP) ? 1 : 0) ^ (w == 0)); + } + if (v->extra) { /* LE SSERAFIM: six of them, who sings and who's seen set by the song's events */ + static const uint8_t extra[3] = {FNF_CHR_SSERAFIM_YUNJIN, FNF_CHR_SSERAFIM_CHAEWON, FNF_CHR_SSERAFIM_EUNCHAE}; + for (int i = 0; i < 3; i++) { + fnf_actor_t *a = &g->actor[3 + i]; + a->chr = extra[i], a->side = 1, a->x = st->extra[i][0], a->y = st->extra[i][1]; + } + /* baseVisible: yunjin only (Girlfriend's stays); baseSinging: none sing yours */ + g->actor[3].visible = 1, g->actor[1].visible = 0, g->actor[0].visible = 0, g->actor[2].visible = 1; + for (int i = 0; i < FNF_ACTORS; i++) g->actor[i].side = 1; + g->icon_hidden = 1; + } + g->now = g->shown_at = fnf_start_time(g); + g->step = (int32_t)floor_div(fnf_steps(g, g->now), 1 << 16); + g->next = 0; + for (int i = 0; i < FNF_ACTORS; i++) dance(&g->actor[i], true, g->now); + bring_notes(g); + return true; +} + +/* ------------------------------------------------------------------------------------------------ for the screen */ +unsigned fnf_actor_sprite(const fnf_game_t *g, int i) { + const fnf_actor_t *a = &g->actor[i]; + const fnf_anim_t *an = anim_of(a); + if (!an) return FNF_NONE; + int64_t f = (int64_t)(g->now - a->since) * an->fps / 1000000; + if (f < 0) f = 0; + if (an->flags & FNF_AF_LOOP) f %= an->len ? an->len : 1; + int k = 0; + for (int j = 1; j < an->n; j++) + if (an->at[j] <= f) k = j; + return an->sprite + (unsigned)k; +} + +int fnf_countdown(const fnf_game_t *g, int32_t *age) { + int32_t beat = (int32_t)floor_div(fnf_steps(g, g->now), 1 << 18); + if (beat < -3 || beat > -1) return 0; + *age = g->now - step_time(g, beat * 4); + return beat + 4; +} diff --git a/games/fnf/src/game.h b/games/fnf/src/game.h new file mode 100644 index 00000000..22ca972e --- /dev/null +++ b/games/fnf/src/game.h @@ -0,0 +1,118 @@ +/* A song being played: the chart's notes against your presses, scored as the game scores them (PBOT1: the + * points follow how close each press is, the judgement is Sick, Good, Bad or Shit), the health, the combo, the + * holds, the misses and the characters singing and dancing to the beat. Times are microseconds of the song (0: its + * first beat; the countdown is before). Everything is whole numbers, so a song plays the same everywhere. */ +#ifndef FNF_GAME_H +#define FNF_GAME_H +#include +#include "data.h" + +#define FNF_ACTIVE 128 /* notes on their way at once */ +#define FNF_WINDOW 160000 /* the hit window, each side (us) */ +#define FNF_HEALTH_MAX 2000000 /* health, in millionths */ +#define FNF_ACTORS 6 +#define FNF_POPS 12 + +enum { J_SICK, J_GOOD, J_BAD, J_SHIT, J_MISS }; +/* a note's head: on its way, gone (hit, or the place is free), hit late or early (Bad, Shit: it scrolls on, faded), + * missed (it scrolls on); its hold: none (or not reached yet), held, done, let go of (hidden), missed (scrolls on) */ +enum { NOTE_WAIT, NOTE_GONE, NOTE_FADED, NOTE_MISSED }; +enum { HOLD_NONE, HOLD_ON, HOLD_DONE, HOLD_DROPPED, HOLD_MISSED }; +enum { FNF_PLAYING, FNF_WON, FNF_DIED }; +enum { ROLE_PLAYER, ROLE_OPPONENT, ROLE_GF }; +enum { STRUM_STATIC, STRUM_PRESS, STRUM_CONFIRM }; + +typedef struct { + int32_t t, len; /* when, and a hold's length */ + int32_t hit; /* when it was hit */ + uint8_t lane, kind, state, hold; +} fnf_note_t; + +typedef struct { + uint8_t chr; /* fnf_char, 255: nobody */ + uint8_t side; /* sings the player's notes (0), the opponent's (1), or neither (2) */ + uint8_t visible, danced; /* danced: the last dance was danceLeft (next: danceRight) */ + uint8_t anim; /* its own animation number, 255: none yet */ + uint8_t locked; /* a special animation nothing else may cut until it ends */ + uint8_t singing; /* 1: a sing animation, 2: a miss one */ + int32_t since; /* when the animation started */ + int32_t hold; /* the hold timer's start (it sings on until singTime after it) */ + int16_t x, y; /* its feet on the screen */ +} fnf_actor_t; + +typedef struct { + uint8_t what; /* 0-3 a judgement, 10-19 a digit, 255 none */ + int32_t at; /* when it popped */ + int16_t x, y, vx, vy, ay; /* pixels, pixels a second, pixels a second squared */ + int32_t fade; /* when it starts fading (0.2 s) */ +} fnf_pop_t; + +typedef struct { + /* what's played */ + uint16_t chart; + uint8_t var, style; + const uint8_t *notes; /* the chart, unpacked */ + uint32_t pos, end; + int32_t next; /* the next note's time, not on its way yet (INT32_MAX: none left) */ + fnf_note_t note[FNF_ACTIVE]; + uint16_t active, peak; + /* time */ + int32_t now, length; + int32_t step; /* the last step reached (whole steps, can be negative) */ + uint16_t tempo, event; + int32_t offset; /* the note offset: presses count this much earlier (us) */ + /* the score */ + int64_t score; /* thousandths of a point */ + int32_t health; + int32_t bonus_rest, score_rest; /* (the hold bonus's leftover fractions) */ + uint16_t combo, max_combo, tally[5], hits, total; + uint8_t held; /* the lanes held down */ + uint8_t over; /* FNF_PLAYING, FNF_WON, FNF_DIED */ + int32_t over_at; + /* what's seen */ + fnf_actor_t actor[FNF_ACTORS]; + int32_t bop_at; /* the last camera bop */ + int32_t bop_cam, bop_hud; /* its strength: the camera's zoom - 1 and the HUD's, millionths */ + int32_t bop_intensity; /* SetCameraBop's, thousandths */ + uint16_t bop_rate; /* a bop every bop_rate steps (0: never), from bop_offset */ + uint8_t bop_offset; + int32_t icon_at; /* the icons' last bop */ + uint8_t icon[2]; /* the player's and the opponent's health icons */ + uint8_t icon_flip[2]; /* (facing the other way: the player's faces left, HealthIcon) */ + uint8_t icon_hidden; /* (LE SSERAFIM hides the opponent's) */ + int32_t health_shown; /* the health bar, catching up with the health */ + int32_t shown_at; + fnf_pop_t pop[FNF_POPS]; + uint8_t popn; + uint32_t rng; + int32_t splash_at[4]; + uint8_t strum[8]; /* STRUM_..., each lane */ + int32_t strum_at[8]; + int32_t speed, speed_from, speed_to, speed_t0, speed_t1; /* scroll speed x 1000, and its change */ + uint8_t speed_ease; + int32_t dark_from, dark_to, dark_t0, dark_t1; /* LE SSERAFIM's dimming, thousandths */ + uint8_t story; /* (no arrows fading in, in story mode) */ +} fnf_game_t; + +/* The chart's notes are unpacked into buf (FNF_CHART_RAW_MAX bytes). False if the chart is damaged. */ +bool fnf_start(fnf_game_t *g, unsigned chart, uint8_t *buf, int32_t offset_ms, bool story); +void fnf_advance(fnf_game_t *g, int32_t t); /* to time t (never back) */ +void fnf_press(fnf_game_t *g, int lane, int32_t t); /* a lane's key down at time t */ +void fnf_release(fnf_game_t *g, int lane, int32_t t); +int32_t fnf_start_time(const fnf_game_t *g); /* the countdown's start */ +/* the song's beat at time t (16.16 steps: 4 a beat), and a step's length (us) */ +int32_t fnf_steps(const fnf_game_t *g, int32_t t); +int32_t fnf_step_len(const fnf_game_t *g, int32_t t); + +/* the rules, on their own */ +int fnf_judge(int32_t ms); /* J_... for a press ms off (whole ms, signed) */ +int fnf_points(int32_t ms); +int32_t fnf_completion(const fnf_game_t *g); /* (sick + good - missed) / total, ten-thousandths, 0-10000 */ +int fnf_rank(const fnf_game_t *g); /* 0 LOSS, 1 GOOD, 2 GREAT, 3 EXCELLENT, 4 PERFECT, 5 PERFECT GOLD */ +int32_t fnf_score(const fnf_game_t *g); /* whole points, as the game shows and keeps them */ + +/* a character's frame now: its sprite (FNF_NONE: none) */ +unsigned fnf_actor_sprite(const fnf_game_t *g, int i); +/* the combo's sound-free cues: the countdown picture showing (0 none, 1 ready, 2 set, 3 go) and its age (us) */ +int fnf_countdown(const fnf_game_t *g, int32_t *age); +#endif diff --git a/games/fnf/src/icon.png b/games/fnf/src/icon.png new file mode 100644 index 00000000..efd1ecf0 Binary files /dev/null and b/games/fnf/src/icon.png differ diff --git a/games/fnf/src/inflate.c b/games/fnf/src/inflate.c new file mode 100644 index 00000000..fa858929 --- /dev/null +++ b/games/fnf/src/inflate.c @@ -0,0 +1,170 @@ +/* Raw DEFLATE (RFC 1951) into a buffer, for the pictures and the charts. Huffman codes up to 9 bits long are read + * through a table of the next 9 bits, longer ones bit by bit; a damaged stream is refused, never overrun. */ +#include +#include "inflate.h" + +#define FAST 9 + +typedef struct { + uint16_t count[16], symbol[288]; + uint16_t fast[1 << FAST]; /* length << 9 | symbol, 0: longer than FAST bits */ +} huff_t; + +typedef struct { + const uint8_t *in, *end; + uint32_t bits; + int nbits, pad; /* pad: zero bytes read past the end (a look ahead may; using them is a damaged stream) */ + int bad; +} reader_t; + +static void need(reader_t *r, int n) { + while (r->nbits < n) { + uint32_t b = 0; + if (r->in < r->end) b = *r->in++; + else if (++r->pad > 4) r->bad = 1; + r->bits |= b << r->nbits; + r->nbits += 8; + } +} + +static uint32_t take(reader_t *r, int n) { + if (!n) return 0; + need(r, n); + uint32_t v = r->bits & ((1u << n) - 1); + r->bits >>= n, r->nbits -= n; + return v; +} + +static int build(huff_t *h, const uint8_t *len, int n) { + uint16_t offs[16]; + memset(h->count, 0, sizeof h->count); + memset(h->fast, 0, sizeof h->fast); + for (int i = 0; i < n; i++) h->count[len[i]]++; + h->count[0] = 0; + int left = 1; + for (int l = 1; l < 16; l++) { + left = (left << 1) - h->count[l]; + if (left < 0) return 0; + } + offs[1] = 0; + for (int l = 1; l < 15; l++) offs[l + 1] = (uint16_t)(offs[l] + h->count[l]); + for (int i = 0; i < n; i++) + if (len[i]) h->symbol[offs[len[i]]++] = (uint16_t)i; + /* the table: the canonical codes, bit-reversed (the stream gives a code's first bit first) */ + int code = 0, k = 0; + for (int l = 1; l <= FAST; l++) { + for (int c = 0; c < h->count[l]; c++, k++, code++) { + int rev = 0; + for (int b = 0; b < l; b++) rev |= ((code >> b) & 1) << (l - 1 - b); + for (int i = rev; i < (1 << FAST); i += 1 << l) h->fast[i] = (uint16_t)(l << 9 | h->symbol[k]); + } + code <<= 1; + } + return 1; +} + +static int decode(reader_t *r, const huff_t *h) { + need(r, FAST); + uint16_t f = h->fast[r->bits & ((1 << FAST) - 1)]; + if (f) { + int l = f >> 9; + r->bits >>= l, r->nbits -= l; + return f & 511; + } + int code = 0, first = 0, index = 0; + for (int l = 1; l < 16; l++) { + code |= (int)take(r, 1); + int count = h->count[l]; + if (code - count < first) return h->symbol[index + (code - first)]; + index += count; + first = (first + count) << 1; + code <<= 1; + } + r->bad = 1; + return -1; +} + +static const uint16_t LEN_BASE[29] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, + 115, 131, 163, 195, 227, 258}; +static const uint8_t LEN_EXTRA[29] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0}; +static const uint16_t DIST_BASE[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, + 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577}; +static const uint8_t DIST_EXTRA[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, + 12, 12, 13, 13}; + +static huff_t lit, dist; /* (static: 2.6 KB off the stack) */ + +int32_t fnf_inflate(const uint8_t *in, uint32_t n, uint8_t *out, uint32_t cap) { + static const uint8_t ORDER[19] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15}; + reader_t r = {in, in + n, 0, 0, 0, 0}; + uint32_t o = 0; + int last; + do { + last = (int)take(&r, 1); + int type = (int)take(&r, 2); + if (type == 0) { /* stored */ + r.bits >>= r.nbits & 7, r.nbits -= r.nbits & 7; + uint32_t len = take(&r, 16), nlen = take(&r, 16); + if ((len ^ 0xFFFF) != nlen || o + len > cap) return -1; + for (uint32_t i = 0; i < len; i++) out[o++] = (uint8_t)take(&r, 8); + } else if (type == 1 || type == 2) { + uint8_t len[320]; + int nlit = 288, ndist = 30; + if (type == 1) { + for (int i = 0; i < 288; i++) len[i] = i < 144 ? 8 : i < 256 ? 9 : i < 280 ? 7 : 8; + for (int i = 0; i < 30; i++) len[288 + i] = 5; + } else { + nlit = (int)take(&r, 5) + 257, ndist = (int)take(&r, 5) + 1; + int ncode = (int)take(&r, 4) + 4; + if (nlit > 286 || ndist > 30) return -1; + memset(len, 0, 19); + for (int i = 0; i < ncode; i++) len[ORDER[i]] = (uint8_t)take(&r, 3); + if (!build(&lit, len, 19)) return -1; + for (int i = 0; i < nlit + ndist;) { + int s = decode(&r, &lit); + if (s < 0 || r.bad) return -1; + if (s < 16) { + len[i++] = (uint8_t)s; + continue; + } + int rep, v = 0; + if (s == 16) { + if (!i) return -1; + v = len[i - 1], rep = 3 + (int)take(&r, 2); + } else if (s == 17) { + rep = 3 + (int)take(&r, 3); + } else { + rep = 11 + (int)take(&r, 7); + } + if (i + rep > nlit + ndist) return -1; + while (rep--) len[i++] = (uint8_t)v; + } + if (!len[256]) return -1; + } + if (!build(&lit, len, nlit) || !build(&dist, len + nlit, ndist)) return -1; + for (;;) { + int s = decode(&r, &lit); + if (s < 0 || r.bad) return -1; + if (s < 256) { + if (o >= cap) return -1; + out[o++] = (uint8_t)s; + } else if (s == 256) { + break; + } else { + s -= 257; + if (s >= 29) return -1; + uint32_t l = LEN_BASE[s] + take(&r, LEN_EXTRA[s]); + int d = decode(&r, &dist); + if (d < 0 || d >= 30) return -1; + uint32_t back = DIST_BASE[d] + take(&r, DIST_EXTRA[d]); + if (r.bad || back > o || o + l > cap) return -1; + for (uint32_t i = 0; i < l; i++, o++) out[o] = out[o - back]; + } + } + } else { + return -1; + } + if (r.bad || r.pad * 8 > r.nbits) return -1; + } while (!last); + return (int32_t)o; +} diff --git a/games/fnf/src/inflate.h b/games/fnf/src/inflate.h new file mode 100644 index 00000000..1c316db7 --- /dev/null +++ b/games/fnf/src/inflate.h @@ -0,0 +1,7 @@ +#ifndef FNF_INFLATE_H +#define FNF_INFLATE_H +#include + +/* Unpacks n bytes of raw DEFLATE into out (cap bytes at most); returns the size unpacked, or -1 if damaged. */ +int32_t fnf_inflate(const uint8_t *in, uint32_t n, uint8_t *out, uint32_t cap); +#endif diff --git a/games/fnf/src/main.c b/games/fnf/src/main.c new file mode 100644 index 00000000..24925e77 --- /dev/null +++ b/games/fnf/src/main.c @@ -0,0 +1,388 @@ +/* Friday Night Funkin' on the NumWorks calculator: the songs of the game's weeks against their singers, as the + * game plays them (game.c), silent (the calculator has no speaker): the notes, the characters and the camera keep + * the beat. The screens: screens.c and draw.c. + * + * Keys: Left, Down, Up and Right (or 7, 8, ( and )) hit the notes; Back pauses; Home saves and quits. */ +#include +#include "app.h" +#include "draw.h" +#include "plat.h" + +/* (seen by tools/emu.py: the frames drawn in songs, the slowest (ms), those over 33 ms, the longest time between + * two readings of the keys in a song (ms); and the song, its clock and the app's screen, for tests that play it) */ +#ifndef HOST +#define SEEN __attribute__((used, externally_visible)) +#else +#define SEEN +#endif +SEEN uint32_t perf_frames, perf_max, perf_slow, perf_gap; +SEEN fnf_game_t G; +SEEN app_t A; +SEEN uint32_t origin; /* the song's clock: plat_millis() at its start */ +SEEN int32_t start_us; +static fnf_progress_t P; +static uint8_t *chart_buf; /* the chart, unpacked (on main's stack) */ +static uint32_t pause_at; +static bool saved; + +/* ------------------------------------------------------------------------------------------------ the keys */ +typedef struct { + uint32_t t; + uint8_t lane, down; +} key_event_t; +static key_event_t queue[64]; +static unsigned q_head, q_len; +static uint8_t lanes, stale_lanes; +static uint32_t keys_now, last_poll; + +static uint8_t lanes_of(uint32_t k) { + /* both keys of a lane count as one: holding the other keeps a hold going */ + return (uint8_t)((k & (FK_LEFT | FK_7) ? 1 : 0) | (k & (FK_DOWN | FK_8) ? 2 : 0) | (k & (FK_UP | FK_LPAR) ? 4 : 0) | + (k & (FK_RIGHT | FK_RPAR) ? 8 : 0)); +} + +/* read the keys and time-stamp the lanes' changes (also between the strips of a frame being drawn) */ +static void poll(void) { + uint32_t t = plat_millis(); + if (last_poll && t - last_poll > perf_gap && A.view == V_PLAY) perf_gap = t - last_poll; + last_poll = t; + keys_now = plat_keys(); + uint8_t l = lanes_of(keys_now); + stale_lanes &= l; /* a key held from before counts once let go of */ + l &= (uint8_t)~stale_lanes; + if (A.view == V_PLAY) { + uint8_t changed = l ^ lanes; + for (int i = 0; i < 4; i++) + if (changed >> i & 1 && q_len < 64) { + queue[(q_head + q_len) % 64] = (key_event_t){t, (uint8_t)i, (uint8_t)(l >> i & 1)}; + q_len++; + } + } + lanes = l; +} +extern void (*draw_between)(void); + +static int32_t song_time(uint32_t ms) { return (int32_t)(ms - origin) * 1000 + start_us; } + +/* ------------------------------------------------------------------------------------------------ saving */ +static void save(void) { + save_write(&P); + saved = true; +} + +/* ------------------------------------------------------------------------------------------------ songs */ +static void go(int view) { + A.view = (uint8_t)view; + A.menu_at = A.clock; +} + +static void menu_back(void) { + go(A.story ? V_STORY : V_FREEPLAY); + A.story = 0; +} + +static unsigned current_chart(void) { + if (A.story) return (unsigned)app_story_chart(fnf_level[P.week].song[A.story_song], P.week_diff); + return (unsigned)app_chart(A.list[P.song < A.list_n ? P.song : 0], P.list, P.song_diff); +} + +static void start_song(bool fresh) { + unsigned chart = current_chart(); + if (chart >= FNF_CHARTS || !fnf_start(&G, chart, chart_buf, P.offset, A.story) || !play_load(&G)) { + go(V_ERROR); + return; + } + origin = last_poll = plat_millis(); /* (the time unpacking took isn't a gap in the keys' reading) */ + if (fresh) A.play_t0 = origin; + else A.play_t0 = origin - 5000; /* (a restart: the arrows are there already) */ + start_us = G.now; + q_len = 0; + stale_lanes = lanes_of(keys_now); + lanes = 0; + go(V_PLAY); +} + +/* the guide the first time, then the song */ +static void begin(void) { + if (!P.guide) { + P.guide = 1; + A.first_run = 1, A.page = 0, A.under = V_PLAY; + go(V_HELP); + return; + } + start_song(true); +} + +static void results(const fnf_game_t *g, bool week) { + if (week) { + fnf_game_t sum; + memset(&sum, 0, sizeof sum); + memcpy(sum.tally, A.week_tally, sizeof sum.tally); + sum.total = A.week_total; + A.result_rank = (uint8_t)fnf_rank(&sum); + A.result_completion = fnf_completion(&sum); + memcpy(A.result_tally, A.week_tally, sizeof A.result_tally); + A.result_total = A.week_total, A.result_max_combo = A.week_max_combo; + A.result_score = A.campaign; + } else { + A.result_rank = (uint8_t)fnf_rank(g); + A.result_completion = fnf_completion(g); + memcpy(A.result_tally, g->tally, sizeof A.result_tally); + A.result_total = g->total, A.result_max_combo = g->max_combo; + A.result_score = fnf_score(g); + } + go(V_RESULTS); +} + +static void song_won(void) { + int32_t score = fnf_score(&G); + int32_t *best = A.story ? &P.week_score[P.week][P.week_diff] : &P.chart[G.chart].score; + int32_t before = *best; + bool had = A.story ? true : P.chart[G.chart].rank != 0; + fnf_record(&P, G.chart, score, fnf_rank(&G), fnf_completion(&G)); + if (A.story) { + A.campaign += score; + for (int j = 0; j < 5; j++) A.week_tally[j] = (uint16_t)(A.week_tally[j] + G.tally[j]); + A.week_total = (uint16_t)(A.week_total + G.total); + if (G.max_combo > A.week_max_combo) A.week_max_combo = G.max_combo; + if (A.story_song + 1 < fnf_level[P.week].n) { + A.story_song++; + save(); + start_song(true); + return; + } + A.result_new = A.campaign > before || !before; + if (A.campaign > *best || !*best) *best = A.campaign; + save(); + results(&G, true); + return; + } + A.result_new = !had || score > before; + save(); + results(&G, false); +} + +static void resume(void) { + uint32_t now = last_poll = plat_millis(); + origin += now - pause_at; + /* keys let go of during the pause: let go of now; keys pressed during it: not until let go of */ + uint8_t l = lanes_of(keys_now); + int32_t t = song_time(now); + for (int i = 0; i < 4; i++) + if ((G.held >> i & 1) && !(l >> i & 1)) fnf_release(&G, i, t); + stale_lanes = (uint8_t)(l & ~G.held); + lanes = (uint8_t)(l & G.held); + q_len = 0; + G.offset = P.offset * 1000; + go(V_PLAY); +} + +/* ------------------------------------------------------------------------------------------------ menus */ +static int wrap(int v, int n) { return ((v % n) + n) % n; } + +static void story_diff_fix(void) { + unsigned diffs = app_story_diffs(P.week); + for (int i = 0; i < FNF_DIFFS && !(diffs >> P.week_diff & 1); i++) P.week_diff = (uint8_t)((P.week_diff + 1) % FNF_DIFFS); +} + +static void song_diff_fix(void) { + if (!A.list_n) return; + if (P.song >= A.list_n) P.song = 0; + for (int i = 0; i < FNF_DIFFS && app_chart(A.list[P.song], P.list, P.song_diff) < 0; i++) + P.song_diff = (uint8_t)((P.song_diff + 1) % FNF_DIFFS); +} + +static void play_keys(uint32_t hit) { + if (hit & FK_BACK) { + pause_at = plat_millis(); + A.sel = PAUSE_RESUME; + go(V_PAUSE); + } +} + +int main(void) { + /* (the big buffers on the stack: the app's RAM stays under what custom builds of Epsilon give it) */ + uint8_t chart[FNF_CHART_RAW_MAX]; + color_t strip[SCREEN_W * STRIP_H]; + if (!chart_buf) chart_buf = chart; + draw_strip_buffer(strip); + plat_begin(); + memset(&A, 0, sizeof A); + A.p = &P, A.g = &G; + save_load(&P); + bold_load(); + draw_between = poll; + keys_now = plat_keys(); + uint32_t stale = keys_now, prev = stale, rep_at = 0; + uint8_t shown = 0xFF; + go(V_TITLE); + app_build_list(&A); + bool quit = false; + while (!quit) { + poll(); + uint32_t now = plat_millis(); + A.clock = now; + uint32_t keys = keys_now; + stale &= keys; /* keys still down from the screen before don't count */ + uint32_t live = keys & ~stale, hit = live & ~prev; + /* arrows held: again after 350 ms, then every 110 ms (not in a song) */ + uint32_t arrows = live & (FK_LEFT | FK_RIGHT | FK_UP | FK_DOWN); + if (A.view != V_PLAY) { + if (hit & arrows) rep_at = now + 350; + else if (arrows && (int32_t)(now - rep_at) >= 0) hit |= arrows, rep_at = now + 110; + } + prev = live; + if (keys & FK_HOME) break; /* Home: saved below, then out */ + int up = hit & FK_UP ? -1 : 0, down = hit & FK_DOWN ? 1 : 0, ud = up + down; + int lr = (hit & FK_RIGHT ? 1 : 0) - (hit & FK_LEFT ? 1 : 0); + switch (A.view) { + case V_TITLE: + if (hit & FK_OK) A.sel = MAIN_STORY, go(V_MAIN); + else if (hit & FK_BACK) quit = true; + break; + case V_MAIN: + A.sel = (uint8_t)wrap(A.sel + ud, MAIN_COUNT); + if (hit & FK_BACK) go(V_TITLE); + else if (hit & FK_OK) switch (A.sel) { + case MAIN_STORY: story_diff_fix(), go(V_STORY); break; + case MAIN_FREEPLAY: app_build_list(&A), song_diff_fix(), go(V_FREEPLAY); break; + case MAIN_OPTIONS: A.under = V_MAIN, A.sel = 0, go(V_OPTIONS); break; + case MAIN_HELP: A.under = V_MAIN, A.page = 0, A.first_run = 0, go(V_HELP); break; + default: go(V_CREDITS); break; + } + break; + case V_STORY: + if (ud) P.week = (uint8_t)wrap(P.week + ud, FNF_LEVELS), story_diff_fix(); + if (lr) { + unsigned diffs = app_story_diffs(P.week); + for (int i = 0; i < FNF_DIFFS; i++) { + P.week_diff = (uint8_t)wrap(P.week_diff + lr, FNF_DIFFS); + if (diffs >> P.week_diff & 1) break; + } + } + if (hit & FK_BACK) A.sel = MAIN_STORY, go(V_MAIN); + else if (hit & FK_OK) { + A.story = 1, A.story_song = 0, A.campaign = 0; + memset(A.week_tally, 0, sizeof A.week_tally); + A.week_total = A.week_max_combo = 0; + begin(); + } + break; + case V_FREEPLAY: + if (ud && A.list_n) P.song = (uint8_t)wrap(P.song + ud, A.list_n), song_diff_fix(); + if (lr && A.list_n) + for (int i = 0; i < FNF_DIFFS; i++) { + P.song_diff = (uint8_t)wrap(P.song_diff + lr, FNF_DIFFS); + if (app_chart(A.list[P.song], P.list, P.song_diff) >= 0) break; + } + if (hit & FK_SHIFT) { /* Boyfriend's songs, or Pico's */ + unsigned song = A.list_n ? A.list[P.song] : 0; + P.list ^= 1; + app_build_list(&A); + P.song = 0; + for (int i = 0; i < A.list_n; i++) + if (A.list[i] == song) P.song = (uint8_t)i; + song_diff_fix(); + } + if (hit & FK_BACK) A.sel = MAIN_FREEPLAY, go(V_MAIN); + else if ((hit & FK_OK) && A.list_n) A.story = 0, begin(); + break; + case V_OPTIONS: + A.sel = (uint8_t)wrap(A.sel + ud, OPT_COUNT); + if (A.sel == OPT_DOWNSCROLL && (lr || (hit & FK_OK))) P.downscroll ^= 1; + if (A.sel == OPT_OFFSET && lr) { + int o = P.offset + lr; + P.offset = (int16_t)(o < -FNF_OFFSET_MAX ? -FNF_OFFSET_MAX : o > FNF_OFFSET_MAX ? FNF_OFFSET_MAX : o); + } + if (hit & FK_BACK) { + save(); + A.sel = A.under == V_PAUSE ? PAUSE_OPTIONS : MAIN_OPTIONS; + go(A.under); + } + break; + case V_HELP: + if (A.first_run && (hit & FK_BACK)) { + A.first_run = 0; + save(); + start_song(true); + } else if ((lr > 0 || (A.first_run && (hit & FK_OK))) && A.page + 1 < HELP_PAGES) { + A.page++; + } else if (lr < 0 && A.page) { + A.page--; + } else if (A.first_run && (hit & FK_OK)) { + A.first_run = 0; + save(); + start_song(true); + } else if (hit & (FK_BACK | FK_OK)) { + A.sel = A.under == V_PAUSE ? PAUSE_HELP : MAIN_HELP; + go(A.under); + } + break; + case V_CREDITS: + if (hit & (FK_BACK | FK_OK)) A.sel = MAIN_CREDITS, go(V_MAIN); + break; + case V_PLAY: { + while (q_len) { + key_event_t e = queue[q_head]; + q_head = (q_head + 1) % 64, q_len--; + int32_t t = song_time(e.t); + if (t < G.now) t = G.now; + if (e.down) fnf_press(&G, e.lane, t); + else fnf_release(&G, e.lane, t); + } + fnf_advance(&G, song_time(plat_millis())); + if (G.over == FNF_WON) song_won(); + else if (G.over == FNF_DIED) A.sel = 0, go(V_GAMEOVER); + else play_keys(hit); + break; + } + case V_PAUSE: + A.sel = (uint8_t)wrap(A.sel + ud, PAUSE_COUNT); + if (hit & FK_BACK) resume(); + else if (hit & FK_OK) switch (A.sel) { + case PAUSE_RESUME: resume(); break; + case PAUSE_RESTART: start_song(false); break; + case PAUSE_EXIT: menu_back(); break; + case PAUSE_OPTIONS: A.under = V_PAUSE, A.sel = 0, go(V_OPTIONS); break; + case PAUSE_HELP: A.under = V_PAUSE, A.page = 0, A.first_run = 0, go(V_HELP); break; + default: A.sel = 0, go(V_QUIT); break; + } + break; + case V_QUIT: + if (hit & (FK_LEFT | FK_RIGHT | FK_UP | FK_DOWN)) A.sel ^= 1; + if (hit & FK_BACK) A.sel = PAUSE_QUIT, go(V_PAUSE); + else if (hit & FK_OK) { + if (A.sel) quit = true; + else A.sel = PAUSE_QUIT, go(V_PAUSE); + } + break; + case V_RESULTS: + if (hit & (FK_OK | FK_BACK)) menu_back(); + break; + case V_GAMEOVER: + if (hit & FK_OK) start_song(false); + else if (hit & FK_BACK) menu_back(); + break; + default: + if (hit & (FK_OK | FK_BACK)) A.story = 0, go(V_MAIN); + break; + } + if (A.view != shown) { + stale = keys, prev = 0; /* a new screen starts with all keys up */ + shown = A.view; + } + uint32_t t0 = plat_millis(); + screens_draw(&A); + uint32_t spent = plat_millis() - t0; + if (A.view == V_PLAY) { + perf_frames++; + if (spent > perf_max) perf_max = spent; + if (spent > 33) perf_slow++; + } else { + plat_sleep(8); + } + } + save(); + (void)saved; + return plat_end(); +} diff --git a/games/fnf/src/plat.h b/games/fnf/src/plat.h new file mode 100644 index 00000000..6661a86a --- /dev/null +++ b/games/fnf/src/plat.h @@ -0,0 +1,18 @@ +/* What the game needs from the calculator (plat_eadk.c), or from a computer for the tests. */ +#ifndef FNF_PLAT_H +#define FNF_PLAT_H +#include +#include + +/* the four lanes: the arrows, or 7 8 ( ) */ +enum { FK_LEFT = 1, FK_DOWN = 2, FK_UP = 4, FK_RIGHT = 8, FK_7 = 16, FK_8 = 32, FK_LPAR = 64, FK_RPAR = 128, + FK_OK = 256, FK_BACK = 512, FK_HOME = 1024, FK_SHIFT = 2048 }; +uint32_t plat_keys(void); +uint32_t plat_millis(void); +void plat_sleep(uint32_t ms); +void plat_push(int x, int y, int w, int h, const uint16_t *px); +bool plat_save(const char *name, const void *data, uint32_t len); +const uint8_t *plat_load(const char *name, uint32_t *len); +void plat_begin(void); +int plat_end(void); +#endif diff --git a/games/fnf/src/plat_eadk.c b/games/fnf/src/plat_eadk.c new file mode 100644 index 00000000..7c4cadc9 --- /dev/null +++ b/games/fnf/src/plat_eadk.c @@ -0,0 +1,37 @@ +/* The calculator: keys, time, the screen and files (EADK, and NumPlay's careful file helpers). */ +#include +#include "../../common/epsilon_app.h" +#include "../../common/epsilon_files.h" +#include "plat.h" + +const char eadk_app_name[] __attribute__((section(".rodata.eadk_app_name"))) = "Friday Night Funkin'"; +const uint32_t eadk_api_level __attribute__((section(".rodata.eadk_api_level"))) = 0; + +#define KEY(k) ((uint64_t)1 << (k)) + +uint32_t plat_keys(void) { + uint64_t k = eadk_keyboard_scan(); + uint32_t r = 0; + if (k & KEY(eadk_key_left)) r |= FK_LEFT; + if (k & KEY(eadk_key_down)) r |= FK_DOWN; + if (k & KEY(eadk_key_up)) r |= FK_UP; + if (k & KEY(eadk_key_right)) r |= FK_RIGHT; + if (k & KEY(eadk_key_seven)) r |= FK_7; + if (k & KEY(eadk_key_eight)) r |= FK_8; + if (k & KEY(eadk_key_left_parenthesis)) r |= FK_LPAR; + if (k & KEY(eadk_key_right_parenthesis)) r |= FK_RPAR; + if (k & (KEY(eadk_key_ok) | KEY(eadk_key_exe))) r |= FK_OK; + if (k & (KEY(eadk_key_back) | KEY(eadk_key_backspace))) r |= FK_BACK; + if (k & (KEY(eadk_key_home) | KEY(eadk_key_on_off))) r |= FK_HOME; + if (k & KEY(eadk_key_shift)) r |= FK_SHIFT; + return r; +} +uint32_t plat_millis(void) { return (uint32_t)eadk_timing_millis(); } +void plat_sleep(uint32_t ms) { eadk_timing_msleep(ms); } +void plat_push(int x, int y, int w, int h, const uint16_t *px) { + eadk_display_push_rect((eadk_rect_t){(uint16_t)x, (uint16_t)y, (uint16_t)w, (uint16_t)h}, (const eadk_color_t *)px); +} +bool plat_save(const char *name, const void *data, uint32_t len) { return ef_write(name, data, len); } +const uint8_t *plat_load(const char *name, uint32_t *len) { return ef_read(name, len); } +void plat_begin(void) { np_app_begin(); } +int plat_end(void) { return np_app_end(); } diff --git a/games/fnf/src/save.c b/games/fnf/src/save.c new file mode 100644 index 00000000..aa95db02 --- /dev/null +++ b/games/fnf/src/save.c @@ -0,0 +1,134 @@ +/* The save (save.h): a bytes file with a magic, a version, its size and a checksum, every value checked; anything + * else (damaged, cut short, from elsewhere) is not read: the copy in the Python script is tried, then a fresh + * start. */ +#include +#include +#include "plat.h" +#include "save.h" + +void fnf_fresh(fnf_progress_t *p) { + memset(p, 0, sizeof *p); + p->week = 1, p->week_diff = FNF_DIFF_NORMAL, p->song_diff = FNF_DIFF_NORMAL; +} + +uint32_t fnf_sum(const void *data, uint32_t n) { + const uint8_t *p = data; + uint32_t h = 2166136261u; + for (uint32_t i = 0; i < n; i++) h = (h ^ p[i]) * 16777619u; + return h; +} + +bool fnf_file_ok(const fnf_file_t *f) { + if (f->magic != FNF_MAGIC || f->version != FNF_VERSION || f->size != sizeof *f) return false; + if (f->sum != fnf_sum(f, offsetof(fnf_file_t, sum))) return false; + const fnf_progress_t *p = &f->p; + if (p->downscroll > 1 || p->guide > 1 || p->offset < -FNF_OFFSET_MAX || p->offset > FNF_OFFSET_MAX) return false; + if (p->week >= FNF_LEVELS || p->week_diff >= FNF_DIFFS || p->song >= FNF_SONGS || p->song_diff >= FNF_DIFFS || + p->list > 1) + return false; + for (int i = 0; i < FNF_CHARTS; i++) + if (p->chart[i].rank > 6 || p->chart[i].completion > 10000) return false; + return true; +} + +static const char head[] = "# Friday Night Funkin' keeps a copy of your scores here,\n" + "# so that installing it again doesn't erase them.\n"; +static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +static uint8_t copy[1 + sizeof head + 2 + sizeof FNF_SAVE_NAME + 1 + (sizeof(fnf_file_t) + 2) / 3 * 4 + 2]; + +/* the copy: the file in base 64, in a comment line of a script */ +static bool copy_write(const fnf_file_t *f) { + const uint8_t *bytes = (const uint8_t *)f; + uint32_t len = sizeof *f, nl = sizeof FNF_SAVE_NAME - 1; + uint8_t *p = copy; + *p++ = 0; /* (a script's first byte: not imported) */ + memcpy(p, head, sizeof head - 1), p += sizeof head - 1; + *p++ = '#', *p++ = '>'; + memcpy(p, FNF_SAVE_NAME, nl), p += nl; + *p++ = ':'; + for (uint32_t i = 0; i < len; i += 3, p += 4) { + uint32_t left = len - i; + uint32_t v = (uint32_t)bytes[i] << 16 | (left > 1 ? (uint32_t)bytes[i + 1] << 8 : 0) | (left > 2 ? bytes[i + 2] : 0); + p[0] = (uint8_t)b64[v >> 18 & 63]; + p[1] = (uint8_t)b64[v >> 12 & 63]; + p[2] = left > 1 ? (uint8_t)b64[v >> 6 & 63] : '='; + p[3] = left > 2 ? (uint8_t)b64[v & 63] : '='; + } + *p++ = '\n'; + return plat_save(FNF_COPY_NAME, copy, (uint32_t)(p - copy)); +} + +static int b64v(uint8_t c) { + for (int i = 0; i < 64; i++) + if ((uint8_t)b64[i] == c) return i; + return -1; +} + +static bool copy_read(fnf_file_t *f) { + uint32_t n, nl = sizeof FNF_SAVE_NAME - 1; + const uint8_t *c = plat_load(FNF_COPY_NAME, &n); + if (!c) return false; + for (uint32_t i = 0; i + nl + 3 <= n; i++) { + if (c[i] != '#' || c[i + 1] != '>' || memcmp(c + i + 2, FNF_SAVE_NAME, nl) || c[i + 2 + nl] != ':') continue; + const uint8_t *l = c + i + 3 + nl; + uint32_t m = 0; + while (l + m < c + n && l[m] != '\n' && l[m]) m++; + if (m != (sizeof *f + 2) / 3 * 4) return false; + uint8_t *bytes = (uint8_t *)f; + uint32_t o = 0; + for (uint32_t k = 0; k < m; k += 4) { + int v0 = b64v(l[k]), v1 = b64v(l[k + 1]), v2 = l[k + 2] == '=' ? 0 : b64v(l[k + 2]), + v3 = l[k + 3] == '=' ? 0 : b64v(l[k + 3]); + if (v0 < 0 || v1 < 0 || v2 < 0 || v3 < 0) return false; + uint32_t w = (uint32_t)(v0 << 18 | v1 << 12 | v2 << 6 | v3); + if (o < sizeof *f) bytes[o++] = (uint8_t)(w >> 16); + if (o < sizeof *f) bytes[o++] = (uint8_t)(w >> 8); + if (o < sizeof *f) bytes[o++] = (uint8_t)w; + } + return true; + } + return false; +} + +static fnf_file_t file; /* (static: the stack stays small) */ + +bool save_load(fnf_progress_t *p) { + uint32_t n; + const uint8_t *d = plat_load(FNF_SAVE_NAME, &n); + bool ok = false; + if (d && n == sizeof file) { + memcpy(&file, d, sizeof file); + ok = fnf_file_ok(&file); + } + if (!ok && copy_read(&file) && fnf_file_ok(&file)) { + ok = true; + plat_save(FNF_SAVE_NAME, &file, sizeof file); /* back where it belongs */ + } + if (!ok) { + fnf_fresh(p); + return false; + } + *p = file.p; + return true; +} + +bool save_write(const fnf_progress_t *p) { + memset(&file, 0, sizeof file); + file.magic = FNF_MAGIC, file.version = FNF_VERSION, file.size = sizeof file; + file.p = *p; + file.sum = fnf_sum(&file, offsetof(fnf_file_t, sum)); + bool ok = plat_save(FNF_SAVE_NAME, &file, sizeof file); + copy_write(&file); + return ok; +} + +void fnf_record(fnf_progress_t *p, unsigned chart, int32_t score, int rank, int32_t completion) { + if (chart >= FNF_CHARTS) return; + fnf_record_t *r = &p->chart[chart]; + if (!r->rank) { + r->score = score, r->rank = (uint8_t)(rank + 1), r->completion = (uint16_t)completion; + return; + } + if (score > r->score) r->score = score; + if (!(r->rank > rank + 1 || r->completion > completion)) r->rank = (uint8_t)(rank + 1), r->completion = (uint16_t)completion; +} diff --git a/games/fnf/src/save.h b/games/fnf/src/save.h new file mode 100644 index 00000000..12925344 --- /dev/null +++ b/games/fnf/src/save.h @@ -0,0 +1,44 @@ +/* What's kept, in fnf.sav, and a copy of it in a Python script, fnf_saves.py (installing apps from the NumWorks + * website keeps only scripts, so the save comes back from it): the options, how to play seen, the menus' last + * choices, each chart's best score and rank, and each week's best score. */ +#ifndef FNF_SAVE_H +#define FNF_SAVE_H +#include +#include "data.h" + +#define FNF_SAVE_NAME "fnf.sav" +#define FNF_COPY_NAME "fnf_saves.py" +#define FNF_MAGIC 0x53464E46u /* "FNFS" */ +#define FNF_VERSION 1 +#define FNF_OFFSET_MAX 300 /* the note offset: -300..300 ms */ + +typedef struct { + int32_t score; /* the best score */ + uint16_t completion; /* the best clear's completion (ten-thousandths) */ + uint8_t rank; /* its rank + 1 (fnf_rank), 0: never cleared */ + uint8_t spare; +} fnf_record_t; + +typedef struct { + uint8_t downscroll, guide; /* the options; how to play shown before the first song */ + int16_t offset; /* the note offset (ms) */ + uint8_t week, week_diff, song, song_diff, list, spare[3]; /* the menus' last choices */ + int32_t week_score[FNF_LEVELS][FNF_DIFFS]; + fnf_record_t chart[FNF_CHARTS]; +} fnf_progress_t; + +typedef struct { + uint32_t magic; + uint16_t version, size; + fnf_progress_t p; + uint32_t sum; /* FNV-1a of everything before it */ +} fnf_file_t; + +void fnf_fresh(fnf_progress_t *p); +uint32_t fnf_sum(const void *data, uint32_t n); +bool fnf_file_ok(const fnf_file_t *f); +bool save_load(fnf_progress_t *p); /* the save, its copy, or a fresh start; false: a fresh start */ +bool save_write(const fnf_progress_t *p); +/* a song cleared: the best score and the best rank kept (Save.applySongRank) */ +void fnf_record(fnf_progress_t *p, unsigned chart, int32_t score, int rank, int32_t completion); +#endif diff --git a/games/fnf/src/screens.c b/games/fnf/src/screens.c new file mode 100644 index 00000000..5a877b6b --- /dev/null +++ b/games/fnf/src/screens.c @@ -0,0 +1,437 @@ +/* The screens (app.h): the game's title, main menu, story menu and freeplay, with its own pictures and letters, the + * pause menu over the song, and NumPlay's key line along the bottom of the menus. */ +#include +#include "app.h" +#include "draw.h" + +#define WHITE 0xFFFF +#define FOCUS HEX(0xFFD43B) +#define INK HEX(0x0A0F1A) +#define MUTED HEX(0xA8B3C7) +#define PINK HEX(0xE55777) +#define YELLOW HEX(0xF9CF51) + +/* ------------------------------------------------------------------------------------------------ text */ +static char *cat(char *p, const char *s) { + while (*s) *p++ = *s++; + *p = 0; + return p; +} +static char *num(char *p, int32_t v, bool commas) { + char t[16]; + int n = 0; + bool neg = v < 0; + uint32_t u = (uint32_t)(neg ? -v : v); + do { + if (commas && n && (n + 1) % 4 == 0) t[n++] = ','; + t[n++] = (char)('0' + u % 10), u /= 10; + } while (u && n < 15); + if (neg) *p++ = '-'; + while (n) *p++ = t[--n]; + *p = 0; + return p; +} +static void upper(char *d, const char *s, int n) { + int i = 0; + for (; s[i] && i < n - 1; i++) d[i] = (char)(s[i] >= 'a' && s[i] <= 'z' ? s[i] - 32 : s[i]); + d[i] = 0; +} + +static void text(int x, int base, const char *s, color_t c) { gfx_text(&np_font_body, x, base, s, c, 256); } +static void text_c(int cx, int base, const char *s, color_t c) { gfx_text_center(&np_font_body, cx, base, s, c, 256); } +static void small_c(int cx, int base, const char *s, color_t c) { gfx_text_center(&np_font_small, cx, base, s, c, 256); } + +/* the key line: "key|what|key|what...", keys in yellow */ +static void keyline(const char *s) { + gfx_fill(0, 226, SCREEN_W, 14, INK); + char b[24]; + int x = 6, i = 0; + while (*s) { + int n = 0; + while (*s && *s != '|' && n < 23) b[n++] = *s++; + b[n] = 0; + if (*s == '|') s++; + gfx_text(&np_font_small, x, 236, b, i % 2 ? WHITE : FOCUS, 256); + x += gfx_text_width(&np_font_small, b) + (i % 2 ? 9 : 4); + i++; + } +} + +static int bold_c(int cx, int y, const char *s, int alpha, int scale) { + int w = bold_width(s, scale); + bold_text(cx - w / 2, y, s, alpha, scale); + return w; +} + +/* a difficulty, between arrows */ +static void diff_line(int cx, int y, const char *name, int scale) { + int w = bold_c(cx, y, name, 32, scale); + int mid = y + FNF_BOLD_HEIGHT * scale / 512 + 7; + gfx_text_center(&np_font_title, cx - w / 2 - 12, mid, "<", FOCUS, 256); + gfx_text_center(&np_font_title, cx + w / 2 + 12, mid, ">", FOCUS, 256); +} + +/* a choice of a list: bright, on a dark band with arrows, or dimmed */ +static void item(int cx, int y, const char *s, bool focus) { + int scale = focus ? 256 : 220; + int w = bold_width(s, scale); + if (focus) { + gfx_rrect(cx - w / 2 - 14, y - 4, w + 28, FNF_BOLD_HEIGHT + 8, 8, INK, 200); + gfx_rrect_outline(cx - w / 2 - 14, y - 4, w + 28, FNF_BOLD_HEIGHT + 8, 8, 2, FOCUS, 256); + } + bold_text(cx - w / 2, y + (focus ? 0 : 2), s, focus ? 32 : 18, scale); +} + +/* ------------------------------------------------------------------------------------------------ pictures */ +static struct { + int view; /* the screen they were unpacked for (-1: none) */ + int bg; /* the background unpacked */ + pic_t logo[2], gf; + uint8_t *gf_px; + int gf_frame; + pic_t icon[5]; + int icon_id[5]; + uint8_t *icon_px[5]; +} M = {.view = -1, .bg = -1}; + +static void bg_use(int id) { + if (M.bg == id) return; + M.bg = bg_load((unsigned)id) ? id : -1; +} + +static const pic_t *icon_slot(int slot, unsigned icon) { + if (slot < 0 || slot >= 5 || !M.icon_px[slot]) return 0; + if (M.icon_id[slot] != (int)icon) { + M.icon_id[slot] = -1; + if (!pic_load(&M.icon[slot], fnf_icon[icon][0], M.icon_px[slot], FNF_ICON_MAX / 3)) return 0; + M.icon_id[slot] = (int)icon; + } + return &M.icon[slot]; +} + +static void menus_load(int view) { + ram_reset(); + memset(&M, 0, sizeof M); + M.view = view, M.bg = -1, M.gf_frame = -1; + for (int i = 0; i < 5; i++) M.icon_id[i] = -1; + if (view == V_TITLE) { + pic_new(&M.logo[0], FNF_SPR_LOGO0); + pic_new(&M.logo[1], FNF_SPR_LOGO1); + uint32_t big = 0; + for (int f = 0; f < 4; f++) { + const fnf_sprite_t *s = &fnf_sprite[FNF_SPR_GFTITLE0 + f]; + uint32_t n = (s->w + 1u) / 2 * s->h; + if (n > big) big = n; + } + M.gf_px = ram_take(big); + } + for (int i = 0; i < 5; i++) M.icon_px[i] = ram_take(FNF_ICON_MAX / 3); +} + +/* ------------------------------------------------------------------------------------------------ each screen */ +typedef struct { + app_t *a; +} ctx_t; + +static void dim(int a256) { gfx_fill_alpha(0, gfx_y0, SCREEN_W, gfx_y1 - gfx_y0, 0, a256); } + +/* the menus' beat: freakyMenu, 102 a minute */ +static uint32_t menu_beat(const app_t *a, uint32_t *into) { + uint32_t ms = a->clock; + uint32_t b = ms * 102 / 60000; + if (into) *into = ms - b * 60000 / 102; + return b; +} + +static void title_strip(app_t *a) { + gfx_fill(0, gfx_y0, SCREEN_W, gfx_y1 - gfx_y0, 0); + uint32_t into; + uint32_t beat = menu_beat(a, &into); + /* Girlfriend dances left, then right, on each beat; the logo bumps */ + int f = (beat & 1 ? 2 : 0) + (into > 294 ? 1 : 0); + if (M.gf_px && f != M.gf_frame) { + M.gf_frame = pic_load(&M.gf, FNF_SPR_GFTITLE0 + (unsigned)f, M.gf_px, 1u << 20) ? f : -1; + } + if (M.gf_frame >= 0) blit_at(&M.gf, 218, 104, 32); + blit_at(&M.logo[into < 160 ? 0 : 1], 100, 66, 32); + int alpha = 20 + (int)(12 * (into < 294 ? 294 - into : into - 294) / 294); + bold_c(160, 194, "PRESS OK TO BEGIN", alpha, 230); + keyline("OK|start|Back|quit"); +} + +static const char *const MAIN_ITEMS[MAIN_COUNT] = {"STORY MODE", "FREEPLAY", "OPTIONS", "HOW TO PLAY", "CREDITS"}; + +static void main_strip(app_t *a) { + bg_use(FNF_SPR_MENUBG); + draw_bg(fnf_palette[fnf_sprite[FNF_SPR_MENUBG].pal], 0, 1); + for (int i = 0; i < MAIN_COUNT; i++) item(160, 30 + i * 38, MAIN_ITEMS[i], a->sel == i); + keyline("Up Down|choose|OK|select|Back|title"); +} + +static const char *const WEEK_TITLE[FNF_LEVELS] = {"TUTORIAL", "WEEK 1", "WEEK 2", "WEEK 3", "WEEK 4", + "WEEK 5", "WEEK 6", "WEEK 7", "WEEKEND 1", "LE SSERAFIM"}; + +static void story_strip(app_t *a) { + const fnf_progress_t *p = a->p; + gfx_fill(0, gfx_y0, SCREEN_W, gfx_y1 - gfx_y0, 0); + char b[64]; + char *q = cat(b, "WEEK SCORE: "); + num(q, p->week_score[p->week][p->week_diff], false); + gfx_text(&np_font_small, 6, 13, b, WHITE, 256); + upper(b, fnf_level[p->week].name, sizeof b); + gfx_text_right(&np_font_small, 314, 13, b, MUTED, 256); + /* the yellow band, with the week's singers: the opponent and you */ + gfx_fill(0, 18, SCREEN_W, 84, YELLOW); + const fnf_level_t *lv = &fnf_level[p->week]; + int c = app_story_chart(lv->song[0], p->week_diff); + if (c >= 0) { + const fnf_var_t *v = &fnf_var[fnf_chart[c].var]; + for (int r = 0; r < 2; r++) { + if (v->chr[r] == 255) continue; + const pic_t *ic = icon_slot(r, fnf_char[v->chr[r]].icon); + if (ic) blit_scaled(ic, (r ? 84 : 236) - 38 + (ic->x + 19) * 2, 60 - 38 + (ic->y + 19) * 2, ic->w * 2, ic->h * 2, 32); + } + } + /* the weeks */ + for (int k = -1; k <= 1; k++) { + int w = p->week + k; + if (w < 0 || w >= FNF_LEVELS) continue; + int y = 128 + k * 30; + if (!k) item(160, y, WEEK_TITLE[w], true); + else bold_c(160, y + 2, WEEK_TITLE[w], 12, 200); + } + /* the tracks, and the difficulty */ + gfx_text(&np_font_small, 14, 184, "TRACKS", PINK, 256); + for (int i = 0; i < lv->n; i++) { + upper(b, fnf_song[lv->song[i]].name, sizeof b); + gfx_text(&np_font_small, 14, 197 + i * 9, b, PINK, 256); + } + diff_line(236, 186, DIFF_NAME[p->week_diff], 200); + keyline("Up Down|week|Left Right|difficulty|OK|play|Back|menu"); +} + +/* freeplay's colours, a week each (the game's FreeplayState) */ +static const uint32_t WEEK_COLOR[FNF_LEVELS] = {0x9271FD, 0x9271FD, 0x223344, 0x941653, 0xFC96D7, 0xA0D1FF, 0xFF78BF, + 0xF6B604, 0x413CAE, 0xF9CF51}; + +static void freeplay_strip(app_t *a) { + const fnf_progress_t *p = a->p; + bg_use(FNF_SPR_MENUDESAT); + unsigned song = a->list_n ? a->list[p->song] : 0; + color_t tint = gfx_rgb(WEEK_COLOR[fnf_song[song].level]); + uint16_t pal[16]; + const uint16_t *src = fnf_palette[fnf_sprite[FNF_SPR_MENUDESAT].pal]; + for (int i = 0; i < 16; i++) pal[i] = gfx_mix(src[i], tint, 20); + draw_bg(pal, 0, 1); + char b[64]; + /* the songs, the chosen one in the middle, with their opponents' icons */ + for (int k = -2; k <= 2; k++) { + int i = p->song + k; + if (i < 0 || i >= a->list_n) continue; + int y = 104 + k * 34; + upper(b, fnf_song[a->list[i]].name, sizeof b); + int scale = k ? 200 : 240; + int w = bold_width(b, scale); + int x = 22 + (k < 0 ? -k : k) * 6; + if (!k) { + gfx_rrect(x - 8, y - 5, w + 58, FNF_BOLD_HEIGHT + 10, 8, INK, 190); + gfx_rrect_outline(x - 8, y - 5, w + 58, FNF_BOLD_HEIGHT + 10, 8, 2, FOCUS, 256); + } + bold_text(x, y, b, k ? 14 : 32, scale); + const pic_t *ic = icon_slot(k + 2, fnf_song[a->list[i]].icon); + if (ic) blit_scaled(ic, x + w + 6 + (ic->x + 19) * 26 / 38, y - 4 + (ic->y + 19) * 26 / 38, ic->w * 26 / 38, + ic->h * 26 / 38, k ? 18 : 32); + } + /* the best score and the difficulty, top right */ + gfx_rrect(196, 4, 120, 46, 6, INK, 200); + int chart = a->list_n ? app_chart(song, p->list, p->song_diff) : -1; + char *q = cat(b, "BEST "); + num(q, chart >= 0 ? p->chart[chart].score : 0, true); + gfx_text_center(&np_font_small, 256, 16, b, WHITE, 256); + q = cat(b, "< "); + q = cat(q, DIFF_NAME[p->song_diff]); + cat(q, " >"); + gfx_text_center(&np_font_body, 256, 31, b, FOCUS, 256); + if (chart >= 0 && p->chart[chart].rank) { + static const char *const RANK[6] = {"LOSS", "GOOD", "GREAT", "EXCELLENT", "PERFECT", "PERFECT"}; + q = cat(b, RANK[p->chart[chart].rank - 1]); + q = cat(q, " "); + q = num(q, p->chart[chart].completion / 100, false); + cat(q, "%"); + gfx_text_center(&np_font_small, 256, 45, b, p->chart[chart].rank >= 5 ? FOCUS : MUTED, 256); + } + gfx_text(&np_font_small, 8, 14, p->list == LIST_PICO ? "PICO" : "BOYFRIEND", WHITE, 256); + keyline(p->list == LIST_PICO ? "Up Down|song|Left Right|difficulty|shift|Boyfriend|OK|play" + : "Up Down|song|Left Right|difficulty|shift|Pico|OK|play"); +} + +static void options_body(app_t *a) { + const fnf_progress_t *p = a->p; + char b[48]; + bold_c(160, 30, "OPTIONS", 32, 256); + char *q = cat(b, "DOWNSCROLL "); + cat(q, p->downscroll ? "ON" : "OFF"); + item(160, 82, b, a->sel == OPT_DOWNSCROLL); + q = cat(b, "NOTE OFFSET "); + if (p->offset > 0) *q++ = '+'; + q = num(q, p->offset, false); + cat(q, " MS"); + item(160, 126, b, a->sel == OPT_OFFSET); + small_c(160, 178, a->sel == OPT_DOWNSCROLL ? "The notes come down to the arrows at the bottom." + : "Raise it if your hits come out late.", WHITE); + keyline(a->sel == OPT_DOWNSCROLL ? "Up Down|choose|OK|on or off|Back|done" : "Up Down|choose|Left Right|change|Back|done"); +} + +static const char *const HELP[HELP_PAGES][4] = { + {"HOW TO PLAY", "Press each note's key as it reaches", "the arrows at the top of the screen:", + "Left Down Up Right, or 7 8 ( )."}, + {"HOLDS AND MISSES", "Long notes: keep the key down to the end.", "A key with no note there costs health,", + "and so does a missed note."}, + {"THE HEALTH BAR", "Yours is the green side. Empty it and", "it's game over. Sick, Good, Bad, Shit:", + "how close to the beat you hit."}, +}; + +static void help_body(app_t *a) { + bold_c(160, 46, HELP[a->page][0], 32, 240); + for (int i = 1; i < 4; i++) text_c(160, 96 + i * 20, HELP[a->page][i], WHITE); + char b[16]; + char *q = num(b, a->page + 1, false); + q = cat(q, " / "); + num(q, HELP_PAGES, false); + small_c(160, 210, b, MUTED); + keyline(a->first_run ? "OK|next|Back|skip" : "Left Right|pages|Back|close"); +} + +static void credits_strip(app_t *a) { + (void)a; + bg_use(FNF_SPR_MENUDESAT); + draw_bg(fnf_palette[fnf_sprite[FNF_SPR_MENUDESAT].pal], 600, 1); + bold_c(160, 16, "CREDITS", 32, 240); + static const char *const L[] = {"Friday Night Funkin' by the Funkin' Crew:", "ninjamuffin99, PhantomArcade,", + "evilsk8r and Kawai Sprite.", "Its charts and pictures, not affiliated with them.", + "Calculator adaptation by Bobydeluxe.", "Made by Mason Chen as part of NumPlay."}; + for (int i = 0; i < 6; i++) text_c(160, 66 + i * 24 + (i >= 4 ? 10 : 0), L[i], i == 0 || i == 4 ? FOCUS : WHITE); + keyline("Back|menu"); +} + +static void pause_body(app_t *a) { + static const char *const PAUSE_ITEMS[PAUSE_COUNT] = {"RESUME", "RESTART SONG", "EXIT TO MENU", "OPTIONS", + "HOW TO PLAY", "QUIT GAME"}; + const fnf_game_t *g = a->g; + char b[64]; + upper(b, fnf_song[fnf_var[g->var].song].name, sizeof b); + gfx_text_right(&np_font_body, 314, 16, b, WHITE, 256); + gfx_text_right(&np_font_small, 314, 30, DIFF_NAME[fnf_chart[g->chart].diff], FOCUS, 256); + for (int i = 0; i < PAUSE_COUNT; i++) item(160, 30 + i * 32, PAUSE_ITEMS[i], a->sel == i); + keyline("Up Down|choose|OK|select|Back|resume"); +} + +static void quit_body(app_t *a) { + bold_c(160, 60, "QUIT THE GAME?", 32, 256); + item(160, 112, "KEEP PLAYING", a->sel == 0); + item(160, 150, "QUIT", a->sel == 1); + small_c(160, 196, "Your scores are saved.", MUTED); + keyline("Up Down|choose|OK|select|Back|pause"); +} + +static void results_strip(app_t *a) { + static const char *const RANK[6] = {"LOSS", "GOOD", "GREAT", "EXCELLENT", "PERFECT", "PERFECT!"}; + bg_use(FNF_SPR_MENUBG); + draw_bg(fnf_palette[fnf_sprite[FNF_SPR_MENUBG].pal], 450, 1); + bold_c(160, 10, RANK[a->result_rank], 32, 280); + char b[48]; + char *q = cat(b, "SCORE "); + num(q, a->result_score, true); + bold_c(160, 44, b, 32, 220); + if (a->result_new) small_c(160, 82, "NEW BEST!", FOCUS); + static const char *const LABEL[7] = {"TOTAL NOTES", "MAX COMBO", "SICK", "GOOD", "BAD", "SHIT", "MISSED"}; + int32_t v[7] = {a->result_total, a->result_max_combo, a->result_tally[0], a->result_tally[1], a->result_tally[2], + a->result_tally[3], a->result_tally[4]}; + static const uint32_t COLOR[7] = {0xFFFFFF, 0xFFFFFF, 0x89E59E, 0x89C9E5, 0xE6CF8A, 0xE68C8A, 0xC68AE6}; + gfx_rrect(56, 90, 208, 112, 8, INK, 210); + for (int i = 0; i < 7; i++) { + text(70, 107 + i * 14, LABEL[i], gfx_rgb(COLOR[i])); + num(b, v[i], false); + gfx_text_right(&np_font_body, 250, 107 + i * 14, b, gfx_rgb(COLOR[i]), 256); + } + q = cat(b, "CLEAR "); + q = num(q, a->result_completion / 100, false); + cat(q, "%"); + text_c(160, 218, b, WHITE); + keyline("OK|continue"); +} + +static void error_strip(app_t *a) { + (void)a; + gfx_fill(0, gfx_y0, SCREEN_W, gfx_y1 - gfx_y0, INK); + text_c(160, 110, "This song's data is damaged.", WHITE); + small_c(160, 130, "Install the game again from NumPlay's page.", MUTED); + keyline("OK|menu"); +} + +/* ------------------------------------------------------------------------------------------------ in a song */ +static bool in_song(const app_t *a) { + int v = a->view; + return v == V_PLAY || v == V_PAUSE || v == V_QUIT || v == V_GAMEOVER || + ((v == V_HELP || v == V_OPTIONS) && a->under == V_PAUSE); +} + +static void song_strip(void *ctx) { + app_t *a = ((ctx_t *)ctx)->a; + switch (a->view) { + case V_PLAY: play_strip(); break; + case V_GAMEOVER: + play_actor_strip(a->g, ROLE_PLAYER); + bold_c(160, 30, "GAME OVER", 32, 256); + keyline("OK|retry|Back|menu"); + break; + default: + play_strip(); + dim(150); + if (a->view == V_PAUSE) pause_body(a); + else if (a->view == V_QUIT) quit_body(a); + else if (a->view == V_HELP) help_body(a); + else options_body(a); + break; + } +} + +static void menu_strip(void *ctx) { + app_t *a = ((ctx_t *)ctx)->a; + switch (a->view) { + case V_TITLE: title_strip(a); break; + case V_MAIN: main_strip(a); break; + case V_STORY: story_strip(a); break; + case V_FREEPLAY: freeplay_strip(a); break; + case V_OPTIONS: + bg_use(FNF_SPR_MENUDESAT); + draw_bg(fnf_palette[fnf_sprite[FNF_SPR_MENUDESAT].pal], 500, 1); + options_body(a); + break; + case V_HELP: + gfx_fill(0, gfx_y0, SCREEN_W, gfx_y1 - gfx_y0, INK); + help_body(a); + break; + case V_CREDITS: credits_strip(a); break; + case V_RESULTS: results_strip(a); break; + default: error_strip(a); break; + } +} + +static bool was_in_song; + +void screens_draw(app_t *a) { + ctx_t c = {a}; + bool song = in_song(a); + if (song) { + if (!was_in_song) M.view = -1; + was_in_song = true; + const fnf_game_t *g = a->g; + play_prepare(g, a->p->downscroll, (int32_t)((a->clock - a->play_t0) * 1000)); + draw_frame(song_strip, &c); + return; + } + if (was_in_song || M.view != a->view) menus_load(a->view); + was_in_song = false; + draw_frame(menu_strip, &c); +} diff --git a/games/fnf/tests/play.c b/games/fnf/tests/play.c new file mode 100644 index 00000000..fb1e4c6d --- /dev/null +++ b/games/fnf/tests/play.c @@ -0,0 +1,791 @@ +/* Friday Night Funkin' on a computer, for tests and pictures (make host, make test). + * + * play test DIR the rules against the game's own numbers (the PBOT1 points and judgements, health, combo, + * holds and their drops, misses, ghost misses, the note offset, ranks); every chart unpacked and + * read; a bot playing every chart on time to a full combo (and the same scores on every + * computer: a hash of them); bots hitting late and missing; a few hundred sessions of the whole + * app with random keys; saves written and read back, damaged, and back from their copy + * play shots DIR pictures of the screens (DIR/.ppm) + * play film DIR CHART MS MS milliseconds of a chart, a bot playing (DIR/_.raw, for tools/record.py) + */ +#define main fnf_app_main +#include "../src/main.c" +#undef main +#include +#include +#include "inflate.h" +#include + +/* ---------------------------------------------------------------- the calculator, on a computer */ +static uint16_t fb[240][320]; +static void (*on_push)(int y, int h); +void plat_push(int x, int y, int w, int h, const uint16_t *px) { + for (int j = 0; j < h; j++) + for (int i = 0; i < w; i++) fb[y + j][x + i] = px[j * w + i]; + if (on_push) on_push(y, h); +} +void *np_alloc(uint32_t n) { (void)n; return 0; } +static uint32_t clock_ms; +static uint32_t (*script)(uint32_t ms); +static uint32_t quit_at; +static void (*watch)(void); +uint32_t plat_keys(void) { + clock_ms += 1; + if (watch) watch(); + if (clock_ms >= quit_at) return FK_HOME; + return script ? script(clock_ms) : 0; +} +uint32_t plat_millis(void) { return clock_ms; } +void plat_sleep(uint32_t ms) { clock_ms += ms; } +void plat_begin(void) {} +int plat_end(void) { return 0; } + +static struct { char name[32]; uint8_t data[4096]; uint32_t len; bool on; } files[4]; +static bool storage_full; +bool plat_save(const char *name, const void *data, uint32_t len) { + if (storage_full) return false; + int k = -1; + for (int i = 0; i < 4; i++) + if (files[i].on && !strcmp(files[i].name, name)) k = i; + for (int i = 0; i < 4 && k < 0; i++) + if (!files[i].on) k = i; + if (k < 0 || len > sizeof files[k].data) return false; + strcpy(files[k].name, name), memcpy(files[k].data, data, len), files[k].len = len, files[k].on = true; + return true; +} +const uint8_t *plat_load(const char *name, uint32_t *len) { + for (int i = 0; i < 4; i++) + if (files[i].on && !strcmp(files[i].name, name)) return *len = files[i].len, files[i].data; + return 0; +} +static void forget(const char *name) { + for (int i = 0; i < 4; i++) + if (files[i].on && !strcmp(files[i].name, name)) files[i].on = false; +} +static uint8_t *file_data(const char *name, uint32_t *len) { + for (int i = 0; i < 4; i++) + if (files[i].on && !strcmp(files[i].name, name)) return *len = files[i].len, files[i].data; + return 0; +} + +static void shot(const char *dir, const char *name) { + char path[512]; + snprintf(path, sizeof path, "%s/%s.ppm", dir, name); + FILE *f = fopen(path, "wb"); + if (!f) exit(2); + fprintf(f, "P6 320 240 255\n"); + for (int y = 0; y < 240; y++) + for (int x = 0; x < 320; x++) { + uint16_t c = fb[y][x]; + fputc((c >> 11) * 255 / 31, f), fputc((c >> 5 & 63) * 255 / 63, f), fputc((c & 31) * 255 / 31, f); + } + fclose(f); +} + +static int fails; +#define CHECK(c, ...) do { if (!(c)) { fails++; printf("FAIL %s:%d: ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } } while (0) + +/* ---------------------------------------------------------------- a chart's notes, read on their own */ +typedef struct { int32_t t, len; uint8_t lane, kind; } note_t; +static note_t notes[FNF_CHART_NOTES_MAX]; +static uint8_t raw[FNF_CHART_RAW_MAX]; + +static int read_chart(unsigned c) { + const fnf_chart_t *ch = &fnf_chart[c]; + if (fnf_inflate(fnf_blob + ch->at, ch->size, raw, sizeof raw) != ch->raw) return -1; + uint32_t pos = 0; + int n = 0; + int32_t t = 0; + while (pos < ch->raw && n < FNF_CHART_NOTES_MAX) { + uint32_t v[3] = {0, 0, 0}; + for (int k = 0; k < 3; k++) { + if (k == 2 && !(v[1] & 8)) break; + int s = 0; + for (;;) { + uint8_t b = raw[pos++]; + v[k] |= (uint32_t)(b & 127) << s; + s += 7; + if (!(b & 128)) break; + } + } + t += (int32_t)v[0]; + notes[n++] = (note_t){t, (int32_t)v[2], (uint8_t)(v[1] & 7), (uint8_t)(v[1] >> 4)}; + } + return pos == ch->raw ? n : -1; +} + +static fnf_game_t T; +static uint8_t buf[FNF_CHART_RAW_MAX], app_chart_buf[FNF_CHART_RAW_MAX]; +static color_t test_strip[SCREEN_W * STRIP_H]; + +/* the n-th player note (lane 0-3, scoreable) of chart c */ +static note_t player_note(int n_notes, int k) { + for (int i = 0; i < n_notes; i++) + if (notes[i].lane < 4 && notes[i].kind != FNF_KIND_NON_SCOREABLE && !k--) return notes[i]; + return (note_t){0}; +} + +static int find_chart(const char *song, int kind, int diff) { + for (int c = 0; c < FNF_CHARTS; c++) { + const fnf_var_t *v = &fnf_var[fnf_chart[c].var]; + if (!strcmp(fnf_song[v->song].name, song) && v->kind == kind && fnf_chart[c].diff == diff) return c; + } + return -1; +} + +/* a bot: each player note pressed on time (chords together), each hold kept to its end, other notes let go of + 30 ms on or just before the lane's next note */ +typedef struct { int32_t t; uint8_t lane, down; uint16_t i; } bot_ev; +static bot_ev evs[2 * FNF_CHART_NOTES_MAX]; +static int cmp_ev(const void *a, const void *b) { + const bot_ev *x = a, *y = b; + if (x->t != y->t) return x->t < y->t ? -1 : 1; + if (x->down != y->down) return x->down < y->down ? -1 : 1; /* (releases first) */ + if (x->lane != y->lane) return x->lane < y->lane ? -1 : 1; + return x->i < y->i ? -1 : x->i > y->i; /* (no ties left: the same order everywhere) */ +} + +static int bot_events(int n, int32_t late) { + int k = 0; + for (int i = 0; i < n; i++) { + const note_t *a = ¬es[i]; + if (a->lane >= 4) continue; + int32_t next = INT32_MAX; + for (int j = i + 1; j < n; j++) + if (notes[j].lane == a->lane) { + next = notes[j].t; + break; + } + int32_t up = a->len > 0 ? a->t + a->len + 1000 : a->t + 30000; + if (up > next - 2000) up = next - 2000; + if (up <= a->t + late) up = a->t + late + 1; + evs[k] = (bot_ev){a->t + late, a->lane, 1, (uint16_t)k}; + k++; + evs[k] = (bot_ev){up, a->lane, 0, (uint16_t)k}; + k++; + } + qsort(evs, (size_t)k, sizeof evs[0], cmp_ev); + return k; +} + +/* ---------------------------------------------------------------- the rules */ +static void judgements_and_points(void) { + CHECK(fnf_judge(0) == J_SICK && fnf_judge(45) == J_SICK && fnf_judge(-45) == J_SICK, "Sick: up to 45 ms"); + CHECK(fnf_judge(46) == J_GOOD && fnf_judge(90) == J_GOOD && fnf_judge(-90) == J_GOOD, "Good: up to 90 ms"); + CHECK(fnf_judge(91) == J_BAD && fnf_judge(135) == J_BAD && fnf_judge(-135) == J_BAD, "Bad: up to 135 ms"); + CHECK(fnf_judge(136) == J_SHIT && fnf_judge(160) == J_SHIT && fnf_judge(-160) == J_SHIT, "Shit: up to 160 ms"); + CHECK(fnf_judge(161) == J_MISS && fnf_judge(-161) == J_MISS, "a miss past 160 ms"); + /* Scoring.scoreNotePBOT1, in double precision */ + for (int d = 0; d <= 160; d++) { + int want = d < 5 ? 500 : (int)(500.0 * (1.0 - 1.0 / (1.0 + exp(-0.080 * (d - 54.99)))) + 9.0); + CHECK(fnf_points(d) == want && fnf_points(-d) == want, "PBOT1 at %d ms: %d, not %d", d, fnf_points(d), want); + } + CHECK(fnf_points(161) == -100, "past the window: -100"); + CHECK(fnf_points(0) == 500 && fnf_points(4) == 500 && fnf_points(160) == 9, "500 to 9 points"); +} + +/* Tutorial, Normal: its first player notes, played by hand */ +static void scoring(void) { + int c = find_chart("Tutorial", FNF_VAR_DEFAULT, FNF_DIFF_NORMAL); + CHECK(c >= 0, "Tutorial, Normal"); + if (c < 0) return; + int n = read_chart((unsigned)c); + CHECK(fnf_start(&T, (unsigned)c, buf, 0, false), "starts"); + CHECK(T.health == 1000000 && T.score == 0 && T.combo == 0, "health at half, no score"); + note_t a = player_note(n, 0), b = player_note(n, 1), d = player_note(n, 2), e = player_note(n, 3); + CHECK(a.t > 0 && b.t > a.t, "notes"); + /* on time: Sick, 500, +1.5% of the bar */ + fnf_press(&T, a.lane, a.t); + CHECK(T.tally[J_SICK] == 1 && fnf_score(&T) == 500 && T.health == 1030000 && T.combo == 1, "Sick: 500, +0.03"); + fnf_release(&T, a.lane, a.t + 20000); + /* 50 ms late: Good, +0.015 */ + fnf_press(&T, b.lane, b.t + 50000); + CHECK(T.tally[J_GOOD] == 1 && fnf_score(&T) == 500 + fnf_points(50) && T.health == 1045000 && T.combo == 2, + "Good at 50 ms: %d points, health %d", fnf_score(&T) - 500, (int)T.health); + fnf_release(&T, b.lane, b.t + 70000); + /* 100 ms early: Bad, no health, the combo broken */ + int before = fnf_score(&T); + fnf_press(&T, d.lane, d.t - 100000); + CHECK(T.tally[J_BAD] == 1 && fnf_score(&T) == before + fnf_points(100) && T.health == 1045000 && T.combo == 0, + "Bad 100 ms early: combo broken, health kept"); + fnf_release(&T, d.lane, d.t - 80000); + /* 150 ms late: Shit, -0.02 */ + before = fnf_score(&T); + fnf_press(&T, e.lane, e.t + 150000); + CHECK(T.tally[J_SHIT] == 1 && fnf_score(&T) == before + fnf_points(150) && T.health == 1025000, "Shit: -0.02"); + fnf_release(&T, e.lane, e.t + 160000); + /* a ghost miss: a key with no note near: -0.08 and -10, no miss counted, the combo kept */ + fnf_press(&T, 0, e.t + 170000); /* (no note a lane's window away) */ + fnf_release(&T, 0, e.t + 180000); + T.combo = 7; + int32_t h = T.health; + before = fnf_score(&T); + int32_t far = e.t + 400000; + bool clear = true; + for (int i = 0; i < n; i++) + if (notes[i].lane == 1 && notes[i].t > far - 400000 && notes[i].t < far + 400000) clear = false; + if (clear) { + fnf_press(&T, 1, far); + CHECK(T.health == h - 80000 && fnf_score(&T) == before - 10 && T.combo == 7 && T.tally[J_MISS] == 0, + "ghost miss: -0.08, -10, combo kept"); + fnf_release(&T, 1, far + 10000); + } + /* a note let by: a miss once 160 ms have gone, -0.08, -100, the combo broken */ + note_t m = player_note(n, 6); + int chord = 0; + for (int i = 0; i < n; i++) chord += notes[i].lane < 4 && notes[i].t == m.t; + fnf_advance(&T, m.t + 160000); + int missed = T.tally[J_MISS]; + h = T.health, before = fnf_score(&T); + fnf_advance(&T, m.t + 160001); + CHECK(T.tally[J_MISS] == missed + chord && T.combo == 0 && T.health == h - 80000 * chord && + fnf_score(&T) == before - 100 * chord, "missed past 160 ms, not at 160 ms: -0.08, -100"); +} + +/* the edges of the window, and the note offset */ +static void window_edges(void) { + int c = find_chart("Bopeebo", FNF_VAR_DEFAULT, FNF_DIFF_EASY); + if (c < 0) return; + int n = read_chart((unsigned)c); + note_t a = player_note(n, 0), b = player_note(n, 1); + fnf_start(&T, (unsigned)c, buf, 0, false); + fnf_press(&T, a.lane, a.t + 160000); + CHECK(T.tally[J_SHIT] == 1, "hit at 160 ms: Shit"); + fnf_release(&T, a.lane, a.t + 170000); + fnf_press(&T, b.lane, b.t - 160999); + CHECK(T.tally[J_SHIT] == 1, "160.999 ms early: out of the window"); + fnf_release(&T, b.lane, b.t - 160500); + fnf_press(&T, b.lane, b.t - 135900); + CHECK(T.tally[J_BAD] == 1, "135.9 ms early: Bad (whole ms, toward 0)"); + fnf_start(&T, (unsigned)c, buf, 0, false); + fnf_press(&T, a.lane, a.t + 160001); + CHECK(T.tally[J_SHIT] == 0 && T.tally[J_MISS] == 1, "160.001 ms late: missed, then a ghost miss"); + /* a 30 ms note offset: a press 30 ms late counts as on time */ + fnf_start(&T, (unsigned)c, buf, 30, false); + fnf_press(&T, a.lane, a.t + 30000); + CHECK(T.tally[J_SICK] == 1 && fnf_score(&T) == 500, "offset 30 ms: Sick"); + fnf_release(&T, a.lane, a.t + 60000); + fnf_advance(&T, b.t + 190000); + CHECK(T.tally[J_MISS] == 0, "offset 30 ms: not missed before 190 ms"); + fnf_advance(&T, b.t + 190001); + CHECK(T.tally[J_MISS] == 1, "offset 30 ms: missed past 190 ms"); +} + +/* holds: +250 a second and +6% of the bar a second held; let go of with more than 160 ms left: -125 a second left, + the combo broken; a missed hold: its whole length lost too */ +/* the bot's presses before time t: the song up to there played on time */ +static void bot_until(unsigned c, int32_t t) { + int n = read_chart(c); + int k = bot_events(n, 0); + fnf_start(&T, c, buf, 0, false); + for (int i = 0; i < k && evs[i].t < t; i++) { + if (evs[i].down) fnf_press(&T, evs[i].lane, evs[i].t); + else fnf_release(&T, evs[i].lane, evs[i].t); + } + fnf_advance(&T, t); + read_chart(c); +} + +static void holds(void) { + for (int c = 0; c < FNF_CHARTS; c++) { + int n = read_chart((unsigned)c); + int k = -1; + for (int i = 0; i < n && k < 0; i++) + if (notes[i].lane < 4 && notes[i].len > 1000000 && notes[i].kind == 0 && notes[i].t > 3000000) k = i; + if (k < 0) continue; + note_t h = notes[k]; + /* nothing else in that lane near it */ + bool alone = true; + for (int i = 0; i < n; i++) + if (i != k && notes[i].lane == h.lane && notes[i].t > h.t - 400000 && notes[i].t < h.t + h.len + 400000) alone = false; + if (!alone) continue; + bot_until((unsigned)c, h.t - 300000); + T.health = 400000, T.combo = 12; + int64_t s0 = T.score; + fnf_press(&T, h.lane, h.t); + fnf_advance(&T, h.t + h.len); + int64_t got = T.score - s0 - 500000; + CHECK(got == h.len / 4, "a hold held: %lld thousandths for %d us, not %d", (long long)got, (int)h.len, (int)(h.len / 4)); + CHECK(T.health == 400000 + 30000 + (int32_t)((int64_t)h.len * 12 / 100) || T.health == FNF_HEALTH_MAX, + "a hold held: +0.12 a second (%d)", (int)T.health); + CHECK(T.combo == 13, "a hold held: the combo kept"); + fnf_release(&T, h.lane, h.t + h.len + 10000); + /* let go of 300 ms before its end */ + bot_until((unsigned)c, h.t - 300000); + T.combo = 12; + s0 = T.score; + fnf_press(&T, h.lane, h.t); + fnf_release(&T, h.lane, h.t + h.len - 300000); + got = T.score - s0 - 500000; + int64_t want = (h.len - 300000) / 4 - 300000 / 8; + CHECK(got == want, "a hold let go of 0.3 s early: %lld, not %lld", (long long)got, (long long)want); + CHECK(T.combo == 0, "let go of early: the combo broken"); + /* let go of 100 ms before its end: no penalty */ + bot_until((unsigned)c, h.t - 300000); + T.combo = 12; + s0 = T.score; + fnf_press(&T, h.lane, h.t); + fnf_release(&T, h.lane, h.t + h.len - 100000); + got = T.score - s0 - 500000; + CHECK(got == (h.len - 100000) / 4 && T.combo == 13, "let go of 0.1 s early: no penalty"); + /* missed altogether */ + bot_until((unsigned)c, h.t - 300000); + s0 = T.score; + int32_t hp = T.health; + fnf_advance(&T, h.t + 170000); + got = T.score - s0; + CHECK(got == -100000 - h.len / 8 && T.health == hp - 80000, "a missed hold: -100 and -125 a second of it"); + return; + } + CHECK(0, "no chart with a long hold"); +} + +static void health_limits(void) { + int c = find_chart("Fresh", FNF_VAR_DEFAULT, FNF_DIFF_HARD); + if (c < 0) return; + fnf_start(&T, (unsigned)c, buf, 0, false); + int32_t t = fnf_start_time(&T) + 1000; + for (int i = 0; i < 12 && T.over == FNF_PLAYING; i++, t += 20000) { /* ghost misses during the countdown */ + fnf_press(&T, 0, t); + fnf_release(&T, 0, t + 5000); + } + CHECK(T.over == FNF_PLAYING && T.health == 1000000 - 12 * 80000, "12 ghost misses: %d", (int)T.health); + for (int i = 0; i < 2 && T.over == FNF_PLAYING; i++, t += 20000) fnf_press(&T, 0, t), fnf_release(&T, 0, t + 5000); + CHECK(T.over == FNF_DIED && T.health == 0, "the bar emptied: game over"); + /* never past 2.0 */ + int n = read_chart((unsigned)c); + fnf_start(&T, (unsigned)c, buf, 0, false); + T.health = 1990000; + note_t a = player_note(n, 0); + fnf_press(&T, a.lane, a.t); + CHECK(T.health == FNF_HEALTH_MAX, "capped at 2.0"); +} + +static void ranks(void) { + fnf_game_t g; + memset(&g, 0, sizeof g); + g.total = 100; + g.tally[J_SICK] = 100; + CHECK(fnf_rank(&g) == 5 && fnf_completion(&g) == 10000, "all Sick: PERFECT GOLD"); + g.tally[J_SICK] = 99, g.tally[J_GOOD] = 1; + CHECK(fnf_rank(&g) == 4, "Sick and Good only: PERFECT"); + g.tally[J_SICK] = 80, g.tally[J_GOOD] = 10, g.tally[J_BAD] = 10; + CHECK(fnf_rank(&g) == 3 && fnf_completion(&g) == 9000, "90%%: EXCELLENT"); + g.tally[J_SICK] = 80, g.tally[J_GOOD] = 5, g.tally[J_MISS] = 5; + CHECK(fnf_rank(&g) == 2 && fnf_completion(&g) == 8000, "(85 - 5) %%: GREAT"); + g.tally[J_SICK] = 60, g.tally[J_GOOD] = 0, g.tally[J_MISS] = 0; + CHECK(fnf_rank(&g) == 1, "60%%: GOOD"); + g.tally[J_SICK] = 59; + CHECK(fnf_rank(&g) == 0, "59%%: LOSS"); + g.tally[J_SICK] = 0, g.tally[J_MISS] = 50; + CHECK(fnf_completion(&g) == 0, "never below 0"); +} + +/* ---------------------------------------------------------------- every chart */ +static uint32_t hash = 2166136261u; +static void mix(uint32_t v) { + for (int i = 0; i < 4; i++) hash = (hash ^ (v >> (8 * i) & 255)) * 16777619u; +} + +static void play_bot(unsigned c, int32_t late, fnf_game_t *g) { + int n = read_chart(c); + int k = bot_events(n, late); + fnf_start(g, c, buf, 0, false); + for (int i = 0; i < k && g->over == FNF_PLAYING; i++) { + if (evs[i].down) fnf_press(g, evs[i].lane, evs[i].t); + else fnf_release(g, evs[i].lane, evs[i].t); + } + while (g->over == FNF_PLAYING) fnf_advance(g, g->now + 50000); +} + +static void every_chart(void) { + int fc = 0, peak = 0; + for (unsigned c = 0; c < FNF_CHARTS; c++) { + const fnf_chart_t *ch = &fnf_chart[c]; + int n = read_chart(c); + CHECK(n == ch->notes, "chart %u: %d notes read, %d listed", c, n, ch->notes); + int total = 0; + for (int i = 0; i < n; i++) { + CHECK(!i || notes[i].t >= notes[i - 1].t, "chart %u: notes in order", c); + CHECK(notes[i].kind < FNF_NOTE_KINDS && notes[i].len >= 0, "chart %u: a note's kind and length", c); + if (notes[i].lane < 4 && notes[i].kind != FNF_KIND_NON_SCOREABLE) total++; + } + CHECK(total == ch->total, "chart %u: %d notes count, %d listed", c, total, ch->total); + CHECK(n > 0 && notes[n - 1].t + notes[n - 1].len < (int32_t)fnf_var[ch->var].length * 1000, + "chart %u: its notes within the song", c); + fnf_game_t g; + play_bot(c, 0, &g); + bool ok = g.over == FNF_WON && g.tally[J_SICK] == total && g.max_combo == total && !g.tally[J_MISS] && + fnf_rank(&g) == 5; + CHECK(ok, "chart %u (%s, %s): bot: over %d, sick %d/%d, max combo %d, misses %d", c, + fnf_song[fnf_var[ch->var].song].name, DIFF_NAME[ch->diff], g.over, g.tally[J_SICK], total, g.max_combo, + g.tally[J_MISS]); + /* the points: 500 a note, and 250 a second of the holds, all held */ + int64_t want = (int64_t)total * 500000, held = 0; + for (int i = 0; i < n; i++) + if (notes[i].lane < 4 && notes[i].kind != FNF_KIND_NON_SCOREABLE) held += notes[i].len; + want += held / 4; + CHECK(g.score == want, "chart %u: %lld points, not %lld", c, (long long)g.score, (long long)want); + if (g.peak > peak) peak = g.peak; + fc += ok; + mix((uint32_t)g.score), mix(g.max_combo), mix((uint32_t)g.health); + } + printf("every chart: %d of %d to a full combo, all Sick; at most %d notes on their way at once\n", fc, FNF_CHARTS, peak); + CHECK(peak < FNF_ACTIVE, "room for the notes on their way"); +} + +/* bots hitting late, and missing: the song goes on, the tallies add up */ +static void late_and_missing(void) { + int c = find_chart("Dadbattle", FNF_VAR_DEFAULT, FNF_DIFF_HARD); + if (c < 0) return; + fnf_game_t g; + play_bot((unsigned)c, 70000, &g); + CHECK(g.tally[J_GOOD] == g.total && g.over == FNF_WON && g.max_combo == g.total, "70 ms late: all Good"); + play_bot((unsigned)c, 120000, &g); + CHECK(g.tally[J_BAD] == g.total && g.combo == 0 && g.max_combo == 0, "120 ms late: all Bad, no combo"); + mix((uint32_t)g.score), mix((uint32_t)g.health), mix(g.over); + /* nobody playing: the bar empties */ + fnf_start(&g, (unsigned)c, buf, 0, false); + while (g.over == FNF_PLAYING) fnf_advance(&g, g.now + 100000); + CHECK(g.over == FNF_DIED, "nobody playing: game over"); +} + +/* ---------------------------------------------------------------- the app, with random keys */ +static uint32_t rng = 12345; +static uint32_t rnd(void) { + rng ^= rng << 13, rng ^= rng >> 17, rng ^= rng << 5; + return rng; +} +static uint32_t held_keys; +static uint32_t random_keys(uint32_t ms) { + (void)ms; + if (rnd() % 23 == 0) { + static const uint32_t K[] = {FK_LEFT, FK_DOWN, FK_UP, FK_RIGHT, FK_7, FK_8, FK_LPAR, FK_RPAR, FK_OK, FK_OK, FK_BACK, FK_SHIFT}; + held_keys ^= K[rnd() % (sizeof K / sizeof K[0])]; + } + return held_keys & ~FK_HOME; +} + +static void app_session(uint32_t length, uint32_t (*keys)(uint32_t)) { + clock_ms = 1000; + quit_at = clock_ms + length; + script = keys; + held_keys = 0; + fnf_app_main(); + script = 0; +} + +static void fuzz(void) { + for (int s = 0; s < 300; s++) { + rng = 0x1234567u + (uint32_t)s * 7919u; + memset(files, 0, sizeof files); + app_session(4000 + rnd() % 26000, random_keys); + uint32_t n; + uint8_t *d = file_data(FNF_SAVE_NAME, &n); + CHECK(d && n == sizeof(fnf_file_t) && fnf_file_ok((const fnf_file_t *)d), "session %d: the save is good", s); + mix(n), mix((uint32_t)G.score); + } + /* the engine on its own: random presses through random charts */ + for (int s = 0; s < 200; s++) { + unsigned c = rnd() % FNF_CHARTS; + fnf_game_t g; + fnf_start(&g, c, buf, (int32_t)(rnd() % 61) - 30, s & 1); + int32_t t = g.now; + while (g.over == FNF_PLAYING && t < g.length + 1000000) { + t += (int32_t)(rnd() % 40000); + int lane = (int)(rnd() % 4); + if (rnd() & 1) fnf_press(&g, lane, t); + else fnf_release(&g, lane, t); + CHECK(g.health >= 0 && g.health <= FNF_HEALTH_MAX && g.combo <= g.total, "chart %u: health and combo in range", c); + } + mix((uint32_t)g.score), mix(g.tally[J_SICK]), mix(g.tally[J_MISS]); + } +} + +/* ---------------------------------------------------------------- a whole session, by the keys */ +static uint32_t seq_at[64], seq_key[64]; +static int seq_n; +static uint32_t scripted(uint32_t ms) { + for (int i = 0; i < seq_n; i++) + if (ms >= seq_at[i] && ms < seq_at[i] + 80) return seq_key[i]; + return 0; +} +static void keys_at(int n, const uint32_t *at, const uint32_t *k) { + seq_n = n; + for (int i = 0; i < n; i++) seq_at[i] = at[i], seq_key[i] = k[i]; +} + +static void sessions(void) { + memset(files, 0, sizeof files); + /* the title, the menu, Freeplay, a song: the guide the first time (Back skips it), then Tutorial plays; Back + pauses, Quit game asks, Quit */ + uint32_t at[] = {1500, 2000, 2400, 2800, 3200, 3600, 9000, 9400, 9800, 10200, 10600, 11000, 11400, 11800}, + k[] = {FK_OK, FK_DOWN, FK_OK, FK_OK, FK_BACK, 0, FK_BACK, FK_DOWN, FK_DOWN, FK_DOWN, FK_DOWN, FK_DOWN, FK_OK, FK_DOWN}; + keys_at(14, at, k); + app_session(14000, scripted); + CHECK(A.view == V_QUIT || A.view == V_PAUSE, "paused, quitting: %d", A.view); + uint32_t n; + uint8_t *d = file_data(FNF_SAVE_NAME, &n); + CHECK(d && fnf_file_ok((const fnf_file_t *)d) && ((const fnf_file_t *)d)->p.guide == 1, "the guide seen, saved"); +} + +/* ---------------------------------------------------------------- saves */ +static void saves(void) { + memset(files, 0, sizeof files); + fnf_progress_t p, q; + fnf_fresh(&p); + p.downscroll = 1, p.offset = -25, p.guide = 1, p.week = 3, p.song = 5, p.list = 1; + p.week_score[2][1] = 123456; + fnf_record(&p, 7, 54321, 3, 9123); + fnf_record(&p, 7, 50000, 4, 10000); /* a better rank, a lower score: both best kept */ + CHECK(p.chart[7].score == 54321 && p.chart[7].rank == 5 && p.chart[7].completion == 10000, "best score, best rank"); + fnf_record(&p, 7, 60000, 1, 6000); + CHECK(p.chart[7].score == 60000 && p.chart[7].rank == 5, "a better score kept, the rank too"); + CHECK(save_write(&p), "written"); + CHECK(save_load(&q) && !memcmp(&p, &q, sizeof p), "read back the same"); + /* damaged: each byte changed in turn: never read, the copy instead */ + uint32_t n; + uint8_t *d = file_data(FNF_SAVE_NAME, &n); + CHECK(n == sizeof(fnf_file_t), "%u bytes", n); + for (uint32_t i = 0; i < n; i += 7) { + d[i] ^= 0x5A; + fnf_file_t f; + memcpy(&f, d, sizeof f); + CHECK(!fnf_file_ok(&f), "byte %u changed: refused", i); + CHECK(save_load(&q) && !memcmp(&p, &q, sizeof p), "byte %u changed: the copy read", i); + d = file_data(FNF_SAVE_NAME, &n); /* (put back from the copy) */ + } + /* only the copy left (installed again) */ + forget(FNF_SAVE_NAME); + CHECK(save_load(&q) && !memcmp(&p, &q, sizeof p), "from the copy alone"); + CHECK(file_data(FNF_SAVE_NAME, &n), "and put back"); + /* both damaged: a fresh start */ + uint8_t *c = file_data(FNF_COPY_NAME, &n); + c[n / 2] = c[n / 2] == 'A' ? 'B' : 'A'; + d = file_data(FNF_SAVE_NAME, &n); + d[20] ^= 1; + CHECK(!save_load(&q) && q.guide == 0 && q.chart[7].score == 0, "both damaged: a fresh start"); + /* cut short, too long, nothing */ + memset(files, 0, sizeof files); + save_write(&p); + forget(FNF_COPY_NAME); + d = file_data(FNF_SAVE_NAME, &n); + files[0].len = n - 1; + CHECK(!save_load(&q), "cut short: refused"); + memset(files, 0, sizeof files); + CHECK(!save_load(&q), "nothing: a fresh start"); + /* the storage full: the game goes on */ + storage_full = true; + CHECK(!save_write(&p), "full: not written"); + storage_full = false; +} + +/* ---------------------------------------------------------------- pictures */ +/* a player a little off the beat: each press up to 60 ms early or late (the same everywhere: xorshift) */ +static void play_human(unsigned c, fnf_game_t *g, uint8_t *b, int32_t until) { + int n = read_chart(c); + int k = 0; + uint32_t r = 2463534242u; + for (int i = 0; i < n; i++) { /* (each press and its release moved together) */ + const note_t *a = ¬es[i]; + if (a->lane >= 4) continue; + r ^= r << 13, r ^= r >> 17, r ^= r << 5; + int32_t off = (int32_t)(r % 121000) - 60000, next = INT32_MAX; + for (int j = i + 1; j < n; j++) + if (notes[j].lane == a->lane) { + next = notes[j].t; + break; + } + int32_t down = a->t + off, up = a->len > 0 ? a->t + a->len + 1000 : down + 30000; + if (up > next - 61000) up = next - 61000; + if (up <= down) up = down + 1; + evs[k] = (bot_ev){down, a->lane, 1, (uint16_t)k}; + k++; + evs[k] = (bot_ev){up, a->lane, 0, (uint16_t)k}; + k++; + } + qsort(evs, (size_t)k, sizeof evs[0], cmp_ev); + fnf_start(g, c, b, 0, false); + for (int i = 0; i < k && g->over == FNF_PLAYING && evs[i].t <= until; i++) { + if (evs[i].down) fnf_press(g, evs[i].lane, evs[i].t); + else fnf_release(g, evs[i].lane, evs[i].t); + } + while (g->over == FNF_PLAYING && g->now < until) fnf_advance(g, g->now + 50000 < until ? g->now + 50000 : until); +} + +static void shots(const char *dir) { + memset(files, 0, sizeof files); + A.p = &P, A.g = &G; + fnf_fresh(&P); + P.guide = 1; + bold_load(); + /* a real result first: Bopeebo, Hard, played a little off the beat */ + int c = find_chart("Bopeebo", FNF_VAR_DEFAULT, FNF_DIFF_HARD); + play_human((unsigned)c, &G, chart_buf, INT32_MAX); + fnf_record(&P, G.chart, fnf_score(&G), fnf_rank(&G), fnf_completion(&G)); + A.result_rank = (uint8_t)fnf_rank(&G), A.result_score = fnf_score(&G), A.result_total = G.total; + A.result_max_combo = G.max_combo, A.result_completion = fnf_completion(&G), A.result_new = 1; + memcpy(A.result_tally, G.tally, sizeof A.result_tally); + P.week_score[1][FNF_DIFF_HARD] = fnf_score(&G) * 3; + A.view = V_RESULTS; + screens_draw(&A); + shot(dir, "results"); + A.clock = 1000; + app_build_list(&A); + A.view = V_TITLE, A.clock = 1000 + 60000 / 102 * 4 + 100; + screens_draw(&A); + shot(dir, "title"); + A.view = V_MAIN, A.sel = MAIN_FREEPLAY; + screens_draw(&A); + shot(dir, "menu"); + A.view = V_STORY, P.week = 1, P.week_diff = FNF_DIFF_HARD; + screens_draw(&A); + shot(dir, "story"); + A.view = V_FREEPLAY, P.list = 0; + app_build_list(&A); + P.song = 1, P.song_diff = FNF_DIFF_HARD; + screens_draw(&A); + shot(dir, "freeplay"); + /* songs, at moments */ + static const struct { const char *song; int kind, diff; int32_t at; const char *name; } S[] = { + {"Bopeebo", FNF_VAR_DEFAULT, FNF_DIFF_HARD, 21300000, "shot"}, + {"Senpai", FNF_VAR_DEFAULT, FNF_DIFF_HARD, 30000000, "school"}, + {"Cocoa", FNF_VAR_DEFAULT, FNF_DIFF_HARD, 25000000, "mall"}, + {"Ugh", FNF_VAR_DEFAULT, FNF_DIFF_HARD, 33000000, "tankman"}, + {"Philly Nice", FNF_VAR_PICO, FNF_DIFF_HARD, 40000000, "pico"}, + {"Spookeez", FNF_VAR_DEFAULT, FNF_DIFF_NORMAL, -1000000, "countdown"}, + {"Monster", FNF_VAR_DEFAULT, FNF_DIFF_HARD, 40000000, "monster"}, + {"Blazin'", FNF_VAR_DEFAULT, FNF_DIFF_NORMAL, 30000000, "blazin"}, + }; + for (unsigned s = 0; s < sizeof S / sizeof S[0]; s++) { + c = find_chart(S[s].song, S[s].kind, S[s].diff); + if (c < 0) { + printf("no %s\n", S[s].song); + continue; + } + play_human((unsigned)c, &G, chart_buf, S[s].at); + if (!play_load(&G)) printf("%s: not enough RAM for its pictures\n", S[s].song); + A.view = V_PLAY, A.play_t0 = 0, A.clock = 100000; + screens_draw(&A); + shot(dir, S[s].name); + if (s == 0) { + A.view = V_PAUSE, A.sel = PAUSE_OPTIONS; + screens_draw(&A); + shot(dir, "pause"); + P.downscroll = 1; + A.view = V_PLAY; + screens_draw(&A); + shot(dir, "downscroll"); + P.downscroll = 0; + } + } + A.view = V_HELP, A.page = 0, A.first_run = 1, A.under = V_PLAY; + screens_draw(&A); + shot(dir, "help"); +} + +/* ---------------------------------------------------------------- a film */ +static const char *film_dir; +static int film_k; +static int32_t film_t0 = INT32_MIN; +static void film(const char *dir, unsigned c, int32_t ms) { + film_dir = dir; + memset(files, 0, sizeof files); + A.p = &P, A.g = &G; + fnf_fresh(&P); + bold_load(); + int n = read_chart(c); + int k = bot_events(n, 0); + fnf_start(&G, c, chart_buf, 0, false); + play_load(&G); + int32_t t = G.now, end = G.now + ms * 1000; + int i = 0; + A.view = V_PLAY, A.play_t0 = 0; + for (; t <= end; t += 33333) { /* 30 a second */ + while (i < k && evs[i].t <= t) { + if (evs[i].down) fnf_press(&G, evs[i].lane, evs[i].t); + else fnf_release(&G, evs[i].lane, evs[i].t); + i++; + } + fnf_advance(&G, t); + A.clock = (uint32_t)((t - fnf_start_time(&G)) / 1000) + 1; + screens_draw(&A); + if (film_t0 == INT32_MIN) film_t0 = t; + char path[512]; + snprintf(path, sizeof path, "%s/%06d_%06d.raw", film_dir, film_k++, (int)((t - film_t0) / 1000)); + FILE *f = fopen(path, "wb"); + if (!f) exit(2); + fwrite(fb, 2, 320 * 240, f); + fclose(f); + } +} + +/* every variation, 25 s in, a bot playing (to look at) */ +static void scenes(const char *dir) { + A.p = &P, A.g = &G; + fnf_fresh(&P); + bold_load(); + for (int v = 0; v < FNF_VARS; v++) { + unsigned c = fnf_var[v].chart + fnf_var[v].ncharts - 1; + int n = read_chart(c); + int k = bot_events(n, 0); + fnf_start(&G, c, chart_buf, 0, false); + play_load(&G); + int32_t at = 25000000; + for (int i = 0; i < k && evs[i].t <= at; i++) { + if (evs[i].down) fnf_press(&G, evs[i].lane, evs[i].t); + else fnf_release(&G, evs[i].lane, evs[i].t); + } + fnf_advance(&G, at); + A.view = V_PLAY, A.play_t0 = 0, A.clock = 100000; + screens_draw(&A); + char name[64]; + snprintf(name, sizeof name, "v%02d", v); + shot(dir, name); + } +} + +int main(int argc, char **argv) { + chart_buf = app_chart_buf; + if (argc >= 3 && !strcmp(argv[1], "notes")) { /* a chart's notes (us): time lane length */ + unsigned c = (unsigned)atoi(argv[2]); + int n = read_chart(c); + fnf_start(&T, c, buf, 0, false); + printf("start %d total %d\n", (int)T.now, fnf_chart[c].total); + for (int i = 0; i < n; i++) + if (notes[i].lane < 4) printf("%d %d %d %d\n", (int)notes[i].t, notes[i].lane, (int)notes[i].len, notes[i].kind); + return 0; + } + if (argc >= 3 && !strcmp(argv[1], "scenes")) { + draw_strip_buffer(test_strip); + scenes(argv[2]); + return 0; + } + draw_strip_buffer(test_strip); + if (argc >= 3 && !strcmp(argv[1], "shots")) { + shots(argv[2]); + return 0; + } + if (argc >= 5 && !strcmp(argv[1], "film")) { + film(argv[2], (unsigned)atoi(argv[3]), atoi(argv[4])); + return 0; + } + if (argc < 3 || strcmp(argv[1], "test")) { + printf("play test DIR | shots DIR | film DIR CHART MS\n"); + return 1; + } + judgements_and_points(); + scoring(); + window_edges(); + holds(); + health_limits(); + ranks(); + every_chart(); + late_and_missing(); + saves(); + sessions(); + fuzz(); + printf("hash %08x (the same on every computer)\n", hash); + printf(fails ? "%d FAILED\n" : "all good\n", fails); + return fails != 0; +} diff --git a/games/fnf/tools/atlas.py b/games/fnf/tools/atlas.py new file mode 100644 index 00000000..e7ba8baf --- /dev/null +++ b/games/fnf/tools/atlas.py @@ -0,0 +1,261 @@ +"""The game's pictures, read from its own files (Friday Night Funkin' keeps them in three formats): Sparrow sheets +(a PNG and an XML of named frames), packer sheets (a PNG and a text list) and Adobe Animate texture atlases +(Animation.json, spritemap*.json and their PNGs: symbols made of placed pieces). A frame comes back as a list of +leaves, (piece, 2x3 matrix, alpha), that flatten() draws into one image. Nothing in the reference is run. + +After Bobydeluxe's reader for the first port. Used by tools/data.py.""" +import json +import math +import re +from pathlib import Path + +from PIL import Image, ImageChops + +IDENTITY = (1, 0, 0, 1, 0, 0) +opened = set() # every file read, for tools/ref.txt + + +def read_json(path): + opened.add(Path(path)) + return json.loads(Path(path).read_text(encoding="utf-8-sig")) + + +def open_image(path): + opened.add(Path(path)) + return Image.open(path).convert("RGBA") + + +def matrix(d): + if "MX" in d: + return tuple(d["MX"]) + if "M3D" in d: + a = d["M3D"] + return (a[0], a[1], a[4], a[5], a[12], a[13]) + return IDENTITY + + +def multiply(a, b): + return (a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1], a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3], + a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5]) + + +def point(m, x, y): + return (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]) + + +def bounds(leaves): + """The box round the visible pixels of a frame, in its own units.""" + pts = [] + for im, m, a in leaves: + b = im.getbbox() + if b and a > 0: + pts.extend(point(m, x, y) for x, y in ((b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3]))) + if not pts: + return (0, 0, 1, 1) + return (min(p[0] for p in pts), min(p[1] for p in pts), max(p[0] for p in pts), max(p[1] for p in pts)) + + +def flatten(leaves, scale=1.0, box=None, resample=Image.Resampling.BICUBIC): + """Draws the leaves scaled into one RGBA image; returns it and where its top left falls (in scaled units).""" + box = box or bounds(leaves) + x0, y0 = math.floor(box[0] * scale), math.floor(box[1] * scale) + x1, y1 = math.ceil(box[2] * scale), math.ceil(box[3] * scale) + out = Image.new("RGBA", (max(1, x1 - x0), max(1, y1 - y0))) + for im, m, alpha in leaves: + a, b, c, d, tx, ty = m + a, b, c, d = a * scale, b * scale, c * scale, d * scale + tx, ty = tx * scale - x0, ty * scale - y0 + det = a * d - b * c + if abs(det) < 1e-8: + continue + inv = (d / det, -c / det, (c * ty - d * tx) / det, -b / det, a / det, (b * tx - a * ty) / det) + src = im + # shrinking a lot: shrink the piece first, so that the affine sampling doesn't alias + shrink = min(math.hypot(a, b), math.hypot(c, d)) + if shrink < 0.5 and resample != Image.Resampling.NEAREST: + f = max(1, int(1 / shrink / 2)) + if f > 1 and im.width >= 2 * f and im.height >= 2 * f: + src = im.resize((max(1, im.width // f), max(1, im.height // f)), Image.Resampling.BOX) + sx, sy = im.width / src.width, im.height / src.height + inv = (inv[0] / sx, inv[1] / sx, inv[2] / sx, inv[3] / sy, inv[4] / sy, inv[5] / sy) + layer = src.transform(out.size, Image.Transform.AFFINE, inv, resample) + if alpha < 0.999: + layer.putalpha(layer.getchannel("A").point(lambda v: round(v * alpha))) + out.alpha_composite(layer) + return out, x0, y0 + + +class Atlas: + """An Adobe Animate texture atlas (a folder with Animation.json).""" + + def __init__(self, path): + self.path = Path(path) + self.d = read_json(self.path / "Animation.json") + self.symbols = {x["SN"]: x["TL"] for x in self.d.get("SD", {}).get("S", [])} + self.textures, self.labels = {}, {} + for p in sorted(self.path.glob("spritemap*.json")): + im = open_image(p.with_suffix(".png")) + for item in read_json(p)["ATLAS"]["SPRITES"]: + s = item["SPRITE"] + piece = im.crop((s["x"], s["y"], s["x"] + s["w"], s["y"] + s["h"])) + if s.get("rotated"): + piece = piece.transpose(Image.Transpose.ROTATE_90) + self.textures[str(s["name"])] = piece + for layer in self.d["AN"]["TL"]["L"]: + for f in layer["FR"]: + if f.get("N"): + self.labels[f["N"]] = (f["I"], f["DU"]) + + @staticmethod + def duration(tl): + return max((f["I"] + f["DU"] for l in tl["L"] for f in l["FR"]), default=1) + + def sequence(self, prefix, symbol=False): + """(timeline, first frame, frame count) of an animation, by frame label or by symbol name.""" + def match(keys): + exact = next((k for k in keys if k.lower() == prefix.lower()), None) + return exact or next((k for k in keys if k.lower().startswith(prefix.lower())), None) + if symbol: + k = match(self.symbols) + if k: + return self.symbols[k], 0, self.duration(self.symbols[k]) + if self.d["AN"].get("SN", "").lower().startswith(prefix.lower()): + return self.d["AN"]["TL"], 0, self.duration(self.d["AN"]["TL"]) + k = match(self.labels) + if k: + return (self.d["AN"]["TL"], *self.labels[k]) + k = match(self.symbols) + if k: + return self.symbols[k], 0, self.duration(self.symbols[k]) + raise KeyError((self.path.name, prefix)) + + def walk(self, tl, frame, transform=IDENTITY, alpha=1.0, depth=0): + if depth > 40: + raise ValueError("cyclic symbols") + result = [] + for l in reversed(tl["L"]): + if l.get("LT") == "Clp": + continue + f = next((f for f in l["FR"] if f["I"] <= frame < f["I"] + f["DU"]), None) + if not f: + continue + leaves = [] + for e in f.get("E", []): + if "ASI" in e: + d = e["ASI"] + im = self.textures.get(str(d["N"])) + if im is not None: + leaves.append((im, multiply(transform, matrix(d)), alpha)) + elif "SI" in e: + d = e["SI"] + child = self.symbols.get(d["SN"]) + if child is None: + continue + t, loop = d.get("FF", 0), d.get("LP", "LP") + if loop != "SF": + t += frame - f["I"] + length = self.duration(child) + t = t % length if loop == "LP" else min(t, length - 1) + a = alpha * d.get("C", {}).get("AM", 1) + leaves.extend(self.walk(child, t, multiply(transform, matrix(d)), a, depth + 1)) + if l.get("Clpb") and leaves: # a clipping mask over this layer + mask = next((x for x in tl["L"] if x.get("LN") == l["Clpb"]), None) + if mask: + masks = self.walk({"L": [{"LN": mask["LN"], "FR": mask["FR"]}]}, frame, transform, alpha, depth + 1) + box = bounds(leaves) + im, x, y = flatten(leaves, box=box) + matte, _, _ = flatten(masks, box=box) + im.putalpha(ImageChops.multiply(im.getchannel("A"), matte.getchannel("A"))) + b = im.getbbox() + leaves = [(im.crop(b), (1, 0, 0, 1, x + b[0], y + b[1]), 1)] if b else [] + result.extend(leaves) + return result + + def frames(self, animation, picks): + """The frames at the given indices of an animation (indices into its frameIndices, if it has any).""" + tl, start, duration = self.sequence(animation["prefix"], animation.get("animType") == "symbol") + ids = animation.get("frameIndices") or list(range(duration)) + return [self.walk(tl, start + ids[min(i, len(ids) - 1)]) for i in picks], len(ids) + + +class Reference: + """The game's assets folder (funkin.assets).""" + + def __init__(self, root): + self.root = Path(root) + self.atlases, self.sparrows = {}, {} + self.boxes = [] # the untrimmed boxes of the Sparrow frames frames() returned last + + def json(self, rel): + return read_json(self.root / rel) + + def path(self, name, library="shared"): + if ":" in name: + library, name = name.split(":", 1) + choices = [self.root / lib / "images" / name for lib in dict.fromkeys([library, "shared", "preload"])] + return next((p for p in choices if p.is_dir() or p.with_suffix(".png").exists()), choices[0]) + + def atlas(self, path): + path = Path(path) + if path not in self.atlases: + self.atlases[path] = Atlas(path) + return self.atlases[path] + + def sparrow(self, path): + path = Path(path) + if path not in self.sparrows: + opened.add(path.with_suffix(".xml")) + text = path.with_suffix(".xml").read_text(encoding="utf-8-sig") + im = open_image(path.with_suffix(".png")) + frames = [] + # (read with a pattern, not an XML parser: some names have a bare < or & in them) + for tag in re.findall(r"", text, re.S): + d = dict(re.findall(r'(\w+)="(.*?)"', tag)) + x, y, w, h = (int(d[k]) for k in ("x", "y", "width", "height")) + piece = im.crop((x, y, x + w, y + h)) + if d.get("rotated") == "true": + piece = piece.transpose(Image.Transpose.ROTATE_90) + fw, fh = int(d.get("frameWidth", w)), int(d.get("frameHeight", h)) + name = d["name"].replace("&", "&").replace("<", "<").replace(">", ">").replace(""", '"') + frames.append((name, piece, (1, 0, 0, 1, -int(d.get("frameX", 0)), -int(d.get("frameY", 0))), fw, fh)) + self.sparrows[path] = frames + return self.sparrows[path] + + def frames(self, path, animation, picks=(0,)): + """The frames at indices `picks` of an animation (a dict with prefix, and maybe frameIndices, animType), as + leaves; and the animation's length in frames. (Its offsets are the caller's.)""" + path = Path(path) + if path.with_suffix(".xml").exists(): + frames = [f for f in self.sparrow(path) if f[0].startswith(animation.get("prefix", ""))] + if not frames: + raise KeyError((str(path), animation.get("prefix"))) + ids = animation.get("frameIndices") or list(range(len(frames))) + out, self.boxes = [], [] + for i in picks: + _, im, m, fw, fh = frames[ids[min(i, len(ids) - 1)] % len(frames)] + out.append([(im, m, 1)]) + self.boxes.append((0, 0, fw, fh)) + return out, len(ids) + if path.with_suffix(".txt").exists(): + opened.add(path.with_suffix(".txt")) + im = open_image(path.with_suffix(".png")) + items = [] + for line in path.with_suffix(".txt").read_text().splitlines(): + m = re.match(r"(.+?)\s*=\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)", line) + if m and m[1].startswith(animation.get("prefix", "")): + x, y, w, h = map(int, m.groups()[1:]) + items.append((m[1], im.crop((x, y, x + w, y + h)))) + if not items: + raise KeyError((str(path), animation.get("prefix"))) + items.sort(key=lambda it: it[0]) + ids = animation.get("frameIndices") or list(range(len(items))) + return [[(items[ids[min(i, len(ids) - 1)] % len(items)][1], IDENTITY, 1)] for i in picks], len(ids) + if path.is_dir(): + for p in sorted(path.rglob("Animation.json")): + try: + return self.atlas(p.parent).frames(animation, picks) + except KeyError: + pass + raise KeyError((str(path), animation.get("prefix"))) + im = open_image(path.with_suffix(".png")) + return [[(im, IDENTITY, 1)] for _ in picks], 1 diff --git a/games/fnf/tools/data.py b/games/fnf/tools/data.py new file mode 100644 index 00000000..6b50d9e7 --- /dev/null +++ b/games/fnf/tools/data.py @@ -0,0 +1,1020 @@ +#!/usr/bin/env python3 +"""Friday Night Funkin' for the calculator, from the game's own files: its charts, characters, stages, notes and +menus, packed into C tables (data.c and ids.h in OUT). The pictures and charts are the Funkin' Crew's and are not +in this repository: tools/fetch.py brings the files this reads (tools/ref.txt) from their funkin.assets repository. + + python3 -I tools/data.py REF OUT REF: funkin.assets at the pinned commit (tools/fetch.py) + [--list FILE] also write the files read, with their git blob hashes (tools/ref.txt) + +Pictures are drawn at the calculator's scale, 16 colours each (one palette per character), 4 bits a pixel, and +each is DEFLATE-packed (zopfli when it's installed). The charts keep every note: times in microseconds, deltas in +LEB128, each chart DEFLATE-packed. Nothing in REF is run. Needs Python 3 and Pillow.""" +import argparse +import hashlib +import json +import math +import os +import re +import struct +import sys +import zlib +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import atlas # noqa: E402 +from atlas import Reference, bounds, flatten, multiply # noqa: E402 + +import numpy as np # noqa: E402 +from PIL import Image # noqa: E402 + +try: + import zopfli.zlib as _zopfli +except ImportError: + _zopfli = None + +# ------------------------------------------------------------------------------------------------- what's played +LEVELS = ["tutorial", "week1", "week2", "week3", "week4", "week5", "week6", "week7", "weekend1", "sserafim"] +VARIATIONS = ["default", "erect", "pico", "bf"] +DIFFICULTIES = ["easy", "normal", "hard", "erect", "nightmare"] +# the instrumental's length (ms), measured from the game's own audio files (songs//Inst[-variation].ogg): +# the song ends with it, after its last note +INST_MS = { + "2hot": 120000, "blammed": 106970, "blammed-erect": 107294, "blammed-pico": 106909, "blazin": 122667, + "bopeebo": 81600, "bopeebo-erect": 113659, "bopeebo-pico": 84000, "cocoa": 112800, "cocoa-erect": 115862, + "cocoa-pico": 122688, "dadbattle": 89361, "dadbattle-erect": 133263, "dadbattle-pico": 89361, + "darnell": 126968, "darnell-bf": 126968, "darnell-erect": 149635, "eggnog": 95200, "eggnog-erect": 130282, + "eggnog-pico": 96031, "fresh": 84000, "fresh-erect": 78720, "fresh-pico": 84099, "guns": 140473, + "guns-pico": 140432, "high": 105665, "high-erect": 105600, "lit-up": 114546, "lit-up-bf": 135000, + "milf": 124736, "monster": 177679, "philly-nice": 98058, "philly-nice-erect": 115200, + "philly-nice-pico": 102857, "pico": 84871, "pico-erect": 109630, "pico-pico": 86400, "roses": 92000, + "roses-erect": 99375, "roses-pico": 96005, "satin-panties": 96080, "satin-panties-erect": 113778, + "senpai": 98292, "senpai-erect": 116952, "senpai-pico": 102869, "south": 88792, "south-erect": 118644, + "south-pico": 90182, "spaghetti": 173587, "spookeez": 99180, "spookeez-erect": 141687, "spookeez-pico": 97600, + "stress": 124009, "stress-pico": 124045, "thorns": 103579, "thorns-erect": 113684, "tutorial": 67200, + "ugh": 86617, "ugh-erect": 83294, "ugh-pico": 85875, "winter-horrorland": 132830, +} +EVENTS = ["SetCameraBop", "PlayAnimation", "ScrollSpeed", "SetHealthIcon", "sserafimSing", "sserafimShow", + "sserafimDark"] +EASES = ["linear", "INSTANT", "quad", "cube", "quart", "quint", "sine", "expo", "circ", "back", "elastic", "bounce", + "smoothStep", "smootherStep"] +EASE_DIRS = ["In", "Out", "InOut"] + +# ------------------------------------------------------------------------------------------------- the screen +SCREEN_W, SCREEN_H = 320, 240 +HUD = 0.25 # 1280x720 -> 320 wide +CHAR_SCALE = 0.17 # characters: world units -> pixels +# the frames kept of each animation (sampled over it, each shown when the original shows it) +DANCE_FRAMES, SING_FRAMES, MISS_FRAMES, OTHER_FRAMES = 4, 2, 2, 2 +ALPHA_CUT = 110 # below: transparent + + +def die(msg): + sys.exit(f"data.py: {msg}") + + +# ------------------------------------------------------------------------------------------------- packing +def deflate(raw): + if _zopfli: + z = _zopfli.compress(raw, numiterations=15) + return z[2:-4] # (raw DEFLATE: no zlib header or checksum) + return zlib.compress(raw, 9, wbits=-15) + + +class Blob: + def __init__(self): + self.data = bytearray() + self.seen = {} + + def add(self, b): + b = bytes(b) + if b in self.seen: + return self.seen[b] + at = len(self.data) + self.data += b + self.seen[b] = at + return at + + +def rgb565(r, g, b): + return (r >> 3) << 11 | (g >> 2) << 5 | b >> 3 + + +class Pictures: + """Every picture: 4-bit pixels (0 transparent), a palette of 15 colours, DEFLATE-packed.""" + + def __init__(self, blob): + self.blob = blob + self.palettes = [] + self.sprites = [] # (w, h, x, y, palette, at, size) + self.bytes = {} + + def palette(self, colors, opaque=False): + """A palette: 15 colours after the transparent 0, or (opaque) 16 colours.""" + p = ([] if opaque else [0]) + [rgb565(*c) for c in colors][:16 if opaque else 15] + p += [0] * (16 - len(p)) + if p not in self.palettes: + self.palettes.append(p) + return self.palettes.index(p) + + def add(self, idx, w, h, x, y, pal, group, opaque=False): + """idx: bytes, one palette index per pixel (0 transparent, unless opaque).""" + row = (w + 1) // 2 + raw = bytearray(row * h) + for j in range(h): + line = idx[j * w:(j + 1) * w] + for i in range(0, w, 2): + a = line[i] + b = line[i + 1] if i + 1 < w else 0 + raw[j * row + i // 2] = a | b << 4 + z = deflate(bytes(raw)) + at = self.blob.add(z) + self.bytes[group] = self.bytes.get(group, 0) + len(z) + key = (w, h, x, y, pal, at, len(z)) + self.sprites.append(key) + return len(self.sprites) - 1 + + +def quantize(images, colors=15, transparent=True): + """One palette for all the images: (palette [(r,g,b)], [index bytes per image]). Index 0 is transparent + (alpha below ALPHA_CUT) when transparent, else the 16 colours are all used.""" + n = colors if transparent else 16 + pixels = [] + for im in images: + a = im.getchannel("A").tobytes() + rgb = im.convert("RGB").tobytes() + pixels += [rgb[i * 3:i * 3 + 3] for i in range(im.width * im.height) if not transparent or a[i] >= ALPHA_CUT] + if not pixels: + pixels = [b"\0\0\0"] + strip = Image.frombytes("RGB", (len(pixels), 1), b"".join(pixels)) + q = strip.quantize(colors=n, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE) + pal = q.getpalette()[:n * 3] + pal += [0] * (n * 3 - len(pal)) + colours = [tuple(pal[i * 3:i * 3 + 3]) for i in range(n)] + lut = np.array(colours, dtype=np.int32) + out = [] + for im in images: + rgb = np.asarray(im.convert("RGB"), dtype=np.int32).reshape(-1, 3) + d = ((rgb[:, None, :] - lut[None, :, :]) ** 2).sum(axis=2) + q = d.argmin(axis=1).astype(np.uint8) # (argmin: the first of equals) + if transparent: + a = np.asarray(im.getchannel("A")).reshape(-1) + q = np.where(a >= ALPHA_CUT, q + 1, 0).astype(np.uint8) + out.append(q.tobytes()) + return colours, out + + +# ------------------------------------------------------------------------------------------------- charts +def leb(v): + assert v >= 0 + out = bytearray() + while v >= 128: + out.append(v & 127 | 128) + v >>= 7 + out.append(v) + return out + + +def us(ms): + return int(round(float(ms) * 1000)) + + +class Songs: + def __init__(self, ref): + self.ref = ref + self.levels = [dict(id=l, **ref.json(f"preload/data/levels/{l}.json")) for l in LEVELS] + self.order = [s for l in self.levels for s in l["songs"]] + self.songs = [] + for song in self.order: + variations = [] + for v in VARIATIONS: + suffix = "" if v == "default" else "-" + v + mp = ref.root / f"preload/data/songs/{song}/{song}-metadata{suffix}.json" + if not mp.exists(): + continue + meta = ref.json(mp.relative_to(ref.root)) + chart = ref.json(f"preload/data/songs/{song}/{song}-chart{suffix}.json") + variations.append(dict(kind=v, meta=meta, chart=chart)) + self.songs.append(dict(id=song, variations=variations, + level=next(i for i, l in enumerate(self.levels) if song in l["songs"]))) + + def characters(self): + names = set() + for s in self.songs: + for v in s["variations"]: + c = v["meta"]["playData"]["characters"] + for k in ("player", "opponent", "girlfriend"): + if c.get(k): + names.add(c[k]) + names |= set(SSERAFIM_EXTRA) + return sorted(names) + + def stages(self): + return sorted({v["meta"]["playData"]["stage"] for s in self.songs for v in s["variations"]}) + + +OWNED = {} # the characters Boyfriend's and Pico's freeplay lists are for (data/players) +SSERAFIM_EXTRA = ["sserafim-yunjin", "sserafim-chaewon", "sserafim-eunchae"] +SSERAFIM_FEET = [(-621, 154), (687, 98), (770, 675)] + +# note kinds: the animation each one plays instead of sing (as the characters' own scripts do) +WEEKEND = {"blockhigh": "block", "blocklow": "block", "blockspin": "block", "cockgun": "cock", "dodgehigh": "dodge", + "dodgelow": "duck", "fakeout": "fakeout", "firegun": "shoot", "hithigh": "hitHigh", "hitlow": "hitLow", + "idle": "idle", "kickcan": "kickCan", "kneecan": "kneeCan", "lightcan": "lightCan", + "picouppercut": "uppercut", "picouppercutprep": "uppercutPrep", "darnelluppercut": "uppercut", + "darnelluppercutprep": "uppercutPrep", "punchhigh": "punchHigh1", "punchhighblocked": "punchHigh2", + "punchhighspin": "punchHigh2", "punchlow": "punchLow1", "punchlowblocked": "punchLow2", + "punchlowdodged": "punchLow2", "punchlowspin": "punchLow2", "taunt": "taunt"} +DIRS = ["LEFT", "DOWN", "UP", "RIGHT"] + + +def kind_anims(kind): + """The four animation names a note kind plays (None: the plain sing; "": none at all).""" + if kind in ("", "non_scoreable"): + return None + if kind == "noanim": + return [""] * 4 + if kind == "mom": + return [f"sing{d}-alt" for d in DIRS] + if kind.startswith("sakura-"): + return [f"sing{d}-{kind[7:]}" for d in DIRS] + if kind == "censor": + return [f"sing{d}-censor" for d in DIRS] + if kind in ("ugh", "hehPrettyGood"): + return [kind] * 4 + if kind.startswith("weekend-1-"): + return [WEEKEND.get(kind[10:], f"sing{d}") for d in DIRS] + return None + + +# ------------------------------------------------------------------------------------------------- characters +class Characters: + def __init__(self, ref, pics, names, wanted_anims, stage_scale): + self.ref, self.pics = ref, pics + self.rows, self.anims, self.anim_names, self.kinds = [], [], [], [] + self.index = {n: i for i, n in enumerate(names)} + self.icons, self.icon_rows = {}, [] + self.preview = {} + self.name_ids = {} + self.stage_scale = stage_scale + self.world = {} + for n in names: + self.convert(n, wanted_anims.get(n, set())) + + def name_id(self, name): + if name not in self.name_ids: + self.name_ids[name] = len(self.name_ids) + return self.name_ids[name] + + def icon(self, ident): + """A health icon (its frames: normal, losing, winning), by its id.""" + if ident in self.icons: + return self.icons[ident] + path = self.ref.root / f"preload/images/icons/icon-{ident}.png" + if not path.exists(): + ident = "face" + path = self.ref.root / "preload/images/icons/icon-face.png" + if ident in self.icons: + return self.icons[ident] + im = atlas.open_image(path) + size = im.height + pixel = size < 50 + frames = [] + n = max(1, min(3, im.width // size)) + target = round(150 * HUD) + for i in range(n): + f = im.crop((i * size, 0, (i + 1) * size, size)) + f = f.resize((target, target), Image.Resampling.NEAREST if pixel else Image.Resampling.LANCZOS) + frames.append(f) + colours, idx = quantize(frames) + pal = self.pics.palette(colours) + ids = [] + for f, ix in zip(frames, idx): + b = f.getbbox() or (0, 0, 1, 1) + ids.append(self.pics.add(crop_idx(ix, target, target, b), b[2] - b[0], b[3] - b[1], b[0] - target // 2, + b[1] - target // 2, pal, "icons")) + while len(ids) < 3: + ids.append(ids[0]) + self.icons[ident] = len(self.icon_rows) + self.icon_rows.append(ids) + return self.icons[ident] + + def convert(self, name, wanted): + cfg = self.ref.json(f"preload/data/characters/{name}.json") + scale = float(cfg.get("scale", 1)) * self.stage_scale.get(name, 1.0) + flip = bool(cfg.get("flipX", False)) + base = self.ref.path(cfg["assetPath"]) + anims = {a["name"]: a for a in cfg["animations"]} + alternate = "danceLeft" in anims + keep = (["danceLeft", "danceRight"] if alternate else ["idle"]) + [f"sing{d}" for d in DIRS] + \ + [f"sing{d}miss" for d in DIRS] + keep += sorted(a for a in wanted if a in anims and a not in keep) + picked = {} + for a in keep: + if a not in anims: + continue + spec = anims[a] + path = self.ref.path(spec["assetPath"]) if spec.get("assetPath") else base + count = DANCE_FRAMES if a in ("idle", "danceLeft", "danceRight") else \ + MISS_FRAMES if a.endswith("miss") else SING_FRAMES if a.startswith("sing") else OTHER_FRAMES + try: + _, length = self.ref.frames(path, spec, [0]) + except (KeyError, FileNotFoundError) as e: + print(f" {name}: no {a} ({e})", file=sys.stderr) + continue + count = min(count, length) + picks = sorted({round(i * (length - 1) / max(1, count - 1)) for i in range(count)}) if count > 1 else [0] + leaves, _ = self.ref.frames(path, spec, picks) + ox, oy = spec.get("offsets", [0, 0]) + picked[a] = (picks, leaves, length, (ox, oy), spec.get("frameRate", 24), bool(spec.get("looped", False))) + if not picked: + die(f"{name}: no animation") + idle = "danceLeft" if alternate else "idle" + if idle not in picked: + idle = next(iter(picked)) + box = bounds(picked[idle][1][0]) + cx, foot = (box[0] + box[2]) / 2, box[3] + # the animations' offsets, from the idle one's: the idle frame stands on the anchor (its feet), the others + # where the game puts them from there (the character's own global offsets only move them all together) + iox, ioy = picked[idle][3] + k = scale * CHAR_SCALE + images = [] + for a, (picks, leaves, length, (ox, oy), fps, loop) in picked.items(): + for lv in leaves: + if flip: # mirrored round the idle frame's middle; the offsets are not + lv = [(im, multiply((-1, 0, 0, 1, 2 * cx, 0), m), al) for im, m, al in lv] + lv = [(im, multiply((1, 0, 0, 1, -(ox - iox), -(oy - ioy)), m), al) for im, m, al in lv] + pixel = cfg.get("isPixel", False) + im, x, y = flatten(lv, k, resample=Image.Resampling.NEAREST if pixel else Image.Resampling.BICUBIC) + b = im.getbbox() or (0, 0, 1, 1) + images.append((a, im.crop(b), x + b[0] - round(cx * k), y + b[1] - round(foot * k))) + colours, idx = quantize([im for _, im, _, _ in images]) + pal = self.pics.palette(colours) + first = len(self.anims) + self.preview[name] = images[0][1:] + slots = {} + it = iter(zip(images, idx)) + for a, (picks, leaves, length, offsets, fps, loop) in picked.items(): + sprite0 = None + for _ in picks: + (_, im, x, y), ix = next(it) + s = self.pics.add(ix, im.width, im.height, x, y, pal, "characters") + sprite0 = s if sprite0 is None else sprite0 + slots[a] = len(self.anims) - first + flags = int(loop) | (a.startswith("sing") and not a.endswith("-end")) << 1 | a.endswith("miss") << 2 | \ + (a.startswith("dance") or a.startswith("idle")) << 3 + self.anims.append([sprite0, len(picks), min(255, length), int(fps), flags, picks + [0] * (6 - len(picks))]) + self.anim_names.append(self.name_id(a)) + slot_names = ["danceLeft" if alternate else "idle", "danceRight" if alternate else "idle"] + \ + [f"sing{d}" for d in DIRS] + [f"sing{d}miss" for d in DIRS] + slot = [slots.get(s, 255) for s in slot_names] + hi = cfg.get("healthIcon") or {} + icon = self.icon(hi.get("id", name.split("-")[0] if not (self.ref.root / f"preload/images/icons/icon-{name}.png").exists() else name)) + dance = float(cfg.get("danceEvery", 1)) + sing = float(cfg.get("singTime", 8)) + owner = 4 if name in OWNED["bf"] else 8 if name in OWNED["pico"] else 0 + owner |= 16 if hi.get("flipX", False) else 0 # (its health icon faces the other way) + self.rows.append(dict(name=name, owner=owner, anim=first, count=len(self.anims) - first, dance=round(dance * 4), + sing=min(255, round(sing)), alternate=alternate, slot=slot, icon=icon, + pixel=bool(cfg.get("isPixel", False)))) + self.world[name] = (box[2] - box[0]) * scale, (box[3] - box[1]) * scale + print(f" {name}: {len(picked)} animations, {len(images)} frames, {self.pics.bytes.get('characters', 0)} bytes so far", + file=sys.stderr) + + def kind_rows(self, kinds): + """(character, kind, four animations) for each note kind a character has its own animations for.""" + for ci, row in enumerate(self.rows): + names = {self.anim_names[row["anim"] + i]: i for i in range(row["count"])} + inv = {v: k for k, v in self.name_ids.items()} + have = {inv[n]: i for n, i in names.items()} + for ki, kind in enumerate(kinds): + want = kind_anims(kind) + if want is None: + continue + anims = [254 if w == "" else have.get(w, 255) for w in want] + if all(a == 255 for a in anims): + continue + self.kinds.append((ci, ki, anims)) + + +def crop_idx(idx, w, h, b): + x0, y0, x1, y1 = b + return bytes(idx[y * w + x] for y in range(y0, y1) for x in range(x0, x1)) + + +# ------------------------------------------------------------------------------------------------- stages +class Stages: + def __init__(self, ref, pics, names, chars, cast): + self.ref, self.pics, self.rows = ref, pics, [] + for n in names: + self.convert(n, chars, cast.get(n, [])) + + def convert(self, name, chars, cast): + """cast: the (player, opponent) pairs of the songs on this stage.""" + cfg = self.ref.json(f"preload/data/stages/{name}.json") + roles = cfg.get("characters", {}) + pos = {r: roles.get(r, {}).get("position", d) for r, d in (("bf", [990, 885]), ("dad", [335, 885]), + ("gf", [750, 787]))} + z = {r: roles.get(r, {}).get("zIndex", d) for r, d in (("bf", 300), ("dad", 200), ("gf", 100))} + # the view: both singers whole, their feet low on the screen + xs = [] + for pair in cast: + for who, role in zip(pair, ("bf", "dad")): + if who in chars.world: + w, _ = chars.world[who] + xs += [pos[role][0] - w / 2, pos[role][0] + w / 2] + if not xs: + xs = [pos["dad"][0] - 200, pos["bf"][0] + 200] + if name == "sserafim": + xs += [x - 150 for x, _ in SSERAFIM_FEET] + [x + 150 for x, _ in SSERAFIM_FEET] + span = max(1440, (max(xs) - min(xs)) * 1.12) + s = SCREEN_W / span + left = (max(xs) + min(xs)) / 2 - span / 2 + top = max(pos["bf"][1], pos["dad"][1]) - 205 / s + view = (left, top, left + span, top + SCREEN_H / s) + order = sorted(z, key=lambda r: (z[r], r)) + layers = [Image.new("RGBA", (SCREEN_W, SCREEN_H), (0, 0, 0, 0)) for _ in range(4)] + for prop in sorted(cfg.get("props", []), key=lambda p: p.get("zIndex", 0)): + path = prop.get("assetPath", "") + if not path or prop.get("alpha", 1) == 0: + continue + if name == "sserafim" and prop.get("name") in ("backTablesCutscene", "burgerCutscene", "truckDoor"): + continue # (the intro's) + if prop.get("name") == "additionalLighten": + continue # (a flash the stage's script turns on) + depth = sum(prop.get("zIndex", 0) > z[r] for r in order) + if path.startswith("#"): # a box of colour, scale = its size + c = tuple(int(path[i:i + 2], 16) for i in (1, 3, 5)) + w, h = prop.get("scale", [1, 1]) + fx, fy = prop.get("scroll", [1, 1]) + px, py = prop.get("position", [0, 0]) + cxm, cym = (view[0] + view[2]) / 2, (view[1] + view[3]) / 2 + px -= (cxm - 640) * (fx - 1) + py -= (cym - 360) * (fy - 1) + x0, y0 = round((px - view[0]) * s), round((py - view[1]) * s) + x1, y1 = round((px + w - view[0]) * s), round((py + h - view[1]) * s) + box = Image.new("RGBA", (SCREEN_W, SCREEN_H), (0, 0, 0, 0)) + box.paste((*c, round(255 * prop.get("alpha", 1))), (max(0, x0), max(0, y0), min(SCREEN_W, x1), min(SCREEN_H, y1))) + layers[depth].alpha_composite(box) + continue + try: + anim = (prop.get("animations") or [dict(prefix="")])[0] + start = prop.get("startingAnimation") + if start: + anim = next((a for a in prop.get("animations", []) if a["name"] == start), anim) + leaves = self.ref.frames(self.ref.path(path, cfg.get("directory", "shared")), anim, [0])[0][0] + except (FileNotFoundError, KeyError) as e: + print(f" {name}: prop {path} ({e})", file=sys.stderr) + continue + sc = prop.get("scale", [1, 1]) + sc = [sc, sc] if isinstance(sc, (int, float)) else sc + px, py = prop.get("position", [0, 0]) + fx, fy = prop.get("scroll", [1, 1]) + # parallax: the camera looks at the view's middle + cxm, cym = (view[0] + view[2]) / 2, (view[1] + view[3]) / 2 + camx, camy = cxm - 640, cym - 360 + px -= camx * (fx - 1) + py -= camy * (fy - 1) + flip = -1 if prop.get("flipX") else 1 + m = (sc[0] * flip, 0, 0, sc[1], px, py) + alpha = prop.get("alpha", 1) + lv = [(im, multiply(m, mm), a * alpha) for im, mm, a in leaves] + pixel = prop.get("isPixel", False) + layer, _, _ = flatten(lv, s, view, Image.Resampling.NEAREST if pixel else Image.Resampling.BICUBIC) + layers[depth].alpha_composite(layer.crop((0, 0, SCREEN_W, SCREEN_H))) + bg = Image.new("RGBA", (SCREEN_W, SCREEN_H), (0, 0, 0, 255)) + bg.alpha_composite(layers[0]) + colours, idx = quantize([bg], transparent=False) + pal = self.pics.palette(colours, opaque=True) + bgid = self.pics.add(idx[0], SCREEN_W, SCREEN_H, 0, 0, pal, "stages", opaque=True) + fronts = [] + for d in (1, 2, 3): + im = layers[d] + b = im.getbbox() + if not b: + fronts.append(0xFFFF) + continue + im = im.crop(b) + colours, ix = quantize([im]) + fronts.append(self.pics.add(ix[0], im.width, im.height, b[0], b[1], self.pics.palette(colours), "stages")) + feet = {r: (round((pos[r][0] - view[0]) * s), round((pos[r][1] - view[1]) * s)) for r in pos} + if name == "phillyBlazin": # (the two fighters share a place: their art stands them side by side) + feet["dad"], feet["bf"] = (104, 214), (214, 214) + extra = [(0, 0)] * 3 + if name == "sserafim": + extra = [(round((x - view[0]) * s), round((y - view[1]) * s)) for x, y in SSERAFIM_FEET] + self.rows.append(dict(name=name, bg=bgid, front=fronts, feet=feet, extra=extra, order=order, scale=s, view=view, + zoom=float(cfg.get("cameraZoom", 1)))) + if DEBUG: + for d in (1, 2, 3): + bg.alpha_composite(layers[d]) + bg.save(DEBUG / f"stage-{name}.png") + + +# ------------------------------------------------------------------------------------------------- HUD and menus +class Art: + """The notes, the HUD and the menus' pictures; ids by name.""" + + def __init__(self, ref, pics): + self.ref, self.pics, self.ids = ref, pics, {} + + def put(self, name, im, ax, ay, group, colours=None): + """An image, its top left (ax, ay) from its anchor.""" + b = im.getbbox() or (0, 0, 1, 1) + im = im.crop(b) + pal_colours, idx = quantize([im]) if colours is None else colours + self.ids[name] = self.pics.add(idx[0], im.width, im.height, ax + b[0], ay + b[1], + self.pics.palette(pal_colours), group) + return self.ids[name] + + def sparrow(self, name, path, prefix, scale, pixel, group, pick=0, center=True): + """A Sparrow frame, centred on its untrimmed box (as the game centres strum and splash frames).""" + leaves, _ = self.ref.frames(path, dict(prefix=prefix), [pick]) + box = self.ref.boxes[0] if self.ref.boxes else bounds(leaves[0]) + im, x, y = flatten(leaves[0], scale, box, Image.Resampling.NEAREST if pixel else Image.Resampling.BICUBIC) + ax = x - round((box[0] + box[2]) / 2 * scale) if center else x + ay = y - round((box[1] + box[3]) / 2 * scale) if center else y + return self.put(name, im, ax, ay, group) + + def image(self, name, path, scale, pixel, group, center=True, size=None): + im = atlas.open_image(path) + w, h = size or (max(1, round(im.width * scale)), max(1, round(im.height * scale))) + im = im.resize((w, h), Image.Resampling.NEAREST if pixel else Image.Resampling.LANCZOS) + return self.put(name, im, -(w // 2) if center else 0, -(h // 2) if center else 0, group) + + def styles(self): + for style in ("funkin", "pixel"): + a = self.ref.json(f"preload/data/notestyles/{style}.json")["assets"] + base = self.ref.json("preload/data/notestyles/funkin.json")["assets"] + pixel = style == "pixel" + hud = HUD * 1.0 + # (pixel art at a whole number of pixels: the notes at 1.5 screen pixels for each of theirs) + for d, dn in enumerate(["left", "down", "up", "right"]): + Dn = dn.capitalize() + n = a["note"] + self.sparrow(f"{style}.note{d}", self.ref.path(n["assetPath"]), n["data"][dn]["prefix"], + n.get("scale", 1) * hud, pixel, "hud") + st = a["noteStrumline"] + for state in ("Static", "Press", "Confirm"): + self.sparrow(f"{style}.{state.lower()}{d}", self.ref.path(st["assetPath"]), + st["data"][f"{dn}{state}"]["prefix"], st.get("scale", 1) * hud, pixel, "hud") + # holds: a row of the body (stretched down the screen) and the end, for each direction + h = a["holdNote"] + im = atlas.open_image(self.ref.path(h["assetPath"]).with_suffix(".png")) + cw = im.width // 8 + z = h.get("scale", 1) * hud + for d in range(4): + body = im.crop((2 * d * cw, 0, 2 * d * cw + cw, im.height)) + end = im.crop(((2 * d + 1) * cw, 0, (2 * d + 2) * cw, im.height)) + w = max(1, round(cw * z)) + rs = Image.Resampling.NEAREST if pixel else Image.Resampling.LANCZOS + row = body.crop((0, body.height // 2, cw, body.height // 2 + 1)).resize((w, 1), rs) + self.put(f"{style}.hold{d}", row, -(w // 2), 0, "hud") + eh = max(1, round(end.height * z)) + self.put(f"{style}.end{d}", end.resize((w, eh), rs), -(w // 2), 0, "hud") + # the splash on a Sick hit (its first two frames) + sp = a.get("noteSplash", base["noteSplash"]) + for d, dn in enumerate(["left", "down", "up", "right"]): + prefix = sp["data"][f"{dn}Splashes"][0]["prefix"] + self.sparrow(f"{style}.splash{d}_0", self.ref.path(sp["assetPath"]), prefix, + sp.get("scale", 1) * hud, pixel, "hud", pick=1) + # ratings, combo digits, countdown + for r in ("sick", "good", "bad", "shit"): + j = a[f"judgement{r.capitalize()}"] + self.image(f"{style}.{r}", self.ref.path(j["assetPath"].replace("default:", "preload:")).with_suffix(".png"), + j.get("scale", 1) * hud, pixel, "hud") + for n in range(10): + j = a[f"comboNumber{n}"] + self.image(f"{style}.num{n}", self.ref.path(j["assetPath"].replace("default:", "preload:")).with_suffix(".png"), + j.get("scale", 1) * hud, pixel, "hud") + for c in ("Two", "One", "Go"): + j = a[f"countdown{c}"] + self.image(f"{style}.count{c}", self.ref.path(j["assetPath"]).with_suffix(".png"), + j.get("scale", 1) * hud, pixel, "hud") + + def menus(self): + p = self.ref.path + # the title: the logo (two frames of its bump) and Girlfriend dancing + for i, f in enumerate((0, 6)): + self.sparrow(f"logo{i}", p("logoBumpin", "preload"), "logo bumpin", HUD, False, "menus", pick=f) + for i, f in enumerate((0, 7, 15, 22)): + self.sparrow(f"gftitle{i}", p("gfDanceTitle", "preload"), "gfDance", HUD, False, "menus", pick=f) + for name in ("menuBG", "menuDesat"): + im = atlas.open_image(p(name, "preload").with_suffix(".png")).resize((SCREEN_W, SCREEN_H), Image.Resampling.LANCZOS) + colours, idx = quantize([im], transparent=False) + self.ids[name] = self.pics.add(idx[0], SCREEN_W, SCREEN_H, 0, 0, self.pics.palette(colours, opaque=True), "menus") + + def alphabet(self, scale=0.28): + """The menus' bold letters (fonts/bold: white with a black outline), A-Z and signs; the digits, which it + doesn't have, from fonts/default, filled white and outlined the same way. One palette for all; returns + the sprite (or 0xFFFF) and the advance of each of ASCII 32..126.""" + from PIL import ImageFilter + signs = {"-": "-dash-", "'": "-apostraphie-", "!": "-exclamation point-", "?": "-question mark-", + ".": "-period-", ",": "-comma-", "&": "-andpersand-", ":": ":", "(": "(", ")": ")", "+": "+", + "/": "-forward slash-", "*": "*", "%": "%", "#": "#", "$": "$"} + bold = self.ref.path("fonts/bold", "preload") + default = self.ref.path("fonts/default", "preload") + frames = {} + for path in (bold, default): + for name, piece, m, fw, fh in self.ref.sparrow(path): + frames.setdefault((path, re.sub(r"\d{4}$", "", name)), (piece, m, fw, fh)) + glyphs = {} + for code in range(33, 127): + c = chr(code) + key = (bold, c if c.isalpha() and c.isupper() else signs.get(c, "\0")) + if key in frames: + piece, m, fw, fh = frames[key] + im, x, y = flatten([(piece, m, 1)], scale, (0, 0, fw, fh)) + elif c.isdigit() and (default, c) in frames: + piece, m, fw, fh = frames[(default, c)] + pad = 8 + mask = Image.new("L", (fw + 2 * pad, fh + 2 * pad)) + mask.paste(piece.getchannel("A"), (pad + int(m[4]), pad + int(m[5]))) + outline = mask.filter(ImageFilter.MaxFilter(11)) + big = Image.new("RGBA", mask.size, (0, 0, 0, 0)) + big.paste((0, 0, 0, 255), (0, 0), outline) + big.paste((255, 255, 255, 255), (0, 0), mask) + im, x, y = flatten([(big, (1, 0, 0, 1, -pad, -pad), 1)], scale, (-pad, -pad, fw + pad, fh + pad)) + y += round(pad * scale) + else: + continue + glyphs[c] = (im, x, y) + # lower case: the capitals + colours, idx = quantize([g[0] for g in glyphs.values()]) + pal = self.pics.palette(colours) + table = [] + height = max(g[0].height + g[2] for g in glyphs.values()) + for code in range(32, 127): + c = chr(code) + c = c.upper() if c.isalpha() else c + if c not in glyphs: + table.append((0xFFFF, round(height * 0.5))) + continue + im, x, y = glyphs[c] + ix = idx[list(glyphs).index(c)] + b = im.getbbox() or (0, 0, 1, 1) + sid = self.pics.add(crop_idx(ix, im.width, im.height, b), b[2] - b[0], b[3] - b[1], x + b[0], y + b[1], + pal, "menus") if (c not in self.ids) else self.ids[c] + self.ids.setdefault(c, sid) + table.append((sid, b[2] - b[0] + max(1, round(height * 0.04)))) + self.bold, self.bold_height = table, height + return table + + +# ------------------------------------------------------------------------------------------------- the songs +def ease_code(ease, easedir): + if ease in ("linear", "INSTANT"): + return EASES.index(ease) + base = re.sub("(InOut|In|Out)$", "", ease) + d = ease[len(base):] or easedir or "Out" + e = EASES.index(base) if base in EASES else 0 + return e | (EASE_DIRS.index(d) if d in EASE_DIRS else 1) << 4 + + +def step_us(bpm): + return 60e6 / bpm / 4 + + +class Charts: + def __init__(self, songs, chars, stages, blob, anim_name): + self.songs, self.blob = songs, blob + self.kinds = [""] + for s in songs.songs: + for v in s["variations"]: + for d, ns in v["chart"]["notes"].items(): + for n in ns: + k = n.get("k", "") or "" + if k not in self.kinds: + self.kinds.append(k) + self.kinds = [""] + sorted(k for k in self.kinds if k) + self.tempos, self.events, self.charts, self.vars, self.rows = [], [], [], [], [] + self.max_raw = self.max_notes = 0 + self.stats = dict(raw=0, packed=0, notes=0) + for si, s in enumerate(songs.songs): + v0 = len(self.vars) + for v in s["variations"]: + self.variation(si, s, v, chars, stages, anim_name) + meta = s["variations"][0]["meta"] + opp = meta["playData"]["characters"]["opponent"] + name = meta["songName"] + if len(name) > 18: # ("SPAGHETTI (feat. j-hope of BTS) (Clean Version)": its title alone) + name = re.sub(r"\s*\(.*$", "", name) + self.rows.append(dict(name=name, level=s["level"], var=v0, nvar=len(self.vars) - v0, + icon=chars.rows[chars.index[opp]]["icon"])) + + def variation(self, si, s, v, chars, stages, anim_name): + meta, chart = v["meta"], v["chart"] + pd = meta["playData"] + c = pd["characters"] + # the tempo: each change's time (us), its step length (1/256 us) and the steps before it (16.16) + t0 = len(self.tempos) + steps, last_t, last_len = 0.0, 0.0, None + for tc in meta["timeChanges"]: + t = float(tc["t"]) + if last_len is not None: + steps += (t - last_t) * 1000 / last_len + if "b" in tc and tc["b"] is not None and last_len is not None: + steps = float(tc["b"]) * 4 + last_t, last_len = t, step_us(float(tc["bpm"])) + self.tempos.append((us(t), round(last_len * 256), round(steps * 65536))) + e0 = len(self.events) + for e in sorted(chart.get("events", []), key=lambda e: float(e["t"])): + ev = self.event(e, chars, anim_name, c, meta) + if ev: + self.events.append(ev) + ch0 = len(self.charts) + speeds = chart.get("scrollSpeed", {}) + for di, diff in enumerate(DIFFICULTIES): + ns = chart["notes"].get(diff) + if diff not in pd["difficulties"] or not ns: + continue + ns = sorted(ns, key=lambda n: (float(n["t"]), int(n["d"]))) + data, prev, total, players = bytearray(), 0, 0, 0 + for n in ns: + t = us(n["t"]) + length = us(n.get("l", 0) or 0) + k = self.kinds.index(n.get("k", "") or "") + d = int(n["d"]) + assert 0 <= d < 8 and t >= prev + data += leb(t - prev) + leb(d | (length > 0) << 3 | k << 4) + if length > 0: + data += leb(length) + prev = t + if d < 4: + players += 1 + if (n.get("k") or "") != "non_scoreable": + total += 1 + z = deflate(bytes(data)) + at = self.blob.add(z) + self.stats["raw"] += len(data) + self.stats["packed"] += len(z) + self.stats["notes"] += len(ns) + self.max_raw = max(self.max_raw, len(data)) + self.max_notes = max(self.max_notes, len(ns)) + speed = float(speeds.get(diff, speeds.get("default", 1))) + self.charts.append(dict(var=len(self.vars), diff=di, at=at, size=len(z), raw=len(data), notes=len(ns), + total=total, speed=round(speed * 1000))) + stage = stages.index(pd["stage"]) + role = [c.get("player"), c.get("opponent"), c.get("girlfriend")] + inst = s["id"] + ("" if v["kind"] == "default" else "-" + (c.get("instrumental") or v["kind"])) + if inst not in INST_MS: + inst = s["id"] + last = max([float(n["t"]) + float(n.get("l", 0) or 0) for d in chart["notes"].values() for n in d] + [0]) + length = max(INST_MS.get(inst, 0), last + 1200) + style = 1 if pd.get("noteStyle") == "pixel" else 0 + self.vars.append(dict(song=si, kind=VARIATIONS.index(v["kind"]), stage=stage, style=style, + chr=[chars.index[r] if r else 255 for r in role], + extra=1 if s["id"] == "spaghetti" else 0, tempo=t0, ntempo=len(self.tempos) - t0, + event=e0, nevent=len(self.events) - e0, chart=ch0, ncharts=len(self.charts) - ch0, + flags=(1 if c.get("opponent") in ("gf",) else 0) | (2 if s["id"] == "blazin" else 0), + length=round(length))) + + def event(self, e, chars, anim_name, c, meta): + kind = e["e"] + if kind not in EVENTS: + return None + v = e.get("v") + v = v if isinstance(v, dict) else {"char": v} + t = us(e["t"]) + typ = EVENTS.index(kind) + a = b = cc = 0 + val = dur = 0 + if kind == "SetCameraBop": + val = round(float(v.get("intensity", 1.0)) * 1000) + dur = max(0, round(float(v.get("rate", 4)) * 4)) # every this many steps + b = round(float(v.get("offset", 0) or 0) * 4) & 255 + elif kind == "PlayAnimation": + target = v.get("target", "bf") + a = {"boyfriend": 0, "bf": 0, "player": 0, "dad": 1, "opponent": 1, "girlfriend": 2, "gf": 2}.get(target, 255) + if a == 255 or v.get("anim") not in anim_name: + return None + b = anim_name[v["anim"]] + cc = 1 if v.get("force", False) else 0 + elif kind == "ScrollSpeed": + val = round(float(v.get("scroll", 1)) * 1000) + a = 1 if v.get("absolute", False) else 0 + b = ease_code(v.get("ease", "linear"), v.get("easeDir", "")) + dur = round(float(v.get("duration", 4)) * 1000) # steps x 1000 + cc = {"both": 0, "player": 1, "opponent": 2}.get(v.get("strumline", "both"), 0) + elif kind == "SetHealthIcon": + a = int(v.get("char", 0)) + b = chars.icon(v.get("id", "face")) + cc = 1 if v.get("flipX", False) else 0 + elif kind == "sserafimSing": + a = sum(1 << i for i, x in enumerate(v.get("singing", [])) if x) + elif kind == "sserafimShow": + a = sum(1 << i for i, x in enumerate(v.get("visible", [])) if x) + elif kind == "sserafimDark": + val = round(float(v.get("amount", 0)) * 1000) + dur = round(float(v.get("duration", 0)) * 1000) # seconds x 1000 + return (t, typ, a, b, cc, val, dur) + + +# ------------------------------------------------------------------------------------------------- C +def c_array(ctype, name, rows, dims=""): + def lit(x): + if isinstance(x, (list, tuple)): + return "{" + ",".join(lit(y) for y in x) + "}" + if isinstance(x, str): + return x + return str(x) + out = [f"const {ctype} {name}[{len(rows)}]{dims} = {{"] + out += [" " + lit(r) + "," for r in rows] + out.append("};") + return out + + +def cstr(s): + return json.dumps(s) + + +def ident(s): + return re.sub("[^A-Z0-9]+", "_", s.upper()).strip("_") + + +def git_blob_sha(path): + data = Path(path).read_bytes() + return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + + +DEBUG = None + + +def main(): + global DEBUG + ap = argparse.ArgumentParser() + ap.add_argument("ref") + ap.add_argument("out") + ap.add_argument("--list", help="write the files read, with their git blob hashes, here") + ap.add_argument("--debug", help="save pictures of the stages here") + args = ap.parse_args() + if args.debug: + DEBUG = Path(args.debug) + DEBUG.mkdir(parents=True, exist_ok=True) + ref = Reference(args.ref) + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + blob = Blob() + pics = Pictures(blob) + songs = Songs(ref) + # the animations the charts' events and note kinds ask for, by character + wanted = {} + for s in songs.songs: + for v in s["variations"]: + c = v["meta"]["playData"]["characters"] + roles = {0: c.get("player"), 1: c.get("opponent"), 2: c.get("girlfriend")} + for e in v["chart"].get("events", []): + if e["e"] == "PlayAnimation" and isinstance(e.get("v"), dict): + t = {"boyfriend": 0, "bf": 0, "player": 0, "dad": 1, "opponent": 1, "girlfriend": 2, + "gf": 2}.get(e["v"].get("target"), None) + if t is not None and roles.get(t): + wanted.setdefault(roles[t], set()).add(e["v"].get("anim")) + for ns in v["chart"]["notes"].values(): + for n in ns: + names = kind_anims(n.get("k", "") or "") + if not names: + continue + who = roles[0] if int(n["d"]) < 4 else roles[1] + targets = [who] + (SSERAFIM_EXTRA + [c.get("player"), c.get("opponent"), c.get("girlfriend")] + if s["id"] == "spaghetti" else []) + for wname in targets: + if wname: + wanted.setdefault(wname, set()).update(x for x in names if x) + for c in songs.characters(): # Girlfriend's cheers and tears + wanted.setdefault(c, set()).update(("combo50", "combo200", "drop70", "hey", "cheer")) + # the stages' own scale for characters (Blazin's), where a character is only ever on such a stage + seen = {} + cast = {} + for s in songs.songs: + for v in s["variations"]: + pd = v["meta"]["playData"] + st = ref.json(f"preload/data/stages/{pd['stage']}.json").get("characters", {}) + c = pd["characters"] + for k, role in (("player", "bf"), ("opponent", "dad"), ("girlfriend", "gf")): + if c.get(k): + seen.setdefault(c[k], set()).add(float(st.get(role, {}).get("scale", 1))) + cast.setdefault(pd["stage"], []).append((c.get("player"), c.get("opponent"))) + stage_scale = {n: next(iter(v)) for n, v in seen.items() if len(v) == 1 and next(iter(v)) != 1} + for who in ("bf", "pico"): + OWNED[who] = set(ref.json(f"preload/data/players/{who}.json").get("ownedChars", [])) + print("characters", file=sys.stderr) + chars = Characters(ref, pics, songs.characters(), wanted, stage_scale) + print("stages", file=sys.stderr) + stage_names = songs.stages() + stages = Stages(ref, pics, stage_names, chars, cast) + print("HUD and menus", file=sys.stderr) + art = Art(ref, pics) + art.styles() + art.menus() + glyphs = art.alphabet() + charts = Charts(songs, chars, stage_names, blob, chars.name_ids) + if DEBUG: # each stage with its first song's characters + done = set() + for v in charts.vars: + st = stages.rows[v["stage"]] + if st["name"] in done: + continue + done.add(st["name"]) + im = Image.open(DEBUG / f"stage-{st['name']}.png").convert("RGBA") + for role, key in ((2, "gf"), (1, "dad"), (0, "bf")): + ci = v["chr"][role] + if ci == 255: + continue + pim, x, y = chars.preview[chars.rows[ci]["name"]] + fx, fy = st["feet"][key] + im.alpha_composite(pim, (max(0, fx + x), max(0, fy + y))) + im.save(DEBUG / f"scene-{st['name']}.png") + chars.kind_rows(charts.kinds) + # ---- write + h = ["/* Made by tools/data.py from the game's own files (see tools/ref.txt). */", "#ifndef FNF_IDS_H", + "#define FNF_IDS_H"] + counts = dict(SPRITES=len(pics.sprites), PALETTES=len(pics.palettes), ANIMS=len(chars.anims), + CHARS=len(chars.rows), KINDS=len(chars.kinds), NOTE_KINDS=len(charts.kinds), + ICONS=len(chars.icon_rows), STAGES=len(stages.rows), TEMPOS=len(charts.tempos), + EVENTS=len(charts.events), CHARTS=len(charts.charts), VARS=len(charts.vars), + SONGS=len(charts.rows), LEVELS=len(songs.levels), BLOB=len(blob.data), + CHART_RAW_MAX=charts.max_raw, CHART_NOTES_MAX=charts.max_notes) + # RAM: what a song needs unpacked (tools/data.py works it out; draw.c lays it out) + def raw(i): + w, hh = pics.sprites[i][0], pics.sprites[i][1] + return (w + 1) // 2 * hh + frame_max = [] + for r in chars.rows: + m = 0 + for a in range(r["anim"], r["anim"] + r["count"]): + s0, n = chars.anims[a][0], chars.anims[a][1] + m = max([m] + [raw(s0 + k) for k in range(n)]) + frame_max.append(m) + front = [sum(raw(f) for f in st["front"] if f != 0xFFFF) for st in stages.rows] + cast = 0 + for v in charts.vars: + ids = [c for c in v["chr"] if c != 255] + if v["extra"]: + ids += [chars.index[n] for n in SSERAFIM_EXTRA] + cast = max(cast, sum(frame_max[c] for c in ids) + front[v["stage"]]) + def style_bytes(style, names): + return sum(raw(art.ids[f"{style}.{n}"]) for n in names) + hud_names = [f"{k}{d}" for k in ("note", "static", "press", "confirm", "hold", "end") for d in range(4)] + \ + ["sick", "good", "bad", "shit"] + [f"num{n}" for n in range(10)] + hud = max(style_bytes(st, hud_names) for st in ("funkin", "pixel")) + transient = max(max(style_bytes(st, [f"splash{d}_0" for d in range(4)]), + max(raw(art.ids[f"{st}.count{c}"]) for c in ("Two", "One", "Go"))) for st in ("funkin", "pixel")) + icon = 3 * max(raw(i) for row in chars.icon_rows for i in row) + bold_bytes = sum((raw(i) + 3) & ~3 for i in sorted({g[0] for g in glyphs if g[0] != 0xFFFF})) + counts.update(CAST_MAX=cast, HUD_MAX=hud, TRANSIENT_MAX=transient, ICON_MAX=icon, BOLD_BYTES=bold_bytes, + BG_BYTES=SCREEN_W * SCREEN_H // 2) + for r, m in zip(chars.rows, frame_max): + r["frame_max"] = m + h += [f"#define FNF_{k} {v}" for k, v in counts.items()] + h += [f"#define FNF_SPR_{ident(k)} {v}" for k, v in art.ids.items() if len(k) > 1] + h.append(f"#define FNF_BOLD_HEIGHT {art.bold_height}") + h += [f"#define FNF_CHR_{ident(r['name'])} {i}" for i, r in enumerate(chars.rows)] + h += [f"#define FNF_ANIM_{ident(k)} {v}" for k, v in chars.name_ids.items()] + h += [f"#define FNF_EV_{ident(k)} {i}" for i, k in enumerate(EVENTS)] + h += [f"#define FNF_KIND_{ident(k or 'normal')} {i}" for i, k in enumerate(charts.kinds)] + h += [f"#define FNF_STAGE_{ident(k)} {i}" for i, k in enumerate(stage_names)] + h += ["#endif", ""] + (out / "ids.h").write_text("\n".join(h), newline="\n") + c = ["/* Made by tools/data.py from the game's own files (see tools/ref.txt): the Funkin' Crew's charts and", + " * pictures, which are not in this repository. */", '#include "data.h"'] + c += c_array("uint16_t", "fnf_palette", pics.palettes, "[16]") + c += c_array("fnf_sprite_t", "fnf_sprite", [[w, h_, x, y, p, 0, at, size] for w, h_, x, y, p, at, size in pics.sprites]) + c += c_array("fnf_anim_t", "fnf_anim", [[s, n, ln, fps, loop, picks] for s, n, ln, fps, loop, picks in chars.anims]) + c += c_array("uint8_t", "fnf_anim_name", chars.anim_names) + c += c_array("fnf_char_t", "fnf_char", [[r["anim"], r["count"], r["dance"], r["sing"], + (1 if r["alternate"] else 0) | (2 if r["pixel"] else 0) | r["owner"], + r["icon"], + r["slot"], r["frame_max"]] for r in chars.rows]) + c += c_array("fnf_kind_t", "fnf_kind", [[ci, ki, an] for ci, ki, an in chars.kinds] or [[255, 255, [255] * 4]]) + c += c_array("uint16_t", "fnf_icon", chars.icon_rows, "[3]") + order_ix = {"bf": 0, "dad": 1, "gf": 2} + c += c_array("fnf_stage_t", "fnf_stage", [[r["bg"], r["front"], [list(r["feet"][k]) for k in ("bf", "dad", "gf")], + [list(e) for e in r["extra"]], [order_ix[o] for o in r["order"]], + round(r["scale"] * 10000)] + for r in stages.rows]) + c += c_array("fnf_tempo_t", "fnf_tempo", charts.tempos) + c += c_array("fnf_event_t", "fnf_event", [[t, typ, a_, b, cc, val, dur] for t, typ, a_, b, cc, val, dur in charts.events]) + c += c_array("fnf_chart_t", "fnf_chart", [[r["var"], r["diff"], 0, r["at"], r["size"], r["raw"], r["notes"], + r["total"], r["speed"]] for r in charts.charts]) + c += c_array("fnf_var_t", "fnf_var", [[r["song"], r["kind"], r["stage"], r["style"], r["chr"], r["extra"], + r["tempo"], r["ntempo"], r["event"], r["nevent"], r["chart"], + r["ncharts"], r["flags"], r["length"]] for r in charts.vars]) + c += c_array("fnf_song_t", "fnf_song", [[cstr(r["name"]), r["level"], r["var"], r["nvar"], r["icon"]] + for r in charts.rows]) + c += c_array("fnf_level_t", "fnf_level", [[cstr(l["name"]), [songs.order.index(s) for s in l["songs"]] + + [255] * (4 - len(l["songs"])), len(l["songs"])] for l in songs.levels]) + c += c_array("fnf_glyph_t", "fnf_bold", [list(g) for g in glyphs]) + c.append(f"const uint8_t fnf_blob[{len(blob.data)}] = {{") + d = blob.data + c += [" " + ",".join(str(x) for x in d[i:i + 40]) + "," for i in range(0, len(d), 40)] + c.append("};") + (out / "data.c").write_text("\n".join(c) + "\n", newline="\n") + print(f"pictures {sum(pics.bytes.values())} bytes {pics.bytes}; charts {charts.stats}, the longest " + f"{charts.max_raw} bytes unpacked ({charts.max_notes} notes); blob {len(blob.data)}", file=sys.stderr) + if args.list: + root = ref.root.resolve() + files = sorted({str(p.resolve().relative_to(root)) for p in atlas.opened}) + Path(args.list).write_text("".join(f"{git_blob_sha(root / f)} {f}\n" for f in files), newline="\n") + + +if __name__ == "__main__": + main() diff --git a/games/fnf/tools/fetch.py b/games/fnf/tools/fetch.py new file mode 100644 index 00000000..ab2dd6a7 --- /dev/null +++ b/games/fnf/tools/fetch.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Brings the Funkin' Crew's files the game is made from (tools/ref.txt: each file's git blob hash and path) from +their funkin.assets repository, at the commit below, into DIR: a sparse, blobless git checkout of just those files +(no audio, nothing else). A DIR that already holds them (a copy of funkin.assets) is only checked. Nothing in it is +run. + + python3 -I tools/fetch.py DIR""" +import hashlib +import subprocess +import sys +from pathlib import Path + +REPO = "https://github.com/FunkinCrew/funkin.assets.git" +COMMIT = "724eba9de9e76761d9cefd52169926bc51c760a5" # the one FunkinCrew/Funkin d05659bb uses +LIST = Path(__file__).resolve().parent / "ref.txt" + + +def blob(path): + data = path.read_bytes() + return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + + +def wrong(root, files): + return [p for sha, p in files if not (root / p).is_file() or blob(root / p) != sha] + + +def git(root, *args): + subprocess.run(["git", "-C", str(root), *args], check=True, stdout=subprocess.DEVNULL) + + +def main(): + if len(sys.argv) != 2: + sys.exit(__doc__) + root = Path(sys.argv[1]) + files = [line.split(" ", 1) for line in LIST.read_text().splitlines() if line.strip()] + bad = wrong(root, files) + if not bad: + return + if root.exists() and any(root.iterdir()) and not (root / ".git").exists(): + sys.exit(f"fetch.py: {root} isn't funkin.assets at {COMMIT[:8]}: {len(bad)} files differ, first {bad[0]}") + print(f"fetch.py: {len(bad)} of the game's {len(files)} files, from {REPO} at {COMMIT[:8]}", file=sys.stderr) + root.mkdir(parents=True, exist_ok=True) + if not (root / ".git").exists(): + git(root, "init", "-q") + git(root, "remote", "add", "origin", REPO) + git(root, "sparse-checkout", "init", "--no-cone") + (root / ".git" / "info" / "sparse-checkout").write_text("".join(f"/{p}\n" for _, p in files)) + git(root, "fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", COMMIT) + git(root, "-c", "advice.detachedHead=false", "checkout", "-q", "--force", COMMIT) + bad = wrong(root, files) + if bad: + sys.exit(f"fetch.py: {len(bad)} files don't match tools/ref.txt, first {bad[0]}") + + +if __name__ == "__main__": + main() diff --git a/games/fnf/tools/ref.txt b/games/fnf/tools/ref.txt new file mode 100644 index 00000000..f698ffe3 --- /dev/null +++ b/games/fnf/tools/ref.txt @@ -0,0 +1,572 @@ +7b1257b76a544a828b3d8037d70926297d89b930 preload/data/characters/bf-car.json +a4e7e04bbb8bbd90f7cef3d8d895f883e6701236 preload/data/characters/bf-christmas.json +c3d4c2871544b16f39d511903a5106d658ec4a27 preload/data/characters/bf-dark.json +d7c4cc4cc85aee039c4e11489a0ef5d25b5e5f9e preload/data/characters/bf-holding-gf.json +1f20f6f286566aaf200a24399bc040aa5968b598 preload/data/characters/bf-pixel.json +d49941884acaa21b05783e57d13b005cf64c2792 preload/data/characters/bf.json +342e433d98db42808a367c84f8c137dba40b87ca preload/data/characters/dad.json +1bb2618444428f20487bbd0d1d8e6b0d484c7fd6 preload/data/characters/darnell-blazin.json +819019c0b1c4a91879be10b746108ccd624665f7 preload/data/characters/darnell.json +52d3cfc2a5a1d601bf33423641288580c15c8d3e preload/data/characters/gf-car.json +ae8fa1e6b868f8d10e534ad88e2b630e6a860afc preload/data/characters/gf-christmas.json +10a9b84e528b6fc03dcf8b58fe9cce2902b2f42a preload/data/characters/gf-dark.json +e7fe9b44b5d971fa25981e850d8d3d43dbcf4a7a preload/data/characters/gf-pixel.json +aba10debd26a2a915c1026487878ded807dbb3b2 preload/data/characters/gf-tankmen.json +878c8708e09acb2ceeda71a0cd0b91ab131d1a2d preload/data/characters/gf.json +45a2d5488085179707f26c3ef8d2aecf42741105 preload/data/characters/mom-car.json +9f3f4cfae20c74fa20836ca2970b9bfb21a02b8a preload/data/characters/monster-christmas.json +58b45b59804665c9690b2785d790c3912a17f764 preload/data/characters/monster.json +f956502d7faa6240aa579287ea1251a7b5c3e5aa preload/data/characters/nene-christmas.json +4475a95a1a8adb26482f4aaedc7d874d5015d73f preload/data/characters/nene-dark.json +fc9bb16cd5e1a60b6f5ca76fe6d43b808164becb preload/data/characters/nene-pixel.json +9a51da1ec7a8f8784c17bd02d7ec4dc76cd4df3d preload/data/characters/nene-tankmen.json +4e99cac7c1456b348f39a6a1c37bc81e019c024d preload/data/characters/nene.json +f8829c438926f8fdd20dfbda3b2d20ccedb8d983 preload/data/characters/otis-speaker.json +b1c17a0eda25dea02df58b9e7800c2406646e3c9 preload/data/characters/parents-christmas.json +fadaa27b3e3e84fc1b4e162aaecf619dd654edc9 preload/data/characters/pico-blazin.json +6845909cf594d6d12d6048482552d11a1e47fde3 preload/data/characters/pico-christmas.json +8814c649a17ad2ef327168da556456023df9d34f preload/data/characters/pico-dark.json +bc0b291d8f050f42fe293f09ddb41df739fd974a preload/data/characters/pico-holding-nene.json +467db89e368bfb3007a53afef41e137049a5e5a8 preload/data/characters/pico-pixel.json +0562491a35e546ced6fe1987d77112e38ae2287a preload/data/characters/pico-playable.json +359ceb1d7cfa00dc5ed6fffd76e316d7382edba7 preload/data/characters/pico-speaker.json +9caefb7e41f2fe23241cec318d740904f145105b preload/data/characters/pico.json +de7fe4d0634550afaa5ebbe6f84eaa93010598d6 preload/data/characters/senpai-angry.json +25f9d01ed6bfc29d795dbeb27e7ca9660b007dce preload/data/characters/senpai.json +21774d36fd6a31a58583ec9bc9e463d3d32d9762 preload/data/characters/spirit.json +47321fcaf0b7f0ff5094ac4bf47ceaf2bfa6ca21 preload/data/characters/spooky-dark.json +757ef7e53b709cafc3cd20b8bee011eb3a7efcfb preload/data/characters/spooky.json +72c2d2c9e944b6a8d7bc13b59646d20594fcd37c preload/data/characters/sserafim-chaewon.json +f497b509846ecf1d797edda94c8f9e36cab5fbfb preload/data/characters/sserafim-eunchae.json +1bcfe86ed920837eb85ebd2bbc6fe54a5e435b94 preload/data/characters/sserafim-gf.json +1bfd2a56d72813aec5f4193c6b915550474765d7 preload/data/characters/sserafim-kazuha.json +20aecf6c3ca50e1dad7f744fa82994b23dfab193 preload/data/characters/sserafim-sakura.json +63b85ac5a216da8a2983e179d6857204a294cf88 preload/data/characters/sserafim-yunjin.json +6392bc0170507bbad16cade19371180697a61d36 preload/data/characters/tankman-bloody.json +c980e7a28e23e7bf5d9c2b1368b9c4eae97abdb9 preload/data/characters/tankman.json +bbb744a223b0bd88ce1d61fab3b24e0d6f956006 preload/data/levels/sserafim.json +8b004e213e3b26a8ac03ae622aa94d783cabceab preload/data/levels/tutorial.json +00da3f488d82cae4adf0eda86e7250f904573336 preload/data/levels/week1.json +f6adfc1053e1dcd1ca05993c39dca4772828f0ae preload/data/levels/week2.json +494050c2dbbea9f713c4a70b52c5d30ca475ec5c preload/data/levels/week3.json +16f6bce7c6adb84e427bb0f61a485f6921168cb3 preload/data/levels/week4.json +f1d9f504da242bf899a823635060916bb02723d6 preload/data/levels/week5.json +16d51229be72a8b938fed1656d48a559de1b9c71 preload/data/levels/week6.json +5634c3b0f06b1308d38e078b770c3e862b296cea preload/data/levels/week7.json +15203d12e456e7a79b1335256713bf88df195959 preload/data/levels/weekend1.json +9f82238182a94a76e1b3f0067cc006e88277040b preload/data/notestyles/funkin.json +f4cb51a368f964eface1fb193c3e12aab042a681 preload/data/notestyles/pixel.json +72a6dd9967866ee89fd29f8dc78b8ae634641547 preload/data/players/bf.json +e4bfb096b2aaf07bc75bdd921baf88b1c6d5a366 preload/data/players/pico.json +a9cf6e6c767667033a5b7399adf41f201ed5ec62 preload/data/songs/2hot/2hot-chart.json +c01283a3b81140fcb80874cc67dd3321638c86d0 preload/data/songs/2hot/2hot-metadata.json +cd847762d06fbe20f743da37452495ac48e3706a preload/data/songs/blammed/blammed-chart-erect.json +2a01a99efc946aca757888c391977080db574afa preload/data/songs/blammed/blammed-chart-pico.json +fabf10538831c08f1bb91f31ff4320f2db4b8351 preload/data/songs/blammed/blammed-chart.json +9388ef84a0f0d096b3afd33bd6d45797a34c5e1f preload/data/songs/blammed/blammed-metadata-erect.json +7320a640ac445d1f2c24186f567cf007a71b441c preload/data/songs/blammed/blammed-metadata-pico.json +4f77d0b00f53589001949f592c7722176be3fad9 preload/data/songs/blammed/blammed-metadata.json +317a41b539b2bf0c94a76391d735beefc88104a0 preload/data/songs/blazin/blazin-chart.json +abe9abee6be24d8074049cea1c21eb0a73e8fb75 preload/data/songs/blazin/blazin-metadata.json +2906c0ebd82657eb45d5ed682e311bee6331ff60 preload/data/songs/bopeebo/bopeebo-chart-erect.json +1a95fe991b945baf97aea9405e148c8262d6eb9e preload/data/songs/bopeebo/bopeebo-chart-pico.json +4f2eb0de72385c0df98da6060f4503570b3908fb preload/data/songs/bopeebo/bopeebo-chart.json +fc82c17201f8c22de83b423803bd658daed90116 preload/data/songs/bopeebo/bopeebo-metadata-erect.json +bdc86ace8bca1fd96d8818ba9f147da072de079a preload/data/songs/bopeebo/bopeebo-metadata-pico.json +273fb186b07ed6406f6f889603b39af7e2c08fa9 preload/data/songs/bopeebo/bopeebo-metadata.json +30bf5018d5bdc0c3cac955d0d415b07f3a27249f preload/data/songs/cocoa/cocoa-chart-erect.json +f2aaac2a83754d6743e7733dc604b014ad3a92c9 preload/data/songs/cocoa/cocoa-chart-pico.json +33e43e3e826008d2b3401216774b93e7dd84f884 preload/data/songs/cocoa/cocoa-chart.json +9964a4baeeb639f0609f8acf286b1bdf08cafee9 preload/data/songs/cocoa/cocoa-metadata-erect.json +e772b83ae593086d706869c8edc4655b01433d19 preload/data/songs/cocoa/cocoa-metadata-pico.json +5c3d76e21aa3314d7339b93a1fcd95ddeb6e5968 preload/data/songs/cocoa/cocoa-metadata.json +d5de8043ebb36b4d3db416772ee222c85fa1e591 preload/data/songs/dadbattle/dadbattle-chart-erect.json +be8a9baef37ac346392541350e977418c494fcc9 preload/data/songs/dadbattle/dadbattle-chart-pico.json +d8448ccd1e7c544b9165869ec6f7bad504888952 preload/data/songs/dadbattle/dadbattle-chart.json +d012473641603a34784f2a49ad4de886cfb0c7e8 preload/data/songs/dadbattle/dadbattle-metadata-erect.json +70794d9a88b9faaa0010da12e19d07980d925ba9 preload/data/songs/dadbattle/dadbattle-metadata-pico.json +08465ae960873dea6699c7f1b7e0772d53c4c374 preload/data/songs/dadbattle/dadbattle-metadata.json +af0c73080d2916f51d462aaa63f4d4448b3308c4 preload/data/songs/darnell/darnell-chart-bf.json +4264a0b70072337c1e9fa4c3d19e9487caac97a6 preload/data/songs/darnell/darnell-chart-erect.json +b8f31e35e84ebe23d79167e0d8df6fd4c836f19b preload/data/songs/darnell/darnell-chart.json +0f6fe4ce4af002ecd82e6dcd97ba65660b841ea8 preload/data/songs/darnell/darnell-metadata-bf.json +1bcc871ca4cdfe23842b619bb72a77677add5d12 preload/data/songs/darnell/darnell-metadata-erect.json +77b3490ff532f19463dff0b989b38c7d0085ffc9 preload/data/songs/darnell/darnell-metadata.json +d47cc2c871ea4f41bf78371206a3d7469fe86bac preload/data/songs/eggnog/eggnog-chart-erect.json +3852974c466a545e785d68c0a30d20862d8cacae preload/data/songs/eggnog/eggnog-chart-pico.json +d1ba9482d95d031d210703b33b37378da3b5e95c preload/data/songs/eggnog/eggnog-chart.json +e3cf3b45cdb108e1465eca459b3b9640e83870d5 preload/data/songs/eggnog/eggnog-metadata-erect.json +0ebcb2044e4489590944b54e2d04b58e7eb8c118 preload/data/songs/eggnog/eggnog-metadata-pico.json +a4ca421113a04b37e9bb0c736aab443ae6de7286 preload/data/songs/eggnog/eggnog-metadata.json +3c9149c8de104085e21bf4c20a1137bd38173fec preload/data/songs/fresh/fresh-chart-erect.json +cc6761be3ace505e2c471db3c7ca8e394c833243 preload/data/songs/fresh/fresh-chart-pico.json +c34e8f200df028daf47ce994938118f28875b8bc preload/data/songs/fresh/fresh-chart.json +1053b7835c6b2d8bb3f7c44d33831859550839ce preload/data/songs/fresh/fresh-metadata-erect.json +232bd252a426b63fda7f0f931eacd5a76f04dc82 preload/data/songs/fresh/fresh-metadata-pico.json +f9660a8f22114a4861e11290e7359f52fd054a07 preload/data/songs/fresh/fresh-metadata.json +7bb5052e4ff5fe7b53f22519663dff1b19035c63 preload/data/songs/guns/guns-chart-pico.json +df4f23c86fcfbc78b75b1f3bcdb2062df5020d44 preload/data/songs/guns/guns-chart.json +47cadc1ee9a298fd9b49d4a925bc8fa9eb71b1a3 preload/data/songs/guns/guns-metadata-pico.json +a95f35071721a8ce226b6191f2883b41b95c53a9 preload/data/songs/guns/guns-metadata.json +6f68a957377d9655ed987b9325c8a4e3b2591365 preload/data/songs/high/high-chart-erect.json +a661a0d8e04be5c64f5a5fc49840d73275adf66e preload/data/songs/high/high-chart.json +3e26b533ef11cbb55fbe8ebadc6434df5949844e preload/data/songs/high/high-metadata-erect.json +559fa241a9a994bdfbaeb5c847d9057c7ab667f6 preload/data/songs/high/high-metadata.json +8f4831c2b6b0a2f4ca2f432eff7008d24b5d0e1a preload/data/songs/lit-up/lit-up-chart-bf.json +e503c6d76a1fc0fd1ad66c4154ef7319afc409a5 preload/data/songs/lit-up/lit-up-chart.json +0246e6ef02d08e6f745fb02093f1a10a81965149 preload/data/songs/lit-up/lit-up-metadata-bf.json +2d49f6249fea80f42e0920cd4bb07c644633dcc4 preload/data/songs/lit-up/lit-up-metadata.json +74cb4f458a61c3e35cc1819136f5a7a3e3dc2bf5 preload/data/songs/milf/milf-chart.json +717f9f8e61c1230eb7936ec23fa2043ae66ef2e4 preload/data/songs/milf/milf-metadata.json +6068612a4a6f65b5087dcbf21b097b85269eddc9 preload/data/songs/monster/monster-chart.json +ed302604fbcc9ad99413b7abe6709768d28abdef preload/data/songs/monster/monster-metadata.json +50559630577ef59f4cb21c8b019c417b47bccbfd preload/data/songs/philly-nice/philly-nice-chart-erect.json +dc1093d5da238b914d6b42688b566a24dd8ac326 preload/data/songs/philly-nice/philly-nice-chart-pico.json +821f2084f78742572589b3040194aa96fbed05fc preload/data/songs/philly-nice/philly-nice-chart.json +a27365b50c77990ffead0a1f97c8736d3b031f3e preload/data/songs/philly-nice/philly-nice-metadata-erect.json +d10949f8fb8b59148eede9cbe3856f9f94a9bfc5 preload/data/songs/philly-nice/philly-nice-metadata-pico.json +aba85c9d7c2bf8067d587d85d0a1092145caa13d preload/data/songs/philly-nice/philly-nice-metadata.json +e31f7c1d8426cb80d53f4ec2634f785c5267ccf2 preload/data/songs/pico/pico-chart-erect.json +5ab397a086a41af5b07abe30bd9a403cdf66164a preload/data/songs/pico/pico-chart-pico.json +ff3701d8675aeb0e1f25cc22fd7219707c5263da preload/data/songs/pico/pico-chart.json +baa4c9aed91f29b4eacdb10e23e95ff6343a51d9 preload/data/songs/pico/pico-metadata-erect.json +080c736b90fe813864ce6ad538a64e65e2598ea3 preload/data/songs/pico/pico-metadata-pico.json +aaaf135b64adeb0026aae6797254a62c4dd3712f preload/data/songs/pico/pico-metadata.json +766b77001094cbfa3f679ce3070d4fb12a6ca818 preload/data/songs/roses/roses-chart-erect.json +9521f955f303d91809d46795603580572631d678 preload/data/songs/roses/roses-chart-pico.json +c287d66c21fe3c2d81929001e0c2b97779bc31b1 preload/data/songs/roses/roses-chart.json +65d90d4afd893b1fa938eab51883fec64a877b91 preload/data/songs/roses/roses-metadata-erect.json +9ac9406cd834ed9f80a0ce167e48dfd8fc70b599 preload/data/songs/roses/roses-metadata-pico.json +510b49c4f4e49f56db68aea71386bdc2ab58c620 preload/data/songs/roses/roses-metadata.json +183ab4aeb6527f05fc451cd048bec5ae60fdbe10 preload/data/songs/satin-panties/satin-panties-chart-erect.json +75533a2338c0916a6de00fd5c72079deec642a0b preload/data/songs/satin-panties/satin-panties-chart.json +a9f2368b5cabc67fbfb681831a11b0dd6f3912a6 preload/data/songs/satin-panties/satin-panties-metadata-erect.json +4bfd21f0c4661c249c8d0861e0d53af3fe62dd8f preload/data/songs/satin-panties/satin-panties-metadata.json +88e81b5e246b5aab1b58cc7beb350a7ae83a2f4e preload/data/songs/senpai/senpai-chart-erect.json +bdedf9dc6d5f10bd85fdff2e7f08962b9bfb3c71 preload/data/songs/senpai/senpai-chart-pico.json +fd953b7a5c88a83a310d42262615b0fa6469a12b preload/data/songs/senpai/senpai-chart.json +f80242cab212f26d2528de36e7d2a06ee71dd44d preload/data/songs/senpai/senpai-metadata-erect.json +4424db3ecd19cc87e8d95a1552761dccfd71395a preload/data/songs/senpai/senpai-metadata-pico.json +b73ccd1398837f9eafbb46252d97f00155e8464e preload/data/songs/senpai/senpai-metadata.json +fd671dfcd4df0198151bdeda0e71e21d09fb0661 preload/data/songs/south/south-chart-erect.json +13fda7e21c391e56565e946dc4eac538cb07f0e1 preload/data/songs/south/south-chart-pico.json +b9855869b70c7934703895eff568ea9ebd0a6215 preload/data/songs/south/south-chart.json +0cc370f30ba78e777e6c0c7f3dfd940037305eb4 preload/data/songs/south/south-metadata-erect.json +22be78a7fab3729689a86b19d39ce83fc0d38378 preload/data/songs/south/south-metadata-pico.json +bc87411ba19ec8d4925922f4943bda56b7f7b5f3 preload/data/songs/south/south-metadata.json +c8db2845fe0831c71f35b924401512de8f38a50c preload/data/songs/spaghetti/spaghetti-chart.json +a9f12234209e1d6d6f95fcd495f8abe6f5633795 preload/data/songs/spaghetti/spaghetti-metadata.json +7416843ff7dfe2efb12f2b6e0bd98f55987655ef preload/data/songs/spookeez/spookeez-chart-erect.json +8ff8be88e084f4d36424fa9417d75afad6d81fed preload/data/songs/spookeez/spookeez-chart-pico.json +0cbdac16e610abb3491ecff89196d205dac4da85 preload/data/songs/spookeez/spookeez-chart.json +00983d280eb581989d1b8a053fd2e7b32853f41e preload/data/songs/spookeez/spookeez-metadata-erect.json +d557b08fd4dfed40c51746e71d32dcd6db856770 preload/data/songs/spookeez/spookeez-metadata-pico.json +bc382c7b40261a2d3bfe7b4492a4c6a319def605 preload/data/songs/spookeez/spookeez-metadata.json +2ce31cf790d6d579084030f603738af84b6d0e06 preload/data/songs/stress/stress-chart-pico.json +f41ac97156fdd27f9d2a3196b25bcae5c1b26a27 preload/data/songs/stress/stress-chart.json +ee68edb8a2e5fa53d72a2bb001db4d4efb714631 preload/data/songs/stress/stress-metadata-pico.json +18700e00f71306e9ae38a0bdaab30f549ba34d8c preload/data/songs/stress/stress-metadata.json +b10dc4f16556c034dd434c8fecbf420afb96633a preload/data/songs/thorns/thorns-chart-erect.json +eb7935840011e1b14522fc5514aa67f65b73bf05 preload/data/songs/thorns/thorns-chart.json +f572257af16b92df525ea0391c80652b2c28ac4a preload/data/songs/thorns/thorns-metadata-erect.json +e908f55bc5a3d726808d9ec4a9ed9098cac629b8 preload/data/songs/thorns/thorns-metadata.json +5c7fed61dd5acc3c9531050cf2587ebcaab61883 preload/data/songs/tutorial/tutorial-chart.json +c1171a746bb0ff8d8ca7b68e3fe421b12d5530cf preload/data/songs/tutorial/tutorial-metadata.json +1b88ac6db4d956dea65ade22fa76b3b54c457018 preload/data/songs/ugh/ugh-chart-erect.json +adf7a862f02e45c9dce750e20556b2549068809b preload/data/songs/ugh/ugh-chart-pico.json +1f044c67601c996f1dece438bc7f68207fc0d6fb preload/data/songs/ugh/ugh-chart.json +8eeb2f27bfda3e5e8984d4e76c9c11ee3fa0bbc6 preload/data/songs/ugh/ugh-metadata-erect.json +7fbe97782ed1a85d63bbe6a423c6e852abfbcfc5 preload/data/songs/ugh/ugh-metadata-pico.json +c68ed0d0eb9017e91462d358707537c5fa0a1f23 preload/data/songs/ugh/ugh-metadata.json +54cd736ea718995ec25536bd46d4be597b657fe6 preload/data/songs/winter-horrorland/winter-horrorland-chart.json +3036978d892275b9278496467d5f34f4bd60ff85 preload/data/songs/winter-horrorland/winter-horrorland-metadata.json +36b23862861286c353ed1297df4d066745d82b35 preload/data/stages/limoRide.json +e66ed2153bac40b2a80af9d2d6891ec3adf46511 preload/data/stages/limoRideErect.json +cb5fdf2ba61f2eea5a6921d1a2adfaa6d1ce0ff6 preload/data/stages/mainStage.json +4862bbc2d460037d34724bbade1d68560da2293e preload/data/stages/mainStageErect.json +a0bb173ca23d420876f7fb2ba94571bf462d4415 preload/data/stages/mallEvil.json +67ac2ace41ba18f6ce2fd18a65f37073c879e056 preload/data/stages/mallXmas.json +72a66e211945972a27776b8af0cf7486101a6c38 preload/data/stages/mallXmasErect.json +a61846d8d4fb6ce4248c2367145ff3a6924fe033 preload/data/stages/phillyBlazin.json +e5de132d914e1fc0bac38f8466df34a3d712ea37 preload/data/stages/phillyStreets.json +39cc7205b1bbfa0baddbe7b3a016f1a963779bcb preload/data/stages/phillyStreetsErect.json +fe52f6b0396614ac1af5dd891edfa9bc87cc6bc1 preload/data/stages/phillyTrain.json +a580e0c54ea5914d61f52c5a9b163b2262c0fa8c preload/data/stages/phillyTrainErect.json +491198044714f86c5b87a3c19820dd5e8cbbd569 preload/data/stages/school.json +425a065c546a178a75b32f9c20a939906feeabf9 preload/data/stages/schoolErect.json +6cfa579d767741eebc26529a976e0df252cb1f56 preload/data/stages/schoolEvil.json +7645c1ecb7fbd605fdb1a604aa6acfbccf501245 preload/data/stages/schoolEvilErect.json +8f92e3a3c6234e4b7a8156e2572cbb05aef2ed41 preload/data/stages/spookyMansion.json +43ff86081074b8bf5481d8a62f577b36b2df3dcf preload/data/stages/spookyMansionErect.json +587b51a4af31e09fdb65c48dd97b860c95e0d383 preload/data/stages/sserafim.json +c5458f69949b38036e939c194e640505d9eab262 preload/data/stages/tankmanBattlefield.json +e761dbec5fb18e6990a53bfd002d0baf9360b399 preload/data/stages/tankmanBattlefieldErect.json +3989554802b77893a4ed8180292cd8c353dea1cb preload/images/fonts/bold.png +00982dad5b23e694f2a519479fe9e9fb1b7695f0 preload/images/fonts/bold.xml +3af3314fced55f3907c3f6ff778cc70b47fe03ce preload/images/fonts/default.png +e4bc98eb571f38fa60379d4b62fdb2bf91cf5491 preload/images/fonts/default.xml +096d090b357e555a890295e15833d269ec3f73bb preload/images/gfDanceTitle.png +1a046a5719a347b867a74fd03604886c149690cb preload/images/gfDanceTitle.xml +ad112170ecd8506d562efaa8b9c1ffd1dea45675 preload/images/icons/icon-bf-pixel.png +065f2a8e0f854de8fa783dfc2756a98588ce5421 preload/images/icons/icon-bf.png +7bd414ba48ccbde725bf00becb37841649a8e8c9 preload/images/icons/icon-chaewon.png +33a54b11429870860b4f2c0e2b64fc720442b875 preload/images/icons/icon-dad.png +a08bda4bbf6d9f1e492cc6fb3498a1b231451d27 preload/images/icons/icon-darnell.png +3cc64d74972e8c1551ca9344346c837b0cb6dd2d preload/images/icons/icon-eunchae.png +965356ae10917ab5c8ca7b5bd3cc3e0365547b48 preload/images/icons/icon-face.png +703bd24d52e86259e26b2ddfe93a957e932b14a0 preload/images/icons/icon-gf.png +fd2b43fce614c44b31431815e3362fc7a1671580 preload/images/icons/icon-kazuha.png +201c37a616e430365da91a358fc31f76d059235b preload/images/icons/icon-mom.png +dbc2117f7df0302ede2c19fd9192944495f9e898 preload/images/icons/icon-monster.png +a20bf37ee104a976566e9d238fbf948fc27b6fb4 preload/images/icons/icon-parents.png +027eae1511ea253d508140f02abde4c692058805 preload/images/icons/icon-pico-pixel.png +185759aa6492313ea75ae179fc65dc58bbd61a21 preload/images/icons/icon-pico.png +b27ab6f5949338917fa72c6474894093012193ae preload/images/icons/icon-sakura.png +e6043999184afecc2ed053c23337ec6c6fa8c01e preload/images/icons/icon-senpai-angry.png +dde5803191ce1c93acea7fb5acd71963acb1c2fd preload/images/icons/icon-senpai.png +1b86e81e1350e472e52b6103b34576f7d23f1ca4 preload/images/icons/icon-spirit.png +62a53e9f5399f4e62f491cc6f0beb4db269f883b preload/images/icons/icon-spooky.png +fac050b92a3721416ba7a0dca7d4c9d28f0cf3d7 preload/images/icons/icon-tankman-bloody.png +412378e38c7ca897ab5f971d80990b4ff223e9d3 preload/images/icons/icon-tankman.png +e8f8c33bbfcfefaeb917d7b88140f7462088251f preload/images/icons/icon-yunjin.png +14479df32687389942636e0243563a91b96e0656 preload/images/logoBumpin.png +9dea8d3ab6fd5cc32e1207d25ae4d16776dac515 preload/images/logoBumpin.xml +35458e804c23987f1255155ced4f1b2869eae8f9 preload/images/menuBG.png +0f923397330482aff6f8160be22fb0827df994ef preload/images/menuDesat.png +eba705cdc41baf33d1dccdbdc9e636fe37596878 preload/images/ui/popup/funkin/bad.png +a88f820cfd660fb2087ef0f9beed094af8391505 preload/images/ui/popup/funkin/good.png +e1c74e62b8c8b8eff48ca49841325c34e96f4239 preload/images/ui/popup/funkin/num0.png +fcaac3c216a26e36ec6cae5eb673a371e840e18e preload/images/ui/popup/funkin/num1.png +cd0f34fd509bd976fd42a0244d5406c5614ee311 preload/images/ui/popup/funkin/num2.png +754c5f90cc7127c0eba76fdaa25d950ca99840b5 preload/images/ui/popup/funkin/num3.png +c7187ce442f0780add52f0a4035f361167512a77 preload/images/ui/popup/funkin/num4.png +313f05345c0099ca9ac97d4ac4100ce01395c8ba preload/images/ui/popup/funkin/num5.png +59599d0bffd4b60722fc0f0ce0cd1e48360d108a preload/images/ui/popup/funkin/num6.png +7090cc0247396148cb85d46a7c0cf4b35cde7843 preload/images/ui/popup/funkin/num7.png +0fc0a114831d30543a3a21ca9711bfac032886e4 preload/images/ui/popup/funkin/num8.png +a59dc0159ab2f27b513f1e5fe78798bea75d2d2b preload/images/ui/popup/funkin/num9.png +8603b7bc567e296e76797105fffdcbc943a86c0a preload/images/ui/popup/funkin/shit.png +e6a7c617fd9ffd3c2c027af3059bc8ad15e13e95 preload/images/ui/popup/funkin/sick.png +777214e3b09e8ca8518c8dca0415ff123ed0e3fd preload/images/ui/popup/pixel/bad.png +302986dc9b32e9619c4b81f215d1a9d225a93231 preload/images/ui/popup/pixel/good.png +d002c36cf000bccebf938322c99363b19b5a569e preload/images/ui/popup/pixel/num0.png +d7b9f424f88cdcf699a4ebd091b8b83a0c19debd preload/images/ui/popup/pixel/num1.png +29396f94d1a5473b699c49bf6e3132b90682db29 preload/images/ui/popup/pixel/num2.png +00dde82eb6c1887e9a50ec950023b5a3fb83dc40 preload/images/ui/popup/pixel/num3.png +d3be8b8cbfad365df704b2e85c851a39c21a907e preload/images/ui/popup/pixel/num4.png +cde2a204003f01782affbb47bf1f2a5a6aceeb04 preload/images/ui/popup/pixel/num5.png +cea53eb9cc4b1159b8e37856418502a107eed38d preload/images/ui/popup/pixel/num6.png +f43639e7086f9cadbd83028669266c00df1404e4 preload/images/ui/popup/pixel/num7.png +63531c584c5486df577a9ca56c660966194396cf preload/images/ui/popup/pixel/num8.png +91b05b66d389558bbbe618f341fb36fdc01c0b77 preload/images/ui/popup/pixel/num9.png +46f415ebd6060bb22159189ffc0fa71fd7a64ba3 preload/images/ui/popup/pixel/shit.png +3d7de62442b2e90a0c0820495c9b21dae79fd494 preload/images/ui/popup/pixel/sick.png +98e6a4b2f0d7d607e06e6a68312d6f4a96c23ea6 shared/images/NOTE_hold_assets.png +0b4db7f5c453014ee16ca2059e43bf3670c0e92f shared/images/characters/Pico_Censored.png +e8b39d63caab5bdffa6b788fe2fd79125b4c3ae0 shared/images/characters/Pico_Censored.xml +732a72cd78c4ee834252726b69c2b048cbdf9589 shared/images/characters/SpookyKids.png +757d3fda478ef5599e106485c656a93de20bedce shared/images/characters/SpookyKids.xml +4dee54f4b2a12ff292a88ac004a01ba794e6c017 shared/images/characters/bf-car/Animation.json +f9fd0eb038e4ac6129f4d7692f4b24c95b014286 shared/images/characters/bf-car/spritemap1.json +932d65ce4aaf85f9cd65d3635ebe2b06598677d9 shared/images/characters/bf-car/spritemap1.png +259d2464d42f5ddd8a10865e80b63f29a2eb5073 shared/images/characters/bf-dark/Animation.json +61565d1169616ff8836fe9749d6ec76a767bebf9 shared/images/characters/bf-dark/spritemap1.json +cbf86aaeda834ee4970c06995101814dc733193b shared/images/characters/bf-dark/spritemap1.png +590c0488b6dcf50018c6b2b292eb4f3cf4bb8070 shared/images/characters/bf/Animation.json +bfbc40cf468a51259e2a94a6f38cfdd74212c734 shared/images/characters/bf/spritemap1.json +556d44cccd43028815eac3245c441027b0c73939 shared/images/characters/bf/spritemap1.png +0549ae76639659f6bfa5a683dcffe5a8ca34dc87 shared/images/characters/bfAndGF.png +1175695a38fd58daa02a83a631d7533bc047d996 shared/images/characters/bfAndGF.xml +006ac41ab69c228a6423ea27c78138ea67719321 shared/images/characters/bfChristmas.png +edcf83f8240d1b45542e24db7a98033b1f1ea210 shared/images/characters/bfChristmas.xml +0e9e2ebcc085fe4c107b91ec979a5a4e28d7990a shared/images/characters/bfChristmas_Censored.png +9609f53578310b317bb2e7898533936054b9e4d1 shared/images/characters/bfChristmas_Censored.xml +c3cbeb3734c7a15b0bf871cfbb8d85aee9eae65f shared/images/characters/bfPixel.png +244779a685916b405ac4240a6d505451837d31b5 shared/images/characters/bfPixel.xml +8cf41ce0bb9abd2a8a73508e6a83a24111bbdf11 shared/images/characters/dad/Animation.json +0874415f302fb8b15191a9df211209eea92d7bac shared/images/characters/dad/spritemap1.json +9f75de7aa2d23275e05d3df6ce416d47de4eea76 shared/images/characters/dad/spritemap1.png +2bba55530f4ca694f4a1fcd514793fd33e06bf0c shared/images/characters/darnell/Animation.json +61b8c35ad5af5125f5bf02a3b53472efaf46b4b1 shared/images/characters/darnell/spritemap1.json +a2e21f5a09881541394be44f4fdcff49ea404546 shared/images/characters/darnell/spritemap1.png +78449006105ac3275b0d940ae6fb071f1e2a8095 shared/images/characters/darnellBlazin/Animation.json +2254b89c501298bf2d6d8ba2ea6d03fbcec45d03 shared/images/characters/darnellBlazin/spritemap1.json +67c3e1d14b336a65daa61cc88133e77b6c2206e5 shared/images/characters/darnellBlazin/spritemap1.png +16b73add41287609b9d81c5780802fe87540bf7f shared/images/characters/gf-christmas/Animation.json +3c4aa0b9ec3c48ea8a42b2740c91066b2e1ba1dc shared/images/characters/gf-christmas/spritemap1.json +b7f5626ec79f235f4e3871270a6b99fd2596589a shared/images/characters/gf-christmas/spritemap1.png +fcb8ab733b9ea8fb49173d7b371ac17b3f24fe0f shared/images/characters/gf-dark/Animation.json +fcd3a98b5ae16add121a0e682494e448f0fd83fd shared/images/characters/gf-dark/spritemap1.json +34727771ed9300df29fdc54e97034a78e2d89273 shared/images/characters/gf-dark/spritemap1.png +6917329fcb6755d15d46044ddd8e2362170e46bf shared/images/characters/gf/Animation.json +82bf5630970988ccf1352d37aff17d0a9f87547b shared/images/characters/gf/spritemap1.json +be32ffb6df606bbaa34a8dbdcf9dd1df3d7a74ad shared/images/characters/gf/spritemap1.png +4905bfa4d5001ef58ceec969dd724b552eb62b66 shared/images/characters/gfCar/Animation.json +a465b8bfba0cb78ee074c6716f27fada75a0c1b0 shared/images/characters/gfCar/spritemap1.json +ac12d24fa0dae2fb65c000594ab8647fbeeedb39 shared/images/characters/gfCar/spritemap1.png +22c984c8a40df78f15c329f803d03a54cea278b0 shared/images/characters/gfPixel.png +c2f2e8b55e116042ae4a652580b6530e198b0561 shared/images/characters/gfPixel.xml +445420642ab12597bbca625bc188b529c77f8bde shared/images/characters/gfTankmen.png +e6218f29b16e727cc78186c49a29d210518470ee shared/images/characters/gfTankmen.xml +6e9cf352a01d59e74cc05b0290107525ebca343f shared/images/characters/momCar/Animation.json +987a95316f2b5fefeaae7959c062a4f36fec1711 shared/images/characters/momCar/spritemap1.json +a26af5d7be3335301fe656e491e255fd5883c644 shared/images/characters/momCar/spritemap1.png +0810216147ed42e2c9d93a874bbdb97a4765845a shared/images/characters/monster/Animation.json +376b032bfbb8be0bd30bb15a2b49aeaca14ba46d shared/images/characters/monster/spritemap1.json +72159e95107e69505cea7f5a032706f4b327a992 shared/images/characters/monster/spritemap1.png +18023cc198b4bc842b6787a3c9d535bef01bc1c3 shared/images/characters/nene-christmas/Animation.json +678e92b746912d6571ac3391875eb15d841a29a5 shared/images/characters/nene-christmas/spritemap1.json +6570dcc5968b06498baee8d51eb694eb7ed6cd87 shared/images/characters/nene-christmas/spritemap1.png +6eb37594ee74ced6b0817a5f858f50c9003d7515 shared/images/characters/nene-dark/Animation.json +c0d20b653a13a4d84c1194cf400f64f56cb375c3 shared/images/characters/nene-dark/spritemap1.json +7e3d04884ae44cf4a7db10d413ae3f5d847be5a1 shared/images/characters/nene-dark/spritemap1.png +4a5fbad936f13c0b9d584010d04a36298f45c105 shared/images/characters/nene/Animation.json +2389b6ab259d042536c1a87e6d0e2344d5ae7d50 shared/images/characters/nene/spritemap1.json +257a4ed624d9a695ea447dd27ac5a010737af3be shared/images/characters/nene/spritemap1.png +0289ed4372e2d0ee11902ac43305dd158954914d shared/images/characters/nenePixel/nenePixel.png +ce95eee7f530899e0c36fa37b5c182b319393aaf shared/images/characters/nenePixel/nenePixel.xml +aa8d40b88c20c39d9824ceb59b4f993661d8ffaa shared/images/characters/otis/Animation.json +d9a891b24f0d041e2e86078122f658e687376e35 shared/images/characters/otis/spritemap1.json +fa3d67f097d8833b6c86d7e42876a8d6d1ef1ebb shared/images/characters/otis/spritemap1.png +eced25a2a9cc9418c0955f977c1cb18108a4c3c5 shared/images/characters/parents-christmas/Animation.json +7deb95c0f1e022f7393219a52522bb729fb2b672 shared/images/characters/parents-christmas/spritemap1.json +c8955d23c7210a60be55381f3b7a26f093ce8b4e shared/images/characters/parents-christmas/spritemap1.png +62f99e9187970628597ffd12da890b26768f826f shared/images/characters/pico-christmas/Animation.json +dfb3174448a6e69311482b8547d13600f2de2170 shared/images/characters/pico-christmas/spritemap1.json +04f4d8e70266b3dca40a1ba0166f0855e48866bc shared/images/characters/pico-christmas/spritemap1.png +a22257961280541109e5c0739afb0eedd7d254c1 shared/images/characters/pico-dark/Animation.json +6cb17fd55a6bd006165688dc382cb57e5a079f27 shared/images/characters/pico-dark/spritemap1.json +5c626ff73104ccdbf49e36f697742807d5fa7de9 shared/images/characters/pico-dark/spritemap1.png +596aae18ade136ad0f0e043167022199aeda0f9b shared/images/characters/pico-holding-nene/Animation.json +cd02b83ad0cbcc742c6243be64c2eab27e77d38d shared/images/characters/pico-holding-nene/spritemap1.json +2766eb8019618fe84cab3129afd24755cd61cbcb shared/images/characters/pico-holding-nene/spritemap1.png +2a796eaddbce69d5078f49b5920da8c1eac76451 shared/images/characters/pico-speaker/Animation.json +105725e939295830b72ad004c93497b3729f7ca5 shared/images/characters/pico-speaker/spritemap1.json +42b64b5ae2ae470e023e018b3ec4271133ad8d9d shared/images/characters/pico-speaker/spritemap1.png +69caab3dd6e465a3b784f746808ff06f8519bfcb shared/images/characters/pico/basic-animations/Animation.json +530a548ec538bf7782839133210ec57bd16d33a3 shared/images/characters/pico/basic-animations/spritemap1.json +808d80e8dac71f3e4f191fca02ef134dc3bdafd4 shared/images/characters/pico/basic-animations/spritemap1.png +18abb77d1b62674da1c9e9974e497d2c7a30cde7 shared/images/characters/pico/playable-animations/Animation.json +591d4af09f7aeb17c23eb1aaff759f5fa0f701a1 shared/images/characters/pico/playable-animations/spritemap1.json +4d2de37e50f329d7612345149da7d598b5bd4206 shared/images/characters/pico/playable-animations/spritemap1.png +976a29389bd794fcd46c41c1d10a12d58d8c2dea shared/images/characters/picoBlazin/Animation.json +de296564163c1e1b52a1f5edd9ee17ac850d6769 shared/images/characters/picoBlazin/spritemap1.json +49c767a595fe12f2ec58acf03ab298b5ce8da516 shared/images/characters/picoBlazin/spritemap1.png +1c79bf6afb13a6f3a7ba35b69567ebb354c46dde shared/images/characters/picoPixel/picoPixel.png +c359068cca5b93d32225aa50c641df580d2d0e29 shared/images/characters/picoPixel/picoPixel.xml +fa5a3293d7bf8657e5c8dfc9b4205647a052c34f shared/images/characters/senpai.png +e51215e130a194581478ac76e93f50e6f837e399 shared/images/characters/senpai.xml +d9b13396d2328bb2e8fd70fbd19245b12d5de98b shared/images/characters/spirit.png +c5d79cfa517c23f359e33be5372b8c6abde1d1e5 shared/images/characters/spirit.txt +d5ebd9df834350298a9ed610034a0cf2f37e7536 shared/images/characters/spooky_dark.png +d252fb8789056b423ad6e04d8b398ab489ab38d5 shared/images/characters/spooky_dark.xml +e8ce587d45e1ce2cd5412e0c881790b225ac0f98 shared/images/characters/sserafim/chaewon/Animation.json +77b7696a3d0d61796fb9c1e526d0c6bfd542392b shared/images/characters/sserafim/chaewon/spritemap1.json +87d0fac7dc3f6c5caf220f3e72bd7d3337f7feba shared/images/characters/sserafim/chaewon/spritemap1.png +124e6e0dca1b9c3a1af06d670e93142835643519 shared/images/characters/sserafim/eunchae/Animation.json +75af1cc915eae4a2652afe9f2f493c1566153e28 shared/images/characters/sserafim/eunchae/spritemap1.json +c7e0189e0e93af9bbac6823476489f3ab06e5d01 shared/images/characters/sserafim/eunchae/spritemap1.png +83fdc0a68530301b625303410120f7ca17f34e38 shared/images/characters/sserafim/kazuha/Animation.json +63d5eb8f6082570802e759dea7982dc542bb68bc shared/images/characters/sserafim/kazuha/spritemap1.json +4fa9bf7e3687c065253becb3ae86914bf06b1f92 shared/images/characters/sserafim/kazuha/spritemap1.png +5036423af1c228e0f0186898814d8fa87c8ea177 shared/images/characters/sserafim/sakura/Animation.json +00acf7aadef4d25b6a0bfc5d9976a479ff60cc03 shared/images/characters/sserafim/sakura/spritemap1.json +f0ee58a2d670c8eeb235283f466f7bcfdf8e9505 shared/images/characters/sserafim/sakura/spritemap1.png +952df5f122ad39d0e682d043f0c851bdd9ec5614 shared/images/characters/sserafim/sserafim-gf/Animation.json +474052ff28da70717704f4656b4c35e190341089 shared/images/characters/sserafim/sserafim-gf/spritemap1.json +ecf3a847188f8e766eb76988d40991563ab41c83 shared/images/characters/sserafim/sserafim-gf/spritemap1.png +7df01fbc2f56cf6f3777752b7326a3fee10c19b9 shared/images/characters/sserafim/yunjin/Animation.json +784f0e40ae8cd578e96223f1a19a44113059bd7c shared/images/characters/sserafim/yunjin/spritemap1.json +c7f686acf4ba4133b464f384f223c68813d8634c shared/images/characters/sserafim/yunjin/spritemap1.png +7e7a1c22a009d39c881772e50cd6f2c5cff4b3e4 shared/images/characters/tankman/basic/Animation.json +12f082128d04fa70e9f74797fcba1312082041e3 shared/images/characters/tankman/basic/spritemap1.json +1336a39cdfba32cb6257c733e7c529c987c5af7e shared/images/characters/tankman/basic/spritemap1.png +49761cd7daaca2ff31939b46a1cbf9c280c6b3db shared/images/characters/tankman/bloody/Animation.json +e939057ce2ffa72dcbb5bfe47479b07d83f1f336 shared/images/characters/tankman/bloody/spritemap1.json +9f549b8deec536d03a7235ce6edc25401f68dd6c shared/images/characters/tankman/bloody/spritemap1.png +fb756b9c15029f734a26ee93a4757f72f9fbb787 shared/images/characters/tankman/extra-animations/Animation.json +64b91cf39e97f425522f3bd5995f3a739aab922e shared/images/characters/tankman/extra-animations/spritemap1.json +5a453d3ac91df313ed3ee4b864b5461757098a58 shared/images/characters/tankman/extra-animations/spritemap1.png +b34ed815def1a25bb2537c20b170db6d5e19d084 shared/images/noteSplashes.png +857ce022b80f8006299f8720594a1fa23dffb34e shared/images/noteSplashes.xml +b15485ecb31c884aa5527fdca9b7aacb23118499 shared/images/noteStrumline.png +dd5a2d3b8ab4ddcbce557fdb778e065aac5b6100 shared/images/noteStrumline.xml +7abe4fb53651ff8ec04c6a76730a98a40641a5dd shared/images/notes.png +a3f7297548b0846a264f9849b2ea03a67977149b shared/images/notes.xml +bffdda1384a0a421d5bdf8a579e70d766e0b182d shared/images/stageback.png +de33c06e5fb30667f58a27caa83d36546bbd953b shared/images/stagecurtains.png +de26bfe1129a001ef0e6bf87b9384f4457729d65 shared/images/stagefront.png +78cc7f68cffd458477c7d8af9b6e2180a961e318 shared/images/ui/countdown/funkin/go.png +380ed37f0fe11343c3c8e42ac0c7b143241866d7 shared/images/ui/countdown/funkin/ready.png +c10518e277d71109ce5a95330e2d17c6bb0c652b shared/images/ui/countdown/funkin/set.png +ee331059d2097e177c8dcfeae2015aed61b482a5 shared/images/ui/countdown/pixel/go.png +7de73278c50d87601dde07064ed422620e3e39c1 shared/images/ui/countdown/pixel/ready.png +02d89d6e7526314517a421b9e35de762361f4b5d shared/images/ui/countdown/pixel/set.png +e0b107dd2f476df5e7380fc704b3ea428d9c33c1 sserafim/images/back-stools.png +692b8efa5ad837892155e9ecaf25ea26764fca61 sserafim/images/back-tables.png +8ac8f05492f07f9866bd6b54979b0cc4282d4c96 sserafim/images/bg.png +d463e26631e16c04f1bfa5a5549d7521296d4a3d sserafim/images/front-stool.png +b8c5979dd3b0018bcd78234bbabd6dd56464c288 sserafim/images/truck-stuff.png +8172b3ce008ea844f2e14167c89b9215b39e0eea week1/images/erect/bg.png +09e68a6966554a85a5e820a952cfea2f8d202d7e week1/images/erect/brightLightSmall.png +4cacf42f39ef8f6dc34e08b3d6adc2118864bca7 week1/images/erect/crowd.png +e16595e7d5e95a222ade568b9de5f7dcfe5e4101 week1/images/erect/crowd.xml +98ea28768c296028fcabd5d7e3257968a8984cf7 week1/images/erect/lightAbove.png +ef9788ba430388ef4b5320bcf275746a2a7fa72b week1/images/erect/lightgreen.png +686ffb36df552ccfce75eacc9ba01791acf43467 week1/images/erect/lightred.png +164fce0b542fcf8486486e804088144ce645da50 week1/images/erect/lights.png +848c9291c76f7fd056d33b658a2911cf74331d3f week1/images/erect/orangeLight.png +1040c127f25fef594a3cf4a828edd16c4090408a week1/images/erect/server.png +7d0a288579d8c8866e5b7f1b2abaa8837a700549 week2/images/erect/bgDark.png +7fd0aec9d072e66b586c2637fbe104209d063dbb week2/images/erect/bgLight.png +e7dd264c05df2b8af0293cb28d2fe0fc13280b45 week2/images/erect/bgtrees.png +61ae310932e47d00241e112c7bca9a5aac5d7da2 week2/images/erect/bgtrees.xml +d1ab22896a3ed3c7b3bd56883eece775c1c528f2 week2/images/erect/stairsDark.png +84bde1daa3a7de4b02c8c9c4a1b1af2c983b44d5 week2/images/erect/stairsLight.png +5681779464e4f8db3f92566b5d89289fad1c14b3 week2/images/halloween_bg/Animation.json +e0930c60395655511e41b334689cc42103ffe6d1 week2/images/halloween_bg/spritemap1.json +515bdc3c524518b62589f5d0d2939a1d54446a36 week2/images/halloween_bg/spritemap1.png +975338ce227a7409cce14e487c63dca8bdc49280 week3/images/philly/behindTrain.png +c1d1b22ce933db4c33deff58cae8deca888a15ef week3/images/philly/city.png +2f1bbb8aa150b18d4fd2359b1b525790c5c28b46 week3/images/philly/erect/behindTrain.png +6de17ee853721495c3e57a62599e5edbb52c9eac week3/images/philly/erect/city.png +19f18f2d2229a863b9a3d3dd28ffbdf4fa73b2bc week3/images/philly/erect/sky.png +85462a8fb6bfe4f9f2d96d95a8ac36659fe7246f week3/images/philly/erect/street.png +cf502063755e9cb3b6b441538a1f0009b1e4529b week3/images/philly/sky.png +f684d1e5bbef10503af9318c24646e83cc095a93 week3/images/philly/street.png +7a89e6e47dd9f18ea12bf0e580fce27a42c03807 week3/images/philly/train.png +92647b5b75748dc611f0334d9009f5cba70343c3 week3/images/philly/win.png +fc9db85ab2346bc08c4d47c43ad467be885414e4 week4/images/limo/bgLimo.png +6034dc79d9c39edd9e6285576e3a5ca7c2e11c45 week4/images/limo/bgLimo.xml +5e5111c49b76aaa5841d0b7762944929b4ad8aee week4/images/limo/erect/bgLimo.png +bba550ab5d7a11d07fcfc9265dd40f9159a5964d week4/images/limo/erect/bgLimo.xml +fd8d52ec938a8c4f3f2e96c401898a323eb10a62 week4/images/limo/erect/limoDrive.png +f8db7fabc069af0b1b959c676d336c0a0e460975 week4/images/limo/erect/limoDrive.xml +ae41686c17a77bb1f9e74be1800c43cdcb98d32d week4/images/limo/erect/limoSunset.png +c904f5bf79db6d4a709d37db687155127a1c5c93 week4/images/limo/erect/shooting star.png +64dff2f4d59ea11b9e8c67ac4566734b98abbf69 week4/images/limo/erect/shooting star.xml +392bc8e4a6f9223f6c53feb41c06aef72b3e6192 week4/images/limo/fastCarLol.png +4df9fbeeabaa1c5e1472d298face97c9ddf0c666 week4/images/limo/henchmen.png +8a84a9a74f95ebb05b11933b55c348374dd68305 week4/images/limo/henchmen.xml +bbc0e21ec78bf0fc64b55ae6a169239098b1f9ce week4/images/limo/limoDrive.png +7c7a1b8ce1c073331f3e796d0bc2ffa5dc08398b week4/images/limo/limoDrive.xml +f15d75e4883d818834671799732175ae0cfcf170 week4/images/limo/limoSunset.png +e836207b615ff99517f26eb9f8d7d0a95006bc5e week5/images/christmas/bgEscalator.png +e5221c5c626b1152daf372891234baffcf59e499 week5/images/christmas/bgWalls.png +623018fbaec4e1d8c3da8964aa52754332dfff00 week5/images/christmas/bottomBop.png +6ca44150325e5751f4fc8e4b4d1bea9eeca67d74 week5/images/christmas/bottomBop.xml +0d7413d7b0c63150ec5e4faa4149f420291ce982 week5/images/christmas/christmasTree.png +25f361eb290f1100a65316b5596473e98304d383 week5/images/christmas/erect/bgEscalator.png +fb1e93ea8750c90f95b3bf3c2fa23dbacde17996 week5/images/christmas/erect/bgWalls.png +848cc22e4280bfd1b962c570430b54448db0f3b6 week5/images/christmas/erect/bottomBop/Animation.json +b874ee876ec7ffa3175b7e890730e9d362f766e1 week5/images/christmas/erect/bottomBop/spritemap1.json +2da235f0108ababa89a7a4bb5ad0b3d9cd9ce15d week5/images/christmas/erect/bottomBop/spritemap1.png +fb99871ae85481c7c06b3f5e35b0675420ff9735 week5/images/christmas/erect/christmasTree.png +1218d965492f1df1b1133d155ef3ec740c5dc881 week5/images/christmas/erect/upperBop.png +6e6b6c2db4e26d32e519783871a1592d1c6aa7cf week5/images/christmas/erect/upperBop.xml +fda05f648603dd7c1ed17bcf1450afb17854565b week5/images/christmas/erect/white.png +a08b4b8ef8e86a1438fc1861cda99825d6779bfc week5/images/christmas/evilBG.png +29e4c940b1926bfdd40f45556911889aac4710f8 week5/images/christmas/evilSnow.png +2a538a1060636f01c85a2632a13aee51e1431ed8 week5/images/christmas/evilTree.png +2223ac5864962990309c4c2ac84c0a2a6b80718b week5/images/christmas/fgSnow.png +e05fd0d7e7721f9fd2f281b83296d2c732b38db5 week5/images/christmas/santa.png +b0c971d9e2e42d0240036e0d3332f1166d2fefe4 week5/images/christmas/santa.xml +18941bddd7ba44e56606fd7c4e6b6a9ecf144c48 week5/images/christmas/upperBop.png +e2b2c8d65d4546343fb2e53dce683d0da691e4c6 week5/images/christmas/upperBop.xml +5ae10c3c092b5057835b66848f293fe4f01108d0 week6/images/pixelNoteSplash.png +02dd61d0f5ae926798b45aa638a0d49a3a7ae02f week6/images/pixelNoteSplash.xml +12800cadc03dfbb7183c1ad3cd7174667519aa20 week6/images/weeb/bgFreaks.png +40c04f485ed0a4f3bb9d0296fae8c497681f0adc week6/images/weeb/bgFreaks.xml +c03a056fad2e69bdff162839631a26f21a2bef75 week6/images/weeb/erect/evil/backSpike.png +c54a582398a7a7a39c06652ba5f5b32e3f5a3cc8 week6/images/weeb/erect/evil/weebBackSpikes.png +0caefd779767de74549819ceec8d57336d88f0de week6/images/weeb/erect/evil/weebSchool.png +89681ba01a5774510fa8f54267b7998126729ddb week6/images/weeb/erect/evil/weebStreet.png +898ea213e4136a75977add6e941bcc250b6fb989 week6/images/weeb/erect/petals.png +4fc02caf4a17353c650a5fa6e838d9c4fcd2333e week6/images/weeb/erect/petals.xml +8ee051721bbb4e7ae30246f3109a73709df540c4 week6/images/weeb/erect/weebBackTrees.png +e17c6a5796b0c77097855b8c1ef919baffa7a49b week6/images/weeb/erect/weebSchool.png +b5bfcf59c206a3620ebd5184913a80bb0b942741 week6/images/weeb/erect/weebSky.png +c9ef46b2f0b941a24e1fd61d82a6ffdcd1d80f0d week6/images/weeb/erect/weebStreet.png +c2f8257f919972ee7f891e24480c56807a51d9d8 week6/images/weeb/erect/weebTrees.png +7846c0084ddcd5c9b88b59dd4c37117a24811c9f week6/images/weeb/erect/weebTrees.txt +fb933d2b41a032d0e09d63eb7ae5475eebc2c94a week6/images/weeb/erect/weebTreesBack.png +50b1acab390098a0a238a12069371bce57b52516 week6/images/weeb/evil/weebBackTrees.png +00a698c2ce7d2714ee18cd504802d412abff8732 week6/images/weeb/evil/weebSchool.png +31f3df3f773221b36c69f57814a3863204631ae4 week6/images/weeb/evil/weebStreet.png +6dcc14455517f98e2a909907614c145ace89c67d week6/images/weeb/evil/weebTrees.png +3c597ff1aa4f99ad60e0b91ef3a0fe51137aa943 week6/images/weeb/petals.png +4fc02caf4a17353c650a5fa6e838d9c4fcd2333e week6/images/weeb/petals.xml +a468c83a9e87d2652b763c74b096f322adbbdeee week6/images/weeb/pixelUI/arrowEndsNew.png +d4469d6bd03ac086ecfafad0e578f00c2af3c44c week6/images/weeb/pixelUI/arrows-pixels.png +2779acc776142ac93099a3242ea668e4ea575f91 week6/images/weeb/pixelUI/arrows-pixels.xml +29d45059f64d41f021fda364e2ee3f5e308c3e6d week6/images/weeb/weebBackTrees.png +c04d0a7ef4ff55cc1bec1328c7d4e30ebc117860 week6/images/weeb/weebSchool.png +ef3eec1322d8a2f2b6bf1fcaeb057425e3a1234c week6/images/weeb/weebSky.png +de1e4586fffc47b254ae4fbc5d74fdba0ef476e2 week6/images/weeb/weebStreet.png +627f2e083e7f2a8ade12cc16ec5c3588c6f3513d week6/images/weeb/weebTrees.png +7846c0084ddcd5c9b88b59dd4c37117a24811c9f week6/images/weeb/weebTrees.txt +2bb981bb30309eb67983d729d4b9246d9a22fede week6/images/weeb/weebTreesBack.png +ad440dce1a10b89c79f9868440ef08e391701a11 week7/images/bricksGround.png +81714721bfbce218a45d21ff83874aad22767431 week7/images/cityruins2.png +24accd57ce702cced9226f848519d09766070e39 week7/images/erect/bg.png +953e2d7b2eb2f9cb30abdb835afa37714fafd461 week7/images/erect/bricksGround.png +015320a3b0f6601c70b5387a59e5d1079232ec4e week7/images/erect/rando/Animation.json +f53fccd7976bfbfdf819c1c3817ccc9550d4d6ef week7/images/erect/rando/spritemap1.json +5bb0b0ce423b0066f21cfd73095ab693c506716d week7/images/erect/rando/spritemap1.png +028fa83adc59bc105f3456e6d31b77d4b28d192f week7/images/erect/sniper/Animation.json +cd4915b2b69dd4d9ddda3733d81ad078389616f4 week7/images/erect/sniper/spritemap1.json +c855fccdd6efdad67179997311f1de720c3206c1 week7/images/erect/sniper/spritemap1.png +32b2016646ed35a6329c047668269f01df2fe3e3 week7/images/mountains2.png +f8c9dc4ba8da7bc1bd28f57b5223ec96b17cc1af week7/images/smokeLeft.png +ebe22df53d6b5f503ad45a7b290e1cb7690647cc week7/images/smokeLeft.xml +8f93375a849d46e272e70677bffc343c008cd9c7 week7/images/smokeRight.png +ed9bd87c05e0a41b53dc71ef26979fc5ae5da1a8 week7/images/smokeRight.xml +d3387f66e467eedcfe790cf2fc160ad95373fda6 week7/images/tank0.png +6d8b68e5ef7101b0e81cba089287d1066862021d week7/images/tank0.xml +ad82dec4e12ed7b6e70b671c9491fd97099c32f4 week7/images/tank1.png +6fd8bec54403d8398c3c1469ec154e5accd4a8a0 week7/images/tank1.xml +15f280e3a3cda6ab9110a4d24e92001d36889ee4 week7/images/tank2.png +34d9b18f2be0600abc201f457637bde4e8f5090c week7/images/tank2.xml +d3281b8670f14b0d385772bb297ae14d22797b19 week7/images/tank3.png +38d1a4e64f3e919dc3a53e4ff4725d8d3e184060 week7/images/tank3.xml +a111eef4613a2d621e8a673047f4bd44554283e1 week7/images/tank4.png +671526a9231ccdbf2a21b5f1f6e0f2fd181dd09a week7/images/tank4.xml +b955747c6239783e1f62ddcbc5e15ed47e71b8fc week7/images/tank5.png +68dbf8e7cbbc3aafca0d91bd7b5c61718f835c78 week7/images/tank5.xml +7ebdcc195acf87f4a2baf925f953c05518b31f52 week7/images/tankBuildings.png +956b9e9e577de33f8060ae6160299d79417cdb8f week7/images/tankGround.png +3575f6c2858091957d9eb08841cb8edf7cf5701a week7/images/tankRolling.png +52fadf56d679fbe805a9c2656e9946e673859eb2 week7/images/tankRolling.xml +606d4f6d02153caa35db92ef05f3a8e06181249a week7/images/tankSky.png +30a72b46374633b75687dc8c52fef7aa090d9030 week7/images/tankWatchtower.png +7dc554733c1330fde8a211c08c8629c01a606cee week7/images/tankWatchtower.xml +ded70d2091d854f6202a8014fff4633fdb9aabeb weekend1/images/SpraycanPile.png +dbe099974d808e0db8efc58f6e30fb2637a39819 weekend1/images/phillyBlazin/lightning.png +1dd202d5fc632113fddc303a5f40a22f4e20b8a2 weekend1/images/phillyBlazin/lightning.xml +edaf14f527bc78f9afd16b3775d1fc5b6b32c992 weekend1/images/phillyBlazin/skyBlur.png +92ebd45710395c967b9819a5b1580ffd39c9c035 weekend1/images/phillyBlazin/streetBlur.png +b958b08cd75ca10014a4c14ffbfe8bb163f20f67 weekend1/images/phillyStreets/erect/greyGradient.png +ecd3b31eabd528d36b2412cd43e7d2c996eaa18c weekend1/images/phillyStreets/erect/phillyCars.png +f5aef9ad24cb82163172443c22f92f2ea182be82 weekend1/images/phillyStreets/erect/phillyCars.xml +a342f8bea5ba41dc72d7bb27c7583c8de9ca6562 weekend1/images/phillyStreets/erect/phillyConstruction.png +6d928f7ece7b17eba3f05521d2af474fe92a950c weekend1/images/phillyStreets/erect/phillyForeground.png +6cca558de6480f3e4f1bd2671e7ee0c9e81553be weekend1/images/phillyStreets/erect/phillyForegroundCity.png +7d9fbf454dffca43714b9003191304fb35f92189 weekend1/images/phillyStreets/erect/phillyHighway.png +dfec01f54a965975ed84c9f7b6463d0b494e2d1f weekend1/images/phillyStreets/erect/phillyHighwayLights.png +608b885e583e252afb8c2a27f5d7000844824f68 weekend1/images/phillyStreets/erect/phillySkyline.png +ba5823cfa2e45a569a7d157844e1c77dcd23c14e weekend1/images/phillyStreets/erect/phillyTraffic.png +d1c1c9b1ac7c613fd78f62a319507ada34d3ef88 weekend1/images/phillyStreets/erect/phillyTraffic.xml +44c1eadf97a841bd8910664b4c72629ed4a48a19 weekend1/images/phillyStreets/erect/phillyTraffic_lightmap.png +0d1d09b1dae7ae4062dcb155707eaf5b5a41fcdf weekend1/images/phillyStreets/phillyCars.png +ac642a55e8bf9435a3d1aff3c1f70f01afb69667 weekend1/images/phillyStreets/phillyCars.xml +57297dab6313092d94230443c2eb0eedf3596939 weekend1/images/phillyStreets/phillyConstruction.png +599a18af3ae8f254f8564bb9dfe2c4d538e9b6e1 weekend1/images/phillyStreets/phillyForeground.png +8ed54dc68b5a73c02053e09e284c390c9b481554 weekend1/images/phillyStreets/phillyForegroundCity.png +dd30ffe0a92ee4c783c5ad4bd16e1bcf8d5895b3 weekend1/images/phillyStreets/phillyHighway.png +bbe325362649a884f2937f42959cd076842dc2ce weekend1/images/phillyStreets/phillyHighwayLights.png +168989235c9b7a1b4f7c701a74e4a2044f91fc27 weekend1/images/phillyStreets/phillyHighwayLights_lightmap.png +669cb7088efd98806f6665318c9b3c1db5be07cf weekend1/images/phillyStreets/phillySkyline.png +700b3a717ab6e0919a7af642ab24f9bceeaa87c6 weekend1/images/phillyStreets/phillySmog.png +5f3da3cec81b111b7c7ca541c2d00e7b039b00bc weekend1/images/phillyStreets/phillyTraffic.png +38b7d6bb61232a8fb5a71074ad01ee8e8f0972e9 weekend1/images/phillyStreets/phillyTraffic.xml +0f00f0e140f91dbd473a8f363583435337e5df06 weekend1/images/phillyStreets/phillyTraffic_lightmap.png diff --git a/games/games.json b/games/games.json index 75ccd0e6..ab7afb1d 100644 --- a/games/games.json +++ b/games/games.json @@ -677,7 +677,7 @@ }, { "id": "championisland", - "rank": 19, + "rank": 20, "title": "Champion Island", "tagline": "Seven sports, one island", "credit": "Based on Google's Champion Island Games", @@ -699,7 +699,7 @@ }, { "id": "finalearth2", - "rank": 20, + "rank": 21, "title": "The Final Earth 2", "tagline": "Build a city in space", "credit": "Based on the game by Florian van Strien", @@ -721,7 +721,7 @@ }, { "id": "numpowder", - "rank": 15, + "rank": 16, "title": "NumPowder", "tagline": "Sand, water, fire and more", "credit": "Inspired by The Powder Toy", @@ -753,7 +753,7 @@ }, { "id": "subway", - "rank": 4, + "rank": 5, "title": "Subway Surfers", "tagline": "Dodge the trains", "credit": "Based on Subway Surfers by SYBO and Kiloo", @@ -775,7 +775,7 @@ }, { "id": "fnaf", - "rank": 13, + "rank": 14, "title": "Five Nights at Freddy's", "tagline": "Survive five nights", "credit": "Based on the game by Scott Cawthon", @@ -797,7 +797,7 @@ }, { "id": "doom", - "rank": 11, + "rank": 12, "title": "Doom", "tagline": "Rip and tear", "credit": "Based on Doom by id Software", @@ -836,7 +836,7 @@ }, { "id": "isaac", - "rank": 17, + "rank": 18, "title": "The Binding of Isaac", "tagline": "Cry through the basement", "credit": "Based on the game by Edmund McMillen", @@ -858,7 +858,7 @@ }, { "id": "hillclimb", - "rank": 18, + "rank": 19, "title": "Hill Climb Racing", "tagline": "Drive up, don't flip", "credit": "Based on Hill Climb Racing by Fingersoft", @@ -880,7 +880,7 @@ }, { "id": "pvz", - "rank": 7, + "rank": 8, "title": "Plants vs. Zombies", "tagline": "Plant your lawn", "credit": "Based on the game by PopCap Games", @@ -902,7 +902,7 @@ }, { "id": "sonic", - "rank": 16, + "rank": 17, "title": "Sonic the Hedgehog", "tagline": "Run through six zones", "credit": "Based on the game by Sonic Team", @@ -923,7 +923,7 @@ }, { "id": "cookieclicker", - "rank": 22, + "rank": 23, "title": "Cookie Clicker", "tagline": "Bake billions of cookies", "credit": "Based on Cookie Clicker by Orteil", @@ -944,7 +944,7 @@ }, { "id": "leveldevil", - "rank": 21, + "rank": 22, "title": "Level Devil", "tagline": "Run to the door", "credit": "Based on Level Devil by Unept", @@ -965,7 +965,7 @@ }, { "id": "jetpackjoyride", - "rank": 5, + "rank": 6, "title": "Jetpack Joyride", "tagline": "Fly Barry through the lab", "credit": "Based on Jetpack Joyride by Halfbrick", @@ -986,7 +986,7 @@ }, { "id": "stickmanhook", - "rank": 9, + "rank": 10, "title": "Stickman Hook", "tagline": "Swing from hook to hook", "credit": "Based on Stickman Hook by Madbox", @@ -1007,7 +1007,7 @@ }, { "id": "donkeykong", - "rank": 14, + "rank": 15, "title": "Donkey Kong", "tagline": "Climb the girders and save Pauline", "credit": "Based on Donkey Kong by Nintendo", @@ -1028,7 +1028,7 @@ }, { "id": "dune", - "rank": 10, + "rank": 11, "title": "Dune", "tagline": "Roll down the dunes and fly over the line", "credit": "Based on Dune! by Team Lava", @@ -1049,7 +1049,7 @@ }, { "id": "monkeymart", - "rank": 6, + "rank": 7, "title": "Monkey Mart", "tagline": "Run a grocery mart as a monkey", "credit": "Based on Monkey Mart by TinyDobbins", @@ -1070,7 +1070,7 @@ }, { "id": "tombofthemask", - "rank": 8, + "rank": 9, "title": "Tomb of the Mask", "tagline": "Dash through the tomb ahead of the lava", "credit": "Based on Tomb of the Mask by Playgendary", @@ -1091,7 +1091,7 @@ }, { "id": "uno", - "rank": 12, + "rank": 13, "title": "UNO", "tagline": "Match the cards, shout UNO", "credit": "Based on UNO by Mattel", @@ -1109,5 +1109,26 @@ "repo": "https://github.com/Mason363/NumPlay/tree/main/games/uno", "default": false, "xl": true + }, + { + "id": "fnf", + "rank": 4, + "title": "Friday Night Funkin'", + "tagline": "Win the rap battles, note by note", + "credit": "Based on Friday Night Funkin' by the Funkin' Crew", + "top": "0x3A1F5D", + "bottom": "0x140A24", + "accent": "0xF9393F", + "colors": 16, + "records": [ + "fnf.sav", + "fnf_saves.py" + ], + "shots": [ + "docs/shot.png" + ], + "repo": "https://github.com/Mason363/NumPlay/tree/main/games/fnf", + "default": false, + "xl": true } ] diff --git a/tests/test_games.py b/tests/test_games.py index 0313aaf7..c3ddf8e3 100644 --- a/tests/test_games.py +++ b/tests/test_games.py @@ -250,6 +250,10 @@ def every(t0, t1, step, key, d=80): # keeps the game. Next time, from the copy, Continue: back at the table, quit with On/Off "uno": ("UNO.nwa", "uno.sav", presses((1500, "ok"), (2200, "ok"), (2900, "back")), 6000, presses((1500, "ok")), 4000), + # the title, Story Mode, Week 1, how to play (the first time: Back skips it), then Bopeebo plays; Home keeps the + # guide read. Next time, from the copy: Options, downscroll on (Back saves it), quit with On/Off + "fnf": ("FridayNightFunkin.nwa", "fnf.sav", presses((1500, "ok"), (2200, "ok"), (2900, "ok"), (3600, "back")), 7000, + presses((1500, "ok"), (2200, "down"), (2700, "down"), (3200, "ok"), (3900, "ok"), (4600, "back")), 6000), # a level, its message, then play; then back to the level select "portal": ("PortalReturns.nwa", "portal.sav", presses((1500, "ok"), (2500, "ok"), (3500, "ok")) + every(4000, 7000, 400, "right", 300), 8000, @@ -315,7 +319,8 @@ def monkeymart_save(): "cookieclicker": "cookieclicker_saves.py", "leveldevil": "leveldevil_saves.py", "jetpackjoyride": "jetpackjoyride_saves.py", "stickmanhook": "stickmanhook_saves.py", "donkeykong": "donkeykong_saves.py", "dune": "dune_saves.py", "monkeymart": "monkeymart_saves.py", - "tombofthemask": "tombofthemask_saves.py", "uno": "uno_saves.py"} + "tombofthemask": "tombofthemask_saves.py", "uno": "uno_saves.py", + "fnf": "fnf_saves.py"} def dk_frames(v): @@ -464,6 +469,10 @@ def dk_frames(v): # the table (16) in its first hand (hands at 682); after the reinstall, from the copy, the same game goes on "uno": (lambda v: len(v) == 728 and v[:4] == b"UNO1" and v[12] == 1 and v[13] == 1 and v[16] == 4 and struct.unpack_from("= 10, lambda v: v[4] == 0 and struct.unpack_from("= 1), # best kept, shake off # Air Hockey and the easy computer chosen; then the normal computer and Tanks, the tallies of the six games kept diff --git a/tools/release.py b/tools/release.py index 0b1c27c2..b118d0ac 100644 --- a/tools/release.py +++ b/tools/release.py @@ -49,6 +49,7 @@ "monkeymart": ("Monkey Mart", "run a grocery mart as a monkey, both marts: crops, animals, machines and workers"), "tombofthemask": ("Tomb of the Mask", "dash through the tomb: the endless arcade climb and all 200 stages, with the 16 masks"), "uno": ("UNO", "the card game against one to three bots, with the official rules and scoring to 500"), + "fnf": ("Friday Night Funkin'", "the Tutorial, Weeks 1 to 7, Weekend 1 and LE SSERAFIM, in story mode and freeplay"), } VARIANTS = [ ("NumPlay-Invisible.nwa", "The same app, hidden: a blank icon with no name"),