diff --git a/.gitignore b/.gitignore index 784d17e8..6be425f3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ /intermediate /build +# Log +/log + # Backup /backup diff --git a/.luacov_all b/.luacov_all index 79b633e2..a4f78ebd 100644 --- a/.luacov_all +++ b/.luacov_all @@ -1,15 +1,15 @@ return { include = { - "src", - "tests/bustedhelper", - "tests/pico8api" + "src" }, exclude = { + "src/engine/tests", "src/engine/wtk", "src/game/itests", - "src/game/test_data" + "src/game/test_data", + "src/game/tests" } } diff --git a/.travis.yml b/.travis.yml index c2bc0fce..12af83f7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ os: - linux env: - - LUA="lua 5.3" BUILD_VERSION="2.2" + - LUA="lua 5.3" BUILD_VERSION="2.3" cache: apt: true # private repositories only diff --git a/LICENSE b/LICENSE index 732b1abb..4f9ba177 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2018 Long Nguyen Huu +Copyright (c) 2018-2019 Long Nguyen Huu Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 0db66060..74a96072 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,17 @@ A partial clone of classic Sonic the Hedgehog games made with PICO-8 ## Features -Version: 2.2 +Version: 2.3 ### Physics * Character runs on flat ground and slopes -* Character is blocked by walls when running +* Character is blocked by walls when running, walls and ceiling when jumping * Character jumps with variable height ### Rendering -* Character rendered with Idle and Spin sprite +* Character rendered with Idle and Spin static sprite, Run animated sprite * Environment rendered with tilemap ## Build @@ -56,13 +56,15 @@ It will install `luamin` (along with `luaparse`), which is used in `npm/luamin_f ### Build and run +First, make sure the `pico8` executable is in your path. + The most straightforward way to build and run the game on Unix platforms is: * `cd path/to/sonic-pico8-repo` * `./build.sh main release` * `./run.sh main release` -or, if `pico8` is not in your path: +Instead of the last instruction, you can also enter directly: * `pico8 -run build/sonic-pico8_v${BUILD_VERSION}_release.p8` @@ -115,9 +117,31 @@ Those tests need to be built with picotool and run with PICO-8. We recommend the * `./build.sh itestplayercharacter itest_light` * `./run.sh itestplayercharacter itest_light` -### New project +## Modding + +You can modify the spritesheet used in the build pipeline by running the custom Sublime Text build command `p8tool: edit data`, or in the shell: `pico8 -run data/data.p8`. + +This will open the cartridge `data.p8` in PICO-8. This cartridge contains only assets, and no code at all. Make your changes, save the cartridge, then build the project to see your result. + +For fast iterations, you can also directly modify assets while running the built game, but remember your changes are temporary and will be overwritten by the next build. To preserve your changes, you must save the cartridge, open it and copy the data parts (`__gfx__`, `__gff__`, `__map__`, `__sfx__` and `__music__`) and replace them in `data.p8` manually. + +Alternatively, to edit the spritesheet in your favorite editor: + +1. Export it from PICO-8 with the PICO-8 command `export spritesheet.png` +2. Edit it in your favorite editor +3. Import it back to PICO-8 with the PICO-8 command `import spritesheet.png` + +## Development -If you use the scripts of this project to create a new game, in order to use build command *p8tool: edit data* you need to create a pico8 file at data/data.p8 first. To do this, open PICO-8, type *save data*, then copy the boilerplate file to data/data.p8. +### Documentation + +Most of the documentation lies in code comment. + +`` means a duck-typed object that must implement functions named `fun1` and `fun2` + +## New project + +If you use the scripts of this project to create a new game, in order to use build command `p8tool: edit data` you need to create a pico8 file at data/data.p8 first. To do this, open PICO-8, type `save data`, then copy the boilerplate file to data/data.p8. ## Runtime third-party libraries @@ -163,6 +187,10 @@ The `npm` folder has its own MIT license because I adapted a script from the `lu ### Assets -Most assets are derivative works of Sonic the Hedgehog, SEGA, especially the Master System and Mega Drive games. They have been created, either manually or with a conversion tool, for demonstration purpose. BGMs have been converted from Master System midi rips to PICO-8 format with [midi2pico](https://github.com/gamax92/midi2pico). I only retain copyright for the manual work of adaptation. +Most assets are derivative works of Sonic the Hedgehog (SEGA), especially the Master System and Mega Drive games. They have been created, either manually or with a conversion tool, for demonstration purpose. BGMs have been converted from Master System midi rips to PICO-8 format with [midi2pico](https://github.com/gamax92/midi2pico), an automated music format converter. + +SEGA owns the Sonic the Hedgehog trademark and retains all copyrights on the original assets. + +I only retain copyright for the manual work of adaptation (i.e. pixel art, but not music). Assets that are not derivative works are under CC BY 4.0. diff --git a/build.sh b/build.sh index 6477eeaa..a9dafa01 100755 --- a/build.sh +++ b/build.sh @@ -24,7 +24,7 @@ minify=true while [ "$1" != "" ]; do case $1 in - -m | --no-minify ) shift + -m | --no-minify ) shift minify=false ;; * ) echo "unknown option: $1" diff --git a/data/data.p8 b/data/data.p8 index b42bfbe7..e216db58 100644 --- a/data/data.p8 +++ b/data/data.p8 @@ -19,47 +19,6 @@ eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeccccccee1eeeeecccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeceeccccccceeeecccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeccffccccceeeccc7cccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeecccfcc7ccceecc777ccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeecccccc770cceeccc777cccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeecee1cc770ceeeccc7cccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeccccf77f0eecccccccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeececccffeeeecccccccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeee1fccf7eeecccccccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeee777cf77eeecccccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeee7777c77eeeecccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeee77eceeeeeeecccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeececeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeee7e7eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeee0872088eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeee27888028eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -99994444eeeeeee49eeeeeeeeeeeeeee99eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -99994444eeeeee4499eeeeeeeeeeeeee999ee4e4eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -99994444eeeee444999eeeeeeeeeeeee99994444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -99994444eeee44449999eeeeeeeeee4499994444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -44449999eee4999944449eeeeeee999944449999eeeeeeee44449999eeee9999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -44449999ee449999444499eeee44999944449999eeeeeeee44449999eeee9999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -44449999e44499994444999e4444999944449999eeeeeeee44449999eeee999944449999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -4444999944449999444499994444999944449999eeeeeeee44449999eeee999944449999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -77777777000000077000000000000000770000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -77777777000000777700000000000000777007070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -77777777000007777770000000000000777777770000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -77777777000077777777000000000077777777770000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -77777777000777777777700000007777777777770000000077777777000077770000000000000000000000000000000000000000000000000000000000000000 -77777777007777777777770000777777777777770000000077777777000077770000000000000000000000000000000000000000000000000000000000000000 -77777777077777777777777077777777777777770000000077777777000077777777777700000000000000000000000000000000000000000000000000000000 -77777777777777777777777777777777777777770000000077777777000077777777777700000000000000000000000000000000000000000000000000000000 -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee @@ -76,23 +35,22 @@ eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee010101010101eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee01eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee01010101eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee01eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee01010101eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee01eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee01010101eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee01eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +99994444eeeeeee49eeeeeeeeeeeeeee99eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +99994444eeeeee4499eeeeeeeeeeeeee999ee4e4eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +99994444eeeee444999eeeeeeeeeeeee99994444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +99994444eeee44449999eeeeeeeeee4499994444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +44449999eee4999944449eeeeeee999944449999eeeeeeee44449999eeee9999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +44449999ee449999444499eeee44999944449999eeeeeeee44449999eeee9999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +44449999e44499994444999e4444999944449999eeeeeeee44449999eeee999944449999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +4444999944449999444499994444999944449999eeeeeeee44449999eeee999944449999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +77777777000000077000000000000000770000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +77777777000000777700000000000000777007070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +77777777000007777770000000000000777777770000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +77777777000077777777000000000077777777770000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +77777777000777777777700000007777777777770000000077777777000077770000000000000000000000000000000000000000000000000000000000000000 +77777777007777777777770000777777777777770000000077777777000077770000000000000000000000000000000000000000000000000000000000000000 +77777777077777777777777077777777777777770000000077777777000077777777777700000000000000000000000000000000000000000000000000000000 +77777777777777777777777777777777777777770000000077777777000077777777777700000000000000000000000000000000000000000000000000000000 eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee @@ -109,6 +67,48 @@ eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eeeeccccccee1eeeeeeeccccceeeeeeeeeeeccccceeeeeeeeeeeccccceeeeeeeeeeeccccceeeeeeeeeeecccccceeceeeeeeecccccceeceeeeeeecccccceeceee +eeeceeccccccceeeeeececccccceeeeeeeececccccceeeeeeeececccccceeeeeeeececccccceeeeeeeeceeccccccceeeeeeceeccccccceeeeeeceeccccccceee +eeeeccffccccceeeeeeeccffcccceeeeeeeeccffcccceeeeeeeeccffcccceeeeeeeeccffcccceeeeeeeeccffccccceeeeeeeccffccccceeeeeeeccffccccceee +eeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceee +eecccccc770cceeeeeccccccc70cceeeeeccccccc70cceeeeeccccccc70cceeeeeccccccc70cceeeeecccccc770cceeeeecccccc770cceeeeecccccc771cceee +eecee1cc770ceeeeeeceecccc70ceeeeeeceecccc70ceeeeeeceecccc70ceeeeeeceecccc70ceeeeeeceeecc770ceeeeeeceeecc770ceeeeeeceeecc771ceeee +eeeeccccf77f0eeeeeeeccccf7ff0eeeeeeeccccf7ff0eeeeeeeccccf7ff0eeeeeeeccccf7ff0eeeeeeeccccf77f0eeeeeeeccccf77f0eeeeeeeffccf77f0eee +eeeececccffeeeeeeeeceecccffeeeeeeeeceecccffeeeeeeeeceecccffeeeeeeeecefcccffeeeeeeeeececccffeeeeeeee77ffcfffeeeeee77fecfccfeee77e +eeeeee1fccf7eeeeeeeeecfcffe77eeeeeeeecccffee77eeeeeeecccffeeeeeeeeeefcccfeeeeeeeeeeeee7fcfeeeeeeee7777ccfeee77ee7777eeccfffff77e +eeeeee777cf77eeeeeee77eccff77eeeeee77fcccfff77eeeeeeee7ccff77eeeeee77eecfeeeeeeeeeeee7777ff77eeeeee77ecccfcf77eee77eeeccfcceee88 +eeeeee7777c77eeeeeee77ecccceeeeeeee77eeccceeeeeeeeeeeeeccce77eeeeee77eecf77eeeeeeeeeee77ccc77eeeeeeeeecccc7ceeeeeeeeeeeccec78780 +eeeeeee77eceeeeeeeeeeeccee7ceeeeeeeeeecceccee88eee000eececceeeeeeeeeeeecc77eeeeeeeeeeee87ceeeeeeeeeee7cee788eeeeeeee7cceee77878e +eeeeeeeececeeeeeeeee7ccee888eeeeee87ccceeec7870ee07887cceecceeeeeee087ccceeeeeeeeeeeeec887eeeeeeeee0877ee8878eeeeee787eeeee880ee +eeeeeeee7e7eeeeeeee877eeee877eeee787eeeeee7880eee8787cceeee778eeee077eee7eeeeeeeeeeeeec087eeeeeeeee0888eee788eeeee087eeeeee00eee +eeeeeee0872088eeeee88878eee88eeee87eeeeeeee80eeeeeeeeeeeeee8878eee08eeee8788eeeeeeeee870788eeeeeeeee8778eee08eeeee888eeeeeeeeeee +eeeeeee27888028eeeee87888eeeeeeee88eeeeeeeeeeeeeeeeeeeeeeeee8788ee88eeee07088eeeeeeee8880888eeeeeeee0888eeeeeeeeee0888eeeeeeeeee +eeeecccccceeceeeeeeecccccceeceeeeeeecccccceeceeeeeeeccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eeeceeccccccceeeeeeceeccccccceeeeeeceeccccccceeeeeececccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eeeeccffccccceeeeeeeccffccccceeeeeeeccffccccceeeeeeeccffcccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eecccccc771cceeeeecccccc771cceeeeecccccc771cceeeeeccccccc70cceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eeceeecc771ceeeeeeceeecc771ceeeeeeceeecc771ceeeeeeceecccc70ceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eeeeccccf77f0eeeeeeeccccf77f0eeeeeeeccccf77f0eeeeeeeccccf7ff0eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eeeecefccfeeeeeeeeeecefccfeeeeeeeeeecffccffeeeeeeeeceecccffeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eeeeefccffe77eeeeeeeefcccfeeeeeeeeeefcccfeeeeeeeeeeeeccffeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eeeefecccff77eeeeeeee77ccf77eeeeeee77eecfeeeeeeeeeeee77cfe77eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eee77eeccceeeeeeeeeee77ccc77eeeeeee77eeccf77eeeeeeeee77ccf77eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eee77ecceccee88eee000eececceeeeeeeeeeeecce77eeeeeeeeeeececeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +ee80ccceeec7870ee07887cceecceeeeeee087ccceeeeeeeeeeeeeec07eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +e787eeeeee7880eee8787cceeee778eeee077eee7eeeeeeeeeeeee7c88eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +e87eeeeeeee80eeeeeeeeeeeeee8878eee08eeee8788eeeeeeeee08700eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +e88eeeeeeeeeeeeeeeeeeeeeeeee8788ee88eeee07088eeeeeeee078880eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eeecccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +ecccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eccccc7cceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +ccccc777cceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +cccccc7ccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +cccccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +cccccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +ecccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +ecccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +eeecccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee diff --git a/postbuild/minify.py b/postbuild/minify.py index 16941ebe..17486472 100755 --- a/postbuild/minify.py +++ b/postbuild/minify.py @@ -69,6 +69,8 @@ def minify_lua_in_p8(cartridge_filepath): min_lua_file.seek(0) min_char_count = sum(len(line) for line in min_lua_file) print(f"Minified lua code to {min_char_count} characters") + if min_char_count > 65536: + logging.warn(f"Maximum character count of 65536 has been exceeded, cartridge will be truncated in PICO-8") # Step 4-6: inject minified lua code phase = Phase.CARTRIDGE_HEADER diff --git a/prebuild/preprocess.py b/prebuild/preprocess.py index c61a40e4..58fa8451 100644 --- a/prebuild/preprocess.py +++ b/prebuild/preprocess.py @@ -7,43 +7,55 @@ from enum import Enum # This script applies preprocessing and stripping to the code: -# 1. it will strip leading and trailing whitespace, ignoring empty lines completely -# 2. it will remove all line comments (doesn't support block comments) -# 3. it will strip all code between #if [symbol] and #endif if symbol is not defined for this config. -# 4. it will enable all code between --[[#pico8 and --#pico8]] -# 5. it will strip debug function calls like log() or assert() +# 1. it will strip all code between #if [symbol] and #endif if symbol is not defined for this config. +# 2. it will enable all code between --[[#pico8 and --#pico8]] (unless stripped by 1.) +# 3. it will strip debug function calls like log() or assert() in configs that don't use those symbols # Config for defined symbols (all configs have pico8, to distinguish from busted using the scripts directly) -# Remember that busted will not preprocess at all and will therefore go through all the blocks. +# Remember that busted will not preprocess at all and will therefore go through all the blocks, +# even blocks surrounded by #if symbol and #ifn symbol for the same symbol! +# In this case, we recomment surrounding the block that is never used by busted with --[[#pico8 and --#pico8]] + # For non-pico8 builds, we use --#ifn pico8 to indicate we won't have preprocessing, # but for busted unit tests we prefer using --#if utest (which is never defined) to make clear that # the code is only needed for a purpose of redundancy and unit test harnessing in general. + +# assert +# If 'assert' is defined here, it should *not* be stripped in the next table. +# The 'assert' symbol if only here to only to strip multi-line asserts or blocks of functions only used inside assert calls. + +# visual_logger +# 'visual_logger' requires 'log', the dependeency is not automatically solved. defined_symbols_table = { - 'debug': ['pico8', 'assert', 'log', 'visual_logger', 'tuner', 'profiler', 'mouse'], + 'debug': ['pico8', 'assert', 'log', 'visual_logger', 'tuner', 'profiler', 'mouse', 'cheat'], 'assert': ['pico8', 'assert', 'log', 'visual_logger'], - 'itest': ['pico8', 'log', 'visual_logger', 'test'], - 'itest_light': ['pico8', 'log', 'test'], + 'itest': ['pico8', 'assert', 'log', 'itest', 'cheat'], + # 'cheat' added as quick fix for itest that must test debug motion, while still stripping cheat input + # when #if or is implemented, remove 'cheat' and add "or itest" in front of "#if cheat" that need it + 'itest_light': ['pico8', 'log', 'itest', 'cheat'], 'profiler': ['pico8', 'log', 'visual_logger', 'profiler'], 'visual_log': ['pico8', 'log', 'visual_logger'], 'pico8_utest': ['pico8', 'assert', 'log'], 'log': ['pico8', 'log'], + 'cheat': ['pico8', 'cheat'], 'release': ['pico8'] } # Functions to strip for each config (not all configs need to be present as keys) # Make sure you never insert gameplay code inside a log or assert (such as assert(coresume(coroutine))) # and always split gameplay/debug code in 2 lines -# Also make sure all your sripped function calls are on 1 line (multi-line regex catch is currently not supported) +# Also make sure all your stripped function calls are on 1 line (multi-line regex catch is currently not supported) # or that you surround them with the corresponding #if (e.g. #if assert for a multi-line assert) stripped_functions_table = { 'debug': [], 'assert': [], - 'itest': ['assert'], + 'itest': [], 'itest_light': ['assert'], 'profiler': ['assert'], 'visual_log': ['assert'], 'pico8_utest': [], 'log': ['assert'], + 'cheat': ['assert', 'log'], 'release': ['assert', 'log', 'warn', 'err'] } @@ -62,31 +74,30 @@ class ParsingMode(Enum): # tag to enter a pico8-only block (it's a comment block so that busted never runs it but preprocess reactivates it) # unlike normal comment blocks, we expect to match from the line start -pico8_start_pattern = re.compile(r"--\[\[#pico8") +pico8_start_pattern = re.compile(r"\s*--\[=*\[#pico8") # closing tag for pico8-only block. Unlike normal comment blocks, we expect to match from the line start and we ignore anything after the block end! -pico8_end_pattern = re.compile(r"--#pico8]]") -# capture the previous part (we recommend to start a line with --[[, but in case it is found in the middle of a line) -block_comment_start_pattern = re.compile(r"(.*)--\[\[") -# capture the part after (same, we recommend to end a line with --]]) -block_comment_end_pattern = re.compile(r"(?:.*)]](.*)") -# Known limitation: an open/close block comment on a single line won't be detected. Use a line comment in this case! - -if_pattern = re.compile(r"--#if (\w+)") # ! ignore anything after 1st symbol -ifn_pattern = re.compile(r"--#ifn (\w+)") # ! ignore anything after 1st symbol -endif_pattern = re.compile(r"--#endif") -comment_pattern = re.compile(r'("[^"\\]*(?:\\.[^"\\]*)*")|(?:--.*)') +pico8_end_pattern = re.compile(r"\s*--#pico8]=*]") + +if_pattern = re.compile(r"\s*--#if (\w+)") # ! ignore anything after 1st symbol +ifn_pattern = re.compile(r"\s*--#ifn (\w+)") # ! ignore anything after 1st symbol +endif_pattern = re.compile(r"\s*--#endif") stripped_function_call_patterns_table = {} for config, stripped_functions in stripped_functions_table.items(): - # many good regex exist to match open and closing brackets, unfortunately they use PCRE features like ?> unsupported in Python re - # so we use a very simple regex, but remember to never put anything fancy on a log/assert line that may have side effects, since they will be stripped on release - # ex: '^(?:log|warn|err)\(.*\)$' - # for better regex with PCRE, see: - # https://stackoverflow.com/questions/2148587/finding-quoted-strings-with-escaped-quotes-in-c-sharp-using-a-regular-expression - # https://stackoverflow.com/questions/4568410/match-comments-with-regex-but-not-inside-a-quote adapted to lua comments - # https://stackoverflow.com/questions/546433/regular-expression-to-match-outer-brackets#546457 - # https://stackoverflow.com/questions/18906514/regex-for-matching-functions-and-capturing-their-arguments#18908330 - function_name_alternative_pattern = f"(?:{'|'.join(stripped_functions)})" - stripped_function_call_patterns_table[config] = re.compile(f'^{function_name_alternative_pattern}\\(.*\\)$') + # if there is nothing to strip, avoid creating a regex with just "(?:)\(\)" that would match a line starting with brackets + if stripped_functions: + # many good regex exist to match open and closing brackets, unfortunately they use PCRE features like ?> unsupported in Python re + # so we use a very simple regex, but remember to never put anything fancy on a log/assert line that may have side effects, since they will be stripped on release + # comments after call are ok + # for better regex with PCRE to detect surrounding brackets and quotes, see: + # https://stackoverflow.com/questions/2148587/finding-quoted-strings-with-escaped-quotes-in-c-sharp-using-a-regular-expression + # https://stackoverflow.com/questions/4568410/match-comments-with-regex-but-not-inside-a-quote adapted to lua comments + # https://stackoverflow.com/questions/546433/regular-expression-to-match-outer-brackets#546457 + # https://stackoverflow.com/questions/18906514/regex-for-matching-functions-and-capturing-their-arguments#18908330 + + # ex: '(?:log|warn|err)' + function_name_alternative_pattern = f"(?:{'|'.join(stripped_functions)})" + # ex: '^\s*(?:log|warn|err)\(.*\)\s*(?:--.*)?$' + stripped_function_call_patterns_table[config] = re.compile(rf'^\s*{function_name_alternative_pattern}\(.*\)\s*(?:--.*)?$') def preprocess_dir(dirpath, config): """Apply preprocessor directives to all the source files inside the given directory, for the given config""" @@ -146,7 +157,6 @@ def preprocess_lines(lines, config): preprocessed_lines = [] inside_pico8_block = False - inside_comment_block = False # explore the tree of #if by storing the current stack of ifs encountered from top to bottom if_block_modes_stack = [] # can only be filled with [IfBlockMode.ACCEPTED*, IfBlockMode.REFUSED?, IfBlockMode.IGNORED* (only if 1 REFUSED)] @@ -194,77 +204,33 @@ def preprocess_lines(lines, config): if last_mode is IfBlockMode.REFUSED: current_mode = ParsingMode.ACTIVE elif current_mode is ParsingMode.ACTIVE: - force_append = False if pico8_start_pattern.match(line): - if not inside_comment_block: + # we detected a pico8 block and should continue appending the lines normally (since we are building for pico8) + # the bool flag is only here to check that 1 end pattern will match 1 start pattern + # since we don't really need embedded pico8 blocks, we assume only 1 level and don't use a stack here + if not inside_pico8_block: inside_pico8_block = True - # we must not append this line - continue else: - logging.warning('a pico8 block start was encountered inside a comment block. It will be ignored') + logging.warning('a pico8 block start was encountered inside a pico8 block. It will be ignored') elif pico8_end_pattern.match(line): if inside_pico8_block: - if inside_comment_block: - logging.warning('a pico8 block end was encountered inside a pico8 block, but also inside a comment block. It will still end the pico8 block, but crossing blocks like this will end in weird behavior in busted') inside_pico8_block = False - # we must not append this line - continue else: logging.warning('a pico8 block end was encountered outside a pico8 block. It will be ignored') else: - block_comment_start_match = block_comment_start_pattern.match(line) - if block_comment_start_match: - inside_comment_block = True - # preserve part just before block start if any - # you need to force append in this case because inside_comment_block is now True - line = block_comment_start_match.group(1) - if line: - force_append = True - else: - block_comment_end_match = block_comment_end_pattern.match(line) - if block_comment_end_match: - # only end block comment if inside a comment - # else, this is a legit Lua case, where ]] will be interpreted as normal code - # of course, --]] would still be stripped as a comment - if inside_comment_block: - inside_comment_block = False - # technically we are now outside the block, so it's important to only retrieve the part of the line after comment closure - # so we don't append the whole line containing the block end itself - line = block_comment_end_match.group(1) - - # inside a pico8 block, we continue appending the lines (since we are preprocessing, so we are building for pico8) - if not inside_comment_block or force_append: - line = strip_line_content(line, config) - # if resulting line is empty, ignore it - if line: - # we stripped eol, so re-add it now - preprocessed_lines.append(line + '\n') + if not is_function_call_to_strip(line, config): + preprocessed_lines.append(line) if if_block_modes_stack: logging.warning('file ended inside an --#if block. Make sure the block is closed by an --#endif directive') + if inside_pico8_block: + logging.warning('file ended inside a --[[#pico8 block. Make sure the block is closed by a --#pico8]] directive') return preprocessed_lines -def strip_line_content(line, config): - """Strip line content as much as possible. Return line without eol. May be empty.""" - # 2. strip comments first (so we can trim whitespace left by after-code comment afterward) - line = strip_comments(line) - # 1. strip blanks (this includes any remaining end of line) - line = line.strip() - # 4. strip debug function calls if not debug - line = strip_function_calls(line, config) - return line - -def strip_comments(line): - # this will keep trailing whitespaces as well as eol, but we count on strip to finish the job - # \1 will preserve the original code - return comment_pattern.sub('\\1', line) - - -def strip_function_calls(line, config): - if config in stripped_function_call_patterns_table and stripped_function_call_patterns_table[config].match(line): - return '' - else: - return line + +def is_function_call_to_strip(line, config): + """Return true iff the line contains a function call (and optionally a comment) that should be stripped in the passed config""" + return config in stripped_function_call_patterns_table and bool(stripped_function_call_patterns_table[config].match(line)) if __name__ == '__main__': diff --git a/prebuild/replace_strings.py b/prebuild/replace_strings.py index 246e2a8f..2d458381 100755 --- a/prebuild/replace_strings.py +++ b/prebuild/replace_strings.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3.6 # -*- coding: utf-8 -*- import argparse +import logging import os +import re # This script replace glyph identifiers, some functions and symbols in general, and arg substitutes ($arg) # with the corresponding unicode characters and substitute symbol names. @@ -31,69 +33,132 @@ # functions and enum constants to substitute # enums are only substituted for token/char limit reasons +# format: { namespace1: {name1: substitute1, name 2: substitute2, ...}, ... } SYMBOL_SUBSTITUTE_TABLE = { # Functions - # api.print is useful for tests using native print but in runtime, just use print - 'api.print': 'print', + # api.print is useful for tests using native print, but in runtime, just use print + 'api': { + 'print': 'print' + }, # Enums # for every enum added here, surround enum definition with --#ifn pico8 # to strip it from the build, unless you need to map the enum string - # to its value dynamically + # to its value dynamically with enum_values[dynamic_string] # remember to update the values of any preprocessed enum modified + # TODO: to avoid issues like enum values mismatch, parse the values + # directly from the .lua instead of writing them manually + # color - 'colors.black': 0, - 'colors.dark_blue': 1, - 'colors.dark_purple': 2, - 'colors.dark_green': 3, - 'colors.brown': 4, - 'colors.dark_gray': 5, - 'colors.light_gray': 6, - 'colors.white': 7, - 'colors.red': 8, - 'colors.orange': 9, - 'colors.yellow': 10, - 'colors.green': 11, - 'colors.blue': 12, - 'colors.indigo': 13, - 'colors.pink': 14, - 'colors.peach': 15, + 'colors': { + 'black': 0, + 'dark_blue': 1, + 'dark_purple': 2, + 'dark_green': 3, + 'brown': 4, + 'dark_gray': 5, + 'light_gray': 6, + 'white': 7, + 'red': 8, + 'orange': 9, + 'yellow': 10, + 'green': 11, + 'blue': 12, + 'indigo': 13, + 'pink': 14, + 'peach': 15, + }, # math - 'directions.left': 0, - 'directions.right': 1, - 'directions.up': 2, - 'directions.down': 3, - - 'horizontal_dirs.left': 1, - 'horizontal_dirs.right': 2, + 'directions': { + 'left': 0, + 'up': 1, + 'right': 2, + 'down': 3, + }, + + 'horizontal_dirs': { + 'left': 1, + 'right': 2, + }, # input - 'button_ids.left': 0, - 'button_ids.right': 1, - 'button_ids.up': 2, - 'button_ids.down': 3, - 'button_ids.o': 4, - 'button_ids.x': 5, - - 'btn_states.released': 0, - 'btn_states.just_pressed': 1, - 'btn_states.pressed': 2, - 'btn_states.just_released': 3, - - 'input_modes.native': 0, - 'input_modes.simulated': 1, - - # itest - 'itest_dsl_command_types.spawn': 1, - 'itest_dsl_command_types.move': 2, - 'itest_dsl_command_types.wait': 11, - 'itest_dsl_command_types.expect': 21, - - 'itest_dsl_value_types.pc_pos': 1, + 'button_ids': { + 'left': 0, + 'right': 1, + 'up': 2, + 'down': 3, + 'o': 4, + 'x': 5, + }, + + 'btn_states': { + 'released': 0, + 'just_pressed': 1, + 'pressed': 2, + 'just_released': 3, + }, + + 'input_modes': { + 'native': 0, + 'simulated': 1, + }, + + # playercharacter + 'control_modes': { + 'human': 1, + 'ai': 2, + 'puppet': 3, + }, + + 'motion_modes': { + 'platformer': 1, + 'debug': 2, + }, + + 'motion_states': { + 'grounded': 1, + 'airborne': 2, + }, + + # itest_dsl + 'parsable_types': { + 'none': 1, + 'number': 2, + 'vector': 3, + 'horizontal_dir': 4, + 'control_mode': 5, + 'motion_mode': 6, + 'motion_state': 7, + 'button_id': 8, + 'gp_value': 9, + }, + + 'command_types': { + 'warp': 1, + 'set': 2, + 'set_control_mode': 3, + 'set_motion_mode': 4, + 'move': 5, + 'stop': 6, + 'jump': 7, + 'stop_jump': 8, + 'press': 9, + 'release': 10, + 'wait': 11, + 'expect': 12, + }, + + 'gp_value_types': { + 'pc_bottom_pos': 1, + 'pc_velocity': 2, + 'pc_ground_spd': 3, + 'pc_motion_state': 4, + 'pc_slope': 5, + }, } # prefix of all arg identifiers @@ -136,7 +201,10 @@ def replace_all_strings_in_file(filepath, arg_substitutes_table): print("press ❎") """ - with open(filepath, 'r+') as f: + # make sure to open files as utf-8 so we can handle glyphs on any platform + # (when locale.getpreferredencoding() and sys.getfilesystemencoding() are not "UTF-8" and "utf-8") + # you can also set PYTHONIOENCODING="UTF-8" to visualize glyphs when debugging if needed + with open(filepath, 'r+', encoding='utf-8') as f: data = f.read() data = replace_all_glyphs_in_string(data) data = replace_all_symbols_in_string(data) @@ -158,21 +226,32 @@ def replace_all_glyphs_in_string(text): text = text.replace(GLYPH_PREFIX + identifier_char, glyph) return text +def generate_get_substitute_from_dict(substitutes): + def get_substitute(match): + member = match.group(1) # "{member}" + if member in substitutes: + return str(substitutes[member]) # enums are substituted with integers, so convert + else: + original_symbol = match.group(0) # "{namespace}.{member}" + # in general, we should substitute all members of a namespace, especially enums + logging.error(f'no substitute defined for {original_symbol}, but the namespace (first part) is present in SYMBOL_SUBSTITUTE_TABLE') + # return something easy to debug in PICO-8, in case the user missed the error message + return f'assert(false, "UNSUBSTITUTED {original_symbol}")' + return get_substitute + def replace_all_symbols_in_string(text): """ - Replace symbols with the corresponding substitutes + Replace symbols "namespace.member" defined in SYMBOL_SUBSTITUTE_TABLE + with the corresponding substitutes Convert integer to string for replacement to support enum constants >>> replace_all_symbols_in_string("api.print(\"hello\")") 'print("hello")' """ - for original_symbol, substitute_symbol in SYMBOL_SUBSTITUTE_TABLE.items(): - # enum constants are defined with integer substitutes for simplicity, - # so convert them to string first - if type(substitute_symbol) == int: - substitute_symbol = str(substitute_symbol) - text = text.replace(original_symbol, substitute_symbol) + for namespace, substitutes in SYMBOL_SUBSTITUTE_TABLE.items(): + SYMBOL_PATTERN = re.compile(rf"{namespace}\.(\w+)") + text = SYMBOL_PATTERN.sub(generate_get_substitute_from_dict(substitutes), text) return text @@ -213,6 +292,8 @@ def parse_arg_substitutes(arg_substitutes): help='extra substitutes table in the format "arg1=substitute1 arg2=substitute2 ...". \ Does not support spaces in names because surrounding quotes would be part of the names') args = parser.parse_args() + + logging.basicConfig(level=logging.INFO) arg_substitutes_table = parse_arg_substitutes(args.substitutes) replace_all_strings_in_dir(args.dirpath, arg_substitutes_table) print(f"Replaced all strings in all files in {args.dirpath} with substitutes: {arg_substitutes_table}.") diff --git a/prebuild/test_preprocess.py b/prebuild/test_preprocess.py index 8aa01694..b80b7b93 100644 --- a/prebuild/test_preprocess.py +++ b/prebuild/test_preprocess.py @@ -6,103 +6,74 @@ import shutil, tempfile -class TestPreprocess(unittest.TestCase): +class TestPreprocessLines(unittest.TestCase): - def test_strip_comments_full_line(self): - test_line = '-- my comment\n' - self.assertEqual(preprocess.strip_comments(test_line), '\n') + def test_is_function_call_to_strip_pure_brackets_false(self): + test_line = '(5)\n' + self.assertFalse(preprocess.is_function_call_to_strip(test_line, 'debug')) - def test_strip_comments_after_code(self): - test_line = 'print("hi") -- prints hi\n' - self.assertEqual(preprocess.strip_comments(test_line), 'print("hi") \n') - - def test_strip_comments_outside_quotes(self): - test_line = 'print("hi -- this is \"not\" a comment") -- prints hi\n' - self.assertEqual(preprocess.strip_comments(test_line), 'print("hi -- this is \"not\" a comment") \n') + def test_is_function_call_to_strip_log_in_debug_false(self): + test_line = 'log(5)\n' + self.assertFalse(preprocess.is_function_call_to_strip(test_line, 'debug')) - def test_strip_comments_after_code(self): - test_line = '"some \\"text" print("hi -- this is \"not\" a comment") -- prints hi "more text" -- more comment\n' - self.assertEqual(preprocess.strip_comments(test_line), '"some \\"text" print("hi -- this is "not" a comment") \n') + def test_is_function_call_to_strip_log_in_debug_with_comment_false(self): + test_line = ' log("character moves", "[character]") -- logging\n' + self.assertFalse(preprocess.is_function_call_to_strip(test_line, 'debug')) - def test_strip_function_calls_no_change(self): - test_line = 'log(5)\n' - self.assertEqual(preprocess.strip_function_calls(test_line, 'debug'), test_line) + def test_is_function_call_to_strip_log_in_release_with_comment_true(self): + test_line = ' log("character moves", "[character]") -- logging\n' + expected_processed_lines = '' + self.assertTrue(preprocess.is_function_call_to_strip(test_line, 'release')) - def test_strip_function_calls_empty_string(self): + def test_is_function_call_to_strip_log_in_release_true(self): test_line = 'log("character moves", "[character]")\n' - self.assertEqual(preprocess.strip_function_calls(test_line, 'release'), '') + self.assertTrue(preprocess.is_function_call_to_strip(test_line, 'release')) - def test_strip_function_calls_tricky_bracket(self): + def test_is_function_call_to_strip_tricky_bracket(self): test_line = 'log("inside quotes )", "[character]")\n' - self.assertEqual(preprocess.strip_function_calls(test_line, 'release'), '') + self.assertTrue(preprocess.is_function_call_to_strip(test_line, 'release')) - def test_strip_function_calls_embedded_brackets(self): + def test_is_function_call_to_strip_embedded_brackets(self): test_line = 'log(value.evaluate(with.style()), "[character]")\n' - self.assertEqual(preprocess.strip_function_calls(test_line, 'release'), '') + self.assertTrue(preprocess.is_function_call_to_strip(test_line, 'release')) - def test_strip_function_calls_tricky_quotes(self): + def test_is_function_call_to_strip_tricky_quotes(self): test_line = 'log("inside quotes )\\"", "[character]")\n' - self.assertEqual(preprocess.strip_function_calls(test_line, 'release'), '') + self.assertTrue(preprocess.is_function_call_to_strip(test_line, 'release')) def test_strip_function_after_something_else(self): test_line = 'dont strip log(this)\n' - self.assertEqual(preprocess.strip_function_calls(test_line, 'release'), test_line) + self.assertFalse(preprocess.is_function_call_to_strip(test_line, 'release')) def test_strip_function_after_something_else(self): test_line = 'log(this) shouldnt be stripped\n' - self.assertEqual(preprocess.strip_function_calls(test_line, 'release'), test_line) + self.assertFalse(preprocess.is_function_call_to_strip(test_line, 'release')) - def test_strip_function_calls_not_alone(self): + def test_is_function_call_to_strip_not_alone(self): test_line = 'log("inside quotes", "[character]") or a = 3\n' - self.assertEqual(preprocess.strip_function_calls(test_line, 'release'), test_line) + self.assertFalse(preprocess.is_function_call_to_strip(test_line, 'release')) - def test_strip_function_calls_not_alone2(self): + def test_is_function_call_to_strip_not_alone2(self): test_line = 'log("inside quotes )\\"", "[character]") or a = 3\n' - self.assertEqual(preprocess.strip_function_calls(test_line, 'release'), test_line) + self.assertFalse(preprocess.is_function_call_to_strip(test_line, 'release')) @unittest.skip("regex is not good enough to detect last bracket does not belong to the log") - def test_strip_function_calls_not_alone_end_bracket(self): + def test_is_function_call_to_strip_not_alone_end_bracket(self): test_line = 'log("inside quotes )\\"", "[character]") or fancy_side_effect()\n' - self.assertEqual(preprocess.strip_function_calls(test_line, 'release'), test_line) + self.assertFalse(preprocess.is_function_call_to_strip(test_line, 'release')) - def test_strip_line_content_debug(self): - test_line = ' log("character moves", "[character]") -- logging\n' - expected_processed_line = 'log("character moves", "[character]")' - self.assertEqual(preprocess.strip_line_content(test_line, 'debug'), expected_processed_line) - - def test_strip_line_content_release(self): - test_line = ' log("character moves", "[character]") -- logging\n' - expected_processed_lines = '' - self.assertEqual(preprocess.strip_line_content(test_line, 'release'), '') - - def test_preprocess_strip_blanks_after_comments(self): + def test_preprocess_lines_no_directives_preserve(self): test_lines = [ 'print ("hi") \n', '\n', 'if true: \n', ' -- prints hello\n', ' print("hello") -- comment\n', - '\n' - ] - expected_processed_lines = [ - 'print ("hi")\n', - 'if true:\n', - 'print("hello")\n' + '\n', ] - self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) + self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), test_lines) - def test_preprocess_no_directives(self): - test_lines = [ - 'if true:\n', - ' print("hello")\n' - ] - expected_processed_lines = [ - 'if true:\n', - 'print("hello")\n' - ] - self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - - def test_preprocess_if_log_in_debug(self): + def test_preprocess_lines_if_log_in_debug(self): test_lines = [ 'print("always")\n', '\n', @@ -111,17 +82,19 @@ def test_preprocess_if_log_in_debug(self): '--#endif\n', '\n', 'if true:\n', - ' print("hello") -- prints hello\n' + ' print("hello") -- prints hello\n', ] expected_processed_lines = [ 'print("always")\n', + '\n', 'print("debug")\n', + '\n', 'if true:\n', - 'print("hello")\n' + ' print("hello") -- prints hello\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_if_log_in_release(self): + def test_preprocess_lines_if_log_in_release(self): test_lines = [ 'print("always")\n', '\n', @@ -130,16 +103,18 @@ def test_preprocess_if_log_in_release(self): '--#endif\n', '\n', 'if true:\n', - 'print("hello") -- prints hello\n' + ' print("hello") -- prints hello\n', ] expected_processed_lines = [ 'print("always")\n', + '\n', + '\n', 'if true:\n', - 'print("hello")\n' + ' print("hello") -- prints hello\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - def test_preprocess_2nd_if_refused(self): + def test_preprocess_lines_2nd_if_refused(self): test_lines = [ '--#if log\n', 'print("debug")\n', @@ -148,17 +123,18 @@ def test_preprocess_2nd_if_refused(self): '--#endif\n', '\n', 'if true:\n', - 'print("hello") -- prints hello\n' + ' print("hello") -- prints hello\n', '--#endif\n', ] expected_processed_lines = [ 'print("debug")\n', + '\n', 'if true:\n', - 'print("hello")\n' + ' print("hello") -- prints hello\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_3rd_if_still_ignore(self): + def test_preprocess_lines_3rd_if_still_ignore(self): test_lines = [ '--#if log\n', 'print("debug")\n', @@ -171,17 +147,18 @@ def test_preprocess_3rd_if_still_ignore(self): '--#endif\n', '\n', 'if true:\n', - 'print("hello") -- prints hello\n' + ' print("hello") -- prints hello\n', '--#endif\n', ] expected_processed_lines = [ 'print("debug")\n', + '\n', 'if true:\n', - 'print("hello")\n' + ' print("hello") -- prints hello\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_3rd_if_ignored_even_if_true(self): + def test_preprocess_lines_3rd_if_ignored_even_if_true(self): test_lines = [ '--#if log\n', 'print("debug")\n', @@ -194,31 +171,32 @@ def test_preprocess_3rd_if_ignored_even_if_true(self): '--#endif\n', '\n', 'if true:\n', - 'print("hello") -- prints hello\n' + ' print("hello") -- prints hello\n', '--#endif\n', ] expected_processed_lines = [ 'print("debug")\n', + '\n', 'if true:\n', - 'print("hello")\n' + ' print("hello") -- prints hello\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_ifn(self): + def test_preprocess_lines_ifn(self): test_lines = [ 'print("always")\n', '--#ifn log\n', 'print("no log")\n', '--#endif\n', - 'print("hello")\n' + 'print("hello")\n', ] expected_processed_lines = [ 'print("always")\n', - 'print("hello")\n' + 'print("hello")\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_if_and_ifn(self): + def test_preprocess_lines_if_and_ifn(self): test_lines = [ 'print("always")\n', '--#if log\n', @@ -227,16 +205,16 @@ def test_preprocess_if_and_ifn(self): '--#ifn log\n', 'print("no log")\n', '--#endif\n', - 'print("hello")\n' + 'print("hello")\n', ] expected_processed_lines = [ 'print("always")\n', 'print("log")\n', - 'print("hello")\n' + 'print("hello")\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_ifn_inside_if(self): + def test_preprocess_lines_ifn_inside_if(self): test_lines = [ 'print("always")\n', '--#if log\n', @@ -246,17 +224,17 @@ def test_preprocess_ifn_inside_if(self): '--#endif\n', 'print("log 2")\n', '--#endif\n', - 'print("hello")\n' + 'print("hello")\n', ] expected_processed_lines = [ 'print("always")\n', 'print("log")\n', 'print("log 2")\n', - 'print("hello")\n' + 'print("hello")\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_if_inside_ifn(self): + def test_preprocess_lines_if_inside_ifn(self): test_lines = [ 'print("always")\n', '--#ifn log\n', @@ -266,193 +244,123 @@ def test_preprocess_if_inside_ifn(self): '--#endif\n', 'print("no log 2")\n', '--#endif\n', - 'print("hello")\n' + 'print("hello")\n', ] expected_processed_lines = [ 'print("always")\n', - 'print("hello")\n' + 'print("hello")\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_ifn_log_in_release(self): + def test_preprocess_lines_ifn_log_in_release(self): test_lines = [ 'print("always")\n', '--#ifn log\n', 'print("no log")\n', '--#endif\n', - 'print("hello")\n' + 'print("hello")\n', ] expected_processed_lines = [ 'print("always")\n', 'print("no log")\n', - 'print("hello")\n' + 'print("hello")\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - def test_preprocess_immediate_endif_ignored(self): + def test_preprocess_lines_immediate_endif_ignored(self): test_lines = [ '--#endif\n', 'if true:\n', - ' print("hello") -- prints hello\n' + ' print("hello") -- prints hello\n', ] expected_processed_lines = [ 'if true:\n', - 'print("hello")\n' + ' print("hello") -- prints hello\n', ] # this will also trigger a warning, but we don't test it self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_missing_endif_ignored(self): + def test_preprocess_lines_missing_endif_ignored(self): test_lines = [ '--#if log\n', 'print("debug")\n', '\n', 'if true:\n', - 'print("hello") -- prints hello\n' + ' print("hello") -- prints hello\n', ] expected_processed_lines = [ ] # this will also trigger a warning, but we don't test it self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - def test_preprocess_comment_block(self): - test_lines = [ - 'print("start")\n', - '--[[ comment start\n', - 'more comment\n', - '--]]\n', - 'print("end")\n' - ] - expected_processed_lines = [ - 'print("start")\n', - 'print("end")\n' - ] - logging.info("NOW") - self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - - def test_preprocess_comment_block_in_the_middle(self): - test_lines = [ - 'print("start")\n', - 'some code --[[ comment start\n', - 'more comment\n', - 'end comment ]] more core\n', - 'print("end")\n' - ] - expected_processed_lines = [ - 'print("start")\n', - 'some code\n', - 'more core\n', - 'print("end")\n' - ] - logging.info("NOW") - self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - - def test_preprocess_stop_comment_outside_comment_block(self): + def test_preprocess_lines_if_after_blank_acknowledged(self): test_lines = [ - 'print("start")\n', - 'legit ]] code\n', - 'print("end")\n' + ' --#if log\n', + ' print("debug")\n', + ' --#endif\n', ] expected_processed_lines = [ - 'print("start")\n', - 'legit ]] code\n', # warning, ignore - 'print("end")\n' + ' print("debug")\n', ] - logging.info("NOW") - self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) + self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_pico8_block(self): + def test_preprocess_lines_if_after_non_blank_preserved(self): test_lines = [ - 'print("start")\n', - '--[[#pico8 pico8 start\n', - 'real pico8 code\n', - '--#pico8]] exceptionally ignored\n', - 'print("end")\n' + 'text before --#if log\n', + 'print("debug")\n', + 'text before --#endif\n', ] expected_processed_lines = [ - 'print("start")\n', - 'real pico8 code\n', - 'print("end")\n' + 'text before --#if log\n', + 'print("debug")\n', + 'text before --#endif\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - def test_preprocess_pico8_comment_block(self): + def test_preprocess_lines_pico8_block(self): test_lines = [ 'print("start")\n', '--[[#pico8 pico8 start\n', 'real pico8 code\n', - 'and --[[ comment inside\n', - 'more comment\n', - 'and ]] over\n', - 'more pico8 code', '--#pico8]] exceptionally ignored\n', - 'print("end")\n' + 'print("end")\n', ] expected_processed_lines = [ 'print("start")\n', 'real pico8 code\n', - 'and\n', - 'over\n', - 'more pico8 code\n', - 'print("end")\n' + 'print("end")\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - def test_preprocess_comment_pico8_block(self): - test_lines = [ - '--[[ comment\n', - '--[[#pico8\n', # warning here, ignored - 'comment\n', - ']] over\n' - ] - expected_processed_lines = [ - 'over\n' - ] - self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - - def test_preprocess_stop_pico8_inside_comment_block(self): - test_lines = [ - '--[[#pico8\n', - '--[[ comment\n', - 'comment\n', - '--#pico8]]\n', # warning here, will still close - 'still comment\n', # warning here, will still close - ']] code is back\n' - ] - expected_processed_lines = [ - 'code is back\n' - ] - self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - - def test_preprocess_stop_pico8_outside_pico8_block(self): + def test_preprocess_lines_stop_pico8_outside_pico8_block(self): test_lines = [ '--#pico8]]\n', # warning here, ignored - 'code\n' + 'code\n', ] expected_processed_lines = [ - 'code\n' + 'code\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - def test_preprocess_ignored_if_inside_pico8_block(self): + def test_preprocess_lines_refused_if_inside_pico8_block(self): test_lines = [ 'print("start")\n', - '--[[#pico8 pico8 start\n', + '--[=[#pico8 pico8 start\n', 'real pico8 code\n', '--#if log\n', 'log only\n', '--#endif\n', - '--#pico8]] exceptionally ignored\n', - 'print("end")\n' + '--#pico8]=] exceptionally ignored\n', + 'print("end")\n', ] expected_processed_lines = [ 'print("start")\n', 'real pico8 code\n', - 'print("end")\n' + 'print("end")\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - def test_preprocess_accepted_if_inside_pico8_block(self): + def test_preprocess_lines_accepted_if_inside_pico8_block(self): test_lines = [ 'print("start")\n', '--[[#pico8 pico8 start\n', @@ -461,35 +369,35 @@ def test_preprocess_accepted_if_inside_pico8_block(self): 'log only\n', '--#endif\n', '--#pico8]] exceptionally ignored\n', - 'print("end")\n' + 'print("end")\n', ] expected_processed_lines = [ 'print("start")\n', 'real pico8 code\n', 'log only\n', - 'print("end")\n' + 'print("end")\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_ignored_ifn_inside_pico8_block(self): + def test_preprocess_lines_refused_ifn_inside_pico8_block(self): test_lines = [ 'print("start")\n', - '--[[#pico8 pico8 start\n', + '--[==[#pico8 pico8 start\n', 'real pico8 code\n', '--#ifn log\n', 'release only\n', '--#endif\n', - '--#pico8]] exceptionally ignored\n', - 'print("end")\n' + '--#pico8]==] exceptionally ignored\n', + 'print("end")\n', ] expected_processed_lines = [ 'print("start")\n', 'real pico8 code\n', - 'print("end")\n' + 'print("end")\n', ] self.assertEqual(preprocess.preprocess_lines(test_lines, 'debug'), expected_processed_lines) - def test_preprocess_accepted_ifn_inside_pico8_block(self): + def test_preprocess_lines_accepted_ifn_inside_pico8_block(self): test_lines = [ 'print("start")\n', '--[[#pico8 pico8 start\n', @@ -498,43 +406,66 @@ def test_preprocess_accepted_ifn_inside_pico8_block(self): 'release only\n', '--#endif\n', '--#pico8]] exceptionally ignored\n', - 'print("end")\n' + 'print("end")\n', ] expected_processed_lines = [ 'print("start")\n', 'real pico8 code\n', 'release only\n', - 'print("end")\n' + 'print("end")\n', + ] + self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) + + def test_preprocess_lines_missing_end_pico8_ignored(self): + test_lines = [ + 'print("start")\n', + '--[[#pico8 pico8 start\n', + 'real pico8 code\n', + 'print("end")\n', ] + expected_processed_lines = [ + 'print("start")\n', + 'real pico8 code\n', + 'print("end")\n', + ] + # this will also trigger a warning, but we don't test it self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - @unittest.skip("we don't use a perfect regex able to detect start and end of block comment on the same line") - def test_preprocess_comment_block_start_end_in_the_middle(self): + def test_preprocess_lines_pico8_after_blank_acknowledged(self): test_lines = [ 'print("start")\n', - '--[[ --]] outside comment\n', + ' --[[#pico8 pico8 start\n', + 'real pico8 code\n', + ' --#pico8]] exceptionally ignored\n', + 'print("end")\n', ] expected_processed_lines = [ 'print("start")\n', - 'outside comment\n' + 'real pico8 code\n', + 'print("end")\n', ] + # this will also trigger a warning, but we don't test it self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - @unittest.skip("we don't use a perfect regex able to detect end and start of block comment on the same line") - def test_preprocess_comment_block_end_start_in_the_middle(self): + def test_preprocess_lines_pico8_after_non_blank_preserved(self): test_lines = [ 'print("start")\n', - '--[[\n', - ']] outside comment --[[\n', - ']]\n' + 'text --[[#pico8 pico8 start\n', + 'real pico8 code\n', + 'text --#pico8]] exceptionally ignored\n', + 'print("end")\n', ] expected_processed_lines = [ 'print("start")\n', - 'outside comment\n' + 'text --[[#pico8 pico8 start\n', + 'real pico8 code\n', + 'text --#pico8]] exceptionally ignored\n', + 'print("end")\n', ] + # this will also trigger a warning, but we don't test it self.assertEqual(preprocess.preprocess_lines(test_lines, 'release'), expected_processed_lines) - def test_preprocess_with_unknown_config(self): + def test_preprocess_lines_with_unknown_config(self): test_lines = [] self.assertRaises(ValueError, preprocess.preprocess_lines, test_lines, 'unknown') @@ -550,51 +481,62 @@ def tearDown(self): shutil.rmtree(self.test_dir) def test_preprocess_file_in_debug(self): - test_lines = [ - 'print("always")\n', - '\n', - '--#if log\n', - 'print("debug")\n', - '--#endif\n', - '\n', - 'if true:\n', - 'print("hello") -- prints hello\n' - ] - expected_processed_lines = [ - 'print("always")\n', - 'print("debug")\n', - 'if true:\n', - 'print("hello")\n' - ] + test_code = """ +print("always") + +--#if log +print("debug") +--#endif + +if true: + print("hello") -- prints hello + log("debug only") -- +""" + + expected_processed_code = """ +print("always") + +print("debug") + +if true: + print("hello") -- prints hello + log("debug only") -- +""" + test_filepath = path.join(self.test_dir, 'test.lua') with open(test_filepath, 'w') as f: - f.write('\n'.join(test_lines)) + f.write(test_code) preprocess.preprocess_file(test_filepath, 'debug') with open(test_filepath, 'r') as f: - self.assertEqual(f.read(), ''.join(expected_processed_lines)) + self.assertEqual(f.read(), expected_processed_code) def test_preprocess_file_in_release(self): - test_lines = [ - 'print("always")\n', - '\n', - '--#if log\n', - 'print("debug")\n', - '--#endif\n', - '\n', - 'if true:\n', - ' print("hello") -- prints hello\n' - ] - expected_processed_lines = [ - 'print("always")\n', - 'if true:\n', - 'print("hello")\n' - ] + test_code = """ +print("always") + +--#if log +print("debug") +--#endif + +if true: + print("hello") -- prints hello + log("debug only") -- +""" + + expected_processed_code = """ +print("always") + + +if true: + print("hello") -- prints hello +""" + test_filepath = path.join(self.test_dir, 'test.lua') with open(test_filepath, 'w') as f: - f.write('\n'.join(test_lines)) + f.write(test_code) preprocess.preprocess_file(test_filepath, 'release') with open(test_filepath, 'r') as f: - self.assertEqual(f.read(), ''.join(expected_processed_lines)) + self.assertEqual(f.read(), expected_processed_code) class TestPreprocessDir(unittest.TestCase): @@ -607,94 +549,106 @@ def tearDown(self): shutil.rmtree(self.test_dir) def test_preprocess_dir_in_debug(self): - test_lines1 = [ - 'print("file1")\n', - '\n', - '--#if log\n', - 'print("debug1")\n', - '--#endif\n', - '\n', - 'if true:\n', - ' print("hello") -- prints hello\n' - ] - test_lines2 = [ - 'print("file2")\n', - '\n', - '--#if log\n', - 'print("debug2")\n', - '--#endif\n', - '\n', - 'if true:\n', - ' print("hello2") -- prints hello\n' - ] - expected_processed_lines1 = [ - 'print("file1")\n', - 'print("debug1")\n', - 'if true:\n', - 'print("hello")\n' - ] - expected_processed_lines2 = [ - 'print("file2")\n', - 'print("debug2")\n', - 'if true:\n', - 'print("hello2")\n' - ] + test_code1 = """ +print("file1") + +--#if log +print("debug1") +--#endif + +if true: + print("hello") -- prints hello +""" + + test_code2 = """ +print("file2") + +--#if log +print("debug2") +--#endif + +if true: + print("hello2") -- prints hello +""" + + expected_processed_code1 = """ +print("file1") +print("debug1") +if true: + print("hello") +""" + + expected_processed_code2 = """ +print("file2") +print("debug2") +if true: + print("hello2") +""" + # files must end with .lua to be processed test_filepath1 = path.join(self.test_dir, 'test1.lua') with open(test_filepath1, 'w') as f1: - f1.write('\n'.join(test_lines1)) + f1.write(test_code1) test_filepath2 = path.join(self.test_dir, 'test2.lua') with open(test_filepath2, 'w') as f2: - f2.write('\n'.join(test_lines2)) + f2.write(test_code2) preprocess.preprocess_dir(self.test_dir, 'debug') with open(test_filepath1, 'r') as f1: - self.assertEqual(f1.read(), '\n'.join(expected_processed_lines1)) + self.assertEqual(f1.read(), expected_processed_code1) with open(test_filepath2, 'r') as f2: - self.assertEqual(f2.read(), '\n'.join(expected_processed_lines2)) + self.assertEqual(f2.read(), expected_processed_code2) def test_preprocess_dir_in_debug(self): - test_lines1 = [ - 'print("file1")\n', - '\n', - '--#if log\n', - 'print("debug1")\n', - '--#endif\n', - '\n', - 'if true:\n', - ' print("hello") -- prints hello\n' - ] - test_lines2 = [ - 'print("file2")\n', - '\n', - '--#if log\n', - 'print("debug2")\n', - '--#endif\n', - '\n', - 'if true:\n', - ' print("hello2") -- prints hello\n' - ] - expected_processed_lines1 = [ - 'print("file1")\n', - 'if true:\n', - 'print("hello")\n' - ] - expected_processed_lines2 = [ - 'print("file2")\n', - 'if true:\n', - 'print("hello2")\n' - ] + test_code1 = """ +print("file1") + +--#if log +print("debug1") +--#endif + +if true: + print("hello") -- prints hello +""" + + test_code2 = """ +print("file2") + +--#if log +print("debug2") +--#endif + +if true: + print("hello2") -- prints hello +""" + + expected_processed_code1 = """ +print("file1") + + +if true: + print("hello") -- prints hello +""" + + expected_processed_code2 = """ +print("file2") + + +if true: + print("hello2") -- prints hello +""" + # files must end with .lua to be processed test_filepath1 = path.join(self.test_dir, 'test1.lua') with open(test_filepath1, 'w') as f1: - f1.write('\n'.join(test_lines1)) + f1.write(test_code1) test_filepath2 = path.join(self.test_dir, 'test2.lua') with open(test_filepath2, 'w') as f2: - f2.write('\n'.join(test_lines2)) + f2.write(test_code2) preprocess.preprocess_dir(self.test_dir, 'release') with open(test_filepath1, 'r') as f1: - self.assertEqual(f1.read(), ''.join(expected_processed_lines1)) + self.assertEqual(f1.read(), expected_processed_code1) with open(test_filepath2, 'r') as f2: - self.assertEqual(f2.read(), ''.join(expected_processed_lines2)) + self.assertEqual(f2.read(), expected_processed_code2) if __name__ == '__main__': logging.basicConfig(level=logging.ERROR) diff --git a/prebuild/test_replace_strings.py b/prebuild/test_replace_strings.py index 112003cc..91df7e6f 100644 --- a/prebuild/test_replace_strings.py +++ b/prebuild/test_replace_strings.py @@ -1,6 +1,9 @@ +# -*- coding: utf-8 -*- + import unittest from . import replace_strings +import logging from os import path import shutil, tempfile @@ -22,10 +25,19 @@ def test_replace_all_glyphs_in_string(self): test_string = '##d and ##x ##d' self.assertEqual(replace_strings.replace_all_glyphs_in_string(test_string), '⬇️ and ❎ ⬇️') - def test_replace_all_symbols_in_string(self): + def test_replace_all_symbols_in_string_function(self): test_string = 'api.print("hello")' self.assertEqual(replace_strings.replace_all_symbols_in_string(test_string), 'print("hello")') + def test_replace_all_symbols_in_string_enum(self): + test_string = 'local c = colors.dark_purple' + self.assertEqual(replace_strings.replace_all_symbols_in_string(test_string), 'local c = 2') + + def test_replace_all_symbols_in_string_missing_member(self): + test_string = 'local c = colors.unknown' + # this will trigger a warning, hide by setting logging level to ERROR in main + self.assertEqual(replace_strings.replace_all_symbols_in_string(test_string), 'local c = colors.unknown') + def test_replace_all_args_in_string(self): test_string = 'require("itest_$itest")' self.assertEqual(replace_strings.replace_all_args_in_string(test_string, {'itest': 'character'}), 'require("itest_character")') @@ -52,10 +64,10 @@ def tearDown(self): def test_replace_strings(self): """^ Test replacing strings in a whole file, with substitutes being shorter or longer than original symbol to test if file is truncated""" test_filepath = path.join(self.test_dir, 'test.lua') - with open(test_filepath, 'w') as f: + with open(test_filepath, 'w', encoding='utf-8') as f: f.write('require("itest_$itest")\nrequire("$symbol_is_much_longer")\n##d or ##u\nand ##x\napi.print("press ##x")') replace_strings.replace_all_strings_in_file(test_filepath, {'itest': 'character', 'symbol_is_much_longer': 'short'}) - with open(test_filepath, 'r') as f: + with open(test_filepath, 'r', encoding='utf-8') as f: self.assertEqual(f.read(), 'require("itest_character")\nrequire("short")\n⬇️ or ⬆️\nand ❎\nprint("press ❎")') class TestReplaceStringsInDir(unittest.TestCase): @@ -70,16 +82,17 @@ def tearDown(self): def test_replace_all_strings_in_dir(self): test_filepath1 = path.join(self.test_dir, 'test1.lua') - with open(test_filepath1, 'w') as f: + with open(test_filepath1, 'w', encoding='utf-8') as f: f.write('require("itest_$itest")\n##d or ##u\nand ##x\napi.print("press ##x")') test_filepath2 = path.join(self.test_dir, 'test2.lua') - with open(test_filepath2, 'w') as f: + with open(test_filepath2, 'w', encoding='utf-8') as f: f.write('require("itest_$itest")\n##l or ##r\nand ##o\napi.print("press ##o")') replace_strings.replace_all_strings_in_dir(self.test_dir, {'itest': 'character'}) - with open(test_filepath1, 'r') as f: + with open(test_filepath1, 'r', encoding='utf-8') as f: self.assertEqual(f.read(), 'require("itest_character")\n⬇️ or ⬆️\nand ❎\nprint("press ❎")') - with open(test_filepath2, 'r') as f: + with open(test_filepath2, 'r', encoding='utf-8') as f: self.assertEqual(f.read(), 'require("itest_character")\n⬅️ or ➡️\nand 🅾️\nprint("press 🅾️")') if __name__ == '__main__': + logging.basicConfig(level=logging.ERROR) unittest.main() diff --git a/run.sh b/run.sh index 1bb9afe9..51c13a57 100755 --- a/run.sh +++ b/run.sh @@ -18,7 +18,7 @@ define_build_vars $1 OUTPUT_FILEPATH="build/${OUTPUT_BASENAME}_$2.p8" -echo "Running ${OUTPUT_FILEPATH} with ${@:3}..." +echo "Running ${OUTPUT_FILEPATH} with extra args: '${@:3}'" # ${@:4} will pass remaining args after the first 2, such as "-x; exec bash # as a trick to run headless and keep terminal open" gnome-terminal -- bash -x -c "pico8 -run ${OUTPUT_FILEPATH} -gif_scale 4 ${@:3}" diff --git a/sonic-2d-tech-demo.sublime-project b/sonic-2d-tech-demo.sublime-project index 1b238f1b..b821b306 100644 --- a/sonic-2d-tech-demo.sublime-project +++ b/sonic-2d-tech-demo.sublime-project @@ -2,17 +2,21 @@ "build_systems": [ { - "name": "p8tool: build current", + "name": "p8tool: build context", "working_dir": "${project_path}", "shell": true, "cmd": ["./build.sh $file_base_name debug"], "file_regex": "(.*\\.sh)(?:\\: line )([\\d]+)(?:\\:)([\\d]+)? (.*)", "syntax": "Packages/Python/Python.sublime-syntax", "env": { - "BUILD_VERSION": "2.2" + "BUILD_VERSION": "2.3" }, "variants": [ + { + "name": "debug", + "cmd": ["./build.sh $file_base_name debug"] + }, { "name": "assert", "cmd": ["./build.sh $file_base_name assert"] @@ -34,17 +38,21 @@ "cmd": ["./build.sh $file_base_name visual_log"] }, { - "name": "pico8_utest (headless)", + "name": "pico8_utest", "cmd": ["./build.sh $file_base_name pico8_utest"] }, { - "name": "pico8_utest (headless) (--no-minify)", + "name": "pico8_utest (--no-minify)", "cmd": ["./build.sh $file_base_name pico8_utest -m"] }, { "name": "log", "cmd": ["./build.sh $file_base_name log"] }, + { + "name": "cheat", + "cmd": ["./build.sh $file_base_name cheat"] + }, { "name": "release", "cmd": ["./build.sh $file_base_name release"] @@ -52,14 +60,14 @@ ] }, { - "name": "p8tool: build and run or reload current", + "name": "p8tool: build and run or reload context", "working_dir": "${project_path}", "shell": true, "cmd": ["./build.sh $file_base_name debug && (./reload.sh || ./run.sh $file_base_name debug)"], "file_regex": "(.*\\.sh)(?:\\: line )([\\d]+)(?:\\:)([\\d]+)? (.*)", "syntax": "Packages/Python/Python.sublime-syntax", "env": { - "BUILD_VERSION": "2.2" + "BUILD_VERSION": "2.3" }, "variants": [ @@ -67,6 +75,10 @@ "name": "assert", "cmd": ["./build.sh $file_base_name assert && (./reload.sh || ./run.sh $file_base_name assert)"] }, + { + "name": "debug", + "cmd": ["./build.sh $file_base_name debug && (./reload.sh || ./run.sh $file_base_name debug)"] + }, { "name": "itest", "cmd": ["./build.sh $file_base_name itest && (./reload.sh || ./run.sh $file_base_name itest)"] @@ -84,17 +96,21 @@ "cmd": ["./build.sh $file_base_name visual_log && (./reload.sh || ./run.sh $file_base_name visual_log)"] }, { - "name": "pico8_utest (headless)", + "name": "pico8_utest", "cmd": ["./build.sh $file_base_name pico8_utest && (./reload.sh || ./run.sh $file_base_name pico8_utest '-x; exec bash')"] }, { - "name": "pico8_utest (headless) (--no-minify)", + "name": "pico8_utest (--no-minify)", "cmd": ["./build.sh $file_base_name pico8_utest -m && (./reload.sh || ./run.sh $file_base_name pico8_utest '-x; exec bash')"] }, { "name": "log", "cmd": ["./build.sh $file_base_name log && (./reload.sh || ./run.sh $file_base_name log)"] }, + { + "name": "cheat", + "cmd": ["./build.sh $file_base_name cheat && (./reload.sh || ./run.sh $file_base_name cheat)"] + }, { "name": "release", "cmd": ["./build.sh $file_base_name release && (./reload.sh || ./run.sh $file_base_name release)"] @@ -102,15 +118,19 @@ ] }, { - "name": "p8tool: run current", + "name": "p8tool: run context", "working_dir": "${project_path}", "shell": true, "cmd": ["./run.sh $file_base_name debug"], "env": { - "BUILD_VERSION": "2.2" + "BUILD_VERSION": "2.3" }, "variants": [ + { + "name": "debug", + "cmd": ["./run.sh $file_base_name debug"] + }, { "name": "assert", "cmd": ["./run.sh $file_base_name assert"] @@ -132,13 +152,17 @@ "cmd": ["./run.sh $file_base_name visual_log"] }, { - "name": "pico8_utest (headless)", + "name": "pico8_utest", "cmd": ["./run.sh $file_base_name pico8_utest"] }, { "name": "log", "cmd": ["./run.sh $file_base_name log"] }, + { + "name": "cheat", + "cmd": ["./run.sh $file_base_name cheat"] + }, { "name": "release", "cmd": ["./run.sh $file_base_name release"] @@ -151,7 +175,7 @@ "shell": true, "cmd": ["./install.sh release"], "env": { - "BUILD_VERSION": "2.2" + "BUILD_VERSION": "2.3" }, "variants": [ @@ -180,13 +204,21 @@ "cmd": ["./install.sh visual_log"], }, { - "name": "pico8_utest (headless)", + "name": "pico8_utest", "cmd": ["./install.sh pico8_utest"], }, { "name": "log", "cmd": ["./install.sh log"], }, + { + "name": "cheat", + "cmd": ["./install.sh cheat"], + }, + { + "name": "release", + "cmd": ["./install.sh release"], + }, { "name": "debug (png)", "cmd": ["./install.sh debug png"], @@ -212,13 +244,17 @@ "cmd": ["./install.sh visual_log png"], }, { - "name": "pico8_utest (headless) (png)", + "name": "pico8_utest (png)", "cmd": ["./install.sh pico8_utest png"], }, { "name": "log (png)", "cmd": ["./install.sh log png"], }, + { + "name": "cheat (png)", + "cmd": ["./install.sh cheat png"], + }, { "name": "release (png)", "cmd": ["./install.sh release png"], @@ -231,6 +267,16 @@ "cmd": ["pico8 -run data/data.p8"], "working_dir": "${project_path}" }, + // it's better to use our custom main in unittest scripts to get the correct log level + // for filtering, it's easier to use -m unittest, but it will show all the warnings + { + "name": "python: test preprocess (TestPreprocessLines)", + "working_dir": "${project_path}", + "shell": true, + "cmd": ["python3 -m unittest prebuild.test_preprocess.TestPreprocessLines"], + "file_regex": "\"(.*\\.py)\"(?:, line )([\\d]+)(?:\\:)?([\\d]+)?, (.*)", + "syntax": "Packages/Python/Python.sublime-syntax" + }, { "name": "python: test preprocess", "working_dir": "${project_path}", @@ -287,11 +333,11 @@ "variants": [ { - "name": "current (solo)", + "name": "current - solo", "cmd": ["./test.sh $file_base_name solo"], }, { - "name": "current (all)", + "name": "current - all", "cmd": ["./test.sh $file_base_name all"], }, { @@ -299,11 +345,11 @@ "cmd": ["./test.sh all"], }, { - "name": "all (solo)", + "name": "all - solo", "cmd": ["./test.sh all solo"], }, { - "name": "all (all)", + "name": "all - all", "cmd": ["./test.sh all all"], }, { @@ -383,9 +429,17 @@ "cmd": ["./test.sh headless_itests"], }, { - "name": "headless_itests (solo)", + "name": "headless_itests (render)", + "cmd": ["./test.sh headless_itests --render"], + }, + { + "name": "headless_itests - solo", "cmd": ["./test.sh headless_itests solo"], }, + { + "name": "headless_itests - solo (render)", + "cmd": ["./test.sh headless_itests solo --render"], + }, ], }, ], @@ -403,6 +457,7 @@ "folder_exclude_patterns": [ ".git", + "backup", "build", "intermediate", "__pycache__" diff --git a/src/engine/application/flow.lua b/src/engine/application/flow.lua index 94cad3ea..7d6e891e 100644 --- a/src/engine/application/flow.lua +++ b/src/engine/application/flow.lua @@ -3,6 +3,13 @@ require("engine/core/class") local logging = require("engine/debug/logging") --#endif +-- abstract gamestate singleton (no actual class, make your own as long as it has member/interface below) +-- type string gamestate type name +-- on_enter function() gamestate enter callback +-- on_exit function() gamestate exit callback +-- update function() gamestate update callback +-- render function() gamestate render callback + -- flow singleton -- state vars -- curr_state gamestates current gamestate @@ -64,7 +71,7 @@ function flow:_change_state(new_gamestate) self.next_state = nil -- clear any gamestate query end ---#if test +--#if itest -- check if a new gamestate was queried, and enter it if so (convenient for itests) function flow:change_gamestate_by_type(gamestate_type) assert(self.gamestates[gamestate_type] ~= nil, "flow:change_gamestate_by_type: gamestate type '"..gamestate_type.."' has not been added to the flow gamestates") diff --git a/src/engine/application/gameapp.lua b/src/engine/application/gameapp.lua new file mode 100644 index 00000000..e8169fb7 --- /dev/null +++ b/src/engine/application/gameapp.lua @@ -0,0 +1,98 @@ +local flow = require("engine/application/flow") +local class = require("engine/core/class") +local input = require("engine/input/input") + +-- main class for the game, taking care of the overall init, update, render +-- usage: derive from gameapp and override on_start, on_reset, on_update, on_render +-- in the main _init, set the initial_gamestate and call the app start() +-- in the main _update(60), call the app update() +-- in the main _draw, call the app render() +-- in integration tests, call the app reset() before starting a new itest +local gameapp = new_class() + +-- constructor: members are only config values for init_modules +-- managers {} sequence of managers to update and render in the loop +-- initial_gamestate string|nil key of the initial first gamestate to enter (nil if unset) +-- set it manually before calling start(), +-- and make sure you called register_gamestates with a matching state +function gameapp:_init() + self.managers = {} + self.initial_gamestate = nil +end + +-- register the managers you want to update and render +-- they may be managers provided by the engine like visual_logger and profiler, +-- or custom managers, as long as they provide the methods `update` and `render` +-- in this engine, we prefer injection to having a configuration with many flags +-- to enable/disable certain managers. +-- we can still override on_update/on_render for custom effects, but prefer handling managers when possible +function gameapp:register_managers(...) + for manager in all({...}) do + add(self.managers, manager) + end +end + +-- override to add gamestates to flow singleton +function gameapp:register_gamestates() + -- ex: + -- flow:add_gamestate(...) +end + +-- unlike _init, init_modules is called later, after finishing the configuration +-- in pico-8, it must be called in the global _init function +function gameapp:start() + self:register_gamestates() + + -- REFACTOR: consider making flow a very generic manager, that knows the initial gamestate + -- and is only added if you want + assert(self.initial_gamestate ~= nil, "gameapp:start: gameapp.initial_gamestate is not set") + flow:query_gamestate_type(self.initial_gamestate) + for manager in all(self.managers) do + manager:start() + end + self:on_start() +end + +-- override to initialize custom managers +function gameapp:on_start() -- virtual +end + +--#if itest +function gameapp:reset() + flow:init() + self:on_reset() +end + +-- override to call :init on your custom managers, or to reset anything set up in +-- in gameapp:start/on_start, really +function gameapp:on_reset() -- virtual +end +--#endif + +function gameapp:update() + input:process_players_inputs() + for manager in all(self.managers) do + manager:update() + end + flow:update() + self:on_update() +end + +-- override to add custom update behavior +function gameapp:on_update() -- virtual +end + +function gameapp:draw() + cls() + flow:render() + for manager in all(self.managers) do + manager:render() + end + self:on_render() +end + +-- override to add custom render behavior +function gameapp:on_render() -- virtual +end + +return gameapp diff --git a/src/engine/core/class.lua b/src/engine/core/class.lua index ca024fbb..5878ee7e 100644 --- a/src/engine/core/class.lua +++ b/src/engine/core/class.lua @@ -30,15 +30,15 @@ local function struct_eq(lhs, rhs) return getmetatable(lhs) == getmetatable(rhs) and are_same(lhs, rhs, true, true) end --- return a copy of a struct instance +-- return a copy of a struct instance 'self' -- this is a simplified version of deepcopy implementations and only support -- structs referencing primitive types or structs (at least copy-able tables) -- with no reference cycle -local function copy(struct_instance) - -- we can't access the 'struct' variable from here so we get it back via getmetatable - local copied = setmetatable({}, getmetatable(struct_instance)) +local function copy(self) + -- we can't access the struct type from here so we get it back via getmetatable + local copied = setmetatable({}, getmetatable(self)) - for key, value in pairs(struct_instance) do + for key, value in pairs(self) do if type(value) == 'table' then --#if assert assert(type(value.copy) == 'function', "value "..stringify(value).. @@ -55,6 +55,28 @@ local function copy(struct_instance) return copied end +-- copy assign struct members of 'from' to struct members of 'self' +-- from and to must be struct instances of the same type +-- copy_assign is useful when manipulating a struct instance reference whose content +-- must be changed in-place, because the function caller will continue using the same reference +local function copy_assign(self, from) + assert(getmetatable(self) == getmetatable(from), "copy_assign: expected 'self' ("..self..") and 'from' ("..from..") to have the same struct type") + + for key, value in pairs(from) do + if type(value) == 'table' then +--#if assert + assert(type(value.copy_assign) == 'function', "value "..stringify(value).. + " is a table member of a struct but it doesn't have expected copy_assign method, so it's not a struct itself") +--#endif + -- recursively copy-assign the struct members. never use circular references + -- between structs or you'll get an infinite recursion + self[key]:copy_assign(value) + else + self[key] = value + end + end +end + -- create and return a new class -- every class should implement :_init(), -- if useful for logging :_tostring(), and if relevant .__eq() @@ -98,6 +120,7 @@ function new_struct() struct.__concat = concat struct.__eq = struct_eq struct.copy = copy + struct.copy_assign = copy_assign setmetatable(struct, { __call = new diff --git a/src/engine/core/datastruct.lua b/src/engine/core/datastruct.lua index 219be748..a099be35 100644 --- a/src/engine/core/datastruct.lua +++ b/src/engine/core/datastruct.lua @@ -1,5 +1,7 @@ -- circular buffer implementation. useful to represent fixed size queue --- adapted from https://gist.github.com/johndgiese/3e1c6d6e0535d4536692 +-- adapted from https://gist.github.com/johndgiese/3e1c6d6e0535d4536692 +-- we are defining our own __eq, don't need copy and may contain reference +-- to fully-fledged objects, so we don't use a struct circular_buffer = new_class() -- params diff --git a/src/engine/core/helper.lua b/src/engine/core/helper.lua index f38e3f1b..a14d2527 100644 --- a/src/engine/core/helper.lua +++ b/src/engine/core/helper.lua @@ -1,6 +1,41 @@ require("engine/application/constants") +-- create an enum from a sequence of variant names +function enum(variant_names) + local t = {} + local i = 1 + + for variant_name in all(variant_names) do + t[variant_name] = i + i = i + 1 + end + + return t +end + +-- implementation of "map" in other languages (but "map" means something else in pico8) +function transform(t, func) + local transformed_t = {} + for value in all(t) do + add(transformed_t, func(value)) + end + return transformed_t +end + +-- return module members from their names as multiple values +-- use it after require("module") to define +-- local a, b = get_members(module, "a", "b") +-- for more simple access +function get_members(module, ...) + local member_names = {...} + return unpack(transform(member_names, + function(member_name) + return module[member_name] + end) + ) +end + -- return true if the table is empty (contrary to #t == 0, -- it also supports non-sequence tables) function is_empty(t) @@ -21,15 +56,19 @@ end -- if no_deep_raw_content is true, do not pass the compare_raw_content parameter to deeper calls -- this is useful if you want to compare content at the first level but delegate equality for embedded structs function are_same(t1, t2, compare_raw_content, no_deep_raw_content) + -- compare_raw_content and no_deep_raw_content default to false (we count on nil being falsy here) + if type(t1) ~= 'table' or type(t2) ~= 'table' then -- we have at least one non-table argument, compare by equality -- if both arguments have different types, it will return false return t1 == t2 end - -- both arguments are tables + -- both arguments are tables, check meta __eq - if (t1.__eq or t2.__eq) and not compare_raw_content then + local mt1 = getmetatable(t1) + local mt2 = getmetatable(t2) + if (mt1 and mt1.__eq or mt2 and mt2.__eq) and not compare_raw_content then -- we are not comparing raw content and equality is defined, use it return t1 == t2 end @@ -73,6 +112,37 @@ function unpack(t, from, to) return t[from], unpack(t, from+1, to) end +--#if assert +-- return a table reversing keys and values, assuming the original table is injective +-- this is "assert" only because we mostly need it to generate enum-to-string tables +function invert_table(t) + inverted_t = {} + for key, value in pairs(t) do + inverted_t[value] = key + end + return inverted_t +end +--#endif + +-- alternative to tonum that only works with strings (and numbers +-- thanks to sub converting them implicitly) +-- it fixes the 0x0000.0001 issue on negative number strings +-- UPDATE: expect native tonum to be fixed in 0.1.12 +-- https://www.lexaloffle.com/bbs/?pid=63583 +function string_tonum(val) + -- inspired by cheepicus's workaround in + -- https://www.lexaloffle.com/bbs/?tid=3780 + if sub(val, 1, 1) == '-' then + local abs_num = tonum(sub(val, 2)) + assert(abs_num, "could not parse absolute part of number: '-"..sub(val, 2).."'") + return - abs_num + else + local num = tonum(val) + assert(num, "could not parse number: '"..val.."'") + return num + end +end + --#if log function stringify(value) @@ -106,6 +176,7 @@ end function joinstr(separator, ...) return joinstr_table(separator, {...}) end + --#endif -- https://pastebin.com/NS8rxMwH diff --git a/src/engine/core/math.lua b/src/engine/core/math.lua index 95fabd3a..ae0152f8 100644 --- a/src/engine/core/math.lua +++ b/src/engine/core/math.lua @@ -4,8 +4,8 @@ require("engine/core/class") function almost_eq(lhs, rhs, eps) eps = eps or 0.01 - assert(lhs) - assert(rhs) + assert(lhs, "lhs is nil") + assert(rhs, "rhs") if type(lhs) == "number" and type(rhs) == "number" then return abs(lhs - rhs) <= eps elseif lhs.almost_eq then @@ -315,11 +315,11 @@ function signed_speed_to_dir(signed_speed) return signed_speed < 0 and horizontal_dirs.left or horizontal_dirs.right end -function oppose_direction(direction) +function oppose_dir(direction) return (direction + 2) % 4 end -function mirror_direction_x(direction) +function mirror_dir_x(direction) if direction == directions.left then return directions.right elseif direction == directions.right then @@ -329,7 +329,7 @@ function mirror_direction_x(direction) end end -function mirror_direction_y(direction) +function mirror_dir_y(direction) if direction == directions.up then return directions.down elseif direction == directions.down then @@ -339,10 +339,10 @@ function mirror_direction_y(direction) end end -function rotate_direction_90_cw(direction) +function rotate_dir_90_cw(direction) return (direction + 1) % 4 end -function rotate_direction_90_ccw(direction) +function rotate_dir_90_ccw(direction) return (direction - 1) % 4 end diff --git a/src/engine/data/tilemap.lua b/src/engine/data/tilemap.lua index a7469198..8ea292e5 100644 --- a/src/engine/data/tilemap.lua +++ b/src/engine/data/tilemap.lua @@ -1,6 +1,4 @@ require("engine/core/class") --- engine > game reference to clean -require("game/data/tile_data") local tilemap = new_struct() @@ -11,7 +9,7 @@ end -- load the content into the current map function tilemap:load(content) - clear_map() + tilemap.clear_map() for i = 1, #self.content do local row = self.content[i] for j = 1, #row do @@ -20,4 +18,16 @@ function tilemap:load(content) end end +-- clear map, using appropriate interface (pico8 or busted pico8api) +function tilemap.clear_map() +--#ifn pico8 + pico8:clear_map() +--#endif + +--[[#pico8 + -- clear map data + memset(0x2000, 0, 0x1000) +--#pico8]] +end + return tilemap diff --git a/src/engine/debug/codetuner.lua b/src/engine/debug/codetuner.lua index 31a3f99d..d95a9cd9 100644 --- a/src/engine/debug/codetuner.lua +++ b/src/engine/debug/codetuner.lua @@ -1,12 +1,16 @@ --#if tuner ---#if log -local logging = require("engine/debug/logging") ---#endif require("engine/core/class") require("engine/render/color") local wtk = require("engine/wtk/pico8wtk") +-- code tuner: a debug utility that allows to tune +-- any value in code by using a small widget on screen +-- usage: +-- where you need to test different numerical values in your code, +-- use `tuned("my var", default_value)` instead of `default_value` +-- then, in game, in a build config that defines `tuner` symbol, +-- use the number selection widget for entry "my var" to tune it local codetuner = singleton(function (self) -- parameters @@ -35,10 +39,11 @@ function codetuner.below(w, dist) return w.x, w.y+w.h+(dist or 2) end --- tuned variable class, represents a variable to tune in the code tuner +-- todo: use this struct for easier variable handling +-- tuned variable struct, represents a variable to tune in the code tuner -- currently unused, it will replace the free vars in codetuner.tuned_vars -- to provide better information (type, range, default value) -codetuner.tuned_variable = new_class() +codetuner.tuned_variable = new_struct() -- name string tuned variable identifier -- default_value any value used for tuned variable if codetuner is inactive @@ -52,11 +57,6 @@ function codetuner.tuned_variable:_tostring(name, default_value) return "tuned_variable \""..self.name.."\" (default: "..self.default_value..")" end --- return true if both tuned vars have the same name *and* default -function codetuner.tuned_variable:__eq(other) - return self.name == other.name and self.default_value == other.default_value -end - -- return a function callback for the spinner, that sets the corresponding tuned variable -- exposed via codetuner for testing function codetuner:get_spinner_callback(tuned_var_name) @@ -135,6 +135,22 @@ end -- at any time, even if the window is not shown codetuner:init_window() -return codetuner +--#endif + +-- prevent busted from parsing both versions of codetuner +--[[#pico8 + +--#ifn tuner + +local codetuner = {} + +-- if tuner is disabled, use default value +function tuned(name, default_value) + return default_value +end --#endif + +--#pico8]] + +return codetuner diff --git a/src/engine/debug/logging.lua b/src/engine/debug/logging.lua index 087aa763..3534ef94 100644 --- a/src/engine/debug/logging.lua +++ b/src/engine/debug/logging.lua @@ -61,6 +61,8 @@ end -- function log_stream:on_log() -- end + +-- console log console_log_stream = derived_singleton(log_stream) logging.console_log_stream = console_log_stream @@ -68,6 +70,25 @@ function console_log_stream:on_log(lm) printh(logging.compound_message(lm)) end + +-- file log +file_log_stream = derived_singleton(log_stream, function (self) + self.file_prefix = "game" -- override this to distinguish logs between games and versions +end) +logging.file_log_stream = file_log_stream + +function file_log_stream:clear() + -- clear file by printing nothing while overwriting content + -- note: this will print an empty line at the beginning of the file + printh("", self.file_prefix.."_log", true) +end + +function file_log_stream:on_log(lm) + -- pico8 will add .p8l extension + printh(logging.compound_message(lm), self.file_prefix.."_log") +end + + local logger = singleton(function (self) self.active_categories = { default = true, @@ -77,7 +98,7 @@ local logger = singleton(function (self) codetuner = true, itest = true, -- trace is considered a category, not a level, so we can toggle it independently from the rest - trace = true + trace = false } self.current_level = logging.level.info self.dump_max_recursion_level = 5 @@ -103,6 +124,9 @@ function logger:register_stream(stream) add(self._streams, stream) end +-- level logging.level +-- category str +-- content str function logger:_generic_log(level, category, content) category = category or "default" if logger.active_categories[category] and logger.current_level <= level then @@ -128,17 +152,95 @@ function err(content, category) logger:_generic_log(logging.level.error, category, content) end --- return a precise variable content, including table entries --- for sequence containing nils, nil is not shown but nil's index will be skipped --- if as_key is true and t is not a string, surround it with [] --- by default table recursion will stop at a call depth of logger.dump_max_recursion_level --- however, you can pass a custom number of remaining levels to see more --- if use_tostring is true, use any implemented _tostring method for tables --- you can also use dump on strings just to surround them with quotes -function dump(dumped_value, as_key, level, use_tostring) - as_key = as_key or false +--[[ +Ordered table iterator, allow to iterate on the natural order of the keys of a +table. + +This is only here to allow dump and nice_dump functions to be deterministic +by dumping elements with sorted keys (with an optional argument, as this is only possible +if the keys are comparable), hence easier to debug and test. + +Source: http://lua-users.org/wiki/SortedIteration +Modification: +- updated API for modern Lua (# instead of getn) +]] + +local function __genOrderedIndex( t ) + local orderedIndex = {} + for key in pairs(t) do + table.insert(orderedIndex, key) + end + table.sort(orderedIndex) + return orderedIndex +end + +local function orderedNext(t, state) + -- Equivalent of the next function, but returns the keys in the alphabetic + -- order. We use a temporary ordered key table that is stored in the + -- table being iterated. + + local key = nil + if state == nil then + -- the first time, generate the index + t.__orderedIndex = __genOrderedIndex(t) + key = t.__orderedIndex[1] + else + -- fetch the next value + for i = 1, #t.__orderedIndex do + if t.__orderedIndex[i] == state then + key = t.__orderedIndex[i+1] + end + end + end + + if key then + return key, t[key] + end + + -- no more value to return, cleanup + t.__orderedIndex = nil + return +end + +local function orderedPairs(t) + -- Equivalent of the pairs() function on tables. Allows to iterate + -- in order + return orderedNext, t, nil +end + + +--[[ +return a precise variable content, including table entries. + +for sequence containing nils, nil is not shown but nil's index will be skipped + +if as_key is true and t is not a string, surround it with [] + +by default, table recursion will stop at a call depth of logger.dump_max_recursion_level +however, you can pass a custom number of remaining levels to see more + +if use_tostring is true, use any implemented _tostring method for tables +you can also use dump on strings just to surround them with quotes + + +if sorted_keys is true, dump will try to sort the entries by key +only use this if you are sure that all the keys are comparable +(e.g. only numeric or only strings) +--]] +function dump(dumped_value, as_key, level, use_tostring, sorted_keys) + if as_key == nil then + as_key = false + end + level = level or logger.dump_max_recursion_level - use_tostring = use_tostring or false + + if use_tostring == nil then + use_tostring = false + end + + if sorted_keys == nil then + sorted_keys = false + end local repr @@ -148,9 +250,10 @@ function dump(dumped_value, as_key, level, use_tostring) else if level > 0 then local entries = {} - for key, value in pairs(dumped_value) do - local key_repr = dump(key, true, level - 1, use_tostring) - local value_repr = dump(value, false, level - 1, use_tostring) + local pairs_callback = sorted_keys and orderedPairs or pairs + for key, value in pairs_callback(dumped_value) do + local key_repr = dump(key, true, level - 1, use_tostring, sorted_keys) + local value_repr = dump(value, false, level - 1, use_tostring, sorted_keys) add(entries, key_repr.." = "..value_repr) end repr = "{"..joinstr_table(", ", entries).."}" @@ -175,8 +278,8 @@ function dump(dumped_value, as_key, level, use_tostring) end -- dump using _tostring method when possible -function nice_dump(value) - return dump(value, false, nil, true) +function nice_dump(value, sorted_keys) + return dump(value, false, nil, true, sorted_keys) end return logging diff --git a/src/engine/input/input.lua b/src/engine/input/input.lua index ab9322e0..d15fb920 100644 --- a/src/engine/input/input.lua +++ b/src/engine/input/input.lua @@ -1,6 +1,5 @@ require("engine/core/math") ---#ifn pico8 button_ids = { left = 0, right = 1, @@ -10,6 +9,7 @@ button_ids = { x = 5 } +--#ifn pico8 btn_states = { released = 0, just_pressed = 1, diff --git a/src/engine/physics/collision.lua b/src/engine/physics/collision.lua index 0c6396ed..3a4d68f2 100644 --- a/src/engine/physics/collision.lua +++ b/src/engine/physics/collision.lua @@ -1,12 +1,6 @@ require("engine/application/constants") require("engine/core/math") --- physics notes: collisions use fixed-point floating coordinates --- to support fractional coordinates like classic sonic uses for motion. --- therefore, we don't use pixel perfect collisions, --- e.g. "touching" does not occur when two aabb's pixel representations --- with 1px wide borders touch, but when their exact borders coincide - local collision = {} -- axis-aligned bounding box struct @@ -151,114 +145,4 @@ function aabb:intersects(other) return signed_distance <= 0 end --- struct containing the result of a ground detection test -local ground_query_info = new_struct() -collision.ground_query_info = ground_query_info - --- signed_distance float signed distance to the detected ground (clamped to min-1 amd max+1) --- slope_angle float slope angle of the detected ground (nil if no ground) -function ground_query_info:_init(signed_distance, slope_angle) - self.signed_distance = signed_distance - self.slope_angle = slope_angle -end - ---#if log -function ground_query_info:_tostring() - return "ground_query_info("..joinstr(", ", self.signed_distance, self.slope_angle)..")" -end ---#endif - - --- struct representing the expected result of a character move over a frame, --- computed step by step -local ground_motion_result = new_struct() -collision.ground_motion_result = ground_motion_result - --- position vector position at the end of motion --- slope_angle float slope angle of the final position --- is_blocked bool was the character blocked during motion? --- is_falling bool should the character fall after this motion? -function ground_motion_result:_init(position, slope_angle, is_blocked, is_falling) - self.position = position - self.slope_angle = slope_angle - self.is_blocked = is_blocked - self.is_falling = is_falling -end - ---#if log -function ground_motion_result:_tostring() - return "ground_motion_result("..joinstr(", ", self.position, self.slope_angle, self.is_blocked, self.is_falling)..")" -end ---#endif - - -local tile_data = new_struct() -collision.tile_data = tile_data - --- id_loc sprite_id_location sprite location on the spritesheet --- slope_angle float slope angle in turn ratio (0.0 to 1.0, positive clockwise) -function tile_data:_init(id_loc, slope_angle) - self.id_loc = id_loc - self.slope_angle = slope_angle -end - ---#if log -function tile_data:_tostring() - return "tile_data("..joinstr(", ", self.id_loc:_tostring(), self.slope_angle)..")" -end ---#endif - - -local height_array = new_struct() -collision.height_array = height_array - --- tile_data_value tile_data tile data to generate the height array from --- _array [int] sequence of heights of a tile collision mask column per index, --- counting index from the left, height from the bottom --- it is filled based on tile_mask_id_location --- slope_angle float slope angle in turn ratio (0.0 to 1.0) -function height_array:_init(tile_data_value) - self._array = {} - self._fill_array(self._array, tile_data_value.id_loc) - self.slope_angle = tile_data_value.slope_angle -end - ---#if log -function height_array:_tostring() - return "height_array("..joinstr(", ", "{"..joinstr_table(", ", self._array).."}", self.slope_angle)..")" -end ---#endif - --- return the height for a column index starting at 0, from left to right -function height_array:get_height(column_index0) - return self._array[column_index0 + 1] -- adapt 0-index to 1-index -end - - --- fill the passed array with height data based on the sprite mask --- located at tile_mask_id_location: sprite_id_location --- pass an empty array so it is only filled with the computed values --- the tile mask must represent the collision mask of a tile, with columns --- of non-transparent (black) pixels filled from the bottom, --- or at least the upper edge of said mask (we don't check what is below --- the edge once we found the first non-transparent pixel from top to bottom) -function height_array._fill_array(array, tile_mask_id_location) - local tile_mask_topleft_position = tile_mask_id_location:to_topleft_position() - -- iterate over columns from left to right, searching for the highest filled pixel - for dx = 0, tile_size - 1 do - -- iterate from the top of the column and stop at the first filled pixel (we assume - -- lower pixels are also filled for readability of the tile mask, but not enforced) - local mask_height = 0 - for dy = 0, tile_size - 1 do - local tile_mask_color = sget(tile_mask_topleft_position.x + dx, tile_mask_topleft_position.y + dy) - -- we use black (0) as transparent mask color - if tile_mask_color ~= 0 then - mask_height = tile_size - dy - break - end - end - add(array, mask_height) - end -end - return collision diff --git a/src/engine/render/animated_sprite.lua b/src/engine/render/animated_sprite.lua new file mode 100644 index 00000000..5128f281 --- /dev/null +++ b/src/engine/render/animated_sprite.lua @@ -0,0 +1,99 @@ +-- stateful animated sprite compounded of an animated_sprite_data table and an animation state +-- it can be used as component of an object rendered with some animation +-- for objects with a single animation, use a data table containing a single element +animated_sprite = new_class() + +-- data_table {string: animated_sprite_data} table of animated sprite data, indexed by animation key (unique name) +-- playing bool is the animation playing? false if the animation has reached the end and stopped +-- play_speed_frame float > 0 playback speed multiplier (in frames per update). it's a float so fractions of frames may be advanced every frame +-- current_anim_key string|nil key in data_table of animation currently played / paused, or nil if no animation is set at all +-- current_step int|nil index of the current sprite shown in the animation sequence, starting at 1, or nil if no animation is set at all +-- local_frame float|nil current frame inside the current step, starting at 0, or nil if no animation is set at all +-- since play_speed_frame is a float, local_frame is also a float to allow fractional advance +function animated_sprite:_init(data_table) + self.data_table = data_table + self.playing = false + self.play_speed_frame = 0. + self.current_anim_key = nil -- the sprite will be invisible until we start an animation + self.current_step = nil + self.local_frame = nil +end + +--#if log +function animated_sprite:_tostring() + return "animated_sprite("..joinstr(", ", nice_dump(self.data_table, true), self.playing, self.play_speed_frame, self.current_anim_key, self.current_step, self.local_frame)..")" +end +--#endif + +-- play animation with given key: string at playback speed: float (default: 1.) +-- if this animation is not already set, play it from start +-- if this animation is already set, check from_start: +-- - if true, play it from start +-- - if false, do nothing (if playing, it means continuing to play; if not playing (e.g. stopped at the end), do not replay from start) +-- note that even if the animation is paused, it won't be resumed in this case (because we don't have a flag has_ended to distinguish pause and end) +-- by default, continue animation already playing +function animated_sprite:play(anim_key, from_start, speed) + assert(self.data_table[anim_key] ~= nil, "animated_sprite:play: self.data_table['"..anim_key.."'] doesn't exist") + + if from_start == nil then + from_start = false + end + + speed = speed or 1. + + -- always update speed. this is useful to change anim speed while continue playing the same animation + self.play_speed_frame = speed + + if self.current_anim_key ~= anim_key or from_start then + self.playing = true -- this will do nothing if forcing replay from start during play + self.current_anim_key = anim_key -- this will do nothing if this animation is already set + self.current_step = 1 + self.local_frame = 0 + end +end + +-- update the sprite animation +-- this must be called once per update at 60 fps, before the render phase +-- fractional playback speed is supported, but not negative playback +function animated_sprite:update() + if self.playing then + local anim_spr_data = self.data_table[self.current_anim_key] + -- advance by playback speed + self.local_frame = self.local_frame + self.play_speed_frame + -- check if we have reached the end of this step + -- in case the playback speed is so high we will skip frames, + -- continue checking until time remainder is less than a step duration + while self.local_frame >= anim_spr_data.step_frames do + -- end of step reached, check if there is another sprite afterward + if self.current_step < #anim_spr_data.sprites then + -- show next sprite and reset local frame counter + self.current_step = self.current_step + 1 + self.local_frame = self.local_frame - anim_spr_data.step_frames + else + -- end of last step reached, should we loop? + if anim_spr_data.looping then + -- continue playing from start + self.current_step = 1 + self.local_frame = self.local_frame - anim_spr_data.step_frames + else + -- stop playing + self.playing = false + break + end + end + end + end +end + +-- render the current sprite data with passed arguments +function animated_sprite:render(position, flip_x, flip_y) + if self.current_anim_key then + -- an animation is set, render even if not playing since we want to show the last frame + -- of a non-looped anim as a still frame + local anim_spr_data = self.data_table[self.current_anim_key] + local current_sprite_data = anim_spr_data.sprites[self.current_step] + current_sprite_data:render(position, flip_x, flip_y) + end +end + +return animated_sprite diff --git a/src/engine/render/animated_sprite_data.lua b/src/engine/render/animated_sprite_data.lua new file mode 100644 index 00000000..1311278f --- /dev/null +++ b/src/engine/render/animated_sprite_data.lua @@ -0,0 +1,34 @@ +-- struct containing data on animated sprite: sprite references and timing +animated_sprite_data = new_struct() + +-- sprites {sprite_data} sequence of sprites to play in order +-- step_frames int how long a single sprite (step) is displayed, in frames +-- looping bool true iff animation should loop +function animated_sprite_data:_init(sprites, step_frames, looping) + assert(#sprites > 0) + assert(step_frames > 0) + self.sprites = sprites + self.step_frames = step_frames + if looping == nil then + looping = false + end + self.looping = looping +end + +-- factory function to create animated sprite data from a table +-- of sprite data, and a sequence of keys +function animated_sprite_data.create(sprite_data_table, sprite_keys, step_frames, looping) + local sprites = {} + for sprite_key in all(sprite_keys) do + add(sprites, sprite_data_table[sprite_key]) + end + return animated_sprite_data(sprites, step_frames, looping) +end + +--#if log +function animated_sprite_data:_tostring() + return "animated_sprite_data("..joinstr(", ", "["..#self.sprites.." sprites]", self.step_frames, self.looping)..")" +end +--#endif + +return animated_sprite_data diff --git a/src/engine/render/sprite.lua b/src/engine/render/sprite_data.lua similarity index 64% rename from src/engine/render/sprite.lua rename to src/engine/render/sprite_data.lua index 81b7f657..f0436e08 100644 --- a/src/engine/render/sprite.lua +++ b/src/engine/render/sprite_data.lua @@ -1,7 +1,8 @@ +require("engine/application/constants") require("engine/core/math") require("engine/render/color") --- sprite class +-- sprite struct sprite_data = new_struct() -- id_loc sprite_id_location sprite location on the spritesheet @@ -15,8 +16,7 @@ end --#if log function sprite_data:_tostring() - return "sprite_data("..(self.id_loc:_tostring())..", "..(self.span:_tostring())..", ".. - (self.pivot:_tostring())..")" + return "sprite_data("..joinstr(", ", self.id_loc, self.span, self.pivot)..")" end --#endif @@ -26,10 +26,27 @@ end -- flip_y bool function sprite_data:render(position, flip_x, flip_y) set_unique_transparency(colors.pink) - - local draw_pos = position - self.pivot + + local pivot = self.pivot:copy() + + if flip_x then + -- flip pivot on x + local spr_width = self.span.i * tile_size + pivot.x = spr_width - self.pivot.x + end + + if flip_y then + -- flip pivot on y + local spr_height = self.span.j * tile_size + pivot.y = spr_height - self.pivot.y + end + + local draw_pos = position - pivot + spr(self.id_loc:to_sprite_id(), draw_pos.x, draw_pos.y, self.span.i, self.span.j, flip_x, flip_y) end + +return sprite_data diff --git a/src/engine/test/assertions.lua b/src/engine/test/assertions.lua index 8d3ab4fd..5dc25ba6 100644 --- a/src/engine/test/assertions.lua +++ b/src/engine/test/assertions.lua @@ -17,19 +17,30 @@ function contains_with_message(sequence, passed) local result = contains(sequence, passed) if result then -- passed is not contained, return false with does_not_contain message (will appear when using assert.is_false(contains_with_message())) - return true, "Expected object not to be one of the entries of the sequence.\nPassed in:\n"..nice_dump(passed).."\nSequence:\n"..nice_dump(sequence).."\n--- Ignore below ---" + return true, "Expected object not to be one of the entries of the sequence.\nPassed in:\n"..nice_dump(passed).."\nSequence:\n"..nice_dump(sequence) else - return false, "Expected object to be one of the entries of the sequence.\nPassed in:\n"..nice_dump(passed).."\nSequence:\n"..nice_dump(sequence).."\n--- Ignore below ---" + return false, "Expected object to be one of the entries of the sequence.\nPassed in:\n"..nice_dump(passed).."\nSequence:\n"..nice_dump(sequence) end end +-- imitation of busted equality check with message used in assert.are_equal +-- it returns a "inequality expected" message if expected == passed so we can use it to assert inequality as well +function eq_with_message(expected, passed) + if expected == passed then + return true, "Expected objects to not be equal.\nPassed in:\n"..nice_dump(passed).."\nDid not expect:\n"..nice_dump(expected) + else + return false, "Expected objects to be equal.\nPassed in:\n"..nice_dump(passed).."\nExpected:\n"..nice_dump(expected) + end +end + +-- same, but with almost equality function almost_eq_with_message(expected, passed, eps) eps = eps or 0.01 local result = almost_eq(expected, passed, eps) if result then - return true, "Expected objects not to be almost equal with eps: "..eps..".\nPassed in:\n"..nice_dump(passed).."\nExpected:\n"..nice_dump(expected).."\n--- Ignore below ---" + return true, "Expected objects to not be almost equal with eps: "..eps..".\nPassed in:\n"..nice_dump(passed).."\nDid not expect:\n"..nice_dump(expected) else - return false, "Expected objects to be almost equal with eps: "..eps..".\nPassed in:\n"..nice_dump(passed).."\nExpected:\n"..nice_dump(expected).."\n--- Ignore below ---" + return false, "Expected objects to be almost equal with eps: "..eps..".\nPassed in:\n"..nice_dump(passed).."\nExpected:\n"..nice_dump(expected) end end diff --git a/tests/bustedhelper.lua b/src/engine/test/bustedhelper.lua similarity index 82% rename from tests/bustedhelper.lua rename to src/engine/test/bustedhelper.lua index b3af79f0..fde86f1e 100644 --- a/tests/bustedhelper.lua +++ b/src/engine/test/bustedhelper.lua @@ -1,12 +1,16 @@ -- required module for all tests -require("pico8api") +require("engine/test/pico8api") require("engine/test/assertions") -- mute all messages during tests, unless told otherwise during the tests local logging = require("engine/debug/logging") logging.logger:register_stream(logging.console_log_stream) +logging.logger:register_stream(logging.file_log_stream) logging.logger:deactivate_all_categories() -- headless itests will restore "itest" and sometimes "trace" +-- clear log file on new utest session +logging.file_log_stream:clear() + -- return the current file line in the format "{file}:{line}" to make it easy to navigate there from the printed message -- if you call this function from an intermediate helper function, add an extra level for each intermediate step function get_file_line(extra_level) @@ -37,8 +41,15 @@ end -- R the test revealed the same bug/regression as another test, being redundant while not helping to discover another bug -- < the test was incomplete, but by examining the code I spotted suspicious cases that I could verify by improving the test, then fix the cases -- ^ the test was incomplete, so when I stumbled on a special case bug, I improved the test to make sure I fixed it and avoid regression later +-- / the test was missing at first, but when I spotted missing cases I added them, although the test passed immediately anyway -- _ the test was missing at first, but by examining the code I spotted suspicious cases that I could verify by adding a new test, then fix the cases -- = the test was missing at first so when I stumbled on a new bug, I wrote that test to make sure I fixed it and avoid regression later -- + the test helped me identify potential bugs and tricky cases during implementation of the function using the test -- ! the test failed, revealing a bug hidden in another function indirectly used by the test but not developed at the same time -- * the test revealed a regression/feature change later during development (very useful) + +-- Note about testing with was_called and was_called_with +-- I reported this issue: assert.spy().was_called_with(...) doesn't provide helpful information on failure #578 +-- on https://github.com/Olivine-Labs/busted/issues/578 +-- When debugging arguments actually called, use this workaround: +-- print(nice_dump(spy/stub.calls[i].refs/vals)), e.g. print(nice_dump(stub.calls[1].vals)) diff --git a/src/engine/test/integrationtest.lua b/src/engine/test/integrationtest.lua index 28389a80..5d9e7e54 100644 --- a/src/engine/test/integrationtest.lua +++ b/src/engine/test/integrationtest.lua @@ -5,12 +5,9 @@ require("engine/test/assertions") --#if log local logging = require("engine/debug/logging") --#endif --- engine -> game reference is not good, consider using flow directly --- and isolating active gamestates somewhere else (e.g. a generic gameapp in engine) -local gameapp = require("game/application/gameapp") local input = require("engine/input/input") -local integrationtest = {} +local mod = {} test_states = { none = 'none', -- no test started @@ -21,11 +18,11 @@ test_states = { } -- integration test manager: registers all itests --- itests {string: itest} registered itests, indexed by name +-- itests {string: itest} registered itests, indexed by name itest_manager = singleton(function (self) self.itests = {} end) -integrationtest.itest_manager = itest_manager +mod.itest_manager = itest_manager -- all-in-one utility function that creates and register a new itest, -- defining setup, actions and final assertion inside a contextual callback, @@ -50,7 +47,7 @@ integrationtest.itest_manager = itest_manager -- end) -- end) function itest_manager:register_itest(name, states, definition) - local itest = integrationtest.integration_test(name, states) + local itest = mod.integration_test(name, states) self:register(itest) -- context @@ -78,7 +75,7 @@ function itest_manager:register_itest(name, states, definition) -- we were already waiting, so finish last wait with empty action itest:add_action(last_time_trigger, nil) end - last_time_trigger = integrationtest.time_trigger(time, use_frame_unit) + last_time_trigger = mod.time_trigger(time, use_frame_unit) end function act(callback) @@ -87,7 +84,7 @@ function itest_manager:register_itest(name, states, definition) last_time_trigger = nil -- consume so we know no final wait-action is needed else -- no wait since last action (or this is the first action), so use immediate trigger - itest:add_action(integrationtest.immediate_trigger, callback) + itest:add_action(mod.immediate_trigger, callback) end end @@ -114,46 +111,69 @@ end function itest_manager:init_game_and_start_by_index(index) local itest = self.itests[index] assert(itest, "itest_manager:init_game_and_start_by_index: index is "..tostr(index).." but only "..tostr(#self.itests).." were registered.") - integration_test_runner:init_game_and_start(itest) + itest_runner:init_game_and_start(itest) end -- integration test runner singleton --- test lifetime: +-- usage: +-- first, make sure you have registered itests via the itest_manager +-- and that you are running an itest via itest_manager:init_game_and_start_by_index (a proxy for itest_runner:init_game_and_start) +-- in _init, create a game app, set its initial_gamestate and set itest_runner.app to this app instance +-- in _update(60), call itest_runner:update_game_and_test +-- in _draw, call itest_runner:draw_game_and_test + +-- attributes +-- initialized bool true if it has already been initialized. +-- initialization is lazy and is only needed once +-- current_test integration_test current itest being run +-- current_frame int index of the current frame run +-- _last_trigger_frame int stored index of the frame where the last command trigger was received +-- _next_action_index int index of the next action to execute in the action list +-- current_state test_states stores if test has not started, is still running or has succeeded/failed +-- current_message string failure message, nil if test has not failed +-- app gameapp gameapp instance of the tested game +-- must be set directly with itest_runner.app = ... + +-- a test's lifetime follows the phases: -- none -> running -> success/failure/timeout (still alive, but not updated) -- -> stopped when a another test starts running -integration_test_runner = singleton(function (self) +itest_runner = singleton(function (self) self.initialized = false self.current_test = nil self.current_frame = 0 self._last_trigger_frame = 0 self._next_action_index = 1 self.current_state = test_states.none - self.current_message = nil -- only defined when current_state is failure + self.current_message = nil + self.app = nil end) -- helper method to use in rendered itest _init -function integration_test_runner:init_game_and_start(test) - -- if there was a previous test, gameapp was already initialized, - -- so reset it now (we could also just keep it and change the gamestate - -- to void, if we are sure that all the itests have the same required modules) +function itest_runner:init_game_and_start(test) + assert(self.app ~= nil, "itest_runner:init_game_and_start: self.app is not set") + + -- if there was a previous test, app was initialized too, so reset both now + -- (in reverse order of start) if self.current_test then - gameapp.reinit_modules() + self:stop() + self.app:reset() end - gameapp.init(test.active_gamestates) - integration_test_runner:start(test) + self.app:start() + itest_runner:start(test) end -- helper method to use in rendered itest _update60 -function integration_test_runner:update_game_and_test() +function itest_runner:update_game_and_test() if self.current_state == test_states.running then - -- update gameapp, then test runner + + -- update app, then test runner -- updating test runner 2nd allows us to check the actual game state at final frame f, -- after everything has been computed -- time_trigger(0.) initial actions will still be applied before first frame -- thanks to the initial _check_next_action on start, but setup is still recommended log("frame #"..self.current_frame + 1, "trace") - gameapp.update() + self.app:update() self:update() if self.current_state ~= test_states.running then log("itest '"..self.current_test.name.."' ended with "..self.current_state, "itest") @@ -165,20 +185,20 @@ function integration_test_runner:update_game_and_test() end -- helper method to use in rendered itest _draw -function integration_test_runner:draw_game_and_test() - gameapp.draw() +function itest_runner:draw_game_and_test() + self.app:draw() self:draw() end -function integration_test_runner:start(test) +-- start a test: integration_test +function itest_runner:start(test) -- lazy initialization if not self.initialized then self:_initialize() end - if self.current_test then - self:stop() - end + -- log after _initialize which sets up the logger + log("starting itest: "..test.name, "trace") self.current_test = test self.current_state = test_states.running @@ -194,8 +214,8 @@ function integration_test_runner:start(test) end end -function integration_test_runner:update() - assert(self.current_test, "integration_test_runner:update: current_test is not set") +function itest_runner:update() + assert(self.current_test, "itest_runner:update: current_test is not set") if self.current_state ~= test_states.running then -- the current test is over and we already got the result -- do nothing and fail silently (to avoid crashing @@ -214,13 +234,16 @@ function integration_test_runner:update() end end -function integration_test_runner:draw() - assert(self.current_test, "integration_test_runner:draw: current_test is not set") - api.print(self.current_test.name, 2, 2, colors.yellow) - api.print(self.current_state, 2, 9, self:_get_test_state_color(self.current_state)) +function itest_runner:draw() + if self.current_test then + api.print(self.current_test.name, 2, 2, colors.yellow) + api.print(self.current_state, 2, 9, self:_get_test_state_color(self.current_state)) + else + api.print("no itest running", 8, 8, colors.white) + end end -function integration_test_runner:_get_test_state_color(test_state) +function itest_runner:_get_test_state_color(test_state) if test_state == test_states.none then return colors.white elseif test_state == test_states.running then @@ -234,27 +257,26 @@ function integration_test_runner:_get_test_state_color(test_state) end end -function integration_test_runner:_initialize() +function itest_runner:_initialize() -- use simulated input during itests input.mode = input_modes.simulated --#if log -- all itests should only print itest logs, and maybe trace if you want logging.logger:deactivate_all_categories() - ---[[#pico8 logging.logger.active_categories["itest"] = true ---#pico8]] - logging.logger.active_categories["trace"] = false --#endif self.initialized = true end -function integration_test_runner:_check_next_action() +function itest_runner:_check_next_action() assert(self._next_action_index <= #self.current_test.action_sequence, "self._next_action_index ("..self._next_action_index..") is out of bounds for self.current_test.action_sequence (size "..#self.current_test.action_sequence..")") + -- test: chain actions with no intervals between them + local should_trigger_next_action + repeat -- check if next action should be applied local next_action = self.current_test.action_sequence[self._next_action_index] local should_trigger_next_action = next_action.trigger:_check(self.current_frame - self._last_trigger_frame) @@ -265,11 +287,14 @@ function integration_test_runner:_check_next_action() end self._last_trigger_frame = self.current_frame self._next_action_index = self._next_action_index + 1 - self:_check_end() + if self:_check_end() then + break + end end + until not should_trigger_next_action end -function integration_test_runner:_check_end() +function itest_runner:_check_end() -- check if last action was applied, end now -- this means you can define an 'end' action just by adding an empty action at the end if self.current_test.action_sequence[1] then @@ -281,7 +306,7 @@ function integration_test_runner:_check_end() return false end -function integration_test_runner:_end_with_final_assertion() +function itest_runner:_end_with_final_assertion() -- check the final assertion so we know if we should end with success or failure result, message = self.current_test:_check_final_assertion() if result then @@ -295,7 +320,7 @@ end -- stop the current test, tear it down and reset all values -- this is only called when starting a new test, not when it finished, -- so we can still access info on the current test while the user examines its result -function integration_test_runner:stop() +function itest_runner:stop() if self.current_test.teardown then self.current_test.teardown() end @@ -309,7 +334,7 @@ end -- time trigger struct local time_trigger = new_struct() -integrationtest.time_trigger = time_trigger +mod.time_trigger = time_trigger -- non-member parameters -- time float time to wait before running callback after last trigger (in seconds by default, in frames if use_frame_unit is true) @@ -338,7 +363,7 @@ function time_trigger:_check(elapsed_frames) end -- helper triggers -integrationtest.immediate_trigger = time_trigger(0, true) +mod.immediate_trigger = time_trigger(0, true) -- scripted action struct (but we use class because comparing functions only work by reference) @@ -363,7 +388,7 @@ end -- integration test class local integration_test = new_class() -integrationtest.integration_test = integration_test +mod.integration_test = integration_test -- parameters -- name string test name @@ -419,4 +444,4 @@ function integration_test:_check_final_assertion() end end -return integrationtest +return mod diff --git a/src/engine/test/itest_dsl.lua b/src/engine/test/itest_dsl.lua deleted file mode 100644 index 100cbf85..00000000 --- a/src/engine/test/itest_dsl.lua +++ /dev/null @@ -1,354 +0,0 @@ -require("engine/core/helper") -local integrationtest = require("engine/test/integrationtest") -local itest_manager, integration_test = integrationtest.itest_manager, integrationtest.integration_test - -local tile_data = require("game/data/tile_data") -local tilemap = require("engine/data/tilemap") - --- dsl interpretation requirements -local flow = require("engine/application/flow") -local stage = require("game/ingame/stage") -- required -local pc_data = require("game/data/playercharacter_data") - --- itest dsl parser singleton, with parser context state --- _itest integration_test current integration test in construction --- _last_time_trigger time_trigger last time trigger registered with wait command -local itest_dsl = singleton(function (self) - self._itest = nil - self._last_time_trigger = nil -end) - - --- type of commands available -itest_dsl_command_types = { - warp = 1, -- warp player character bottom args: {bottom position: vector} - move = 2, -- set sticky pc move intention args: {move_dir: horizontal_dirs} - wait = 11, -- - expect = 21 -} - --- type of gameplay values available for expectations -itest_dsl_gp_value_types = { - pc_bottom_pos = 1 -} - --- string mapping for itest messages -local value_type_strings = { - "player character bottom position" -} - - --- command struct - --- attributes --- type command_types type of command invoked --- args {*} sequence of arguments -local command = new_struct() -itest_dsl.command = command - -function command:_init(cmd_type, args) - self.type = cmd_type - self.args = args -end - - --- dsl itest struct - --- attributes --- gamestate_type string gamestate type to start test in (also the only active gamestate) --- stage_name string|nil stage name to play if gamestate type is 'stage', nil else --- tilemap tilemap|nil tilemap data if gamestate type is 'stage', nil else --- commands {commands} sequence of commands to apply -local dsl_itest = new_struct() -itest_dsl.dsl_itest = dsl_itest - -function dsl_itest:_init() - -- all attributes are initially nil or empty -end - - --- parse, create and register itest from dsl -function itest_dsl.register(name, dsli_source) - local dsli = itest_dsl.parse(dsli_source) - local test = itest_dsl.create_itest(name, dsli) - itest_manager:register(test) -end - --- parse a dsl itest source and return a dsl itest --- an itest is defined by a scenario and expectations --- a dsl itest is split into 2 parts: --- 1. gamestate definition --- 2. action sequence and expectations --- ex: --- [[ --- @stage # < gamestate 'stage' with tag '#' for custom --- ... < for custom stage, provide the tilemap in ascii --- ### < . for empty tile, # for full tile, etc. --- < blank after tilemap to mark the end --- warp 4 8 < initial setup (it's an action like any other) --- move right < more actions... --- wait 30 < wait delays the next action (here, the nil action) --- expect pc_bottom_pos 14. 8. < expectation (only final assertion is supported) --- ]] -function itest_dsl.parse(dsli_source) - -- create dsl itest - local dsli = dsl_itest() - - -- split source lines (do not collapse \n so we can use blank lines as separator) - local lines = strspl(dsli_source, '\n') - - -- parse in 2 steps: gamestate and action sequence - local next_line_index - dsli.gamestate_type, dsli.stage_name, dsli.tilemap, next_line_index = itest_dsl.parse_gamestate_definition(lines) - dsli.commands = itest_dsl.parse_action_sequence(lines, next_line_index) - - return dsli -end - --- return gamestate type, stage_name, tilemap data and index of next line to parse so we can chain parsing --- the format of the gamestate definition is: --- @[gamestate] (stage_name|#)? < 2nd part only if gamestate == 'stage', '#' for custom tilemap --- [tilemap row 1] < only for custom tilemap --- ... --- [tilemap row n] --- < blank after tilemap (or one-line gamestate definition) to mark the end --- ? < we don't check what's after, just return this line's index -function itest_dsl.parse_gamestate_definition(lines) - -- parse first line to get state and optional stage - local gamestate_header = lines[1] - assert(sub(gamestate_header, 1, 1) == '@', "gamestate_header '"..gamestate_header.."' doesn't start with @") - local header_parts = strspl(gamestate_header, ' ', true) - local gamestate_type = sub(header_parts[1], 2) - local stage_name = nil - if gamestate_type == 'stage' then - assert(#header_parts == 2) - stage_name = header_parts[2] - end - - local tm = nil - local next_line_index = 3 - if stage_name == '#' then - -- we are defining a custom tilemap, let's parse it - tm, next_line_index = itest_dsl.parse_tilemap(lines) - end - - return gamestate_type, stage_name, tm, next_line_index -end - -function itest_dsl.parse_tilemap(lines) - -- tilemap should always start at line 2 - -- first line will give the tilemap width - assert(#lines >= 2, "only "..#lines.." line(s), need at least 2") - - local content = {} -- sequence of sequence of tilemap symbols - local width = 0 -- number of symbols per tilemap row - - for i = 2, #lines do - local line_str = lines[i] - - -- ensure that line is either empty or made of one block of symbols with no spaces - -- this step will also trim any extra space (e.g. from " \" used to chain lines) - local line_blocks = strspl(line_str, ' ', true) - if #line_blocks == 0 then - -- we reached the end of tilemap definition - break - end - - assert(#line_blocks == 1, "too many blocks: "..#line_blocks..", expected 1") - local trimmed_line_str = line_blocks[1] - if width == 0 then - -- no width defined on first line (i == 2), store it now - width = #trimmed_line_str - else - -- on further lines, check consistency - assert(#trimmed_line_str == width, "inconsistent line length: "..#trimmed_line_str.." vs "..width) - end - - - local current_row = {} - - for j = 1, width do - local tile_symbol = sub(trimmed_line_str, j, j) - local tile_id = tile_symbol_to_ids[tile_symbol] - assert(tile_id, "unknown tile symbol: "..tile_symbol) - add(current_row, tile_id) - end - - add(content, current_row) - end - - -- return tilemap, next line = initial line index + nb rows + 1 - return tilemap(content), 2 + #content + 1 -end - - -function itest_dsl.parse_action_sequence(lines, next_line_index) - local commands = {} - for i = next_line_index, #lines do - words = strspl(lines[i], ' ', true) - -- if there are no words, the line is empty, so continue - if #words > 0 then - local cmd_type_str = words[1] - local arg_strings = {} - for j = 2, #words do - add(arg_strings, words[j]) - end - local cmd_type = itest_dsl_command_types[cmd_type_str] - local parse_fn_name = '_parse_args_'..cmd_type_str - assert(itest_dsl[parse_fn_name], "parse function '"..parse_fn_name.."' is not defined") - local args = {itest_dsl[parse_fn_name](arg_strings)} - add(commands, command(cmd_type, args)) - end - end - return commands -end - --- convert string args to vector -function itest_dsl._parse_args_warp(args) - assert(#args == 2, "got "..#args.." args") - return vector(tonum(args[1]), tonum(args[2])) -- bottom position -end - --- convert string args to vector -function itest_dsl._parse_args_move(args) - assert(#args == 1, "got "..#args.." args") - return horizontal_dirs[args[1]] -- move intention -end - --- convert string args to vector -function itest_dsl._parse_args_wait(args) - assert(#args == 1, "got "..#args.." args") - return tonum(args[1]) -- frames to wait -end - --- convert string args to vector -function itest_dsl._parse_args_expect(args) - assert(#args > 1, "got "..#args.." args") - -- same principle as itest_dsl.parse, the type of the first arg - -- determines how we parse the rest of the args, named "value components" - local value_type_str = args[1] - -- gather all the value components as strings (e.g. {"3", "4"} for vector(3, 4)) - local expected_value_comps = {} - for i = 2, #args do - add(expected_value_comps, args[i]) - end - -- determine the type of value reference tested for comparison (e.g. pc position) - local value_type = itest_dsl_gp_value_types[value_type_str] - -- parse the value components to semantical type (e.g. vector) - local parse_fn_name = '_parse_value_'..value_type_str - assert(itest_dsl[parse_fn_name], "parse function '"..parse_fn_name.."' is not defined") - local expected_value = itest_dsl[parse_fn_name](expected_value_comps) - return value_type, expected_value -end - --- convert string args to vector -function itest_dsl._parse_value_pc_bottom_pos(args) - assert(#args == 2, "got "..#args.." args") - return vector(tonum(args[1]), tonum(args[2])) -end - --- create and return an itest from a dsli, providing a name -function itest_dsl.create_itest(name, dsli) - itest_dsl._itest = integration_test(name, {dsli.gamestate_type}) - - itest_dsl._itest.setup = function () - flow:change_gamestate_by_type(dsli.gamestate_type) - if dsli.gamestate_type == "stage" then - -- puppet control - stage.state.player_char.control_mode = control_modes.puppet - if dsli.stage_name == '#' then - -- load tilemap data and build it from ascii - setup_map_data() - dsli.tilemap:load() - else - -- load stage by name when api is ready - end - end - end - - itest_dsl._itest.teardown = function () - flow:change_gamestate_by_type(dsli.gamestate_type) - if dsli.gamestate_type == "stage" then - if dsli.stage_name == '#' then - -- clear tilemap and unload tilemap data - clear_map() - teardown_map_data() - end - end - end - - for cmd in all(dsli.commands) do - if cmd.type == itest_dsl_command_types.warp then - itest_dsl:_act(function () - stage.state.player_char:warp_bottom_to(vector(cmd.args[1].x, cmd.args[1].y)) - end) - elseif cmd.type == itest_dsl_command_types.move then - itest_dsl:_act(function () - stage.state.player_char.move_intention = horizontal_dir_vectors[cmd.args[1]] - end) - elseif cmd.type == itest_dsl_command_types.wait then - itest_dsl:_wait(cmd.args[1]) - elseif cmd.type == itest_dsl_command_types.expect then - -- we currently don't support live assertions, only final assertion - itest_dsl:_final_assert(unpack(cmd.args)) - end - end - - -- if we finished with a wait (with or without final assertion), - -- we need to close the itest with a wait-action - if itest_dsl._last_time_trigger then - itest_dsl._itest:add_action(itest_dsl._last_time_trigger, nil) - end - - local test = itest_dsl._itest - - -- cleanup - itest_dsl._itest = nil - itest_dsl._last_time_trigger = nil - - return test -end - -function itest_dsl:_act(callback) - if self._last_time_trigger then - self._itest:add_action(self._last_time_trigger, callback) - self._last_time_trigger = nil -- consume so we know no final wait-action is needed - else - -- no wait since last action (or this is the first action), so use immediate trigger - self._itest:add_action(integrationtest.immediate_trigger, callback) - end -end - -function itest_dsl:_wait(interval) - if self._last_time_trigger then - -- we were already waiting, so finish last wait with empty action - self._itest:add_action(self._last_time_trigger, nil) - end - -- we only support frame unit in the dsl - self._last_time_trigger = integrationtest.time_trigger(interval, true) -end - -function itest_dsl:_final_assert(gp_value_type, expected_gp_value) - local gp_value_name = value_type_strings[gp_value_type] - assert(gp_value_name, "invalid gp_value_type: "..gp_value_type) - self._itest.final_assertion = function () - local gp_value = self._evaluate(gp_value_type) - return gp_value == expected_gp_value, - "Passed gameplay value '"..gp_value_name.."':\n".. - gp_value.."\n".. - "Expected:\n".. - expected_gp_value - end -end - --- evaluate gameplay value. it is important to call this at expect --- time, not when defining the test, to get the actual runtime value -function itest_dsl._evaluate(gp_value_type) - if gp_value_type == itest_dsl_gp_value_types.pc_bottom_pos then - return stage.state.player_char:get_bottom_center() - else - assert(false, "unknown gameplay value: "..gp_value_type) - end -end - -return itest_dsl diff --git a/tests/pico8api.lua b/src/engine/test/pico8api.lua similarity index 80% rename from tests/pico8api.lua rename to src/engine/test/pico8api.lua index 7ae6afaf..a2d25d8e 100644 --- a/tests/pico8api.lua +++ b/src/engine/test/pico8api.lua @@ -1,4 +1,7 @@ -- pico-8 api placeholders for tests run under vanilla lua +-- pico8:method calls in non-test scripts should be surrounded by +-- "--#ifn pico8" but don't need a require("engine/test/pico8api") (since they will +-- always be required by a utest script already requiring bustedhelper) -- functions taken from gamax92's fork of picolove -- https://github.com/gamax92/picolove/blob/master/api.lua @@ -52,9 +55,9 @@ end -- busted-only helper to clear the map, as memset(0x2000, 0, 0x1000) wouldn't work function pico8:clear_map() for y = 0, 63 do - pico8.map[y] = {} + self.map[y] = {} for x = 0, 127 do - pico8.map[y][x] = 0 + self.map[y][x] = 0 end end end @@ -63,7 +66,7 @@ end -- and we don't really do that in pico8 anyway function pico8:clear_spriteflags() for n = 0, 255 do - pico8.spriteflags[n] = 0 + self.spriteflags[n] = 0 end end @@ -107,17 +110,32 @@ end function cursor(x, y) end +-- convert string to number, preserve number +-- return nil if it fails to parse (unlike pico8 which returns custom value [no value]) +-- tonumber is even a little better than pico8 tonum which fails on negative number strings: +-- e.g. "-0.1875" -> 0xffff.d001 instead of 0xffff.d000 +-- https://www.lexaloffle.com/bbs/?tid=3780 +-- for this reason, we recommend not using tonum in actual pico8 code +-- and to use helper's string_tonum instead +-- UPDATE: expect native tonum to be fixed in 0.1.12 +-- https://www.lexaloffle.com/bbs/?pid=63583 function tonum(val) return tonumber(val) -- not a direct assignment to prevent usage of the radix argument end +-- http://pico-8.wikia.com/wiki/Tostr +-- slight difference with pico8: when passing the result of a function +-- that returns nothing, we return "[nil]" instead of "[no value]" function tostr(val, hex) local kind=type(val) if kind == "string" then return val elseif kind == "number" then if hex then - val=val*0x10000 + -- in floating-point precision Lua, val may have more that 4 hex figures + -- after the hexadecimal point + val=flr(val*0x10000) + local test = val & 0xFFFF0000 local part1=(val & 0xFFFF0000) >> 16 -- fixed from original api.lua local part2=val & 0xFFFF return string.format("0x%04x.%04x", part1, part2) @@ -125,6 +143,8 @@ function tostr(val, hex) return tostring(val) end elseif kind == "boolean" then + -- this is even more precise that pico8 tostr, that will skip the last decimals (e.g. 1e-4 in 1+1e-4), + -- even if fixed point precision didn't lose them. but it's fine since it's mostly useful to debug failing tests return tostring(val) else return "[" .. kind .. "]" @@ -381,6 +401,8 @@ function max(a, b) return b end +-- return value in the middle +-- can also be used for clamping function mid(x, y, z) return (x<=y)and((y<=z)and y or((x= 8 and x <= 15 and y >= 16 and y <= 23 then - -- return filled pixel color iff below mask height on this column - local height = mock_height_array[x - 7] - if y - 16 >= tile_size - height then - return 1 - else - return 0 - end - end - end) - end) - - teardown(function () - sget_mock:revert() - end) - - it('should fill the array with ', function () - local array = {} - height_array._fill_array(array, sprite_id_location(1, 2)) - assert.are_same({2, 3, 5, 6, 0, 1, 4, 2}, array) - end) - - end) - - end) - end) diff --git a/tests/utestcolor.lua b/src/engine/tests/utestcolor.lua similarity index 98% rename from tests/utestcolor.lua rename to src/engine/tests/utestcolor.lua index e619e345..b1b6054f 100644 --- a/tests/utestcolor.lua +++ b/src/engine/tests/utestcolor.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") require("engine/render/color") describe('color_tostring', function () diff --git a/tests/utestcoroutine.lua b/src/engine/tests/utestcoroutine.lua similarity index 96% rename from tests/utestcoroutine.lua rename to src/engine/tests/utestcoroutine.lua index b4737ebc..569e88e2 100644 --- a/tests/utestcoroutine.lua +++ b/src/engine/tests/utestcoroutine.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") require("engine/core/coroutine") describe('coroutine_curry', function () diff --git a/tests/utestdatastruct.lua b/src/engine/tests/utestdatastruct.lua similarity index 98% rename from tests/utestdatastruct.lua rename to src/engine/tests/utestdatastruct.lua index d0ce24d3..b16344f6 100644 --- a/tests/utestdatastruct.lua +++ b/src/engine/tests/utestdatastruct.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") require("engine/core/datastruct") describe('circular_buffer', function () @@ -110,6 +110,8 @@ describe('circular_buffer', function () max_length = 3, buffer = {1, 2, 10}, get = function (self, i) + -- circular_buffer.__eq won't even try to compare cb1 and cb2, + -- so this will actually never be called return self._buffer[i] end } @@ -147,6 +149,7 @@ describe('circular_buffer', function () it('(^): no iteration at all', function () local cb = circular_buffer(2) for i, v in ipairs(cb) do + -- should never be called assert.is_true(false) end end) diff --git a/tests/utestdebug_window.lua b/src/engine/tests/utestdebug_window.lua similarity index 98% rename from tests/utestdebug_window.lua rename to src/engine/tests/utestdebug_window.lua index cddea119..1f0b0e11 100644 --- a/tests/utestdebug_window.lua +++ b/src/engine/tests/utestdebug_window.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local debug_window = require("engine/debug/debug_window") local wtk = require("engine/wtk/pico8wtk") diff --git a/tests/utestflow.lua b/src/engine/tests/utestflow.lua similarity index 60% rename from tests/utestflow.lua rename to src/engine/tests/utestflow.lua index 9097a862..7ceb2a19 100644 --- a/tests/utestflow.lua +++ b/src/engine/tests/utestflow.lua @@ -1,11 +1,30 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local flow = require("engine/application/flow") local helper = require("engine/core/helper") -local titlemenu = require("game/menu/titlemenu") -local credits = require("game/menu/credits") describe('flow', function () + local mock_gamestate = new_class() + + function mock_gamestate:_init(type) + self.type = type + end + + function mock_gamestate:on_enter() + end + + function mock_gamestate:on_exit() + end + + function mock_gamestate:update() + end + + function mock_gamestate:render() + end + + local mock_gamestate1 = mock_gamestate("mock1") + local mock_gamestate2 = mock_gamestate("mock2") + describe('init', function () assert.are_same({{}, nil, nil}, {flow.gamestates, flow.curr_state, flow.next_state}) @@ -26,8 +45,8 @@ describe('flow', function () end) it('should add a gamestate', function () - flow:add_gamestate(titlemenu.state) - assert.are_equal(titlemenu.state, flow.gamestates[titlemenu.state.type]) + flow:add_gamestate(mock_gamestate1) + assert.are_equal(mock_gamestate1, flow.gamestates[mock_gamestate1.type]) end) it('should assert if a nil gamestate is passed', function () @@ -39,10 +58,10 @@ describe('flow', function () end) - describe('(titlemenu gamestate added)', function () + describe('(mock gamestate 1 added)', function () setup(function () - flow:add_gamestate(titlemenu.state) + flow:add_gamestate(mock_gamestate1) end) teardown(function () @@ -56,13 +75,13 @@ describe('flow', function () end) it('should query a new gamestate with the correct type', function () - flow:query_gamestate_type(titlemenu.state.type) - assert.are_equal(titlemenu.state.type, flow.next_state.type) + flow:query_gamestate_type(mock_gamestate1.type) + assert.are_equal(mock_gamestate1.type, flow.next_state.type) end) it('should query a new gamestate with the correct reference', function () - flow:query_gamestate_type(titlemenu.state.type) - assert.are_equal(flow.gamestates[titlemenu.state.type], flow.next_state) + flow:query_gamestate_type(mock_gamestate1.type) + assert.are_equal(flow.gamestates[mock_gamestate1.type], flow.next_state) end) it('should assert if a nil gamestate type is passed', function () @@ -72,10 +91,10 @@ describe('flow', function () "flow:query_gamestate_type: passed gamestate_type is nil") end) - describe('(titlemenu state entered)', function () + describe('(mock gamestate 1 entered)', function () before_each(function () - flow.curr_state = titlemenu.state + flow.curr_state = mock_gamestate1 end) after_each(function () @@ -84,9 +103,9 @@ describe('flow', function () it('should assert if the same gamestate type as the current one is passed', function () assert.has_error(function () - flow:query_gamestate_type(titlemenu.state.type) + flow:query_gamestate_type(mock_gamestate1.type) end, - "flow:query_gamestate_type: cannot query the current gamestate type 'titlemenu' itself") + "flow:query_gamestate_type: cannot query the current gamestate type 'mock1' itself") end) end) @@ -96,7 +115,7 @@ describe('flow', function () describe('query_gamestate_type', function () before_each(function () - flow:query_gamestate_type(titlemenu.state.type) + flow:query_gamestate_type(mock_gamestate1.type) end) after_each(function () @@ -110,16 +129,16 @@ describe('flow', function () end) after_each(function () - flow.curr_state:on_exit() -- just cleanup in case titlemenu on_enter had some side effects, since we didn't stub it + -- no need to on_exit current state, we know our mock states do nothing flow.curr_state = nil end) it('should enter a new gamestate with the correct type', function () - assert.are_equal(titlemenu.state.type, flow.curr_state.type) + assert.are_equal(mock_gamestate1.type, flow.curr_state.type) end) it('should enter a new gamestate with the correct reference', function () - assert.are_equal(flow.gamestates[titlemenu.state.type], flow.curr_state) + assert.are_equal(flow.gamestates[mock_gamestate1.type], flow.curr_state) end) it('should clear the next gamestate query', function () @@ -135,16 +154,16 @@ describe('flow', function () end) after_each(function () - flow.curr_state:on_exit() -- just cleanup in case titlemenu on_enter had some side effects, since we didn't stub it + -- no need to on_exit current state, we know our mock states do nothing flow.curr_state = nil end) it('via _check_next_state enter a new gamestate with the correct type', function () - assert.are_equal(titlemenu.state.type, flow.curr_state.type) + assert.are_equal(mock_gamestate1.type, flow.curr_state.type) end) it('via _check_next_state enter a new gamestate with correct reference', function () - assert.are_equal(flow.gamestates[titlemenu.state.type], flow.curr_state) + assert.are_equal(flow.gamestates[mock_gamestate1.type], flow.curr_state) end) it('via _check_next_state hence clear the next gamestate query', function () @@ -157,7 +176,7 @@ describe('flow', function () after_each(function () if flow.curr_state then - flow.curr_state:on_exit() -- just cleanup in case titlemenu on_enter had some side effects, since we didn't stub it + -- no need to on_exit current state, we know our mock states do nothing flow.curr_state = nil end end) @@ -170,13 +189,13 @@ describe('flow', function () end) it('should directly enter a gamestate', function () - flow:_change_state(titlemenu.state) - assert.are_equal(flow.gamestates[titlemenu.state.type], flow.curr_state) - assert.are_equal(titlemenu.state.type, flow.curr_state.type) + flow:_change_state(mock_gamestate1) + assert.are_equal(flow.gamestates[mock_gamestate1.type], flow.curr_state) + assert.are_equal(mock_gamestate1.type, flow.curr_state.type) end) it('should cleanup the now obsolete next gamestate query', function () - flow:_change_state(titlemenu.state) + flow:_change_state(mock_gamestate1) assert.is_nil(flow.next_state) end) @@ -195,7 +214,7 @@ describe('flow', function () after_each(function () if flow.curr_state then - flow.curr_state:on_exit() -- just cleanup in case titlemenu on_enter had some side effects, since we didn't stub it + -- no need to on_exit current state, we know our mock states do nothing flow.curr_state = nil end flow._change_state:clear() @@ -209,14 +228,14 @@ describe('flow', function () end) it('should directly enter a gamestate by type', function () - flow:change_gamestate_by_type(titlemenu.state.type) + flow:change_gamestate_by_type(mock_gamestate1.type) -- implementation assert.spy(flow._change_state).was_called(1) - assert.spy(flow._change_state).was_called_with(match.ref(flow), match.ref(titlemenu.state)) + assert.spy(flow._change_state).was_called_with(match.ref(flow), match.ref(mock_gamestate1)) -- interface - assert.are_equal(flow.gamestates[titlemenu.state.type], flow.curr_state) - assert.are_equal(titlemenu.state.type, flow.curr_state.type) + assert.are_equal(flow.gamestates[mock_gamestate1.type], flow.curr_state) + assert.are_equal(mock_gamestate1.type, flow.curr_state.type) end) end) @@ -224,89 +243,89 @@ describe('flow', function () end) describe('_change_state 1st time', function () - local titlemenu_on_enter_stub + local mock_gamestate1_on_enter_stub setup(function () - titlemenu_on_enter_stub = stub(titlemenu.state, "on_enter") + mock_gamestate1_on_enter_stub = stub(mock_gamestate1, "on_enter") end) teardown(function () - titlemenu_on_enter_stub:revert() + mock_gamestate1_on_enter_stub:revert() end) before_each(function () - flow:_change_state(titlemenu.state) + flow:_change_state(mock_gamestate1) end) after_each(function () flow.curr_state = nil - titlemenu_on_enter_stub:clear() + mock_gamestate1_on_enter_stub:clear() end) it('should directly enter a gamestate', function () - assert.are_equal(flow.gamestates[titlemenu.state.type], flow.curr_state) + assert.are_equal(flow.gamestates[mock_gamestate1.type], flow.curr_state) end) it('should call the gamestate:on_enter', function () - assert.spy(titlemenu_on_enter_stub).was_called(1) - assert.spy(titlemenu_on_enter_stub).was_called_with(match.ref(titlemenu.state)) + assert.spy(mock_gamestate1_on_enter_stub).was_called(1) + assert.spy(mock_gamestate1_on_enter_stub).was_called_with(match.ref(mock_gamestate1)) end) - describe('(credits gamestate added)', function () + describe('(mock gamestate 2 added)', function () setup(function () - flow:add_gamestate(credits.state) + flow:add_gamestate(mock_gamestate2) end) teardown(function () - flow.gamestates[credits.state.type] = nil + flow.gamestates[mock_gamestate2.type] = nil end) describe('_change_state 2nd time', function () - local titlemenu_on_exit_stub - local credits_on_enter_stub + local mock_gamestate1_on_exit_stub + local mock_gamestate2_on_enter_stub setup(function () - titlemenu_on_exit_stub = stub(titlemenu.state, "on_exit") - credits_on_enter_stub = stub(credits.state, "on_enter") + mock_gamestate1_on_exit_stub = stub(mock_gamestate1, "on_exit") + mock_gamestate2_on_enter_stub = stub(mock_gamestate2, "on_enter") end) teardown(function () - titlemenu_on_exit_stub:revert() - credits_on_enter_stub:revert() + mock_gamestate1_on_exit_stub:revert() + mock_gamestate2_on_enter_stub:revert() end) before_each(function () - flow:_change_state(credits.state) + flow:_change_state(mock_gamestate2) end) after_each(function () - flow.curr_state = titlemenu.state - titlemenu_on_exit_stub:clear() - credits_on_enter_stub:clear() + flow.curr_state = mock_gamestate1 + mock_gamestate1_on_exit_stub:clear() + mock_gamestate2_on_enter_stub:clear() end) it('should directly enter another gamestate', function () - assert.are_equal(flow.gamestates[credits.state.type], flow.curr_state) + assert.are_equal(flow.gamestates[mock_gamestate2.type], flow.curr_state) end) it('should call the old gamestate:on_exit', function () - assert.spy(titlemenu_on_exit_stub).was_called(1) - assert.spy(titlemenu_on_exit_stub).was_called_with(match.ref(titlemenu.state)) + assert.spy(mock_gamestate1_on_exit_stub).was_called(1) + assert.spy(mock_gamestate1_on_exit_stub).was_called_with(match.ref(mock_gamestate1)) end) it('should call the new gamestate:on_enter', function () - assert.spy(credits_on_enter_stub).was_called(1) - assert.spy(credits_on_enter_stub).was_called_with(match.ref(credits.state)) + assert.spy(mock_gamestate2_on_enter_stub).was_called(1) + assert.spy(mock_gamestate2_on_enter_stub).was_called_with(match.ref(mock_gamestate2)) end) end) - end) -- (credits gamestate added) + end) -- (mock_gamestate2 gamestate added) end) -- changed gamestate 1st time - end) -- (titlemenu gamestate added) + end) -- (mock_gamestate1 gamestate added) describe('render', function () @@ -316,30 +335,30 @@ describe('flow', function () describe('(when current gamestate is set)', function () - local titlemenu_render_stub + local mock_gamestate1_render_stub setup(function () - titlemenu_render_stub = stub(titlemenu.state, "render") + mock_gamestate1_render_stub = stub(mock_gamestate1, "render") end) teardown(function () - titlemenu_render_stub:revert() + mock_gamestate1_render_stub:revert() end) before_each(function () - flow:_change_state(titlemenu.state) + flow:_change_state(mock_gamestate1) end) after_each(function () flow.curr_state:on_exit() flow.curr_state = nil - titlemenu_render_stub:clear() + mock_gamestate1_render_stub:clear() end) it('should not delegate render to current gamestate', function () flow:render() - assert.spy(titlemenu_render_stub).was_called(1) - assert.spy(titlemenu_render_stub).was_called_with(match.ref(titlemenu.state)) + assert.spy(mock_gamestate1_render_stub).was_called(1) + assert.spy(mock_gamestate1_render_stub).was_called_with(match.ref(mock_gamestate1)) end) end) diff --git a/src/engine/tests/utestgameapp.lua b/src/engine/tests/utestgameapp.lua new file mode 100644 index 00000000..910a559b --- /dev/null +++ b/src/engine/tests/utestgameapp.lua @@ -0,0 +1,270 @@ +require("engine/test/bustedhelper") +local gameapp = require("engine/application/gameapp") + +local flow = require("engine/application/flow") +local input = require("engine/input/input") +local ui = require("engine/ui/ui") + +describe('gameapp', function () + + describe('init', function () + + it('should set empty managers sequence and nil initial gamestate', function () + local app = gameapp() + assert.are_same({{}, nil}, {app.managers, app.initial_gamestate}) + end) + + end) + + describe('(with default app)', function () + + local app + + local mock_manager1 = { + start = spy.new(function () end), + update = spy.new(function () end), + render = spy.new(function () end) + } + local mock_manager2 = { + start = spy.new(function () end), + update = spy.new(function () end), + render = spy.new(function () end) + } + + before_each(function () + app = gameapp() + end) + + describe('register_managers', function () + + it('should register each manager passed in variadic arg', function () + app:register_managers(mock_manager1, mock_manager2) + assert.are_same({mock_manager1, mock_manager2}, app.managers) + end) + + end) + + describe('(with mock_manager1 and mock_manager2 registered)', function () + + before_each(function () + app:register_managers(mock_manager1, mock_manager2) + end) + + describe('start', function () + + setup(function () + spy.on(gameapp, "register_gamestates") + spy.on(gameapp, "on_start") + stub(flow, "query_gamestate_type") + end) + + teardown(function () + gameapp.register_gamestates:revert() + gameapp.on_start:revert() + flow.query_gamestate_type:revert() + end) + + after_each(function () + gameapp.register_gamestates:clear() + gameapp.on_start:clear() + flow.query_gamestate_type:clear() + + mock_manager1.start:clear() + mock_manager2.start:clear() + end) + + it('should assert if initial_gamestate is not set', function () + assert.has_error(function () + app:start() + end, "gameapp:start: gameapp.initial_gamestate is not set") + end) + + describe('(initial gamestate set to "dummy")', function () + + before_each(function () + app.initial_gamestate = "dummy" + end) + + it('should call register_gamestates', function () + app:start() + + assert.spy(gameapp.register_gamestates).was_called(1) + assert.spy(gameapp.register_gamestates).was_called_with(match.ref(app)) + end) + + it('should call flow:query_gamestate_type with self.initial_gamestate', function () + app.initial_gamestate = "dummy_state" + + app:start() + + assert.spy(flow.query_gamestate_type).was_called(1) + assert.spy(flow.query_gamestate_type).was_called_with(match.ref(flow), "dummy_state") + end) + + it('should call start on each manager', function () + app:start() + + assert.spy(mock_manager1.start).was_called(1) + assert.spy(mock_manager1.start).was_called_with(match.ref(mock_manager1)) + assert.spy(mock_manager2.start).was_called(1) + assert.spy(mock_manager2.start).was_called_with(match.ref(mock_manager2)) + end) + + it('should call start on_start', function () + app:start() + + assert.spy(gameapp.on_start).was_called(1) + assert.spy(gameapp.on_start).was_called_with(match.ref(app)) + end) + + end) -- (initial gamestate set to "dummy") + + end) + + describe('reset', function () + + setup(function () + stub(flow, "init") + spy.on(gameapp, "on_reset") + end) + + teardown(function () + flow.init:revert() + gameapp.on_reset:revert() + end) + + after_each(function () + flow.init:clear() + gameapp.on_reset:clear() + end) + + it('should call flow:init', function () + app:reset() + + assert.spy(flow.init).was_called(1) + assert.spy(flow.init).was_called_with(match.ref(flow)) + end) + + it('should call on_reset', function () + app:reset() + + assert.spy(gameapp.on_reset).was_called(1) + assert.spy(gameapp.on_reset).was_called_with(match.ref(app)) + end) + + end) + + describe('update', function () + + setup(function () + stub(input, "process_players_inputs") + stub(flow, "update") + spy.on(gameapp, "on_update") + end) + + teardown(function () + input.process_players_inputs:revert() + flow.update:revert() + gameapp.on_update:revert() + end) + + after_each(function () + input.process_players_inputs:clear() + flow.update:clear() + gameapp.on_update:clear() + + mock_manager1.update:clear() + mock_manager2.update:clear() + end) + + it('should call input:process_players_inputs', function () + app:update() + + local s = assert.spy(input.process_players_inputs) + s.was_called(1) + s.was_called_with(match.ref(input)) + end) + + -- bugfix history: + -- + forget self. in front of managers + it('should update all registered managers', function () + app:update() + + local s1 = assert.spy(mock_manager1.update) + s1.was_called(1) + s1.was_called_with(match.ref(mock_manager1)) + local s2 = assert.spy(mock_manager2.update) + s2.was_called(1) + s2.was_called_with(match.ref(mock_manager2)) + end) + + it('should update the flow', function () + app:update() + + local s2 = assert.spy(flow.update) + s2.was_called(1) + s2.was_called_with(match.ref(flow)) + end) + + it('should call on_update', function () + app:update() + + local s2 = assert.spy(app.on_update) + s2.was_called(1) + s2.was_called_with(match.ref(app)) + end) + + end) + + describe('draw', function () + + setup(function () + stub(_G, "cls") + stub(flow, "render") + end) + + teardown(function () + cls:revert() + flow.render:revert() + end) + + after_each(function () + cls:clear() + flow.render:clear() + + mock_manager1.render:clear() + mock_manager2.render:clear() + end) + + it('should clear screen', function () + app:draw() + assert.spy(cls).was_called(1) + end) + + it('should call flow:render', function () + app:draw() + local s = assert.spy(flow.render) + s.was_called(1) + s.was_called_with(match.ref(flow)) + end) + + -- bugfix history: + -- + forget self. in front of managers + it('should render all registered managers', function () + app:draw() + + local s1 = assert.spy(mock_manager1.render) + s1.was_called(1) + s1.was_called_with(match.ref(mock_manager1)) + local s2 = assert.spy(mock_manager2.render) + s2.was_called(1) + s2.was_called_with(match.ref(mock_manager2)) + end) + + end) + + end) -- (with mock_manager1 and mock_manager2 registered) + + end) -- (with default app) + +end) diff --git a/tests/utesthelper.lua b/src/engine/tests/utesthelper.lua similarity index 71% rename from tests/utesthelper.lua rename to src/engine/tests/utesthelper.lua index ffab060b..00d141d6 100644 --- a/tests/utesthelper.lua +++ b/src/engine/tests/utesthelper.lua @@ -1,7 +1,30 @@ -require("bustedhelper") +require("engine/test/bustedhelper") helper = require("engine/core/helper") math = require("engine/core/math") -- just to test stringify and are_same +describe('enum', function () + it('should return a table containing enum variants with the names passed as a sequence, values starting from 1', function () + assert.are_same({ + left = 1, + right = 2, + up = 3, + down = 4 + }, enum {"left", "right", "up", "down"}) + end) +end) + +describe('get_members', function () + it('should return module members from their names as multiple values', function () + local module = { + a = 1, + b = 2, + [3] = function () end + } + assert.are_same({module.a, module.b, module[3]}, + {get_members(module, "a", "b", 3)}) + end) +end) + describe('is_empty', function () it('return true if the table is empty', function () assert.is_true(is_empty({})) @@ -18,18 +41,64 @@ describe('is_empty', function () end) describe('are_same', function () - local single_t = {} - local comparable_mt = { + local comparable_mt_sum = { __eq = function (lhs, rhs) - return lhs.a == rhs.a and lhs.b == rhs.b + -- a flexible check that allows different member values to have the table considered equal in the end + return lhs.a + lhs.b == rhs.a + rhs.b end } + local comparable_mt_offset = { + __eq = function (lhs, rhs) + -- a contrived check that makes sure __eq is used by returning true when it should be false in raw content + return lhs.a == rhs.a - 1 + end + } + local comparable_struct1 = {a = 1, b = 2} local comparable_struct2 = {a = 1, b = 2} - setmetatable(comparable_struct1, comparable_mt) - setmetatable(comparable_struct2, comparable_mt) -- actually, only one is enough + local comparable_struct3 = {a = 2, b = 1} + local comparable_struct4 = {a = 1} + local comparable_struct5 = {a = 1} + local comparable_struct6 = {a = 2} + + setmetatable(comparable_struct1, comparable_mt_sum) + setmetatable(comparable_struct2, comparable_mt_sum) + setmetatable(comparable_struct3, comparable_mt_sum) + setmetatable(comparable_struct4, comparable_mt_offset) + setmetatable(comparable_struct5, comparable_mt_offset) + setmetatable(comparable_struct6, comparable_mt_offset) + + -- bugfix history: + -- _ the non-table and comparable_struct tests below have been added, as I was exceptionally covering + -- the utest files themselves and saw that the metatables were not used at all; so I fixed are_same itself + -- to check __eq on the metatable instead of the table + + it('return true if both elements are not table, but equal', function () + assert.is_true(are_same(2, 2)) + end) + it('return false if both elements are not table, and not equal', function () + assert.is_false(are_same(2, 3)) + end) + + it('return true if both tables define __eq that returns true, and not comparing raw content', function () + assert.is_true(are_same(comparable_struct1, comparable_struct2)) + assert.is_true(are_same(comparable_struct1, comparable_struct3)) + assert.is_true(are_same(comparable_struct4, comparable_struct6)) + end) + it('return true if both tables define __eq that returns false, and not comparing raw content', function () + assert.is_false(are_same(comparable_struct4, comparable_struct5)) + end) + + it('return false if both tables define __eq that returns true, but comparing different raw content', function () + assert.is_false(are_same(comparable_struct1, comparable_struct3, true)) + assert.is_false(are_same(comparable_struct4, comparable_struct6, true)) + end) + + it('return true if both tables define __eq that returns false, but comparing same raw content', function () + assert.is_true(are_same(comparable_struct4, comparable_struct5, true)) + end) it('return true both tables are empty', function () assert.is_true(are_same({}, {})) @@ -150,6 +219,70 @@ describe('unpack', function () end) end) +describe('invert_table', function () + it('should return a table with reversed keys and values', function () + assert.are_same({[41] = "a", foo = 1}, invert_table({a = 41, [1] = "foo"})) + end) +end) + +describe('string_tonum', function () + it('"100" => 100', function () + assert.are_equal(100, string_tonum("100")) + end) + -- unlike tonum, this one works for both pico8 and native Lua + it('"-25.25" => -25.25', function () + assert.are_equal(-25.25, string_tonum("-25.25")) + end) + it('"304.25" => 304.25', function () + assert.are_equal(304.25, string_tonum(304.25)) + end) + it('"-25.25" => -25.25', function () + assert.are_equal(-25.25, string_tonum(-25.25)) + end) + it('"0x0000.2fa4" => 0x0000.2fa4', function () + assert.are_equal(0x0000.2fa4, string_tonum("0x0000.2fa4")) + end) + it('"-0x0000.2fa4" => -0x0000.2fa4', function () + assert.are_equal(-0x0000.2fa4, string_tonum("-0x0000.2fa4")) + end) + it('"-abc" => error (minus sign instead of hyphen-minus)', function () + assert.has_error(function () + string_tonum("-abc") + end, + "could not parse absolute part of number: '-abc'") + end) + it('"−5" => error (minus sign instead of hyphen-minus)', function () + assert.has_error(function () + string_tonum("−5") + end, + "could not parse number: '−5'") + end) + it('"abc" => error (minus sign instead of hyphen-minus)', function () + assert.has_error(function () + string_tonum("abc") + end, + "could not parse number: 'abc'") + end) + it('nil => error', function () + assert.has_error(function () + string_tonum(nil) + end, + "bad argument #1 to 'sub' (string expected, got nil)") + end) + it('true => error', function () + assert.has_error(function () + string_tonum(true) + end, + "bad argument #1 to 'sub' (string expected, got boolean)") + end) + it('{} => error', function () + assert.has_error(function () + string_tonum({}) + end, + "bad argument #1 to 'sub' (string expected, got table)") + end) +end) + describe('stringify', function () it('nil => "[nil]"', function () assert.are_equal("[nil]", stringify(nil)) @@ -180,7 +313,6 @@ describe('stringify', function () end assert.are_equal("[function]", stringify(f)) end) - end) describe('joinstr_table', function () diff --git a/tests/utestinput.lua b/src/engine/tests/utestinput.lua similarity index 99% rename from tests/utestinput.lua rename to src/engine/tests/utestinput.lua index 32f95554..cb72b024 100644 --- a/tests/utestinput.lua +++ b/src/engine/tests/utestinput.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local input = require("engine/input/input") describe('input', function () diff --git a/tests/utestintegrationtest.lua b/src/engine/tests/utestintegrationtest.lua similarity index 53% rename from tests/utestintegrationtest.lua rename to src/engine/tests/utestintegrationtest.lua index 7544afc9..2a7cf4f7 100644 --- a/tests/utestintegrationtest.lua +++ b/src/engine/tests/utestintegrationtest.lua @@ -1,10 +1,10 @@ -require("bustedhelper") +require("engine/test/bustedhelper") require("engine/core/helper") require("engine/render/color") +local gameapp = require("engine/application/gameapp") local integrationtest = require("engine/test/integrationtest") local itest_manager, integration_test, time_trigger = integrationtest.itest_manager, integrationtest.integration_test, integrationtest.time_trigger local logging = require("engine/debug/logging") -local gameapp = require("game/application/gameapp") local input = require("engine/input/input") local function repeat_callback(time, callback) @@ -24,6 +24,14 @@ describe('itest_manager', function () itest_manager:init() end) + describe('init', function () + + it('should create a singleton instance with empty itests', function () + assert.are_same({}, itest_manager.itests) + end) + + end) + describe('register_itest', function () it('should register a new test', function () @@ -91,7 +99,7 @@ describe('itest_manager', function () describe('init_game_and_start_by_index', function () setup(function () - itest_runner_own_method = stub(integration_test_runner, "init_game_and_start") + itest_runner_own_method = stub(itest_runner, "init_game_and_start") end) teardown(function () @@ -107,7 +115,7 @@ describe('itest_manager', function () itest_manager:register(itest) itest_manager:init_game_and_start_by_index(1) assert.spy(itest_runner_own_method).was_called(1) - assert.spy(itest_runner_own_method).was_called_with(match.ref(integration_test_runner), itest) + assert.spy(itest_runner_own_method).was_called_with(match.ref(itest_runner), itest) end) it('should assert if the index is invalid', function () @@ -124,7 +132,10 @@ describe('itest_manager', function () end) -describe('integration_test_runner', function () +describe('itest_runner', function () + + -- prepare mock app with default implementation + local mock_app = gameapp() local test @@ -134,202 +145,278 @@ describe('integration_test_runner', function () after_each(function () -- full reset - integration_test_runner:init() + itest_runner:init() input.mode = input_modes.native logging.logger:init() end) + describe('init', function () + + it('should initialize parameters', function () + assert.are_same({ + false, + nil, + 0, + 0, + 1, + test_states.none, + nil, + nil + }, + { + itest_runner.initialized, + itest_runner.current_test, + itest_runner.current_frame, + itest_runner._last_trigger_frame, + itest_runner._next_action_index, + itest_runner.current_state, + itest_runner.current_message, + itest_runner.gameapp + }) + end) + + end) + describe('init_game_and_start', function () setup(function () - gameapp_init_stub = stub(gameapp, "init") - gameapp_reinit_modules_stub = stub(gameapp, "reinit_modules") - itest_runner_start_stub = stub(integration_test_runner, "start") + stub(gameapp, "reset") + stub(gameapp, "start") + stub(itest_runner, "stop") + stub(itest_runner, "start") end) teardown(function () - gameapp_init_stub:revert() - gameapp_reinit_modules_stub:revert() - itest_runner_start_stub:revert() + gameapp.reset:revert() + gameapp.start:revert() + itest_runner.stop:revert() + itest_runner.start:revert() end) after_each(function () - gameapp_init_stub:clear() - gameapp_reinit_modules_stub:clear() - itest_runner_start_stub:clear() + gameapp.reset:clear() + gameapp.start:clear() + itest_runner.stop:clear() + itest_runner.start:clear() end) - it('should init the gameapp and the passed test', function () - integration_test_runner:init_game_and_start(test) - assert.spy(gameapp_init_stub).was_called(1) - assert.spy(gameapp_init_stub).was_called_with({'stage'}) - assert.spy(itest_runner_start_stub).was_called(1) - assert.spy(itest_runner_start_stub).was_called_with(match.ref(integration_test_runner), test) - end) + it('should error if app is not set', function () - describe('(when another test was running)', function () + assert.has_error(function () + itest_runner:init_game_and_start(test) + end, "itest_runner:init_game_and_start: self.app is not set") + end) - setup(function () - integration_test_runner.current_test = integration_test('previous test', {}) - end) + describe('(with mock app)', function () - it('should reinit the gameapp modules first', function () - integration_test_runner:init_game_and_start(test) - assert.spy(gameapp_reinit_modules_stub).was_called(1) - assert.spy(gameapp_reinit_modules_stub).was_called_with() + before_each(function () + itest_runner.app = mock_app end) - end) + describe('(when current_test is already set)', function () - end) - - describe('update_game_and_test', function () + before_each(function () + itest_runner.current_test = test + end) - setup(function () - gameapp_update_stub = stub(gameapp, "update") - spy.on(integration_test_runner, "update") - end) + it('should reset the app', function () + itest_runner:init_game_and_start(test) - teardown(function () - gameapp_update_stub:revert() - integration_test_runner.update:revert() - end) + local s = assert.spy(gameapp.reset) + s.was_called(1) + s.was_called_with(match.ref(mock_app)) + end) - after_each(function () - gameapp_update_stub:clear() - integration_test_runner.update:clear() - end) + it('should stop', function () + itest_runner:init_game_and_start(test) - describe('(when state is not running)', function () + local s = assert.spy(itest_runner.stop) + s.was_called(1) + s.was_called_with(match.ref(itest_runner)) + end) - it('should do nothing', function () - integration_test_runner:update_game_and_test() - assert.spy(gameapp_update_stub).was_not_called() - assert.spy(integration_test_runner.update).was_not_called() end) - end) - - describe('(when state is running for some actions)', function () + it('should start the gameapp', function () + itest_runner:init_game_and_start(test) - before_each(function () - test:add_action(time_trigger(1.0), function () end, 'some_action') - integration_test_runner:start(test) + local s = assert.spy(gameapp.start) + s.was_called(1) + s.was_called_with(match.ref(mock_app)) end) - it('should update the gameapp and the passed test', function () - integration_test_runner:update_game_and_test() - assert.spy(gameapp_update_stub).was_called(1) - assert.spy(gameapp_update_stub).was_called_with() - assert.spy(integration_test_runner.update).was_called(1) - assert.spy(integration_test_runner.update).was_called_with(match.ref(integration_test_runner)) + it('should init a set gameapp and the passed test', function () + itest_runner:init_game_and_start(test) + + local s = assert.spy(itest_runner.start) + s.was_called(1) + s.was_called_with(match.ref(itest_runner), test) end) end) - describe('(when test ends on this update with success)', function () + end) - local log_stub + describe('(with mock app)', function () - before_each(function () - test:add_action(time_trigger(0.017), function () end, 'some_action') - integration_test_runner:start(test) - end) + before_each(function () + itest_runner.app = mock_app + end) + + describe('update_game_and_test', function () setup(function () - log_stub = stub(_G, "log") + stub(gameapp, "update") + spy.on(itest_runner, "update") end) teardown(function () - log_stub:revert() + gameapp.update:revert() + itest_runner.update:revert() end) after_each(function () - log_stub:clear() + gameapp.update:clear() + itest_runner.update:clear() end) - it('should only log the result', function () - integration_test_runner:update_game_and_test() - assert.spy(log_stub).was_called() -- we only want 1 call, but we check "at least once" because there are other unrelated logs - assert.spy(log_stub).was_called_with("itest 'character walks' ended with success", "itest") + describe('(when state is not running)', function () + + it('should do nothing', function () + itest_runner:update_game_and_test() + assert.spy(gameapp.update).was_not_called() + assert.spy(itest_runner.update).was_not_called() + end) + end) - end) + describe('(when state is running for some actions)', function () - describe('(when test ends on this update with failure)', function () + before_each(function () + test:add_action(time_trigger(1.0), function () end, 'some_action') + end) - local log_stub + it('should update the set gameapp and the passed test', function () + itest_runner:start(test) - before_each(function () - test:add_action(time_trigger(0.017), function () end, 'some_action') - test.final_assertion = function () - return false, "character walks failed" - end - integration_test_runner:start(test) - end) + itest_runner:update_game_and_test() - setup(function () - log_stub = stub(_G, "log") - end) + local s_app = assert.spy(gameapp.update) + s_app.was_called(1) + s_app.was_called_with(match.ref(mock_app)) + local s_runner = assert.spy(itest_runner.update) + s_runner.was_called(1) + s_runner.was_called_with(match.ref(itest_runner)) + end) - teardown(function () - log_stub:revert() end) - after_each(function () - log_stub:clear() + describe('(when running, and test ends on this update with success)', function () + + before_each(function () + test:add_action(time_trigger(0.017), function () end, 'some_action') + itest_runner:start(test) + end) + + setup(function () + stub(_G, "log") + end) + + teardown(function () + log:revert() + end) + + after_each(function () + log:clear() + end) + + it('should only log the result', function () + itest_runner:update_game_and_test() + local s = assert.spy(log) + s.was_called() -- we only want 1 call, but we check "at least once" because there are other unrelated logs + s.was_called_with("itest 'character walks' ended with success", "itest") + end) + end) - it('should log the result and failure message', function () - integration_test_runner:update_game_and_test() - assert.spy(log_stub).was_called() -- we only want 2 calls, but we check "at least twice" because there are other unrelated logs - assert.spy(log_stub).was_called_with("itest 'character walks' ended with failure", "itest") - assert.spy(log_stub).was_called_with("failed: character walks failed", "itest") + describe('(when running, and test ends on this update with failure)', function () + + before_each(function () + test:add_action(time_trigger(0.017), function () end, 'some_action') + test.final_assertion = function () + return false, "character walks failed" + end + itest_runner:start(test) + end) + + setup(function () + stub(_G, "log") + end) + + teardown(function () + log:revert() + end) + + after_each(function () + log:clear() + end) + + it('should log the result and failure message', function () + itest_runner:update_game_and_test() + local s = assert.spy(log) + s.was_called() -- we only want 2 calls, but we check "at least twice" because there are other unrelated logs + s.was_called_with("itest 'character walks' ended with failure", "itest") + s.was_called_with("failed: character walks failed", "itest") + end) + end) end) - end) + describe('draw_game_and_test', function () - describe('draw_game_and_test', function () + setup(function () + stub(gameapp, "draw") + stub(itest_runner, "draw") + end) - setup(function () - gameapp_draw_stub = stub(gameapp, "draw") - itest_runner_draw_stub = stub(integration_test_runner, "draw") - end) + teardown(function () + gameapp.draw:revert() + itest_runner.draw:revert() + end) - teardown(function () - gameapp_draw_stub:revert() - itest_runner_draw_stub:revert() - end) + after_each(function () + gameapp.draw:clear() + itest_runner.draw:clear() + end) - after_each(function () - gameapp_draw_stub:clear() - itest_runner_draw_stub:clear() - end) + it('should draw the gameapp and the passed test information', function () + itest_runner:draw_game_and_test() + + local s_app = assert.spy(gameapp.draw) + s_app.was_called(1) + s_app.was_called_with(match.ref(mock_app)) + local s_runner = assert.spy(itest_runner.draw) + s_runner.was_called(1) + s_runner.was_called_with(match.ref(itest_runner)) + end) - it('should draw the gameapp and the passed test information', function () - integration_test_runner:draw_game_and_test() - assert.spy(gameapp_draw_stub).was_called(1) - assert.spy(gameapp_draw_stub).was_called_with() - assert.spy(itest_runner_draw_stub).was_called(1) - assert.spy(itest_runner_draw_stub).was_called_with(match.ref(integration_test_runner)) end) - end) + end) -- (with mock app) describe('start', function () setup(function () - spy.on(integration_test_runner, "_initialize") - spy.on(integration_test_runner, "_check_end") - spy.on(integration_test_runner, "_check_next_action") + spy.on(itest_runner, "_initialize") + spy.on(itest_runner, "_check_end") + spy.on(itest_runner, "_check_next_action") end) teardown(function () - integration_test_runner._initialize:revert() - integration_test_runner._check_end:revert() - integration_test_runner._check_next_action:revert() + itest_runner._initialize:revert() + itest_runner._check_end:revert() + itest_runner._check_next_action:revert() end) before_each(function () @@ -337,53 +424,53 @@ describe('integration_test_runner', function () end) after_each(function () - integration_test_runner._initialize:clear() - integration_test_runner._check_end:clear() - integration_test_runner._check_next_action:clear() + itest_runner._initialize:clear() + itest_runner._check_end:clear() + itest_runner._check_next_action:clear() end) it('should set the current test to the passed test', function () - integration_test_runner:start(test) - assert.are_equal(test, integration_test_runner.current_test) + itest_runner:start(test) + assert.are_equal(test, itest_runner.current_test) end) it('should initialize state vars', function () - integration_test_runner:start(test) + itest_runner:start(test) assert.are_same({0, 0, 1}, { - integration_test_runner.current_frame, - integration_test_runner._last_trigger_frame, - integration_test_runner._next_action_index + itest_runner.current_frame, + itest_runner._last_trigger_frame, + itest_runner._next_action_index }) end) it('should call the test setup callback', function () - integration_test_runner:start(test) + itest_runner:start(test) assert.spy(test.setup).was_called(1) assert.spy(test.setup).was_called_with() end) it('should call _initialize the first time', function () - integration_test_runner:start(test) - assert.spy(integration_test_runner._initialize).was_called(1) - assert.spy(integration_test_runner._initialize).was_called_with(match.ref(integration_test_runner)) + itest_runner:start(test) + assert.spy(itest_runner._initialize).was_called(1) + assert.spy(itest_runner._initialize).was_called_with(match.ref(itest_runner)) end) it('should call _check_end', function () - integration_test_runner:start(test) - assert.spy(integration_test_runner._check_end).was_called(1) - assert.spy(integration_test_runner._check_end).was_called_with(match.ref(integration_test_runner)) + itest_runner:start(test) + assert.spy(itest_runner._check_end).was_called(1) + assert.spy(itest_runner._check_end).was_called_with(match.ref(itest_runner)) end) describe('(when no actions)', function () it('should not check the next action', function () - integration_test_runner:start(test) - assert.spy(integration_test_runner._check_next_action).was_not_called() + itest_runner:start(test) + assert.spy(itest_runner._check_next_action).was_not_called() end) it('should immediately end the run (result depends on final assertion)', function () - integration_test_runner:start(test) - assert.are_not_equal(test_states.running, integration_test_runner.current_state) + itest_runner:start(test) + assert.are_not_equal(test_states.running, itest_runner.current_state) end) end) @@ -395,14 +482,14 @@ describe('integration_test_runner', function () end) it('should check the next action immediately (if at time 0, will also call it)', function () - integration_test_runner:start(test) - assert.spy(integration_test_runner._check_next_action).was_called(1) - assert.spy(integration_test_runner._check_next_action).was_called_with(match.ref(integration_test_runner)) + itest_runner:start(test) + assert.spy(itest_runner._check_next_action).was_called(1) + assert.spy(itest_runner._check_next_action).was_called_with(match.ref(itest_runner)) end) it('should enter running state', function () - integration_test_runner:start(test) - assert.are_equal(test_states.running, integration_test_runner.current_state) + itest_runner:start(test) + assert.are_equal(test_states.running, itest_runner.current_state) end) end) @@ -412,28 +499,18 @@ describe('integration_test_runner', function () before_each(function () test:add_action(time_trigger(1.0), function () end, 'restart_action') -- some progress - integration_test_runner:start(test) + itest_runner:start(test) repeat_callback(1.0, function () - integration_test_runner:update() + itest_runner:update() end) end) - it('should automatically stop before restarting, effectively resetting state vars but the current test and state', function () - integration_test_runner:start(test) - assert.are_same({0, 0, 1, test_states.running}, { - integration_test_runner.current_frame, - integration_test_runner._last_trigger_frame, - integration_test_runner._next_action_index, - integration_test_runner.current_state - }) - end) - it('should not call _initialize the second time', function () -- in this specific case, start was called in before_each so we need to clear manually -- just before we call start ourselves to have the correct count - integration_test_runner._initialize:clear() - integration_test_runner:start(test) - assert.spy(integration_test_runner._initialize).was_not_called() + itest_runner._initialize:clear() + itest_runner:start(test) + assert.spy(itest_runner._initialize).was_not_called() end) end) @@ -444,9 +521,9 @@ describe('integration_test_runner', function () it('should assert when no test has been started', function () assert.has_error(function() - integration_test_runner:update() + itest_runner:update() end, - "integration_test_runner:update: current_test is not set") + "itest_runner:update: current_test is not set") end) describe('(after test started)', function () @@ -463,7 +540,7 @@ describe('integration_test_runner', function () end) before_each(function () - integration_test_runner:start(test) + itest_runner:start(test) end) after_each(function () @@ -471,41 +548,41 @@ describe('integration_test_runner', function () end) it('should advance the current time by 1', function () - integration_test_runner:update() - assert.are_equal(1, integration_test_runner.current_frame) + itest_runner:update() + assert.are_equal(1, itest_runner.current_frame) end) it('should call an initial action (t=0.) immediately, preserving last trigger time to 0 and incrementing the _next_action_index', function () - integration_test_runner:update() + itest_runner:update() assert.spy(action_callback).was_not_called() - assert.are_equal(0., integration_test_runner._last_trigger_frame) - assert.are_equal(1, integration_test_runner._next_action_index) + assert.are_equal(0., itest_runner._last_trigger_frame) + assert.are_equal(1, itest_runner._next_action_index) end) it('should not call a later action (t=1.02) before the expected time (1.0s)', function () repeat_callback(1.0, function () - integration_test_runner:update() + itest_runner:update() end) assert.spy(action_callback).was_not_called() - assert.are_equal(0., integration_test_runner._last_trigger_frame) - assert.are_equal(1, integration_test_runner._next_action_index) + assert.are_equal(0., itest_runner._last_trigger_frame) + assert.are_equal(1, itest_runner._next_action_index) end) it('should call a later action (t=1.02) after the action time has been reached', function () repeat_callback(1.02, function () - integration_test_runner:update() + itest_runner:update() end) assert.spy(action_callback).was_called(1) - assert.are_equal(61, integration_test_runner._last_trigger_frame) - assert.are_equal(2, integration_test_runner._next_action_index) + assert.are_equal(61, itest_runner._last_trigger_frame) + assert.are_equal(2, itest_runner._next_action_index) end) it('should end the test once the last action has been applied', function () repeat_callback(1.02, function () - integration_test_runner:update() + itest_runner:update() end) - assert.are_equal(test_states.success, integration_test_runner.current_state) - assert.are_equal(2, integration_test_runner._next_action_index) + assert.are_equal(test_states.success, itest_runner.current_state) + assert.are_equal(2, itest_runner._next_action_index) end) describe('(with timeout set to 2s and more actions after that, usually unmet conditions)', function () @@ -518,12 +595,12 @@ describe('integration_test_runner', function () describe('(when next frame is below 120)', function () before_each(function () - integration_test_runner.current_frame = 118 + itest_runner.current_frame = 118 end) it('should call next action (no time out)', function () - integration_test_runner:update() - assert.are_equal(test_states.running, integration_test_runner.current_state) + itest_runner:update() + assert.are_equal(test_states.running, itest_runner.current_state) assert.spy(action_callback).was_called(1) end) @@ -532,12 +609,12 @@ describe('integration_test_runner', function () describe('(when next frame is 120 or above)', function () before_each(function () - integration_test_runner.current_frame = 119 + itest_runner.current_frame = 119 end) it('should time out without calling next action', function () - integration_test_runner:update() - assert.are_equal(test_states.timeout, integration_test_runner.current_state) + itest_runner:update() + assert.are_equal(test_states.timeout, itest_runner.current_state) assert.spy(action_callback).was_not_called() end) @@ -551,13 +628,13 @@ describe('integration_test_runner', function () before_each(function () -- without any action, start should end the test immediately - integration_test_runner:start(test) + itest_runner:start(test) end) it('should do nothing', function () - assert.are_equal(integration_test_runner.current_state, test_states.success) - assert.has_no_errors(function () integration_test_runner:update() end) - assert.are_equal(integration_test_runner.current_state, test_states.success) + assert.are_equal(itest_runner.current_state, test_states.success) + assert.has_no_errors(function () itest_runner:update() end) + assert.are_equal(itest_runner.current_state, test_states.success) end) end) @@ -566,34 +643,39 @@ describe('integration_test_runner', function () describe('draw', function () - it('should assert if no current test is set', function () - assert.has_error(function () - integration_test_runner:draw() - end, - "integration_test_runner:draw: current_test is not set") - end) - - describe('(when curent test is set)', function () - - local api_print_stub + describe('(stubbing api.print)', function () setup(function () - integration_test_runner.current_test = test - integration_test_runner.current_state = test_states.running - api_print_stub = stub(api, "print") + stub(api, "print") end) teardown(function () - api_print_stub:revert() + api.print:revert() end) after_each(function () - api_print_stub:clear() + api.print:clear() end) - it('should draw information on the current test', function () - integration_test_runner:draw() - assert.spy(api_print_stub).was_called(2) + it('should draw "no itest running"', function () + itest_runner:draw() + local s = assert.spy(api.print) + s.was_called(1) + s.was_called_with("no itest running", 8, 8, colors.white) + end) + + describe('(when current test is set)', function () + + before_each(function () + itest_runner.current_test = test + itest_runner.current_state = test_states.running + end) + + it('should draw information on the current test', function () + itest_runner:draw() + assert.spy(api.print).was_called(2) + end) + end) end) @@ -603,23 +685,23 @@ describe('integration_test_runner', function () describe('_get_test_state_color', function () it('should return white for none', function () - assert.are_equal(colors.white, integration_test_runner:_get_test_state_color(test_states.none)) + assert.are_equal(colors.white, itest_runner:_get_test_state_color(test_states.none)) end) it('should return white for none', function () - assert.are_equal(colors.white, integration_test_runner:_get_test_state_color(test_states.running)) + assert.are_equal(colors.white, itest_runner:_get_test_state_color(test_states.running)) end) it('should return green for success', function () - assert.are_equal(colors.green, integration_test_runner:_get_test_state_color(test_states.success)) + assert.are_equal(colors.green, itest_runner:_get_test_state_color(test_states.success)) end) it('should return red for failure', function () - assert.are_equal(colors.red, integration_test_runner:_get_test_state_color(test_states.failure)) + assert.are_equal(colors.red, itest_runner:_get_test_state_color(test_states.failure)) end) it('should return dark purple for timeout', function () - assert.are_equal(colors.dark_purple, integration_test_runner:_get_test_state_color(test_states.timeout)) + assert.are_equal(colors.dark_purple, itest_runner:_get_test_state_color(test_states.timeout)) end) end) @@ -627,12 +709,12 @@ describe('integration_test_runner', function () describe('_initialize', function () it('should set the input mode to simulated', function () - integration_test_runner:_initialize() + itest_runner:_initialize() assert.are_equal(input_modes.simulated, input.mode) end) it('should set all logger categories (except itest, but that\'s only visible in pico8 build)', function () - integration_test_runner:_initialize() + itest_runner:_initialize() -- hack until we implement #82 TEST integration-busted-trace-build-system -- since "trace" is not set in data but in code in _initialize, -- it promises to change often during development so we "hide" such tuning in code @@ -643,68 +725,70 @@ describe('integration_test_runner', function () player = false, ui = false, codetuner = false, - itest = false, -- would be true in pico8 itests + itest = true, -- now true for both pico8 and busted tests trace = false -- forced to false for this test }, logging.logger.active_categories) end) it('should set initialized to true', function () - integration_test_runner:_initialize() - assert.is_true(integration_test_runner.initialized) + itest_runner:_initialize() + assert.is_true(itest_runner.initialized) end) end) - describe('_check_next_action (with single action)', function () + describe('_check_next_action', function () describe('(with dummy action after 1s)', function () local action_callback = spy.new(function () end) + local action_callback2 = spy.new(function () end) - before_each(function () - test:add_action(time_trigger(1.0), action_callback, '_check_next_action_test_action') + setup(function () -- don't stub a function if the return value matters, as in start - spy.on(integration_test_runner, "_check_end") + spy.on(itest_runner, "_check_end") end) teardown(function () action_callback:revert() - integration_test_runner._check_end:revert() + action_callback2:revert() + itest_runner._check_end:revert() end) before_each(function () - integration_test_runner:start(test) + itest_runner:start(test) + test:add_action(time_trigger(1.0), action_callback, 'action_callback') end) after_each(function () action_callback:clear() - integration_test_runner._check_end:clear() + action_callback2:clear() + itest_runner._check_end:clear() end) describe('(when next action index is 1/1)', function () before_each(function () - integration_test_runner._next_action_index = 1 + itest_runner._next_action_index = 1 end) describe('(when next action time trigger is not reached yet)', function () before_each(function () -- time trigger uses relative frames, so compare the difference since last trigger to 60 - integration_test_runner.current_frame = 158 - integration_test_runner._last_trigger_frame = 100 - test:add_action(time_trigger(1.0), action_callback, '_check_next_action_test_action') + itest_runner.current_frame = 158 + itest_runner._last_trigger_frame = 100 end) it('should not call the action nor advance the time/index', function () - integration_test_runner._check_end:clear() -- was called on start in before_each - integration_test_runner:_check_next_action() + itest_runner._check_end:clear() -- was called on start in before_each + itest_runner:_check_next_action() assert.spy(action_callback).was_not_called() - assert.are_equal(100, integration_test_runner._last_trigger_frame) - assert.are_equal(1, integration_test_runner._next_action_index) - assert.spy(integration_test_runner._check_end).was_not_called() + assert.are_equal(100, itest_runner._last_trigger_frame) + assert.are_equal(1, itest_runner._next_action_index) + assert.spy(itest_runner._check_end).was_not_called() end) end) @@ -713,20 +797,19 @@ describe('integration_test_runner', function () before_each(function () -- time trigger uses relative frames, so compare the difference since last trigger to 60 - integration_test_runner.current_frame = 160 - integration_test_runner._last_trigger_frame = 100 - test:add_action(time_trigger(1.0), action_callback, '_check_next_action_test_action') + itest_runner.current_frame = 160 + itest_runner._last_trigger_frame = 100 end) it('should call the action and advance the timeindex', function () - integration_test_runner._check_end:clear() -- was called on start in before_each - integration_test_runner:_check_next_action() + itest_runner._check_end:clear() -- was called on start in before_each + itest_runner:_check_next_action() assert.spy(action_callback).was_called(1) assert.spy(action_callback).was_called_with() - assert.are_equal(160, integration_test_runner._last_trigger_frame) - assert.are_equal(2, integration_test_runner._next_action_index) - assert.spy(integration_test_runner._check_end).was_called(1) - assert.spy(integration_test_runner._check_end).was_called_with(match.ref(integration_test_runner)) + assert.are_equal(160, itest_runner._last_trigger_frame) + assert.are_equal(2, itest_runner._next_action_index) + assert.spy(itest_runner._check_end).was_called(1) + assert.spy(itest_runner._check_end).was_called_with(match.ref(itest_runner)) end) end) @@ -737,18 +820,131 @@ describe('integration_test_runner', function () before_each(function () -- we still have the dummy action from the outer scope - integration_test_runner._next_action_index = 2 -- we are now at 2/1 + itest_runner._next_action_index = 2 -- we are now at 2/1 end) it('should assert', function () assert.has_error(function () - integration_test_runner:_check_next_action() + itest_runner:_check_next_action() end, "self._next_action_index (2) is out of bounds for self.current_test.action_sequence (size 1)") end) end) + describe('(with 2nd dummy action immediately after the other)', function () + + describe('(when next action index is 1/1)', function () + + before_each(function () + itest_runner._next_action_index = 1 + end) + + describe('(when next action time trigger is not reached yet)', function () + + before_each(function () + -- time trigger uses relative frames, so compare the difference since last trigger to 60 + test:add_action(time_trigger(0.0), action_callback2, 'action_callback2') + itest_runner.current_frame = 158 + itest_runner._last_trigger_frame = 100 + end) + + it('should not call any actions nor advance the time/index', function () + itest_runner._check_end:clear() -- was called on start in before_each + itest_runner:_check_next_action() + assert.spy(action_callback).was_not_called() + assert.spy(action_callback2).was_not_called() + assert.are_equal(100, itest_runner._last_trigger_frame) + assert.are_equal(1, itest_runner._next_action_index) + assert.spy(itest_runner._check_end).was_not_called() + end) + + end) + + describe('(when next action time trigger is reached)', function () + + before_each(function () + -- time trigger uses relative frames, so compare the difference since last trigger to 60 + test:add_action(time_trigger(0.0), action_callback2, 'action_callback2') + itest_runner.current_frame = 160 + itest_runner._last_trigger_frame = 100 + end) + + it('should call both actions and advance the timeindex by 2', function () + itest_runner._check_end:clear() -- was called on start in before_each + itest_runner:_check_next_action() + assert.spy(action_callback).was_called(1) + assert.spy(action_callback).was_called_with() + assert.spy(action_callback2).was_called(1) -- thx to action chaining when next action time is 0 + assert.spy(action_callback2).was_called_with() + assert.are_equal(160, itest_runner._last_trigger_frame) + assert.are_equal(3, itest_runner._next_action_index) -- after action 2 + assert.spy(itest_runner._check_end).was_called(2) -- checked after each action + assert.spy(itest_runner._check_end).was_called_with(match.ref(itest_runner)) + end) + + end) + + end) + + end) + + describe('(with 2nd dummy action some frames after the other)', function () + + describe('(when next action index is 1/1)', function () + + before_each(function () + itest_runner._next_action_index = 1 + end) + + describe('(when next action time trigger is not reached yet)', function () + + before_each(function () + -- time trigger uses relative frames, so compare the difference since last trigger to 60 + test:add_action(time_trigger(0.2), action_callback2, 'action_callback2') + itest_runner.current_frame = 158 + itest_runner._last_trigger_frame = 100 + end) + + it('should not call any actions nor advance the time/index', function () + itest_runner._check_end:clear() -- was called on start in before_each + itest_runner:_check_next_action() + assert.spy(action_callback).was_not_called() + assert.spy(action_callback2).was_not_called() + assert.are_equal(100, itest_runner._last_trigger_frame) + assert.are_equal(1, itest_runner._next_action_index) + assert.spy(itest_runner._check_end).was_not_called() + end) + + end) + + describe('(when next action time trigger is reached)', function () + + before_each(function () + -- time trigger uses relative frames, so compare the difference since last trigger to 60 + test:add_action(time_trigger(0.2), action_callback2, 'action_callback2') + itest_runner.current_frame = 160 + itest_runner._last_trigger_frame = 100 + end) + + it('should call only the first action and advance the timeindex', function () + itest_runner._check_end:clear() -- was called on start in before_each + itest_runner:_check_next_action() + assert.spy(action_callback).was_called(1) + assert.spy(action_callback).was_called_with() + assert.spy(action_callback2).was_not_called() -- at least 1 frame before action2, no action chaining + assert.are_equal(160, itest_runner._last_trigger_frame) + assert.are_equal(2, itest_runner._next_action_index) + assert.spy(itest_runner._check_end).was_called(1) + assert.spy(itest_runner._check_end).was_called_with(match.ref(itest_runner)) + end) + + end) + + end) + + end) + end) describe('(with empty action)', function () @@ -758,12 +954,12 @@ describe('integration_test_runner', function () test:add_action(time_trigger(1, true), nil, 'empty action') end) - it('should assert', function () - integration_test_runner:start(test) - integration_test_runner.current_frame = 2 -- to trigger action to do at end of frame 1 + it('should recognize next empty action and do nothing', function () + itest_runner:start(test) + itest_runner.current_frame = 2 -- to trigger action to do at end of frame 1 assert.has_no_errors(function () - integration_test_runner:_check_next_action() + itest_runner:_check_next_action() end) end) @@ -774,7 +970,7 @@ describe('integration_test_runner', function () describe('_check_end', function () before_each(function () - integration_test_runner:start(test) + itest_runner:start(test) end) describe('(when no actions left)', function () @@ -782,10 +978,10 @@ describe('integration_test_runner', function () describe('(when no final assertion)', function () it('should make test end immediately with success and return true', function () - local result = integration_test_runner:_check_end(test) + local result = itest_runner:_check_end(test) assert.is_true(result) assert.are_same({test_states.success, nil}, - {integration_test_runner.current_state, integration_test_runner.current_message}) + {itest_runner.current_state, itest_runner.current_message}) end) end) @@ -799,10 +995,10 @@ describe('integration_test_runner', function () end) it('should check the final assertion immediately, end with success and return true', function () - local result = integration_test_runner:_check_end(test) + local result = itest_runner:_check_end(test) assert.is_true(result) assert.are_same({test_states.success, nil}, - {integration_test_runner.current_state, integration_test_runner.current_message}) + {itest_runner.current_state, itest_runner.current_message}) end) end) @@ -816,9 +1012,9 @@ describe('integration_test_runner', function () end) it('should check the final assertion immediately, end with failure and return true', function () - local result = integration_test_runner:_check_end(test) + local result = itest_runner:_check_end(test) assert.is_true(result) - assert.are_equal(test_states.failure, integration_test_runner.current_state) + assert.are_equal(test_states.failure, itest_runner.current_state) end) end) @@ -832,7 +1028,7 @@ describe('integration_test_runner', function () end) it('should return false', function () - assert.is_false(integration_test_runner:_check_end(test)) + assert.is_false(itest_runner:_check_end(test)) end) end) @@ -842,19 +1038,19 @@ describe('integration_test_runner', function () describe('_end_with_final_assertion', function () before_each(function () - -- inline some parts of integration_test_runner:start(test) + -- inline some parts of itest_runner:start(test) -- to get a boilerplate to test on -- avoid calling start() directly as it would call _check_end, messing the teardown spy count - integration_test_runner:_initialize() - integration_test_runner.current_test = test - integration_test_runner.current_state = test_states.running + itest_runner:_initialize() + itest_runner.current_test = test + itest_runner.current_state = test_states.running end) describe('(when no final assertion)', function () it('should end with success', function () - integration_test_runner:_end_with_final_assertion(test) - assert.are_equal(test_states.success, integration_test_runner.current_state) + itest_runner:_end_with_final_assertion(test) + assert.are_equal(test_states.success, itest_runner.current_state) end) end) @@ -868,8 +1064,8 @@ describe('integration_test_runner', function () end) it('should check the final assertion and end with success', function () - integration_test_runner:_end_with_final_assertion(test) - assert.are_equal(test_states.success, integration_test_runner.current_state) + itest_runner:_end_with_final_assertion(test) + assert.are_equal(test_states.success, itest_runner.current_state) end) end) @@ -883,9 +1079,9 @@ describe('integration_test_runner', function () end) it('should check the final assertion and end with failure', function () - integration_test_runner:_end_with_final_assertion(test) + itest_runner:_end_with_final_assertion(test) assert.are_same({test_states.failure, "error message"}, - {integration_test_runner.current_state, integration_test_runner.current_message}) + {itest_runner.current_state, itest_runner.current_message}) end) end) @@ -895,21 +1091,21 @@ describe('integration_test_runner', function () describe('stop', function () before_each(function () - integration_test_runner:start(test) + itest_runner:start(test) end) it('should reset the current test', function () - integration_test_runner:stop(test) - assert.is_nil(integration_test_runner.current_test) + itest_runner:stop(test) + assert.is_nil(itest_runner.current_test) end) it('should reset state vars', function () - integration_test_runner:stop(test) + itest_runner:stop(test) assert.are_same({0, 0, 1, test_states.none}, { - integration_test_runner.current_frame, - integration_test_runner._last_trigger_frame, - integration_test_runner._next_action_index, - integration_test_runner.current_state + itest_runner.current_frame, + itest_runner._last_trigger_frame, + itest_runner._next_action_index, + itest_runner.current_state }) end) @@ -921,7 +1117,7 @@ describe('integration_test_runner', function () end) it('should call teardown', function () - integration_test_runner:stop(test) + itest_runner:stop(test) assert.spy(test.teardown).was_called(1) assert.spy(test.teardown).was_called_with() end) @@ -953,15 +1149,6 @@ describe('time_trigger', function () end) end) - describe('__eq', function () - it('should return true if times are equal', function () - assert.is_true(time_trigger(2.0) == time_trigger(2.0)) - end) - it('should return false if times are not equal', function () - assert.is_true(time_trigger(1.0) ~= time_trigger(2.0)) - end) - end) - describe('_check', function () it('should return true if elapsed time is equal to {self.frames}', function () assert.is_true(time_trigger(2.0):_check(120)) diff --git a/tests/utestlogging.lua b/src/engine/tests/utestlogging.lua similarity index 87% rename from tests/utestlogging.lua rename to src/engine/tests/utestlogging.lua index 7de645b2..3b474e3c 100644 --- a/tests/utestlogging.lua +++ b/src/engine/tests/utestlogging.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") require("engine/core/math") local logging = require("engine/debug/logging") @@ -7,8 +7,8 @@ logging.logger:init() describe('logging', function () - local log_msg = logging.log_msg - local log_stream = logging.log_stream + local log_msg, log_stream, file_log_stream = get_members(logging, + "log_msg", "log_stream", "file_log_stream") describe('log_msg', function () @@ -109,14 +109,72 @@ describe('logging', function () describe('init', function () - it('should set all active categories flags to true', function () + it('should set all active categories flags to true, except trace', function () for category, _ in pairs(logger.active_categories) do - assert.is_true(logger.active_categories[category], "category '"..category.."' is not active") + if category == "trace" then + assert.is_false(logger.active_categories[category], "category '"..category.."' is active") + else + assert.is_true(logger.active_categories[category], "category '"..category.."' is not active") + end end end) end) + -- for file logging, our tests are low-level and just check that on_log + -- is calling printh on the compounded message + describe('file_log_stream', function () + + local printh_stub + + setup(function () + printh_stub = stub(_G, "printh") + end) + + teardown(function () + printh_stub:revert() + end) + + before_each(function () + logger:register_stream(file_log_stream) + end) + + after_each(function () + file_log_stream:init() + printh_stub:clear() + end) + + describe('derived_init', function () + it('should set file_prefix to "game"', function () + assert.are_equal("game", file_log_stream.file_prefix) + end) + end) + + describe('clear', function () + it('should call printh with empty message and overwrite mode', function () + file_log_stream.file_prefix = "my_game" + + file_log_stream:clear() + + assert.spy(printh_stub).was_called(1) + assert.spy(printh_stub).was_called_with("", "my_game_log", true) + end) + end) + + describe('on_log', function () + it('should call printh with compounded message and target file "{self.file_prefix}_log.txt"', function () + file_log_stream.file_prefix = "my_game" + + local lm = log_msg(logging.level.info, "default", "dummy") + file_log_stream:on_log(lm) + + assert.spy(printh_stub).was_called(1) + assert.spy(printh_stub).was_called_with(logging.compound_message(lm), "my_game_log") + end) + end) + + end) + describe('deactivate_all_categories', function () it('should set all active categories flags to false', function () @@ -255,24 +313,23 @@ describe('logging', function () end) + -- for console logging, our tests are high-level + -- and contain checking that compound_message is doing its job describe('console logging', function () local printh_stub setup(function () - -- important since busted_helper will set it to false - logging.console_log_stream.active = true printh_stub = stub(_G, "printh") end) teardown(function () - logging.console_log_stream.active = false printh_stub:revert() end) before_each(function () logger.active_categories.flow = true - logger:register_stream(logging.console_log_stream) + logger:register_stream(console_log_stream) end) after_each(function () @@ -771,7 +828,8 @@ describe('logging', function () describe('dump', function () - setup(function () + before_each(function () + -- no need to teardown, we are inside the 'logger' block that reinit logger after_each logger.dump_max_recursion_level = 2 end) @@ -796,7 +854,7 @@ describe('logging', function () assert.are_equal("56.2", dump(56.2)) end) - -- as key + -- as_key: used to mimic key representation in lua tables it('"string" => "string"', function () assert.are_equal("string", dump("string", true)) @@ -808,12 +866,14 @@ describe('logging', function () assert.are_equal("[56.2]", dump(56.2, true)) end) - -- tables + -- sequence of mixed values it('{1 nil "string"} => "{[1] = 1 [3] = "string"}"', function () assert.are_equal("{[1] = 1, [3] = \"string\"}", dump({1, nil, "string"})) end) + -- mix of non-comparable keys (cannot use sorted_keys here) + it('{[7] = 5 string = "other"} => "{[7] = 5, string = "other"}" or "{string = "other", [7] = 5}"', function () -- matchers are difficult to use outside of called_with, so we can't use match.any_of directly -- instead we test the alternative with a simple assert.is_true and a custom message to debug if false @@ -821,6 +881,8 @@ describe('logging', function () dump({[7] = 5, string = "other"}))) end) + -- mix of sequence of and indexed values + it('{5 "text" string = "other"} => "{[1] = 5 [2] = "text" string = "other"}', function () assert.are_equal("{[1] = 5, [2] = \"text\", string = \"other\"}", dump({5, "text", string = "other"})) end) @@ -829,6 +891,8 @@ describe('logging', function () assert.are_equal("{[1] = 2, mytable = {[1] = 1, [2] = 3, key = \"value\"}}", dump({2, mytable = {1, 3, key = "value"}})) end) + -- tables as values + it('{...} => "{{[1] = 1 [2] = 3 key = "value"} = 11}', function () assert.are_equal("{[{[1] = 1, [2] = 3, key = \"value\"}] = 11}", dump({[{1, 3, key = "value"}] = 11})) end) @@ -837,14 +901,18 @@ describe('logging', function () assert.are_equal("{[{[1] = 1, [2] = 3, key = \"value\"}] = {[1] = true, [2] = false}}", dump({[{1, 3, key = "value"}] = {true, false}})) end) - -- tables with tostring + -- sequences with table elements implementing _tostring it('{1, "text", vector(2, 4)} => "{[1] = 1, [2] = "text", [3] = vector(2, 4)}"', function () assert.are_equal("{[1] = 1, [2] = \"text\", [3] = vector(2, 4)}", dump({1, "text", vector(2, 4)}, false, 1, true)) end) - it('{1, "text", vector(2, 4)} => "{[1] = 1, [2] = "text", [3] = vector(2, 4)}"', function () - assert.are_equal("{[1] = 1, [2] = \"text\", [3] = vector(2, 4)}", nice_dump({1, "text", vector(2, 4)})) + -- non-sequence tables where ambiguous representation can be made deterministic with sorted_keys + -- as long as the keys are comparable + -- note that we are not testing __genOrderedIndex, orderedNext and orderedPairs, so we test them via dump with sorted_keys: true instead + + it('{f = 4, ["0"] = "a", b = -100} => "{[0] = "a", b = -100, f = 4}"', function () + assert.are_equal("{0 = \"a\", b = -100, f = 4}", dump({f = 4, ["0"] = "a", b = -100}, false, nil, true, --[[sorted_keys:]] true)) end) -- infinite recursion prevention @@ -873,6 +941,24 @@ describe('logging', function () end) - end) + describe('nice_dump', function () + + + before_each(function () + -- no need to teardown, we are inside the 'logger' block that reinit logger after_each + logger.dump_max_recursion_level = 2 + end) + + it('{1, "text", vector(2, 4)} => "{[1] = 1, [2] = "text", [3] = vector(2, 4)}"', function () + assert.are_equal("{[1] = 1, [2] = \"text\", [3] = vector(2, 4)}", nice_dump({1, "text", vector(2, 4)})) + end) + + it('{[10.5] = "b", [-22] = "a", [34.7] = "c"} => "{[-22] = "a", [10.5] = "b", [34.7] = "c"}"', function () + assert.are_equal("{[-22] = \"a\", [10.5] = \"b\", [34.7] = \"c\"}", nice_dump({[10.5] = "b", [-22] = "a", [34.7] = "c"}, true)) + end) + + end) + + end) -- logger end) diff --git a/tests/utestmath.lua b/src/engine/tests/utestmath.lua similarity index 90% rename from tests/utestmath.lua rename to src/engine/tests/utestmath.lua index 9b24758a..2af4482e 100644 --- a/tests/utestmath.lua +++ b/src/engine/tests/utestmath.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") require("engine/core/math") describe('almost_eq', function () @@ -481,77 +481,77 @@ describe('signed_speed_to_dir', function () end) end) -describe('oppose_direction', function () +describe('oppose_dir', function () it('should left => right', function () - assert.are_equal(directions.right, oppose_direction(directions.left)) + assert.are_equal(directions.right, oppose_dir(directions.left)) end) it('should right => left', function () - assert.are_equal(directions.left, oppose_direction(directions.right)) + assert.are_equal(directions.left, oppose_dir(directions.right)) end) it('should up => down', function () - assert.are_equal(directions.down, mirror_direction_y(directions.up)) + assert.are_equal(directions.down, mirror_dir_y(directions.up)) end) it('should down => up', function () - assert.are_equal(directions.up, mirror_direction_y(directions.down)) + assert.are_equal(directions.up, mirror_dir_y(directions.down)) end) end) -describe('mirror_direction_x', function () +describe('mirror_dir_x', function () it('should left => right', function () - assert.are_equal(directions.right, mirror_direction_x(directions.left)) + assert.are_equal(directions.right, mirror_dir_x(directions.left)) end) it('should right => left', function () - assert.are_equal(directions.left, mirror_direction_x(directions.right)) + assert.are_equal(directions.left, mirror_dir_x(directions.right)) end) it('should up => up', function () - assert.are_equal(directions.up, mirror_direction_x(directions.up)) + assert.are_equal(directions.up, mirror_dir_x(directions.up)) end) it('should down => down', function () - assert.are_equal(directions.down, mirror_direction_x(directions.down)) + assert.are_equal(directions.down, mirror_dir_x(directions.down)) end) end) -describe('+ mirror_direction_y', function () +describe('+ mirror_dir_y', function () it('should up => down', function () - assert.are_equal(directions.down, mirror_direction_y(directions.up)) + assert.are_equal(directions.down, mirror_dir_y(directions.up)) end) it('should down => up', function () - assert.are_equal(directions.up, mirror_direction_y(directions.down)) + assert.are_equal(directions.up, mirror_dir_y(directions.down)) end) it('should left => left', function () - assert.are_equal(directions.left, mirror_direction_y(directions.left)) + assert.are_equal(directions.left, mirror_dir_y(directions.left)) end) it('should right => right', function () - assert.are_equal(directions.right, mirror_direction_y(directions.right)) + assert.are_equal(directions.right, mirror_dir_y(directions.right)) end) end) -describe('rotate_direction_90_cw', function () +describe('rotate_dir_90_cw', function () it('should left => up', function () - assert.are_equal(directions.up, rotate_direction_90_cw(directions.left)) + assert.are_equal(directions.up, rotate_dir_90_cw(directions.left)) end) it('should up => right', function () - assert.are_equal(directions.right, rotate_direction_90_cw(directions.up)) + assert.are_equal(directions.right, rotate_dir_90_cw(directions.up)) end) it('should right => down', function () - assert.are_equal(directions.down, rotate_direction_90_cw(directions.right)) + assert.are_equal(directions.down, rotate_dir_90_cw(directions.right)) end) it('should down => left', function () - assert.are_equal(directions.left, rotate_direction_90_cw(directions.down)) + assert.are_equal(directions.left, rotate_dir_90_cw(directions.down)) end) end) -describe('rotate_direction_90_ccw', function () +describe('rotate_dir_90_ccw', function () it('should left => down', function () - assert.are_equal(directions.down, rotate_direction_90_ccw(directions.left)) + assert.are_equal(directions.down, rotate_dir_90_ccw(directions.left)) end) it('should down => right', function () - assert.are_equal(directions.right, rotate_direction_90_ccw(directions.down)) + assert.are_equal(directions.right, rotate_dir_90_ccw(directions.down)) end) it('should right => up', function () - assert.are_equal(directions.up, rotate_direction_90_ccw(directions.right)) + assert.are_equal(directions.up, rotate_dir_90_ccw(directions.right)) end) it('should up => left', function () - assert.are_equal(directions.left, rotate_direction_90_ccw(directions.up)) + assert.are_equal(directions.left, rotate_dir_90_ccw(directions.up)) end) end) diff --git a/tests/utestpico8api.lua b/src/engine/tests/utestpico8api.lua similarity index 80% rename from tests/utestpico8api.lua rename to src/engine/tests/utestpico8api.lua index 3390bcaa..89cebf41 100644 --- a/tests/utestpico8api.lua +++ b/src/engine/tests/utestpico8api.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") require("engine/core/math") describe('pico8api', function () @@ -88,30 +88,34 @@ describe('pico8api', function () describe('tonum', function () it('should return the number corresponding to a number', function () - assert.are_equal(5, tonum(5)) + assert.are_equal(-25.34, tonum(-25.34)) end) - it('should return the number corresponding to a string', function () - assert.are_equal(5, tonum("5")) + it('should return the positive number corresponding to a string', function () + assert.are_equal(25, tonum("25")) end) - end) - - describe('tonum', function () - - it('should return the number corresponding to a number', function () - assert.are_equal(5, tonum(5)) + it('should return the negative number corresponding to a string (not fractional power of 2)', function () + assert.are_equal(-25.34, tonum("-25.34")) end) - it('should return the number corresponding to a string', function () - assert.are_equal(5, tonum("5")) + -- this one is for native Lua only: PICO-8 itself doesn't pass it + -- because tonum fails on negative number strings of 0x0000.0001! + it('should return the negative number corresponding to a string (fractional power of 2)', function () + assert.are_equal(-25.25, tonum("-25.25")) end) end) describe('tostr', function () it('nil => "[nil]"', function () - assert.are_equal("[nil]", tostr(nil)) + assert.are_equal("[nil]", tostr(nil)) -- or tostr() + end) + -- this one works for native Lua only; it differs from pico8 + -- which would return "[no value]", indicating a special value + it('empty function return => "[nil]"', function () + function f() end + assert.are_equal("[nil]", tostr(f())) end) it('"string" => "string"', function () assert.are_equal("string", tostr("string")) @@ -131,6 +135,15 @@ describe('pico8api', function () it('0x58cb.fd85 => "0x58cb.fd85" (hex)', function () assert.are_equal("0x58cb.fd85", tostr(0x58cb.fd85, true)) end) + -- this one is only useful to test robustness with native Lua: + -- in PICO-8, floats have 16:16 fixed point precision, + -- so they can never get more than 4 hex figures after the point + -- with busted, we need to cut the extra hex figures to avoid + -- error "number (local 'val') has no integer representation" + -- when applying binary operations + it('0x58cb.fd8524 => "0x58cb.fd85" (hex)', function () + assert.are_equal("0x58cb.fd85", tostr(0x58cb.fd8524, true)) + end) it('{} => "[table]" (_tostring not implemented)', function () assert.are_equal("[table]", tostr({})) end) @@ -965,12 +978,14 @@ describe('pico8api', function () it('should return an empty iterator for nil', function () for value in all(nil) do + -- should never be called assert.is_true(false) end end) it('should return an empty iterator for an empty sequence', function () for value in all({}) do + -- should never be called assert.is_true(false) end end) @@ -1027,57 +1042,147 @@ describe('pico8api', function () describe('printh', function () - local native_print_stub + -- caution: this will hide *all* native prints, including debug logs + -- so we only do this for the utests that really need it + describe('(stubbing print)', function () - setup(function () - native_print_stub = stub(_G, "print") -- native print - end) + local native_print_stub - teardown(function () - native_print_stub:revert() - end) + setup(function () + native_print_stub = stub(_G, "print") -- native print + end) - after_each(function () - native_print_stub:clear() - end) + teardown(function () + native_print_stub:revert() + end) - it('should print the current file:line with a message', function () - print_at_line("text") - end) + after_each(function () + native_print_stub:clear() + end) + + it('should call native print', function () + printh("hello") + + assert.spy(native_print_stub).was_called(1) + assert.spy(native_print_stub).was_called_with("hello") + end) - it('should call native print', function () - printh("hello") - assert.spy(native_print_stub).was_called(1) - assert.spy(native_print_stub).was_called_with("hello") end) - end) + describe('(with temp file', function () + -- in general we should use os.tmpname, but because of the fact + -- that printh prints to a log folder, we prefer using a custom path + -- make sure to use a temp dir name that is not an actual folder in the project + local temp_dirname = "_temp" + local temp_file_basename = "temp" + local temp_filepath = temp_dirname.."/"..temp_file_basename..".txt" + local temp_file = nil + local function is_dir(dirpath) + local attr = lfs.attributes(dirpath) + return attr and attr.mode == "directory" + end - describe('printh', function () + -- https://stackoverflow.com/questions/37835565/lua-delete-non-empty-directory + local function remove_dir_recursive(dirpath) + for file in lfs.dir(dirpath) do + local file_path = dirpath..'/'..file + if file ~= "." and file ~= ".." then + if lfs.attributes(file_path, 'mode') == 'file' then + os.remove(file_path) + elseif lfs.attributes(file_path, 'mode') == 'directory' then + -- just a safety net (if you apply coverage to utest files you'll see it's never called) + remove_dir_recursive(file_path) + end + end + end + lfs.rmdir(dirpath) + end - local native_print_stub + local function remove_if_exists(path) + local attr = lfs.attributes(path) + if attr then + if attr.mode == "directory" then + remove_dir_recursive(path) + else + os.remove(path) + end + end + end - setup(function () - native_print_stub = stub(_G, "print") -- native print - end) + local function get_lines(file) + local lines = {} + for line in file:lines() do + add(lines, line) + end + return lines + end - teardown(function () - native_print_stub:revert() - end) + before_each(function () + remove_if_exists(temp_dirname) + end) - after_each(function () - native_print_stub:clear() - end) + after_each(function () + if temp_file then + -- an error occurred (maybe the assert failed) and the temp file wasn't closed and set to nil + -- this is never called in working tests + print("WARNING: emergency close needed, the last write operation likely failed") + temp_file:close() + end - it('should print the current file:line with a message', function () - print_at_line("text") - end) + remove_if_exists(temp_dirname) + end) + + it('should create log directory if it doesn\'t exist', function () + printh("hello", temp_file_basename, true, temp_dirname) + + assert.is_true(is_dir(temp_dirname)) + end) + + it('should assert if a non-directory "log" already exists', function () + local f,error = io.open(temp_dirname, "w") + f:close() + + assert.has_error(function () + printh("hello", temp_file_basename, true, temp_dirname) + end, "'_temp' is not a directory but a file") + end) + + it('should overwrite a file with filepath and true', function () + printh("hello", temp_file_basename, true, temp_dirname) + + temp_file = io.open(temp_filepath) + assert.is_not_nil(temp_file) + assert.are_same({"hello"}, get_lines(temp_file)) + temp_file = nil + end) + + it('should append to a file with filepath and false', function () + lfs.mkdir(temp_dirname) + temp_file = io.open(temp_filepath, "w") + temp_file:write("hello1\n") + temp_file:close() + temp_file = nil + + printh("hello2", temp_file_basename, false, temp_dirname) + + temp_file = io.open(temp_filepath) + assert.is_not_nil(temp_file) + assert.are_same({"hello1", "hello2"}, get_lines(temp_file)) + temp_file = nil + end) + + it('should append to a file with filepath and false, adding newline at the end', function () + printh("hello1", temp_file_basename, false, temp_dirname) + printh("hello2", temp_file_basename, false, temp_dirname) + printh("hello3", temp_file_basename, false, temp_dirname) + + temp_file = io.open(temp_filepath) + assert.is_not_nil(temp_file) + assert.are_same({"hello1", "hello2", "hello3"}, get_lines(temp_file)) + temp_file = nil + end) - it('should call native print', function () - printh("hello") - assert.spy(native_print_stub).was_called(1) - assert.spy(native_print_stub).was_called_with("hello") end) end) diff --git a/tests/wtk/utestpico8wtk.lua b/src/engine/tests/utestpico8wtk.lua similarity index 99% rename from tests/wtk/utestpico8wtk.lua rename to src/engine/tests/utestpico8wtk.lua index 925c223e..449e8a20 100644 --- a/tests/wtk/utestpico8wtk.lua +++ b/src/engine/tests/utestpico8wtk.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local wtk = require("engine/wtk/pico8wtk") describe('wtk', function () diff --git a/tests/utestprofiler.lua b/src/engine/tests/utestprofiler.lua similarity index 97% rename from tests/utestprofiler.lua rename to src/engine/tests/utestprofiler.lua index 8757dd65..7ed983c8 100644 --- a/tests/utestprofiler.lua +++ b/src/engine/tests/utestprofiler.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local profiler = require("engine/debug/profiler") describe('profiler', function () diff --git a/tests/utestsprite.lua b/src/engine/tests/utestsprite_data.lua similarity index 52% rename from tests/utestsprite.lua rename to src/engine/tests/utestsprite_data.lua index a3271a58..7bf3e61b 100644 --- a/tests/utestsprite.lua +++ b/src/engine/tests/utestsprite_data.lua @@ -1,5 +1,5 @@ -require("bustedhelper") -sprite = require("engine/render/sprite") +require("engine/test/bustedhelper") +local sprite_data = require("engine/render/sprite_data") describe('sprite', function () @@ -39,25 +39,10 @@ describe('sprite', function () end) - describe('__eq', function () - - local spr_data = sprite_data(sprite_id_location(1, 3), tile_vector(2, 3), vector(2, 4)) - local spr_data2 = sprite_data(sprite_id_location(1, 3), tile_vector(2, 3), vector(2, 4)) - local spr_data3 = sprite_data(sprite_id_location(1, 5), tile_vector(2, 3), vector(2, 4)) - - it('sprite_data((1 3) ...) == sprite_data((1 3) ...)', function () - assert.are_equal(spr_data2, spr_data) - end) - - it('sprite_data((1 3) ...) == sprite_data((1 5), ...)', function () - assert.are_not_equal(spr_data3, spr_data) - end) - - end) - describe('render', function () - local spr_data = sprite_data(sprite_id_location(1, 3), tile_vector(2, 3), vector(2, 4)) + local spr_data = sprite_data(sprite_id_location(1, 3), tile_vector(2, 3), vector(11, 10)) + local spr_data2 = sprite_data(sprite_id_location(1, 3), tile_vector(2, 1), vector(8, 4)) local spr_stub setup(function () @@ -72,10 +57,43 @@ describe('sprite', function () spr_stub:clear() end) - it('should render the sprite from the id location, at the draw position minus pivot, with correct span and flip', function () - spr_data:render(vector(4, 8), false, true) + it('should render the sprite from the id location, at the draw position minus pivot, with correct span when not flipping', function () + spr_data:render(vector(41, 80), false, false) + + assert.spy(spr_stub).was_called(1) + assert.spy(spr_stub).was_called_with(49, 30, 70, 2, 3, false, false) + end) + + it('should render the sprite from the id location, at the draw position minus pivot itself flipped on x, with correct span when flipping x', function () + spr_data:render(vector(41, 80), true, false) + + assert.spy(spr_stub).was_called(1) + -- flip pivot (11, 10) around center x axis which is at 8 * span.x / 2 = 8 -> flipped pivot (5, 10) + assert.spy(spr_stub).was_called_with(49, 36, 70, 2, 3, true, false) + end) + + it('should render the sprite from the id location, at the draw position minus pivot itself flipped on y, with correct span when flipping y', function () + spr_data:render(vector(41, 80), false, true) + + assert.spy(spr_stub).was_called(1) + -- flip pivot (11, 10) around center y axis which is at 8 * span.y / 2 = 12 -> flipped pivot (11, 14) + assert.spy(spr_stub).was_called_with(49, 30, 66, 2, 3, false, true) + end) + + it('should render the sprite from the id location, at the draw position minus pivot itself flipped on x and y, with correct span when flipping x and y', function () + spr_data:render(vector(41, 80), true, true) + + assert.spy(spr_stub).was_called(1) + assert.spy(spr_stub).was_called_with(49, 36, 66, 2, 3, true, true) + end) + + it('should render the sprite from the id location, at the draw position minus pivot located at center, with correct span when flipping x and y', function () + spr_data2:render(vector(8, 4), true, true) + assert.spy(spr_stub).was_called(1) - assert.spy(spr_stub).was_called_with(49, 2, 4, 2, 3, false, true) + -- pivot is already at center, so flip has no effect on it + -- and since position == pivot, it draws at the origin + assert.spy(spr_stub).was_called_with(49, 0, 0, 2, 1, true, true) end) end) diff --git a/tests/utesttilemap.lua b/src/engine/tests/utesttilemap.lua similarity index 58% rename from tests/utesttilemap.lua rename to src/engine/tests/utesttilemap.lua index b5dff7a7..62477ce4 100644 --- a/tests/utesttilemap.lua +++ b/src/engine/tests/utesttilemap.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local tilemap = require("engine/data/tilemap") describe('tilemap', function () @@ -22,4 +22,25 @@ describe('tilemap', function () end) end) + describe('clear_map', function () + + setup(function () + stub(pico8, "clear_map") + end) + + teardown(function () + pico8.clear_map:revert() + end) + + after_each(function () + pico8.clear_map:clear() + end) + + it('should call clear_map from pico8api (busted only)', function () + tilemap.clear_map() + assert.spy(pico8.clear_map).was_called(1) + assert.spy(pico8.clear_map).was_called_with(match.ref(pico8)) + end) + end) + end) diff --git a/tests/utestui.lua b/src/engine/tests/utestui.lua similarity index 91% rename from tests/utestui.lua rename to src/engine/tests/utestui.lua index a30e8790..297b8aa9 100644 --- a/tests/utestui.lua +++ b/src/engine/tests/utestui.lua @@ -1,7 +1,8 @@ -require("bustedhelper") +require("engine/test/bustedhelper") require("engine/render/color") local ui = require("engine/ui/ui") local input = require("engine/input/input") +local sprite_data = require("engine/render/sprite_data") local label = ui.label local overlay = ui.overlay @@ -27,20 +28,6 @@ describe('ui', function () end) - describe('__eq', function () - - it('should return true for label with same text and position', function () - assert.are_equal(label("good", vector(22, 62), colors.orange), label("good", vector(22, 62), colors.orange)) - end) - - it('should return false for label with different text or position', function () - assert.are_not_equal(label("good", vector(22, 62), colors.orange), label("bad", vector(22, 62), colors.orange)) - assert.are_not_equal(label("good", vector(23, 62), colors.orange), label("good", vector(22, 62), colors.orange)) - assert.are_not_equal(label("good", vector(23, 62), colors.orange), label("good", vector(23, 62), colors.peach)) - end) - - end) - end) describe('overlay', function () diff --git a/tests/utestunittest.lua b/src/engine/tests/utestunittest.lua similarity index 98% rename from tests/utestunittest.lua rename to src/engine/tests/utestunittest.lua index f5cf2b98..2346af7c 100644 --- a/tests/utestunittest.lua +++ b/src/engine/tests/utestunittest.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local unittest = require("engine/test/unittest") local utest_manager, unit_test, time_trigger = unittest.utest_manager, unittest.unit_test diff --git a/tests/utestunittest_helper.lua b/src/engine/tests/utestunittest_helper.lua similarity index 97% rename from tests/utestunittest_helper.lua rename to src/engine/tests/utestunittest_helper.lua index a4d7a036..ba6b93fe 100644 --- a/tests/utestunittest_helper.lua +++ b/src/engine/tests/utestunittest_helper.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") require("engine/test/unittest_helper") describe('unittest_helper', function () diff --git a/tests/utestvisual_logger.lua b/src/engine/tests/utestvisual_logger.lua similarity index 99% rename from tests/utestvisual_logger.lua rename to src/engine/tests/utestvisual_logger.lua index 52bc6baf..08d91832 100644 --- a/tests/utestvisual_logger.lua +++ b/src/engine/tests/utestvisual_logger.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local vlogger = require("engine/debug/visual_logger") local logging = require("engine/debug/logging") local wtk = require("engine/wtk/pico8wtk") diff --git a/src/engine/ui/ui.lua b/src/engine/ui/ui.lua index 11afdec5..0abac02f 100644 --- a/src/engine/ui/ui.lua +++ b/src/engine/ui/ui.lua @@ -3,7 +3,6 @@ local logging = require("engine/debug/logging") --#endif local input = require("engine/input/input") -local visual = require("game/resources/visual") local ui = { cursor_sprite_data = nil diff --git a/src/game/application/gamestate_proxy.lua b/src/game/application/gamestate_proxy.lua index 6fbded28..ef294dc3 100644 --- a/src/game/application/gamestate_proxy.lua +++ b/src/game/application/gamestate_proxy.lua @@ -53,7 +53,7 @@ end -- with a lightweight dummy state function gamestate_proxy:get(module_name) assert(type(module_name) == "string") - assert(self._state_modules[module_name] ~= nil, "gamestate_proxy:get: self._state_modules[module_name] is nil, make sure you have called gamestate_proxy:require_gamestates before") + assert(self._state_modules[module_name] ~= nil, "gamestate_proxy:get: self._state_modules['"..module_name.."'] is nil, make sure you have called gamestate_proxy:require_gamestates before") assert(type(self._state_modules[module_name]) == "table" and self._state_modules[module_name].state, "gamestate_proxy:get: self._state_modules[module_name] is not a function with a 'state' member") return self._state_modules[module_name].state end diff --git a/src/game/application/gameapp.lua b/src/game/application/picosonic_app.lua similarity index 51% rename from src/game/application/gameapp.lua rename to src/game/application/picosonic_app.lua index 8ef225c2..a9f16a54 100644 --- a/src/game/application/gameapp.lua +++ b/src/game/application/picosonic_app.lua @@ -1,9 +1,15 @@ +local gameapp = require("engine/application/gameapp") + local flow = require("engine/application/flow") -local input = require("engine/input/input") local gamestate_proxy = require("game/application/gamestate_proxy") -local gamestate = require("game/application/gamestate") local visual = require("game/resources/visual") +--#if log +local logging = require("engine/debug/logging") +-- pico8 doesn't support output file path containing "-" so use "_" +logging.file_log_stream.file_prefix = "sonic_pico8_v2.3" +--#endif + --#if visual_logger local vlogger = require("engine/debug/visual_logger") --#endif @@ -20,51 +26,29 @@ local profiler = require("engine/debug/profiler") local ui = require("engine/ui/ui") --#endif -local gameapp = {} - --- todo: consider making gameapp a singleton with init like the other modules, --- so we can easily reinit it (implementation would b more a reset than the init --- below, as it would reinit the flow, etc.) - --- in pico8 builds, pass nothing for active_gamestates --- in busted tests, pass active_gamestates so they can be required automatically on gameapp init -function gameapp.init(active_gamestates) ---#ifn pico8 - assert(active_gamestates, "gameapp.init: non-pico8 build requires active_gamestates to define them at runtime") ---#endif - ---#if mouse - ui:set_cursor_sprite_data(visual.sprite_data_t.cursor) ---#endif - ---#ifn pico8 - gamestate_proxy:require_gamestates(active_gamestates) ---#endif +local picosonic_app = derived_class(gameapp) +function picosonic_app:register_gamestates() -- override for state in all({"titlemenu", "credits", "stage"}) do flow:add_gamestate(gamestate_proxy:get(state)) end - flow:query_gamestate_type(gamestate.types.titlemenu) end ---#ifn utest -function gameapp.reinit_modules() +function picosonic_app.on_start() -- override --#if mouse - ui:set_cursor_sprite_data(nil) + ui:set_cursor_sprite_data(visual.sprite_data_t.cursor) --#endif +end ---#ifn pico8 - gamestate_proxy:init() +--#if itest +function picosonic_app.on_reset() -- override +--#if mouse + ui:set_cursor_sprite_data(nil) --#endif - - flow:init() end --#endif -function gameapp.update() - input:process_players_inputs() - flow:update() - +function picosonic_app.on_update() -- override --#if visual_logger vlogger.window:update() --#endif @@ -78,10 +62,7 @@ function gameapp.update() --#endif end -function gameapp.draw() - cls() - flow:render() - +function picosonic_app.on_render() --#if visual_logger vlogger.window:render() --#endif @@ -99,4 +80,4 @@ function gameapp.draw() --#endif end -return gameapp +return picosonic_app diff --git a/src/game/data/collision_data.lua b/src/game/data/collision_data.lua index f7e003a6..84d0eaf9 100644 --- a/src/game/data/collision_data.lua +++ b/src/game/data/collision_data.lua @@ -1,5 +1,6 @@ -local collision = require("engine/physics/collision") -local tile_data = collision.tile_data +require("engine/core/math") +local tile = require("game/platformer/tile") +local tile_data = tile.tile_data sprite_flags = { collision = 0 diff --git a/src/game/data/playercharacter_data.lua b/src/game/data/playercharacter_data.lua index 4e8557c2..341d2071 100644 --- a/src/game/data/playercharacter_data.lua +++ b/src/game/data/playercharacter_data.lua @@ -1,30 +1,33 @@ -return { +local sprite_data = require("engine/render/sprite_data") +local animated_sprite_data = require("engine/render/animated_sprite_data") + +local playercharacter_data = { -- platformer motion -- ground acceleration (px/frame^2) - ground_accel_frame2 = 0.0234375, + ground_accel_frame2 = 0.0234375, -- 1.5/64 -- ground active deceleration (brake) (px/frame^2) - ground_decel_frame2 = 0.25, + ground_decel_frame2 = 0.25, -- 16/64 -- ground friction (passive deceleration) (px/frame^2) - ground_friction_frame2 = 0.0234375, + ground_friction_frame2 = 0.0234375, -- 1.5/64 -- gravity acceleration (px/frame^2) - gravity_frame2 = 0.109375, + gravity_frame2 = 0.109375, -- 7/64 -- slope accel acceleration factor (px/frame^2), to multiply by sin(angle) - slope_accel_factor_frame2 = 0.0625, + slope_accel_factor_frame2 = 0.0625, -- 7/64 -- air acceleration on x axis (px/frames^2) - air_accel_x_frame2 = 0.046875, + air_accel_x_frame2 = 0.046875, -- 3/64 -- ground acceleration (px/frame) max_ground_speed = 3, -- initial variable jump speed (Sonic) (px/frame) - initial_var_jump_speed_frame = 3.25, + initial_var_jump_speed_frame = 3.25, -- 208/64 = 3 + 16/64 -- initial hop vertical speed and new speed when jump is interrupted by releasing jump button (px/frame) -- note that when jump is interrupted mid-air, gravity should still be applied just after that @@ -46,9 +49,11 @@ return { -- max vertical distance allowed to escape from inside ground (must be < tile_size as -- _compute_signed_distance_to_closest_ground uses it as upper_limit tile_size) + -- also the max step up of the character in ground motion max_ground_escape_height = 4, -- max vertical distance allowed to snap to a lower ground while running (on step or curve) + -- a.k.a. max step down max_ground_snap_height = 4, -- debug motion @@ -66,9 +71,30 @@ return { -- sprite -- stand right - sonic_sprite_data = { - idle = sprite_data(sprite_id_location(0, 2), tile_vector(2, 2), vector(12, 8)), - spin = sprite_data(sprite_id_location(2, 2), tile_vector(2, 2), vector(6, 6)), - } + sonic_sprite_data_table = { + idle = sprite_data(sprite_id_location(0, 8), tile_vector(2, 2), vector(11, 8)), + run1 = sprite_data(sprite_id_location(2, 8), tile_vector(2, 2), vector(11, 8)), + run2 = sprite_data(sprite_id_location(4, 8), tile_vector(2, 2), vector(11, 8)), + run3 = sprite_data(sprite_id_location(6, 8), tile_vector(2, 2), vector(11, 8)), + run4 = sprite_data(sprite_id_location(8, 8), tile_vector(2, 2), vector(11, 8)), + run5 = sprite_data(sprite_id_location(10, 8), tile_vector(2, 2), vector(11, 8)), + run6 = sprite_data(sprite_id_location(12, 8), tile_vector(2, 2), vector(11, 8)), + run7 = sprite_data(sprite_id_location(14, 8), tile_vector(2, 2), vector(11, 8)), + run8 = sprite_data(sprite_id_location(0, 10), tile_vector(2, 2), vector(11, 8)), + run9 = sprite_data(sprite_id_location(2, 10), tile_vector(2, 2), vector(11, 8)), + run10 = sprite_data(sprite_id_location(4, 10), tile_vector(2, 2), vector(11, 8)), + run11 = sprite_data(sprite_id_location(6, 10), tile_vector(2, 2), vector(11, 8)), + spin = sprite_data(sprite_id_location(0, 12), tile_vector(2, 2), vector(5, 5)), + }, + +} +-- define animated sprite data in a second step, as it needs sprite data to be defined first +playercharacter_data.sonic_animated_sprite_data_table = { + idle = animated_sprite_data.create(playercharacter_data.sonic_sprite_data_table, {"idle"}, 10, true), + run = animated_sprite_data.create(playercharacter_data.sonic_sprite_data_table, + {"run1", "run2", "run3", "run4", "run5", "run6", "run7", "run8", "run9", "run10", "run11"}, 5, true), + spin = animated_sprite_data.create(playercharacter_data.sonic_sprite_data_table, {"spin"}, 10, true), } + +return playercharacter_data diff --git a/src/game/data/stage_data.lua b/src/game/data/stage_data.lua index f53e5e64..c0e09915 100644 --- a/src/game/data/stage_data.lua +++ b/src/game/data/stage_data.lua @@ -20,7 +20,7 @@ return { title = "proto zone", width = 100, - height = 40, + height = 32, -- where the player character spawns on stage start spawn_location = location(2, 10), diff --git a/src/game/data/tile_data.lua b/src/game/data/tile_data.lua index edb2403e..086fbb52 100644 --- a/src/game/data/tile_data.lua +++ b/src/game/data/tile_data.lua @@ -1,10 +1,16 @@ -- this script is similar to tile_test_data, but has some parts -- useful for itest in pico8, whereas tile_test_data is only for busted utests/itests +-- it is used by tilemap for the dsl +--#ifn pico8 local tile_test_data = require("game/test_data/tile_test_data") +--#endif tile_symbol_to_ids = { - ['.'] = 0, -- empty - ['#'] = 64, -- full tile + ['.'] = 0, -- empty + ['#'] = 64, -- full tile + ['/'] = 65, -- ascending slope 45 + ['\\'] = 66, -- descending slope 45 + ['<'] = 67, -- ascending slope 22.5 } -- for itests that need map setup, we exceptionally not teardown @@ -21,15 +27,3 @@ function teardown_map_data() tile_test_data.teardown() --#endif end - --- clear map, using appropriate interface (pico8 or busted pico8api) -function clear_map() ---#ifn pico8 - pico8:clear_map() ---#endif - ---[[#pico8 - -- clear map data - memset(0x2000, 0, 0x1000) --- #pico8]] -end diff --git a/src/game/ingame/playercharacter.lua b/src/game/ingame/playercharacter.lua index 398349a3..596c2e60 100644 --- a/src/game/ingame/playercharacter.lua +++ b/src/game/ingame/playercharacter.lua @@ -2,10 +2,12 @@ require("engine/application/constants") require("engine/core/class") require("engine/core/helper") require("engine/core/math") -require("engine/render/sprite") -local collision = require("engine/physics/collision") -local world = require("engine/physics/world") +local input = require("engine/input/input") +local world = require("game/platformer/world") +local animated_sprite = require("engine/render/animated_sprite") local pc_data = require("game/data/playercharacter_data") +local motion = require("game/platformer/motion") + -- enum for character control control_modes = { @@ -14,6 +16,13 @@ control_modes = { puppet = 3 -- itest script controls character } +-- motion_modes and motion_states are accessed dynamically via variant name in itest_dsl +-- so we don't strip them away from pico8 builds +-- it is only used for debug and expectations, though, so it could be #if cheat/test only, +-- but the dsl may be used for attract mode later (dsl) so unless we distinguish +-- parsable types like motion_states that are only used for expectations (and cheat actions) +-- as opposed to actions, we should keep this in the release build + -- enum for character motion mode motion_modes = { platformer = 1, -- normal in-game @@ -26,6 +35,7 @@ motion_states = { airborne = 2 -- character is in the air } + local player_char = new_class() -- parameters @@ -35,49 +45,64 @@ local player_char = new_class() -- debug_move_accel float move acceleration in debug mode -- debug_move_decel float move deceleration in debug mode + +-- components + +-- anim_spr animated_sprite controls sprite animation, responsible for sprite rendering + + -- state vars --- control_mode control_modes control mode: human (default) or ai --- motion_mode motion_modes motion mode: platformer (under gravity) or debug (fly around) --- motion_state motion_states motion state (platformer mode only) --- position vector current position (character center "between" pixels) --- ground_speed float current speed along the ground (~px/frame) --- velocity vector current velocity in platformer mode (px/frame) --- debug_velocity vector current velocity in debug mode (m/s) --- slope_angle float slope angle of the current ground (clockwise turn ratio) --- move_intention vector current move intention (normalized) --- jump_intention bool current intention to start jump (consumed on jump) --- hold_jump_intention bool current intention to hold jump (always true when jump_intention is true) --- should_jump bool should the character jump when next frame is entered? used to delay variable jump/hop by 1 frame --- has_interrupted_jump bool has the character already interrupted his jump once? --- current_sprite string current sprite key in the spr_data +-- control_mode control_modes control mode: human (default) or ai +-- motion_mode (cheat) motion_modes motion mode: platformer (under gravity) or debug (fly around) +-- motion_state motion_states motion state (platformer mode only) +-- horizontal_dir horizontal_dirs direction faced by character +-- position vector current position (character center "between" pixels) +-- ground_speed float current speed along the ground (~px/frame) +-- velocity vector current velocity in platformer mode (px/frame) +-- debug_velocity vector current velocity in debug mode (m/s) +-- slope_angle float slope angle of the current ground (clockwise turn ratio) +-- move_intention vector current move intention (normalized) +-- jump_intention bool current intention to start jump (consumed on jump) +-- hold_jump_intention bool current intention to hold jump (always true when jump_intention is true) +-- should_jump bool should the character jump when next frame is entered? used to delay variable jump/hop by 1 frame +-- has_jumped_this_frame bool has the character started a jump/hop this frame? +-- has_interrupted_jump bool has the character already interrupted his jump once? +-- current_sprite string current sprite key in the spr_data function player_char:_init() self.spr_data = pc_data.sonic_sprite_data self.debug_move_max_speed = pc_data.debug_move_max_speed self.debug_move_accel = pc_data.debug_move_accel self.debug_move_decel = pc_data.debug_move_decel + self.anim_spr = animated_sprite(pc_data.sonic_animated_sprite_data_table) + self:_setup() end function player_char:_setup() self.control_mode = control_modes.human +--#if cheat self.motion_mode = motion_modes.platformer +--#endif self.motion_state = motion_states.grounded + self.horizontal_dir = horizontal_dirs.right self.position = vector.zero() self.ground_speed = 0. self.velocity = vector.zero() self.debug_velocity = vector.zero() - self.slope_angle = 0 + -- slope_angle starts at 0 instead of nil to match grounded state above (first spawn will set this anyway) + self.slope_angle = 0. self.move_intention = vector.zero() self.jump_intention = false self.hold_jump_intention = false self.should_jump = false + self.has_jumped_this_frame = false self.has_interrupted_jump = false - self.current_sprite = "idle" + self.anim_spr:play("idle") end -- spawn character at given position, and escape from ground / enter airborne state if needed @@ -124,13 +149,72 @@ function player_char:move_by(delta_vector) self.position = self.position + delta_vector end --- update player position function player_char:update() - if self.motion_mode == motion_modes.platformer then - self:_update_platformer_motion() - else -- self.motion_mode == motion_modes.debug + self:_handle_input() + self:_update_motion() + self.anim_spr:update() +end + +-- update intention based on current input +function player_char:_handle_input() + if self.control_mode == control_modes.human then + -- move + local player_move_intention = vector.zero() + + if input:is_down(button_ids.left) then + player_move_intention:add_inplace(vector(-1, 0)) + elseif input:is_down(button_ids.right) then + player_move_intention:add_inplace(vector(1, 0)) + end + + if input:is_down(button_ids.up) then + player_move_intention:add_inplace(vector(0, -1)) + elseif input:is_down(button_ids.down) then + player_move_intention:add_inplace(vector(0, 1)) + end + + self.move_intention = player_move_intention + + -- jump + local is_jump_input_down = input:is_down(button_ids.o) -- convenient var for optional pre-check + -- set jump intention each frame, don't set it to true for later consumption to avoid sticky input + -- without needing a reset later during update + self.jump_intention = is_jump_input_down and input:is_just_pressed(button_ids.o) + self.hold_jump_intention = is_jump_input_down + +--#if cheat + if input:is_just_pressed(button_ids.x) then + self:_toggle_debug_motion() + end +--#endif + end +end + +--#if cheat +function player_char:_toggle_debug_motion() + if self.motion_mode == motion_modes.debug then + -- respawn character at current position. this will in particular: + -- - set the motion mode back to platformer + -- - detect ground and update the motion state correctly + self:spawn_at(self.position) + else -- self.motion_mode == motion_modes.platformer + self.motion_mode = motion_modes.debug + self.debug_velocity = vector.zero() + end +end +--#endif + +-- update player position +function player_char:_update_motion() +--#if cheat + if self.motion_mode == motion_modes.debug then self:_update_debug() + return end + -- else: self.motion_mode == motion_modes.platformer +--#endif + + self:_update_platformer_motion() end -- return (signed_distance, slope_angle) where: @@ -170,7 +254,7 @@ function player_char:_compute_ground_sensors_signed_distance(center_position) end - return collision.ground_query_info(min_signed_distance, highest_ground_slope_angle) + return motion.ground_query_info(min_signed_distance, highest_ground_slope_angle) end @@ -190,6 +274,7 @@ end -- return the position of the ground sensor in horizontal_dir when the character center is at center_position -- subpixels are ignored function player_char:_get_ground_sensor_position_from(center_position, horizontal_dir) + -- ignore subpixels from center position in x local x_floored_center_position = vector(flr(center_position.x), center_position.y) local x_floored_bottom_center = x_floored_center_position + vector(0, pc_data.center_height_standing) @@ -217,13 +302,13 @@ function player_char:_compute_signed_distance_to_closest_ground(sensor_position) -- check the presence of a collider pixel from top to bottom, from max step up - 1 to min step up (we don't go until + 1 -- because if we found nothing until min step down, signed distance will be max step down + 1 anyway) - local query_info = collision.ground_query_info(pc_data.max_ground_snap_height + 1, nil) + local query_info = motion.ground_query_info(pc_data.max_ground_snap_height + 1, nil) for offset_y = -pc_data.max_ground_escape_height - 1, pc_data.max_ground_snap_height do local does_collide, slope_angle = world.get_pixel_collision_info(sensor_position.x, initial_y + offset_y) if does_collide then -- signed_distance is just the current offset, minus the initial subpixel fraction that we ignored for the pixel test iteration local fraction_y = sensor_position.y - initial_y - query_info = collision.ground_query_info(offset_y - fraction_y, slope_angle) -- slope_angle may still be nil if we are inside ground + query_info = motion.ground_query_info(offset_y - fraction_y, slope_angle) -- slope_angle may still be nil if we are inside ground break else -- optimization: use extra info from is_collision_pixel to skip pixels that we know are empty already thx to the column system @@ -262,14 +347,16 @@ function player_char:_enter_motion_state(next_motion_state) -- we have just left the ground, enter airborne state -- and since ground speed is now unused, reset it for clarity self.ground_speed = 0 + self.slope_angle = nil self.should_jump = false - self.current_sprite = "spin" + self.anim_spr:play("spin") elseif next_motion_state == motion_states.grounded then -- we have just reached the ground (and possibly escaped), -- reset values airborne vars self.velocity.y = 0 -- no velocity retain yet on y + self.has_jumped_this_frame = false -- optional since consumed immediately in _update_platformer_motion_airborne self.has_interrupted_jump = false - self.current_sprite = "idle" + self.anim_spr:play("idle") end end @@ -309,15 +396,31 @@ function player_char:_update_platformer_motion_grounded() self.position = ground_motion_result.position self.slope_angle = ground_motion_result.slope_angle + -- todo: reset jump intention on fall... we don't want character to cancel a natural fall by releasing jump button + -- (does not happen because of negative jump speed interrupt threshold, but could happen + -- once inertia is added by running off an ascending cliff) if ground_motion_result.is_falling then self:_enter_motion_state(motion_states.airborne) else + -- we are still grounded, so: + -- only allow jump preparation for next frame if not already falling self:_check_jump_intention() + + -- update ground animation based on speed + if self.ground_speed == 0 then + self.anim_spr:play("idle") + else + self.anim_spr:play("run", false, abs(self.ground_speed)) + end end log("self.position: "..self.position, "trace") + log("self.position.x (hex): "..tostr(self.position.x, true), "trace") + log("self.position.y (hex): "..tostr(self.position.y, true), "trace") log("self.velocity: "..self.velocity, "trace") + log("self.velocity.x (hex): "..tostr(self.velocity.x, true), "trace") + log("self.velocity.y (hex): "..tostr(self.velocity.y, true), "trace") log("self.ground_speed: "..self.ground_speed, "trace") end @@ -346,6 +449,7 @@ end -- update ground speed based on current move intention function player_char:_update_ground_speed_by_intention() if self.move_intention.x ~= 0 then + if self.ground_speed == 0 or sgn(self.ground_speed) == sgn(self.move_intention.x) then -- accelerate self.ground_speed = self.ground_speed + self.move_intention.x * pc_data.ground_accel_frame2 @@ -359,6 +463,14 @@ function player_char:_update_ground_speed_by_intention() self.ground_speed = sgn(self.ground_speed) * pc_data.ground_accel_frame2 end end + + if self.ground_speed ~= 0 then + -- always update direction when player tries to move and the character is moving after update + -- this is useful even when move intention x has same sign as ground speed, + -- as the character may be running backward after failing to run a steep slope up + self.horizontal_dir = signed_speed_to_dir(self.ground_speed) + end + elseif self.ground_speed ~= 0 then -- friction self.ground_speed = sgn(self.ground_speed) * max(0, abs(self.ground_speed) - pc_data.ground_friction_frame2) @@ -379,7 +491,7 @@ end function player_char:_compute_ground_motion_result() -- if character is not moving, he is not blocked nor falling (we assume the environment is static) if self.ground_speed == 0 then - return collision.ground_motion_result( + return motion.ground_motion_result( self.position, self.slope_angle, false, @@ -389,50 +501,70 @@ function player_char:_compute_ground_motion_result() local horizontal_dir = signed_speed_to_dir(self.ground_speed) - -- initialise result - local motion_result = collision.ground_motion_result( - vector(flr(self.position.x), self.position.y), + -- initialise result with floored x (we will reinject subpixels if character didn't touch a wall) + -- note that left and right are not completely symmetrical since floor is asymmetrical + local floored_x = flr(self.position.x) + local motion_result = motion.ground_motion_result( + vector(floored_x, self.position.y), self.slope_angle, false, false ) - -- fixme: consider slope angle cos to reduce motion along x - -- only full pixels matter for collisions, but subpixels may sum up to a full pixel -- so first estimate how many full pixel columns the character may actually explore this frame - local distance_x = self.ground_speed * cos(self.slope_angle) - local max_column_distance = player_char._compute_max_column_distance(self.position.x, distance_x) + local signed_distance_x = self.ground_speed * cos(self.slope_angle) + local max_column_distance = player_char._compute_max_pixel_distance(self.position.x, signed_distance_x) -- iterate pixel by pixel on the x direction until max possible distance is reached -- only stopping if the character is blocked by a wall (not if falling, since we want -- him to continue moving in the air as far as possible; in edge cases, he may even -- touch the ground again some pixels farther) - local column_distance = 1 - while column_distance <= max_column_distance and not motion_result.is_blocked do + local column_distance_before_step = 0 + while column_distance_before_step < max_column_distance and not motion_result.is_blocked do self:_next_ground_step(horizontal_dir, motion_result) - column_distance = column_distance + 1 + column_distance_before_step = column_distance_before_step + 1 end -- check if we need to add or cut subpixels if not motion_result.is_blocked then - local are_subpixels_left = self.position.x + distance_x > motion_result.position.x + -- local max_distance_x = abs(signed_distance_x) + -- local distance_to_floored_x = abs(motion_result.position.x - floored_x) + -- since character was not blocked, we know that we have reached a column distance of max_column_distance + -- local are_subpixels_left = max_distance_x > distance_to_floored_x + -- since subpixels are always counted to the right, the subpixel test below is asymmetrical + -- but this is correct, we will simply move backward a bit when moving left + local are_subpixels_left = self.position.x + signed_distance_x > motion_result.position.x + if are_subpixels_left then -- character has not been blocked and has some subpixels left to go - -- check if character has touched a wall (we need an extra step to "ceil" the subpixels) - local extra_step_motion_result = motion_result:copy() - self:_next_ground_step(horizontal_dir, extra_step_motion_result) - if extra_step_motion_result.is_blocked then - -- character has just reached a wall, plus a few subpixels - -- unlike Classic Sonic, we decide to cut the subpixels and block the character - -- on the spot (we just reuse the result of the extra step, since is_falling doesn't change if is_blocked is true) - motion_result = extra_step_motion_result - else + -- unlike Classic Sonic, and *only* when moving right, we decide to check if those + -- subpixels would leak to hitting a wall on the right, and cut them if so, + -- blocking the character on the spot (we just reuse the result of the extra step, + -- since is_falling doesn't change if is_blocked is true) + -- when moving left, the subpixels are a small "backward" motion to the right and should + -- never hit a wall back + local is_blocked_by_extra_step = false + if signed_distance_x > 0 then + local extra_step_motion_result = motion_result:copy() + self:_next_ground_step(horizontal_dir, extra_step_motion_result) + if extra_step_motion_result.is_blocked then + motion_result = extra_step_motion_result + is_blocked_by_extra_step = true + end + end + + -- unless moving right and hitting a wall due to subpixels, apply the remaining subpixels + -- as they cannot affect collision anymore. when moving left, they go a little backward + if not is_blocked_by_extra_step then -- character has not touched a wall at all, so add the remaining subpixels - -- (it's simpler to just recompute the full motion in x; don't touch y tough, - -- as it depends on the shape of the ground) - -- also set the slope since we may have moved to another tile - motion_result.position.x = self.position.x + distance_x + -- (it's simpler to just recompute the full motion in x; don't touch y tough, + -- as it depends on the shape of the ground) + -- do not apply other changes (like slope) since technically we have not reached + -- the next tile yet, only advanced of some subpixels + -- note that this calculation equivalent to adding to ref_motion_result.position[coord] + -- sign(signed_distance_x) * (max_distance_x - distance_to_floored_x) + motion_result.position.x = self.position.x + signed_distance_x end end end @@ -440,23 +572,27 @@ function player_char:_compute_ground_motion_result() return motion_result end --- return the number of new pixel columns explored when moving from initial_position_x --- over ground_speed * 1 frame. this is either flr(ground_speed) --- or flr(ground_speed) + 1 (if subpixels from initial position x and speed sum up to 1.0 or more) -function player_char._compute_max_column_distance(initial_position_x, ground_speed) - return abs(flr(initial_position_x + ground_speed) - flr(initial_position_x)) +-- return the number of new pixel columns explored when moving from initial_position_coord (x or y) +-- over velocity_coord (x or y) * 1 frame. consider full pixel motion starting at floored coord, +-- even when moving in the negative direction +-- this is either flr(velocity_coord) +-- or flr(velocity_coord) + 1 (if subpixels from initial position coord and speed sum up to 1.0 or more) +-- note that for negative motion, we must go a bit beyond the next integer to count a full pixel motion, +-- and that is intended +function player_char._compute_max_pixel_distance(initial_position_coord, velocity_coord) + return abs(flr(initial_position_coord + velocity_coord) - flr(initial_position_coord)) end --- update motion_result: collision.ground_motion_result for a character trying to move +-- update ref_motion_result: motion.ground_motion_result for a character trying to move -- by 1 pixel step in horizontal_dir, taking obstacles into account -- if character is blocked, it doesn't update the position and flag is_blocked --- if character is fallling, it updates the position and flag is_falling +-- if character is falling, it updates the position and flag is_falling -- ground_motion_result.position.x should be floored for these steps --- although _compute_ground_sensors_signed_distance will floor in x anyway -function player_char:_next_ground_step(horizontal_dir, motion_result) +-- (some functions assert when giving subpixel coordinates) +function player_char:_next_ground_step(horizontal_dir, ref_motion_result) -- compute candidate position on next step. only flat slopes supported local step_vec = horizontal_dir_vectors[horizontal_dir] - local next_position_candidate = motion_result.position + step_vec + local next_position_candidate = ref_motion_result.position + step_vec -- check if next position is inside/above ground local query_info = self:_compute_ground_sensors_signed_distance(next_position_candidate) @@ -468,12 +604,12 @@ function player_char:_next_ground_step(horizontal_dir, motion_result) -- step up next_position_candidate.y = next_position_candidate.y - penetration_height -- if we left the ground during a previous step, cancel that (step up land, very rare) - motion_result.is_falling = false + ref_motion_result.is_falling = false else -- step blocked: step up is too high, character is blocked -- if character left the ground during a previous step, let it this way; -- character will simply hit the wall, then fall - motion_result.is_blocked = true + ref_motion_result.is_blocked = true end elseif signed_distance_to_closest_ground > 0 then -- position is above ground, check if we can step down during this step @@ -481,37 +617,43 @@ function player_char:_next_ground_step(horizontal_dir, motion_result) -- step down next_position_candidate.y = next_position_candidate.y + signed_distance_to_closest_ground -- if character left the ground during a previous step, cancel that (step down land, very rare) - motion_result.is_falling = false + ref_motion_result.is_falling = false else -- step fall: step down is too low, character will fall -- in some rare instances, character may find ground again farther, so don't stop the outside loop yet - motion_result.is_falling = true + -- caution: we are not updating y at all, which means the character starts + -- "walking horizontally in the air". in sonic games, we would expect + -- momentum to take over and send the character upward/downward, preserving + -- velocity y from last frame + -- so when adding momentum, consider reusing the last delta y (e.g. signed_distance_to_closest_ground) + -- and applying it this frame + ref_motion_result.is_falling = true end else -- step flat -- if character left the ground during a previous step, cancel that (very rare) - motion_result.is_falling = false + ref_motion_result.is_falling = false end -- character is not blocked by a steep step up/wall, but we need to check if it is -- blocked by a ceiling too low; in the extreme case, a diagonal tile pattern -- ->X -- X - if not motion_result.is_blocked then - motion_result.is_blocked = self:_is_blocked_by_ceiling_at(next_position_candidate) + if not ref_motion_result.is_blocked then + ref_motion_result.is_blocked = self:_is_blocked_by_ceiling_at(next_position_candidate) -- only advance if character is still not blocked (else, preserve previous position, -- which should be floored) - -- this only works because the wall sensors are 1px farther from the character - -- center than the ground sensors; if there were even farther, we'd even need to + -- this only works because the wall sensors are 1px farther from the character center + -- than the ground sensors; if there were even farther, we'd even need to -- move the position backward by hypothetical wall_sensor_extent_x - ground_sensor_extent_x - 1 - -- when motion_result.is_blocked (and adapt y) - if not motion_result.is_blocked then - motion_result.position = next_position_candidate - if motion_result.is_falling then - motion_result.slope_angle = nil + -- when ref_motion_result.is_blocked (and adapt y) + if not ref_motion_result.is_blocked then + ref_motion_result.position = next_position_candidate + if ref_motion_result.is_falling then + ref_motion_result.slope_angle = nil else - motion_result.slope_angle = next_slope_angle + ref_motion_result.slope_angle = next_slope_angle end end end @@ -543,7 +685,7 @@ end -- so the step up itself will be ignored (e.g. when moving from a flat ground to an ascending slope) function player_char._is_column_blocked_by_ceiling_at(sensor_position) - assert(flr(sensor_position.x) == sensor_position.x, "player_char:_is_blocked_by_ceiling_at_column: sensor_position.x must be floored") + assert(flr(sensor_position.x) == sensor_position.x, "player_char:_is_column_blocked_by_ceiling_at: sensor_position.x must be floored") -- find the tile where this sensor is located local curr_tile_loc = sensor_position:to_location() @@ -563,6 +705,14 @@ function player_char._is_column_blocked_by_ceiling_at(sensor_position) -- (90 and 270-rotated tiles will be ignored as they are not supposed to block the character's head) curr_tile_loc.j = curr_tile_loc.j - 1 local current_tile_top = curr_tile_loc:to_topleft_position().y + local current_tile_bottom = current_tile_top + tile_size + + -- if the bottom of next ceiling to check is already higher than, or equal to + -- one character height, if cannot block him, so return false + local height_distance = sensor_position.y - current_tile_bottom + if height_distance >= pc_data.full_height_standing then + return false + end local ground_array_height, _ = world._compute_column_height_at(curr_tile_loc, column_index0) if ground_array_height ~= nil and ground_array_height > 0 then @@ -573,16 +723,10 @@ function player_char._is_column_blocked_by_ceiling_at(sensor_position) -- with ceiling tiles, we will need to check if the ceiling column height -- hits the head or not. if it doesn't stop here, return false, -- the head is below the ceiling: - -- local current_tile_bottom = current_tile_top + tile_size -- local height_distance = sensor_position.y - current_tile_bottom -- return height_distance < pc_data.full_height_standing end - local height_distance = sensor_position.y - current_tile_top - if height_distance >= pc_data.full_height_standing then - return false - end - end end @@ -592,7 +736,8 @@ end -- depending whether input is hold or not function player_char:_check_jump_intention() if self.jump_intention then - -- jump_intention is set each frame, no need to consume it here + -- consume intention so puppet control mode (which is sticky) also works + self.jump_intention = false self.should_jump = true end end @@ -604,22 +749,15 @@ function player_char:_check_jump() if self.should_jump then self.should_jump = false - -- compute initial jump speed based on whether player is still holding jump button - local initial_jump_speed - if self.hold_jump_intention then - -- variable jump - initial_jump_speed = pc_data.initial_var_jump_speed_frame - else - -- hop - initial_jump_speed = pc_data.jump_interrupt_speed_frame - -- mark jump as interrupted so we don't check it again - -- (optional, since we will never be able to interrupt such a small jump anyway) - self.has_interrupted_jump = true - end - - -- only support flat ground for now - self.velocity.y = self.velocity.y - initial_jump_speed + -- apply initial jump speed for variable jump + -- note: if the player is doing a hop, the vertical speed will be reset + -- to the interrupt speed during the same frame in _update_platformer_motion_airborne + -- via _check_hold_jump (we don't do it here so we centralize the check and + -- don't apply gravity during such a frame) + -- limitation: only support flat ground for now + self.velocity.y = self.velocity.y - pc_data.initial_var_jump_speed_frame self:_enter_motion_state(motion_states.airborne) + self.has_jumped_this_frame = true return true end return false @@ -627,24 +765,47 @@ end -- update motion following platformer airborne motion rules function player_char:_update_platformer_motion_airborne() - -- check if player is continuing or interrupting jump *before* applying gravity - -- since our pc_data.jump_interrupt_speed_frame is defined to be applied before gravity + if self.has_jumped_this_frame then + -- do not apply gravity on first frame of jump, and consume has_jumped_this_frame + self.has_jumped_this_frame = false + else + -- apply gravity to current speed y + self.velocity.y = self.velocity.y + pc_data.gravity_frame2 + end + + -- check if player is continuing or interrupting jump *after* applying gravity + -- this means gravity will *not* be applied during the hop/interrupt jump frame self:_check_hold_jump() - -- apply gravity to current speed y - self.velocity.y = self.velocity.y + pc_data.gravity_frame2 + if self.move_intention.x ~= 0 then + -- apply x acceleration via intention (if not 0) + self.velocity.x = self.velocity.x + self.move_intention.x * pc_data.air_accel_x_frame2 - -- apply x acceleration via intention (if not 0) - self.velocity.x = self.velocity.x + self.move_intention.x * pc_data.air_accel_x_frame2 + -- in the air, apply intended motion to direction immediately + self.horizontal_dir = signed_speed_to_dir(self.move_intention.x) + end -- apply air motion - self:move_by(self.velocity) - -- detect ground and snap up for landing - local has_landed = self:_check_escape_from_ground() - if has_landed then + local air_motion_result = self:_compute_air_motion_result() + + self.position = air_motion_result.position + + if air_motion_result.is_blocked_by_wall then + self.velocity.x = 0 + end + + if air_motion_result.is_blocked_by_ceiling then + self.velocity.y = 0 + end + + if air_motion_result.is_landing then + self.slope_angle = air_motion_result.slope_angle self:_enter_motion_state(motion_states.grounded) end + + log("self.position: "..self.position, "trace") + log("self.velocity: "..self.velocity, "trace") end -- check if character wants to interrupt jump by not holding anymore, @@ -658,11 +819,224 @@ function player_char:_check_hold_jump() -- character tries to interrupt jump, check if's not too late local signed_jump_interrupt_speed_frame = -pc_data.jump_interrupt_speed_frame if self.velocity.y < signed_jump_interrupt_speed_frame then + log("interrupt jump "..self.velocity.y.." -> "..signed_jump_interrupt_speed_frame, "trace") self.velocity.y = signed_jump_interrupt_speed_frame end end end +-- return {next_position: vector, is_blocked_by_ceiling: bool, is_blocked_by_wall: bool, is_landing: bool} where +-- - next_position is the position of the character next frame considering his current (air) velocity +-- - is_blocked_by_ceiling is true iff the character encounters a ceiling during this motion +-- - is_blocked_by_wall is true iff the character encounters a wall during this motion +-- - is_landing is true iff the character touches a ground from above during this motion +function player_char:_compute_air_motion_result() + -- if character is not moving, he is not blocked nor landing (we assume the environment is static) + if self.velocity == vector.zero() then + return motion.air_motion_result( + self.position, + false, + false, + false, + nil + ) + end + + -- initialize air motion result (do not floor coordinates, _advance_in_air_along will do it) + local motion_result = motion.air_motion_result( + vector(self.position.x, self.position.y), + false, + false, + false, + nil + ) + + -- from here, unlike ground motion, there are 3 ways to iterate: + -- a. describe a Bresenham's line, stepping on x and y, for the best precision + -- b. step on x until you reach the max distance x, then step on y (may hit wall you wouldn't have with a. or c.) + -- c. step on y until you reach the max distance y, then step on x (may hit ceiling you wouldn't have with a. or b.) + + -- we focus on landing/ceiling first, and prefer simplicity to precision as long as motion seems ok, + -- so we choose c. + self:_advance_in_air_along(motion_result, self.velocity, "y") + log("=> "..motion_result, "trace") + self:_advance_in_air_along(motion_result, self.velocity, "x") + log("=> "..motion_result, "trace") + + return motion_result +end + +-- TODO: factorize with _compute_ground_motion_result +-- modifies ref_motion_result in-place, setting it to the result of an air motion from ref_motion_result.position +-- over velocity[coord] px, where coord is "x" or "y" +function player_char:_advance_in_air_along(ref_motion_result, velocity, coord) + log("_advance_in_air_along: "..joinstr(", ", ref_motion_result, velocity, coord), "trace") + + if velocity[coord] == 0 then return end + + -- only full pixels matter for collisions, but subpixels may sum up to a full pixel + -- so first estimate how many full pixel columns the character may actually explore this frame + local initial_position_coord = ref_motion_result.position[coord] + local max_pixel_distance = player_char._compute_max_pixel_distance(initial_position_coord, velocity[coord]) + + -- floor coordinate to simplify step by step pixel detection (mostly useful along x to avoid + -- flooring every time we query column heights) + -- since initial_position_coord is storing the original position with subpixels, we are losing information + ref_motion_result.position[coord] = flr(ref_motion_result.position[coord]) + + -- iterate pixel by pixel on the x direction until max possible distance is reached + -- only stopping if the character is blocked by a wall (not if falling, since we want + -- him to continue moving in the air as far as possible; in edge cases, he may even + -- touch the ground again some pixels farther) + local direction + if coord == "x" then + direction = directions.right + else + direction = directions.down + end + if velocity[coord] < 0 then + direction = oppose_dir(direction) + end + + local pixel_distance_before_step = 0 + while pixel_distance_before_step < max_pixel_distance and not ref_motion_result:is_blocked_along(direction) do + self:_next_air_step(direction, ref_motion_result) + log(" => "..ref_motion_result, "trace") + pixel_distance_before_step = pixel_distance_before_step + 1 + end + + -- check if we need to add or cut subpixels + if not ref_motion_result:is_blocked_along(direction) then + -- since subpixels are always counted to the right, the subpixel test below is asymmetrical + -- but this is correct, we will simply move backward a bit when moving left + local are_subpixels_left = initial_position_coord + velocity[coord] > ref_motion_result.position[coord] + -- local are_subpixels_left = initial_position_coord + max_pixel_distance > ref_motion_result.position[coord] + if are_subpixels_left then + -- character has not been blocked and has some subpixels left to go + -- *only* when moving in the positive sense (right/up), + -- as a way to clean the subpixels unlike classic sonic, + -- we check if character is theoretically colliding a wall with those subpixels + -- (we need an extra step to "ceil" the subpixels) + -- when moving in the negative sense, the subpixels are a small "backward" motion + -- to the positive sense and should + -- never hit a wall back + local is_blocked_by_extra_step = false + if velocity[coord] > 0 then + local extra_step_motion_result = ref_motion_result:copy() + self:_next_air_step(direction, extra_step_motion_result) + log(" => "..ref_motion_result, "trace") + if extra_step_motion_result:is_blocked_along(direction) then + -- character has just reached a wall, plus a few subpixels + -- unlike classic sonic, we decide to cut the subpixels and block the character + -- on the spot (we just reuse the result of the extra step, since is_falling doesn't change if is_blocked is true) + -- it's very important to keep the reference and assign member values instead + ref_motion_result:copy_assign(extra_step_motion_result) + is_blocked_by_extra_step = true + end + end + + if not is_blocked_by_extra_step then + -- character has not touched a wall at all, so add the remaining subpixels + -- (it's simpler to just recompute the full motion in x; don't touch y tough, + -- as it depends on the shape of the ground) + -- do not apply other changes (like slope) since technically we have not reached + -- the next tile yet, only advanced of some subpixels + -- note that this calculation equivalent to adding to ref_motion_result.position[coord] + -- sign(velocity[coord]) * (max_distance - distance_to_floored_coord) + ref_motion_result.position[coord] = initial_position_coord + velocity[coord] + end + end + end +end + +-- update ref_motion_result: motion.air_motion_result for a character trying to move +-- by 1 pixel step in direction in the air, taking obstacles into account +-- if character is blocked by wall, ceiling or landing when moving toward left/right, up or down resp., +-- it doesn't update the position and the corresponding flag is set +-- air_motion_result.position.x/y should be floored for these steps +function player_char:_next_air_step(direction, ref_motion_result) + log(" _next_air_step: "..joinstr(", ", direction, ref_motion_result), "trace") + + local step_vec = dir_vectors[direction] + local next_position_candidate = ref_motion_result.position + step_vec + + log("direction: "..direction, "trace") + log("step_vec: "..step_vec, "trace") + log("next_position_candidate: "..next_position_candidate, "trace") + + -- we can only hit walls or the ground when moving left, right or down + if direction ~= directions.up then + -- query ground to check for obstacles (we only care about distance, not slope angle) + -- note that we reuse the ground sensors for air motion, because they are good at finding + -- collisions around the bottom left/right corners + local query_info = self:_compute_ground_sensors_signed_distance(next_position_candidate) + local signed_distance_to_closest_ground, next_slope_angle = query_info.signed_distance, query_info.slope_angle + + log("signed_distance_to_closest_ground: "..signed_distance_to_closest_ground, "trace") + + -- check if the character has hit a ground or a wall + if signed_distance_to_closest_ground < 0 then + -- we do not activate step up during air motion, so any pixel above the character's bottom + -- is considered a hard obstacle + -- depending on the direction, we consider we were blocked by either a ceiling or a wall + if direction == directions.down then + -- landing: the character has just set foot on ground, flag it and initialize slope angle + -- note that we only consider the character to touch ground when it is about to enter it + -- therefore, if he exactly reaches signed_distance_to_closest_ground == 0 this frame, + -- it is still technically considered in the air + -- if this step is blocked by landing, there is no extra motion, + -- but character will enter grounded state + ref_motion_result.is_landing = true + ref_motion_result.slope_angle = next_slope_angle + else + ref_motion_result.is_blocked_by_wall = true + log("is blocked by wall", "trace") + end + elseif signed_distance_to_closest_ground > 0 then + -- in the air: the most common case, in general requires nothing to do + -- in rare cases, the character has landed on a previous step, and we must cancel that now + ref_motion_result.is_landing = false + ref_motion_result.slope_angle = nil + elseif ref_motion_result.is_landing then + -- if we enter this, direction must be horizontal, so update slope angle with new ground + ref_motion_result.slope_angle = next_slope_angle + log("is landing, setting slope angle to "..next_slope_angle, "trace") + end + end + + -- we can only hit ceiling when moving left, right or up + -- note that the ceiling check is necessary during horizontal motion to complement + -- ground sensors, the edge case being when the bottom of the character matches + -- the bottom of a collision tile, ground sensors could only detect the tile below + -- if we have already found a blocker above (only possible for left and right), + -- then there is no need to check further, though + if direction ~= directions.down and not ref_motion_result.is_blocked_by_wall then + local is_blocked_by_ceiling_at_next = self:_is_blocked_by_ceiling_at(next_position_candidate) + if is_blocked_by_ceiling_at_next then + if direction == directions.up then + ref_motion_result.is_blocked_by_ceiling = true + log("is blocked by ceiling", "trace") + else + -- we would be blocked by ceiling on the next position, but since we can't even go there, + -- we are actually blocked by the wall preventing the horizontal move + ref_motion_result.is_blocked_by_wall = true + end + end + end + + -- only advance if character is still not blocked (else, preserve previous position, + -- which should be floored) + if not ref_motion_result:is_blocked_along(direction) then + -- this only works because the wall sensors are 1px farther from the character center + -- than the ground sensors; if there were even farther, we'd even need to + -- move the position backward by hypothetical wall_sensor_extent_x - ground_sensor_extent_x - 1 + -- when ref_motion_result:is_blocked_along() (and adapt y) + ref_motion_result.position = next_position_candidate + end +end + +--#if cheat + -- update the velocity and position of the character following debug motion rules function player_char:_update_debug() self:_update_velocity_debug() @@ -676,6 +1050,8 @@ function player_char:_update_velocity_debug() self:_update_velocity_component_debug("y") end +--#endif + -- update the velocity component for coordinate "x" or "y" with debug motion -- coord string "x" or "y" function player_char:_update_velocity_component_debug(coord) @@ -694,7 +1070,8 @@ end -- render the player character sprite at its current position function player_char:render() - self.spr_data[self.current_sprite]:render(self.position) + local flip_x = self.horizontal_dir == horizontal_dirs.left + self.anim_spr:render(self.position, flip_x) end return player_char diff --git a/src/game/ingame/stage.lua b/src/game/ingame/stage.lua index 97a2b327..22e27961 100644 --- a/src/game/ingame/stage.lua +++ b/src/game/ingame/stage.lua @@ -2,13 +2,12 @@ require("engine/core/class") require("engine/core/coroutine") require("engine/core/math") require("engine/render/color") -local player_char = require("game/ingame/playercharacter") -local gamestate = require("game/application/gamestate") local flow = require("engine/application/flow") -local audio = require("game/resources/audio") -local input = require("engine/input/input") local ui = require("engine/ui/ui") +local player_char = require("game/ingame/playercharacter") +local gamestate = require("game/application/gamestate") local stage_data = require("game/data/stage_data") +local audio = require("game/resources/audio") local stage = { @@ -80,7 +79,6 @@ function state:update() self:update_coroutines() if self.current_substate == stage.substates.play then - self:handle_input() self.player_char:update() self:check_reached_goal() self:update_camera() @@ -148,39 +146,6 @@ function state:spawn_player_char() end --- input - --- refactor: move to player_character --- handle player input -function state:handle_input() - if self.player_char.control_mode == control_modes.human then - -- move - local player_move_intention = vector.zero() - - if input:is_down(button_ids.left) then - player_move_intention:add_inplace(vector(-1, 0)) - elseif input:is_down(button_ids.right) then - player_move_intention:add_inplace(vector(1, 0)) - end - - if input:is_down(button_ids.up) then - player_move_intention:add_inplace(vector(0, -1)) - elseif input:is_down(button_ids.down) then - player_move_intention:add_inplace(vector(0, 1)) - end - - self.player_char.move_intention = player_move_intention - - -- jump - local is_jump_input_down = input:is_down(button_ids.o) -- convenient var for optional pre-check - -- set jump intention each frame, don't set it to true for later consumption to avoid sticky input - -- without needing a reset later during update - self.player_char.jump_intention = is_jump_input_down and input:is_just_pressed(button_ids.o) - self.player_char.hold_jump_intention = is_jump_input_down -- set each frame - end -end - - -- gameplay events function state:check_reached_goal() diff --git a/src/game/itest_main.lua b/src/game/itest_main.lua index 7e75b461..d0357e79 100644 --- a/src/game/itest_main.lua +++ b/src/game/itest_main.lua @@ -4,18 +4,28 @@ -- to use for the build. other states will be replaced with dummy equivalents. require("engine/test/integrationtest") +local picosonic_app = require("game/application/picosonic_app") require("game/itests/itest$itest") local gamestate_proxy = require("game/application/gamestate_proxy") -local input = require("engine/input/input") --#if log local logging = require("engine/debug/logging") logging.logger:register_stream(logging.console_log_stream) +logging.logger:register_stream(logging.file_log_stream) --#endif +local codetuner = require("engine/debug/codetuner") + local current_itest_index = 0 function _init() +--#if log + -- clear log file on new itest session + logging.file_log_stream:clear() +--#endif + + itest_runner.app = picosonic_app() + -- require only gamestate modules written on first line of the required $itest (pico8-build way) gamestate_proxy:require_gamestates() @@ -25,27 +35,53 @@ end function _update60() handle_input() - integration_test_runner:update_game_and_test() + itest_runner:update_game_and_test() end function _draw() - integration_test_runner:draw_game_and_test() + itest_runner:draw_game_and_test() end function init_game_and_start_next_itest() - if #itest_manager.itests > current_itest_index then - current_itest_index = current_itest_index + 1 - itest_manager:init_game_and_start_by_index(current_itest_index) + init_game_and_start_itest_by_relative_index(1) +end + +function init_game_and_start_itest_by_relative_index(delta) + -- clamp new index + local new_index = mid(1, current_itest_index + delta, #itest_manager.itests) + -- check that an effective idnex change occurs + if new_index ~= current_itest_index then + current_itest_index = new_index + itest_manager:init_game_and_start_by_index(new_index) end end +-- press left/right to navigate freely in itests, even if not finished +-- press x to skip itest only if finished function handle_input() - if integration_test_runner.current_state == test_states.success or - integration_test_runner.current_state == test_states.failure or - integration_test_runner.current_state == test_states.timeout then - -- previous itest has finished, wait for x press to continue - -- to next itest - -- since input.mode is simulated during itests, use pico8 api directly + -- since input.mode is simulated during itests, use pico8 api directly for input + if btnp(button_ids.left) then + -- go back to previous itest + init_game_and_start_itest_by_relative_index(-1) + return + elseif btnp(button_ids.right) then + -- skip current itest + init_game_and_start_next_itest() + return + elseif btnp(button_ids.up) then + -- go back 10 itests + init_game_and_start_itest_by_relative_index(-10) + return + elseif btnp(button_ids.down) then + -- skip many itests + init_game_and_start_itest_by_relative_index(10) + return + end + + if itest_runner.current_state == test_states.success or + itest_runner.current_state == test_states.failure or + itest_runner.current_state == test_states.timeout then + -- previous itest has finished, wait for x press to continue to next itest if btnp(button_ids.x) then init_game_and_start_next_itest() end diff --git a/src/game/itests/itest_dsl.lua b/src/game/itests/itest_dsl.lua new file mode 100644 index 00000000..b30d37d5 --- /dev/null +++ b/src/game/itests/itest_dsl.lua @@ -0,0 +1,671 @@ +--[[ itest domain-specific language definition and parser + +usage example: + +``` +itest_dsl_parser.register('debug move right', [[ +@stage # +... +### + +warp 4 8 +move right +wait 30 +expect pc_bottom_pos 14. 8. +``` + +list of commands: + +warp x y warp player character bottom to (x, y) +set_motion_mode mode set motion mode (do it before warping to avoid + unwanted position adjustment on arrival) +move dir set sticky pc move intention toward [dir] +wait n wait [n] frames +expect gp_value_type expect a gameplay value to be equal to (...) + (value params...) + +--]] + +require("engine/core/helper") +require("engine/test/assertions") +local integrationtest = require("engine/test/integrationtest") +local itest_manager, integration_test = get_members(integrationtest, + "itest_manager", "integration_test") + +local tile_data = require("game/data/tile_data") +local tilemap = require("engine/data/tilemap") + +-- dsl interpretation requirements +local flow = require("engine/application/flow") +local input = require("engine/input/input") +local stage = require("game/ingame/stage") +local player_char = require("game/ingame/playercharacter") +local pc_data = require("game/data/playercharacter_data") + + +-- module +local itest_dsl = {} + +local value_parsers +local executors +local evaluators + +-- struct holding data on a gameplay value for expectations + +-- attributes +-- name string descriptive name of the gameplay value (to debug failing itests) +-- parsable_type parsable_type type of gameplay value (for expected args parsing) +local gameplay_value_data = new_struct() +itest_dsl.gameplay_value_data = gameplay_value_data + +function gameplay_value_data:_init(name, parsable_type, eval) + self.name = name + self.parsable_type = parsable_type +end + + +-- optimize tokens: if this is too much, remove proxy function tables +-- altogether and directly access functions via itest_dsl[prefix..type_name] +-- (this requires to keep the enum_strings table in config with #itest) +-- return table containing functions named {prefix}{enum_type_name} +-- inside a module, indexed by enum value +local function generate_function_table(module, enum_types, prefix) + local t = {} + for type_name, enum_type in pairs(enum_types) do + t[enum_type] = module[prefix..type_name] + end + return t +end +--#if utest +itest_dsl.generate_function_table = generate_function_table +--#endif + +-- type of variables that can be parsed +parsable_types = enum { + "none", + "number", + "vector", + "horizontal_dir", + "control_mode", + "motion_mode", + "motion_state", + "button_id", + "gp_value", -- meta-type compounded of [gp_value_type, gp_value_args...] where gp_value_args depend on gp_value_type +} + +--#if assert +parsable_type_strings = invert_table(parsable_types) +--#endif + + +-- type of commands available +command_types = enum { + "warp", -- warp player character bottom args: {bottom_position: vector} + "set", -- set gameplay value args: {gp_value_type_str: string, new_value_args...: matching gp value parsable type} + "set_control_mode", -- set control mode args: {control_mode_str: control_modes key} + "set_motion_mode", -- set motion mode args: {motion_mode_str: motion_modes key} + "move", -- set sticky pc move intention args: {move_dir_str: horizontal_dirs key} + "stop", -- stop moving horizontally args: {} + "jump", -- start and hold jump args: {} + "stop_jump", -- stop any jump intention args: {} + -- todo: crouch, spin_dash + "press", -- press and hold button args: {button_id_str: button_ids key} + "release", -- release button args: {button_id_str: button_ids key} + "wait", -- wait some frames args: {frames: int} + "expect", -- expect a gameplay value args: {gp_value_type: gp_value_types, expected_args...: matching gp value parsable type} +} + +--#if assert +command_type_strings = invert_table(command_types) +--#endif + +-- argument types expected after those commands +command_arg_types = { + [command_types.warp] = parsable_types.vector, + [command_types.set] = parsable_types.gp_value, + [command_types.set_control_mode] = parsable_types.control_mode, + [command_types.set_motion_mode] = parsable_types.motion_mode, + [command_types.move] = parsable_types.horizontal_dir, + [command_types.stop] = parsable_types.none, + [command_types.jump] = parsable_types.none, + [command_types.stop_jump] = parsable_types.none, + [command_types.press] = parsable_types.button_id, + [command_types.release] = parsable_types.button_id, + [command_types.wait] = parsable_types.number, + [command_types.expect] = parsable_types.gp_value, +} + + +-- type of gameplay values available for expectations +gp_value_types = enum { + "pc_bottom_pos", -- bottom position of player character + "pc_velocity", -- velocity of player character + "pc_ground_spd", -- ground speed of player character + "pc_motion_state", -- motion state of player character + "pc_slope", -- current slope on which player character is grounded +} + + +-- data for each gameplay value type +local gp_value_data_t = { + [gp_value_types.pc_bottom_pos] = gameplay_value_data("player character bottom position", parsable_types.vector), + [gp_value_types.pc_velocity] = gameplay_value_data("player character velocity", parsable_types.vector), + [gp_value_types.pc_ground_spd] = gameplay_value_data("player character ground speed", parsable_types.number), + [gp_value_types.pc_motion_state] = gameplay_value_data("player character motion state", parsable_types.motion_state), + [gp_value_types.pc_slope] = gameplay_value_data("player character slope", parsable_types.number), +} + + +-- parsing functions + +function itest_dsl.parse_none(arg_strings) + assert(#arg_strings == 0, "parse_none: got "..#arg_strings.." args, expected 0") + return nil +end + +function itest_dsl.parse_number(arg_strings) + assert(#arg_strings == 1, "parse_number: got "..#arg_strings.." args, expected 1") + return string_tonum(arg_strings[1]) +end + +function itest_dsl.parse_vector(arg_strings) + assert(#arg_strings == 2, "parse_vector: got "..#arg_strings.." args, expected 2") + return vector(string_tonum(arg_strings[1]), string_tonum(arg_strings[2])) +end + +function itest_dsl.parse_horizontal_dir(arg_strings) + assert(#arg_strings == 1, "parse_horizontal_dir: got "..#arg_strings.." args, expected 1") + local horizontal_dir = horizontal_dirs[arg_strings[1]] + assert(horizontal_dir, "horizontal_dirs["..arg_strings[1].."] is not defined") + return horizontal_dir +end + +function itest_dsl.parse_control_mode(arg_strings) + assert(#arg_strings == 1, "parse_control_mode: got "..#arg_strings.." args, expected 1") + local control_mode = control_modes[arg_strings[1]] + assert(control_mode, "control_modes["..arg_strings[1].."] is not defined") + return control_mode +end + +function itest_dsl.parse_motion_mode(arg_strings) + assert(#arg_strings == 1, "parse_motion_mode: got "..#arg_strings.." args, expected 1") + local motion_mode = motion_modes[arg_strings[1]] + assert(motion_mode, "motion_modes["..arg_strings[1].."] is not defined") + return motion_mode +end + +function itest_dsl.parse_motion_state(arg_strings) + assert(#arg_strings == 1, "parse_motion_state: got "..#arg_strings.." args, expected 1") + local motion_state = motion_states[arg_strings[1]] + assert(motion_state, "motion_states["..arg_strings[1].."] is not defined") + return motion_states[arg_strings[1]] +end + +function itest_dsl.parse_button_id(arg_strings) + assert(#arg_strings == 1, "parse_button_id: got "..#arg_strings.." args, expected 1") + local button_id = button_ids[arg_strings[1]] + assert(button_id, "button_ids["..arg_strings[1].."] is not defined") + return button_ids[arg_strings[1]] +end + +function itest_dsl.parse_gp_value(arg_strings) + assert(#arg_strings > 1, "parse_gp_value: got "..#arg_strings.." args, expected at least 2") + -- same principle as itest_dsl_parser.parse, the type of the first arg + -- determines how we parse the rest of the args, named "value components" + local gp_value_type_str = arg_strings[1] + -- gather all the value components as strings (e.g. {"3", "4"} for vector(3, 4)) + local gp_value_comps = {} + for i = 2, #arg_strings do + add(gp_value_comps, arg_strings[i]) + end + -- determine the type of value reference tested for comparison (e.g. pc position) + local gp_value_type = gp_value_types[gp_value_type_str] + assert(gp_value_type, "gp_value_types['"..gp_value_type_str.."'] is not defined") + -- parse the value components to semantical type (e.g. vector) + local gp_value_data = gp_value_data_t[gp_value_type] + assert(gp_value_data, "gp_value_data_t["..gp_value_type.."] (for '"..gp_value_type_str.."') is not defined") + local gp_value_parser = value_parsers[gp_value_data.parsable_type] + assert(gp_value_parser, "no value parser defined for gp value type '"..parsable_type_strings[gp_value_data.parsable_type].."'") + local gp_value = gp_value_parser(gp_value_comps) + return gp_value_type_str, gp_value +end + +-- table of parsers for command args and gameplay values, indexed by parsed type +value_parsers = generate_function_table(itest_dsl, parsable_types, "parse_") +itest_dsl.value_parsers = value_parsers + + +-- functions to execute dsl commands. they take the dsl parser as 1st parameter +-- so they can update its state if needed + +function itest_dsl.execute_warp(args) + stage.state.player_char:warp_bottom_to(args[1]) +end + +function itest_dsl.execute_set(args) + local gp_value_type_str, new_gp_value = unpack(args) + + local setter = itest_dsl["set_"..gp_value_type_str] + assert(setter, "itest_dsl.set_"..gp_value_type_str.." is not defined") + setter(new_gp_value) +end + +function itest_dsl.execute_set_control_mode(args) + stage.state.player_char.control_mode = args[1] +end + +function itest_dsl.execute_set_motion_mode(args) + stage.state.player_char.motion_mode = args[1] +end + +function itest_dsl.execute_move(args) + stage.state.player_char.move_intention = horizontal_dir_vectors[args[1]] +end + +function itest_dsl.execute_stop(args) + stage.state.player_char.move_intention = vector.zero() +end + +function itest_dsl.execute_jump(args) + stage.state.player_char.jump_intention = true -- will be consumed + stage.state.player_char.hold_jump_intention = true +end + +function itest_dsl.execute_stop_jump(args) + stage.state.player_char.hold_jump_intention = false +end + +function itest_dsl.execute_press(args) + -- simulate sticky press for player 0 + input.simulated_buttons_down[0][args[1]] = true +end + +function itest_dsl.execute_release(args) + -- simulate release for player 0 + input.simulated_buttons_down[0][args[1]] = false +end + +-- wait and expect are not timed actions and will be handled as special cases + +-- table of functions to call when applying a command with args, indexed by command type +executors = generate_function_table(itest_dsl, command_types, "execute_") +itest_dsl.executors = executors + + +-- gameplay value evaluation functions + +function itest_dsl.eval_pc_bottom_pos() + return stage.state.player_char:get_bottom_center() +end + +function itest_dsl.eval_pc_velocity() + return stage.state.player_char.velocity +end + +function itest_dsl.eval_pc_ground_spd() + return stage.state.player_char.ground_speed +end + +function itest_dsl.eval_pc_motion_state() + return stage.state.player_char.motion_state +end + +function itest_dsl.eval_pc_slope() + return stage.state.player_char.slope_angle +end + +-- table of functions used to evaluate and returns the gameplay value in current game state +evaluators = generate_function_table(itest_dsl, gp_value_types, "eval_") +itest_dsl.evaluators = evaluators + + +-- gameplay value setters (only when setting value directly makes sense) + +function itest_dsl.set_pc_velocity(value) + stage.state.player_char.velocity = value +end + +function itest_dsl.set_pc_ground_spd(value) + stage.state.player_char.ground_speed = value +end + + +-- command struct + +-- attributes +-- type command_types type of command invoked +-- args {*} sequence of arguments +local command = new_struct() +itest_dsl.command = command + +function command:_init(cmd_type, args) + self.type = cmd_type + self.args = args +end + + +-- expectation struct + +-- attributes +-- gp_value_type_str string name of gameplay value to compare +-- expected_value {type used for gp_value_type_str} expected gameplay value +local expectation = new_struct() +itest_dsl.expectation = expectation + +function expectation:_init(gp_value_type_str, expected_value) + self.gp_value_type_str = gp_value_type_str + self.expected_value = expected_value +end + + +-- dsl itest struct + +-- attributes +-- gamestate_type string gamestate type to start test in (also the only active gamestate) +-- stage_name string|nil stage name to play if gamestate type is 'stage', nil else +-- tilemap tilemap|nil tilemap data if gamestate type is 'stage', nil else +-- commands {command} sequence of commands to apply +local dsl_itest = new_struct() +itest_dsl.dsl_itest = dsl_itest + +function dsl_itest:_init() + -- all attributes are initially nil (even commands, as we construct the table during parsing) +end + + +-- itest dsl parser singleton, with parser context state +-- _itest integration_test current integration test in construction +-- _last_time_trigger time_trigger last time trigger registered with wait command +-- _final_expectations {expectation} sequence of expectations to verify +local itest_dsl_parser = singleton(function (self) + self._itest = nil + self._last_time_trigger = nil + self._final_expectations = {} +end) +itest_dsl.itest_dsl_parser = itest_dsl_parser + +-- parse, create and register itest from dsl +function itest_dsl_parser.register(name, dsli_source) + local dsli = itest_dsl_parser.parse(dsli_source) + local test = itest_dsl_parser.create_itest(name, dsli) + itest_manager:register(test) +end + +-- parse a dsl itest source and return a dsl itest +-- an itest is defined by a scenario and expectations +-- a dsl itest is split into 2 parts: +-- 1. gamestate definition +-- 2. action sequence and expectations +-- ex: +-- [[ +-- @stage # < gamestate 'stage' with tag '#' for custom +-- ... < for custom stage, provide the tilemap in ascii +-- ### < . for empty tile, # for full tile, etc. +-- < blank after tilemap to mark the end +-- warp 4 8 < initial setup (it's an action like any other) +-- move right < more actions... +-- wait 30 < wait delays the next action (here, the nil action) +-- expect pc_bottom_pos 14. 8. < expectation (only final assertion is supported) +-- ]] +function itest_dsl_parser.parse(dsli_source) + -- create dsl itest + local dsli = dsl_itest() + + -- split source lines (do not collapse \n so we can use blank lines as separator) + local lines = strspl(dsli_source, '\n') + + -- parse in 2 steps: gamestate and action sequence + local next_line_index + dsli.gamestate_type, dsli.stage_name, dsli.tilemap, next_line_index = itest_dsl_parser.parse_gamestate_definition(lines) + dsli.commands = itest_dsl_parser.parse_action_sequence(lines, next_line_index) + + return dsli +end + +-- return gamestate type, stage_name, tilemap data and index of next line to parse so we can chain parsing +-- the format of the gamestate definition is: +-- @[gamestate] (stage_name|#)? < 2nd part only if gamestate == 'stage', '#' for custom tilemap +-- [tilemap row 1] < only for custom tilemap +-- ... +-- [tilemap row n] +-- < blank after tilemap (or one-line gamestate definition) to mark the end +-- ? < we don't check what's after, just return this line's index +function itest_dsl_parser.parse_gamestate_definition(lines) + -- parse first line to get state and optional stage + local gamestate_header = lines[1] + assert(sub(gamestate_header, 1, 1) == '@', "gamestate_header '"..gamestate_header.."' doesn't start with @") + local header_parts = strspl(gamestate_header, ' ', true) + local gamestate_type = sub(header_parts[1], 2) + local stage_name = nil + if gamestate_type == 'stage' then + assert(#header_parts == 2) + stage_name = header_parts[2] + end + + local tm = nil + local next_line_index = 3 + if stage_name == '#' then + -- we are defining a custom tilemap, let's parse it + tm, next_line_index = itest_dsl_parser.parse_tilemap(lines) + end + + return gamestate_type, stage_name, tm, next_line_index +end + +function itest_dsl_parser.parse_tilemap(lines) + -- tilemap should always start at line 2 + -- first line will give the tilemap width + assert(#lines >= 2, "only "..#lines.." line(s), need at least 2") + + local content = {} -- sequence of sequence of tilemap symbols + local width = 0 -- number of symbols per tilemap row + + for i = 2, #lines do + local line_str = lines[i] + + -- ensure that line is either empty or made of one block of symbols with no spaces + -- this step will also trim any extra space (e.g. from " \" used to chain lines) + local line_blocks = strspl(line_str, ' ', true) + if #line_blocks == 0 then + -- we reached the end of tilemap definition + break + end + + assert(#line_blocks == 1, "too many blocks: "..#line_blocks..", expected 1") + local trimmed_line_str = line_blocks[1] + if width == 0 then + -- no width defined on first line (i == 2), store it now + width = #trimmed_line_str + else + -- on further lines, check consistency + assert(#trimmed_line_str == width, "inconsistent line length: "..#trimmed_line_str.." vs "..width) + end + + + local current_row = {} + + for j = 1, width do + local tile_symbol = sub(trimmed_line_str, j, j) + local tile_id = tile_symbol_to_ids[tile_symbol] + assert(tile_id, "unknown tile symbol: "..tile_symbol) + add(current_row, tile_id) + end + + add(content, current_row) + end + + -- return tilemap, next line = initial line index + nb rows + 1 + return tilemap(content), 2 + #content + 1 +end + + +-- return a sequence of commands read in lines, starting at next_line_index +function itest_dsl_parser.parse_action_sequence(lines, next_line_index) + local commands = {} + for i = next_line_index, #lines do + words = strspl(lines[i], ' ', true) + -- if there are no words, the line is empty, so continue + if #words > 0 then + local cmd_type_str = words[1] + local arg_strings = {} + for j = 2, #words do + add(arg_strings, words[j]) + end + local cmd_type = command_types[cmd_type_str] + assert(cmd_type, "no command type named '"..cmd_type_str.."'") + local arg_parsable_type = command_arg_types[cmd_type] + assert(arg_parsable_type, "no command arg type defined for command '"..command_type_strings[cmd_type].."'") + local arg_parser = value_parsers[arg_parsable_type] + assert(arg_parser, "no value parser defined for arg type '"..parsable_type_strings[arg_parsable_type].."'") + local args = {arg_parser(arg_strings)} + add(commands, command(cmd_type, args)) + end + end + return commands +end + + +-- create and return an itest from a dsli, providing a name +function itest_dsl_parser.create_itest(name, dsli) + itest_dsl_parser._itest = integration_test(name, {dsli.gamestate_type}) + + itest_dsl_parser._itest.setup = function () + flow:change_gamestate_by_type(dsli.gamestate_type) + if dsli.gamestate_type == "stage" then + -- puppet control + stage.state.player_char.control_mode = control_modes.puppet + if dsli.stage_name == '#' then + -- load tilemap data and build it from ascii + setup_map_data() + dsli.tilemap:load() + else + -- load stage by name when api is ready + end + end + end + + itest_dsl_parser._itest.teardown = function () + -- clear map + -- no need to "unload" the game state, the next test will reset the flow anyway + if dsli.gamestate_type == "stage" then + if dsli.stage_name == '#' then + -- clear tilemap and unload tilemap data + tilemap.clear_map() + teardown_map_data() + end + end + end + + for cmd in all(dsli.commands) do + if cmd.type == command_types.wait then + itest_dsl_parser:_wait(cmd.args[1]) + + elseif cmd.type == command_types.expect then + -- we currently don't support live assertions, but we support multiple + -- final expectations + add(itest_dsl_parser._final_expectations, expectation(cmd.args[1], cmd.args[2])) + + else + -- common action, store callback for execution during + itest_dsl_parser:_act(function () + executors[cmd.type](cmd.args) + end) + end + end + + -- if we finished with a wait (with or without final assertion), + -- we need to close the itest with a wait-action + if itest_dsl_parser._last_time_trigger then + itest_dsl_parser._itest:add_action(itest_dsl_parser._last_time_trigger, nil) + itest_dsl_parser._last_time_trigger = nil -- consume and cleanup for next itest + end + + -- glue code to remain retro-compatible with function-based final assertion + itest_dsl_parser:_define_final_assertion() + + local test = itest_dsl_parser._itest + itest_dsl_parser._itest = nil -- consume and cleanup for next itest + + return test +end + +-- glue code for old callback-based system +-- the time trigger system makes actions and waiting asymmetrical, +-- as waiting is not an action but adds a parameter to the next action, +-- and requires nil actions to chain waiting (they don't even merge) +-- prefer a flat sequence of generic actions that can be actual gameplay +-- changes or waiting. when waiting, just skip frames until waiting ends, +-- at which point you can apply all further actions immediately, until +-- a new wait action is found. +function itest_dsl_parser:_act(callback) + if self._last_time_trigger then + self._itest:add_action(self._last_time_trigger, callback) + self._last_time_trigger = nil -- consume so we know no final wait-action is needed + else + -- no wait since last action (or this is the first action), so use immediate trigger + self._itest:add_action(integrationtest.immediate_trigger, callback) + end +end + +function itest_dsl_parser:_wait(interval) + if self._last_time_trigger then + -- we were already waiting, so finish last wait with empty action + self._itest:add_action(self._last_time_trigger, nil) + end + -- we only support frame unit in the dsl + self._last_time_trigger = integrationtest.time_trigger(interval, true) +end + + +-- define final assertion based on sequence of final expectations +-- this is a glue method to make it retro-compatible with the function-based final assertion +-- eventually, the itest will only hold expectations (possibly predefined functions for currying) +-- to avoid creating lambda +function itest_dsl_parser:_define_final_assertion() + -- define an intermediate local variable to avoid the "local variable closure issue" + -- i.e. if we access "self._final_expectations" directly from inside the function + -- constructed below, it would get the actual value of self._final_expectations + -- at evaluation time (too late, the temporary table reference would have been lost + -- and the table gc-ed). So we either need to copy the table content (then clear table) + -- or store the reference in an intermediate variable like this one (then create new table) + local final_expectations_proxy = self._final_expectations + self._final_expectations = {} -- consume and cleanup for next itest + + self._itest.final_assertion = function () + local success = true + local full_message = "" + + -- check each expectation one by one + for exp in all(final_expectations_proxy) do + local gp_value_type = gp_value_types[exp.gp_value_type_str] + local evaluator = evaluators[gp_value_type] + assert(evaluator, "evaluators["..gp_value_type.."] (for '"..exp.gp_value_type_str.."') is not defined") + local gp_value = evaluator() +--[[#pico8 + -- in pico8, we use fixed point precision, which is what we expect as final values + -- however, precomputing 16.16 fixed precision values by hand is very hard, + -- so I may end up using the same approx as with busted below + local value_success, value_eq_message = eq_with_message(exp.expected_value, gp_value) +--#pico8]] +--#if utest + -- with busted, we use float point precision, which gives us slightly different values + -- unfortunately, the error accumulates over time, and position integrates from speed from accel, + -- so depending on the simulation time and the gameplay value type, the error threshold will vary + -- to be safe, we use 1/64 (0.015) although 1/256 is often enough) + local value_success, value_eq_message = almost_eq_with_message(exp.expected_value, gp_value, 1/64) +--#endif + if not value_success then + success = false + local gp_value_data = gp_value_data_t[gp_value_type] + assert(gp_value_data, "gp_value_data_t["..gp_value_type.."] is not defined") + local gp_value_name = gp_value_data.name + local value_message = "\nFor gameplay value '"..gp_value_name.."':\n"..value_eq_message + full_message = full_message..value_message.."\n" + end + end + + return success, full_message + end +end + +return itest_dsl diff --git a/src/game/itests/itestplayercharacter.lua b/src/game/itests/itestplayercharacter.lua index 65a6a421..bdd5e154 100644 --- a/src/game/itests/itestplayercharacter.lua +++ b/src/game/itests/itestplayercharacter.lua @@ -1,7 +1,9 @@ -- gamestates: stage local integrationtest = require("engine/test/integrationtest") -local itest_dsl = require("engine/test/itest_dsl") -local itest_manager, integration_test, time_trigger = integrationtest.itest_manager, integrationtest.integration_test, integrationtest.time_trigger +local itest_dsl = require("game/itests/itest_dsl") +local itest_dsl_parser = itest_dsl.itest_dsl_parser +local itest_manager, integration_test, time_trigger = get_members(integrationtest, + "itest_manager", "integration_test", "time_trigger") local input = require("engine/input/input") local flow = require("engine/application/flow") local stage = require("game/ingame/stage") -- required @@ -13,1065 +15,641 @@ local tile_test_data = require("game/test_data/tile_test_data") local itest +-- debug motion --- dsl training - --- pico8 doesn't like [[]] and will replace lines after the 3rd with --- empty lines... need "text \n".. or "text \ to continue to next line -itest_dsl.register('#solo platformer accel right flat', -"@stage # \ -... \ -### \ - \ -warp 4 8 \ -move right \ -wait 30 \ -expect pc_bottom_pos 14.8984375 8. \ -" -) - ---[[ original -itest.setup = function () - -- stage.state.player_char.motion_mode = motion_modes.platformer -end - -itest.teardown = function () - clear_map() - -- teardown_map_data() -end - --- check that player char has moved to the right and is still on the ground -itest.final_assertion = function () - -- local is_motion_state_expected, motion_state_message = motion_states.grounded == stage.state.player_char.motion_state, "Expected motion state 'grounded', got "..stage.state.player_char.motion_state - -- no almost eq - -- no bottom center - local is_position_expected, position_message = almost_eq_with_message(vector(14.8984375, 80.), stage.state.player_char:get_bottom_center(), 1/256) - -- local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0.703125, stage.state.player_char.ground_speed, 1/256) - -- local is_velocity_expected, velocity_message = almost_eq_with_message(vector(0.703125, 0), stage.state.player_char.velocity, 1/256) - - -- local final_message = "" - - -- local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - -- if not success then - -- if not is_motion_state_expected then - -- final_message = final_message..motion_state_message.."\n" - -- end - -- if not is_position_expected then - -- final_message = final_message..position_message.."\n" - -- end - -- if not is_ground_speed_expected then - -- final_message = final_message..ground_speed_message.."\n" - -- end - -- if not is_velocity_expected then - -- final_message = final_message..velocity_message.."\n" - -- end - - -- end -end ---]] - - ---[[ -itest = integration_test('debug move right', {stage.state.type}) -itest_manager:register(itest) - -itest.setup = function () - setup_map_data() - - -- just add a tile in the way to make sure debug motion ignores collisions - mset(1, 10, 64) - - flow:change_gamestate_by_type(stage.state.type) - stage.state.player_char.position = vector(0., 80.) - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.debug - - -- player char starts moving to the right - stage.state.player_char.move_intention = vector(1., 0.) -end - -itest.teardown = function () - clear_map() - teardown_map_data() -end - --- stop after 1 second -itest:add_action(time_trigger(1.), function () end) - --- check that player char has moved a little to the right (integrate accel) -itest.final_assertion = function () - -- 56.7185 in PICO-8 fixed point precision - -- 56.7333 in Lua floating point precision - return almost_eq_with_message(vector(56.7185, 80.), stage.state.player_char.position, 0.015) -end +itest_dsl_parser.register( + 'debug move right', [[ +@stage # +.# +set_motion_mode debug +warp 0 8 +move right +wait 60 --- bugfix history: --- . test failed because initial character position was wrong in the test --- * test failed in pico8 only because in _compute_signed_distance_to_closest_ground, --- I was setting min_signed_distance = 32768 = -32767 -itest = integration_test('platformer land vertical', {stage.state.type}) -itest_manager:register(itest) - -itest.setup = function () - setup_map_data() +expect pc_bottom_pos 0x0038.b7f1 8 +]]) - -- add tile where the character will land - mset(0, 10, 64) +-- precision note on expected pc_bottom_pos: +-- 56.7185211181640625 (0x0038.b7f1) in PICO-8 fixed point precision +-- 56.733333333333 in Lua floating point precision - flow:change_gamestate_by_type(stage.state.type) - -- respawn character in the air (important to always start with airborne state) - stage.state.player_char:spawn_at(vector(4., 48.)) - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer -end +-- ground motion -itest.teardown = function () - clear_map() - teardown_map_data() -end +-- common calculation notes: +-- to compute position x from x0 after n frames at accel a from speed s0: x = x0 + n*s0 + n(n+1)/2*a +-- to compute speed s from s0 after n frames at accel a: x = s0 + n*a --- wait 1 second and stop -itest:add_action(time_trigger(1.), function () end) --- check that player char has landed and snapped to the ground -itest.final_assertion = function () - return almost_eq_with_message(vector(4., 80.), stage.state.player_char:get_bottom_center(), 1/256) -end +-- bugfix history: +-- . test was wrong, initialize in setup, not at time trigger 0 +itest_dsl_parser.register( + 'platformer accel right flat', [[ +@stage # +... +### + +warp 4 8 +move right +wait 30 + +expect pc_bottom_pos 14.8984375 8 +expect pc_motion_state grounded +expect pc_ground_spd 0.703125 +expect pc_velocity 0.703125 0 +]]) + +itest_dsl_parser.register( + 'platformer decel right flat', [[ +@stage # +... +### + +warp 4 8 +move right +wait 30 +move left +wait 10 + +expect pc_bottom_pos 14.7109375 8 +expect pc_motion_state grounded +expect pc_ground_spd -0.1875 +expect pc_velocity -0.1875 0 +]]) + +itest_dsl_parser.register( + 'platformer friction right flat', [[ +@stage # +.... +#### + +warp 4 8 +move right +wait 30 +stop +wait 30 + +expect pc_bottom_pos 25.09375 8 +expect pc_motion_state grounded +expect pc_ground_spd 0 +expect pc_velocity 0 0 +]]) + +-- calculation notes: +-- to compute position, use the fact that friction == accel, so our speed describes a pyramid over where each value is mirrored +-- around the middle, where the max is, except the max speed itself (0.703125) which is only reached a single frame +-- so we can 2x the accumulated distance computed in the first test (only accel over 30 frames), +-- then subtract the unique max value, and add the initial position x +-- expected position: vector(4 + 2 * 10.8984375 - 0.703125, 80.) = vector(25.09375, 80) +-- otherwise, character has stopped so expected speed is 0 --- bugfix history: . test was wrong, initialize in setup, not at time trigger 0 -itest = integration_test('platformer accel right flat', {stage.state.type}) -itest_manager:register(itest) - -itest.setup = function () - setup_map_data() - - mset(0, 10, 64) - mset(1, 10, 64) - mset(2, 10, 64) - - flow:change_gamestate_by_type(stage.state.type) - - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer - - -- start moving to the right from frame 0 by setting intention in setup - stage.state.player_char.move_intention = vector(1, 0) -end - -itest.teardown = function () - clear_map() - teardown_map_data() -end - --- wait 30 frames and stop -itest:add_action(time_trigger(0.5), function () end) - --- check that player char has moved to the right and is still on the ground -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.grounded == stage.state.player_char.motion_state, "Expected motion state 'grounded', got "..stage.state.player_char.motion_state - -- to compute position x from x0 after n frames at accel a from speed s0: x = x0 + n*s0 + n(n+1)/2*a - local is_position_expected, position_message = almost_eq_with_message(vector(14.8984375, 80.), stage.state.player_char:get_bottom_center(), 1/256) - -- to compute speed s from s0 after n frames at accel a: x = s0 + n*a - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0.703125, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(0.703125, 0), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message..position_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message..velocity_message.."\n" - end - - end - - return success, final_message -end - - -itest = integration_test('platformer decel right flat', {stage.state.type}) -itest_manager:register(itest) - -itest.setup = function () - setup_map_data() - - -- add tiles where the character will move - mset(0, 10, 64) - mset(1, 10, 64) - mset(2, 10, 64) - - flow:change_gamestate_by_type(stage.state.type) - - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer - - -- start moving to the right from frame 0 by setting intention in setup - stage.state.player_char.move_intention = vector(1, 0) -end - -itest.teardown = function () - clear_map() - teardown_map_data() -end - --- at frame 30, decelerate (brake) -itest:add_action(time_trigger(0.5), function () - stage.state.player_char.move_intention = vector(-1, 0) -end) - --- wait 10 frames and stop -itest:add_action(time_trigger(10, true), function () end) - --- check that player char has moved to the right and is still on the ground -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.grounded == stage.state.player_char.motion_state, "Expected motion state 'grounded', got "..stage.state.player_char.motion_state - -- to compute position, apply deceleration to the current speed and sum to the last position at frame 30. don't forget to clamp speed to - max speed when changing sign over max speed, - -- before continuing to increase speed with - max accel each step after that - local is_position_expected, position_message = almost_eq_with_message(vector(14.7109375, 80.), stage.state.player_char:get_bottom_center(), 1/256) - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(-0.1875, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(-0.1875, 0), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message..position_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message..velocity_message.."\n" - end - - end - - return success, final_message -end - - -itest = integration_test('platformer friction right flat', {stage.state.type}) -itest_manager:register(itest) - -itest.setup = function () - setup_map_data() - - -- add tiles where the character will move - mset(0, 10, 64) - mset(1, 10, 64) - mset(2, 10, 64) - mset(3, 10, 64) - - flow:change_gamestate_by_type(stage.state.type) - - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer - - -- start moving to the right from frame 0 by setting intention in setup - stage.state.player_char.move_intention = vector(1, 0) -end - -itest.teardown = function () - clear_map() - teardown_map_data() -end - --- at frame 30, slow down with friction -itest:add_action(time_trigger(0.5), function () - stage.state.player_char.move_intention = vector.zero() -end) - --- wait 30 frames and stop -itest:add_action(time_trigger(0.5), function () end) - --- check that player char has moved to the right and is still on the ground -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.grounded == stage.state.player_char.motion_state, "Expected motion state 'grounded', got "..stage.state.player_char.motion_state - -- to compute position, use the fact that friction == accel, so our speed describes a pyramid over time with a non-mirrored, unique max at 0.703125, - -- so we can 2x the accumulated distance computed in the first test (only accel over 30 frames), then subtract the non-doubled max value, and add the initial position x - local is_position_expected, position_message = almost_eq_with_message(vector(4 + 2 * 10.8984375 - 0.703125, 80.), stage.state.player_char:get_bottom_center(), 1/256) - -- to compute speed s from s0 after n frames at accel a: x = s0 + n*a - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(0, 0), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message..position_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message..velocity_message.."\n" - end - - end - - return success, final_message -end ---]] - -- bugfix history: -- . forgot to add a solid ground below the slope to confirm ground -- ! identified bug in _compute_ground_motion_result where slope angle was set on extra step, -- despite being only a subpixel extra move -- . was expecting positive speed but slope was ascending -itest = integration_test('platformer ascending slope right', {stage.state.type}) -itest_manager:register(itest) +itest_dsl_parser.register( + 'platformer ascending slope right', [[ +@stage # +.. +./ +#. + +warp 4 16 +move right +wait 15 + +expect pc_bottom_pos 0x0006.8509 15 +expect pc_motion_state grounded +expect pc_slope -0.125 +expect pc_ground_spd 0.26318359375 +expect pc_velocity 0x0000.2fa4 -0x0000.2fa5 +]]) + +-- precision note on expected pc_bottom_pos: +-- 6.5196685791015625, 15 (0x0006.8509, 0x000f.0000) in PICO-8 fixed point precision +-- 6.5196741403377, 15 in Lua floating point precision + +-- precision note on expected pc_velocity: +-- 0.18609619140625, −0.186111450195 (0x0000.2fa4, 0xffff.d05b = -1 + 0x0000.d05b = -0x0000.2fa5) in PICO-8 fixed point precision +-- (we cannot use 0xffff. which would be interpreted as 65535; also note that vx != -vy due to cos imprecision of 0x0001 I guess) +-- 0.1860922277609, -0.1860922277609 in Lua floating point precision + +-- pc_slope -45/360 = -1/8 = -0.125 + +-- calculation notes: +-- at frame 1: bottom pos (4 + ground_accel_frame2, 16), velocity (ground_accel_frame2, 0), ground_speed (ground_accel_frame2) +-- at frame n before slope: bpos (4 + n(n+1)/2*ground_accel_frame2, 16), velocity (n*ground_accel_frame2, 0) +-- character makes first step on slope when right sensor reaches position x = 8 (column 0 height of tile 65 is 1) +-- i.e. center reaches 8 - ground_sensor_extent_x = 5.5 +-- at frame 1: bpos (4.0234375, 16), velocity (0.0234375, 0), ground_speed(0.0234375) +-- at frame 9: bpos (5.0546875, 16), velocity (0.2109375, 0), ground_speed(0.2109375) +-- at frame 10: bpos (5.2890625, 16), velocity (0.234375, 0), ground_speed(0.234375) +-- at frame 11: bpos (5.546875, 16), velocity (0.2578125, 0), ground_speed(0.2578125) +-- at frame 12: bpos (5.828125, 16), velocity (0.28125, 0), ground_speed(0.28125) +-- at frame 13: bpos (6.1328125, 15), velocity (0.3046875, 0), ground_speed(0.3046875), first step on slope and at higher level than flat ground, acknowledge slope as current ground +-- at frame 14: bpos (6.333572387695, 15), velocity (0.2007598876953125, -0.2007598876953125), ground_speed(0.283935546875), because slope was current ground at frame start, slope factor was applied with 0.0625*sin(45) = -0.044189453125 (in PICO-8 16.16 fixed point precision) +-- at frame 15: bpos (6.519668501758, 15), velocity (0.1860961140625, -0.1860961140625), ground_speed(0.26318359375), still under slope factor effect and velocity following slope tangent -itest.setup = function () - setup_map_data() - -- add tiles where the character will move - mset(0, 10, 64) -- flat ground - mset(1, 9, 65) -- ascending slope 45, one level up +-- calculation notes - flow:change_gamestate_by_type(stage.state.type) +-- wait 2 frame (1 to register jump, 1 to confirm and leave ground) then move to the right +-- this is just to avoid starting moving on the ground, as we only want to test air control here, +-- not how ground speed is transferred to air velocity - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer +-- wait for the apogee (frame 31) and stop +-- at frame 1: pos (4, 8), velocity (0, 0), grounded (waits 1 frame before confirming hop/jump) +-- at frame 2: pos (4, 8 - 3.25), velocity (0, -3.25), airborne (do not apply gravity on first frame of jump, no air accel yet) +-- at frame 3: pos (4 + 0.046875, 8 - 49.84375), velocity (0.046875, -3.140625), airborne -> accel forward +-- at frame 30: pos (4 + 19.03125, 8 - 49.84375), velocity (1.3125, -0.1875), airborne -> before apogee +-- at frame 31: pos (4 + 20.390625, 8 - 49.921875), velocity (1.359375, -0.078125), airborne -> reached apogee (100px in 16-bit, matches SPG on Jumping) +-- at frame 32: pos (4 + 21.796875, 8 - 49.890625), velocity (1.40625, 0.03125), airborne -> starts going down +-- at frame 61: pos (4 + 82.96875, 8 - 1.40625), velocity (2.765625, 3.203125), airborne -> about to land +-- at frame 62: pos (4 + 85.78125, 8), velocity (2.8125, 0), grounded -> has landed, preserve x speed - -- start moving to the right from frame 0 by setting intention in setup - stage.state.player_char.move_intention = vector(1, 0) -end +-- check for apogee -itest.teardown = function () - clear_map() - teardown_map_data() -end --- wait 30 frames and stop --- ground_accel_frame2 = 0.0234375 --- at frame 1: bottom pos (4 + ground_accel_frame2, 80), velocity (ground_accel_frame2, 0), ground_speed (ground_accel_frame2) --- at frame n before slope: bpos (4 + n(n+1)/2*ground_accel_frame2, 80), velocity (n*ground_accel_frame2, 0) --- character makes first step on slope when right sensor reaches position x = 8 (column 0 height of tile 65 is 1) --- i.e. center reaches 8 - ground_sensor_extent_x = 5.5 --- at frame 1: bpos (4.0234375, 80), velocity (0.0234375, 0), ground_speed(0.0234375) --- at frame 9: bpos (5.0546875, 80), velocity (0.2109375, 0), ground_speed(0.2109375) --- at frame 10: bpos (5.2890625, 80), velocity (0.234375, 0), ground_speed(0.234375) --- at frame 11: bpos (5.546875, 80), velocity (0.2578125, 0), ground_speed(0.2578125) --- at frame 12: bpos (5.828125, 80), velocity (0.28125, 0), ground_speed(0.28125) --- at frame 13: bpos (6.1328125, 79), velocity (0.3046875, 0), ground_speed(0.3046875), first step on slope and at higher level than flat ground, acknowledge slope as current ground --- at frame 14: bpos (6.333572387695, 79), velocity (0.2007598876953125, -0.2007598876953125), ground_speed(0.283935546875), because slope was current ground at frame start, slope factor was applied with 0.0625*sin(45) = -0.044189453125 (in PICO-8 16.16 fixed point precision) --- at frame 15: bpos (6.519668501758, 79), velocity (0.1860961140625, -0.1860961140625), ground_speed(0.26318359375), still under slope factor effect and velocity following slope tangent --- note that speed decrease on slope is not implemented yet (via cosine but also gravity), so this test will have to change when it is --- however, the result should stay true for a very low slope (a wave where registered slope is 0) -itest:add_action(time_trigger(15, true), function () end) - --- check that player char has moved to the right and is still on the ground -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.grounded == stage.state.player_char.motion_state, "Expected motion state 'grounded', got "..stage.state.player_char.motion_state - -- to compute position, use the fact that friction == accel, so our speed describes a pyramid over time with a non-mirrored, unique max at 0.703125, - -- so we can 2x the accumulated distance computed in the first test (only accel over 30 frames), then subtract the non-doubled max value, and add the initial position x - local is_position_expected, position_message = almost_eq_with_message(vector(6.519668501758, 79), stage.state.player_char:get_bottom_center(), 1/256) - local is_slope_expected, slope_message = almost_eq_with_message(-45/360, stage.state.player_char.slope_angle, 1/256) - -- to compute speed s from s0 after n frames at accel a: x = s0 + n*a - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0.26318359375, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(0.1860961140625, -0.1860961140625), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message.."motion_state: \n"..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message.."position_message: \n"..position_message.."\n" - end - if not is_slope_expected then - final_message = final_message.."slope_message: \n"..slope_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message.."ground_speed_message: \n"..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message.."velocity_message: \n"..velocity_message.."\n" - end - - end - - return success, final_message -end - ---[[ --- bugfix history: ! identified bug in _update_platformer_motion where absence of elseif --- allowed to enter both grounded and airborne update, causing 2x update when leaving the cliff -itest = integration_test('platformer fall cliff', {stage.state.type}) -itest_manager:register(itest) - -itest.setup = function () - setup_map_data() - - -- add tiles where the character will move - mset(0, 10, 64) - mset(1, 10, 64) - - flow:change_gamestate_by_type(stage.state.type) - - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer - - -- start moving to the right from frame 0 by setting intention in setup - stage.state.player_char.move_intention = vector(1, 0) -end - -itest.teardown = function () - clear_map() - teardown_map_data() -end - --- at frame 34: pos (17.9453125, 74), velocity (0.796875, 0), grounded --- at frame 35: pos (18.765625, 74), velocity (0.8203125, 0), airborne -> stop accel -itest:add_action(time_trigger(35, true), function () - stage.state.player_char.move_intention = vector.zero() -end) - --- wait 25 frames and stop --- at frame 60: pos (39.2734375, 74 + 35.546875), velocity (0.8203125, 2.734375), airborne -itest:add_action(time_trigger(25, true), function () end) - --- check that player char has moved to the right and fell -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.airborne == stage.state.player_char.motion_state, "Expected motion state 'airborne', got "..stage.state.player_char.motion_state - local is_position_expected, position_message = almost_eq_with_message(vector(39.2734375, 80. + 35.546875), stage.state.player_char:get_bottom_center(), 1/256) - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(0.8203125, 2.734375), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message..position_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message..velocity_message.."\n" - end - - end - - return success, final_message -end - - -itest = integration_test('platformer hop flat', {stage.state.type}) -itest_manager:register(itest) - -itest.setup = function () - setup_map_data() - - -- add tiles where the character will move - mset(0, 10, 64) - - flow:change_gamestate_by_type(stage.state.type) - - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer - - -- start jump - stage.state.player_char.jump_intention = true -- will be consumed - -- don't set hold_jump_intention at all to get a hop - -- (you can also set it on setup and reset it at end of frame 1) -end - -itest.teardown = function () - clear_map() - teardown_map_data() -end +-- bugfix history: +-- + revealed that spawn_at was not resetting state vars, so added _setup method +itest_dsl_parser.register( + 'platformer ground wall block right', [[ +@stage # +..# +##. --- wait for apogee (frame 20) and stop --- at frame 1: bpos (4, 80), velocity (0, 0), grounded (waits 1 frame before confirming hop/jump) --- at frame 2: bpos (4, 80 - 2), velocity (0, -2), airborne (hop confirmed) --- at frame 3: bpos (4, 80 - 3.890625), velocity (0, -1.890625), airborne (hop confirmed) --- at frame 19: pos (4, 80 - 19.265625), velocity (0, -0.140625), airborne -> before apogee --- at frame 20: pos (4, 80 - 19.296875), velocity (0, -0.03125), airborne -> reached apogee --- at frame 21: pos (4, 80 - 19.21875), velocity (0, 0.078125), airborne -> starts going down --- at frame 38: pos (4, 80 - 1.15625), velocity (0, 1.9375), airborne -> about to land --- at frame 39: pos (4, 80), velocity (0, 0), grounded -> has landed -itest:add_action(time_trigger(20, true), function () -end) - --- check that player char has moved to the right and fell -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.airborne == stage.state.player_char.motion_state, "Expected motion state 'airborne', got "..stage.state.player_char.motion_state - local is_position_expected, position_message = almost_eq_with_message(vector(4, 80. - 19.296875), stage.state.player_char:get_bottom_center(), 1/256) - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(0, -0.03125), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message..position_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message..velocity_message.."\n" - end - - end - - return success, final_message -end - - -itest = integration_test('platformer jump f2 interrupt flat', {stage.state.type}) -itest_manager:register(itest) - -itest.setup = function () - setup_map_data() - - -- add tiles where the character will move - mset(0, 10, 64) - - flow:change_gamestate_by_type(stage.state.type) - - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer - - -- start jump - stage.state.player_char.jump_intention = true -- will be consumed - stage.state.player_char.hold_jump_intention = true -end - -itest.teardown = function () - clear_map() - teardown_map_data() -end +warp 4 8 +move right +wait 28 --- interrupt variable jump at the end of frame 2 --- at frame 1: bpos (4, 80), velocity (0, 0), grounded (waits 1 frame before confirming hop/jump) --- at frame 2: bpos (4, 80 - 3.25), velocity (0, -3.25), airborne (jump confirmed) -itest:add_action(time_trigger(2, true), function () - stage.state.player_char.hold_jump_intention = false -end) - --- wait for the apogee (frame 20) and stop --- at frame 3: bpos (4, 80 - 5.140625), velocity (0, -1.890625), airborne -> jump interrupted (gravity is applied *after* setting speed y to -2) --- at frame 19: bpos (4, 80 - 20.515625), velocity (0, -0.140625), airborne -> before apogee --- at frame 20: bpos (4, 80 - 20.546875), velocity (0, -0.03125), airborne -> reached apogee --- at frame 21: bpos (4, 80 - 20.46875), velocity (0, 0.078125), airborne -> starts going down --- at frame 39: bpos (4, 80 - 0.3594), velocity (0, 2.15625), airborne -> about to land --- at frame 40: bpos (4, 80), velocity (0, 0), grounded -> has landed -itest:add_action(time_trigger(18, true), function () end) - --- check that player char has reached the apogee of the jump -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.airborne == stage.state.player_char.motion_state, "Expected motion state 'airborne', got "..stage.state.player_char.motion_state - local is_position_expected, position_message = almost_eq_with_message(vector(4, 80 - 20.546875), stage.state.player_char:get_bottom_center(), 1/256) - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(0, -0.03125), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message..position_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message..velocity_message.."\n" - end - - end - - return success, final_message -end - - -itest = integration_test('platformer full jump flat', {stage.state.type}) -itest_manager:register(itest) - -itest.setup = function () - setup_map_data() - - -- add tiles where the character will move - mset(0, 10, 64) - - flow:change_gamestate_by_type(stage.state.type) - - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer - - -- start jump - stage.state.player_char.jump_intention = true -- will be consumed - stage.state.player_char.hold_jump_intention = true -end - -itest.teardown = function () - clear_map() - teardown_map_data() -end +expect pc_bottom_pos 13 8 +expect pc_motion_state grounded +expect pc_ground_spd 0 +expect pc_velocity 0 0 +]]) --- wait for the apogee (frame 31) and stop --- at frame 1: pos (4, 80), velocity (0, 0), grounded (waits 1 frame before confirming hop/jump) --- at frame 2: pos (4, 80 - 3.25), velocity (0, -3.25), airborne (do not apply gravity on first frame of jump since we were grounded) --- at frame 30: pos (4, 80 - 49.84375), velocity (0, -0.1875), airborne -> before apogee --- at frame 31: pos (4, 80 - 49.921875), velocity (0, -0.078125), airborne -> reached apogee (100px in 16-bit, matches SPG on Jumping) --- at frame 32: pos (4, 80 - 49.890625), velocity (0, 0.03125), airborne -> starts going down --- at frame 61: pos (4, 80 - 1.40625), velocity (0, 3.203125), airborne -> about to land --- at frame 62: pos (4, 80), velocity (0, 0), grounded -> has landed -itest:add_action(time_trigger(31, true), function () end) - --- check that player char has moved to the right and fell -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.airborne == stage.state.player_char.motion_state, "Expected motion state 'airborne', got "..stage.state.player_char.motion_state - local is_position_expected, position_message = almost_eq_with_message(vector(4, 80 - 49.921875), stage.state.player_char:get_bottom_center(), 1/256) - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(0, -0.078125), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message..position_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message..velocity_message.."\n" - end - - end - - return success, final_message -end ---]] - - ---[[ +-- calculation notes --- if the player presses the jump button in mid-air, the character should not --- jump again when he lands on the ground (later, it will trigger a special action) -itest = integration_test('platformer no predictive jump in air', {stage.state.type}) -itest_manager:register(itest) +-- wait 28 frames and stop +-- character will be blocked when right wall sensor is at x = 16.5, so when center will be at x = 13 -itest.setup = function () - setup_map_data() +-- at frame 1: pos (4 + 0.0234375, 8), velocity (0.0234375, 0), grounded +-- at frame 27: pos (12.8359375, 8), velocity (0.6328125, 0), about to meet wall +-- at frame 28: pos (13, 8), velocity (0, 0), hit wall - -- add tiles where the character will move - mset(0, 10, 64) - flow:change_gamestate_by_type(stage.state.type) +itest_dsl_parser.register( + 'platformer slope ceiling block right', [[ +@stage # +..# +.<. +#.. - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - -- this is an end-to-end test because we don't want bother with how mid-air predicitve jump order is ignored - -- indeed, if it is ignored by ignoring the input itself, then hijacking the jump_intention - -- in puppet mode will prove nothing - -- if it is ignored by resetting the jump intention on land, the puppet test would be useful - -- to show that the intention itself is reset, but here we only want to ensure the end-to-end behavior is correct - -- so we us a human control mode and hijack the input directly - stage.state.player_char.control_mode = control_modes.human - stage.state.player_char.motion_mode = motion_modes.platformer +warp 4 16 +set pc_ground_spd 3 +move right +wait 4 - -- start hop - input.simulated_buttons_down[0][button_ids.o] = true -end +expect pc_bottom_pos 13 11 +expect pc_motion_state grounded +expect pc_ground_spd 0 +expect pc_velocity 0 0 +]]) -itest.teardown = function () - clear_map() - teardown_map_data() -end --- wait for apogee (frame 20) and stop --- at frame 1: bpos (4, 80), velocity (0, 0), grounded (waits 1 frame before confirming hop/jump) --- at frame 2: bpos (4, 80 - 2), velocity (0, -2), airborne (hop confirmed) --- at frame 3: bpos (4, 80 - 3.890625), velocity (0, -1.890625), airborne (hop confirmed) --- at frame 19: pos (4, 80 - 19.265625), velocity (0, -0.140625), airborne -> before apogee --- at frame 20: pos (4, 80 - 19.296875), velocity (0, -0.03125), airborne -> reached apogee --- at frame 21: pos (4, 80 - 19.21875), velocity (0, 0.078125), airborne -> starts going down --- at frame 38: pos (4, 80 - 1.15625), velocity (0, 1.9375), airborne -> about to land --- at frame 39: pos (4, 80), velocity (0, 0), grounded -> has landed - --- end of frame 2: end short press for a hop -itest:add_action(time_trigger(1, true), function () - input.simulated_buttons_down[0][button_ids.o] = false -end) - --- frame bug: it seems that 1+19!=20, time_trigger(1) is just ignored and it will give frame 19 --- end of frame 20: at the jump apogee, try another jump press -itest:add_action(time_trigger(19, true), function () - input.simulated_buttons_down[0][button_ids.o] = true -end) - --- end of frame 21: end short press -itest:add_action(time_trigger(1, true), function () - input.simulated_buttons_down[0][button_ids.o] = false -end) - --- frame bug: it seems that character will be on ground during frames +16 and +17 --- not sure why since he only needs 1 frame to confirm a hop --- wait for character to land (frame 39) and see if he hops again --- for now, to be safe we +19 -> frame 40, but actually supposedly frame 39 also works due to frame bug mentioned above --- frame 40: character should still be on ground, not re-jump -itest:add_action(time_trigger(19, true), function () -end) - --- check that player char has moved to the right and fell -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.grounded == stage.state.player_char.motion_state, "Expected motion state 'airborne', got "..stage.state.player_char.motion_state - local is_position_expected, position_message = almost_eq_with_message(vector(4, 80.), stage.state.player_char:get_bottom_center(), 1/256) - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(0, 0), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message..position_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message..velocity_message.."\n" - end - - end - - return success, final_message -end - -itest = integration_test('platformer jump air accel', {stage.state.type}) -itest_manager:register(itest) - -itest.setup = function () - setup_map_data() - - -- add tiles where the character will move - mset(0, 10, 64) - - flow:change_gamestate_by_type(stage.state.type) - - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer - - -- start full jump and immediately try to move right - stage.state.player_char.jump_intention = true -- will be consumed - stage.state.player_char.hold_jump_intention = true -end - -itest.teardown = function () - clear_map() - teardown_map_data() -end +-- calculation notes --- wait 2 frame (1 to register jump, 1 to confirm and leave ground) then move to the right --- this is just to avoid starting moving on the ground, as we only want to test air control here, --- not how ground speed is transferred to air velocity -itest:add_action(time_trigger(2, true), function () - stage.state.player_char.move_intention = vector(1, 0) -end) +-- ground speed start at 40 for fast startup (velocity will be updated on first frame) +-- wait 29 frames and stop --- wait for the apogee (frame 31) and stop --- at frame 1: pos (4, 80), velocity (0, 0), grounded (waits 1 frame before confirming hop/jump) --- at frame 2: pos (4, 80 - 3.25), velocity (0, -3.25), airborne (do not apply gravity on first frame of jump, no air accel yet) --- at frame 3: pos (4 + 0.046875, 80 - 49.84375), velocity (0.046875, -3.140625), airborne -> accel forward --- at frame 30: pos (4 + 19.03125, 80 - 49.84375), velocity (1.3125, -0.1875), airborne -> before apogee --- at frame 31: pos (4 + 20.390625, 80 - 49.921875), velocity (1.359375, -0.078125), airborne -> reached apogee (100px in 16-bit, matches SPG on Jumping) --- at frame 32: pos (4 + 21.796875, 80 - 49.890625), velocity (1.40625, 0.03125), airborne -> starts going down --- at frame 61: pos (4 + 82.96875, 80 - 1.40625), velocity (2.765625, 3.203125), airborne -> about to land --- at frame 62: pos (4 + 85.78125, 80), velocity (2.8125, 0), grounded -> has landed, preserve x speed -itest:add_action(time_trigger(29, true), function () end) - --- check that player char has moved to the right and fell -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.airborne == stage.state.player_char.motion_state, "Expected motion state 'airborne', got "..stage.state.player_char.motion_state - local is_position_expected, position_message = almost_eq_with_message(vector(4 + 20.390625, 80 - 49.921875), stage.state.player_char:get_bottom_center(), 1/256) - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(1.359375, -0.078125), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message..position_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message..velocity_message.."\n" - end - - end - - return success, final_message -end - ---]] - ---[[ +-- character will be blocked when right wall sensor is at x = 16.5, so when center will be at x = 13 --- bugfix history: --- + revealed that spawn_at was not resetting state vars, so added _setup method -itest = integration_test('platformer ground wall block right', {stage.state.type}) -itest_manager:register(itest) +-- if move intention is applied after slope factor (or both are applied, then ground speed is clamped as we should): +-- at frame 1: pos (7, 14), velocity (3, 0), grounded +-- at frame 2: pos (7 + 0x0002.c589 = 9.771621704102, 13), velocity (3, 0), grounded +-- at frame 3: pos (7 + 2 * 0x0002.c589 = 12.543243408204, 11), velocity (3, 0), grounded +-- at frame 4: pos (13, 11), velocity (3, 0), grounded -itest.setup = function () - setup_map_data() +-- in practice, slope after is applied after intention, causing a slight decel: - -- X - -- XXX - mset(0, 10, 64) -- to walk on - mset(1, 10, 64) -- to walk on - mset(2, 10, 64) -- for now, we need supporting block - mset(2, 9, 64) -- blocking wall +-- frame 2: ground speed 2.9995 +-- frame 3: ground speed 2.9991 - flow:change_gamestate_by_type(stage.state.type) +-- however, this strongly depends on the slope factor x intention combination before clamping +-- and is likely to change, so no need to test this far for being blocked by the final ceiling - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer - -- start moving to the right from frame 0 by setting intention in setup - stage.state.player_char.move_intention = vector(1, 0) -end +-- air motion -itest.teardown = function () - clear_map() - teardown_map_data() -end +-- bugfix history: +-- . test failed because initial character position was wrong in the test +-- * test failed in pico8 only because in _compute_signed_distance_to_closest_ground, +-- I was setting min_signed_distance = 32768 = -32767 +itest_dsl_parser.register( + 'platformer land vertical', [[ +@stage # +. +. +. +# +warp 4 0 +wait 21 --- wait 29 frames and stop --- to compute position x from x0 after n frames at accel a from speed s0: x = x0 + n*s0 + n(n+1)/2*a --- to compute speed s from s0 after n frames at accel a: x = s0 + n*a --- character will be blocked when right wall sensor is at x = 16, so when center is at x = 12 --- at frame 1: pos (4 + 0.0234375, 80), velocity (0.0234375, 0), grounded --- at frame 27: pos (12.8359375, 80 - 8), velocity (0.6328125, 0), about to meet wall --- at frame 28: pos (13, 80 - 8), velocity (0, 0), hit wall -itest:add_action(time_trigger(28, true), function () end) - --- check that player char has moved to the right and is still on the ground -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.grounded == stage.state.player_char.motion_state, "Expected motion state 'grounded', got "..stage.state.player_char.motion_state - -- to compute position x from x0 after n frames at accel a from speed s0: x = x0 + n*s0 + n(n+1)/2*a - local is_position_expected, position_message = almost_eq_with_message(vector(13., 80.), stage.state.player_char:get_bottom_center(), 1/256) - -- to compute speed s from s0 after n frames at accel a: x = s0 + n*a - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(0, 0), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message..position_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message..velocity_message.."\n" - end - - end - - return success, final_message -end - ---]] - ---[[ - -itest = integration_test('platformer slope wall block right', {stage.state.type}) -itest_manager:register(itest) - -itest.setup = function () - setup_map_data() - - mset(0, 10, 64) -- to walk on - mset(1, 10, 64) -- support ground for slope - mset(1, 9, 67) -- ascending slope 22.5 to walk on - mset(2, 8, 64) -- blocking wall at the top of the slope - - flow:change_gamestate_by_type(stage.state.type) - - -- respawn character on the ground (important to always start with grounded state) - stage.state.player_char:spawn_at(vector(4., 80. - pc_data.center_height_standing)) -- set bottom y at 80 - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.platformer - - -- start moving to the right from frame 0 by setting intention in setup - stage.state.player_char.move_intention = vector(1, 0) - -- cheat for fast startup (velocity will be updated on first frame) - stage.state.player_char.ground_speed = 40 -end - -itest.teardown = function () - clear_map() - teardown_map_data() -end +expect pc_bottom_pos 4 24 +expect pc_motion_state grounded +expect pc_ground_spd 0 +expect pc_velocity 0 0 +]]) --- wait 29 frames and stop --- to compute position x from x0 after n frames at accel a from speed s0: x = x0 + n*s0 + n(n+1)/2*a --- to compute speed s from s0 after n frames at accel a: x = s0 + n*a --- character will be blocked when right wall sensor is at x = 16, so when center is at x = 12 --- remember character must reach x=13 (not visible, inside frame calculation) to detect the wall, then snap to 12! --- at frame 1: pos (4 + 0.0234375, 80), velocity (0.0234375, 0), grounded - --- at frame 12: bpos (5.828125, 80), velocity (0.28125, 0), ground_speed(0.28125) --- at frame 13: bpos (6.1328125, 79), velocity (0.3046875, 0), ground_speed(0.3046875), first step on slope and at higher level than flat ground, acknowledge slope as current ground --- at frame 14: bpos (6.333572387695, 79), velocity (0.2007598876953125, -0.2007598876953125), ground_speed(0.283935546875), because slope was current ground at frame start, slope factor was applied with 0.0625*sin(45) = -0.044189453125 (in PICO-8 16.16 fixed point precision) --- at frame 15: bpos (6.519668501758, 79), velocity (0.1860961140625, -0.1860961140625), ground_speed(0.26318359375), still under slope factor effect and velocity following slope tangent --- problem: with slope 45, character slows down and never get past x=7 --- instead, we just cheat and add an extra speed, then just check the final position after a long time enough to reach the block at the top - --- at frame 27: pos (12.8359375, 80 - 8), velocity (0.6328125, 0), about to meet wall --- at frame 28: pos (13, 80 - 8), velocity (0, 0), hit wall - --- at frame 1: bpos (4.0234375, 80), velocity (0.0234375, 0), ground_speed(0.0234375) --- at frame 9: bpos (5.0546875, 80), velocity (0.2109375, 0), ground_speed(0.2109375) --- at frame 10: bpos (5.2890625, 80), velocity (0.234375, 0), ground_speed(0.234375) --- at frame 11: bpos (5.546875, 80), velocity (0.2578125, 0), ground_speed(0.2578125) - --- even at 22.5, character doesn't manage to climb up perfectly and oscillates near the top... - --- note that speed decrease on slope is not implemented yet (via cosine but also gravity), so this test will have to change when it is --- when it is, prefer passing a very low slope or apply slope factor to adapt the position/velocity calculation - -itest:add_action(time_trigger(28, true), function () end) - --- check that player char has moved to the right and is still on the ground -itest.final_assertion = function () - local is_motion_state_expected, motion_state_message = motion_states.grounded == stage.state.player_char.motion_state, "Expected motion state 'grounded', got "..stage.state.player_char.motion_state - -- to compute position x from x0 after n frames at accel a from speed s0: x = x0 + n*s0 + n(n+1)/2*a - -- actually 13 if we use more narrow ground sensor - local is_position_expected, position_message = almost_eq_with_message(vector(13, 80 - 8), stage.state.player_char:get_bottom_center(), 1/256) - -- to compute speed s from s0 after n frames at accel a: x = s0 + n*a - local is_ground_speed_expected, ground_speed_message = almost_eq_with_message(0, stage.state.player_char.ground_speed, 1/256) - local is_velocity_expected, velocity_message = almost_eq_with_message(vector(0, 0), stage.state.player_char.velocity, 1/256) - - local final_message = "" - - local success = is_position_expected and is_ground_speed_expected and is_velocity_expected and is_motion_state_expected - if not success then - if not is_motion_state_expected then - final_message = final_message..motion_state_message.."\n" - end - if not is_position_expected then - final_message = final_message..position_message.."\n" - end - if not is_ground_speed_expected then - final_message = final_message..ground_speed_message.."\n" - end - if not is_velocity_expected then - final_message = final_message..velocity_message.."\n" - end - - end - - return success, final_message -end - ---]] - ---[[#pico8 --- human test for pico8 only to check rendering -- bugfix history: --- = fixed character pivot computed from drawn sprite topleft (with some gap above character's head) --- and not actual sprite topleft in the spritesheet -itest = integration_test('character is correctly rendered idle', {stage.state.type}) -itest_manager:register(itest) +-- ! identified bug in _update_platformer_motion where absence of elseif +-- allowed to enter both grounded and airborne update, causing 2x update when leaving the cliff +-- * revealed that new system always flooring pixel position x caused leaving cliff +-- frame later, adding a grounded frame with friction +itest_dsl_parser.register( + 'platformer fall cliff', [[ +@stage # +.. +## + +warp 4 8 +move right +wait 36 +stop +wait 24 + +expect pc_bottom_pos 39.859375 40.8125 +expect pc_motion_state airborne +expect pc_ground_spd 0 +expect pc_velocity 0.84375 2.625 +]]) + +-- calculation notes: +-- at frame 1: pos (17.9453125, 8), velocity (0.796875, 0), grounded +-- at frame 34: pos (17.9453125, 8), velocity (0.796875, 0), grounded +-- at frame 35: pos (18.765625, 8), velocity (0.8203125, 0), grounded (do not apply ground sensor extent: -2.5 directly, floor to full px first) +-- at frame 36: pos (19.609375, 8), velocity (0.84375, 0), airborne (flr_x=19) -> stop accel +-- wait 24 frames and stop +-- gravity during 24 frames: accel = 0.109375 * (24 * 25 / 2), velocity = 0.109375 * 24 = 2.625 +-- at frame 60: pos (39.859375, 8 + 32.8125), velocity (0.84375, 2.625), airborne + +itest_dsl_parser.register( + 'platformer hop flat', [[ +@stage # +. +# + +warp 4 8 +jump +stop_jump +wait 20 + +expect pc_bottom_pos 4 -11.296875 +expect pc_motion_state airborne +expect pc_ground_spd 0 +expect pc_velocity 0 -0.03125 +]]) + +-- calculation notes + +-- wait for apogee (frame 20) and stop +-- at frame 1: bpos (4, 8), velocity (0, 0), grounded (waits 1 frame before confirming hop/jump) +-- at frame 2: bpos (4, 8 - 2), velocity (0, -2), airborne (hop confirmed, no gravity applied this frame) +-- at frame 3: bpos (4, 8 - 3.890625), velocity (0, -1.890625), airborne +-- at frame 19: pos (4, 8 - 19.265625), velocity (0, -0.140625), airborne -> before apogee +-- at frame 20: pos (4, 8 - 19.296875), velocity (0, -0.03125), airborne -> reached apogee +-- at frame 21: pos (4, 8 - 19.21875), velocity (0, 0.078125), airborne -> starts going down +-- at frame 38: pos (4, 8 - 1.15625), velocity (0, 1.9375), airborne -> about to land +-- at frame 39: pos (4, 8), velocity (0, 0), grounded -> has landed + +-- => apogee at y = 8 - 19.296875 = -11.296875 + + +itest_dsl_parser.register( + 'platformer jump start flat', [[ +@stage # +. +# + +warp 4 8 +jump +wait 2 + +expect pc_bottom_pos 4 4.75 +expect pc_motion_state airborne +expect pc_ground_spd 0 +expect pc_velocity 0 -3.25 +]]) + + +itest_dsl_parser.register( + 'platformer jump interrupt flat', [[ +@stage # +. +# + +warp 4 8 +jump +wait 4 +stop_jump +wait 1 + +expect pc_bottom_pos 4 -3.421875 +expect pc_motion_state airborne +expect pc_ground_spd 0 +expect pc_velocity 0 -2 +]]) + +-- calculation notes + +-- interrupt variable jump at the end of frame 2 +-- at frame 1: bpos (4, 8), velocity (0, 0), grounded (waits 1 frame before confirming hop/jump) +-- at frame 2: bpos (4, 8 - 3.25), velocity (0, -3.25), airborne (jump confirmed) +-- at frame 3: bpos (4, 8 - 6.390625), velocity (0, -3.140625), airborne +-- at frame 4: bpos (4, 8 - 9.421875), velocity (0, -3.03125), airborne +-- at frame 5: bpos (4, 8 - 11.421875), velocity (0, -2), airborne (interrupt jump, no extra gravity) + + +itest_dsl_parser.register( + 'platformer small jump flat', [[ +@stage # +. +# + +warp 4 8 +jump +wait 4 +stop_jump +wait 6 + +expect pc_bottom_pos 4 -11.78125 +expect pc_motion_state airborne +expect pc_ground_spd 0 +expect pc_velocity 0 -1.453125 +]]) + +-- calculation notes + +-- frames 1-5 is same as 'platformer jump interrupt flat' -itest.setup = function () - setup_map_data() +-- wait 5 frames and stop +-- at frame 6: bpos (4, 8 - 13.3125), velocity (0, -1.890625), airborne +-- at frame 7: bpos (4, 8 - 15.09375), velocity (0, -1.78125), airborne +-- at frame 8: bpos (4, 8 - 16.765625), velocity (0, -1.671875), airborne +-- at frame 9: bpos (4, 8 - 18.328125), velocity (0, -1.5625), airborne +-- at frame 10: bpos (4, 8 - 19.78125), velocity (0, -1.453125), airborne - mset(0, 10, 64) -- to stand on - flow:change_gamestate_by_type(stage.state.type) - stage.state.player_char:warp_bottom_to(vector(4., 80.)) - stage.state.player_char.control_mode = control_modes.puppet - stage.state.player_char.motion_mode = motion_modes.debug -end +itest_dsl_parser.register( + 'platformer full jump flat', [[ +@stage # +. +# -itest.teardown = function () - clear_map() - teardown_map_data() -end +warp 4 8 +jump +wait 31 --- wait just 0.1 second so the character can be rendered at least 1 frame because the test pauses -itest:add_action(time_trigger(1.), function () end) +expect pc_bottom_pos 4 -41.921875 +expect pc_motion_state airborne +expect pc_ground_spd 0 +expect pc_velocity 0 -0.078125 +]]) --- no final assertion, let the user check if result is correct or not (note it will display success whatever) --- #pico8]] +-- calculation notes + +-- wait for the apogee (frame 31) and stop +-- at frame 1: bpos (4, 8), velocity (0, 0), grounded (waits 1 frame before confirming hop/jump) +-- at frame 2: bpos (4, 8 - 3.25), velocity (0, -3.25), airborne (do not apply gravity on first frame of jump since we were grounded) +-- at frame 30: bpos (4, 8 - 49.84375), velocity (0, -0.1875), airborne -> before apogee +-- at frame 31: bpos (4, 8 - 49.921875), velocity (0, -0.078125), airborne -> reached apogee (100px in 16-bit, matches SPG on Jumping) +-- at frame 32: bpos (4, 8 - 49.890625), velocity (0, 0.03125), airborne -> starts going down +-- at frame 61: bpos (4, 8 - 1.40625), velocity (0, 3.203125), airborne -> about to land +-- at frame 62: bpos (4, 8), velocity (0, 0), grounded -> has landed + + +itest_dsl_parser.register( + 'ignore hold jump landing', [[ +@stage # +. +# + +warp 4 8 +jump +stop_jump +wait 20 +set_control_mode human +press o +wait 20 + +expect pc_bottom_pos 4 8 +expect pc_motion_state grounded +expect pc_ground_spd 0 +expect pc_velocity 0 0 +]]) + +-- if the player presses the jump button in mid-air, the character should not +-- jump again when he lands on the ground (later, it will trigger a special action) + +-- input note: +-- this is an end-to-end test because we don't want bother with how mid-air predicitve jump order is ignored +-- indeed, if it is ignored by ignoring the input itself, then hijacking the jump_intention +-- in puppet mode will prove nothing +-- if it is ignored by resetting the jump intention on land, the puppet test would be useful +-- to show that the intention itself is reset, but here we only want to ensure the end-to-end behavior is correct +-- so we us a human control mode and hijack the input directly + +-- calculation notes: +-- wait for apogee (frame 20) and stop +-- at frame 1: bpos (4, 8), velocity (0, 0), grounded (waits 1 frame before confirming hop/jump) +-- at frame 2: bpos (4, 8 - 2), velocity (0, -2), airborne (hop confirmed) +-- at frame 3: bpos (4, 8 - 3.890625), velocity (0, -1.890625), airborne (hop confirmed) +-- at frame 19: bpos (4, 8 - 19.265625), velocity (0, -0.140625), airborne -> before apogee +-- at frame 20: bpos (4, 8 - 19.296875), velocity (0, -0.03125), airborne -> reached apogee +-- at frame 21: bpos (4, 8 - 19.21875), velocity (0, 0.078125), airborne -> starts going down +-- at frame 38: bpos (4, 8 - 1.15625), velocity (0, 1.9375), airborne -> about to land +-- at frame 39: bpos (4, 8), velocity (0, 0), grounded -> has landed + +-- and wait an extra frame to see if Sonic will jump due to holding jump input, +-- so stop at frame 40 + + +itest_dsl_parser.register( + 'platformer jump air accel', [[ +@stage # +. +# + +warp 4 8 +jump +wait 2 +move right +wait 29 + +expect pc_bottom_pos 24.390625 -41.921875 +expect pc_motion_state airborne +expect pc_ground_spd 0 +expect pc_velocity 1.359375 -0.078125 +]]) + + +itest_dsl_parser.register( + 'platformer air right wall block', [[ +@stage # +.# +.. +#. + +warp 4 16 +jump +stop_jump +wait 1 +move right +wait 9 + +expect pc_bottom_pos 5 1.9375 +expect pc_motion_state airborne +expect pc_ground_spd 0 +expect pc_velocity 0 -1.125 +]]) + +-- calculation notes: +-- start jump input +-- at frame 1: bpos (4, 16), velocity (0, 0), grounded +-- wait 1 frame to confirm hop, and start moving right, then wait 9 frames +-- at frame 2: bpos (4 + .046875, 16 - 2), velocity (3/64, -2), airborne (hop) +-- at frame 3: bpos (4.140625, 16 - 3.890625), velocity (6/64, -1 - 57/64), airborne +-- at frame 4: bpos (4.28125, 16 - 5.671875), velocity (9/64, -1 - 50/64), airborne +-- at frame 5: bpos (4.46875, 16 - 7.34375), velocity (12/64, -1 - 43/64), airborne +-- at frame 6: bpos (4.703125, 16 - 8.90625), velocity (15/64, -1 - 36/64), airborne +-- at frame 7: bpos (4.984375, 16 - 10.359375), velocity (18/64, -1 - 29/64), airborne +-- after 7 frames, we are almost touching the wall above +-- at frame 8: bpos (5, 16 - 11.703125), velocity (18/64, -1 - 22/64), airborne (hit wall) +-- after 8 frames, we have hit the wall +-- at frame 9: bpos (5, 16 - 12.9375), velocity (0, -1 - 15/64), airborne (hit wall) +-- at frame 10: bpos (5, 16 - 14.0625), velocity (0, -1 - 8/64), airborne (hit wall) + +-- /64 format is nice, but I need to make a helper +-- that converts floats to this format if I want a meaningful +-- comparison with itest trace log + + +itest_dsl_parser.register( + 'platformer air left wall block', [[ +@stage # +#. +.. +.# + +warp 12 16 +jump +stop_jump +wait 1 +move left +wait 9 + +expect pc_bottom_pos 11 1.9375 +expect pc_motion_state airborne +expect pc_ground_spd 0 +expect pc_velocity 0 -1.125 +]]) + +itest_dsl_parser.register( + 'platformer air ceiling block', [[ +@stage # +# +. +. +. +# + +warp 4 32 +jump +wait 4 + +expect pc_bottom_pos 4 24 +expect pc_motion_state airborne +expect pc_ground_spd 0 +expect pc_velocity 0 0 +]]) + +-- calculation notes + +-- we are still using sonic standing height during jump +-- so he will hit ceiling at bottom pos = 24 = 32 - 8 + +-- wait for the apogee (frame 31) and stop +-- frame bottom pos velocity state event +-- 1 (4, 32) (0, 0) grounded +-- 2 (4, 32 - 3 - 16/64) (0, -3 - 16/64) airborne confirm jump (no gravity on first frame) +-- 3 (4, 32 - 6 - 25/64) (0, -3 - 9/64) airborne +-- 4 (4, 32 - 8) (0, 0) airborne hit ceiling + +-- keep calculation below for later, when sonic will have half height during spin +-- 4 (4, 32 - 9 - 27/64) (0, -3 - 2/64) airborne +-- 5 (4, 32 - 12 - 22/64) (0, -2 - 59/64) airborne +-- 6 (4, 32 - 15 - 10/64) (0, -2 - 52/64) airborne +-- 7 (4, 32 - 16) (0, 0) airborne hit ceiling + + +-- human tests: let human check rendering (until I find a way to automate this) +-- they have no final assertion, and will always succeed +-- although it's about rendering, we don't strip them from busted headless itests so we can debug the trace without having to run pico8 + +-- bugfix history: +-- = fixed character pivot computed from drawn sprite topleft (with some gap above character's head) +-- and not actual sprite topleft in the spritesheet +itest_dsl_parser.register( + 'pc anim idle right', [[ +@stage # +. +# + +warp 4 8 +wait 60 +]]) + +itest_dsl_parser.register( + 'pc anim idle left', [[ +@stage # +. +# + +warp 4 8 +move left +wait 1 +stop +wait 59 +]]) +-- note: due to flooring, character will go 1px to the left in only 1 frame, +-- so it will look offset compared to the previous test with right + + +itest_dsl_parser.register( + 'pc anim run left and fall', [[ +@stage # +... +### + +warp 20 8 +move left +wait 60 +]]) + + +itest_dsl_parser.register( + 'pc anim run right and fall', [[ +@stage # +... +### + +warp 4 8 +move right +wait 60 +]]) diff --git a/src/game/main.lua b/src/game/main.lua index a2bed560..881b66e3 100644 --- a/src/game/main.lua +++ b/src/game/main.lua @@ -1,9 +1,12 @@ -local gameapp = require("game/application/gameapp") +local picosonic_app = require("game/application/picosonic_app") local gamestate_proxy = require("game/application/gamestate_proxy") +local gamestate = require("game/application/gamestate") --#if log local logging = require("engine/debug/logging") logging.logger:register_stream(logging.console_log_stream) +logging.logger:register_stream(logging.file_log_stream) +logging.logger.active_categories["trace"] = false --#if visual_logger local vlogger = require("engine/debug/visual_logger") @@ -18,8 +21,9 @@ local profiler = require("engine/debug/profiler") profiler.window:show() --#endif ---#if tuner +-- always require code tuner, since ifn tuned, `tuned` will simply use the default value local codetuner = require("engine/debug/codetuner") +--#if tuner codetuner:show() codetuner.active = true --#endif @@ -31,16 +35,24 @@ input:toggle_mouse(true) -- pico-8 functions must be placed at the end to be parsed by p8tool +local app = picosonic_app() + function _init() +--#if log + -- clear log file on new game session + logging.file_log_stream:clear() +--#endif + -- require all gamestate modules, according to preprocessing step gamestate_proxy:require_gamestates() - gameapp.init() + app.initial_gamestate = gamestate.types.titlemenu + app:start() end function _update60() - gameapp.update() + app:update() end function _draw() - gameapp.draw() + app:draw() end diff --git a/src/game/platformer/motion.lua b/src/game/platformer/motion.lua new file mode 100644 index 00000000..36b4fdbe --- /dev/null +++ b/src/game/platformer/motion.lua @@ -0,0 +1,86 @@ +local motion = {} + +-- physics notes: collisions use fixed-point floating coordinates +-- to support fractional coordinates like classic sonic uses for motion. +-- therefore, we don't use pixel perfect collisions, +-- e.g. "touching" does not occur when two aabb's pixel representations +-- with 1px wide borders touch, but when their exact borders coincide + +-- struct containing the result of a ground detection test +local ground_query_info = new_struct() +motion.ground_query_info = ground_query_info + +-- signed_distance float signed distance to the detected ground (clamped to min-1 amd max+1) +-- slope_angle float|nil slope angle of the detected ground (nil if no ground) +function ground_query_info:_init(signed_distance, slope_angle) + self.signed_distance = signed_distance + self.slope_angle = slope_angle +end + +--#if log +function ground_query_info:_tostring() + return "ground_query_info("..joinstr(", ", self.signed_distance, tostr(self.slope_angle))..")" +end +--#endif + + +-- struct representing the expected result of a character ground move over a frame, +-- computed step by step. similar to a raycast hit info, specialized for ground motion +local ground_motion_result = new_struct() +motion.ground_motion_result = ground_motion_result + +-- position vector position at the end of motion +-- slope_angle float|nil slope angle of the final position (nil if is_falling is true) +-- is_blocked bool was the character blocked during motion? +-- is_falling bool should the character fall after this motion? +function ground_motion_result:_init(position, slope_angle, is_blocked, is_falling) + self.position = position + self.slope_angle = slope_angle + self.is_blocked = is_blocked + self.is_falling = is_falling +end + +--#if log +function ground_motion_result:_tostring() + return "ground_motion_result("..joinstr(", ", self.position, self.slope_angle, self.is_blocked, self.is_falling)..")" +end +--#endif + + +-- struct representing the expected result of a character air move over a frame, +-- computed step by step. similar to a raycast hit info, specialized for air motion +local air_motion_result = new_struct() +motion.air_motion_result = air_motion_result + +-- position vector position at the end of motion +-- is_blocked_by_wall bool was the character blocked by a left/right wall during motion? +-- is_blocked_by_ceiling bool was the character blocked by a ceiling during motion? +-- is_landing bool has the character landed at the end of this motion? +-- slope_angle float|nil slope angle of the final position (nil unless is_landing is true) +function air_motion_result:_init(position, is_blocked_by_wall, is_blocked_by_ceiling, is_landing, slope_angle) + self.position = position + self.is_blocked_by_wall = is_blocked_by_wall + self.is_blocked_by_ceiling = is_blocked_by_ceiling + self.is_landing = is_landing + self.slope_angle = slope_angle +end + +-- return true iff motion result indicates a blocker in the given direction +function air_motion_result:is_blocked_along(direction) + if direction == directions.left or direction == directions.right then + return self.is_blocked_by_wall + elseif direction == directions.up then + return self.is_blocked_by_ceiling + else -- direction == directions.down + return self.is_landing + end +end + +--#if log +function air_motion_result:_tostring() + return "air_motion_result("..joinstr(", ", + self.position, self.is_blocked_by_wall, self.is_blocked_by_ceiling, self.is_landing, self.slope_angle)..")" +end +--#endif + +return motion diff --git a/src/game/platformer/tile.lua b/src/game/platformer/tile.lua new file mode 100644 index 00000000..c41529bd --- /dev/null +++ b/src/game/platformer/tile.lua @@ -0,0 +1,74 @@ +require("engine/core/class") + +local tile = {} + +local tile_data = new_struct() +tile.tile_data = tile_data + +-- id_loc sprite_id_location sprite location on the spritesheet +-- slope_angle float slope angle in turn ratio (0.0 to 1.0, positive clockwise) +function tile_data:_init(id_loc, slope_angle) + self.id_loc = id_loc + self.slope_angle = slope_angle +end + +--#if log +function tile_data:_tostring() + return "tile_data("..joinstr(", ", self.id_loc:_tostring(), self.slope_angle)..")" +end +--#endif + + +local height_array = new_struct() +tile.height_array = height_array + +-- tile_data_value tile_data tile data to generate the height array from +-- _array [int] sequence of heights of a tile collision mask column per index, +-- counting index from the left, height from the bottom +-- it is filled based on tile_mask_id_location +-- slope_angle float slope angle in turn ratio (0.0 to 1.0) +function height_array:_init(tile_data_value) + self._array = {} + self._fill_array(self._array, tile_data_value.id_loc) + self.slope_angle = tile_data_value.slope_angle +end + +--#if log +function height_array:_tostring() + return "height_array("..joinstr(", ", "{"..joinstr_table(", ", self._array).."}", self.slope_angle)..")" +end +--#endif + +-- return the height for a column index starting at 0, from left to right +function height_array:get_height(column_index0) + return self._array[column_index0 + 1] -- adapt 0-index to 1-index +end + + +-- fill the passed array with height data based on the sprite mask +-- located at tile_mask_id_location: sprite_id_location +-- pass an empty array so it is only filled with the computed values +-- the tile mask must represent the collision mask of a tile, with columns +-- of non-transparent (black) pixels filled from the bottom, +-- or at least the upper edge of said mask (we don't check what is below +-- the edge once we found the first non-transparent pixel from top to bottom) +function height_array._fill_array(array, tile_mask_id_location) + local tile_mask_topleft_position = tile_mask_id_location:to_topleft_position() + -- iterate over columns from left to right, searching for the highest filled pixel + for dx = 0, tile_size - 1 do + -- iterate from the top of the column and stop at the first filled pixel (we assume + -- lower pixels are also filled for readability of the tile mask, but not enforced) + local mask_height = 0 + for dy = 0, tile_size - 1 do + local tile_mask_color = sget(tile_mask_topleft_position.x + dx, tile_mask_topleft_position.y + dy) + -- we use black (0) as transparent mask color + if tile_mask_color ~= 0 then + mask_height = tile_size - dy + break + end + end + add(array, mask_height) + end +end + +return tile diff --git a/src/engine/physics/world.lua b/src/game/platformer/world.lua similarity index 95% rename from src/engine/physics/world.lua rename to src/game/platformer/world.lua index 4f307100..d51a1358 100644 --- a/src/engine/physics/world.lua +++ b/src/game/platformer/world.lua @@ -1,4 +1,5 @@ -local collision = require("engine/physics/collision") +local tile = require("game/platformer/tile") +-- engine > game reference to clean local collision_data = require("game/data/collision_data") local world = {} @@ -24,7 +25,7 @@ function world._compute_column_height_at(tile_location, column_index0) if tile_data_value then -- optimize: cache collision height array on game start (otherwise, we get all the data every time, -- including the unused slope angle) - local h_array = collision.height_array(tile_data_value) + local h_array = tile.height_array(tile_data_value) return h_array:get_height(column_index0), h_array.slope_angle end diff --git a/src/game/resources/visual.lua b/src/game/resources/visual.lua index 13ef15d1..b8ad0526 100644 --- a/src/game/resources/visual.lua +++ b/src/game/resources/visual.lua @@ -1,4 +1,4 @@ -require("engine/render/sprite") +local sprite_data = require("engine/render/sprite_data") local visual = {} diff --git a/src/game/sandbox.lua b/src/game/sandbox.lua index 429cf739..4023cd81 100644 --- a/src/game/sandbox.lua +++ b/src/game/sandbox.lua @@ -1,4 +1,5 @@ require("engine/core/math") +require("engine/core/helper") -- caveats @@ -9,3 +10,20 @@ require("engine/core/math") printh("27"..vector(11, 45)) -- or -- printh(tostr(27)..vector(11, 45)) + +s = [[ +1 + +2]] + +lines = strspl(s, "\n") + +-- COMMENT +--[[BLOCk +COMMENT]] +for line in all(lines) do + print("line: "..line) +end + +--[[ +--]] diff --git a/src/game/test_data/tile_test_data.lua b/src/game/test_data/tile_test_data.lua index 23c94fd6..f9a495e3 100644 --- a/src/game/test_data/tile_test_data.lua +++ b/src/game/test_data/tile_test_data.lua @@ -1,7 +1,7 @@ --#ifn pico8 -require("pico8api") -local collision = require("engine/physics/collision") +require("engine/test/pico8api") +local tile = require("game/platformer/tile") local collision_data = require("game/data/collision_data") local stub = require("luassert.stub") @@ -23,7 +23,7 @@ function tile_test_data.setup() fset(73, sprite_flags.collision, true) -- high-tile (3/4 filled) -- mock height array _init so it doesn't have to dig in sprite data, inaccessible from busted - height_array_init_mock = stub(collision.height_array, "_init", function (self, tile_data) + height_array_init_mock = stub(tile.height_array, "_init", function (self, tile_data) local tile_mask_id_location = tile_data.id_loc if tile_mask_id_location == collision_data.tiles_data[64].id_loc then self._array = {8, 8, 8, 8, 8, 8, 8, 8} -- full tile @@ -59,6 +59,15 @@ function tile_test_data.teardown() height_array_init_mock:revert() end +-- helper safety function that verifies that mock tile data is active when creating mock maps for utests +-- always use it instead of mset in utest setup meant to test collisions +function mock_mset(x, y, v) + -- verify that tile_test_data.setup has been called since the last tile_test_data.teardown + -- just check if the mock of height_array exists and is active + assert(height_array_init_mock and not height_array_init_mock.reverted, "mock_mset: tile_test_data.setup has not been called since the last tile_test_data.teardown") + mset(x, y, v) +end + return tile_test_data --#endif diff --git a/tests/utestcredits.lua b/src/game/tests/utestcredits.lua similarity index 96% rename from tests/utestcredits.lua rename to src/game/tests/utestcredits.lua index 932b7d6b..ae78b66a 100644 --- a/tests/utestcredits.lua +++ b/src/game/tests/utestcredits.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local credits = require("game/menu/credits") local gamestate = require("game/application/gamestate") diff --git a/tests/utestdummy_gamestate.lua b/src/game/tests/utestdummy_gamestate.lua similarity index 97% rename from tests/utestdummy_gamestate.lua rename to src/game/tests/utestdummy_gamestate.lua index 4c3f2c8b..73f8e83f 100644 --- a/tests/utestdummy_gamestate.lua +++ b/src/game/tests/utestdummy_gamestate.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local dummy_gamestate = require("game/application/dummy_gamestate") describe('dummy_gamestate', function () diff --git a/tests/utestgamestate_proxy.lua b/src/game/tests/utestgamestate_proxy.lua similarity index 89% rename from tests/utestgamestate_proxy.lua rename to src/game/tests/utestgamestate_proxy.lua index 0684d91f..354b5bf8 100644 --- a/tests/utestgamestate_proxy.lua +++ b/src/game/tests/utestgamestate_proxy.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local gamestate_proxy = require("game/application/gamestate_proxy") describe('gamestate_proxy', function () @@ -30,7 +30,7 @@ describe('gamestate_proxy', function () assert.has_error(function () gamestate_proxy:get("invalid") end, - "gamestate_proxy:get: self._state_modules[module_name] is nil, make sure you have called gamestate_proxy:require_gamestates before") + "gamestate_proxy:get: self._state_modules['invalid'] is nil, make sure you have called gamestate_proxy:require_gamestates before") end) describe('(when modules have been required)', function () diff --git a/tests/itests/utestheadless_itests.lua b/src/game/tests/utestheadless_itests.lua similarity index 53% rename from tests/itests/utestheadless_itests.lua rename to src/game/tests/utestheadless_itests.lua index 65d1b28f..0bf44a61 100644 --- a/tests/itests/utestheadless_itests.lua +++ b/src/game/tests/utestheadless_itests.lua @@ -1,8 +1,14 @@ -require("bustedhelper") +require("engine/test/bustedhelper") require("engine/test/integrationtest") -local gameapp = require("game/application/gameapp") +local picosonic_app = require("game/application/picosonic_app") local gamestate_proxy = require("game/application/gamestate_proxy") +-- check options +local should_render = false +if contains(arg, "--render") then + should_render = true +end + local function find_all_scripts(dir) local files = {} local p = io.popen('find "'..dir..'" -type f -name *.lua') @@ -25,8 +31,11 @@ end describe('headless itest', function () - after_each(function () - gameapp.reinit_modules() + local app = picosonic_app() + + setup(function () + app.initial_gamestate = "titlemenu" + itest_runner.app = app end) -- define a headless unit test for each registered itest so far @@ -35,18 +44,25 @@ describe('headless itest', function () local itest = itest_manager.itests[i] it(itest.name..' should succeed', function () + -- just require the gamestates you need for this itest + -- (in practice, any gamestate module required at least once by an itest will be loaded + -- anyway; this will just redirect untested gamestates to a dummy to avoid useless processing) + gamestate_proxy:require_gamestates(itest.active_gamestates) itest_manager:init_game_and_start_by_index(i) - while integration_test_runner.current_state == test_states.running do - integration_test_runner:update_game_and_test() + while itest_runner.current_state == test_states.running do + itest_runner:update_game_and_test() + if should_render then + itest_runner:draw_game_and_test() + end end local itest_fail_message = nil - if integration_test_runner.current_message then - itest_fail_message = "itest '"..itest.name.."' ended with "..integration_test_runner.current_state.." due to:\n"..integration_test_runner.current_message + if itest_runner.current_message then + itest_fail_message = "itest '"..itest.name.."' ended with "..itest_runner.current_state.." due to:\n"..itest_runner.current_message end - assert.are_equal(test_states.success, integration_test_runner.current_state, itest_fail_message) + assert.are_equal(test_states.success, itest_runner.current_state, itest_fail_message) end) diff --git a/src/game/tests/utestitest_dsl.lua b/src/game/tests/utestitest_dsl.lua new file mode 100644 index 00000000..64b590ca --- /dev/null +++ b/src/game/tests/utestitest_dsl.lua @@ -0,0 +1,1060 @@ +require("engine/test/bustedhelper") +require("engine/core/helper") +require("engine/core/math") +local itest_dsl = require("game/itests/itest_dsl") +local gameplay_value_data, generate_function_table = get_members(itest_dsl, + "gameplay_value_data", "generate_function_table") +local eval_pc_bottom_pos, eval_pc_velocity, eval_pc_ground_spd, eval_pc_motion_state, eval_pc_slope = get_members(itest_dsl, + "eval_pc_bottom_pos", "eval_pc_velocity", "eval_pc_ground_spd", "eval_pc_motion_state", "eval_pc_slope") +local command, expectation = get_members(itest_dsl, + "command", "expectation") +local dsl_itest, itest_dsl_parser = get_members(itest_dsl, + "dsl_itest", "itest_dsl_parser") +local integrationtest = require("engine/test/integrationtest") +local itest_manager, time_trigger, integration_test = get_members(integrationtest, + "itest_manager", "time_trigger", "integration_test") +local flow = require("engine/application/flow") +local input = require("engine/input/input") +local gamestate = require("game/application/gamestate") +local gamestate_proxy = require("game/application/gamestate_proxy") +local stage = require("game/ingame/stage") +local tilemap = require("engine/data/tilemap") +local player_char = require("game/ingame/playercharacter") +local pc_data = require("game/data/playercharacter_data") + + +describe('itest_dsl', function () + + describe('gameplay_value_data', function () + + describe('_init', function () + it('should create gameplay value data', function () + local data = gameplay_value_data("position", parsable_types.vector) + assert.is_not_nil(data) + assert.are_same({"position", parsable_types.vector}, {data.name, data.parsable_type}) + end) + end) + + end) + + describe('generate_function_table', function () + + it('should assert when the number of arguments is wrong', function () + local enum_types = {a = 10, b = 20} + local module = { + use_a = function() end, + use_b = function() end + } + local function_table = generate_function_table(module, enum_types, "use_") + assert.are_same({[10] = module.use_a, [20] = module.use_b}, function_table) + end) + + end) + + describe('parse_', function () + + describe('parse_none', function () + + it('should assert when the number of arguments is wrong', function () + assert.has_error(function () + itest_dsl.parse_none({"too many"}) + end, "parse_none: got 1 args, expected 0") + end) + + it('should return nil', function () + assert.is_nil(itest_dsl.parse_none({})) + end) + + end) + + describe('parse_number', function () + + it('should assert when the number of arguments is wrong', function () + assert.has_error(function () + itest_dsl.parse_number({"too", "many"}) + end, "parse_number: got 2 args, expected 1") + end) + + it('should return the single string argument as number', function () + assert.are_equal(5, itest_dsl.parse_number({"5"})) + end) + + end) + + describe('parse_vector', function () + + it('should assert when the number of arguments is wrong', function () + assert.has_error(function () + itest_dsl.parse_vector({"too few"}) + end, "parse_vector: got 1 args, expected 2") + end) + + it('should return the 2 coordinate string arguments as vector', function () + assert.are_equal(vector(2, -3.5), itest_dsl.parse_vector({"2", "-3.5"})) + end) + + end) + + describe('parse_horizontal_dir', function () + + it('should assert when the number of arguments is wrong', function () + assert.has_error(function () + itest_dsl.parse_horizontal_dir({"too", "many"}) + end, "parse_horizontal_dir: got 2 args, expected 1") + end) + + it('should return the single argument as horizontal direction', function () + assert.are_equal(horizontal_dirs.right, itest_dsl.parse_horizontal_dir({"right"})) + end) + + end) + + describe('parse_control_mode', function () + + it('should assert when the number of arguments is wrong', function () + assert.has_error(function () + itest_dsl.parse_control_mode({"too", "many"}) + end, "parse_control_mode: got 2 args, expected 1") + end) + + it('should return the single argument as control mode', function () + assert.are_equal(control_modes.ai, itest_dsl.parse_control_mode({"ai"})) + end) + + end) + + describe('parse_motion_mode', function () + + it('should assert when the number of arguments is wrong', function () + assert.has_error(function () + itest_dsl.parse_motion_mode({"too", "many"}) + end, "parse_motion_mode: got 2 args, expected 1") + end) + + it('should return the single argument as motion mode', function () + assert.are_equal(motion_modes.debug, itest_dsl.parse_motion_mode({"debug"})) + end) + + end) + + describe('parse_button_id', function () + + it('should assert when the number of arguments is wrong', function () + assert.has_error(function () + itest_dsl.parse_button_id({"too", "many"}) + end, "parse_button_id: got 2 args, expected 1") + end) + + it('should return the single argument as motion mode', function () + assert.are_equal(button_ids.o, itest_dsl.parse_button_id({"o"})) + end) + + end) + + describe('parse_motion_state', function () + + it('should assert when the number of arguments is wrong', function () + assert.has_error(function () + itest_dsl.parse_motion_state({"too", "many"}) + end, "parse_motion_state: got 2 args, expected 1") + end) + + it('should return the single argument as motion state', function () + assert.are_equal(motion_states.airborne, itest_dsl.parse_motion_state({"airborne"})) + end) + + end) + + describe('parse_gp_value', function () + + it('should assert when the number of arguments is wrong', function () + assert.has_error(function () + itest_dsl.parse_gp_value({"too few"}) + end, "parse_gp_value: got 1 args, expected at least 2") + end) + + it('should return the gameplay value type string and the expected value, itself recursively parsed', function () + assert.are_same({"pc_bottom_pos", vector(1, 3)}, + {itest_dsl.parse_gp_value({"pc_bottom_pos", "1", "3"})}) + end) + + end) + + end) + + describe('execute_', function () + + before_each(function () + -- some executions require the player character + stage.state.player_char = player_char() + end) + + after_each(function () + -- clean up dummy player character + stage.state:init() + end) + + describe('execute_warp', function () + + setup(function () + spy.on(player_char, "warp_bottom_to") + end) + + teardown(function () + player_char.warp_bottom_to:revert() + end) + + it('should call warp_bottom_to on the current player character', function () + itest_dsl.execute_warp({vector(1, 3)}) + + assert.spy(player_char.warp_bottom_to).was_called(1) + assert.spy(player_char.warp_bottom_to).was_called_with(match.ref(stage.state.player_char), vector(1, 3)) + end) + + end) + + describe('"execute_set', function () + + it('#solo should set pc velocity to (1, -3)', function () + itest_dsl.execute_set({"pc_velocity", vector(1, -3)}) + assert.are_equal(vector(1, -3), stage.state.player_char.velocity) + end) + + it('#solo should fail with unsupported gp_value_type for setting', function () + assert.has_error(function () + itest_dsl.execute_set({"pc_slope", -2}) + end, "itest_dsl.set_pc_slope is not defined") + end) + + end) + + describe('execute_set_control_mode', function () + + it('should set the control mode', function () + itest_dsl.execute_set_control_mode({control_modes.puppet}) + assert.are_equal(control_modes.puppet, stage.state.player_char.control_mode) + end) + + end) + + describe('execute_set_motion_mode', function () + + it('should set the motion mode', function () + itest_dsl.execute_set_motion_mode({motion_modes.debug}) + assert.are_equal(motion_modes.debug, stage.state.player_char.motion_mode) + end) + + end) + + describe('execute_move', function () + + it('should set the move intention of the current player character to the directional unit vector matching his horizontal direction', function () + itest_dsl.execute_move({horizontal_dirs.right}) + assert.are_equal(vector(1, 0), stage.state.player_char.move_intention) + end) + + end) + + describe('execute_stop', function () + + it('should set the move intention of the current player character to vector zero', function () + stage.state.player_char.move_intention = vector(99, -99) + itest_dsl.execute_stop({}) + assert.are_equal(vector.zero(), stage.state.player_char.move_intention) + end) + + end) + + describe('execute_jump', function () + + it('should set the jump intention and hold jump intention to true', function () + itest_dsl.execute_jump({}) + assert.are_same({true, true}, + {stage.state.player_char.jump_intention, stage.state.player_char.hold_jump_intention}) + end) + + end) + + describe('execute_stop_jump', function () + + it('should set the hold jump intention to false', function () + stage.state.player_char.hold_jump_intention = true + itest_dsl.execute_stop_jump({}) + assert.is_false(stage.state.player_char.hold_jump_intention) + end) + + end) + + describe('execute_press', function () + + it('should set the simulated button down state to true', function () + input.simulated_buttons_down[0][button_ids.x] = false + itest_dsl.execute_press({button_ids.x}) + assert.is_true(input.simulated_buttons_down[0][button_ids.x]) + end) + + end) + + describe('execute_release', function () + + it('should set the simulated button down state to true', function () + input.simulated_buttons_down[0][button_ids.up] = true + itest_dsl.execute_release({button_ids.up}) + assert.is_false(input.simulated_buttons_down[0][button_ids.up]) + end) + + end) + + end) + + describe('eval_', function () + + before_each(function () + -- some evaluators require the player character + stage.state.player_char = player_char() + end) + + after_each(function () + -- clean up dummy player character + stage.state:init() + end) + + describe('eval_pc_bottom_pos', function () + + it('should return the bottom position of the current player character', function () + stage.state.player_char:set_bottom_center(vector(12, 47)) + assert.are_equal(vector(12, 47), eval_pc_bottom_pos()) + end) + + end) + + describe('eval_pc_velocity', function () + + it('should return the velocity the current player character', function () + stage.state.player_char.velocity = vector(1, -4) + assert.are_equal(vector(1, -4), eval_pc_velocity()) + end) + + end) + + describe('eval_pc_ground_spd', function () + + it('should return the ground speed current player character', function () + stage.state.player_char.ground_speed = 3.5 + assert.are_equal(3.5, eval_pc_ground_spd()) + end) + + end) + + describe('eval_pc_motion_state', function () + + it('should return the ground speed current player character', function () + stage.state.player_char.motion_state = motion_states.airborne + assert.are_equal(motion_states.airborne, eval_pc_motion_state()) + end) + + end) + + describe('eval_pc_slope', function () + + it('should return the ground speed current player character', function () + stage.state.player_char.slope_angle = -0.125 + assert.are_equal(-0.125, eval_pc_slope()) + end) + + end) + + end) + + + describe('set_', function () + + before_each(function () + -- some setters require the player character + stage.state.player_char = player_char() + end) + + after_each(function () + -- clean up dummy player character + stage.state:init() + end) + + describe('set_pc_velocity', function () + + it('should return the velocity the current player character', function () + itest_dsl.set_pc_velocity(vector(1, -4)) + assert.are_equal(vector(1, -4), stage.state.player_char.velocity) + end) + + end) + + describe('set_pc_ground_spd', function () + + it('should return the ground speed current player character', function () + itest_dsl.set_pc_ground_spd(3.5) + assert.are_equal(3.5, stage.state.player_char.ground_speed) + end) + + end) + + end) + + + describe('command', function () + + describe('_init', function () + it('should create a new dsl itest', function () + local cmd = command(command_types.move, {horizontal_dirs.left}) + assert.is_not_nil(cmd) + assert.are_same({command_types.move, {horizontal_dirs.left}}, {cmd.type, cmd.args}) + end) + end) + + end) + + describe('expectation', function () + + describe('_init', function () + it('should create a new dsl itest', function () + local exp = expectation("pc_bottom_pos", 24) + assert.is_not_nil(exp) + assert.are_same({"pc_bottom_pos", 24}, {exp.gp_value_type_str, exp.expected_value}) + end) + end) + + end) + + describe('dsl_itest', function () + + describe('_init', function () + it('should create a new dsl itest', function () + local dsli = dsl_itest() + assert.is_not_nil(dsli) + assert.are_same({nil, nil, nil}, {dsli.gamestate_type, dsli.stage_name, dsli.commands}) + end) + end) + + end) + + describe('itest_dsl_parser', function () + + setup(function () + -- spying should be enough, but we stub so it's easier to call these functions + -- without calling the symmetrical one (e.g. teardown may fail with nil reference + -- if setup is not called first) + stub(_G, "setup_map_data") + stub(_G, "teardown_map_data") + end) + + teardown(function () + setup_map_data:revert() + teardown_map_data:revert() + end) + + after_each(function () + itest_dsl_parser:init() + flow:init() + stage.state:init() + pico8:clear_map() + setup_map_data:clear() + teardown_map_data:clear() + end) + + describe('init', function () + assert.are_same({ + nil, + nil, + {} + }, + { + itest_dsl_parser._itest, + itest_dsl_parser._last_time_trigger, + itest_dsl_parser._final_expectations + }) + end) + + describe('register', function () + + setup(function () + -- mock parse + stub(itest_dsl_parser, "parse", function (dsli_source) + return dsli_source.."_parsed" + end) + -- mock create_itest + stub(itest_dsl_parser, "create_itest", function (name, dsli) + return name..": "..dsli.."_itest" + end) + end) + + teardown(function () + itest_dsl_parser.parse:revert() + itest_dsl_parser.create_itest:revert() + end) + + after_each(function () + itest_manager:init() + end) + + it('should parse, create and register an itest by name and source', function () + itest_dsl_parser.register("my test", "dsl_source") + assert.are_equal(1, #itest_manager.itests) + assert.are_equal("my test: dsl_source_parsed_itest", itest_manager.itests[1]) + end) + + end) + + describe('parse', function () + + setup(function () + stub(itest_dsl_parser, "parse_gamestate_definition", function (lines) + local tile_id = string_tonum(lines[3]) + return lines[1], + lines[2], + tilemap({ + { 0, tile_id}, + {tile_id, 0} + }), + 5 + end) + stub(itest_dsl_parser, "parse_action_sequence", function (lines, next_line_index) + return { + command(command_types[lines[next_line_index]], { vector(1, 2) } ), + command(command_types[lines[next_line_index+1]], {gp_value_types.pc_bottom_pos, vector(3, 4)}) + } + end) + end) + + teardown(function () + itest_dsl_parser.parse_gamestate_definition:revert() + itest_dsl_parser.parse_action_sequence:revert() + end) + + -- bugfix history: + -- + spot tilemap not being set, although parse_gamestate_definition worked, so the error is in the glue code + it('should parse the itest source written in domain-specific language into a dsl itest', function () + local dsli_source = [[ +stage +# +64 + +warp +expect +]] + + local dsli = itest_dsl_parser.parse(dsli_source) + + -- interface + assert.is_not_nil(dsli) + assert.are_same( + { + 'stage', + '#', + tilemap({ + { 0, 64}, + {64, 0} + }), + { + command(command_types.warp, { vector(1, 2) } ), + command(command_types.expect, {gp_value_types.pc_bottom_pos, vector(3, 4)}) + } + }, + { + dsli.gamestate_type, + dsli.stage_name, + dsli.tilemap, + dsli.commands + }) + end) + + end) + + + describe('parse_gamestate_definition', function () + + it('should return gamestate name, nil, nil and 3 for a non-stage gamestate and no extra line', function () + local dsli_lines = {"@titlemenu"} + local gamestate_type, stage_name, tm, next_line_index = itest_dsl_parser.parse_gamestate_definition(dsli_lines) + assert.are_same( + { + 'titlemenu', + nil, + nil, + 3 + }, + { + gamestate_type, + stage_name, + tm, + next_line_index + }) + end) + + it('should return \'stage\', the stage name, nil and 4 for a pre-defined stage definition after 1 blank line', function () + local dsli_lines = { + "@stage test1", + "", + "", + "???" + } + local gamestate_type, stage_name, tm, next_line_index = itest_dsl_parser.parse_gamestate_definition(dsli_lines) + assert.are_same( + { + 'stage', + "test1", + nil, + 3 + }, + { + gamestate_type, + stage_name, + tm, + next_line_index + }) + end) + + describe('(mocking parse_tilemap)', function () + + setup(function () + stub(itest_dsl_parser, "parse_tilemap", function () + return tilemap({ + {70, 64}, + {64, 70} + }), 5 + end) + end) + + teardown(function () + itest_dsl_parser.parse_tilemap:revert() + end) + + it('should return \'stage\', \'#\', tilemap data and 6 for a custom stage definition finishing at line 5 (including blank line)', function () + local dsli_lines = { + "@stage #", + "[this part is ignored, mocked parse_tilemap]", + "[will return predefined tilemap]" + } + + local gamestate_type, stage_name, tm, next_line_index = itest_dsl_parser.parse_gamestate_definition(dsli_lines) + + -- interface + assert.are_same( + { + 'stage', + '#', + tilemap({ + {70, 64}, + {64, 70} + }), + 5 + }, + { + gamestate_type, + stage_name, + tm, + next_line_index + }) + end) + + end) + + end) + + -- bugfix history: + -- + removed "local" in "local width =" inside loop after applying trimming + -- to lines to support " \" multilines + describe('parse_tilemap', function () + + it('should return an empty tilemap data if the 2nd line is blank', function () + local tilemap_text = { + "@stage # (ignored)", + "", + ".... (ignored)", -- next line: 3 + ".... (ignored)" + } + local tm, next_line_index = itest_dsl_parser.parse_tilemap(tilemap_text) + assert.are_same( + { + tilemap({}), + 3 + }, + {tm, next_line_index}) + end) + + it('should return a tilemap data with tiles corresponding to the tile symbols in the string', function () + local tilemap_text = { + "@stage # (ignored)", + "....", + "##..", + "..##", + "", + "(ignored)", -- next line: 6 + "(ignored)" + } + local tm, next_line_index = itest_dsl_parser.parse_tilemap(tilemap_text) + assert.are_same( + { + tilemap({ + { 0, 0, 0, 0}, + {64, 64, 0, 0}, + { 0, 0, 64, 64} + }), + 6 + }, + {tm, next_line_index}) + end) + + it('should assert if there as fewer than 2 lines', function () + local tilemap_text = { + "?" + } + assert.has_error(function () + itest_dsl_parser.parse_tilemap(tilemap_text) + end, "only 1 line(s), need at least 2") + end) + + it('should assert if there are too many blocks', function () + local tilemap_text = { + "@stage # (ignored)", + "... ..." + } + assert.has_error(function () + itest_dsl_parser.parse_tilemap(tilemap_text) + end, "too many blocks: 2, expected 1") + end) + + it('should assert if line width is inconsistent', function () + local tilemap_text = { + "@stage # (ignored)", + "....", + "..." + } + assert.has_error(function () + itest_dsl_parser.parse_tilemap(tilemap_text) + end, "inconsistent line length: 3 vs 4") + end) + + it('should assert if unknown tile symbol is found', function () + local tilemap_text = { + "@stage # (ignored)", + "?" + } + assert.has_error(function () + itest_dsl_parser.parse_tilemap(tilemap_text) + end, "unknown tile symbol: ?") + end) + + end) + + describe('parse_action_sequence', function () + + it('should return a sequence of commands read in lines, starting at next_line_index', function () + local dsli_lines = { + "???", + "???", + "???", + "", + "warp 12 45", + "wait 1", + "move left", + "wait 2", + "expect pc_bottom_pos 10 45", + "expect pc_velocity 2 -3.5" + } + local commands = itest_dsl_parser.parse_action_sequence(dsli_lines, 5) + assert.are_same( + { + command(command_types.warp, { vector(12, 45) } ), + command(command_types.wait, { 1 } ), + command(command_types.move, { horizontal_dirs.left } ), + command(command_types.wait, { 2 } ), + command(command_types.expect, {"pc_bottom_pos", vector(10, 45)}), + command(command_types.expect, {"pc_velocity", vector(2, -3.5)}), + }, + commands) + end) + + it('should assert if an unknown command is found', function () + local dsli_lines = { + "???", + "???", + "???", + "", + "unknown ? ?", + } + assert.has_error(function () + itest_dsl_parser.parse_action_sequence(dsli_lines, 5) + end, + "no command type named 'unknown'") + end) + + end) + + describe('create_itest', function () + + it('should create an itest with a name and a dsl itest', function () + local dsli = dsl_itest() + dsli.gamestate_type = 'stage' + dsli.stage_name = "test1" + dsli.tilemap = nil + dsli.commands = { + command(command_types.warp, { vector(12, 45) } ), + command(command_types.wait, { 10 } ), + command(command_types.wait, { 1 } ), + command(command_types.move, { horizontal_dirs.left } ), + command(command_types.wait, { 2 } ), + command(command_types.expect, {"pc_bottom_pos", vector(10, 45)}), + command(command_types.expect, {"pc_velocity", vector(2, -3.5)}), + } + + local test = itest_dsl_parser.create_itest("test 1", dsli) + + -- interface + assert.is_not_nil(test) + assert.are_equal(4, #test.action_sequence) + assert.are_same({ + "test 1", + {'stage'}, + time_trigger(0, true), -- warp immediately + scripted_action(time_trigger(10, true), nil), -- empty action after 10 frames + time_trigger(1, true), -- start moving after 1 frame + scripted_action(time_trigger(2, true), nil) -- empty action after 2 frames + }, + { + test.name, + test.active_gamestates, + test.action_sequence[1].trigger, + test.action_sequence[2], + test.action_sequence[3].trigger, + test.action_sequence[4] + }) + + -- we could not directly test if generated functions are correct + -- they were generated from parameters passed dynamically, + -- so it's impossible to find the references back (except for dummy) + -- instead, we call the functions one by one and see if we get + -- the expected result + -- note that most actions depend on the previous one, so we exceptionally + -- assert multiple times in chain in a single utest + + -- simulate the itest runner behavior by initializing active gamestates for this test + gamestate_proxy:require_gamestates(test.active_gamestates) + + -- simulate picosonic_app:register_gamestates once gamestates have been registered, + -- so gamestates are available when starting an itest (we could also use the picosonic_app directly) + for state in all(test.active_gamestates) do + flow:add_gamestate(gamestate_proxy:get(state)) + end + + -- verify setup callback behavior + test.setup() + assert.are_equal(gamestate.types.stage, flow.curr_state.type) + + -- verify warp callback behavior + test.action_sequence[1].callback() + assert.is_not_nil(stage.state.player_char) + assert.are_equal(vector(12, 45 - pc_data.center_height_standing), stage.state.player_char.position) + + -- verify move callback behavior + test.action_sequence[3].callback() + assert.are_equal(vector(-1, 0), stage.state.player_char.move_intention) + + -- we have not passed time so the character cannot have reached expected position + -- note we are testing as busted, so we get the almost_eq messages + local expected_message = "\nFor gameplay value 'player character bottom position':\nExpected objects to be almost equal with eps: 0.015625.\n".. + "Passed in:\nvector(12, 45)\nExpected:\nvector(10, 45)\n".. + "\nFor gameplay value 'player character velocity':\nExpected objects to be almost equal with eps: 0.015625.\n".. + "Passed in:\nvector(0, 0)\nExpected:\nvector(2, -3.5)\n" + assert.are_same({false, expected_message}, {test.final_assertion()}) + + -- but if we cheat and warp him on the spot, final assertion will work + stage.state.player_char:set_bottom_center(vector(10, 45)) + stage.state.player_char.velocity = vector(2, -3.5) + assert.are_same({true, ""}, {test.final_assertion()}) + + -- verify that parser state is cleaned up, ready for next parsing + assert.are_same({ + nil, + nil, + {} + }, + { + itest_dsl_parser._itest, + itest_dsl_parser._last_time_trigger, + itest_dsl_parser._final_expectations + }) + end) + + describe('(spying tilemap load)', function () + + setup(function () + stub(tilemap, "load") + stub(tilemap, "clear_map") + end) + + teardown(function () + tilemap.load:revert() + tilemap.clear_map:revert() + end) + + after_each(function () + tilemap.load:clear() + tilemap.clear_map:clear() + end) + + it('setup should call setup_map_data and load on the tilemap if custom stage definition', function () + local dsli = dsl_itest() + dsli.gamestate_type = 'stage' + dsli.stage_name = "#" + dsli.tilemap = tilemap({}) + dsli.commands = {} + + local test = itest_dsl_parser.create_itest("test 1", dsli) + + -- simulate the itest runner behavior by initializing active gamestates for this test + gamestate_proxy:require_gamestates(test.active_gamestates) + + -- simulate picosonic_app:register_gamestates once gamestates have been registered, + -- so gamestates are available when starting an itest (we could also use the picosonic_app directly) + for state in all(test.active_gamestates) do + flow:add_gamestate(gamestate_proxy:get(state)) + end + + test.setup() + + -- interface + assert.are_equal(control_modes.puppet, stage.state.player_char.control_mode) + + -- implementation + local s_data = assert.spy(setup_map_data) + s_data.was_called(1) + s_data.was_called_with() + local s_load = assert.spy(tilemap.load) + s_load.was_called(1) + s_load.was_called_with(match.ref(dsli.tilemap)) + end) + + it('teardown should call clear_map and teardown_map_data if custom stage definition', function () + local dsli = dsl_itest() + dsli.gamestate_type = 'stage' + dsli.stage_name = "#" + dsli.tilemap = tilemap({}) + dsli.commands = {} + + local test = itest_dsl_parser.create_itest("test 1", dsli) + + gamestate_proxy:require_gamestates(test.active_gamestates) + test.teardown() + + -- implementation + local s_clear = assert.spy(tilemap.clear_map) + s_clear.was_called(1) + s_clear.was_called_with() + local s_teardown = assert.spy(teardown_map_data) + s_teardown.was_called(1) + s_teardown.was_called_with() + end) + + end) + + end) + + describe('_act', function () + + local function f() end + + before_each(function () + itest_dsl_parser._itest = integration_test("test 1", {}) + end) + + after_each(function () + itest_manager:init() + end) + + it('should add an action after an existing time trigger, and clear the last time trigger', function () + itest_dsl_parser._last_time_trigger = time_trigger(3, true) + itest_dsl_parser:_act(f) + assert.are_equal(1, #itest_dsl_parser._itest.action_sequence) + local action = itest_dsl_parser._itest.action_sequence[1] + assert.are_same({time_trigger(3, true), f, + nil}, + {action.trigger, action.callback, + itest_dsl_parser._last_time_trigger}) + end) + + end) + + describe('_wait', function () + + before_each(function () + itest_dsl_parser._itest = integration_test('test', {}) + end) + + it('should set the current time_trigger of the parser to one with the passed interval, in frames', function () + itest_dsl_parser:_wait(12) + assert.are_equal(time_trigger(12, true), itest_dsl_parser._last_time_trigger) + end) + + it('should add a dummy action with any previous time trigger, then set the last time trigger to the new one', function () + itest_dsl_parser._last_time_trigger = time_trigger(4, true) + itest_dsl_parser:_wait(8) + assert.are_equal(1, #itest_dsl_parser._itest.action_sequence) + local action = itest_dsl_parser._itest.action_sequence[1] + assert.are_same({time_trigger(4, true), nil}, {action.trigger, action.callback}) + assert.are_equal(time_trigger(8, true), itest_dsl_parser._last_time_trigger) + end) + + end) + + describe('_define_final_assertion', function () + + setup(function () + -- mock evaluators + itest_dsl.evaluators[gp_value_types.pc_bottom_pos] = function () + return vector(27, 30) + end + itest_dsl.evaluators[gp_value_types.pc_velocity] = function () + return vector(-3, 2.5) + end + end) + + teardown(function () + -- reset evaluators + itest_dsl.evaluators = generate_function_table(itest_dsl, gp_value_types, "eval_") + end) + + before_each(function () + itest_dsl_parser._itest = integration_test('test', {}) + end) + + it('#solo should set the final assertion as returning true, message when the gameplay value is expected', function () + itest_dsl_parser._final_expectations = { + expectation("pc_bottom_pos", vector(27, 30)), + expectation("pc_velocity", vector(-3, 2.5)) + } + itest_dsl_parser:_define_final_assertion() + assert.are_same({true, ""}, {itest_dsl_parser._itest.final_assertion()}) + end) + + it('#solo should set the final assertion as returning false, message when the gameplay value is not expected', function () + itest_dsl_parser._final_expectations = { + expectation("pc_bottom_pos", vector(27, 30)), -- ok + expectation("pc_velocity", vector(-3, 7.5)) -- different from actual + } + itest_dsl_parser:_define_final_assertion() + local expected_message = "\nFor gameplay value 'player character velocity':\nExpected objects to be almost equal with eps: 0.015625.\n".. + "Passed in:\nvector(-3, 2.5)\nExpected:\nvector(-3, 7.5)\n" + assert.are_same({false, expected_message}, {itest_dsl_parser._itest.final_assertion()}) + end) + + it('should assert when the passed gameplay value type is invalid', function () + itest_dsl_parser._itest = integration_test('test', {}) + assert.has_error(function () + itest_dsl_parser._itest.final_assertion() + end) + end) + + end) + + end) + +end) diff --git a/src/game/tests/utestmotion.lua b/src/game/tests/utestmotion.lua new file mode 100644 index 00000000..de5096d8 --- /dev/null +++ b/src/game/tests/utestmotion.lua @@ -0,0 +1,124 @@ +require("engine/test/bustedhelper") +local motion = require("game/platformer/motion") +local ground_query_info = motion.ground_query_info +local ground_motion_result, air_motion_result = get_members(motion, + "ground_motion_result", "air_motion_result") + +describe('motion', function () + + describe('ground_query_info', function () + + describe('_init', function () + + it('should create a ground_query_info with signed_distance, slope_angle', function () + local info = ground_query_info(-2.0, 0.25) + assert.are_same({-2.0, 0.25}, {info.signed_distance, info.slope_angle}) + end) + + end) + + describe('_tostring', function () + + it('should return "ground_query_info({self.signed_distance}, 0.125)"', function () + local info = ground_query_info(-2.0, 0.25) + assert.are_equal("ground_query_info(-2.0, 0.25)", info:_tostring()) + end) + + it('should return "ground_query_info({self.signed_distance}, [nil])"', function () + local info = ground_query_info(-2.0, nil) + assert.are_equal("ground_query_info(-2.0, [nil])", info:_tostring()) + end) + + end) + + end) + + describe('ground_motion_result', function () + + describe('_init', function () + + it('should create a ground_motion_result with position, slope_angle, is_blocked, is_falling', function () + local gmr = ground_motion_result(vector(2, 3), 0.25, true, false) + assert.are_same({vector(2, 3), 0.25, true, false}, {gmr.position, gmr.slope_angle, gmr.is_blocked, gmr.is_falling}) + end) + + end) + + describe('_tostring', function () + + it('should return "ground_motion_result(vector(2, 3), 0.25, true, false)"', function () + local gmr = ground_motion_result(vector(2, 3), 0.25, true, false) + assert.are_equal("ground_motion_result(vector(2, 3), 0.25, true, false)", gmr:_tostring()) + end) + + end) + + end) + + describe('air_motion_result', function () + + describe('_init', function () + + it('should create a air_motion_result with position, is_blocked_by_wall, is_blocked_by_ceiling, is_landing, slope_angle', function () + local gmr = air_motion_result(vector(2, 3), true, false, true, -0.25) + assert.are_same({vector(2, 3), true, false, true, -0.25}, {gmr.position, gmr.is_blocked_by_wall, gmr.is_blocked_by_ceiling, gmr.is_landing, gmr.slope_angle}) + end) + + end) + + describe('is_blocked_along', function () + + it('return false if direction is left and is_blocked_by_wall is false', function () + local gmr = air_motion_result(vector(2, 3), false, false, false) + assert.is_false(gmr:is_blocked_along(directions.left)) + end) + + it('return true if direction is left and is_blocked_by_wall is true', function () + local gmr = air_motion_result(vector(2, 3), true, false, false) + assert.is_true(gmr:is_blocked_along(directions.left)) + end) + + it('return false if direction is right and is_blocked_by_wall is false', function () + local gmr = air_motion_result(vector(2, 3), false, false, false) + assert.is_false(gmr:is_blocked_along(directions.right)) + end) + + it('return true if direction is right and is_blocked_by_wall is true', function () + local gmr = air_motion_result(vector(2, 3), true, false, false) + assert.is_true(gmr:is_blocked_along(directions.right)) + end) + + it('return false if direction is up and is_blocked_by_ceiling is false', function () + local gmr = air_motion_result(vector(2, 3), false, false, false) + assert.is_false(gmr:is_blocked_along(directions.up)) + end) + + it('return true if direction is up and is_blocked_by_ceiling is true', function () + local gmr = air_motion_result(vector(2, 3), false, true, false) + assert.is_true(gmr:is_blocked_along(directions.up)) + end) + + it('return false if direction is down and is_landing is false', function () + local gmr = air_motion_result(vector(2, 3), false, false, false) + assert.is_false(gmr:is_blocked_along(directions.down)) + end) + + it('return true if direction is down and is_landing is true', function () + local gmr = air_motion_result(vector(2, 3), false, false, true) + assert.is_true(gmr:is_blocked_along(directions.down)) + end) + + end) + + describe('_tostring', function () + + it('should return "air_motion_result(vector(2, 3), true, false, true, -0.25)"', function () + local gmr = air_motion_result(vector(2, 3), true, false, true, -0.25) + assert.are_equal("air_motion_result(vector(2, 3), true, false, true, -0.25)", gmr:_tostring()) + end) + + end) + + end) + +end) diff --git a/src/game/tests/utestpicosonic_app.lua b/src/game/tests/utestpicosonic_app.lua new file mode 100644 index 00000000..8ba88801 --- /dev/null +++ b/src/game/tests/utestpicosonic_app.lua @@ -0,0 +1,181 @@ +require("engine/test/bustedhelper") +local picosonic_app = require("game/application/picosonic_app") + +local flow = require("engine/application/flow") +local codetuner = require("engine/debug/codetuner") +local profiler = require("engine/debug/profiler") +local vlogger = require("engine/debug/visual_logger") +local ui = require("engine/ui/ui") +local gamestate_proxy = require("game/application/gamestate_proxy") +local titlemenu = require("game/menu/titlemenu_dummy") +local credits = require("game/menu/credits_dummy") +local stage = require("game/ingame/stage") +local visual = require("game/resources/visual") + +describe('picosonic_app', function () + + local app + + before_each(function () + app = picosonic_app() + end) + + describe('register_gamestates', function () + + it('should add all gamestates', function () + -- require the real stage (as we required "stage" not "stage_dummy" at the top + -- but leave the other states as dummy + gamestate_proxy:require_gamestates({"stage"}) + picosonic_app:register_gamestates() + + -- interface + assert.are_equal(titlemenu.state, flow.gamestates[titlemenu.state.type]) + assert.are_equal(credits.state, flow.gamestates[credits.state.type]) + assert.are_equal(stage.state, flow.gamestates[stage.state.type]) + end) + + end) + + describe('on_start', function () + + setup(function () + stub(ui, "set_cursor_sprite_data") + end) + + teardown(function () + ui.set_cursor_sprite_data:revert() + end) + + after_each(function () + ui.set_cursor_sprite_data:clear() + end) + + it('should set the ui cursor sprite data', function () + app.on_start() + local s = assert.spy(ui.set_cursor_sprite_data) + s.was_called(1) + s.was_called_with(match.ref(ui), match.ref(visual.sprite_data_t.cursor)) + end) + + end) + + describe('on_reset (#utest only)', function () + + setup(function () + stub(ui, "set_cursor_sprite_data") + end) + + teardown(function () + ui.set_cursor_sprite_data:revert() + end) + + after_each(function () + ui.set_cursor_sprite_data:clear() + end) + + it('should reset the ui cursor sprite data', function () + picosonic_app:on_reset() + local s = assert.spy(ui.set_cursor_sprite_data) + s.was_called(1) + s.was_called_with(match.ref(ui), nil) + end) + + end) + + describe('on_update', function () + + setup(function () + stub(vlogger.window, "update") + stub(profiler.window, "update") + stub(codetuner, "update_window") + end) + + teardown(function () + vlogger.window.update:revert() + profiler.window.update:revert() + codetuner.update_window:revert() + end) + + after_each(function () + vlogger.window.update:clear() + profiler.window.update:clear() + codetuner.update_window:clear() + end) + + it('should update the vlogger window', function () + picosonic_app:on_update() + local s = assert.spy(vlogger.window.update) + s.was_called(1) + s.was_called_with(match.ref(vlogger.window)) + end) + + it('should update the profiler window', function () + picosonic_app:on_update() + local s = assert.spy(profiler.window.update) + s.was_called(1) + s.was_called_with(match.ref(profiler.window)) + end) + + it('should update the codetuner window', function () + picosonic_app:on_update() + local s = assert.spy(codetuner.update_window) + s.was_called(1) + s.was_called_with(match.ref(codetuner)) + end) + + end) + + describe('on_render', function () + + setup(function () + stub(vlogger.window, "render") + stub(profiler.window, "render") + stub(codetuner, "render_window") + stub(ui, "render_mouse") + end) + + teardown(function () + vlogger.window.render:revert() + profiler.window.render:revert() + codetuner.render_window:revert() + ui.render_mouse:revert() + end) + + after_each(function () + vlogger.window.render:clear() + profiler.window.render:clear() + codetuner.render_window:clear() + ui.render_mouse:clear() + end) + + it('should render the vlogger window', function () + picosonic_app:on_render() + local s = assert.spy(vlogger.window.render) + s.was_called(1) + s.was_called_with(match.ref(vlogger.window)) + end) + + it('should render the profiler window', function () + picosonic_app:on_render() + local s = assert.spy(profiler.window.render) + s.was_called(1) + s.was_called_with(match.ref(profiler.window)) + end) + + it('should render the codetuner window', function () + picosonic_app:on_render() + local s = assert.spy(codetuner.render_window) + s.was_called(1) + s.was_called_with(match.ref(codetuner)) + end) + + it('should render the mouse', function () + picosonic_app:on_render() + local s = assert.spy(ui.render_mouse) + s.was_called(1) + s.was_called_with(match.ref(ui)) + end) + + end) + +end) diff --git a/tests/utestplayercharacter.lua b/src/game/tests/utestplayercharacter.lua similarity index 58% rename from tests/utestplayercharacter.lua rename to src/game/tests/utestplayercharacter.lua index 37950ef9..f29a3e1a 100644 --- a/tests/utestplayercharacter.lua +++ b/src/game/tests/utestplayercharacter.lua @@ -1,38 +1,75 @@ -require("bustedhelper") +require("engine/test/bustedhelper") require("engine/core/math") local player_char = require("game/ingame/playercharacter") -local collision = require("engine/physics/collision") -local ground_query_info = collision.ground_query_info +local input = require("engine/input/input") +local motion = require("game/platformer/motion") +local ground_query_info = motion.ground_query_info local pc_data = require("game/data/playercharacter_data") local tile_test_data = require("game/test_data/tile_test_data") describe('player_char', function () - -- static method - describe('_compute_max_column_distance', function () + -- static methods + + describe('_compute_max_pixel_distance', function () it('(2, 0) => 0', function () - assert.are_equal(0, player_char._compute_max_column_distance(2, 0)) + assert.are_equal(0, player_char._compute_max_pixel_distance(2, 0)) end) it('(2, 1.5) => 1', function () - assert.are_equal(1, player_char._compute_max_column_distance(2, 1.5)) + assert.are_equal(1, player_char._compute_max_pixel_distance(2, 1.5)) end) it('(2, 3) => 3', function () - assert.are_equal(3, player_char._compute_max_column_distance(2, 3)) + assert.are_equal(3, player_char._compute_max_pixel_distance(2, 3)) end) it('(2.2, 1.7) => 1', function () - assert.are_equal(1, player_char._compute_max_column_distance(2.2, 1.7)) + assert.are_equal(1, player_char._compute_max_pixel_distance(2.2, 1.7)) end) it('(2.2, 1.8) => 2', function () - assert.are_equal(2, player_char._compute_max_column_distance(2.2, 1.8)) + assert.are_equal(2, player_char._compute_max_pixel_distance(2.2, 1.8)) + end) + + -- bugfix history: + -- / I completely forgot the left case, which is important to test flooring asymmetry + -- I thought it was hiding bugs, but I realize my asymmetrical design was actually fine + + it('(2, -0.1) => 1', function () + assert.are_equal(1, player_char._compute_max_pixel_distance(2, -0.1)) + end) + + it('(2, -1) => 1', function () + assert.are_equal(1, player_char._compute_max_pixel_distance(2, -1)) + end) + + it('(2, -1.1) => 2', function () + assert.are_equal(2, player_char._compute_max_pixel_distance(2, -1.1)) + end) + + it('(2.2, -0.2) => 0', function () + assert.are_equal(0, player_char._compute_max_pixel_distance(2.2, -0.2)) + end) + + it('(2.2, -0.3) => 1', function () + assert.are_equal(1, player_char._compute_max_pixel_distance(2.2, -0.3)) + end) + + it('(2.2, -1.2) => 1', function () + assert.are_equal(1, player_char._compute_max_pixel_distance(2.2, -1.2)) + end) + + it('(2.2, -1.3) => 2', function () + assert.are_equal(2, player_char._compute_max_pixel_distance(2.2, -1.3)) end) end) + + -- methods + describe('_init', function () setup(function () @@ -56,13 +93,6 @@ describe('player_char', function () assert.spy(player_char._setup).was_called_with(match.ref(pc)) end) - it('should create a player character with control mode: human, motion mode: platformer, motion state: grounded', function () - local pc = player_char() - assert.is_not_nil(pc) - assert.are_same({control_modes.human, motion_modes.platformer, motion_states.grounded}, - {pc.control_mode, pc.motion_mode, pc.motion_state}) - end) - it('should create a player character storing values from playercharacter_data', function () local pc = player_char() assert.is_not_nil(pc) @@ -85,11 +115,23 @@ describe('player_char', function () describe('_setup', function () + setup(function () + spy.on(animated_sprite, "play") + end) + + teardown(function () + animated_sprite.play:revert() + end) + it('should reset the character state vars', function () local pc = player_char() assert.is_not_nil(pc) assert.are_same( { + control_modes.human, + motion_modes.platformer, + motion_states.grounded, + horizontal_dirs.right, vector.zero(), 0, vector.zero(), @@ -100,22 +142,30 @@ describe('player_char', function () false, false, false, - "idle" + false }, { + pc.control_mode, + pc.motion_mode, + pc.motion_state, + pc.horizontal_dir, + pc.position, pc.ground_speed, pc.velocity, pc.debug_velocity, pc.slope_angle, + pc.move_intention, pc.jump_intention, pc.hold_jump_intention, pc.should_jump, - pc.has_interrupted_jump, - pc.current_sprite + pc.has_jumped_this_frame, + pc.has_interrupted_jump } ) + assert.spy(animated_sprite.play).was_called(1) + assert.spy(animated_sprite.play).was_called_with(match.ref(pc.anim_spr), "idle") end) end) @@ -296,6 +346,211 @@ describe('player_char', function () describe('update', function () + setup(function () + stub(player_char, "_handle_input") + stub(player_char, "_update_motion") + stub(animated_sprite, "update") + end) + + teardown(function () + player_char._handle_input:revert() + player_char._update_motion:revert() + animated_sprite.update:revert() + end) + + after_each(function () + player_char._handle_input:clear() + player_char._update_motion:clear() + animated_sprite.update:clear() + end) + + it('should call _handle_input and _update_motion', function () + pc:update() + + -- implementation + assert.spy(pc._handle_input).was_called(1) + assert.spy(pc._handle_input).was_called_with(match.ref(pc)) + assert.spy(pc._update_motion).was_called(1) + assert.spy(pc._update_motion).was_called_with(match.ref(pc)) + assert.spy(animated_sprite.update).was_called(1) + assert.spy(animated_sprite.update).was_called_with(match.ref(pc.anim_spr)) + end) + + end) + + + describe('_handle_input', function () + + setup(function () + stub(player_char, "_toggle_debug_motion") + end) + + teardown(function () + player_char._toggle_debug_motion:revert() + end) + + after_each(function () + input.players_btn_states[0] = generate_initial_btn_states() + + player_char._toggle_debug_motion:clear() + end) + + describe('(when player character control mode is not human)', function () + + before_each(function () + pc.control_mode = control_modes.ai -- or puppet + end) + + it('should do nothing', function () + input.players_btn_states[0][button_ids.left] = btn_states.pressed + pc:_handle_input() + assert.are_equal(vector:zero(), pc.move_intention) + input.players_btn_states[0][button_ids.up] = btn_states.pressed + pc:_handle_input() + assert.are_equal(vector:zero(), pc.move_intention) + end) + + end) + + -- control mode is human by default + + it('(when input left in down) it should update the player character\'s move intention by (-1, 0)', function () + input.players_btn_states[0][button_ids.left] = btn_states.pressed + pc:_handle_input() + assert.are_equal(vector(-1, 0), pc.move_intention) + end) + + it('(when input right in down) it should update the player character\'s move intention by (1, 0)', function () + input.players_btn_states[0][button_ids.right] = btn_states.just_pressed + pc:_handle_input() + assert.are_equal(vector(1, 0), pc.move_intention) + end) + + it('(when input left and right are down) it should update the player character\'s move intention by (-1, 0)', function () + input.players_btn_states[0][button_ids.left] = btn_states.pressed + input.players_btn_states[0][button_ids.right] = btn_states.just_pressed + pc:_handle_input() + assert.are_equal(vector(-1, 0), pc.move_intention) + end) + + it('(when input up in down) it should update the player character\'s move intention by (-1, 0)', function () + input.players_btn_states[0][button_ids.up] = btn_states.pressed + pc:_handle_input() + assert.are_equal(vector(0, -1), pc.move_intention) + end) + + it('(when input down in down) it should update the player character\'s move intention by (0, 1)', function () + input.players_btn_states[0][button_ids.down] = btn_states.pressed + pc:_handle_input() + assert.are_equal(vector(0, 1), pc.move_intention) + end) + + it('(when input up and down are down) it should update the player character\'s move intention by (0, -1)', function () + input.players_btn_states[0][button_ids.up] = btn_states.just_pressed + input.players_btn_states[0][button_ids.down] = btn_states.pressed + pc:_handle_input() + assert.are_equal(vector(0, -1), pc.move_intention) + end) + + it('(when input left and up are down) it should update the player character\'s move intention by (-1, -1)', function () + input.players_btn_states[0][button_ids.left] = btn_states.just_pressed + input.players_btn_states[0][button_ids.up] = btn_states.just_pressed + pc:_handle_input() + assert.are_equal(vector(-1, -1), pc.move_intention) + end) + + it('(when input left and down are down) it should update the player character\'s move intention by (-1, 1)', function () + input.players_btn_states[0][button_ids.left] = btn_states.just_pressed + input.players_btn_states[0][button_ids.down] = btn_states.just_pressed + pc:_handle_input() + assert.are_equal(vector(-1, 1), pc.move_intention) + end) + + it('(when input right and up are down) it should update the player character\'s move intention by (1, -1)', function () + input.players_btn_states[0][button_ids.right] = btn_states.just_pressed + input.players_btn_states[0][button_ids.up] = btn_states.just_pressed + pc:_handle_input() + assert.are_equal(vector(1, -1), pc.move_intention) + end) + + it('(when input right and down are down) it should update the player character\'s move intention by (1, 1)', function () + input.players_btn_states[0][button_ids.right] = btn_states.just_pressed + input.players_btn_states[0][button_ids.down] = btn_states.just_pressed + pc:_handle_input() + assert.are_equal(vector(1, 1), pc.move_intention) + end) + + it('(when input o is released) it should update the player character\'s jump intention to false, hold jump intention to false', function () + pc:_handle_input() + assert.are_same({false, false}, {pc.jump_intention, pc.hold_jump_intention}) + end) + + it('(when input o is just pressed) it should update the player character\'s jump intention to true, hold jump intention to true', function () + input.players_btn_states[0][button_ids.o] = btn_states.just_pressed + pc:_handle_input() + assert.are_same({true, true}, {pc.jump_intention, pc.hold_jump_intention}) + end) + + it('(when input o is pressed) it should update the player character\'s jump intention to false, hold jump intention to true', function () + input.players_btn_states[0][button_ids.o] = btn_states.pressed + pc:_handle_input() + assert.are_same({false, true}, {pc.jump_intention, pc.hold_jump_intention}) + end) + + it('(when input x is pressed) it should call _toggle_debug_motion', function () + input.players_btn_states[0][button_ids.x] = btn_states.just_pressed + + pc:_handle_input() + + -- implementation + assert.spy(pc._toggle_debug_motion).was_called(1) + assert.spy(pc._toggle_debug_motion).was_called_with(match.ref(pc)) + end) + + end) + + describe('_toggle_debug_motion', function () + + setup(function () + -- don't stub, we need to check if the motion mode actually changed after toggle > spawn_at + spy.on(player_char, "spawn_at") + end) + + teardown(function () + player_char.spawn_at:revert() + end) + + after_each(function () + input.players_btn_states[0] = generate_initial_btn_states() + + player_char.spawn_at:clear() + end) + + it('(motion mode is platformer) it should toggle motion mode to debug', function () + pc.motion_mode = motion_modes.platformer + pc:_toggle_debug_motion() + assert.are_equal(motion_modes.debug, pc.motion_mode) + assert.are_equal(vector.zero(), pc.debug_velocity) + end) + + it('(motion mode is debug) it should toggle motion mode to platformer', function () + local previous_position = pc.position -- in case we change it during the spawn + pc.motion_mode = motion_modes.debug + + pc:_toggle_debug_motion() + + -- interface (partial) + assert.are_equal(motion_modes.platformer, pc.motion_mode) + + -- implementation + assert.spy(pc.spawn_at).was_called(1) + assert.spy(pc.spawn_at).was_called_with(match.ref(pc), previous_position) + end) + + end) + + describe('_update_motion', function () + local update_platformer_motion_stub local update_debug_stub @@ -317,7 +572,7 @@ describe('player_char', function () describe('(when motion mode is platformer)', function () it('should call _update_platformer_motion', function () - pc:update() + pc:_update_motion() assert.spy(update_platformer_motion_stub).was_called(1) assert.spy(update_platformer_motion_stub).was_called_with(match.ref(pc)) assert.spy(update_debug_stub).was_not_called() @@ -331,8 +586,11 @@ describe('player_char', function () pc.motion_mode = motion_modes.debug end) - it('. should call _update_debug', function () - pc:update() + -- bugfix history + -- . + -- * the test revealed a missing return, as _update_platformer_motion was called but shouldn't + it('should call _update_debug', function () + pc:_update_motion() assert.spy(update_platformer_motion_stub).was_not_called() assert.spy(update_debug_stub).was_called(1) assert.spy(update_debug_stub).was_called_with(match.ref(pc)) @@ -375,17 +633,17 @@ describe('player_char', function () compute_signed_distance_to_closest_ground_mock = stub(player_char, "_compute_signed_distance_to_closest_ground", function (self, sensor_position) if sensor_position == vector(-1, 0) then - return collision.ground_query_info(-4, 0.25) + return motion.ground_query_info(-4, 0.25) elseif sensor_position == vector(1, 0) then - return collision.ground_query_info(5, -0.125) + return motion.ground_query_info(5, -0.125) elseif sensor_position == vector(-1, 1) then - return collision.ground_query_info(7, -0.25) + return motion.ground_query_info(7, -0.25) elseif sensor_position == vector(1, 1) then - return collision.ground_query_info(6, 0.25) + return motion.ground_query_info(6, 0.25) elseif sensor_position == vector(-1, 2) then - return collision.ground_query_info(3, 0) + return motion.ground_query_info(3, 0) else -- sensor_position == vector(1, 2) - return collision.ground_query_info(3, 0.125) + return motion.ground_query_info(3, 0.125) end end) end) @@ -402,12 +660,12 @@ describe('player_char', function () it('should return the signed distance to closest ground from left sensor if the lowest', function () -- -4 vs 5 => -4 - assert.are_same(collision.ground_query_info(-4, 0.25), pc:_compute_ground_sensors_signed_distance(vector(0, 0))) + assert.are_same(motion.ground_query_info(-4, 0.25), pc:_compute_ground_sensors_signed_distance(vector(0, 0))) end) it('should return the signed distance to closest ground from right sensor if the lowest', function () -- 7 vs 6 => 6 - assert.are_same(collision.ground_query_info(6, 0.25), pc:_compute_ground_sensors_signed_distance(vector(0, 1))) + assert.are_same(motion.ground_query_info(6, 0.25), pc:_compute_ground_sensors_signed_distance(vector(0, 1))) end) describe('(prioritized direction is left)', function () @@ -424,7 +682,7 @@ describe('player_char', function () it('should return the signed distance to left ground if both sensors are at the same level, but left is prioritized', function () -- 3 vs 3 => 3 left - assert.are_same(collision.ground_query_info(3, 0), pc:_compute_ground_sensors_signed_distance(vector(0, 2))) + assert.are_same(motion.ground_query_info(3, 0), pc:_compute_ground_sensors_signed_distance(vector(0, 2))) end) end) @@ -445,7 +703,7 @@ describe('player_char', function () it('should return the signed distance to right ground if both sensors are at the same level, but left is prioritized', function () -- 3 vs 3 => 3 right - assert.are_same(collision.ground_query_info(3, 0.125), pc:_compute_ground_sensors_signed_distance(vector(0, 2))) + assert.are_same(motion.ground_query_info(3, 0.125), pc:_compute_ground_sensors_signed_distance(vector(0, 2))) end) end) @@ -516,7 +774,7 @@ describe('player_char', function () before_each(function () -- create a full tile at (1, 1), i.e. (8, 8) to (15, 15) px - mset(1, 1, 64) + mock_mset(1, 1, 64) end) -- on the sides @@ -603,7 +861,7 @@ describe('player_char', function () before_each(function () -- create a half-tile at (1, 1), top-left at (8, 12), top-right at (15, 16) included - mset(1, 1, 70) + mock_mset(1, 1, 70) end) -- just above @@ -693,7 +951,7 @@ describe('player_char', function () before_each(function () -- create an ascending slope at (1, 1), i.e. (8, 15) to (15, 8) px - mset(1, 1, 65) + mock_mset(1, 1, 65) end) it('should return 0.0625, -45/360 if just above slope column 0', function () @@ -762,7 +1020,7 @@ describe('player_char', function () before_each(function () -- create a descending slope at (1, 1), i.e. (8, 8) to (15, 15) px - mset(1, 1, 66) + mock_mset(1, 1, 66) end) it('. should return 0.0625, 45/360 if right sensors are just a little above column 0', function () @@ -827,7 +1085,7 @@ describe('player_char', function () before_each(function () -- create an ascending slope 22.5 at (1, 1), i.e. (8, 14) to (15, 11) px - mset(1, 1, 67) + mock_mset(1, 1, 67) end) it('should return -4, -22.5/360 if below column 7 by 4px)', function () @@ -841,7 +1099,7 @@ describe('player_char', function () before_each(function () -- create a quarter-tile at (1, 1), i.e. (12, 12) to (15, 15) px -- note that the quarter-tile is made of 2 subtiles of slope 0, hence overall slope is considered 0, not an average slope between min and max height - mset(1, 1, 71) + mock_mset(1, 1, 71) end) it('should return ground_query_info(max_ground_snap_height + 1, nil) if just at the bottom of the tile, on the left part, so in the air (and not 0 just because it is at height 0)', function () @@ -876,8 +1134,8 @@ describe('player_char', function () -- 11111111 -- 11111111 23 - mset(1, 1, 72) - mset(1, 2, 64) + mock_mset(1, 1, 72) + mock_mset(1, 2, 64) end) it('should return -4, 0 if below top by 4px, with character crossing 2 tiles', function () @@ -895,7 +1153,7 @@ describe('player_char', function () before_each(function () -- create a full tile at (1, 1), i.e. (8, 8) to (15, 15) px - mset(1, 1, 64) + mock_mset(1, 1, 64) end) it('should do nothing when character is not touching ground at all, and return false', function () @@ -936,7 +1194,7 @@ describe('player_char', function () before_each(function () -- create a descending slope at (1, 1), i.e. (8, 8) to (15, 15) px - mset(1, 1, 66) + mock_mset(1, 1, 66) end) it('should do nothing when character is not touching ground at all, and return false', function () @@ -977,39 +1235,58 @@ describe('player_char', function () describe('_enter_motion_state', function () + setup(function () + spy.on(animated_sprite, "play") + end) + + teardown(function () + animated_sprite.play:revert() + end) + + -- since pc is _init in before_each and _init calls _setup + -- which calls pc.anim_spr:play, we must clear call count just after that + before_each(function () + animated_sprite.play:clear() + end) + it('should enter passed state: airborne and reset ground-specific state vars', function () -- character starts grounded pc:_enter_motion_state(motion_states.airborne) + assert.are_same({ motion_states.airborne, 0, - false, - "spin" + false }, { pc.motion_state, pc.ground_speed, - pc.should_jump, - pc.current_sprite + pc.should_jump }) + assert.spy(animated_sprite.play).was_called(1) + assert.spy(animated_sprite.play).was_called_with(match.ref(pc.anim_spr), "spin") end) - it('. should enter passed state: grounded and reset speed y and has_interrupted_jump', function () + -- bugfix history: . + it('should enter passed state: grounded and reset speed y and has_interrupted_jump', function () pc.motion_state = motion_states.airborne pc:_enter_motion_state(motion_states.grounded) + assert.are_same({ motion_states.grounded, 0, false, - "idle" + false }, { pc.motion_state, pc.velocity.y, - pc.has_interrupted_jump, - pc.current_sprite + pc.has_jumped_this_frame, + pc.has_interrupted_jump }) + assert.spy(animated_sprite.play).was_called(1) + assert.spy(animated_sprite.play).was_called_with(match.ref(pc.anim_spr), "idle") end) end) @@ -1167,7 +1444,7 @@ describe('player_char', function () end) - end) + end) -- _update_platformer_motion -- bugfix history: -- ^ use fractional speed to check that fractional moves are supported @@ -1179,6 +1456,8 @@ describe('player_char', function () local compute_ground_motion_result_mock setup(function () + spy.on(animated_sprite, "play") + update_ground_speed_mock = stub(player_char, "_update_ground_speed", function (self) self.ground_speed = -2.5 -- use fractional speed to check that fractions are preserved end) @@ -1187,11 +1466,19 @@ describe('player_char', function () end) teardown(function () + animated_sprite.play:revert() + update_ground_speed_mock:revert() enter_motion_state_stub:revert() check_jump_intention_stub:revert() end) + -- since pc is _init in before_each and _init calls _setup + -- which calls pc.anim_spr:play, we must clear call count just after that + before_each(function () + animated_sprite.play:clear() + end) + after_each(function () update_ground_speed_mock:clear() enter_motion_state_stub:clear() @@ -1206,12 +1493,13 @@ describe('player_char', function () assert.spy(update_ground_speed_mock).was_called_with(match.ref(pc)) end) - describe('(when _compute_ground_motion_result returns a motion result with position vector(3, 4), is_blocked: false, is_falling: false)', function () + describe('(when _compute_ground_motion_result returns a motion result with position vector(3, 4), slope_angle: 0.25, is_blocked: false, is_falling: false)', function () setup(function () compute_ground_motion_result_mock = stub(player_char, "_compute_ground_motion_result", function (self) - return collision.ground_motion_result( + return motion.ground_motion_result( vector(3, 4), + 0.25, false, false ) @@ -1226,22 +1514,30 @@ describe('player_char', function () compute_ground_motion_result_mock:clear() end) - it('should set the position to vector(3, 4)', function () - pc:_update_platformer_motion_grounded() - assert.are_equal(vector(3, 4), pc.position) - end) - it('should keep updated ground speed and set velocity frame according to ground speed (not blocked)', function () pc:_update_platformer_motion_grounded() + -- interface: relying on _update_ground_speed implementation assert.are_same({-2.5, vector(-2.5, 0)}, {pc.ground_speed, pc.velocity}) end) it('should keep updated ground speed and set velocity frame according to ground speed and slope if not flat (not blocked)', function () pc.slope_angle = -1/6 -- cos = 1/2, sin = -sqrt(3)/2, but use the formula directly to support floating errors pc:_update_platformer_motion_grounded() + -- interface: relying on _update_ground_speed implementation assert.are_same({-2.5, vector(-2.5*cos(1/6), 2.5*sqrt(3)/2)}, {pc.ground_speed, pc.velocity}) end) + it('should set the position to vector(3, 4)', function () + pc:_update_platformer_motion_grounded() + assert.are_equal(vector(3, 4), pc.position) + end) + + it('should set the slope angle to 0.25', function () + pc.slope_angle = -0.25 + pc:_update_platformer_motion_grounded() + assert.are_equal(0.25, pc.slope_angle) + end) + it('should call _check_jump_intention, not _enter_motion_state (not falling)', function () pc:_update_platformer_motion_grounded() @@ -1251,19 +1547,28 @@ describe('player_char', function () assert.spy(enter_motion_state_stub).was_not_called() end) + it('#solo should play the run animation at playback speed = abs(ground speed), if not 0', function () + -- mock is setting ground speed to -2.5 + pc:_update_platformer_motion_grounded() + + -- implementation + assert.spy(animated_sprite.play).was_called(1) + assert.spy(animated_sprite.play).was_called_with(match.ref(pc.anim_spr), "run", false, 2.5) + end) + end) - describe('(when _compute_ground_motion_result returns a motion result with position vector(3, 4))', function () + describe('(when _compute_ground_motion_result returns a motion result with position vector(3, 4), slope_angle: 0.5, is_blocked: true, is_falling: false)', function () local compute_ground_motion_result_mock setup(function () compute_ground_motion_result_mock = stub(player_char, "_compute_ground_motion_result", function (self) - return collision.ground_motion_result( + return motion.ground_motion_result( vector(3, 4), - -0.25, + 0.5, true, - true + false ) end) end) @@ -1276,79 +1581,197 @@ describe('player_char', function () compute_ground_motion_result_mock:clear() end) - it('should set the position to vector(3, 4)', function () + it('should reset ground speed and velocity frame to zero (blocked)', function () pc:_update_platformer_motion_grounded() - assert.are_equal(vector(3, 4), pc.position) + assert.are_same({0, vector.zero()}, {pc.ground_speed, pc.velocity}) end) - it('should set the slope angle to -0.25', function () + it('should call _check_jump_intention, not _enter_motion_state (not falling)', function () pc:_update_platformer_motion_grounded() - assert.are_equal(-0.25, pc.slope_angle) + + -- implementation + assert.spy(check_jump_intention_stub).was_called(1) + assert.spy(check_jump_intention_stub).was_called_with(match.ref(pc)) + assert.spy(enter_motion_state_stub).was_not_called() end) - it('should reset ground speed and velocity frame to zero (blocked)', function () + it('should set the position to vector(3, 4)', function () pc:_update_platformer_motion_grounded() - assert.are_same({0, vector.zero()}, {pc.ground_speed, pc.velocity}) + assert.are_equal(vector(3, 4), pc.position) + end) + + it('should set the slope angle to 0.5', function () + pc.slope_angle = -0.25 + pc:_update_platformer_motion_grounded() + assert.are_equal(0.5, pc.slope_angle) end) - it('should call _enter_motion_state with airborne state, not call _check_jump_intention (falling)', function () + it('should play the idle animation (ground speed ~= 0)', function () pc:_update_platformer_motion_grounded() -- implementation - assert.spy(enter_motion_state_stub).was_called(1) - assert.spy(enter_motion_state_stub).was_called_with(match.ref(pc), motion_states.airborne) - assert.spy(check_jump_intention_stub).was_not_called() + assert.spy(animated_sprite.play).was_called(1) + assert.spy(animated_sprite.play).was_called_with(match.ref(pc.anim_spr), "idle") end) end) - end) + describe('(when _compute_ground_motion_result returns a motion result with position vector(3, 4), slope_angle: nil, is_blocked: false, is_falling: true)', function () - describe('_update_ground_speed', function () + local compute_ground_motion_result_mock - setup(function () - spy.on(player_char, "_update_ground_speed_by_slope") - spy.on(player_char, "_update_ground_speed_by_intention") - spy.on(player_char, "_clamp_ground_speed") - end) + setup(function () + compute_ground_motion_result_mock = stub(player_char, "_compute_ground_motion_result", function (self) + return motion.ground_motion_result( + vector(3, 4), + nil, + false, + true + ) + end) + end) - teardown(function () - player_char._update_ground_speed_by_slope:revert() - player_char._update_ground_speed_by_intention:revert() - player_char._clamp_ground_speed:revert() - end) + teardown(function () + compute_ground_motion_result_mock:revert() + end) - after_each(function () - player_char._update_ground_speed_by_slope:clear() - player_char._update_ground_speed_by_intention:clear() - player_char._clamp_ground_speed:clear() - end) + after_each(function () + compute_ground_motion_result_mock:clear() + end) - it('should counter the ground speed in the opposite direction of motion when moving upward a 45-degree slope', function () - pc:_update_ground_speed() + it('should keep updated ground speed and set velocity frame according to ground speed (not blocked)', function () + pc:_update_platformer_motion_grounded() + -- interface: relying on _update_ground_speed implementation + assert.are_same({-2.5, vector(-2.5, 0)}, {pc.ground_speed, pc.velocity}) + end) - -- interface - pc.ground_speed = 0 - pc.slope_angle = -1/8 -- 45 deg ascending - pc.move_intention.x = 1 - pc:_update_ground_speed() - assert.are_equal(pc_data.ground_accel_frame2 - pc_data.slope_accel_factor_frame2 * sin(-1/8), pc.ground_speed) - end) + it('should keep updated ground speed and set velocity frame according to ground speed and slope if not flat (not blocked)', function () + pc.slope_angle = -1/6 -- cos = 1/2, sin = -sqrt(3)/2, but use the formula directly to support floating errors + pc:_update_platformer_motion_grounded() + -- interface: relying on _update_ground_speed implementation + assert.are_same({-2.5, vector(-2.5*cos(1/6), 2.5*sqrt(3)/2)}, {pc.ground_speed, pc.velocity}) + end) - it('should update ground speed based on slope, then intention', function () - pc:_update_ground_speed() + it('should call _enter_motion_state with airborne state, not call _check_jump_intention nor anim_spr:play (falling)', function () + pc:_update_platformer_motion_grounded() - -- implementation - assert.spy(player_char._update_ground_speed_by_slope).was_called(1) - assert.spy(player_char._update_ground_speed_by_slope).was_called_with(match.ref(pc)) - assert.spy(player_char._update_ground_speed_by_intention).was_called(1) - assert.spy(player_char._update_ground_speed_by_intention).was_called_with(match.ref(pc)) - assert.spy(player_char._clamp_ground_speed).was_called(1) - assert.spy(player_char._clamp_ground_speed).was_called_with(match.ref(pc)) - end) - end) + -- implementation + assert.spy(enter_motion_state_stub).was_called(1) + assert.spy(enter_motion_state_stub).was_called_with(match.ref(pc), motion_states.airborne) + assert.spy(check_jump_intention_stub).was_not_called() + assert.spy(animated_sprite.play).was_not_called() + end) - describe('_update_ground_speed_by_slope', function () + it('should set the position to vector(3, 4)', function () + pc:_update_platformer_motion_grounded() + assert.are_equal(vector(3, 4), pc.position) + end) + + it('should set the slope angle to nil', function () + pc:_update_platformer_motion_grounded() + assert.is_nil(pc.slope_angle) + end) + + end) + + describe('(when _compute_ground_motion_result returns a motion result with position vector(3, 4), slope_angle: nil, is_blocked: true, is_falling: true)', function () + + local compute_ground_motion_result_mock + + setup(function () + compute_ground_motion_result_mock = stub(player_char, "_compute_ground_motion_result", function (self) + return motion.ground_motion_result( + vector(3, 4), + nil, + true, + true + ) + end) + end) + + teardown(function () + compute_ground_motion_result_mock:revert() + end) + + after_each(function () + compute_ground_motion_result_mock:clear() + end) + + it('should reset ground speed and velocity frame to zero (blocked)', function () + pc:_update_platformer_motion_grounded() + assert.are_same({0, vector.zero()}, {pc.ground_speed, pc.velocity}) + end) + + it('should call _enter_motion_state with airborne state, not call _check_jump_intention nor anim_spr:play (falling)', function () + pc:_update_platformer_motion_grounded() + + -- implementation + assert.spy(enter_motion_state_stub).was_called(1) + assert.spy(enter_motion_state_stub).was_called_with(match.ref(pc), motion_states.airborne) + assert.spy(check_jump_intention_stub).was_not_called() + assert.spy(animated_sprite.play).was_not_called() + end) + + it('should set the position to vector(3, 4)', function () + pc:_update_platformer_motion_grounded() + assert.are_equal(vector(3, 4), pc.position) + end) + + it('should set the slope angle to nil', function () + pc:_update_platformer_motion_grounded() + assert.is_nil(pc.slope_angle) + end) + + end) + + end) -- _update_platformer_motion_grounded + + describe('_update_ground_speed', function () + + setup(function () + spy.on(player_char, "_update_ground_speed_by_slope") + spy.on(player_char, "_update_ground_speed_by_intention") + spy.on(player_char, "_clamp_ground_speed") + end) + + teardown(function () + player_char._update_ground_speed_by_slope:revert() + player_char._update_ground_speed_by_intention:revert() + player_char._clamp_ground_speed:revert() + end) + + after_each(function () + player_char._update_ground_speed_by_slope:clear() + player_char._update_ground_speed_by_intention:clear() + player_char._clamp_ground_speed:clear() + end) + + it('should counter the ground speed in the opposite direction of motion when moving upward a 45-degree slope', function () + pc:_update_ground_speed() + + -- interface + pc.ground_speed = 0 + pc.slope_angle = -1/8 -- 45 deg ascending + pc.move_intention.x = 1 + pc:_update_ground_speed() + assert.are_equal(pc_data.ground_accel_frame2 - pc_data.slope_accel_factor_frame2 * sin(-1/8), pc.ground_speed) + end) + + it('should update ground speed based on slope, then intention', function () + pc:_update_ground_speed() + + -- implementation + assert.spy(player_char._update_ground_speed_by_slope).was_called(1) + assert.spy(player_char._update_ground_speed_by_slope).was_called_with(match.ref(pc)) + assert.spy(player_char._update_ground_speed_by_intention).was_called(1) + assert.spy(player_char._update_ground_speed_by_intention).was_called_with(match.ref(pc)) + assert.spy(player_char._clamp_ground_speed).was_called(1) + assert.spy(player_char._clamp_ground_speed).was_called_with(match.ref(pc)) + end) + + end) -- _update_ground_speed + + describe('_update_ground_speed_by_slope', function () it('should preserve ground speed on flat ground', function () pc.ground_speed = 2 @@ -1371,117 +1794,161 @@ describe('player_char', function () assert.are_equal(2 + pc_data.slope_accel_factor_frame2 * sqrt(2)/2, pc.ground_speed) end) - end) + end) -- _update_ground_speed_by_slope describe('_update_ground_speed_by_intention', function () - it('should accelerate when character has ground speed 0 and move intention x is not 0', function () + it('should accelerate and set direction based on new speed when character is facing left, has ground speed 0 and move intention x > 0', function () + pc.horizontal_dir = horizontal_dirs.left pc.move_intention.x = 1 pc:_update_ground_speed_by_intention() - assert.are_equal(pc_data.ground_accel_frame2, pc.ground_speed) + assert.are_same({horizontal_dirs.right, pc_data.ground_accel_frame2}, + {pc.horizontal_dir, pc.ground_speed}) end) - it('should accelerate when character has ground speed > 0 and move intention x > 0', function () + it('should accelerate and set direction when character is facing left, has ground speed > 0 and move intention x > 0', function () + pc.horizontal_dir = horizontal_dirs.left -- rare to oppose ground speed sense, but possible when running backward pc.ground_speed = 1.5 pc.move_intention.x = 1 pc:_update_ground_speed_by_intention() - assert.are_equal(1.5 + pc_data.ground_accel_frame2, pc.ground_speed) + assert.are_same({horizontal_dirs.right, 1.5 + pc_data.ground_accel_frame2}, + {pc.horizontal_dir, pc.ground_speed}) end) - it('should accelerate when character has ground speed < 0 and move intention x < 0', function () + it('should accelerate and preserve direction when character is facing left, has ground speed < 0 and move intention x < 0', function () + pc.horizontal_dir = horizontal_dirs.left -- rare to oppose ground speed sense, but possible when running backward pc.ground_speed = -1.5 pc.move_intention.x = -1 pc:_update_ground_speed_by_intention() - assert.are_equal(-1.5 - pc_data.ground_accel_frame2, pc.ground_speed) + assert.are_same({horizontal_dirs.left, -1.5 - pc_data.ground_accel_frame2}, + {pc.horizontal_dir, pc.ground_speed}) end) - it('should decelerate keeping same sign when character has high ground speed > 0 and move intention x < 0', function () + it('should decelerate keeping same sign and direction when character is facing right, has high ground speed > ground accel * 1 frame and move intention x < 0', function () + pc.horizontal_dir = horizontal_dirs.right pc.ground_speed = 1.5 pc.move_intention.x = -1 pc:_update_ground_speed_by_intention() -- ground_decel_frame2 = 0.25, subtract it from ground_speed - assert.are_equal(1.25, pc.ground_speed) + assert.are_same({horizontal_dirs.right, 1.25}, + {pc.horizontal_dir, pc.ground_speed}) end) - -- bugfix history: missing tests that check the change of sign of ground speed - it('_ should decelerate and change sign when character has low ground speed > 0 and move intention x < 0 '.. + it('should decelerate and stop exactly at speed 0, preserving direction, when character has ground speed = ground accel * 1 frame and move intention x < 0', function () + pc.horizontal_dir = horizontal_dirs.right + pc.ground_speed = 0.25 + pc.move_intention.x = -1 + pc:_update_ground_speed_by_intention() + -- ground_decel_frame2 = 0.25, subtract it from ground_speed + assert.are_same({horizontal_dirs.right, 0}, + {pc.horizontal_dir, pc.ground_speed}) + end) + + -- bugfix history: + -- _ missing tests that check the change of sign of ground speed + it('should decelerate and change sign and direction when character is facing right, '.. + 'has low ground speed > 0 but < ground accel * 1 frame and move intention x < 0 '.. 'but the ground speed is high enough so that the new speed wouldn\'t be over the max ground speed', function () + pc.horizontal_dir = horizontal_dirs.right -- start with speed >= -ground_accel_frame2 + ground_decel_frame2 pc.ground_speed = 0.24 pc.move_intention.x = -1 pc:_update_ground_speed_by_intention() + assert.are_equal(horizontal_dirs.left, pc.horizontal_dir) assert.is_true(almost_eq_with_message(-0.01, pc.ground_speed, 1e-16)) end) - it('should decelerate and clamp to the max ground speed in the opposite sign '.. - 'when character has low ground speed > 0 and move intention x < 0', function () + it('should change direction, decelerate and clamp to the max ground speed in the opposite sign '.. + 'when character is facing right, has low ground speed > 0 and move intention x < 0', function () + pc.horizontal_dir = horizontal_dirs.right -- start with speed < -ground_accel_frame2 + ground_decel_frame2 pc.ground_speed = 0.12 pc.move_intention.x = -1 pc:_update_ground_speed_by_intention() - assert.are_equal(-pc_data.ground_accel_frame2, pc.ground_speed) + assert.are_same({horizontal_dirs.left, -pc_data.ground_accel_frame2}, + {pc.horizontal_dir, pc.ground_speed}) end) + -- tests below seem symmetrical, but as a twist we have the character running backward + -- so he's facing the opposite direction of the run, so we can test direction update + it('should decelerate keeping same sign when character has high ground speed < 0 and move intention x > 0', function () + pc.horizontal_dir = horizontal_dirs.right pc.ground_speed = -1.5 pc.move_intention.x = 1 pc:_update_ground_speed_by_intention() - assert.are_equal(-1.25, pc.ground_speed) + assert.are_same({horizontal_dirs.left, -1.25}, + {pc.horizontal_dir, pc.ground_speed}) end) - -- bugfix history: missing tests that check the change of sign of ground speed - it('_ should decelerate and change sign when character has low ground speed < 0 and move intention x > 0 '.. + it('should decelerate and change sign when character has low ground speed < 0 and move intention x > 0 '.. 'but the ground speed is high enough so that the new speed wouldn\'t be over the max ground speed', function () + pc.horizontal_dir = horizontal_dirs.right -- start with speed <= ground_accel_frame2 - ground_decel_frame2 pc.ground_speed = -0.24 pc.move_intention.x = 1 pc:_update_ground_speed_by_intention() + assert.are_equal(horizontal_dirs.right, pc.horizontal_dir) assert.is_true(almost_eq_with_message(0.01, pc.ground_speed, 1e-16)) end) it('should decelerate and clamp to the max ground speed in the opposite sign '.. 'when character has low ground speed < 0 and move intention x > 0', function () + pc.horizontal_dir = horizontal_dirs.right -- start with speed > ground_accel_frame2 - ground_decel_frame2 pc.ground_speed = -0.12 pc.move_intention.x = 1 pc:_update_ground_speed_by_intention() - assert.are_equal(pc_data.ground_accel_frame2, pc.ground_speed) + assert.are_same({horizontal_dirs.right, pc_data.ground_accel_frame2}, + {pc.horizontal_dir, pc.ground_speed}) end) - it('should apply friction when character has ground speed > 0 and move intention x is 0', function () + it('should apply friction and preserve direction when character has ground speed > 0 and move intention x is 0', function () + pc.horizontal_dir = horizontal_dirs.right pc.ground_speed = 1.5 pc:_update_ground_speed_by_intention() - assert.are_equal(1.5 - pc_data.ground_friction_frame2, pc.ground_speed) + assert.are_same({horizontal_dirs.right, 1.5 - pc_data.ground_friction_frame2}, + {pc.horizontal_dir, pc.ground_speed}) end) -- bugfix history: missing tests that check the change of sign of ground speed - it('_ should apply friction but stop at 0 without changing ground speed sign when character has low ground speed > 0 and move intention x is 0', function () + it('_ should apply friction and preserve direction but stop at 0 without changing ground speed sign when character has low ground speed > 0 and move intention x is 0', function () + pc.horizontal_dir = horizontal_dirs.right -- must be < friction pc.ground_speed = 0.01 pc:_update_ground_speed_by_intention() - assert.are_equal(0, pc.ground_speed) + assert.are_same({horizontal_dirs.right, 0}, + {pc.horizontal_dir, pc.ground_speed}) end) - it('should apply friction when character has ground speed < 0 and move intention x is 0', function () + -- tests below seem symmetrical, but the character is actually running backward + + it('should apply friction and preserive direction when character has ground speed < 0 and move intention x is 0', function () + pc.horizontal_dir = horizontal_dirs.right pc.ground_speed = -1.5 pc:_update_ground_speed_by_intention() - assert.are_equal(-1.5 + pc_data.ground_friction_frame2, pc.ground_speed) + assert.are_same({horizontal_dirs.right, -1.5 + pc_data.ground_friction_frame2}, + {pc.horizontal_dir, pc.ground_speed}) end) -- bugfix history: missing tests that check the change of sign of ground speed it('_ should apply friction but stop at 0 without changing ground speed sign when character has low ground speed < 0 and move intention x is 0', function () + pc.horizontal_dir = horizontal_dirs.right -- must be < friction in abs pc.ground_speed = -0.01 pc:_update_ground_speed_by_intention() - assert.are_equal(0, pc.ground_speed) + assert.are_same({horizontal_dirs.right, 0}, + {pc.horizontal_dir, pc.ground_speed}) end) - it('should not change ground speed when ground speed is 0 and move intention x is 0', function () + it('should not change ground speed nor direction when ground speed is 0 and move intention x is 0', function () + pc.horizontal_dir = horizontal_dirs.left pc:_update_ground_speed_by_intention() - assert.are_equal(0, pc.ground_speed) + assert.are_same({horizontal_dirs.left, 0}, + {pc.horizontal_dir, pc.ground_speed}) end) - end) + end) -- _update_ground_speed_by_intention describe('_clamp_ground_speed', function () @@ -1508,7 +1975,7 @@ describe('player_char', function () pc.position = vector(3, 4) pc.slope_angle = 0.125 - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(3, 4), 0.125, false, @@ -1522,7 +1989,7 @@ describe('player_char', function () pc.position = vector(3.5, 4) pc.slope_angle = 0.125 - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(3.5, 4), 0.125, false, @@ -1555,10 +2022,10 @@ describe('player_char', function () it('(vector(3, 4) at speed 0.5) should return vector(3.5, 4), slope: 0, is_blocked: false, is_falling: false', function () pc.position = vector(3, 4) pc.ground_speed = 0.5 - -- we assume _compute_max_column_distance is correct, so it should return 0 + -- we assume _compute_max_pixel_distance is correct, so it should return 0 -- but as there is no blocking, the remaining subpixels will still be added - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(3.5, 4), 0, -- character has not moved by a full pixel, so visible position and slope remains the same false, @@ -1575,7 +2042,7 @@ describe('player_char', function () pc.slope_angle = -1/6 -- cos(-pi/3) = 1/2 pc.ground_speed = 1 -- * slope cos = 0.5 - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(3.5, 4), -1/6, -- character has not moved by a full pixel, so visible position and slope remains the same false, @@ -1588,9 +2055,9 @@ describe('player_char', function () it('(vector(3.5, 4) at speed 0.5) should return vector(0.5, 4), is_blocked: false, is_falling: false', function () pc.position = vector(3.5, 4) pc.ground_speed = 0.5 - -- we assume _compute_max_column_distance is correct, so it should return 1 + -- we assume _compute_max_pixel_distance is correct, so it should return 1 - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(4, 4), -0.125, false, @@ -1604,7 +2071,7 @@ describe('player_char', function () pc.position = vector(3, 4) pc.ground_speed = -2.5 - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(0.5, 4), -0.125, false, @@ -1616,18 +2083,21 @@ describe('player_char', function () end) - describe('(when _next_ground_step moves motion_result.position.x by 1px in the horizontal_dir, but blocks when motion_result.position.x >= 5)', function () + describe('(when _next_ground_step moves motion_result.position.x by 1px in the horizontal_dir, but blocks when motion_result.position.x <= -5 or x >= 5)', function () local next_ground_step_mock setup(function () next_ground_step_mock = stub(player_char, "_next_ground_step", function (self, horizontal_dir, motion_result) local step_vec = horizontal_dir_vectors[horizontal_dir] - if motion_result.position.x < 5 then + -- x < -4 <=> x <= -5 for an integer as passed to step functions, + -- but we want to make clear that flooring is asymmetrical + -- and that for floating coordinates, -4.01 is already hitting the left wall + if motion_result.position.x < -4 and step_vec.x < 0 or motion_result.position.x >= 5 and step_vec.x > 0 then + motion_result.is_blocked = true + else motion_result.position = motion_result.position + step_vec motion_result.slope_angle = 0.125 - else - motion_result.is_blocked = true end end) end) @@ -1639,9 +2109,9 @@ describe('player_char', function () it('(vector(3.5, 4) at speed 1.5) should return vector(5, 4), slope before blocked, is_blocked: false, is_falling: false', function () pc.position = vector(3.5, 4) pc.ground_speed = 1.5 - -- we assume _compute_max_column_distance is correct, so it should return 2 + -- we assume _compute_max_pixel_distance is correct, so it should return 2 - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(5, 4), 0.125, false, @@ -1651,14 +2121,29 @@ describe('player_char', function () ) end) + it('(vector(-3.5, 4) at speed -1.5) should return vector(-5, 4), slope before blocked, is_blocked: false, is_falling: false', function () + pc.position = vector(-3.5, 4) + pc.ground_speed = -1.5 + -- we assume _compute_max_pixel_distance is correct, so it should return 2 + + assert.are_equal(motion.ground_motion_result( + vector(-5, 4), + 0.125, + false, + false + ), + pc:_compute_ground_motion_result() + ) + end) + -- bugfix history: + the test revealed that is_blocked should be false when just touching a wall on arrival -- so I added a check to only check a wall on an extra column farther if there are subpixels left in motion it('(vector(4.5, 4) at speed 0.5) should return vector(5, 4), slope before blocked, is_blocked: false, is_falling: false', function () pc.position = vector(4.5, 4) pc.ground_speed = 0.5 - -- we assume _compute_max_column_distance is correct, so it should return 2 + -- we assume _compute_max_pixel_distance is correct, so it should return 1 - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(5, 4), 0.125, false, @@ -1668,15 +2153,31 @@ describe('player_char', function () ) end) + -- the negative motion equivalent is not symmetrical due to flooring + it('(vector(-4, 4) at speed -0.1) should return vector(-5, 4), slope before blocked, is_blocked: false, is_falling: false', function () + pc.position = vector(-4, 4) + pc.ground_speed = -1 + -- we assume _compute_max_pixel_distance is correct, so it should return 1 + + assert.are_equal(motion.ground_motion_result( + vector(-5, 4), + 0.125, + false, + false + ), + pc:_compute_ground_motion_result() + ) + end) + -- bugfix history: < replaced self.ground_speed with distance_x in are_subpixels_left evaluation it('(vector(4.5, 4) at speed 1 on slope cos 0.5) should return vector(5, 4), is_blocked: false, is_falling: false', function () -- this is the same as the test above (we just reach the wall edge without being blocked), -- but we make sure that are_subpixels_left check takes the slope factor into account pc.position = vector(4.5, 4) pc.slope_angle = -1/6 -- cos(-pi/3) = 1/2 - pc.ground_speed = 1 + pc.ground_speed = 1 -- * slope cos = -0.5 - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(5, 4), 0.125, -- new slope angle, no relation with initial one false, @@ -1686,15 +2187,32 @@ describe('player_char', function () ) end) + -- the negative motion equivalent is not symmetrical due to flooring + -- in particular, to update the slope angle, we need to change of full pixel + it('(vector(-4, 4) at speed -2 on slope cos 0.5) should return vector(-5, 4), is_blocked: false, is_falling: false', function () + pc.position = vector(-4, 4) + pc.slope_angle = -1/6 -- cos(-pi/3) = 1/2 + pc.ground_speed = -2 -- * slope cos = -1 + + assert.are_equal(motion.ground_motion_result( + vector(-5, 4), + 0.125, -- new slope angle, no relation with initial one + false, + false + ), + pc:_compute_ground_motion_result() + ) + end) + it('(vector(4, 4) at speed 1.5) should return vector(5, 4), slope before blocked, is_blocked: true, is_falling: false', function () pc.position = vector(4, 4) pc.ground_speed = 1.5 - -- we assume _compute_max_column_distance is correct, so it should return 1 + -- we assume _compute_max_pixel_distance is correct, so it should return 1 -- the character will just touch the wall but because it has some extra subpixels -- going "into" the wall, we floor them and consider character as blocked -- (unlike Classic Sonic that would simply ignore subpixels) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(5, 4), 0.125, true, @@ -1704,6 +2222,24 @@ describe('player_char', function () ) end) + it('(vector(-4, 4) at speed -1.5) should return vector(-5, 4), slope before blocked, is_blocked: true, is_falling: false', function () + pc.position = vector(-4, 4) + pc.ground_speed = -1.5 + -- we assume _compute_max_pixel_distance is correct, so it should return 1 + -- the character will just touch the wall but because it has some extra subpixels + -- going "into" the wall, we floor them and consider character as blocked + -- (unlike Classic Sonic that would simply ignore subpixels) + + assert.are_equal(motion.ground_motion_result( + vector(-5, 4), + 0.125, + true, + false + ), + pc:_compute_ground_motion_result() + ) + end) + -- bugfix history: -- ?? same reason as test far above where "character has not moved by a full pixel" so slope should not change it('(vector(4, 4) at speed 1.5 on slope cos 0.5) should return vector(4.75, 4), slope before blocked, is_blocked: false, is_falling: false', function () @@ -1712,7 +2248,7 @@ describe('player_char', function () pc.ground_speed = 1.5 -- * slope cos = 0.75 -- this time, due to the slope cos, charaacter doesn't reach the wall and is not blocked - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(4.75, 4), -1/6, -- character has not moved by a full pixel, so visible position and slope remains the same false, @@ -1722,13 +2258,29 @@ describe('player_char', function () ) end) + it('(vector(-4.1, 4) at speed -1.5 on slope cos 0.5) should return vector(-4.85, 4), slope before blocked, is_blocked: false, is_falling: false', function () + -- start under -4 so we don't change full pixel and preserve slope angle + pc.position = vector(-4.1, 4) + pc.slope_angle = -1/6 -- cos(-pi/3) = 1/2 + pc.ground_speed = -1.5 -- * slope cos = -0.75 + + assert.are_equal(motion.ground_motion_result( + vector(-4.85, 4), + -1/6, -- character has not moved by a full pixel, so visible position and slope remains the same + false, + false + ), + pc:_compute_ground_motion_result() + ) + end) + it('(vector(4, 4) at speed 3 on slope cos 0.5) should return vector(5, 4), slope before blocked, is_blocked: true, is_falling: false', function () pc.position = vector(4, 4) pc.slope_angle = -1/6 -- cos(-pi/3) = 1/2 pc.ground_speed = 3 -- * slope cos = 1.5 -- but here, even with the slope cos, charaacter will hit wall - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(5, 4), 0.125, true, @@ -1738,16 +2290,32 @@ describe('player_char', function () ) end) - -- bugfix history: it failed until I added the subpixels check at the end of the method - -- (also fixed in v1: subpixel cut when max_column_distance is 0 and blocked on next column) - it('+ (vector(5, 4) at speed 0.5) should return vector(5, 4), slope before moving, is_blocked: false, is_falling: false', function () + it('(vector(-4, 4) at speed 3 on slope cos 0.5) should return vector(-5, 4), slope before blocked, is_blocked: true, is_falling: false', function () + pc.position = vector(-4, 4) + pc.slope_angle = -1/6 -- cos(-pi/3) = 1/2 + pc.ground_speed = -3 -- * slope cos = -1.5 + + assert.are_equal(motion.ground_motion_result( + vector(-5, 4), + 0.125, + true, + false + ), + pc:_compute_ground_motion_result() + ) + end) + + -- bugfix history: + -- + it failed until I added the subpixels check at the end of the method + -- (also fixed in v1: subpixel cut when max_column_distance is 0 and blocked on next column) + it('(vector(5, 4) at speed 0.5) should return vector(5, 4), slope before moving, is_blocked: true, is_falling: false', function () pc.position = vector(5, 4) pc.ground_speed = 0.5 - -- we assume _compute_max_column_distance is correct, so it should return 0 + -- we assume _compute_max_pixel_distance is correct, so it should return 0 -- the character is already touching the wall, so any motion, even of just a few subpixels, -- is considered blocked - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(5, 4), 0, -- character couldn't move at all, so we preserved the initial slope angle true, @@ -1757,19 +2325,36 @@ describe('player_char', function () ) end) - it('(vector(5.5, 4) at speed 0.5) should return vector(5, 4), slope before moving, is_blocked: false, is_falling: false', function () + it('(vector(-5, 4) at speed 0.5) should return vector(-5, 4), slope before moving, is_blocked: true, is_falling: false', function () + pc.position = vector(-5, 4) + pc.ground_speed = -0.5 + -- we assume _compute_max_pixel_distance is correct, so it should return 0 + -- the character is already touching the wall, so any motion, even of just a few subpixels, + -- is considered blocked + + assert.are_equal(motion.ground_motion_result( + vector(-5, 4), + 0, -- character couldn't move at all, so we preserved the initial slope angle + true, + false + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(vector(5.5, 4) at speed 0.5) should return vector(5, 4), slope before moving, is_blocked: true, is_falling: false', function () -- this is possible e.g. if character walked along 1.5 from x=4 -- to reduce computation we didn't check an extra column for a wall -- at that time, but starting next frame we will, effectively clamping -- the character to x=5 pc.position = vector(5.5, 4) pc.ground_speed = 0.5 - -- we assume _compute_max_column_distance is correct, so it should return 1 + -- we assume _compute_max_pixel_distance is correct, so it should return 1 -- but we will be blocked by the wall anyway - assert.are_equal(collision.ground_motion_result( - vector(5, 4), - 0, -- character couldn't move at all, so we preserved the initial slope angle + assert.are_equal(motion.ground_motion_result( + vector(5, 4), -- this works on the *right* thanks to subpixel cut working "inside" a wall + 0, -- character couldn't move and went back, so we preserved the initial slope angle true, false ), @@ -1777,13 +2362,45 @@ describe('player_char', function () ) end) + it('(vector(-5.5, 4) at speed -0.5) should return vector(-6, 4), slope before moving, is_blocked: false, is_falling: false', function () + pc.position = vector(-5.5, 4) + pc.ground_speed = -0.5 + -- we assume _compute_max_pixel_distance is correct, so it should return 1 + -- but we will be blocked by the wall anyway + + assert.are_equal(motion.ground_motion_result( + vector(-6, 4), -- we are already inside the wall, floored to -6 + 0, -- character only snap to floored x, so we preserved the slope angle + false, -- no wall detected from inside! + false + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(vector(-5.5, 4) at speed -1) should return vector(-6, 4), slope before moving, is_blocked: true, is_falling: false', function () + pc.position = vector(-5.5, 4) + pc.ground_speed = -1 + -- we assume _compute_max_pixel_distance is correct, so it should return 1 + -- but we will be blocked by the wall anyway + + assert.are_equal(motion.ground_motion_result( + vector(-6, 4), -- we are already inside the wall, floored to -6 + 0, -- character only snap to floored x, so we preserved the slope angle + true, -- wall detected from inside if moving 1 full pixel toward the next column on the left + false + ), + pc:_compute_ground_motion_result() + ) + end) + it('(vector(3, 4) at speed 3) should return vector(5, 4), slope before blocked, is_blocked: false, is_falling: false', function () pc.position = vector(3, 4) pc.ground_speed = 3.5 - -- we assume _compute_max_column_distance is correct, so it should return 3 + -- we assume _compute_max_pixel_distance is correct, so it should return 3 -- but because of the blocking, we stop at x=5 instead of 6.5 - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(5, 4), 0.125, true, @@ -1793,6 +2410,22 @@ describe('player_char', function () ) end) + it('(vector(-3, 4) at speed -3) should return vector(-5, 4), slope before blocked, is_blocked: false, is_falling: false', function () + pc.position = vector(-3, 4) + pc.ground_speed = -3.5 + -- we assume _compute_max_pixel_distance is correct, so it should return 3 + -- but because of the blocking, we stop at x=5 instead of 6.5 + + assert.are_equal(motion.ground_motion_result( + vector(-5, 4), + 0.125, + true, + false + ), + pc:_compute_ground_motion_result() + ) + end) + end) -- bugfix history: the mock was wrong (was using updated position instead of original_position) @@ -1826,10 +2459,10 @@ describe('player_char', function () it('(vector(3, 4) at speed 3) should return vector(6, 4), slope_angle: nil, is_blocked: false, is_falling: false', function () pc.position = vector(3, 4) pc.ground_speed = 3 - -- we assume _compute_max_column_distance is correct, so it should return 3 + -- we assume _compute_max_pixel_distance is correct, so it should return 3 -- we are falling but not blocked, so we continue running in the air until x=6 - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(6, 4), nil, false, @@ -1842,10 +2475,10 @@ describe('player_char', function () it('(vector(3, 4) at speed 3) should return vector(7, 4), slope_angle: nil, is_blocked: false, is_falling: false', function () pc.position = vector(3, 4) pc.ground_speed = 5 - -- we assume _compute_max_column_distance is correct, so it should return 3 + -- we assume _compute_max_pixel_distance is correct, so it should return 3 -- we are falling then blocked on 7 - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(7, 4), nil, true, @@ -1857,7 +2490,7 @@ describe('player_char', function () end) - end) + end) -- _compute_ground_motion_result describe('_next_ground_step', function () @@ -1868,11 +2501,11 @@ describe('player_char', function () describe('(with flat ground)', function () before_each(function () - mset(0, 1, 64) -- full tile + mock_mset(0, 1, 64) -- full tile end) it('when stepping left with the right sensor still on the ground, decrement x', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(-1, 8 - pc_data.center_height_standing), 0, false, @@ -1882,7 +2515,7 @@ describe('player_char', function () -- step flat pc:_next_ground_step(horizontal_dirs.left, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(-2, 8 - pc_data.center_height_standing), 0, false, @@ -1893,7 +2526,7 @@ describe('player_char', function () end) it('when stepping right with the left sensor still on the ground, increment x', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(9, 8 - pc_data.center_height_standing), 0, false, @@ -1903,7 +2536,7 @@ describe('player_char', function () -- step flat pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(10, 8 - pc_data.center_height_standing), 0, false, @@ -1914,7 +2547,7 @@ describe('player_char', function () end) it('when stepping left leaving the ground, decrement x and fall', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(-2, 8 - pc_data.center_height_standing), 0, false, @@ -1924,7 +2557,7 @@ describe('player_char', function () -- step fall pc:_next_ground_step(horizontal_dirs.left, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(-3, 8 - pc_data.center_height_standing), nil, false, @@ -1935,7 +2568,7 @@ describe('player_char', function () end) it('when stepping right leaving the ground, increment x and fall', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(10, 8 - pc_data.center_height_standing), 0, false, @@ -1945,7 +2578,7 @@ describe('player_char', function () -- step fall pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(11, 8 - pc_data.center_height_standing), nil, false, @@ -1956,7 +2589,7 @@ describe('player_char', function () end) it('when stepping right back on the ground, increment x and cancel fall', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(-3, 8 - pc_data.center_height_standing), nil, false, @@ -1966,7 +2599,7 @@ describe('player_char', function () -- step land (very rare) pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(-2, 8 - pc_data.center_height_standing), 0, false, @@ -1983,15 +2616,15 @@ describe('player_char', function () before_each(function () -- X X -- XXX - mset(0, 0, 64) -- full tile (left wall) - mset(0, 1, 64) -- full tile - mset(1, 1, 64) -- full tile - mset(2, 0, 64) -- full tile - mset(2, 1, 64) -- full tile (right wall) + mock_mset(0, 0, 64) -- full tile (left wall) + mock_mset(0, 1, 64) -- full tile + mock_mset(1, 1, 64) -- full tile + mock_mset(2, 0, 64) -- full tile + mock_mset(2, 1, 64) -- full tile (right wall) end) it('when stepping left and hitting the wall, preserve x and block', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(3, 8 - pc_data.center_height_standing), 0, false, @@ -2001,7 +2634,7 @@ describe('player_char', function () -- step block pc:_next_ground_step(horizontal_dirs.left, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(3, 8 - pc_data.center_height_standing), 0, true, @@ -2012,7 +2645,7 @@ describe('player_char', function () end) it('when stepping right and hitting the wall, preserve x and block', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(5, 8 - pc_data.center_height_standing), 0, false, @@ -2022,7 +2655,7 @@ describe('player_char', function () -- step block pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(5, 8 - pc_data.center_height_standing), 0, true, @@ -2039,14 +2672,14 @@ describe('player_char', function () before_each(function () -- X -- X - mset(0, 1, 64) -- full tile (ground) - mset(1, 0, 64) -- full tile (wall without ground below) + mock_mset(0, 1, 64) -- full tile (ground) + mock_mset(1, 0, 64) -- full tile (wall without ground below) end) -- it will fail until _compute_signed_distance_to_closest_ground -- detects upper-level tiles as suggested in the note it('when stepping right on the ground and hitting the non-supported wall, preserve x and block', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(5, 8 - pc_data.center_height_standing), 0, false, @@ -2056,7 +2689,7 @@ describe('player_char', function () -- step block pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(5, 8 - pc_data.center_height_standing), 0, true, @@ -2073,14 +2706,14 @@ describe('player_char', function () before_each(function () -- X -- = - mset(0, 1, 70) -- bottom half-tile - mset(1, 0, 64) -- full tile (head wall) + mock_mset(0, 1, 70) -- bottom half-tile + mock_mset(1, 0, 64) -- full tile (head wall) end) -- it will fail until _compute_signed_distance_to_closest_ground -- detects upper-level tiles as suggested in the note it('when stepping right on the half-tile and hitting the head wall, preserve x and block', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(5, 12 - pc_data.center_height_standing), 0, false, @@ -2090,7 +2723,7 @@ describe('player_char', function () -- step block pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(5, 12 - pc_data.center_height_standing), 0, true, @@ -2110,12 +2743,12 @@ describe('player_char', function () before_each(function () -- / -- X - mset(0, 1, 64) -- full tile (ground) - mset(1, 0, 65) -- ascending slope 45 + mock_mset(0, 1, 64) -- full tile (ground) + mock_mset(1, 0, 65) -- ascending slope 45 end) it('when stepping right from the bottom of the ascending slope, increment x and adjust y', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(5, 8 - pc_data.center_height_standing), 0, false, @@ -2125,7 +2758,7 @@ describe('player_char', function () -- step down pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(6, 7 - pc_data.center_height_standing), -45/360, false, @@ -2142,14 +2775,14 @@ describe('player_char', function () before_each(function () -- X X -- X/X - mset(0, 0, 64) -- full tile (high wall, needed to block motion to the left as right sensor makes the character quite high on the slope) - mset(0, 1, 64) -- full tile (wall) - mset(1, 1, 65) -- ascending slope 45 - mset(2, 0, 64) -- full tile (wall) + mock_mset(0, 0, 64) -- full tile (high wall, needed to block motion to the left as right sensor makes the character quite high on the slope) + mock_mset(0, 1, 64) -- full tile (wall) + mock_mset(1, 1, 65) -- ascending slope 45 + mock_mset(2, 0, 64) -- full tile (wall) end) it('when stepping left on the ascending slope without leaving the ground, decrement x and adjust y', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(12, 9 - pc_data.center_height_standing), -45/360, false, @@ -2159,7 +2792,7 @@ describe('player_char', function () -- step down pc:_next_ground_step(horizontal_dirs.left, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(11, 10 - pc_data.center_height_standing), -45/360, false, @@ -2170,7 +2803,7 @@ describe('player_char', function () end) it('when stepping right on the ascending slope without leaving the ground, decrement x and adjust y', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(12, 9 - pc_data.center_height_standing), -45/360, false, @@ -2180,7 +2813,7 @@ describe('player_char', function () -- step up pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(13, 8 - pc_data.center_height_standing), -45/360, false, @@ -2191,7 +2824,7 @@ describe('player_char', function () end) it('when stepping right on the ascending slope and hitting the right wall, preserve x and y and block', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(13, 10 - pc_data.center_height_standing), -45/360, false, @@ -2201,7 +2834,7 @@ describe('player_char', function () -- step up blocked pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(13, 10 - pc_data.center_height_standing), -45/360, true, @@ -2212,7 +2845,7 @@ describe('player_char', function () end) it('when stepping left on the ascending slope and hitting the left wall, preserve x and y and block', function () - local motion_result = collision.ground_motion_result( + local motion_result = motion.ground_motion_result( vector(11, 10 - pc_data.center_height_standing), -45/360, false, @@ -2222,7 +2855,7 @@ describe('player_char', function () -- step down blocked pc:_next_ground_step(horizontal_dirs.left, motion_result) - assert.are_equal(collision.ground_motion_result( + assert.are_equal(motion.ground_motion_result( vector(11, 10 - pc_data.center_height_standing), -45/360, true, @@ -2234,7 +2867,7 @@ describe('player_char', function () end) - end) + end) -- _next_ground_step describe('_is_blocked_by_ceiling_at', function () @@ -2281,7 +2914,7 @@ describe('player_char', function () assert.is_true(pc:_is_blocked_by_ceiling_at(vector(0, 4))) end) - end) + end) -- _is_blocked_by_ceiling_at describe('_is_column_blocked_by_ceiling_at', function () @@ -2296,8 +2929,8 @@ describe('player_char', function () describe('(1 full tile)', function () before_each(function () - -- X - mset(1, 0, 64) -- full tile (act like a full ceiling if position is at bottom) + -- .X + mock_mset(1, 0, 64) -- full tile (act like a full ceiling if position is at bottom) end) it('should return false for sensor position just above the bottom of the tile', function () @@ -2309,6 +2942,11 @@ describe('player_char', function () assert.is_false(pc._is_column_blocked_by_ceiling_at(vector(7, 8))) end) + -- bugfix history: + -- ? i thought that by design, function should return true but realized it was not consistent + -- ? actually I was right, since if the character moves inside the 2nd of a diagonal tile pattern, + -- it *must* be blocked. when character has a foot on the lower tile, it is considered to be + -- in this lower tile it('should return true for sensor position at the bottom-left of the tile', function () assert.is_true(pc._is_column_blocked_by_ceiling_at(vector(8, 8))) end) @@ -2325,6 +2963,11 @@ describe('player_char', function () assert.is_true(pc._is_column_blocked_by_ceiling_at(vector(12, 8 + pc_data.full_height_standing - 1))) end) + -- bugfix history: + -- < i realized that values of full_height_standing < 8 would fail the test + -- so i moved the height_distance >= pc_data.full_height_standing check above + -- the ground_array_height check (computing height_distance from tile bottom instead of top) + -- to pass it in this case too it('should return false for sensor position below the tile, at character height', function () assert.is_false(pc._is_column_blocked_by_ceiling_at(vector(12, 8 + pc_data.full_height_standing))) end) @@ -2335,7 +2978,7 @@ describe('player_char', function () before_each(function () -- / - mset(0, 0, 65) + mock_mset(0, 0, 65) end) it('should return false for sensor position on the left of the tile', function () @@ -2353,7 +2996,7 @@ describe('player_char', function () end) - end) + end) -- _is_column_blocked_by_ceiling_at describe('_check_jump_intention', function () @@ -2362,140 +3005,318 @@ describe('player_char', function () assert.are_same({false, false}, {pc.jump_intention, pc.should_jump}) end) - it('should *not* consume jump_intention and set should_jump to true if jump_intention is true', function () + it('should consume jump_intention and set should_jump to true if jump_intention is true', function () pc.jump_intention = true pc:_check_jump_intention() - assert.are_same({true, true}, {pc.jump_intention, pc.should_jump}) + assert.are_same({false, true}, {pc.jump_intention, pc.should_jump}) end) end) describe('_check_jump', function () - it('should return false when should_jump is false', function () - pc.velocity = vector(4.1, -1) - local result = pc:_check_jump() - - -- interface - assert.are_same({false, vector(4.1, -1), motion_states.grounded}, {result, pc.velocity, pc.motion_state}) - end) - - it('should consume should_jump, add initial hop velocity, update motion state and return false when should_jump is true and hold_jump_intention is false', function () + it('should not set jump members and return false when should_jump is false', function () pc.velocity = vector(4.1, -1) - pc.should_jump = true local result = pc:_check_jump() -- interface - assert.are_same({true, vector(4.1, -3), motion_states.airborne}, {result, pc.velocity, pc.motion_state}) + assert.are_same({false, vector(4.1, -1), motion_states.grounded, false}, {result, pc.velocity, pc.motion_state, pc.has_jumped_this_frame}) end) - it('should consume should_jump, add initial var jump velocity, update motion state and return false when should_jump is true and hold_jump_intention is true', function () + it('should consume should_jump, add initial var jump velocity, update motion state, set has_jumped_this_frame flag and return true when should_jump is true', function () pc.velocity = vector(4.1, -1) pc.should_jump = true - pc.hold_jump_intention = true local result = pc:_check_jump() -- interface - assert.are_same({true, vector(4.1, -4.25), motion_states.airborne}, {result, pc.velocity, pc.motion_state}) + assert.are_same({true, vector(4.1, -4.25), motion_states.airborne, true}, {result, pc.velocity, pc.motion_state, pc.has_jumped_this_frame}) end) end) describe('_update_platformer_motion_airborne', function () - local check_hold_jump_stub - local enter_motion_state_stub - setup(function () - check_hold_jump_stub = stub(player_char, "_check_hold_jump") - enter_motion_state_stub = stub(player_char, "_enter_motion_state") + spy.on(player_char, "_enter_motion_state") end) teardown(function () - check_hold_jump_stub:revert() - enter_motion_state_stub:revert() - end) - - after_each(function () - check_hold_jump_stub:clear() - enter_motion_state_stub:clear() + player_char._enter_motion_state:revert() end) - it('. should apply gravity to speed y', function () - pc:_update_platformer_motion_airborne() - assert.are_equal(pc_data.gravity_frame2, pc.velocity.y) + before_each(function () + -- optional, just to enter airborne state and be in a meaningful state + pc:_enter_motion_state(motion_states.airborne) + -- clear spy just after this instead of after_each to avoid messing the call count + player_char._enter_motion_state:clear() end) - it('should apply accel x', function () - pc.velocity.x = 4 - pc.move_intention.x = -1 - - pc:_update_platformer_motion_airborne() - - assert.are_equal(4 - pc_data.air_accel_x_frame2, pc.velocity.x) - end) + describe('(when _compute_air_motion_result returns a motion result with position vector(2, 8), is_blocked_by_ceiling: false, is_blocked_by_wall: false, is_landing: false)', function () - it('. should update position with new speed y', function () - pc.position = vector(4, -4) - pc:_update_platformer_motion_airborne() - assert.are_equal(vector(4, -4 + pc_data.gravity_frame2), pc.position) - end) + setup(function () + compute_air_motion_result_mock = stub(player_char, "_compute_air_motion_result", function (self) + return motion.air_motion_result( + vector(2, 8), + false, + false, + false, + nil + ) + end) + end) - it('should call _check_hold_jump', function () - pc:_update_platformer_motion_airborne() + teardown(function () + compute_air_motion_result_mock:revert() + end) - -- implementation - assert.spy(check_hold_jump_stub).was_called(1) - assert.spy(check_hold_jump_stub).was_called_with(match.ref(pc)) - end) + after_each(function () + compute_air_motion_result_mock:clear() + end) + + it('should set velocity y to -jump_interrupt_speed_frame on first frame of hop if velocity.y is not already greater, and clear has_jumped_this_frame flag', function () + pc.velocity.y = -3 -- must be < -pc_data.jump_interrupt_speed_frame (-2) + pc.has_jumped_this_frame = true + pc.hold_jump_intention = false + + pc:_update_platformer_motion_airborne() + + -- interface: we are assessing the effect of _check_hold_jump directly + assert.are_same({-pc_data.jump_interrupt_speed_frame, false}, {pc.velocity.y, pc.has_jumped_this_frame}) + end) + + it('should preserve velocity y completely on first frame of hop if velocity.y is already greater, and clear has_jumped_this_frame flag', function () + -- this can happen when character is running down a steep slope, and hops with a normal close to horizontal + pc.velocity.y = -1 -- must be >= -pc_data.jump_interrupt_speed_frame (-2) + pc.has_jumped_this_frame = true + pc.hold_jump_intention = false + + pc:_update_platformer_motion_airborne() + + -- interface: we are assessing the effect of _check_hold_jump directly + assert.are_same({-1, false}, {pc.velocity.y, pc.has_jumped_this_frame}) + end) + + it('should preserve (supposedly initial jump) velocity y on first frame of jump (not hop) and clear has_jumped_this_frame flag', function () + pc.velocity.y = -3 + pc.has_jumped_this_frame = true + pc.hold_jump_intention = true + + pc:_update_platformer_motion_airborne() + + -- interface: we are assessing the effect of _check_hold_jump directly + assert.are_same({-3, false}, {pc.velocity.y, pc.has_jumped_this_frame}) + end) + + it('should apply gravity to velocity y when not on first frame of jump and not interrupting jump', function () + pc.velocity.y = -1 + pc.has_jumped_this_frame = false + pc.hold_jump_intention = true + + pc:_update_platformer_motion_airborne() + + -- interface: we are assessing the effect of _check_hold_jump directly + assert.are_same({-1 + pc_data.gravity_frame2, false}, {pc.velocity.y, pc.has_jumped_this_frame}) + end) + + it('should set to speed y to interrupt speed (no gravity added) when interrupting actual jump', function () + pc.velocity.y = -3 -- must be < -pc_data.jump_interrupt_speed_frame (-2) + pc.has_jumped_this_frame = false + pc.hold_jump_intention = false + + pc:_update_platformer_motion_airborne() + + -- interface: we are assessing the effect of _check_hold_jump directly + -- note that gravity is applied *before* interrupt jump, so we don't see it in the final velocity.y + assert.are_same({-pc_data.jump_interrupt_speed_frame, false}, {pc.velocity.y, pc.has_jumped_this_frame}) + end) + + it('should set to speed y to interrupt speed (no gravity added) when interrupting actual jump', function () + pc.velocity.y = -1 -- must be >= -pc_data.jump_interrupt_speed_frame (-2) + pc.has_jumped_this_frame = false + pc.hold_jump_intention = false + + pc:_update_platformer_motion_airborne() + + -- interface: we are assessing the effect of _check_hold_jump directly + assert.are_same({-1 + pc_data.gravity_frame2, false}, {pc.velocity.y, pc.has_jumped_this_frame}) + end) - describe('(_check_escape_from_ground returns false, so has not landed)', function () + it('should apply air accel x', function () + pc.velocity.x = 4 + pc.move_intention.x = -1 - local check_escape_from_ground_mock + pc:_update_platformer_motion_airborne() + + -- interface: we are assessing the effect of _check_hold_jump directly + assert.are_equal(4 - pc_data.air_accel_x_frame2, pc.velocity.x) + end) + + it('should set horizontal direction to intended motion direction: left', function () + pc.horizontal_dir = horizontal_dirs.right + pc.velocity.x = 4 + pc.move_intention.x = -1 + + pc:_update_platformer_motion_airborne() + + assert.are_equal(horizontal_dirs.left, pc.horizontal_dir) + end) + + it('should set horizontal direction to intended motion direction: right', function () + pc.horizontal_dir = horizontal_dirs.left + pc.velocity.x = 4 + pc.move_intention.x = 1 + + pc:_update_platformer_motion_airborne() + + assert.are_equal(horizontal_dirs.right, pc.horizontal_dir) + end) + + -- bugfix history: + -- . + it('should update position with air motion result position', function () + pc.position = vector(0, 0) -- doesn't matter, since we mock _compute_air_motion_result + + pc:_update_platformer_motion_airborne() + + assert.are_equal(vector(2, 8), pc.position) + end) + + it('should preserve velocity.y', function () + -- set those flags to true to make computations more simple: + -- velocity.y will not affected by gravity nor interrupt jump + pc.has_jumped_this_frame = true + pc.hold_jump_intention = true + pc.velocity = vector(10, -10) + + pc:_update_platformer_motion_airborne() + + assert.are_equal(-10, pc.velocity.y) + end) + + end) -- compute_air_motion_result_mock (vector(2, 8), false, false, false) + + describe('(when _compute_air_motion_result returns a motion result with is_blocked_by_wall: false, is_blocked_by_ceiling: true)', function () setup(function () - check_escape_from_ground_mock = stub(player_char, "_check_escape_from_ground", function () - return false + compute_air_motion_result_mock = stub(player_char, "_compute_air_motion_result", function (self) + return motion.air_motion_result( + vector(2, 8), + false, -- not the focus, but verified + true, -- focus in this test + false, + nil + ) end) end) teardown(function () - check_escape_from_ground_mock:revert() + compute_air_motion_result_mock:revert() end) - it('should not enter grounded state', function () + after_each(function () + compute_air_motion_result_mock:clear() + end) + + it('should set velocity.y to 0', function () + -- set those flags to true to make computations more simple: + -- velocity.y will not affected by gravity nor interrupt jump + pc.has_jumped_this_frame = true + pc.hold_jump_intention = true + pc.velocity = vector(10, -10) + pc:_update_platformer_motion_airborne() - -- implementation - assert.spy(enter_motion_state_stub).was_not_called() + assert.are_equal(0, pc.velocity.y) end) - end) + it('should preserve velocity.x', function () + pc.velocity = vector(10, -10) + + pc:_update_platformer_motion_airborne() + + assert.are_equal(10, pc.velocity.x) + end) - describe('(_check_escape_from_ground returns true, so has landed)', function () + end) -- compute_air_motion_result_mock (is_blocked_by_ceiling: true) - local check_escape_from_ground_mock + describe('(when _compute_air_motion_result returns a motion result with is_blocked_by_wall: true, is_blocked_by_ceiling: false)', function () setup(function () - check_escape_from_ground_mock = stub(player_char, "_check_escape_from_ground", function () - return true + compute_air_motion_result_mock = stub(player_char, "_compute_air_motion_result", function (self) + return motion.air_motion_result( + vector(2, 8), + true, -- focus in this test + false, -- not the focus, but verified + false, + nil + ) end) end) teardown(function () - check_escape_from_ground_mock:revert() + compute_air_motion_result_mock:revert() end) - it('should enter grounded state', function () + after_each(function () + compute_air_motion_result_mock:clear() + end) + + it('should preserve velocity.y', function () + -- set those flags to true to make computations more simple: + -- velocity.y will not affected by gravity nor interrupt jump + pc.has_jumped_this_frame = true + pc.hold_jump_intention = true + pc.velocity = vector(10, -10) + pc:_update_platformer_motion_airborne() - -- implementation - assert.spy(enter_motion_state_stub).was_called(1) - assert.spy(enter_motion_state_stub).was_called_with(match.ref(pc), motion_states.grounded) + assert.are_equal(-10, pc.velocity.y) + end) + + it('should set velocity.x to 0', function () + pc.velocity = vector(10, -10) + + pc:_update_platformer_motion_airborne() + + assert.are_equal(0, pc.velocity.x) end) end) + describe('(when _compute_air_motion_result returns a motion result with is_landing: true, slope_angle: 0.5)', function () + + setup(function () + compute_air_motion_result_mock = stub(player_char, "_compute_air_motion_result", function (self) + return motion.air_motion_result( + vector(2, 8), + false, + false, + true, -- focus in this test + 0.5 + ) + end) + end) + + teardown(function () + compute_air_motion_result_mock:revert() + end) + + after_each(function () + compute_air_motion_result_mock:clear() + end) + + it('should enter grounded state with slope_angle: 0.5', function () + pc:_update_platformer_motion_airborne() + + -- implementation + assert.spy(pc._enter_motion_state).was_called(1) + assert.spy(pc._enter_motion_state).was_called_with(match.ref(pc), motion_states.grounded) + + assert.are_equal(0.5, pc.slope_angle) + end) + + end) -- compute_air_motion_result_mock (is_blocked_by_wall: true) + end) -- _update_platformer_motion_airborne end) -- (with mock tiles data setup) @@ -2543,6 +3364,485 @@ describe('player_char', function () end) + describe('_compute_air_motion_result', function () + + it('(when velocity is zero) should return air_motion_result with initial position and no hits', function () + pc.position = vector(4, 8) + assert.are_equal(motion.air_motion_result( + vector(4, 8), + false, + false, + false, + nil + ), pc:_compute_air_motion_result()) + end) + + describe('(when _advance_in_air_along returns an air_motion_result with full motion done along x, half motion done with hit ceiling along y)', function () + + setup(function () + advance_in_air_along_mock = stub(player_char, "_advance_in_air_along", function (self, ref_motion_result, velocity, coord) + if coord == "x" then + local motion = vector(velocity.x, 0) + ref_motion_result.position = ref_motion_result.position + motion + else -- coord == "y" + -- to make sure we are calling _advance_in_air_along on y before x, we add a check here: + -- if we have already moved from initial pos.x = 4.5 (see test below), block any motion along y + if ref_motion_result.position.x == 4.5 then + local motion = vector(0, velocity.y / 2) + ref_motion_result.position = ref_motion_result.position + motion + end + ref_motion_result.is_blocked_by_ceiling = true + end + end) + end) + + teardown(function () + advance_in_air_along_mock:revert() + end) + + after_each(function () + advance_in_air_along_mock:clear() + end) + + it('(when velocity is zero) should return air_motion_result with initial position and no hits', function () + pc.position = vector(4.5, 8) + pc.velocity = vector(5, -12) + + -- character should advance of (5, -6) resulting in pos (9.5, 2) + + -- interface: check that the final result is correct + assert.are_equal(motion.air_motion_result( + vector(9.5, 2), + false, + true, -- hit ceiling + false, + nil + ), pc:_compute_air_motion_result()) + end) + + end) + + end) + + describe('_advance_in_air_along', function () + + describe('(when _next_air_step moves motion_result.position.x/y by 1px in the given direction, ' .. + 'unless moving along x from x >= 5, where it is blocking by wall)', function () + + local next_air_step_mock + + setup(function () + next_air_step_mock = stub(player_char, "_next_air_step", function (self, direction, motion_result) + if coord == "y" or motion_result.position.x < 5 then + local step_vec = dir_vectors[direction] + motion_result.position = motion_result.position + step_vec + else + motion_result.is_blocked_by_wall = true + end + end) + end) + + teardown(function () + next_air_step_mock:revert() + end) + + after_each(function () + next_air_step_mock:clear() + end) + + -- bugfix history: + -- = the itest 'platformer air wall block' showed that the subpixel check + -- was using the integer max_pixel_distance instead of the float velocity[coord] + -- and this revealed a bug of no motion on x at all when velocity.x is < 1 and x starts integer + it('(vector(0, 10) at speed 0.5 along x) should move to vector(0.7, 10) without being blocked', function () + local motion_result = motion.air_motion_result( + vector(0, 10), + false, + false, + false, + nil + ) + + -- we assume _compute_max_pixel_distance is correct + pc:_advance_in_air_along(motion_result, vector(0.5, 99), "x") + + assert.are_equal(motion.air_motion_result( + vector(0.5, 10), + false, + false, + false, + nil + ), motion_result + ) + end) + + it('(vector(0.2, 10) at speed 0.5 along x) should move to vector(0.7, 10) without being blocked', function () + local motion_result = motion.air_motion_result( + vector(0.2, 10), + false, + false, + false, + nil + ) + + -- we assume _compute_max_pixel_distance is correct + pc:_advance_in_air_along(motion_result, vector(0.5, 99), "x") + + assert.are_equal(motion.air_motion_result( + vector(0.7, 10), + false, + false, + false, + nil + ), motion_result + ) + end) + + it('(vector(0.5, 10) at speed 0.5 along x) should move to vector(1, 10) without being blocked', function () + local motion_result = motion.air_motion_result( + vector(0.5, 10), + false, + false, + false, + nil + ) + + -- we assume _compute_max_pixel_distance is correct + pc:_advance_in_air_along(motion_result, vector(0.5, 99), "x") + + assert.are_equal(motion.air_motion_result( + vector(1, 10), + false, + false, + false, + nil + ), motion_result + ) + end) + + it('(vector(0.4, 10) at speed 2.7 along x) should move to vector(3.1, 10)', function () + local motion_result = motion.air_motion_result( + vector(0.4, 10), + false, + false, + false, + nil + ) + + -- we assume _compute_max_pixel_distance is correct + pc:_advance_in_air_along(motion_result, vector(2.7, 99), "x") + + assert.are_equal(motion.air_motion_result( + vector(3.1, 10), + false, + false, + false, + nil + ), motion_result + ) + end) + + it('(vector(2.5, 10) at speed 2.7 along x) should move to vector(5, 10) and blocked by wall', function () + local motion_result = motion.air_motion_result( + vector(2.5, 10), + false, + false, + false, + nil + ) + + -- we assume _compute_max_pixel_distance is correct + pc:_advance_in_air_along(motion_result, vector(2.7, 99), "x") + + assert.are_equal(motion.air_motion_result( + vector(5, 10), + true, + false, + false, + nil + ), motion_result + ) + end) + + it('(vector(2.5, 7.3) at speed -4.4 along y) should move to vector(2.5, 2.9) without being blocked', function () + local motion_result = motion.air_motion_result( + vector(2.5, 7.3), + false, + false, + false, + nil + ) + + -- we assume _compute_max_pixel_distance is correct + pc:_advance_in_air_along(motion_result, vector(99, -4.4), "y") + + assert.is_true(almost_eq_with_message(vector(2.5, 2.9), motion_result.position)) + assert.are_same({ + false, + false, + false + }, { + motion_result.is_blocked_by_wall, + motion_result.is_blocked_by_ceiling, + motion_result.is_landing + }) + end) + + end) + + end) + + describe('_next_air_step', function () + it('(in the air) direction up should move 1px up without being blocked', function () + local motion_result = motion.air_motion_result( + vector(2, 7), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.up, motion_result) + + assert.are_equal(motion.air_motion_result( + vector(2, 6), + false, + false, + false, + nil + ), + motion_result + ) + end) + + it('(in the air) direction down should move 1px down without being blocked', function () + local motion_result = motion.air_motion_result( + vector(2, 7), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.down, motion_result) + + assert.are_equal(motion.air_motion_result( + vector(2, 8), + false, + false, + false, + nil + ), + motion_result + ) + end) + + it('(in the air) direction left should move 1px left without being blocked', function () + local motion_result = motion.air_motion_result( + vector(2, 7), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.left, motion_result) + + assert.are_equal(motion.air_motion_result( + vector(1, 7), + false, + false, + false, + nil + ), + motion_result + ) + end) + + it('(in the air) direction right should move 1px right without being blocked', function () + local motion_result = motion.air_motion_result( + vector(2, 7), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.right, motion_result) + + assert.are_equal(motion.air_motion_result( + vector(3, 7), + false, + false, + false, + nil + ), + motion_result + ) + end) + + describe('(with mock tiles data setup)', function () + + setup(function () + tile_test_data.setup() + end) + + teardown(function () + tile_test_data.teardown() + end) + + after_each(function () + pico8:clear_map() + end) + + -- for these utests, we assume that _compute_ground_sensors_signed_distance and + -- _is_blocked_by_ceiling are correct, + -- so rather than mocking them, so we setup simple tiles to walk on + + describe('(with flat ground)', function () + + before_each(function () + mock_mset(0, 0, 64) -- full tile + end) + + it('direction up into ceiling should not move, and flag is_blocked_by_ceiling', function () + local motion_result = motion.air_motion_result( + vector(4, 8 + pc_data.full_height_standing - pc_data.center_height_standing), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.up, motion_result) + + assert.are_equal(motion.air_motion_result( + vector(4, 8 + pc_data.full_height_standing - pc_data.center_height_standing), + false, + true, + false, + nil + ), + motion_result + ) + end) + + it('direction down into ground should not move, and flag is_landing with slope_angle', function () + local motion_result = motion.air_motion_result( + vector(4, 0 - pc_data.center_height_standing), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.down, motion_result) + + assert.are_equal(motion.air_motion_result( + vector(4, 0 - pc_data.center_height_standing), + false, + false, + true, + 0 + ), + motion_result + ) + end) + + it('direction left into wall via ground should not move, and flag is_blocked_by_wall', function () + local motion_result = motion.air_motion_result( + vector(11, 1 - pc_data.center_height_standing), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.left, motion_result) + + assert.are_equal(motion.air_motion_result( + vector(11, 1 - pc_data.center_height_standing), + true, + false, + false, + nil + ), + motion_result + ) + end) + + it('direction right into wall via ceiling should not move, and flag is_blocked_by_wall', function () + local motion_result = motion.air_motion_result( + vector(-3, 7 + pc_data.full_height_standing - pc_data.center_height_standing), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.right, motion_result) + + assert.are_equal(motion.air_motion_result( + vector(-3, 7 + pc_data.full_height_standing - pc_data.center_height_standing), + true, + false, + false, + nil + ), + motion_result + ) + end) + + it('(after landing in previous step) direction right onto new ground should move and update slope_angle', function () + local motion_result = motion.air_motion_result( + vector(-3, 0 - pc_data.center_height_standing), + false, + false, + true, + 0.5 + ) + + pc:_next_air_step(directions.right, motion_result) + + assert.are_equal(motion.air_motion_result( + vector(-2, 0 - pc_data.center_height_standing), + false, + false, + true, + 0 + ), + motion_result + ) + end) + + it('(after landing in previous step) direction left into the air should move and unset is_landing', function () + local motion_result = motion.air_motion_result( + vector(-2, 0 - pc_data.center_height_standing), + false, + false, + true, + 0 + ) + + pc:_next_air_step(directions.left, motion_result) + + assert.are_equal(motion.air_motion_result( + vector(-3, 0 - pc_data.center_height_standing), + false, + false, + false, + nil + ), + motion_result + ) + end) + + end) + + end) -- (with mock tiles data setup) + + end) -- _next_air_step + describe('_update_debug', function () local update_velocity_debug_stub @@ -2666,25 +3966,41 @@ describe('player_char', function () describe('render', function () - local spr_data_render_stub + local anim_spr_render_stub setup(function () - spr_data_render_stub = stub(pc_data.sonic_sprite_data["idle"], "render") + -- create a generic stub at struct level so it works with any particular sprite + anim_spr_render_stub = stub(animated_sprite, "render") end) teardown(function () - spr_data_render_stub:revert() + anim_spr_render_stub:revert() end) after_each(function () - spr_data_render_stub:clear() + anim_spr_render_stub:clear() + end) + + it('(when character is facing left) should call render on sonic sprite data: idle with the character\'s position, flipped x', function () + pc.position = vector(12, 8) + pc.horizontal_dir = horizontal_dirs.left + + pc:render() + + assert.spy(anim_spr_render_stub).was_called(1) + assert.spy(anim_spr_render_stub).was_called_with(match.ref(pc.anim_spr), vector(12, 8), true) end) - it('should call render on sonic sprite data: idle with the character\'s position', function () + it('(when character is facing right) should call render on sonic sprite data: idle with the character\'s position, not flipped x', function () + pc.position = vector(12, 8) + pc.horizontal_dir = horizontal_dirs.right + pc:render() - assert.spy(spr_data_render_stub).was_called(1) - assert.spy(spr_data_render_stub).was_called_with(match.ref(pc_data.sonic_sprite_data["idle"]), pc.position) + + assert.spy(anim_spr_render_stub).was_called(1) + assert.spy(anim_spr_render_stub).was_called_with(match.ref(pc.anim_spr), vector(12, 8), false) end) + end) end) diff --git a/tests/uteststage.lua b/src/game/tests/uteststage.lua similarity index 80% rename from tests/uteststage.lua rename to src/game/tests/uteststage.lua index 82239b8f..a783589a 100644 --- a/tests/uteststage.lua +++ b/src/game/tests/uteststage.lua @@ -1,5 +1,4 @@ -require("bustedhelper") -local input = require("engine/input/input") +require("engine/test/bustedhelper") local ui = require("engine/ui/ui") local stage = require("game/ingame/stage") local state = stage.state @@ -319,7 +318,8 @@ describe('stage', function () state:init() end) - it('. init', function () + -- bugifx history: . + it('init', function () assert.are_same({ gamestate.types.stage, {}, @@ -345,7 +345,7 @@ describe('stage', function () describe('on_enter', function () local spawn_player_char_stub - local handle_input_stub + local start_coroutine_method_stub local play_bgm_stub setup(function () @@ -402,7 +402,7 @@ describe('stage', function () describe('on_exit', function () local title_overlay_clear_labels_stub - local handle_input_stub + local start_coroutine_method_stub local stop_bgm_stub setup(function () @@ -503,128 +503,6 @@ describe('stage', function () flow.curr_state = nil end) - describe('player_char', function () - - describe('handle_input', function () - - after_each(function () - input.players_btn_states[0][button_ids.left] = btn_states.released - input.players_btn_states[0][button_ids.right] = btn_states.released - input.players_btn_states[0][button_ids.up] = btn_states.released - input.players_btn_states[0][button_ids.down] = btn_states.released - input.players_btn_states[0][button_ids.o] = btn_states.released - - state.player_char.move_intention = vector.zero() - state.player_char.jump_intention = false - state.player_char.hold_jump_intention = false - end) - - describe('(when player character control mode is not human)', function () - - before_each(function () - state.player_char.control_mode = control_modes.ai - end) - - it('should do nothing', function () - input.players_btn_states[0][button_ids.left] = btn_states.pressed - state:handle_input() - assert.are_equal(vector:zero(), state.player_char.move_intention) - input.players_btn_states[0][button_ids.up] = btn_states.pressed - state:handle_input() - assert.are_equal(vector:zero(), state.player_char.move_intention) - end) - - end) - - -- control mode is human by default - - it('(when input left in down) it should update the player character\'s move intention by (-1, 0)', function () - input.players_btn_states[0][button_ids.left] = btn_states.pressed - state:handle_input() - assert.are_equal(vector(-1, 0), state.player_char.move_intention) - end) - - it('(when input right in down) it should update the player character\'s move intention by (1, 0)', function () - input.players_btn_states[0][button_ids.right] = btn_states.just_pressed - state:handle_input() - assert.are_equal(vector(1, 0), state.player_char.move_intention) - end) - - it('(when input left and right are down) it should update the player character\'s move intention by (-1, 0)', function () - input.players_btn_states[0][button_ids.left] = btn_states.pressed - input.players_btn_states[0][button_ids.right] = btn_states.just_pressed - state:handle_input() - assert.are_equal(vector(-1, 0), state.player_char.move_intention) - end) - - it('(when input up in down) it should update the player character\'s move intention by (-1, 0)', function () - input.players_btn_states[0][button_ids.up] = btn_states.pressed - state:handle_input() - assert.are_equal(vector(0, -1), state.player_char.move_intention) - end) - - it('(when input down in down) it should update the player character\'s move intention by (0, 1)', function () - input.players_btn_states[0][button_ids.down] = btn_states.pressed - state:handle_input() - assert.are_equal(vector(0, 1), state.player_char.move_intention) - end) - - it('(when input up and down are down) it should update the player character\'s move intention by (0, -1)', function () - input.players_btn_states[0][button_ids.up] = btn_states.just_pressed - input.players_btn_states[0][button_ids.down] = btn_states.pressed - state:handle_input() - assert.are_equal(vector(0, -1), state.player_char.move_intention) - end) - - it('(when input left and up are down) it should update the player character\'s move intention by (-1, -1)', function () - input.players_btn_states[0][button_ids.left] = btn_states.just_pressed - input.players_btn_states[0][button_ids.up] = btn_states.just_pressed - state:handle_input() - assert.are_equal(vector(-1, -1), state.player_char.move_intention) - end) - - it('(when input left and down are down) it should update the player character\'s move intention by (-1, 1)', function () - input.players_btn_states[0][button_ids.left] = btn_states.just_pressed - input.players_btn_states[0][button_ids.down] = btn_states.just_pressed - state:handle_input() - assert.are_equal(vector(-1, 1), state.player_char.move_intention) - end) - - it('(when input right and up are down) it should update the player character\'s move intention by (1, -1)', function () - input.players_btn_states[0][button_ids.right] = btn_states.just_pressed - input.players_btn_states[0][button_ids.up] = btn_states.just_pressed - state:handle_input() - assert.are_equal(vector(1, -1), state.player_char.move_intention) - end) - - it('(when input right and down are down) it should update the player character\'s move intention by (1, 1)', function () - input.players_btn_states[0][button_ids.right] = btn_states.just_pressed - input.players_btn_states[0][button_ids.down] = btn_states.just_pressed - state:handle_input() - assert.are_equal(vector(1, 1), state.player_char.move_intention) - end) - - it('(when input o is released) it should update the player character\'s jump intention to false, hold jump intention to false', function () - state:handle_input() - assert.are_same({false, false}, {state.player_char.jump_intention, state.player_char.hold_jump_intention}) - end) - - it('(when input o is just pressed) it should update the player character\'s jump intention to true, hold jump intention to true', function () - input.players_btn_states[0][button_ids.o] = btn_states.just_pressed - state:handle_input() - assert.are_same({true, true}, {state.player_char.jump_intention, state.player_char.hold_jump_intention}) - end) - - it('(when input o is pressed) it should update the player character\'s jump intention to false, hold jump intention to true', function () - input.players_btn_states[0][button_ids.o] = btn_states.pressed - state:handle_input() - assert.are_same({false, true}, {state.player_char.jump_intention, state.player_char.hold_jump_intention}) - end) - - end) - - end) - describe('update_camera', function () before_each(function () @@ -641,14 +519,12 @@ describe('stage', function () describe('update', function () local update_coroutines_stub - local handle_input_stub local player_char_update_stub local check_reached_goal_stub local update_camera_stub setup(function () update_coroutines_stub = stub(state, "update_coroutines") - handle_input_stub = stub(state, "handle_input") player_char_update_stub = stub(player_char, "update") check_reached_goal_stub = stub(state, "check_reached_goal") update_camera_stub = stub(state, "update_camera") @@ -656,7 +532,6 @@ describe('stage', function () teardown(function () update_coroutines_stub:revert() - handle_input_stub:revert() player_char_update_stub:revert() check_reached_goal_stub:revert() update_camera_stub:revert() @@ -664,7 +539,6 @@ describe('stage', function () after_each(function () update_coroutines_stub:clear() - handle_input_stub:clear() player_char_update_stub:clear() check_reached_goal_stub:clear() update_camera_stub:clear() @@ -672,13 +546,11 @@ describe('stage', function () describe('(current substate is play)', function () - it('should call handle_input, player_char:update, check_reached_goal and update_camera', function () + it('should call player_char:update, check_reached_goal and update_camera', function () state.current_substate = stage.substates.play state:update() assert.spy(update_coroutines_stub).was_called(1) assert.spy(update_coroutines_stub).was_called_with(match.ref(state)) - assert.spy(handle_input_stub).was_called(1) - assert.spy(handle_input_stub).was_called_with(match.ref(state)) assert.spy(player_char_update_stub).was_called(1) assert.spy(player_char_update_stub).was_called_with(match.ref(state.player_char)) assert.spy(check_reached_goal_stub).was_called(1) @@ -689,12 +561,11 @@ describe('stage', function () describe('(current substate is result)', function () - it('should call handle_input, player_char:update, check_reached_goal and update_camera', function () + it('should call player_char:update, check_reached_goal and update_camera', function () state.current_substate = stage.substates.result state:update() assert.spy(update_coroutines_stub).was_called(1) assert.spy(update_coroutines_stub).was_called_with(match.ref(state)) - assert.spy(handle_input_stub).was_not_called() assert.spy(player_char_update_stub).was_not_called() assert.spy(check_reached_goal_stub).was_not_called() assert.spy(update_camera_stub).was_not_called() diff --git a/src/game/tests/utesttile.lua b/src/game/tests/utesttile.lua new file mode 100644 index 00000000..fa49b792 --- /dev/null +++ b/src/game/tests/utesttile.lua @@ -0,0 +1,119 @@ +require("engine/test/bustedhelper") +require("engine/core/math") +local tile = require("game/platformer/tile") +local tile_data = tile.tile_data +local height_array = tile.height_array + +describe('tile', function () + + describe('tile_data', function () + + describe('_init', function () + + it('should create a tile data setting the sprite id location and the slope angle', function () + local td = tile_data(sprite_id_location(1, 2), 0.125) + assert.are_same({sprite_id_location(1, 2), 0.125}, {td.id_loc, td.slope_angle}) + end) + + end) + + describe('_tostring', function () + + it('should return "height_array({4, 5, 6, 7, 8, 9, 10, 11}, 0.125)"', function () + local td = tile_data(sprite_id_location(1, 2), 0.125) + assert.are_equal("tile_data(sprite_id_location(1, 2), 0.125)", td:_tostring()) + end) + + end) + + end) + + describe('height_array', function () + + describe("mocking _fill_array", function () + + local fill_array_mock + + setup(function () + fill_array_mock = stub(height_array, "_fill_array", function (array, tile_mask_sprite_id_location) + for i = 1, tile_size do + array[i] = tile_mask_sprite_id_location.i + tile_mask_sprite_id_location.j + i + end + end) + end) + + teardown(function () + fill_array_mock:revert() + end) + + after_each(function () + fill_array_mock:clear() + end) + + describe('_init', function () + + it('should create a height array using fill_array and setting the slope angle', function () + local h_array = height_array(tile_data(sprite_id_location(1, 2), 0.125)) + assert.are_same({{4, 5, 6, 7, 8, 9, 10, 11}, 0.125}, {h_array._array, h_array.slope_angle}) + end) + + end) + + describe('_tostring', function () + + it('should return "height_array({4, 5, 6, 7, 8, 9, 10, 11}, 0.125)"', function () + local h_array = height_array(tile_data(sprite_id_location(1, 2), 0.125)) + assert.are_equal("height_array({4, 5, 6, 7, 8, 9, 10, 11}, 0.125)", h_array:_tostring()) + end) + + end) + + describe('get_height', function () + + it('should return the height at the given column index', function () + local h_array = height_array(tile_data(sprite_id_location(1, 2), 0.125)) + assert.are_equal(6, h_array:get_height(2)) + end) + + end) + + end) + + describe('_fill_array', function () + + local sget_mock + + setup(function () + -- simulate an sget that would return the pixel of a tile mask + -- if coordinates fall in the sprite at location (1, 2), i.e. [8-15] x [16-23], + -- where mock_height_array contains the respective height of the mask columns + -- for each column from left to right + local mock_height_array = {2, 3, 5, 6, 0, 1, 4, 2} + sget_mock = stub(_G, "sget", function (x, y) + if x >= 8 and x <= 15 and y >= 16 and y <= 23 then + -- return filled pixel color iff below mask height on this column + local height = mock_height_array[x - 7] + if y - 16 >= tile_size - height then + return 1 + else + return 0 + end + end + end) + end) + + teardown(function () + sget_mock:revert() + end) + + it('should fill the array with ', function () + local array = {} + height_array._fill_array(array, sprite_id_location(1, 2)) + assert.are_same({2, 3, 5, 6, 0, 1, 4, 2}, array) + end) + + end) + + end) + +end) diff --git a/src/game/tests/utesttile_data.lua b/src/game/tests/utesttile_data.lua new file mode 100644 index 00000000..53237c17 --- /dev/null +++ b/src/game/tests/utesttile_data.lua @@ -0,0 +1,38 @@ +require("engine/test/bustedhelper") +require("game/data/tile_data") +local tile_test_data = require("game/test_data/tile_test_data") + +describe('tiledata', function () + + setup(function () + stub(tile_test_data, "setup") + stub(tile_test_data, "teardown") + end) + + teardown(function () + tile_test_data.setup:revert() + tile_test_data.teardown:revert() + end) + + after_each(function () + tile_test_data.setup:clear() + tile_test_data.teardown:clear() + end) + + describe('setup_map_data', function () + it('should call setup on tile_test_data (busted only)', function () + setup_map_data() + assert.spy(tile_test_data.setup).was_called(1) + assert.spy(tile_test_data.setup).was_called_with() + end) + end) + + describe('teardown_map_data', function () + it('should call teardown on tile_test_data (busted only)', function () + teardown_map_data() + assert.spy(tile_test_data.teardown).was_called(1) + assert.spy(tile_test_data.teardown).was_called_with() + end) + end) + +end) diff --git a/tests/utesttitlemenu.lua b/src/game/tests/utesttitlemenu.lua similarity index 99% rename from tests/utesttitlemenu.lua rename to src/game/tests/utesttitlemenu.lua index e03b738b..7296c00c 100644 --- a/tests/utesttitlemenu.lua +++ b/src/game/tests/utesttitlemenu.lua @@ -1,4 +1,4 @@ -require("bustedhelper") +require("engine/test/bustedhelper") local input = require("engine/input/input") local titlemenu = require("game/menu/titlemenu") local flow = require("engine/application/flow") diff --git a/tests/utestworld.lua b/src/game/tests/utestworld.lua similarity index 97% rename from tests/utestworld.lua rename to src/game/tests/utestworld.lua index 736568c8..10072e9b 100644 --- a/tests/utestworld.lua +++ b/src/game/tests/utestworld.lua @@ -1,4 +1,4 @@ -local world = require("engine/physics/world") +local world = require("game/platformer/world") local tile_test_data = require("game/test_data/tile_test_data") describe('world (with mock tiles data setup)', function () @@ -29,7 +29,7 @@ describe('world (with mock tiles data setup)', function () before_each(function () -- create an invalid tile with a collision flag but no collision mask associated - mset(1, 1, 1) + mock_mset(1, 1, 1) end) it('should assert if tile has collision flag set but no collision mask id associated', function () @@ -45,7 +45,7 @@ describe('world (with mock tiles data setup)', function () before_each(function () -- create an ascending slope 22.5 at (1, 1), i.e. (8, 14) to (15, 11) px - mset(1, 1, 67) + mock_mset(1, 1, 67) end) it('should return 3 on column 3', function () @@ -62,7 +62,7 @@ describe('world (with mock tiles data setup)', function () before_each(function () -- create a full tile at (1, 1), i.e. (8, 8) to (15, 15) px - mset(1, 1, 64) + mock_mset(1, 1, 64) end) it('should return {false, nil} on (7, 7)', function () @@ -141,7 +141,7 @@ describe('world (with mock tiles data setup)', function () before_each(function () -- create an ascending slope at (1, 1), i.e. (8, 15) to (15, 8) px - mset(1, 1, 65) + mock_mset(1, 1, 65) end) it('should return {false, nil} on (8, 14)', function () diff --git a/src/game/utests/utestdata.lua b/src/game/utests/utestdata.lua index d8d5cf7c..cc701436 100644 --- a/src/game/utests/utestdata.lua +++ b/src/game/utests/utestdata.lua @@ -1,9 +1,9 @@ require("engine/test/unittest") require("engine/core/math") -local collision = require("engine/physics/collision") +local tile = require("game/platformer/tile") local collision_data = require("game/data/collision_data") -local height_array = collision.height_array -local tile_data = collision.tile_data +local height_array = tile.height_array +local tile_data = tile.tile_data check('sprite_id_location(0, 4) should have collision flag set', function () local sprite_id = sprite_id_location(0, 4):to_sprite_id() diff --git a/test.sh b/test.sh index d86d004d..8f989326 100755 --- a/test.sh +++ b/test.sh @@ -2,32 +2,63 @@ # $1: test name (module name) if [[ $# -lt 1 ]] ; then - echo "test.sh takes 1 mandatory param and 1 optional param, provided $#: + echo "test.sh takes 1 mandatory param, 1 optional param and 1 option, provided $#: $1: test file pattern - $2: test filter mode: (default 'standard') 'standard' to filter out all #mute, 'solo' to filter #solo, 'all' to include #mute" + $2: test filter mode: (default 'standard') 'standard' to filter out all #mute, 'solo' to filter #solo, 'all' to include #mute + -r or --render to enable rendering in the itest loop (used for $1=headless_itests only)" exit 1 fi +# https://stackoverflow.com/questions/192249/how-do-i-parse-command-line-arguments-in-bash + if [[ ${1::5} = "utest" ]] ; then MODULE=${1:5} else MODULE=$1 fi +# shift arguments 1 position so we start reading options at $1 +shift + +TEST_FILTER_MODE="standard" + +# if second argument is not an option, it means it's the positional argument "test filter mode" +# remember to check for "-" not "--" as the shortcut options only use a single hyphen +if [[ "${1::1}" != "-" ]] ; then + TEST_FILTER_MODE=$1 # should be "all" or "solo" + shift +fi + +RENDER=false + +while [[ "$1" != "" ]]; do + case $1 in + -r | --render ) shift + RENDER=true + ;; + * ) echo "unknown option: $1" + exit 1 + esac + shift +done + if [[ $MODULE = "all" || -z $MODULE ]] ; then TEST_FILE_PATTERN="utest" # all unit tests COVERAGE_OPTIONS="-c .luacov_all" # we cannot just use default .luacov since it would also affect specific module tests else - # prepend "utest" again in case a module name contains another one (e.g. logger c visual_logger) - TEST_FILE_PATTERN="utest$MODULE" - COVERAGE_OPTIONS="-c .luacov_current \"/$MODULE\"" + # prepend "utest" again, and append ".lua" in case a module name contains another one + # (e.g. logger for visual_logger or tile for tile_data) + TEST_FILE_PATTERN="utest${MODULE}.lua" + # FIXME + # "/"" makes sure the filename starts with MODULE, but "."" is interpreted as "any char" so _data would be included + COVERAGE_OPTIONS="-c .luacov_current \"/${MODULE}.lua\"" fi -if [[ $2 = "all" ]] ; then +if [[ $TEST_FILTER_MODE = "all" ]] ; then FILTER="" FILTER_OUT="" USE_COVERAGE=true -elif [[ $2 = "solo" ]]; then +elif [[ $TEST_FILTER_MODE = "solo" ]]; then FILTER="--filter \"#solo\"" FILTER_OUT="" # coverage on a file is not relevant when testing one or two functions @@ -38,7 +69,7 @@ else USE_COVERAGE=true fi -if [[ "$USE_COVERAGE" = true ]]; then +if [[ $USE_COVERAGE = true ]]; then PRE_TEST="rm -f luacov.stats.out luacov.report.out &&" POST_TEST="&& luacov $COVERAGE_OPTIONS && grep -C 3 -P \"(?:(?:^|[ *])\*0|\d+%)\" luacov.report.out" else @@ -46,8 +77,14 @@ else POST_TEST="" fi -LUA_PATH="src/?.lua;tests/?.lua" -CORE_TEST="busted tests --lpath=\"$LUA_PATH\" -p \"$TEST_FILE_PATTERN\" $FILTER $FILTER_OUT -c -v" +EXTRA_ARGS="" + +if [[ $RENDER = true ]]; then + EXTRA_ARGS+="--render" +fi + +LUA_PATH="src/?.lua;src/engine/tests/?.lua;src/game/tests/?.lua" +CORE_TEST="busted src/engine/tests src/game/tests --lpath=\"$LUA_PATH\" -p \"$TEST_FILE_PATTERN\" $FILTER $FILTER_OUT -c -v -- $EXTRA_ARGS" TEST_COMMAND="$PRE_TEST $CORE_TEST $POST_TEST" echo "Testing $1..." diff --git a/tests/utestgameapp.lua b/tests/utestgameapp.lua deleted file mode 100644 index cc762748..00000000 --- a/tests/utestgameapp.lua +++ /dev/null @@ -1,176 +0,0 @@ -require("bustedhelper") -local gameapp = require("game/application/gameapp") - -local flow = require("engine/application/flow") -local input = require("engine/input/input") -local titlemenu = require("game/menu/titlemenu_dummy") -local credits = require("game/menu/credits_dummy") -local stage = require("game/ingame/stage") -local ui = require("engine/ui/ui") -local gamestate_proxy = require("game/application/gamestate_proxy") - -describe('gameapp', function () - - describe('init', function () - - setup(function () - ui_set_cursor_sprite_data_stub = stub(ui, "set_cursor_sprite_data") - spy.on(gamestate_proxy, "require_gamestates") - end) - - teardown(function () - ui_set_cursor_sprite_data_stub:revert() - gamestate_proxy.require_gamestates:revert() - end) - - after_each(function () - gameapp.reinit_modules() - ui_set_cursor_sprite_data_stub:clear() - gamestate_proxy.require_gamestates:clear() - end) - - it('should assert if active_gamestates is nil (for non-pico8 build)', function () - assert.has_error(function () - gameapp.init() - end) - end) - - it('should set the ui cursor sprite data', function () - local visual = require("game/resources/visual") - gameapp.init({}) - assert.spy(ui_set_cursor_sprite_data_stub).was_called(1) - assert.spy(ui_set_cursor_sprite_data_stub).was_called_with(match.ref(ui), match.ref(visual.sprite_data_t.cursor)) - end) - - it('should require active gamestates via gamestate proxy', function () - gameapp.init({'stage'}) - - -- implementation - assert.spy(gamestate_proxy.require_gamestates).was_called(1) - assert.spy(gamestate_proxy.require_gamestates).was_called_with(match.ref(gamestate_proxy), {'stage'}) - end) - - it('should add all gamestates', function () - gameapp.init({'stage'}) -- needed because we require the stage at the top, not a dummy - - -- interface - assert.are_equal(titlemenu.state, flow.gamestates[titlemenu.state.type]) - assert.are_equal(credits.state, flow.gamestates[credits.state.type]) - assert.are_equal(stage.state, flow.gamestates[stage.state.type]) - end) - - it('should query titlemenu as initial state', function () - gameapp.init({}) - assert.are_equal(titlemenu.state, flow.next_state) - end) - - end) - - describe('renit_modules (#utest only)', function () - - setup(function () - ui_set_cursor_sprite_data_stub = stub(ui, "set_cursor_sprite_data") - gamestate_proxy_init = stub(gamestate_proxy, "init") - flow_init_stub = stub(flow, "init") - end) - - teardown(function () - ui_set_cursor_sprite_data_stub:revert() - gamestate_proxy_init:revert() - flow_init_stub:revert() - end) - - after_each(function () - ui_set_cursor_sprite_data_stub:clear() - gamestate_proxy_init:clear() - flow_init_stub:clear() - end) - - it('should reset the ui cursor sprite data', function () - gameapp.reinit_modules() - assert.spy(ui_set_cursor_sprite_data_stub).was_called(1) - assert.spy(ui_set_cursor_sprite_data_stub).was_called_with(match.ref(ui), nil) - end) - - it('should reinit gamestate_proxy', function () - gameapp.reinit_modules() - assert.spy(gamestate_proxy_init).was_called(1) - assert.spy(gamestate_proxy_init).was_called_with(match.ref(gamestate_proxy)) - end) - - it('should reinit flow', function () - gameapp.reinit_modules() - assert.spy(flow_init_stub).was_called(1) - assert.spy(flow_init_stub).was_called_with(match.ref(flow)) - end) - - end) - - describe('update', function () - - local process_players_inputs_stub - local flow_update_stub - - setup(function () - gameapp.init({}) - process_players_inputs_stub = stub(input, "process_players_inputs") - flow_update_stub = stub(flow, "update") - end) - - teardown(function () - process_players_inputs_stub:revert() - flow_update_stub:revert() - end) - - after_each(function () - process_players_inputs_stub:clear() - flow_update_stub:clear() - end) - - it('should update the input', function () - gameapp.update() - assert.spy(process_players_inputs_stub).was_called(1) - assert.spy(process_players_inputs_stub).was_called_with(match.ref(input)) - end) - - it('should update the flow', function () - gameapp.update() - assert.spy(flow_update_stub).was_called(1) - assert.spy(flow_update_stub).was_called_with(match.ref(flow)) - end) - - end) - - describe('draw', function () - - local cls_stub - local flow_render_stub - - setup(function () - gameapp.init({}) - gameapp.update() - cls_stub = stub(_G, "cls") - flow_render_stub = stub(flow, "render") - end) - - teardown(function () - cls_stub:revert() - flow_render_stub:revert() - end) - - after_each(function () - cls_stub:clear() - flow_render_stub:clear() - end) - - it('should clear screen and delegate rendering to flow', function () - gameapp.draw() - assert.spy(flow_render_stub).was_called(1) - assert.spy(flow_render_stub).was_called_with(match.ref(flow)) - assert.spy(cls_stub).was_called(1) - end) - - end) - - -end) diff --git a/tests/utestitest_dsl.lua b/tests/utestitest_dsl.lua deleted file mode 100644 index 4d73ae28..00000000 --- a/tests/utestitest_dsl.lua +++ /dev/null @@ -1,523 +0,0 @@ -require("bustedhelper") -require("math") -local itest_dsl = require("engine/test/itest_dsl") -local dsl_itest, command = itest_dsl.dsl_itest, itest_dsl.command -local integrationtest = require("engine/test/integrationtest") -local itest_manager, time_trigger, integration_test = integrationtest.itest_manager, integrationtest.time_trigger, integrationtest.integration_test -local flow = require("engine/application/flow") -local gameapp = require("game/application/gameapp") -local gamestate = require("game/application/gamestate") -local stage = require("game/ingame/stage") -local tilemap = require("engine/data/tilemap") -local player_char = require("game/ingame/playercharacter") -local pc_data = require("game/data/playercharacter_data") - - -describe('itest_dsl', function () - - setup(function () - -- spying should be enough, but we stub so it's easier to call these functions - -- without calling the symmetrical one (e.g. teardown may fail with nil reference - -- if setup is not called first) - stub(_G, "setup_map_data") - stub(_G, "teardown_map_data") - end) - - teardown(function () - setup_map_data:revert() - teardown_map_data:revert() - end) - - after_each(function () - itest_dsl:init() - flow:init() - stage.state:init() - pico8:clear_map() - setup_map_data:clear() - teardown_map_data:clear() - end) - - describe('command', function () - - describe('_init', function () - it('should create a new dsl itest', function () - local cmd = command(itest_dsl_command_types.move, {horizontal_dirs.left}) - assert.is_not_nil(cmd) - assert.are_same({itest_dsl_command_types.move, {horizontal_dirs.left}}, {cmd.type, cmd.args}) - end) - end) - - end) - - describe('dsl_itest', function () - - describe('_init', function () - it('should create a new dsl itest', function () - local dsli = dsl_itest() - assert.is_not_nil(dsli) - assert.are_same({nil, nil, nil}, {dsli.gamestate_type, dsli.stage_name, dsli.commands}) - end) - end) - - end) - - describe('register', function () - - setup(function () - -- mock parse - stub(itest_dsl, "parse", function (dsli_source) - return dsli_source.."_parsed" - end) - -- mock create_itest - stub(itest_dsl, "create_itest", function (name, dsli) - return name..": "..dsli.."_itest" - end) - end) - - teardown(function () - itest_dsl.parse:revert() - itest_dsl.create_itest:revert() - end) - - after_each(function () - itest_manager:init() - end) - - it('should parse, create and register an itest by name and source', function () - itest_dsl.register("my test", "dsl_source") - assert.are_equal(1, #itest_manager.itests) - assert.are_equal("my test: dsl_source_parsed_itest", itest_manager.itests[1]) - end) - - end) - - describe('parse', function () - - -- bugfix history: - -- + spot tilemap not being set, although parse_gamestate_definition worked, so the error is in the glue code - it('should parse the itest source written in domain-specific language into a dsl itest', function () - local dsli_source = [[@stage # -..## -##.. - -warp 12 45 -wait 1 -move left -wait 2 -expect pc_bottom_pos 10 45 -]] - local dsli = itest_dsl.parse(dsli_source) - - -- interface - assert.is_not_nil(dsli) - assert.are_same( - { - 'stage', - '#', - tilemap({ - { 0, 0, 64, 64}, - {64, 64, 0, 0} - }), - { - command(itest_dsl_command_types.warp, { vector(12, 45) } ), - command(itest_dsl_command_types.wait, { 1 } ), - command(itest_dsl_command_types.move, { horizontal_dirs.left } ), - command(itest_dsl_command_types.wait, { 2 } ), - command(itest_dsl_command_types.expect, {itest_dsl_gp_value_types.pc_bottom_pos, vector(10, 45)}), - } - }, - { - dsli.gamestate_type, - dsli.stage_name, - dsli.tilemap, - dsli.commands - }) - - -- implementation - -- todo: check call to parse_gamestate_definition and parse_action_sequence - -- to avoid test redundancy - end) - - end) - - - describe('parse_gamestate_definition', function () - - it('should return gamestate name, nil, nil and 3 for a non-stage gamestate and no extra line', function () - local dsli_lines = {"@titlemenu"} - local gamestate_type, stage_name, tm, next_line_index = itest_dsl.parse_gamestate_definition(dsli_lines) - assert.are_same( - { - 'titlemenu', - nil, - nil, - 3 - }, - { - gamestate_type, - stage_name, - tm, - next_line_index - }) - end) - - it('should return \'stage\', the stage name, nil and 4 for a pre-defined stage definition after 1 blank line', function () - local dsli_lines = { - "@stage test1", - "", - "", - "???" - } - local gamestate_type, stage_name, tm, next_line_index = itest_dsl.parse_gamestate_definition(dsli_lines) - assert.are_same( - { - 'stage', - "test1", - nil, - 3 - }, - { - gamestate_type, - stage_name, - tm, - next_line_index - }) - end) - - it('should return \'stage\', \'#\', tilemap data and 6 for a custom stage definition finishing at line 5 (including blank line)', function () - local dsli_lines = { - "@stage #", - "....", - "##..", - "..##", - "", - "???" - } - local gamestate_type, stage_name, tm, next_line_index = itest_dsl.parse_gamestate_definition(dsli_lines) - assert.are_same( - { - 'stage', - '#', - tilemap({ - { 0, 0, 0, 0}, - {64, 64, 0, 0}, - { 0, 0, 64, 64} - }), - 6 - }, - { - gamestate_type, - stage_name, - tm, - next_line_index - }) - end) - - end) - - -- bugfix history: - -- + removed "local" in "local width =" inside loop after applying trimming - -- to lines to support " \" multilines - describe('parse_tilemap', function () - - it('should return an empty tilemap data if the 2nd line is blank', function () - local tilemap_text = { - "@stage # (ignored)", - "", - ".... (ignored)", -- next line: 3 - ".... (ignored)" - } - local tm, next_line_index = itest_dsl.parse_tilemap(tilemap_text) - assert.are_same( - { - tilemap({}), - 3 - }, - {tm, next_line_index}) - end) - - it('should return a tilemap data with tiles corresponding to the tile symbols in the string', function () - local tilemap_text = { - "@stage # (ignored)", - "....", - "##..", - "..##", - "", - "(ignored)", -- next line: 6 - "(ignored)" - } - local tm, next_line_index = itest_dsl.parse_tilemap(tilemap_text) - assert.are_same( - { - tilemap({ - { 0, 0, 0, 0}, - {64, 64, 0, 0}, - { 0, 0, 64, 64} - }), - 6 - }, - {tm, next_line_index}) - end) - - it('should assert if there as fewer than 2 lines', function () - local tilemap_text = { - "?" - } - assert.has_error(function () - itest_dsl.parse_tilemap(tilemap_text) - end, "only 1 line(s), need at least 2") - end) - - it('should assert if there are too many blocks', function () - local tilemap_text = { - "@stage # (ignored)", - "... ..." - } - assert.has_error(function () - itest_dsl.parse_tilemap(tilemap_text) - end, "too many blocks: 2, expected 1") - end) - - it('should assert if line width is inconsistent', function () - local tilemap_text = { - "@stage # (ignored)", - "....", - "..." - } - assert.has_error(function () - itest_dsl.parse_tilemap(tilemap_text) - end, "inconsistent line length: 3 vs 4") - end) - - it('should assert if unknown tile symbol is found', function () - local tilemap_text = { - "@stage # (ignored)", - "?" - } - assert.has_error(function () - itest_dsl.parse_tilemap(tilemap_text) - end, "unknown tile symbol: ?") - end) - - end) - - describe('parse_action_sequence', function () - - it('should return ', function () - local dsli_lines = { - "???", - "???", - "???", - "", - "warp 12 45", - "wait 1", - "move left", - "wait 2", - "expect pc_bottom_pos 10 45" - } - local commands = itest_dsl.parse_action_sequence(dsli_lines, 5) - assert.are_same( - { - command(itest_dsl_command_types.warp, { vector(12, 45) } ), - command(itest_dsl_command_types.wait, { 1 } ), - command(itest_dsl_command_types.move, { horizontal_dirs.left } ), - command(itest_dsl_command_types.wait, { 2 } ), - command(itest_dsl_command_types.expect, {itest_dsl_gp_value_types.pc_bottom_pos, vector(10, 45)}), - }, - commands) - end) - - end) - - describe('create_itest', function () - - it('should create an itest with a name and a dsl itest', function () - local dsli = dsl_itest() - dsli.gamestate_type = 'stage' - dsli.stage_name = "test1" - dsli.tilemap = nil - dsli.commands = { - command(itest_dsl_command_types.warp, { vector(12, 45) } ), - command(itest_dsl_command_types.wait, { 10 } ), - command(itest_dsl_command_types.wait, { 1 } ), - command(itest_dsl_command_types.move, { horizontal_dirs.left } ), - command(itest_dsl_command_types.wait, { 2 } ), - command(itest_dsl_command_types.expect, {itest_dsl_gp_value_types.pc_bottom_pos, vector(10, 45)}), - } - - local test = itest_dsl.create_itest("test 1", dsli) - - -- interface - assert.is_not_nil(test) - assert.are_equal(4, #test.action_sequence) - assert.are_same({ - "test 1", - {'stage'}, - time_trigger(0, true), -- warp immediately - scripted_action(time_trigger(10, true), nil), -- empty action after 10 frames - time_trigger(1, true), -- start moving after 1 frame - scripted_action(time_trigger(2, true), nil) -- empty action after 2 frames - }, - { - test.name, - test.active_gamestates, - test.action_sequence[1].trigger, - test.action_sequence[2], - test.action_sequence[3].trigger, - test.action_sequence[4] - }) - - -- we could not directly test if generated functions are correct - -- they were generated from parameters passed dynamically, - -- so it's impossible to find the references back (except for dummy) - -- instead, we call the functions one by one and see if we get - -- the expected result - -- note that most actions depend on the previous one, so we exceptionally - -- assert multiple times in chain in a single utest - - -- simulate the itest runner behavior by initializing gameapp to inject active gamestates - gameapp.init(test.active_gamestates) - - -- verify setup callback behavior - test.setup() - assert.are_equal(gamestate.types.stage, flow.curr_state.type) - - -- verify warp callback behavior - test.action_sequence[1].callback() - assert.is_not_nil(stage.state.player_char) - assert.are_equal(vector(12, 45 - pc_data.center_height_standing), stage.state.player_char.position) - - -- verify move callback behavior - test.action_sequence[3].callback() - assert.are_equal(vector(-1, 0), stage.state.player_char.move_intention) - - -- we have not passed time so the character cannot have reached expected position - assert.is_false(test.final_assertion()) - - -- but if we cheat and warp him on the spot, final assertion will work - stage.state.player_char:set_bottom_center(vector(10, 45)) - assert.is_true(test.final_assertion()) - end) - - describe('(spying tilemap load)', function () - - setup(function () - spy.on(tilemap, "load") - end) - - teardown(function () - tilemap.load:revert() - end) - - it('setup should call setup_map_data and load on the tilemap if custom stage definition', function () - local dsli = dsl_itest() - dsli.gamestate_type = 'stage' - dsli.stage_name = "#" - dsli.tilemap = tilemap({}) - dsli.commands = {} - - local test = itest_dsl.create_itest("test 1", dsli) - - gameapp.init(test.active_gamestates) - test.setup() - - -- interface - assert.are_equal(control_modes.puppet, stage.state.player_char.control_mode) - - -- implementation - assert.spy(setup_map_data).was_called(1) - assert.spy(setup_map_data).was_called_with() - assert.spy(tilemap.load).was_called(1) - assert.spy(tilemap.load).was_called_with(match.ref(dsli.tilemap)) - end) - - it('teardown should call clear_map and teardown_map_data if custom stage definition', function () - local dsli = dsl_itest() - dsli.gamestate_type = 'stage' - dsli.stage_name = "#" - dsli.tilemap = tilemap({}) - dsli.commands = {} - - local test = itest_dsl.create_itest("test 1", dsli) - - gameapp.init(test.active_gamestates) - test.teardown() - - -- implementation - assert.spy(teardown_map_data).was_called(1) - assert.spy(teardown_map_data).was_called_with() - assert.spy(tilemap.load).was_called(1) - assert.spy(tilemap.load).was_called_with(match.ref(dsli.tilemap)) - end) - - end) - - end) - - describe('_evaluate', function () - - it('should assert if an unknown gameplay value type is passed', function () - assert.has_error(function () - itest_dsl._evaluate(-1) - end, "unknown gameplay value: -1") - end) - - end) - - describe('_final_assert', function () - - setup(function () - -- mock _evaluate (we won't care about the 1st argument thx to this) - stub(itest_dsl, "_evaluate", function (gameplay_value_type) - return 27 - end) - end) - - teardown(function () - itest_dsl._evaluate:revert() - end) - - it('should set the final assertion as returning true, message when the gameplay value is expected', function () - itest_dsl._itest = integration_test('test', {}) - itest_dsl:_final_assert(itest_dsl_gp_value_types.pc_bottom_pos, 27) - local message = "Passed gameplay value 'player character bottom position':\n27\nExpected:\n27" - assert.are_same({true, message}, {itest_dsl._itest.final_assertion()}) - end) - - it('should set the final assertion as returning false, message when the gameplay value is not expected', function () - itest_dsl._itest = integration_test('test', {}) - itest_dsl:_final_assert(itest_dsl_gp_value_types.pc_bottom_pos, 28) - local message = "Passed gameplay value 'player character bottom position':\n27\nExpected:\n28" - assert.are_same({false, message}, {itest_dsl._itest.final_assertion()}) - end) - - it('should assert when the passed gameplay value type is invalid', function () - itest_dsl._itest = integration_test('test', {}) - assert.has_error(function () - itest_dsl:_final_assert(-1, 20) - end) - end) - - end) - - describe('_evaluate', function () - - -- add gameplay value types tests here - - it('should return the player character bottom position for ', function () - -- simulate stage state on_enter by just creating pc - stage.state.player_char = player_char() - stage.state.player_char:spawn_bottom_at(vector(2, 8)) - - assert.are_equal(vector(2, 8), itest_dsl._evaluate(itest_dsl_gp_value_types.pc_bottom_pos)) - end) - - it('should assert if an unknown gameplay value type is passed', function () - assert.has_error(function () - itest_dsl._evaluate(-1) - end, "unknown gameplay value: -1") - end) - - end) - -end) diff --git a/tests/utesttile_data.lua b/tests/utesttile_data.lua deleted file mode 100644 index 456b1399..00000000 --- a/tests/utesttile_data.lua +++ /dev/null @@ -1,21 +0,0 @@ -require("bustedhelper") -require("game/data/tile_data") - -describe('tiledata', function () - - describe('setup_map_data', function () - pending('should call setup on tile_test_data (busted only)', function () - end) - end) - - describe('teardown_map_data', function () - pending('should call teardown on tile_test_data (busted only)', function () - end) - end) - - describe('clear_map', function () - pending('should call clear_map from pico8api (busted only)', function () - end) - end) - -end)