diff --git a/.gitmodules b/.gitmodules index 04f46fbb..07ac0c8e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "pico-boots"] path = pico-boots - url = ../pico-boots.git + url = https://github.com/hsandt/pico-boots.git diff --git a/.travis.yml b/.travis.yml index cc7b709e..de12732f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,6 @@ +# Do not trigger Travis if commit message contains "(WIP)" or "#no_travis" (case-insensitive) +if: commit_message !~ /(\(WIP\)|#no_travis)/ + os: linux language: python # Can use any language here, but if it's not 'python' @@ -52,20 +55,27 @@ before_script: - ln -s "$(pwd)/tool/picotool-master/p8tool" "$HOME/.local/bin/p8tool" script: + # build game and itest to make sure everything works fine + # (even if build fails, tests will be run independently thanks to busted) + # disabled build_game.sh debug because character count may get over 65536 + # easily when working at the limit, and build release is what really counts + # (although it's bad sign for us if we cannot debug the game) + # - ./build_game.sh debug + - ./build_game.sh release + # disabled build_itest.sh in Travis until strings are compressed + # because character count is over 65536 and that systematically failed the build + # while build release works fine + # - ./build_itest.sh # test (including rendered headless itests thanks to ENABLE_RENDER=1) - ./test.sh -m all # coverage - bash <(curl -s https://codecov.io/bash) - # build game and itest to make sure everything works fine - - ./build_game.sh debug - - ./build_game.sh release - - ./build_itest.sh deploy: provider: releases api_key: secure: bfXQQ0AXGHgXiq0xOxhYQ2AXX/flQnxJh/+eA/HUGfwdoPDq0QTdqFA/3jEMWkJSsFKEBVKDjJGCt24QPxUIjTu91r1wyCNdL2KlNfnogRjWAVutRZxB/OC2HWR3kJtPjkFQBCsOXHBxGI3hMJL7LWr5WfNsSGMbcRMfvphxFT3ER8XBHAUEJY6roITm6noHroqQt8Uye+0+rkGqJ8QslKRqq8qBZMZeOiOrh6SBdlhsGw0KqNno/dMXQxx2ZCrh/VUeWjNvxzXe/mZjfBPbhvyecN7jz+FytEdAhdt1Dy37hhyOAkDfxLGGsH1YAAfinH8uFwoSRo0MH8fuhdXpT7jUXuAgP9/RS0FEiZDdX+J/FdncCbnoDfE9B4Dt3L3srISeiNwxKK5sx2kzyWvftK30pV1+zEgnbVEKGPIIeGb5wYWSCmzHf+CfLMk+bzeznTrpo/irY/vjoRBefNaVWXqLygrNWxM1uIMJae+OA3MYeUSYd1lpCyRw98i3GC7si68M9OaDeLoDjnqOLqvhurB/RmLzCU7mCYipn2kxykAOdevWN73cyx9VhdFy2GPE5VDw6EO6ZQP04KaeYxP2pgR4ts2kYWpVvf1PGg+2yN4QMkVhrWV+6dG2jtUO0BrCqt5Tpw0I3C3aFmBjjzFBBuKsZpr2yUG3roxnu1Dhww0= - file: build/picosonic_v3.0_release.p8 + file: build/picosonic_v3.2_release.p8 on: tags: 'true' skip_cleanup: 'true' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ce87eda5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,108 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [3.2] - 2020-08-25 +### Added +- Loop quadrant system (walk at any angle, jump orthogonally) + +## [3.1] - 2020-08-11 +### Added +- Angel Island tiles +- Serialization module (represent data as string) + +### Changed +- Original feature: Reduced Deceleration on Descending Slope +- Original feature: No Friction on Steep Descending Slope +- Original feature: Progressive Ascending Slope Factor +- Set minimum playback speed for Running animation +- Reduced tokens heavily by extracting code in modules and using data serialization + +## [3.0] - 2020-07-11 +### Added +- Game: split airborne state into falling and air_spin to only play Spin animation on jump +- Game: when going airborne/grounded, adapt height and center position so Sonic hits the ceiling when we expect by looking at the sprite +- Test: convert itests to new DSL system + +### Changed +- Project: extracted engine as submodule pico-boots, adapted build pipeline +- Game: set pink as transparent color for Sonic sprites to match new… +- Game: preserve last animation (including playback speed) when falling +- Game: only allow ceiling detection during descending motion if abs(vel.x) > abs(vel.y) +- Test: improved itests + +## [2.3-sprite-anim] - 2019-05-16 +### Added +- Engine: sprite animation system +- Game: press X to toggle debug motion +- Game: added air motion block with wall, ceiling and landing +- Game: character running animation +- Game: character can run on slopes +- Test: added itest for running up a slope +- Test: convert itests to new DSL system + +### Changed +- Project: split engine and game folders properly +- Engine: misc logging fixes +- Game: clamp ground speed on acceleration +- Game: fixed sticky jump input during jump +- Game: fixed air motion pixel flooring system + +## [2.2] - 2018-10-31 +### Added +- Game: added Air Spin sprite used when airborne +- Test: completed 100% coverage on player character + +### Changed +- Game: fixed ground speed accumulating during wall block +- Game: character is blocked by ceiling and diagonal tiles +- Game: cleaner wall block by cutting subpixels + +## [2.1] - 2018-10-27 +### Added +- Game: character snaps to lower ground when walking +- Game: character falls when walking off a cliff +- Game: character can jump with air control (X axis motion) +- Game: apply artificial gravity after jump interrupt, allow hop on 1-frame jumps + +## [2.0-flat-ground] - 2018-09-09 +### Added +- Game: character can walk on flat ground +- Test: added support for #solo for headless itests + +## [2.0-land] - 2018-09-08 +### Added +- Game: blue sky background +- Game: added gravity, character can fall and land on ground +- Test: integration (simulation) test system aka "itests" + +### Changed +- Engine: improved logging, classes, math + +## [1.0] - 2018-07-06 +### Added +- Engine: application: constants, flow with gamestates +- Engine: core modules: class, coroutine, helper, math +- Engine: debug tools: codetuner, logging, profiler using WTK +- Engine: input: mouse input and button IDs +- Engine: render: color constants and sprite render +- Engine: UI: render mouse and overlay system +- Engine: build: basic build pipeline with data.p8 and post-build replace strings script +- Game: gamestates title menu, in-game, credits (empty) +- Game: menu: simple titlemenu to start the debug stage +- Game: in-game: stage shows title on start, plays BGM, has goal +- Game: in-game: debug character flies X/Y on directional input, go back to title menu on reach goal +- Test: all busted unit tests in separator folder tests + +[Unreleased]: https://github.com/hsandt/sonic-pico8/compare/v3.0...HEAD +[3.1]: https://github.com/hsandt/sonic-pico8/compare/v3.0-sprite-anim...v3.1 +[3.0]: https://github.com/hsandt/sonic-pico8/compare/v2.3-sprite-anim...v3.0 +[2.3-sprite-anim]: https://github.com/hsandt/sonic-pico8/compare/v2.2...v2.3-sprite-anim +[2.2]: https://github.com/hsandt/sonic-pico8/compare/v2.1...v2.2 +[2.1]: https://github.com/hsandt/sonic-pico8/compare/v2.0-flat-ground...v2.1 +[2.0-flat-ground]: https://github.com/hsandt/sonic-pico8/compare/v2.0-land...v2.0-flat-ground +[2.0-land]: https://github.com/hsandt/sonic-pico8/compare/v1.0-framework...v2.0-land +[1.0-framework]: https://github.com/hsandt/sonic-pico8/releases/tag/v1.0-framework diff --git a/README.md b/README.md index 24a8b4ec..31075745 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,13 @@ This is a fan game distributed for free and is not endorsed by Sega Games Co., L It is currently under development. +## Compatibility + +Works with PICO-8 0.2.0i and 0.2.1b. + ## Features -Version: 3.0 +Version: 3.2 ### Physics diff --git a/analysis/analysis_tokens_engine+game.py b/analysis/analysis_tokens_engine+game.py new file mode 100644 index 00000000..30c8e131 --- /dev/null +++ b/analysis/analysis_tokens_engine+game.py @@ -0,0 +1,515 @@ +Analyzing lua scripts in intermediate/release/pico-boots... + +common.lua (common.p8) +by analyze_script +- version: 16 +- lines: 20 +- chars: 779 +- tokens: 21 +- compressed chars: 489 + +animated_sprite_data.lua (animated_sprite_data.p8) +by analyze_script +- version: 16 +- lines: 40 +- chars: 1526 +- tokens: 97 +- compressed chars: 687 + +sprite_data.lua (sprite_data.p8) +by analyze_script +- version: 16 +- lines: 61 +- chars: 1885 +- tokens: 184 +- compressed chars: 808 + +sprite.lua (sprite.p8) +by analyze_script +- version: 16 +- lines: 95 +- chars: 4922 +- tokens: 246 +- compressed chars: 2282 + +animated_sprite.lua (animated_sprite.p8) +by analyze_script +- version: 16 +- lines: 124 +- chars: 6732 +- tokens: 275 +- compressed chars: 2476 + +color.lua (color.p8) +by analyze_script +- version: 16 +- lines: 33 +- chars: 567 +- tokens: 82 +- compressed chars: 363 + +input.lua (input.p8) +by analyze_script +- version: 16 +- lines: 189 +- chars: 6144 +- tokens: 398 +- compressed chars: 1730 + +constants.lua (constants.p8) +by analyze_script +- version: 16 +- lines: 20 +- chars: 370 +- tokens: 27 +- compressed chars: 259 + +gameapp.lua (gameapp.p8) +by analyze_script +- version: 16 +- lines: 192 +- chars: 6748 +- tokens: 303 +- compressed chars: 2572 + +coroutine_runner.lua (coroutine_runner.p8) +by analyze_script +- version: 16 +- lines: 52 +- chars: 2085 +- tokens: 117 +- compressed chars: 986 + +gamestate.lua (gamestate.p8) +by analyze_script +- version: 16 +- lines: 28 +- chars: 870 +- tokens: 38 +- compressed chars: 479 + +manager.lua (manager.p8) +by analyze_script +- version: 16 +- lines: 24 +- chars: 1027 +- tokens: 39 +- compressed chars: 538 + +flow.lua (flow.p8) +by analyze_script +- version: 16 +- lines: 102 +- chars: 2933 +- tokens: 122 +- compressed chars: 1287 + +assertions.lua (assertions.p8) +by analyze_script +- version: 16 +- lines: 34 +- chars: 1736 +- tokens: 147 +- compressed chars: 656 + +unittest_helper.lua (unittest_helper.p8) +by analyze_script +- version: 16 +- lines: 16 +- chars: 826 +- tokens: 69 +- compressed chars: 446 + +unittest.lua (unittest.p8) +by analyze_script +- version: 16 +- lines: 61 +- chars: 2055 +- tokens: 91 +- compressed chars: 1137 + +integrationtest.lua (integrationtest.p8) +by analyze_script +- version: 16 +- lines: 469 +- chars: 16810 +- tokens: 933 +- compressed chars: 5966 + +label.lua (label.p8) +by analyze_script +- version: 16 +- lines: 20 +- chars: 542 +- tokens: 47 +- compressed chars: 310 + +ui.lua (ui.p8) +by analyze_script +- version: 16 +- lines: 191 +- chars: 6363 +- tokens: 656 +- compressed chars: 1996 + +overlay.lua (overlay.p8) +by analyze_script +- version: 16 +- lines: 58 +- chars: 1491 +- tokens: 131 +- compressed chars: 745 + +api.lua (api.p8) +by analyze_script +- version: 16 +- lines: 6 +- chars: 178 +- tokens: 6 +- compressed chars: 155 + +serialization.lua (serialization.p8) +by analyze_script +- version: 16 +- lines: 276 +- chars: 13385 +- tokens: 587 +- compressed chars: 4900 + +tilemap.lua (tilemap.p8) +by analyze_script +- version: 16 +- lines: 28 +- chars: 608 +- tokens: 70 +- compressed chars: 382 + +direction_ext.lua (direction_ext.p8) +by analyze_script +- version: 16 +- lines: 29 +- chars: 598 +- tokens: 72 +- compressed chars: 209 + +string.lua (string.p8) +by analyze_script +- version: 16 +- lines: 181 +- chars: 6030 +- tokens: 412 +- compressed chars: 2845 + +class.lua (class.p8) +by analyze_script +- version: 16 +- lines: 178 +- chars: 7470 +- tokens: 327 +- compressed chars: 3107 + +random.lua (random.p8) +by analyze_script +- version: 16 +- lines: 18 +- chars: 479 +- tokens: 36 +- compressed chars: 287 + +datastruct.lua (datastruct.p8) +by analyze_script +- version: 16 +- lines: 97 +- chars: 3239 +- tokens: 283 +- compressed chars: 1422 + +coroutine.lua (coroutine.p8) +by analyze_script +- version: 16 +- lines: 11 +- chars: 355 +- tokens: 20 +- compressed chars: 242 + +vector_ext.lua (vector_ext.p8) +by analyze_script +- version: 16 +- lines: 107 +- chars: 3435 +- tokens: 297 +- compressed chars: 1198 + +helper.lua (helper.p8) +by analyze_script +- version: 16 +- lines: 154 +- chars: 5069 +- tokens: 303 +- compressed chars: 2220 + +math.lua (math.p8) +by analyze_script +- version: 16 +- lines: 194 +- chars: 5100 +- tokens: 435 +- compressed chars: 2076 + +dump.lua (dump.p8) +by analyze_script +- version: 16 +- lines: 3 +- chars: 34 +- tokens: 0 +- compressed chars: 35 + +debug_window.lua (debug_window.p8) +by analyze_script +- version: 16 +- lines: 36 +- chars: 742 +- tokens: 91 +- compressed chars: 393 + +codetuner.lua (codetuner.p8) +by analyze_script +- version: 16 +- lines: 16 +- chars: 247 +- tokens: 13 +- compressed chars: 182 + +visual_logger.lua (visual_logger.p8) +by analyze_script +- version: 16 +- lines: 11 +- chars: 297 +- tokens: 6 +- compressed chars: 232 + +profiler.lua (profiler.p8) +by analyze_script +- version: 16 +- lines: 11 +- chars: 285 +- tokens: 6 +- compressed chars: 220 + +logging.lua (logging.p8) +by analyze_script +- version: 16 +- lines: 11 +- chars: 271 +- tokens: 6 +- compressed chars: 221 + +collision.lua (collision.p8) +by analyze_script +- version: 16 +- lines: 142 +- chars: 6933 +- tokens: 359 +- compressed chars: 2512 + +pico8wtk.lua (pico8wtk.p8) +by analyze_script +- version: 16 +- lines: 761 +- chars: 12907 +- tokens: 2877 +- compressed chars: 4315 + +Analyzing lua scripts in intermediate/release/src... + +itest_main.lua (itest_main.p8) +by analyze_script +- version: 16 +- lines: 70 +- chars: 2031 +- tokens: 128 +- compressed chars: 936 + +main.lua (main.p8) +by analyze_script +- version: 16 +- lines: 35 +- chars: 618 +- tokens: 42 +- compressed chars: 417 + +sandbox.lua (sandbox.p8) +by analyze_script +- version: 16 +- lines: 23 +- chars: 426 +- tokens: 31 +- compressed chars: 340 + +utest_main.lua (utest_main.p8) +by analyze_script +- version: 16 +- lines: 20 +- chars: 692 +- tokens: 26 +- compressed chars: 443 + +picosonic_app.lua (picosonic_app.p8) +by analyze_script +- version: 16 +- lines: 44 +- chars: 790 +- tokens: 73 +- compressed chars: 389 + +playercharacter.lua (playercharacter.p8) +by analyze_script +- version: 16 +- lines: 1401 +- chars: 73541 +- tokens: 2805 +- compressed chars: 27471 + +stage_state.lua (stage_state.p8) +by analyze_script +- version: 16 +- lines: 213 +- chars: 5604 +- tokens: 495 +- compressed chars: 2024 + +itesttitlemenu.lua (itesttitlemenu.p8) +by analyze_script +- version: 16 +- lines: 50 +- chars: 1453 +- tokens: 128 +- compressed chars: 677 + +itestplayercharacter.lua (itestplayercharacter.p8) +by analyze_script +- version: 16 +- lines: 350 +- chars: 19759 +- tokens: 182 +- compressed chars: 7122 + +visual.lua (visual.p8) +by analyze_script +- version: 16 +- lines: 12 +- chars: 191 +- tokens: 17 +- compressed chars: 122 + +audio.lua (audio.p8) +by analyze_script +- version: 16 +- lines: 16 +- chars: 223 +- tokens: 25 +- compressed chars: 135 + +world.lua (world.p8) +by analyze_script +- version: 16 +- lines: 240 +- chars: 10206 +- tokens: 488 +- compressed chars: 3839 + +motion.lua (motion.p8) +by analyze_script +- version: 16 +- lines: 72 +- chars: 3050 +- tokens: 136 +- compressed chars: 1155 + +credits.lua (credits.p8) +by analyze_script +- version: 16 +- lines: 27 +- chars: 434 +- tokens: 39 +- compressed chars: 257 + +titlemenu.lua (titlemenu.p8) +by analyze_script +- version: 16 +- lines: 66 +- chars: 1601 +- tokens: 184 +- compressed chars: 666 + +raw_tile_collision_data.lua (raw_tile_collision_data.p8) +by analyze_script +- version: 16 +- lines: 21 +- chars: 1018 +- tokens: 21 +- compressed chars: 624 + +collision_data.lua (collision_data.p8) +by analyze_script +- version: 16 +- lines: 47 +- chars: 6347 +- tokens: 92 +- compressed chars: 2938 + +tile_collision_data.lua (tile_collision_data.p8) +by analyze_script +- version: 16 +- lines: 225 +- chars: 10658 +- tokens: 525 +- compressed chars: 3732 + +playercharacter_data.lua (playercharacter_data.p8) +by analyze_script +- version: 16 +- lines: 148 +- chars: 7247 +- tokens: 215 +- compressed chars: 3030 + +stage_data.lua (stage_data.p8) +by analyze_script +- version: 16 +- lines: 42 +- chars: 865 +- tokens: 60 +- compressed chars: 542 + +tile_test_data.lua (tile_test_data.p8) +by analyze_script +- version: 16 +- lines: 11 +- chars: 313 +- tokens: 6 +- compressed chars: 228 + +tile_representation.lua (tile_representation.p8) +by analyze_script +- version: 16 +- lines: 32 +- chars: 1223 +- tokens: 78 +- compressed chars: 664 + +itest_dsl.lua (itest_dsl.p8) +by analyze_script +- version: 16 +- lines: 668 +- chars: 25360 +- tokens: 1640 +- compressed chars: 8913 + +utestdata.lua (utestdata.p8) +by analyze_script +- version: 16 +- lines: 39 +- chars: 1718 +- tokens: 112 +- compressed chars: 825 diff --git a/build_game.sh b/build_game.sh index 6a4a911b..86256167 100755 --- a/build_game.sh +++ b/build_game.sh @@ -13,7 +13,7 @@ build_output_path="$(dirname "$0")/build" author="hsandt" title="pico-sonic" cartridge_stem="picosonic" -version="3.0" +version="3.2" help() { echo "Build a PICO-8 cartridge with the passed config." @@ -70,9 +70,9 @@ fi symbols='' if [[ $config == 'debug' ]]; then - symbols='assert,deprecated,log,visual_logger,tuner,profiler,mouse,cheat,sandbox' + # symbols='assert,deprecated,log,visual_logger,tuner,profiler,mouse,cheat,sandbox' # lighter config (to remain under 65536 chars) - # symbols='assert,deprecated,log,cheat,sandbox' + symbols='assert,deprecated,log,cheat,sandbox' elif [[ $config == 'debug-ultrafast' ]]; then symbols='assert,deprecated,log,cheat,sandbox,ultrafast' elif [[ $config == 'cheat' ]]; then diff --git a/build_itest.sh b/build_itest.sh index 4cb2a8bb..3373212b 100755 --- a/build_itest.sh +++ b/build_itest.sh @@ -16,12 +16,11 @@ build_output_path="$(dirname "$0")/build" author="hsandt" title="pico-sonic itests (all)" cartridge_stem="picosonic_itest_all" -version="3.0" -config='debug' +version="3.2" +config='itest' # symbols='assert,log,visual_logger,tuner,profiler,mouse,itest' -# for now, we don't set extra symbols like cheat to make it lighter, but it's still possible -# to test cheats in headless itests as busted preserves all (non-#pico8) code -symbols='assert,log,itest' +# cheat needed to set debug motion mode +symbols='assert,log,itest,cheat' # Build from itest main for all itests "$picoboots_scripts_path/build_cartridge.sh" \ diff --git a/build_pico8_utests.sh b/build_pico8_utests.sh old mode 100644 new mode 100755 index 072cf581..88ea1478 --- a/build_pico8_utests.sh +++ b/build_pico8_utests.sh @@ -16,14 +16,15 @@ build_output_path="$(dirname "$0")/build" author="hsandt" title="pico-sonic pico8 utests (all)" cartridge_stem="picosonic_pico8_utests_all" -version="3.0" +version="3.2" config='debug' -symbols='assert,log' +symbols='assert,log,p8utest' # Build from itest main for all pico8 utests # Note that a pico8 utest_main build is much smaller than a normal build, # so minification is not required in general; however it is useful to spot # issues in the real build like unprotected sprite animation keys being minified +# So just set minify-level to 0-2 depending on your needs "$picoboots_scripts_path/build_cartridge.sh" \ "$game_src_path" utest_main.lua utests \ -d "$data_path/data.p8" -M "$data_path/metadata.p8" \ @@ -32,4 +33,4 @@ symbols='assert,log' -o "${cartridge_stem}_v${version}" \ -c "$config" \ -s "$symbols" \ - --minify-level 2 + --minify-level 1 diff --git a/data/data.p8 b/data/data.p8 index ec094ee3..a23e0def 100644 --- a/data/data.p8 +++ b/data/data.p8 @@ -1,72 +1,72 @@ pico-8 cartridge // http://www.pico-8.com -version 27 +version 29 __lua__ __gfx__ -eeeeeeeee5eeeeeeeeeecccccceeceee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eeeeeeee575eeeeeeeeceeccccccceee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -ee7ee7ee5775eeeeeeeeccffccccceee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eee77eee57775eeeeeecccfcc7ccceee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eee77eee577775eeeecccccc770cceee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -ee7ee7ee57755eeeeeceeecc770ceeee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eeeeeeee575eeeeeeeeeccccf77f0eee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eeeeeeeee5eeeeeeeee77ffcfffeeeee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -8888888888888888ee7777ccfeee77ee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -8888888888888888eee77ecccfcf77ee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -8888888888888888eeeeeecccc7ceeee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -8888888888888888eeeee7cee788eeee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -8888888888888888eee0877ee8878eee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -8888888888888888eee0888eee788eee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -8888888888888888eeee8778eee08eee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -8888888888888888eeee0888eeeeeeee888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -99994444eeeeeee49eeeeeeeeeeeeeee99eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -99994444eeeeee4499eeeeeeeeeeeeee999ee4e4eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -99994444eeeee444999eeeeeeeeeeeee99994444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -99994444eeee44449999eeeeeeeeee4499994444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -44449999eee4999944449eeeeeee999944449999eeeeeeee44449999eeee9999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -44449999ee449999444499eeee44999944449999eeeeeeee44449999eeee9999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -44449999e44499994444999e4444999944449999eeeeeeee44449999eeee999944449999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -4444999944449999444499994444999944449999eeeeeeee44449999eeee999944449999eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee -77777777000000077000000000000000770000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -77777777000000777700000000000000777007070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -77777777000007777770000000000000777777770000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -77777777000077777777000000000077777777770000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -77777777000777777777700000007777777777770000000077777777000077770000000000000000000000000000000000000000000000000000000000000000 -77777777007777777777770000777777777777770000000077777777000077770000000000000000000000000000000000000000000000000000000000000000 -77777777077777777777777077777777777777770000000077777777000077777777777700000000000000000000000000000000000000000000000000000000 -77777777777777777777777777777777777777770000000077777777000077777777777700000000000000000000000000000000000000000000000000000000 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 +eeeeeeeee5eeeeeeeeeecccccceeceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee4444444444444440044444444444444477777777777777777777777777777777 +eeeeeeee575eeeeeeeeceeccccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee449449444444440ee00444444494494477777777777777000077777777777777 +ee7ee7ee5775eeeeeeeeccffccccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee4499444444000eeeeee004444444994477777777777700000000777777777777 +eee77eee57775eeeeeecccfcc7ccceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee4449444440eeeeeeeeeeee004444944477777777770000000000007777777777 +eee77eee577775eeeecccccc770cceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee44444440eeeeeeeeeeeeeeee0444444477777777000000000000000077777777 +ee7ee7ee57755eeeeeceeecc770ceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee4494440eeeeeeeeeeeeeeeeee444494477777770000000000000000007777777 +eeeeeeee575eeeeeeeeeccccf77f0eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee444440eeeeeeeeeeeeeeeeeeee04444477777700000000000000000000777777 +eeeeeeeee5eeeeeeeee77ffcfffeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee44444eeeeeeeeeeeeeeeeeeeeee0444477777000000000000000000000077777 +8888888888888888ee7777ccfeee77eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee4440eeeeeeeeeeeeeeeeeeeeeeee044477770000000000000000000000007777 +8888888888888888eee77ecccfcf77eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee9440eeeeeeeeeeeeeeeeeeeeeeee044977770000000000000000000000007777 +8888888888888888eeeeeecccc7ceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee944eeeeeeeeeeeeeeeeeeeeeeeeee44977700000000000000000000000000777 +8888888888888888eeeee7cee788eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee440eeeeeeeeeeeeeeeeeeeeeeeeee04477700000000000000000000000000777 +8888888888888888eee0877ee8878eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee44eeeeeeeeeeeeeeeeeeeeeeeeeeee4477000000000000000000000000000077 +8888888888888888eee0888eee788eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee40eeeeeeeeeeeeeeeeeeeeeeeeeeee0477000000000000000000000000000077 +8888888888888888eeee8778eee08eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee4eeeeeeeeeeeeeeeeeeeeeeeeeeeeee470000000000000000000000000000007 +8888888888888888eeee0888eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee4eeeeeeeeeeeeeeeeeeeeeeeeeeeeee470000000000000000000000000000007 +7777777700000000eeeeeeeeeeeeeeeeeeeeeeee0000000000000000eeeeeeee4eeeeeeeeeeeeeeeeeeeeeeeeeeeeee470000000000000000000000000000007 +7777777700007777eeeeeeeeeeeeeeeeeeeeeeee7777000000000000eeeeeeee4eeeeeeeeeeeeeeeeeeeeeeeeeeeeee470000000000000000000000000000007 +7777777777777777eeeeeeeeeeeeeeeeeeeeeeee7777777777777777eeeeeeee40eeeeeeeeeeeeeeeeeeeeeeeeeeee0477000000000000000000000000000077 +7777777777777777eeeeeeeeeeeeeeeeeeeeeeee7777777777777777eeeeeeee44eeeeeeeeeeeeeeeeeeeeeeeeeeee4477000000000000000000000000000077 +7777777777777777eeeeeeeeeeeeeeeeeeeeeeee7777777777777777eeeeeeee440eeeeeeeeeeeeeeeeeeeeeeeeee04477700000000000000000000000000777 +7777777777777777eeeeeeeeeeebeeeeeeeee9ee7777777777777777eeeeeeee944eeeeeeeeeeeeeeeeeeeeeeeeee44977700000000000000000000000000777 +7777777777777777eeeeeeeeebeb9e9eee9e99ee7777777777777777eeeeeeee9440eeeeeeeeeeeeeeeeeeeeeeee044977770000000000000000000000007777 +7777777777777777e9eeee9ebb39b9b9e9be9beb7777777777777777eeeeeeee4440eeeeeeeeeeeeeeeeeeeeeeee044477770000000000000000000000007777 +eeeeeeeeeee9ee9eb9bb9b9bb9b9b9b9b9b39b9be9ee9eeeeeeee9eeeeeeeeee44440eeeeeeeeeeeeeeeeeeeeee0444477777000000000000000000000077777 +eeeeeeee9ee9bb9bb9bb9bbbb9bbb9bbb9bbbb9bb9bb9ee9ee9ee9e9eeeeeeee444440eeeeeeeeeeeeeeeeeeee44444477777700000000000000000000777777 +eeeeeeeb9bbbbb9bb9bbbbbbbbbbbbbbbbbbbbbbb9bbbbb9bb9bb9b9beeeeeee4494444eeeeeeeeeeeeeeeeee044494477777770000000000000000007777777 +eeeeee9bbbb93bbbbbb39b3bbbbbbbbbbbbbbbbbbbb39bbbbbbbb9bbb9eeeeee44444440eeeeeeeeeeeeeeee0444444477777777000000000000000077777777 +eeeeee9bbbbb03bbbb30b30bbbbb0bbbbbb3bbbbbb30bbbbbbbbbbbbb9eeeeee4449444440eeeeeeeeeeee044444944477777777770000000000007777777777 +eeeeeeb3b3bb03b30b30b00bb03b03bbbbb030b0bb30bb3bbbbb3bbb3beeeeee449944444440beeeeeeb04444444994477777777777700000000777777777777 +eeeeeebeb0b3003003003003000b00b033b00030b3003b0bb3b30b33ebeeeeee44944944444bb4beeb4bb4444494494477777777777777000077777777777777 +eeeeeebe00000000000400400403003003004000b000000003000303ebeeeeee44444444444bbbbbbbbbb4444444444477777777777777777777777777777777 +0000000004440444000440404440440044404440eeeeeeeeeeeeeeeeeeeeeeee44444444444b3bbbbbb3b44444444444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +0000000004444444404444444444444044444440eeeeeeeeeeeeeeeeeeeeeeee4444444444430bbbbbb0344444444444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +0000000004444444444444444444444444444440eeeeeeeeeeeeeeeeeeeeeeee4444444494404b3bb3b4044944444444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +0000000004444494444449499444444449444440eeeeeeeeeeeeeeeeeeeeeeee444494444444430bb034444444494444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +0000777744444499444444499494444499444444eeeeeeeeeeeeeeeeeeeeeeee44444449944440433404444994444444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +0000777704444499444494999944444499444440eeeeeeeeeeeeeeeeeeeeeeee44444499994444400444449999444444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +0000777744444494444494994949444449444444eeeeeeeeeeeeeeeeeeeeeeee44449494994944444444949949494444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +0000777704444494444494494949444449444440eeeeeeeeeeeeeeeeeeeeeeee44449494944944444444944949494444eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +0000000004444444444444499949444444444440eeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000eeeeeeee +0000000004444494444449499444444449444440eeeeeeeeeeeeeeee0000000000000000000000000000777777770000000000000000000000000000eeeeeeee +0000000004444444444449499444444444444440eeeeeeeeeeeeeeee0000000000000000000000007777777777777777000000000000000000000000eeeeeeee +0000000004444494444444499949444449444440eeeeeeeeeeeeeeee0000000000000000000077777777777777777777777700000000000000000000eeeeeeee +7777777744444499444444499944444499444444eeeeeeeeeeeeeeee0000000000007777777777777777777777777777777777777777000000000000eeeeeeee +7777777704444499444494949994444499444440eeeeeeeeeeeeeeee0000000077777777777777777777777777777777777777777777777700000000eeeeeeee +7777777744444494444494949949444449444444eeeeeeeeeeeeeeee0000777777777777777777777777777777777777777777777777777777770000eeeeeeee +7777777704444494444494999949444449444440eeeeeeeeeeeeeeee7777777777777777777777777777777777777777777777777777777777777777eeeeeeee +0000000004444494444444999949444449444440eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee9ebeebe9eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +0000000004444494444494999944444449444440eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee9eeeb9e9bbbbbb9e9beee9eeeeeeeeeeeeeeeeeeeeeeeeeeeee +0000000004444444434499999449444444444440eeeeeeeeeeeeeeeeeeeeeeeeeeeeee9eee9ee9eebb9b9bbbbbb9b9bbee9ee9eee9eeeeeeeeeeeeeeeeeeeeee +00000000044444944b3439499949444449444440eeeeeeeeeeeeeeeeeeeeeeeee9eeee9ee99ebb9bbbbbbbbbbbbbbbbbb9bbe99ee9eeee9eeeeeeeeeeeeeeeee +00000000b44444993bb4b949999944449944444beeeeeeeeeeeeeeeeeeeeeeeee9be9b9bb93bbb9bbbbb9bbbbbb9bbbbb9bbb39bb9b9eb9eeeeeeeeeeeeeeeee +00000000bb4444bbbbbb3999999944b3bb4444bbeeeeeeeeeeeeeeeeeee9ee9bb9bb9bbbbb3bbbbbb3bbbbb33bbbbb3bbbbbb3bbbbb9bb9bb9ee9eeeeeeeeeee +777777774b344bbbb33bb939993b43bbbbb443b4eeeeeeeeeeeeeeeee9e99b9bbbbb93b9bb3bbbbbb33b3b3003b3b33bbbbbb3bb9b39bbbbb9b99e9eeeeeeeee +77777777bbb3443bbb33b3b3393b3bbbb3443bbbeeeeeeeeeeeeeeee99b99bb3bbbbb3b9bbbb3b3b3303030000303033b3b3bbbb9b3bbbbb3bb99b99eeeeeeee +0000000000000007bb3b3bbbbb3bbb3b7000000000000077eeeeeeeb9bbb9bbbbbbbbbb3bbbbbbb300000004400000003bbbbbbb3bbbbbbbbbb9bbb9beeeeeee +0000000000000077b3bb33bbb333bb3b7700000000007777eeeeee9bbbbbbbbbbb0bb3b3bb0bb3b304404004400404403b3bb0bb3b3bb0bbbbbbbbbbb9eeeeee +000000000000077703b3bbbbb3b3b3b07770000000777777eeeeee9bbbbb300b3003b0303003b0304444440440444444030b3003030b3003b003bbbbb9eeeeee +0000007700007777b03bbb333bbbbbb07777000077777777eeeeeeb3b3bb04030040300400403004444444444444444440030400400304003040bb3b3beeeeee +0000777700077777b333bbbbb33b30bb7777700077777777eeeeeebeb0bb44404444004444440044444444444444444444004444440044440444bb0bebeeeeee +0077777700777777b3bbb3bb30033bbb7777770077777777eeeeeebe30b3444044444044444440449444444444444449440444444404444404443b03ebeeeeee +77777777077777773bb330bbbb3b33bb7777777077777777eeeeeeee00b4444444444444444444499449444444449449944444444444444444444b00eeeeeeee +7777777777777777bbbbb0bbb33bb3bb7777777777777777eeeeeeee4034444494494444444494499944444444444499944944444444944944444304eeeeeeee eeeeccccccee1eeeeeeeccccceeeeeeeeeeeccccceeeeeeeeeeeccccceeeeeeeeeeeccccceeeeeeeeeeecccccceeceeeeeeecccccceeceeeeeeecccccceeceee eeeceeccccccceeeeeececccccceeeeeeeececccccceeeeeeeececccccceeeeeeeececccccceeeeeeeeceeccccccceeeeeeceeccccccceeeeeeceeccccccceee eeeeccffccccceeeeeeeccffcccceeeeeeeeccffcccceeeeeeeeccffcccceeeeeeeeccffcccceeeeeeeeccffccccceeeeeeeccffccccceeeeeeeccffccccceee @@ -83,90 +83,90 @@ eeeeeeeececeeeeeeeee7ccee888eeeeee87ccceeec7870ee07887cceecceeeeeee087ccceeeeeee eeeeeeee7e7eeeeeeee877eeee877eeee787eeeeee7880eee8787cceeee778eeee077eee7eeeeeeeeeeeeec087eeeeeeeee0888eee788eeeee087eeeeee00eee eeeeeee0872088eeeee88878eee88eeee87eeeeeeee80eeeeeeeeeeeeee8878eee08eeee8788eeeeeeeee870788eeeeeeeee8778eee08eeeee888eeeeeeeeeee eeeeeee27888028eeeee87888eeeeeeee88eeeeeeeeeeeeeeeeeeeeeeeee8788ee88eeee07088eeeeeeee8880888eeeeeeee0888eeeeeeeeee0888eeeeeeeeee -eeeecccccceeceeeeeeecccccceeceeeeeeecccccceeceeeeeeeccccceeeeeee8888888888888888888888888888888888888888888888888888888888888888 -eeeceeccccccceeeeeeceeccccccceeeeeeceeccccccceeeeeececccccceeeee8888888888888888888888888888888888888888888888888888888888888888 -eeeeccffccccceeeeeeeccffccccceeeeeeeccffccccceeeeeeeccffcccceeee8888888888888888888888888888888888888888888888888888888888888888 -eeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceee8888888888888888888888888888888888888888888888888888888888888888 -eecccccc771cceeeeecccccc771cceeeeecccccc771cceeeeeccccccc70cceee8888888888888888888888888888888888888888888888888888888888888888 -eeceeecc771ceeeeeeceeecc771ceeeeeeceeecc771ceeeeeeceecccc70ceeee8888888888888888888888888888888888888888888888888888888888888888 -eeeeccccf77f0eeeeeeeccccf77f0eeeeeeeccccf77f0eeeeeeeccccf7ff0eee8888888888888888888888888888888888888888888888888888888888888888 -eeeecefccfeeeeeeeeeecefccfeeeeeeeeeecffccffeeeeeeeeceecccffeeeee8888888888888888888888888888888888888888888888888888888888888888 -eeeeefccffe77eeeeeeeefcccfeeeeeeeeeefcccfeeeeeeeeeeeeccffeeeeeee8888888888888888888888888888888888888888888888888888888888888888 -eeeefecccff77eeeeeeee77ccf77eeeeeee77eecfeeeeeeeeeeee77cfe77eeee8888888888888888888888888888888888888888888888888888888888888888 -eee77eeccceeeeeeeeeee77ccc77eeeeeee77eeccf77eeeeeeeee77ccf77eeee8888888888888888888888888888888888888888888888888888888888888888 -eee77ecceccee88eee000eececceeeeeeeeeeeecce77eeeeeeeeeeececeeeeee8888888888888888888888888888888888888888888888888888888888888888 -ee80ccceeec7870ee07887cceecceeeeeee087ccceeeeeeeeeeeeeec07eeeeee8888888888888888888888888888888888888888888888888888888888888888 -e787eeeeee7880eee8787cceeee778eeee077eee7eeeeeeeeeeeee7c88eeeeee8888888888888888888888888888888888888888888888888888888888888888 -e87eeeeeeee80eeeeeeeeeeeeee8878eee08eeee8788eeeeeeeee08700eeeeee8888888888888888888888888888888888888888888888888888888888888888 -e88eeeeeeeeeeeeeeeeeeeeeeeee8788ee88eeee07088eeeeeeee078880eeeee8888888888888888888888888888888888888888888888888888888888888888 -eeecccceeeeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -ecccccccceeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eccccc7cceeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -ccccc777cceeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -cccccc7ccceeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -cccccccccceeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -cccccccccceeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -ecccccccceeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -ecccccccceeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eeecccceeeeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eeeeeeeeeeeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eeeeeeeeeeeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eeeeeeeeeeeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eeeeeeeeeeeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eeeeeeeeeeeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -eeeeeeeeeeeeeeee8888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 +eeeecccccceeceeeeeeecccccceeceeeeeeecccccceeceeeeeeeccccceeeeeee9999999999999999999999999999999999999999999999999999999999999999 +eeeceeccccccceeeeeeceeccccccceeeeeeceeccccccceeeeeececccccceeeee9999999999999999999999999999999999999999999999999999999999999999 +eeeeccffccccceeeeeeeccffccccceeeeeeeccffccccceeeeeeeccffcccceeee9999999999999999999999999999999999999999999999999999999999999999 +eeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceeeeeecccfcc7ccceee9999999999999999999999999999999999999999999999999999999999999999 +eecccccc771cceeeeecccccc771cceeeeecccccc771cceeeeeccccccc70cceee9999999999999999999999999999999999999999999999999999999999999999 +eeceeecc771ceeeeeeceeecc771ceeeeeeceeecc771ceeeeeeceecccc70ceeee9999999999999999999999999999999999999999999999999999999999999999 +eeeeccccf77f0eeeeeeeccccf77f0eeeeeeeccccf77f0eeeeeeeccccf7ff0eee9999999999999999999999999999999999999999999999999999999999999999 +eeeecefccfeeeeeeeeeecefccfeeeeeeeeeecffccffeeeeeeeeceecccffeeeee9999999999999999999999999999999999999999999999999999999999999999 +eeeeefccffe77eeeeeeeefcccfeeeeeeeeeefcccfeeeeeeeeeeeeccffeeeeeee9999999999999999999999999999999999999999999999999999999999999999 +eeeefecccff77eeeeeeee77ccf77eeeeeee77eecfeeeeeeeeeeee77cfe77eeee9999999999999999999999999999999999999999999999999999999999999999 +eee77eeccceeeeeeeeeee77ccc77eeeeeee77eeccf77eeeeeeeee77ccf77eeee9999999999999999999999999999999999999999999999999999999999999999 +eee77ecceccee88eee000eececceeeeeeeeeeeecce77eeeeeeeeeeececeeeeee9999999999999999999999999999999999999999999999999999999999999999 +ee80ccceeec7870ee07887cceecceeeeeee087ccceeeeeeeeeeeeeec07eeeeee9999999999999999999999999999999999999999999999999999999999999999 +e787eeeeee7880eee8787cceeee778eeee077eee7eeeeeeeeeeeee7c88eeeeee9999999999999999999999999999999999999999999999999999999999999999 +e87eeeeeeee80eeeeeeeeeeeeee8878eee08eeee8788eeeeeeeee08700eeeeee9999999999999999999999999999999999999999999999999999999999999999 +e88eeeeeeeeeeeeeeeeeeeeeeeee8788ee88eeee07088eeeeeeee078880eeeee9999999999999999999999999999999999999999999999999999999999999999 +eeecccceeeeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +ecccccccceeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +eccccc7cceeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +ccccc777cceeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +cccccc7ccceeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +cccccccccceeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +cccccccccceeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +ecccccccceeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +ecccccccceeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +eeecccceeeeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +eeeeeeeeeeeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +eeeeeeeeeeeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +eeeeeeeeeeeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +eeeeeeeeeeeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +eeeeeeeeeeeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +eeeeeeeeeeeeeeee9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 __gff__ -0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010001010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +0000000000000000010101010000000000000000000000000100000100000000010000000000000001000001000000000001010101010100010101010000000001010101010000000000000000000000010101010100000000000000000000000101010101000001010101010101010001010101010100010101010101010100 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 __map__ -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454540404040404040404040454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454540404045454545454545454545454540404040454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4045454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4045454545454545454545454545454545454545454545454545454545454545454545454545454545454545404040404545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4045454545454545454545454545454545454545454545454545454545454545454545454545454545454545404040404545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4045454545454545454545454545454545454545454545454545454545454545454040404040454545454545404040404545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4045454545454545454545454545454646464646454545454545454545454545454545454545454545454546454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4045454545454545454545454545454545454545454545454545454545454545454545454545454541404040454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4045454545454545454545454545454545454545454545454545454545454545454545454545454540404040454545454545454040404045454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4045454545454545464646464645454545454545454545454545454545454545454545454545454545454545454545454545454040404045454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4045454545454545454545454545454545454545454545454545454545454545454545454545454545454545454540404040404545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4045454545454545454545454545454545454545454545454545414040404040454545454545454545454545454540404040404545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4040404040404040424545454545454545454545454545454541404040404040454545454545454140404040404040454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4040404040404040404245454545454545454545454545454140404040404040454545454545414040404040404040454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4040404040404040404042454545454545454545454545414040404040404040404040404040404040404040404545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4040404040404040404040424545454545454545454541404040404040404040454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454040404245454545454545454140404040404040404040454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454540404040404040404040404040404040404040404040454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 -4545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545 +1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101010101010101010101010101010101010101010101010101010101010101010666768696a6b6c6d6e6f1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101010101010101010101010101010101010101010101010101010101010101010767778797a7b7c7d7e7f1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +10101010101010101010101010101010101010666768696a6b6c6d6e6f101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +10101010101010101010101010101010101010767778797a7b7c7d7e7f101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101010666768696a6b6c6d6e6f10101010101010101010101010101010101010101010101010101010101010101010101010666768696a6b6c6d6e6f101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101010767778797a7b7c7d7e7f10101010101010101010101010101010101010101010101010101010101010101010101010767778797a7b7c7d7e7f101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +101010101010101010101010101010101010101010101010101010666768696a6b6c6d6e6f10101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +101022232422232422232410101010101010101010101010101010767778797a7b7c7d7e7f10101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +3031333432333432333434363710101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1041535253525352535253441010666768696a6b6c6d6e6f1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +10515308091010100a0b52541010767778797a7b7c7d7e7f1010101010101010101010101010101010102020202020202020201010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +10515318191010101a1b52541010101010101010101010101010101010101010101010101010101010102020202020202020201010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +105154101010101010105154101010101010101010103a751010101010101010101010101010101010102020202020202020201010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +10515410101010101010101010101010101010103a7510101010101010101010101010101010101010102020202020202020201010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1051541010101010101010101010101010103a75101010101010101010101010101010101010101010102020202020202020201010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +10515328291010101010101010101010101010101010101010101010101010106768696a6b6c6d6e101010101010101010101010666768696a6b6c6d6e6f101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +10515338392223241010101010102422232222246768696a6b6c6d6e6768696a7778797a7b7c7d7e6b6c6d6e6f10101010101010767778797a7b7c7d7e7f101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +10101030493233343536363135313234323334327778797a7b7c7d7e7778797a52535253525352537b7c7d7e7f1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101041424342434243424342434243424342525352535253525352535253525352535253525352535253101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101051525352535253525352535253525352525352535253525352535253525352535253525352535253101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101051525352535253525352535253525352525352535253525352535253525352535253525352535253101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101061626362636263626362636263626362626362636263626362636263626362636263626362636263101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101072737273737273737273737273737273737372737372737273737273737273737273737273737273101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 +1010101073737272737272737272737272737272737272727373727373737272737272727372727273737372101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010 __sfx__ 010c00002d340293402d340293402f3402b3402f3402b340303402d340303402d340323402f340323402f34023340233403062500000306250000021340213403062500000306250000023340233403062500000 010c000030625000000934009345153401534509340093450a3400a34516340163450a3400a34517340173450c3400c3450c3400c3450c3400c3450c3400c3450c3400c3450c3400c3450c3400c3450c3400c345 diff --git a/edit_data.sh b/edit_data.sh old mode 100644 new mode 100755 diff --git a/edit_metadata.sh b/edit_metadata.sh old mode 100644 new mode 100755 diff --git a/export_cartridge_release.p8 b/export_cartridge_release.p8 index 34d51581..42a20d38 100644 --- a/export_cartridge_release.p8 +++ b/export_cartridge_release.p8 @@ -10,14 +10,14 @@ __lua__ -- Paths are relative to PICO-8 carts directory. cd("picosonic") -load("picosonic_v3.0_release.p8") +load("picosonic_v3.2_release.p8") -- png cartridge export is done via SAVE -- the metadata label is used automatically -save("picosonic_v3.0_release.p8.png") +save("picosonic_v3.2_release.p8.png") -- other exports are done via EXPORT, and can use an icon -- instead of the .p8.png label -- icon is a 16x16 square => -s 2 tiles wide -- with top-left at sprite 2 => -i 2 -- on pink (color 14) background => -c 14 -export("picosonic_v3.0_release.bin -i 2 -s 2 -c 14") -export("picosonic_v3.0_release.html -i 2 -s 2 -c 14") +export("picosonic_v3.2_release.bin -i 2 -s 2 -c 14") +export("picosonic_v3.2_release.html -i 2 -s 2 -c 14") diff --git a/export_cartridge_release.sh b/export_cartridge_release.sh old mode 100644 new mode 100755 diff --git a/install_cartridge_linux.sh b/install_cartridge_linux.sh old mode 100644 new mode 100755 index 47315108..7d69748c --- a/install_cartridge_linux.sh +++ b/install_cartridge_linux.sh @@ -12,7 +12,7 @@ fi # Configuration: cartridge cartridge_stem="picosonic" -version="3.0" +version="3.2" config="$1"; shift # option "png" will export the png cartridge diff --git a/pico-boots b/pico-boots index d93d71ca..654e6e10 160000 --- a/pico-boots +++ b/pico-boots @@ -1 +1 @@ -Subproject commit d93d71ca3b9d827e22c424406c0b484d911e5dc7 +Subproject commit 654e6e101bacecca12370967c3ff9d2d33041b2a diff --git a/prebuild/replace_strings.py b/prebuild/replace_strings.py index ae5596c4..79d56c98 100755 --- a/prebuild/replace_strings.py +++ b/prebuild/replace_strings.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3.6 # -*- coding: utf-8 -*- + +# LEGACY: use preprocess.py in pico-boots now + import argparse import logging import os diff --git a/prebuild/test_replace_strings.py b/prebuild/test_replace_strings.py index 3ed835cf..ffb5edd2 100644 --- a/prebuild/test_replace_strings.py +++ b/prebuild/test_replace_strings.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +# LEGACY: use test_preprocess.py to test preprocess.py in pico-boots now + import unittest from . import replace_strings @@ -94,5 +96,5 @@ def test_replace_all_strings_in_dir(self): self.assertEqual(f.read(), 'require("itest_character")\n⬅️ or ➡️\nand 🅾️\nprint("press 🅾️")') if __name__ == '__main__': - logging.basicConfig(level=logging.ERROR) + logging.basicConfig(level=logging.CRITICAL) unittest.main() diff --git a/run_game.sh b/run_game.sh index 2c7ae77e..01678ac0 100755 --- a/run_game.sh +++ b/run_game.sh @@ -5,10 +5,10 @@ # Configuration: cartridge cartridge_stem="picosonic" -version="3.0" +version="3.2" config="$1"; shift -run_cmd="pico8 -run build/${cartridge_stem}_v${version}_${config}.p8 -screenshot_scale 4 -gif_scale 4 $@" +run_cmd="pico8 -run build/${cartridge_stem}_v${version}_${config}.p8 -screenshot_scale 4 -gif_scale 4 -gif_len 60 $@" # Support UNIX platforms without gnome-terminal by checking if the command exists # If you `reload.sh` the game, the separate terminal allows you to keep watching the program output, diff --git a/run_itest.sh b/run_itest.sh index bd3036e9..de54d06a 100755 --- a/run_itest.sh +++ b/run_itest.sh @@ -1,13 +1,13 @@ #!/bin/bash -# Run itest with PICO-8 executable (itests only work in debug config) +# Run itest with PICO-8 executable # Pass any extra arguments to pico8 # Configuration: cartridge cartridge_stem="picosonic_itest_all" -version="3.0" +version="3.2" -run_cmd="pico8 -run build/${cartridge_stem}_v${version}_debug.p8 -screenshot_scale 4 -gif_scale 4 $@" +run_cmd="pico8 -run build/${cartridge_stem}_v${version}_itest.p8 -screenshot_scale 4 -gif_scale 4 $@" # Support UNIX platforms without gnome-terminal by checking if the command exists # If you `reload.sh` the game, the separate terminal allows you to keep watching the program output, diff --git a/run_pico8_utests.sh b/run_pico8_utests.sh old mode 100644 new mode 100755 index a96d4868..e827f39d --- a/run_pico8_utests.sh +++ b/run_pico8_utests.sh @@ -5,9 +5,9 @@ # Configuration: cartridge cartridge_stem="picosonic_pico8_utests_all" -version="3.0" +version="3.2" -run_cmd="pico8_0.1.11g_dev -run build/${cartridge_stem}_v${version}_debug.p8 -screenshot_scale 4 -gif_scale 4 $@" +run_cmd="pico8 -run build/${cartridge_stem}_v${version}_debug.p8 -screenshot_scale 4 -gif_scale 4 $@" # Support UNIX platforms without gnome-terminal by checking if the command exists # If you `reload.sh` the game, the separate terminal allows you to keep watching the program output, diff --git a/src/application/picosonic_app.lua b/src/application/picosonic_app.lua index f0537846..7e51865e 100644 --- a/src/application/picosonic_app.lua +++ b/src/application/picosonic_app.lua @@ -1,9 +1,7 @@ -- custom game application -- used by main and itest_main -require("engine/application/constants") local gameapp = require("engine/application/gameapp") -require("engine/core/class") local input = require("engine/input/input") --#if tuner diff --git a/src/application/picosonic_app_utest.lua b/src/application/picosonic_app_utest.lua index 5d179895..29aae6b7 100644 --- a/src/application/picosonic_app_utest.lua +++ b/src/application/picosonic_app_utest.lua @@ -1,4 +1,4 @@ -require("engine/test/bustedhelper") +require("test/bustedhelper") local picosonic_app = require("application/picosonic_app") local flow = require("engine/application/flow") diff --git a/src/common.lua b/src/common.lua new file mode 100644 index 00000000..2d65429f --- /dev/null +++ b/src/common.lua @@ -0,0 +1,8 @@ +-- Require all common game modules (used across various scripts in game project) +-- that define globals and don't return a module table +-- Equivalent to engine/common.lua but for game. +-- Usage: add require("common") at the top of each of your main scripts +-- (along with "engine/common") and in bustedhelper (after pico8api) + +require("engine/core/direction_ext") +require("engine/core/vector_ext") diff --git a/src/data/collision_data.lua b/src/data/collision_data.lua index 9d6f55f8..94c04f1d 100644 --- a/src/data/collision_data.lua +++ b/src/data/collision_data.lua @@ -1,36 +1,192 @@ -require("engine/core/math") -local tile = require("platformer/tile") -local tile_data = tile.tile_data +local serialization = require("engine/data/serialization") + +local raw_tile_collision_data = require("data/raw_tile_collision_data") +local tile_collision_data = require("data/tile_collision_data") sprite_flags = { collision = 0 } -return { +-- table mapping visual tile sprite id to tile collision data (collision mask sprite id location + slope) +-- the mask is generally placed just below the visual tile in pico8 sprite editor, +-- hence the location @ (i, j) but the sprite_id_location(i, j + 1) +-- this will be completed as tiles are added, adding extra information +-- such as "mirror_y: true" for upside-down tiles +-- for readability we also indicate the sprite id location in comment +-- note that for mockup, tile_test_data now contains the mock height arrays +-- while this contains the slopes, which is bad practice; we'll need to centralize +-- mock data in the end. we'll probably create a pico8 tile data to data string +-- converter so we can edit visually, but also generate data code reusable +-- for headless tests + +-- all angles are defined with atan2 using top-left XY convention to avoid issues +-- proto tiles may pass values manually, but in this case make sure to enter the angle +-- between 0 and 1, as if Sonic was running a loop counter-clockwise + +--[[ + EXPLANATION OF EACH TILE ID <-> (I, J) IN SPRITESHEET + (put in comments outside data string because we don't have a preprocess step able to strip comments + inside data strings, nor is parse_expression able to ignore comments) + # common tiles (flat or very low slope) + 49 @ (1, 3) + 50 @ (2, 3) + 51 @ (3, 3) + 52 @ (4, 3) + 53 @ (5, 3) + 54 @ (6, 3) + 65 @ (1, 4) + 66 @ (2, 4) + 67 @ (3, 4) + 68 @ (4, 4) + 81 @ (1, 5) + 82 @ (2, 5) + 83 @ (3, 5) + 84 @ (4, 5) + 97 @ (1, 6) + 98 @ (2, 6) + 99 @ (3, 6) + 100 @ (4, 6) + 114 @ (2, 7) + 115 @ (3, 7) + # low slopes ascending and descending + 87 @ (7, 6) + 88 @ (8, 6) + 89 @ (9, 6) + 90 @ (10, 6) + 91 @ (11, 6) + 92 @ (12, 6) + 93 @ (13, 6) + 94 @ (14, 6) + # bottom of said slopes (full tiles) + 119 @ (7, 7) + 120 @ (8, 7) + 121 @ (9, 7) + 122 @ (10, 7) + 123 @ (11, 7) + 124 @ (12, 7) + 125 @ (13, 7) + 126 @ (14, 7) + # loop (start from top-left tile, then rotate clockwise) + # note that we always write angles as atan2(dx, dy) with motion (dx, dy) + # as if Sonic was running the loop counter-clockwise + # this allows to identify ceiling angles vs floor angles easily + # (ceiling angles between 0.25 and 0.75) + 8 @ (8, 0) + 9 @ (9, 0) + 10 @ (10, 0) + 11 @ (11, 0) + 27 @ (11, 1) + 43 @ (11, 2) + 59 @ (11, 3) + 58 @ (10, 3) + 57 @ (9, 3) + 56 @ (8, 3) + 40 @ (8, 2) + 24 @ (8, 1) + # loop bottom ground (full) + 72 @ (8, 4) + 73 @ (9, 4) + 74 @ (10, 4) + 75 @ (11, 4) + + # proto (black and white tiles being their own collision masks) + # must match tile_data.lua + # if too heavy, surround with #itest and create a separate spritesheet for itests with only polygonal tiles + # stored in some proto_data.p8 or test_data.p8 + # this will allow us to reuse the extra space left by removing proto tiles for release (adding FX, etc.) + 32 @ (0, 2) FULL TILE # + 80 @ (0, 5) HALF TILE (4px high) = + 96 @ (0, 6) FLAT LOW TILE (2px high) _ + 64 @ (0, 4) BOTTOM-RIGHT QUARTER TILE (4px high) r + 112 @ (0, 7) ASCENDING 22.5 < slope_angle: 0.0625 ~= atan2(8, -4) (actually 0.0738) but kept for historical utest/itest reasons + 113 @ (1, 7) ASCENDING 45 / slope_angle: 0.125 = atan2(1, -1) + 116 @ (4, 7) DESCENDING 45 \ slope_angle: 1-0.125 = atan2(1, 1) + 117 @ (5, 7) higher 2:1 ascending slope (completes 58 from loop) + 12 @ (12, 0) LOOP TOP-LEFT: reusing mask of loop top-left with itself + --]] +local raw_tiles_data = serialization.parse_expression( + --[tile_id] = tile_data( + -- mask_tile_id_loc, slope_angle=atan2(x, y) or angle (proto only)) + [[{ + [49] = {{1, 2}, {8, -2}}, + [50] = {{0, 2}, {8, 0}}, + [51] = {{0, 2}, {8, 0}}, + [52] = {{0, 2}, {8, 0}}, + [53] = {{5, 2}, {8, 2}}, + [54] = {{6, 2}, {8, 0}}, + [65] = {{0, 2}, {8, 0}}, + [66] = {{0, 2}, {8, 0}}, + [67] = {{0, 2}, {8, 0}}, + [68] = {{0, 2}, {8, 0}}, + [81] = {{0, 2}, {8, 0}}, + [82] = {{0, 2}, {8, 0}}, + [83] = {{0, 2}, {8, 0}}, + [84] = {{0, 2}, {8, 0}}, + [97] = {{0, 2}, {8, 0}}, + [98] = {{0, 2}, {8, 0}}, + [99] = {{0, 2}, {8, 0}}, + [100]= {{0, 2}, {8, 0}}, + [114]= {{0, 2}, {8, 0}}, + [115]= {{0, 2}, {8, 0}}, + + [103] = {{7, 5}, {8, -2}}, + [104] = {{8, 5}, {8, -1}}, + [105] = {{9, 5}, {8, -2}}, + [106] = {{10, 5}, {8, -2}}, + [107] = {{11, 5}, {8, 2}}, + [108] = {{12, 5}, {8, 2}}, + [109] = {{13, 5}, {8, 1}}, + [110] = {{14, 5}, {8, 2}}, - -- table mapping tile sprite id to tile data (collision mask + slope) - -- the mask is generally placed just below the visual tile in pico8 sprite editor, - -- hence the location @ (i, j) but the sprite_id_location(i, j + 1) - -- this will be completed as tiles are added, adding extra information - -- such as "mirror_y: true" for upside-down tiles - -- for readability we also indicate the sprite id location in comment - -- note that for mockup, tile_test_data now contains the mock height arrays - -- while this contains the slopes, which is bad practice; we'll need to centralize - -- mock data in the end. we'll probably create a pico8 tile data to data string - -- converter so we can edit visually, but also generate data code reusable - -- for headless tests - tiles_data = { - [64] = tile_data(sprite_id_location(0, 5), 0), -- 64 @ (0, 4) - [65] = tile_data(sprite_id_location(1, 5), -0.125), -- 65 @ (1, 4) - [66] = tile_data(sprite_id_location(2, 5), 0.125), -- 66 @ (2, 4) - [67] = tile_data(sprite_id_location(3, 5), -0.0625), -- 67 @ (3, 4) - [68] = tile_data(sprite_id_location(4, 5), 0), -- 68 @ (4, 4) - [69] = tile_data(sprite_id_location(5, 5), 0), -- 69 @ (5, 4) - [70] = tile_data(sprite_id_location(6, 5), 0), -- 70 @ (6, 4) - [71] = tile_data(sprite_id_location(7, 5), 0), -- 71 @ (7, 4) - [72] = tile_data(sprite_id_location(8, 5), 0), -- 72 @ (8, 4) - [73] = tile_data(sprite_id_location(9, 5), 0), -- 73 @ (9, 4) - [74] = tile_data(sprite_id_location(10, 5), 0), -- 74 @ (10, 4) - } + [119]= {{0, 2}, {8, 0}}, + [120]= {{0, 2}, {8, 0}}, + [121]= {{0, 2}, {8, 0}}, + [122]= {{0, 2}, {8, 0}}, + [123]= {{0, 2}, {8, 0}}, + [124]= {{0, 2}, {8, 0}}, + [125]= {{0, 2}, {8, 0}}, + [126]= {{0, 2}, {8, 0}}, + [8]= {{12, 0}, {-4, 4}}, + [9]= {{13, 0}, {-8, 4}}, + [10]= {{14, 0}, {-8, -4}}, + [11]= {{15, 0}, {-4, -4}}, + [27]= {{15, 1}, {-4, -8}}, + [43]= {{15, 2}, {4, -8}}, + [59]= {{15, 3}, {4, -4}}, + [58]= {{14, 3}, {8, -4}}, + [57]= {{13, 3}, {8, 4}}, + [56]= {{12, 3}, {4, 4}}, + [40]= {{12, 2}, {4, 8}}, + [24]= {{12, 1}, {-4, 8}}, + + [72]= {{12, 4}, {8, 0}}, + [73]= {{13, 4}, {8, 0}}, + [74]= {{14, 4}, {8, 0}}, + [75]= {{15, 4}, {8, 0}}, + + [32] = {{0, 2}, {8, 0}}, + [80] = {{0, 5}, {8, 0}}, + [96] = {{0, 6}, {8, 0}}, + [64] = {{0, 4}, {8, 0}}, + [112]= {{0, 7}, 0.0625}, + [113]= {{1, 7}, {8, -8}}, + [116]= {{4, 7}, {8, 8}}, + [117]= {{5, 7}, {8, -4}}, + [12]= {{12, 0}, {-4, 4}} + }]], function (t) + -- t[2] may be {x, y} to use for atan2 or slope_angle directly + -- this is only for [112], if we update utests/itests to use the more correct atan2(8, -4) then we can get rid of + -- that ternary check + return raw_tile_collision_data(sprite_id_location(t[1][1], t[1][2]), type(t[2]) == 'table' and atan2(t[2][1], t[2][2]) or t[2]) + end +) + +local tiles_collision_data = transform(raw_tiles_data, tile_collision_data.from_raw_tile_collision_data) + +return { + -- proxy getter is only here to make stubbing possible in tile_test_data + get_tile_collision_data = function (tile_id) + return tiles_collision_data[tile_id] + end } diff --git a/src/data/playercharacter_data.lua b/src/data/playercharacter_data.lua index 3389e09d..2e916f30 100644 --- a/src/data/playercharacter_data.lua +++ b/src/data/playercharacter_data.lua @@ -1,10 +1,14 @@ -require("engine/render/color") +local serialization = require("engine/data/serialization") local sprite_data = require("engine/render/sprite_data") local animated_sprite_data = require("engine/render/animated_sprite_data") local playercharacter_data = { -- platformer motion + -- values in px, px/frame, px/frame^2 are *2 compared to SPG since we work with 8px tiles + -- for values in px, px/frame, px/frame^2, I added /64 + -- for degrees, /360 form + -- (for readability) -- ground acceleration (px/frame^2) ground_accel_frame2 = 0.0234375, -- 1.5/64 @@ -12,6 +16,10 @@ local playercharacter_data = { -- ground active deceleration (brake) (px/frame^2) ground_decel_frame2 = 0.25, -- 16/64 + -- Original feature (not in SPG): Reduced Deceleration on Descending Slope + -- ground active deceleration factor on descending slope ([0-1]) + ground_decel_descending_slope_factor = 0.5, + -- ground friction (passive deceleration) (px/frame^2) ground_friction_frame2 = 0.0234375, -- 1.5/64 @@ -21,12 +29,39 @@ local playercharacter_data = { -- slope accel acceleration factor (px/frame^2), to multiply by sin(angle) slope_accel_factor_frame2 = 0.0625, -- 7/64 + -- Used by 3 original features (not in SPG): + -- - Reduced Deceleration on Steep Descending Slope + -- - No Friction on Steep Descending Slope + -- - Progressive Ascending Steep Slope Factor + -- max slope angle on which friction is applied (]0-0.25[, but we recommend more than 22.5 degrees i.e. 0.0625) + steep_slope_min_angle = 0.075, -- 27/360 + + -- derived data: the slope angle for which ground friction is exactly opposed to slope factor + -- is 22.02 degrees ~ 0.061 angle/360 ratio (PICO-8 unit) + + -- Original feature (not in SPG): Progressive Ascending Slope Factor + -- time needed when ascending a slope before full slope factor is applied (s) + progressive_ascending_slope_duration = 0.5, + -- air acceleration on x axis (px/frames^2) air_accel_x_frame2 = 0.046875, -- 3/64 + -- air drag factor applied every frame, at 60 FPS + air_drag_factor_per_frame = 0.96875, + + -- min absolute velocity x for which air drag is applied + air_drag_min_velocity_x = 0.25, -- 16/64 + + -- maximum absolute velocity y for which air drag is applied + -- the actual range is ] -air_drag_max_abs_velocity_y, 0 [ + air_drag_max_abs_velocity_y = 8, -- 512/64 + -- ground acceleration (px/frame) max_ground_speed = 3, + -- max air speed (very high, probably won't happen unless Sonic falls in bottomless pit) + max_air_velocity_y = 32, -- 2048/64 + -- initial variable jump speed (Sonic) (px/frame) initial_var_jump_speed_frame = 3.25, -- 208/64 = 3 + 16/64 @@ -80,30 +115,51 @@ local playercharacter_data = { -- sprite -- stand right - sonic_sprite_data_table = { - ["idle"] = sprite_data(sprite_id_location(0, 8), tile_vector(2, 2), vector(11, 8), colors.pink), - ["run1"] = sprite_data(sprite_id_location(2, 8), tile_vector(2, 2), vector(11, 8), colors.pink), - ["run2"] = sprite_data(sprite_id_location(4, 8), tile_vector(2, 2), vector(11, 8), colors.pink), - ["run3"] = sprite_data(sprite_id_location(6, 8), tile_vector(2, 2), vector(11, 8), colors.pink), - ["run4"] = sprite_data(sprite_id_location(8, 8), tile_vector(2, 2), vector(11, 8), colors.pink), - ["run5"] = sprite_data(sprite_id_location(10, 8), tile_vector(2, 2), vector(11, 8), colors.pink), - ["run6"] = sprite_data(sprite_id_location(12, 8), tile_vector(2, 2), vector(11, 8), colors.pink), - ["run7"] = sprite_data(sprite_id_location(14, 8), tile_vector(2, 2), vector(11, 8), colors.pink), - ["run8"] = sprite_data(sprite_id_location(0, 10), tile_vector(2, 2), vector(11, 8), colors.pink), - ["run9"] = sprite_data(sprite_id_location(2, 10), tile_vector(2, 2), vector(11, 8), colors.pink), - ["run10"] = sprite_data(sprite_id_location(4, 10), tile_vector(2, 2), vector(11, 8), colors.pink), - ["run11"] = sprite_data(sprite_id_location(6, 10), tile_vector(2, 2), vector(11, 8), colors.pink), - ["spin"] = sprite_data(sprite_id_location(0, 12), tile_vector(2, 2), vector(5, 5), colors.pink), - }, + -- colors.pink: 14 + sonic_sprite_data_table = serialization.parse_expression( + --anim_name = sprite_data( + -- id_loc, span, pivot, transparent_color (14: pink)) + [[{ + idle = {{0, 8}, {2, 2}, {11, 8}, 14}, + run1 = {{2, 8}, {2, 2}, {11, 8}, 14}, + run2 = {{4, 8}, {2, 2}, {11, 8}, 14}, + run3 = {{6, 8}, {2, 2}, {11, 8}, 14}, + run4 = {{8, 8}, {2, 2}, {11, 8}, 14}, + run5 = {{10, 8}, {2, 2}, {11, 8}, 14}, + run6 = {{12, 8}, {2, 2}, {11, 8}, 14}, + run7 = {{14, 8}, {2, 2}, {11, 8}, 14}, + run8 = {{0, 10}, {2, 2}, {11, 8}, 14}, + run9 = {{2, 10}, {2, 2}, {11, 8}, 14}, + run10 = {{4, 10}, {2, 2}, {11, 8}, 14}, + run11 = {{6, 10}, {2, 2}, {11, 8}, 14}, + spin = {{0, 12}, {2, 2}, {5, 5}, 14}, + }]], function (t) + return sprite_data( + sprite_id_location(t[1][1], t[1][2]), -- id_loc + tile_vector(t[2][1], t[2][2]), -- span + vector(t[3][1], t[3][2]), -- pivot + t[4] -- transparent_color + ) + end), + + -- minimum playback speed for "run" animation, to avoid very slow animation + -- 5/16: the 5 counters the 5 duration frames of ["run"] below, 1/8 to represent max duration 8 in SPG:Animations + -- and an extra 1/2 because for some reason, SPG values make animations look too fast (as if durations were for 30FPS) + run_anim_min_play_speed = 0.3125 } -- 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), -} +playercharacter_data.sonic_animated_sprite_data_table = serialization.parse_expression( + --[anim_name] = animated_sprite_data.create(playercharacter_data.sonic_sprite_data_table, + -- sprite_keys, step_frames, loop_mode) + [[{ + idle = {{"idle"}, 10, true}, + run = {{"run1", "run2", "run3", "run4", "run5", "run6", "run7", "run8", "run9", "run10", "run11"}, + 5, true}, + spin = {{"spin"}, 10, true}, +}]], function (t) + return animated_sprite_data.create(playercharacter_data.sonic_sprite_data_table, t[1], t[2], t[3]) +end) return playercharacter_data diff --git a/src/data/raw_tile_collision_data.lua b/src/data/raw_tile_collision_data.lua new file mode 100644 index 00000000..c1e0bf46 --- /dev/null +++ b/src/data/raw_tile_collision_data.lua @@ -0,0 +1,24 @@ +-- Raw tile data as stored directly in code +-- It determines the id location of a collision mask sprite +-- and a slope angle (simply because it's simpler to define angles manually +-- than deduce it from a collision mask which may have bumps or an ambiguous angle +-- such as +1 or +2 in height depending on context). +-- Once processed in combination with either PICO-8 spritesheet +-- or a mockup process (for busted), collision mask data (height and row arrays) +-- will be injected, giving a fully-fledged tile_data. +local raw_tile_collision_data = new_struct() + +-- mask_tile_id_loc sprite_id_location sprite location of the collision mask for this tile on the spritesheet +-- slope_angle float slope angle in turn ratio (0.0 to 1.0, positive clockwise) +function raw_tile_collision_data:_init(mask_tile_id_loc, slope_angle) + self.mask_tile_id_loc = mask_tile_id_loc + self.slope_angle = slope_angle +end + +--#if log +function raw_tile_collision_data:_tostring() + return "raw_tile_collision_data("..joinstr(", ", self.mask_tile_id_loc:_tostring(), self.slope_angle)..")" +end +--#endif + +return raw_tile_collision_data diff --git a/src/data/raw_tile_collision_data_utest.lua b/src/data/raw_tile_collision_data_utest.lua new file mode 100644 index 00000000..b73e6bca --- /dev/null +++ b/src/data/raw_tile_collision_data_utest.lua @@ -0,0 +1,24 @@ +require("test/bustedhelper") +local raw_tile_collision_data = require("data/raw_tile_collision_data") + +describe('raw_tile_collision_data', function () + + describe('_init', function () + + it('should create a raw tile data setting the sprite id location and the slope angle', function () + local td = raw_tile_collision_data(sprite_id_location(1, 2), 0.125) + assert.are_same({sprite_id_location(1, 2), 0.125}, {td.mask_tile_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 = raw_tile_collision_data(sprite_id_location(1, 2), 0.125) + assert.are_equal("raw_tile_collision_data(sprite_id_location(1, 2), 0.125)", td:_tostring()) + end) + + end) + +end) diff --git a/src/data/stage_data.lua b/src/data/stage_data.lua index 979f8010..4f3c8418 100644 --- a/src/data/stage_data.lua +++ b/src/data/stage_data.lua @@ -23,10 +23,10 @@ return { height = 32, -- where the player character spawns on stage start - spawn_location = location(2, 10), + spawn_location = location(5, 20), -- the x to reach to finish the stage - goal_x = 100 * 8, + goal_x = 800, -- 100 tiles -- background color background_color = colors.dark_blue, diff --git a/src/data/tile_collision_data.lua b/src/data/tile_collision_data.lua new file mode 100644 index 00000000..be300005 --- /dev/null +++ b/src/data/tile_collision_data.lua @@ -0,0 +1,225 @@ +local tile = {} + +-- struct that contains directly usable data on tile collision +-- semantically, it is almost equivalent to raw_tile_collision_data +-- (technically it loses the mask sprite id location) and redundant, +-- but by precomputing more detailed data using PICO-8 spritesheet / busted mocks, +-- tile_collision_data makes it possible to check for collision with ground very easily +local tile_collision_data = new_struct() + +-- height_array [int] sequence of column heights of a tile collision mask per column index, +-- counting index from the left +-- if tile vertical interior is down, a column rises from the bottom (floor) +-- if tile vertical interior is up, a column falls from the top (ceiling) +-- width_array [int] sequence of row widths of a tile collision mask per row index, +-- counting index from the top +-- if tile horizontal interior is left, a row is filled from the left (left wall or desc slope) +-- if tile horizontal interior is up, a row is filled from the right (right wall or asc slope) +-- note: knowing height_array and knowing width_array is equivalent (reciprocity) +-- we simply store both for faster access +-- slope_angle float slope angle in turn ratio (0.0 to 1.0 excluded, positive clockwise) +-- it also determines the interior: +-- 0 to 0.25: horizontal interior right, vertical interior down (flat or asc slope) +-- 0.25 to 0.5: horizontal interior right, vertical interior up (ceiling right corner or flat) +-- 0.5 to 0.75: horizontal interior left, vertical interior up (ceiling flat or left corner) +-- 0.75 to 1: horizontal interior left, vertical interior down (desc slope or flat) +-- interior_v vertical_dirs vertical direction of the tile's interior +-- (up for ceiling, down for floor) +-- interior_h horizontal_dirs horizontal direction of the tile's interior +-- (left for desc slope or left ceiling, asc slope or right ceiling) +function tile_collision_data:_init(height_array, width_array, slope_angle, interior_v, interior_h) + self.height_array = height_array + self.width_array = width_array + self.slope_angle = slope_angle + self.interior_v = interior_v + self.interior_h = interior_h +end + +-- return the height for a column index starting at 0, from left to right +function tile_collision_data:get_height(column_index0) + return self.height_array[column_index0 + 1] -- adapt 0-index to 1-index +end + +-- return the width for a row index starting at 0, from top to bottom +function tile_collision_data:get_width(row_index0) + return self.width_array[row_index0 + 1] -- adapt 0-index to 1-index +end + +-- helper function: return true iff array only contains 0 or 8 +local function is_full_or_empty(array) + -- check if all values in array are 8 / any value is not 8 + -- (there are no any/all helper functions yet, only contains with is any + ==) + + -- check columns first + for v in all(array) do + if v ~= 0 and v ~= 8 then + return false + end + end + + return true +end + +-- return true iff tile is made of empty/full columns only +-- (height array only contains 0 or 8) +-- in practice, those columns should be contiguous (else the row widths cannot be defined) +-- and the tile is a rectangle of height 8 +function tile_collision_data:is_full_vertical_rectangle() + return is_full_or_empty(self.height_array) +end + +-- return true iff tile is made of empty/full columns only +-- (height array only contains 0 or 8) +-- in practice, those columns should be contiguous (else the row widths cannot be defined) +-- and the tile is a rectangle of height 8 +function tile_collision_data:is_full_horizontal_rectangle() + return is_full_or_empty(self.width_array) +end + +-- return tuple (interior_v, interior_h) for a slope angle +function tile_collision_data.slope_angle_to_interiors(slope_angle) + assert(slope_angle % 1 == slope_angle) + -- in edge cases (square angles), interior direction is arbitrary + local interior_v = (slope_angle < 0.25 or slope_angle >= 0.75) and vertical_dirs.down or vertical_dirs.up + local interior_h = slope_angle < 0.5 and horizontal_dirs.right or horizontal_dirs.left + return interior_v, interior_h +end + +function tile_collision_data.from_raw_tile_collision_data(raw_data) + assert(raw_data.slope_angle >= 0 and raw_data.slope_angle < 1, "tile_collision_data.from_raw_tile_collision_data: raw_data.slope_angle is "..raw_data.slope_angle..", apply `% 1` before passing") + -- we don't mind edge cases (slope angle at 0, 0.25, 0.5 or 0.75 exactly) + -- and assume the code will handle any arbitrary decision on interior_h/v + local interior_v, interior_h = tile_collision_data.slope_angle_to_interiors(raw_data.slope_angle) + + return tile_collision_data( + tile_collision_data.read_height_array(raw_data.mask_tile_id_loc, interior_v), + tile_collision_data.read_width_array(raw_data.mask_tile_id_loc, interior_h), + raw_data.slope_angle, + interior_v, + interior_h + ) +end + +-- return a stateful range iterator +-- this function is generic enough to be in picoboots helper +-- but right now it's only used in this script so it is local +-- normally we should make it accessible from other modules +-- and test it, but it's likely to be correct since it was copied from +-- http://lua-users.org/wiki/RangeIterator and this module's tests are passing +local function range(from, to, step) + step = step or 1 + return function(_, lastvalue) + local nextvalue = lastvalue + step + if step > 0 and nextvalue <= to or step < 0 and nextvalue >= to or + step == 0 + then + return nextvalue + end + end, nil, from - step -- from - step: trick to start at from on the first step +end + +-- check for collision pixel at tile_mask_x + dx, tile_mask_y + dy, +-- and if one is found, return the length of the mask strip segment (height or width that constitutes +-- a collision array0) in the direction we were iterating on in calling context, thanks to segment_length_evaluator +-- (it is needed because height is read on y, width on x, and depending on the interior's +-- direction, we iterated from a different side and measure the length differently) +-- if no collision pixel at this position, return nil +function tile_collision_data.check_collision_pixel(tile_mask_x, tile_mask_y, dx, dy, interior_v, interior_h, segment_length_evaluator) + local tile_mask_color = sget(tile_mask_x + dx, tile_mask_y + dy) + -- we use black (0) as transparent mask color + if tile_mask_color ~= 0 then + -- segment_length_evaluator will either use dx & interior_v or dy & interior_h, + -- but we don't know from here so pass both + return segment_length_evaluator(dx, dy, interior_v, interior_h) + end +end + +-- return height to fill collision height array when finding first collision pixel at dy, +-- vertical interior on interior_v side +function tile_collision_data.evaluate_collision_height(dx, dy, interior_v, interior_h) + if interior_v == vertical_dirs.down then + -- we were iterating from the sky to the floor + -- so the height is complementary to our iteration distance + -- if we hit a collision pixel on first iteration (dy == 0), + -- then the column is full (return tile_size), so no offset + return tile_size - dy + else + -- we were iterating from the bottom to the ceiling, but backward, + -- so our iteration index tells us how far from the top we are + -- if we hit a collision pixel on first iteration (dy == tile_size - 1) + -- then the column is full (return tile_size), so need offset +1 + return dy + 1 + end +end + +-- return height to fill collision width array when finding first collision pixel at dx, +-- horizontal interior on interior_h side +function tile_collision_data.evaluate_collision_width(dx, dy, interior_v, interior_h) + -- see comments in evaluate_collision_height and transpose everything + if interior_h == horizontal_dirs.right then + return tile_size - dx + else + return dx + 1 + end +end + +-- Read tile mask collision height array located at tile_mask_id_location: sprite_id_location +-- We assume that the tile mask is only compounded or white (more exactly non-black) +-- vertical segments aka "columns" touching the top (if interior is up) or bottom (if interior is down) +-- of the tile. +function tile_collision_data.read_height_array(tile_mask_id_location, interior_v) + local array = {} + + local tile_mask_topleft_position = tile_mask_id_location:to_topleft_position() + + -- range returns a tuple so we need to pack and unpack later + local y_range = interior_v == vertical_dirs.down and {range(0, tile_size - 1)} or {range(tile_size - 1, 0, -1)} + + -- iterate over columns from left to right (order doesn't matter as long as we fill the height array in the same order) + for dx = 0, tile_size - 1 do + -- iterate from the opposite side of the vertical interior (e.g. bottom if interior is ceiling) + -- so we can find the collision pixel the farthest from the interior, which really represents the column height + for dy in unpack(y_range) do + -- no need to pass interior_h (but we need dx to check pixel at this position) + column_height = tile_collision_data.check_collision_pixel(tile_mask_topleft_position.x, tile_mask_topleft_position.y, dx, dy, interior_v, nil, tile_collision_data.evaluate_collision_height) + if column_height then + -- collision pixel found at column_height + -- break so we can immediately store the column_height in the array + break + end + end + if not column_height then + -- no pixel found at all, so column width is 0 + column_height = 0 + end + add(array, column_height) + end + return array +end + +-- see read_height_array and transpose everything for comments: +-- height -> width +-- x <-> y +-- interior_v -> interior_h +-- up -> left and down -> right +function tile_collision_data.read_width_array(tile_mask_id_location, interior_h) + local array = {} + local tile_mask_topleft_position = tile_mask_id_location:to_topleft_position() + local x_range = interior_h == horizontal_dirs.right and {range(0, tile_size - 1)} or {range(tile_size - 1, 0, -1)} + + for dy = 0, tile_size - 1 do + for dx in unpack(x_range) do + row_width = tile_collision_data.check_collision_pixel(tile_mask_topleft_position.x, tile_mask_topleft_position.y, dx, dy, nil, interior_h, tile_collision_data.evaluate_collision_width) + if row_width then + break + end + end + if not row_width then + row_width = 0 + end + add(array, row_width) + end + return array +end + +return tile_collision_data diff --git a/src/data/tile_collision_data_utest.lua b/src/data/tile_collision_data_utest.lua new file mode 100644 index 00000000..cbfd91cb --- /dev/null +++ b/src/data/tile_collision_data_utest.lua @@ -0,0 +1,338 @@ +require("test/bustedhelper") +local tile_collision_data = require("data/tile_collision_data") + +local raw_tile_collision_data = require("data/raw_tile_collision_data") + +-- when we have to mock tile sprite data in PICO-8, +-- we use the following + +-- mask tile 1: bottom-right asc slope variant with column 0 empty +-- (just to cover case column_height = 0 in read_height_array) +-- pixel representation: +-- ........ +-- ........ +-- ........ +-- ........ +-- ......## +-- ....#### +-- ..###### +-- .####### + +-- mask tile 2: top-left concave ceiling +-- pixel representation: +-- ######## +-- ######.. +-- ####.... +-- ###..... +-- ##...... +-- ##...... +-- #....... +-- #....... + +-- mask tile 3: top-right concave ceiling (to check asymmetry) +-- pixel representation: +-- ######## +-- ..###### +-- ....#### +-- .....### +-- ......## +-- ......## +-- .......# +-- .......# + +describe('tile_collision_data', function () + + describe('_init', function () + + it('should create a tile_collision_data with reciprocal arrays and slope angle', function () + local tcd = tile_collision_data({0, 1, 2, 2, 3, 3, 4, 4}, {0, 0, 0, 0, 2, 4, 6, 7}, atan2(8, -4), horizontal_dirs.right, vertical_dirs.down) + assert.are_same({{0, 1, 2, 2, 3, 3, 4, 4}, {0, 0, 0, 0, 2, 4, 6, 7}, atan2(8, -4)}, {tcd.height_array, tcd.width_array, tcd.slope_angle}) + end) + + end) + + describe('get_height', function () + + it('should return the height at the given column index', function () + local tcd = tile_collision_data({0, 1, 2, 2, 3, 3, 4, 4}, {0, 0, 0, 0, 2, 4, 6, 7}, atan2(8, -4)) + assert.are_equal(2, tcd:get_height(2)) + end) + + end) + + describe('get_width', function () + + it('should return the width at the given column index', function () + local tcd = tile_collision_data({0, 1, 2, 2, 3, 3, 4, 4}, {0, 0, 0, 0, 2, 4, 6, 7}, atan2(8, -4)) + assert.are_equal(2, tcd:get_width(4)) + end) + + end) + + describe('is_full_vertical_rectangle', function () + + -- just skip defining width array, we don't use it + -- if you want to now what it should be normally, + -- check the utests for is_full_horizontal_rectangle, which + -- define the same tiles! + + it('should return true when empty', function () + local tcd = tile_collision_data({0, 0, 0, 0, 0, 0, 0, 0}, {}, 0) + assert.is_true(tcd:is_full_vertical_rectangle()) + end) + + it('should return true when made of empty/full columns', function () + local tcd = tile_collision_data({8, 8, 8, 0, 0, 0, 0, 0}, {}, 0.75) + assert.is_true(tcd:is_full_vertical_rectangle()) + end) + + it('should return false when not made of empty/full columns only', function () + local tcd = tile_collision_data({4, 4, 4, 4, 3, 3, 3, 3}, {}, atan2(8, -1)) + assert.is_false(tcd:is_full_vertical_rectangle()) + end) + + end) + + describe('is_full_horizontal_rectangle', function () + + it('should return true when empty', function () + local tcd = tile_collision_data({}, {0, 0, 0, 0, 0, 0, 0, 0}, 0) + assert.is_true(tcd:is_full_horizontal_rectangle()) + end) + + it('should return true when made of empty/full rows', function () + local tcd = tile_collision_data({}, {0, 0, 0, 0, 0, 8, 8, 8}, 0) + assert.is_true(tcd:is_full_horizontal_rectangle()) + end) + + it('should return false when not made of empty/full rows only', function () + local tcd = tile_collision_data({}, {0, 0, 0, 0, 4, 8, 8, 8}, atan2(8, -1)) + assert.is_false(tcd:is_full_horizontal_rectangle()) + end) + + end) + + describe('slope_angle_to_interiors', function () + + -- cardinals + + it('should return down, right (edge case) for 0', function () + assert.are_same({vertical_dirs.down, horizontal_dirs.right}, {tile_collision_data.slope_angle_to_interiors(0)}) + end) + + it('should return up (edge case), right for 0.25', function () + assert.are_same({vertical_dirs.up, horizontal_dirs.right}, {tile_collision_data.slope_angle_to_interiors(0.25)}) + end) + + it('should return up, left (edge case) for 0.5', function () + assert.are_same({vertical_dirs.up, horizontal_dirs.left}, {tile_collision_data.slope_angle_to_interiors(0.5)}) + end) + + it('should return down (edge case), left for 0.75', function () + assert.are_same({vertical_dirs.down, horizontal_dirs.left}, {tile_collision_data.slope_angle_to_interiors(0.75)}) + end) + + -- diagonals + + it('should return a down, right for bottom-right tile', function () + assert.are_same({vertical_dirs.down, horizontal_dirs.right}, {tile_collision_data.slope_angle_to_interiors(atan2(8, -4))}) + end) + + it('should return a tile_collision_data containing (mock tile 2) height/width array, slope angle, derived interior directions', function () + assert.are_same({vertical_dirs.up, horizontal_dirs.left}, {tile_collision_data.slope_angle_to_interiors(atan2(-8, 8))}) + end) + + it('should return a tile_collision_data containing (mock tile 3) height/width array, slope angle, derived interior directions', function () + assert.are_same({vertical_dirs.up, horizontal_dirs.right}, {tile_collision_data.slope_angle_to_interiors(atan2(-8, -8))}) + end) + + end) + + describe('from_raw_tile_collision_data', function () + + -- we wrote these utests before extracting slope_angle_to_interiors + -- so we don't stub slope_angle_to_interiors and check final result directly + + setup(function () + stub(tile_collision_data, "read_height_array", function (tile_mask_id_location, slope_angle) + if tile_mask_id_location == 1 then + return {0, 1, 2, 2, 3, 3, 4, 4} + elseif tile_mask_id_location == 2 then + return {8, 6, 4, 3, 2, 2, 1, 1} + else + return {1, 1, 2, 2, 3, 4, 6, 8} + end + end) + stub(tile_collision_data, "read_width_array", function (tile_mask_id_location, slope_angle) + if tile_mask_id_location == 1 then + return {0, 0, 0, 0, 2, 4, 6, 7} + elseif tile_mask_id_location == 2 then + return {8, 6, 4, 3, 2, 2, 1, 1} + else + return {8, 6, 4, 3, 2, 2, 1, 1} + end + end) + end) + + teardown(function () + tile_collision_data.read_height_array:revert() + tile_collision_data.read_width_array:revert() + end) + + it('should return a tile_collision_data containing (mock tile 1) height/width array, slope angle, derived interior directions', function () + local raw_data = raw_tile_collision_data(1, atan2(8, -4)) + local tcd = tile_collision_data.from_raw_tile_collision_data(raw_data) + -- struct equality with are_equal would work, we just use are_same to benefit from diff asterisk provided by luassert + assert.are_same(tile_collision_data({0, 1, 2, 2, 3, 3, 4, 4}, {0, 0, 0, 0, 2, 4, 6, 7}, atan2(8, -4), vertical_dirs.down, horizontal_dirs.right), tcd) + end) + + it('should return a tile_collision_data containing (mock tile 2) height/width array, slope angle, derived interior directions', function () + local raw_data = raw_tile_collision_data(2, atan2(-8, 8)) + local tcd = tile_collision_data.from_raw_tile_collision_data(raw_data) + assert.are_same(tile_collision_data({8, 6, 4, 3, 2, 2, 1, 1}, {8, 6, 4, 3, 2, 2, 1, 1}, atan2(-8, 8), vertical_dirs.up, horizontal_dirs.left), tcd) + end) + + it('should return a tile_collision_data containing (mock tile 3) height/width array, slope angle, derived interior directions', function () + local raw_data = raw_tile_collision_data(2, atan2(-8, -8)) + local tcd = tile_collision_data.from_raw_tile_collision_data(raw_data) + assert.are_same(tile_collision_data({8, 6, 4, 3, 2, 2, 1, 1}, {8, 6, 4, 3, 2, 2, 1, 1}, atan2(-8, -8), vertical_dirs.up, horizontal_dirs.right), tcd) + end) + + end) + + describe('(mock sget)', function () + + local sget_mock + + setup(function () + local mock_mask_dot_matrix1 = { + {0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0, 1, 1}, + {0, 0, 0, 0, 1, 1, 1, 1}, + {0, 0, 1, 1, 1, 1, 1, 1}, + {0, 1, 1, 1, 1, 1, 1, 1}, + } + + local mock_mask_dot_matrix2 = { + {1, 1, 1, 1, 1, 1, 1, 1}, + {1, 1, 1, 1, 1, 1, 0, 0}, + {1, 1, 1, 1, 0, 0, 0, 0}, + {1, 1, 1, 0, 0, 0, 0, 0}, + {1, 1, 0, 0, 0, 0, 0, 0}, + {1, 1, 0, 0, 0, 0, 0, 0}, + {1, 0, 0, 0, 0, 0, 0, 0}, + {1, 0, 0, 0, 0, 0, 0, 0}, + } + + -- simulate an sget that would return the pixel of a tile mask + -- if coordinates fall in the sprite 1 at location (1, 0), i.e. [8-15] x [0-8], + -- or sprite 2 at location (2, 0), i.e. [16-23] x [0-8] + stub(_G, "sget", function (x, y) + if x >= 8 and x <= 15 and y >= 0 and y <= 8 then + -- convert offset to 1-based Lua index + -- multi-dimensional array above is first indexed by row (j), then column (i) + return mock_mask_dot_matrix1[y+1][x-8+1] + elseif x >= 16 and x <= 23 and y >= 0 and y <= 8 then + return mock_mask_dot_matrix2[y+1][x-16+1] + end + return 0 + end) + end) + + teardown(function () + sget:revert() + end) + + -- read_height/width_array utests could be done without mocking sget + -- and mocking check_collision_pixel instead, but since we had already written the utests below + -- (which check the final result without stubbing) before extracting check_collision_pixel, + -- it was simpler to just keep them, that to create a stub for check_collision_pixel that would cheat a lot + -- with the passed arguments + + describe('read_height_array', function () + + it('should return an array with respective column heights, from left to right', function () + local array = tile_collision_data.read_height_array(sprite_id_location(1, 0), vertical_dirs.down) + assert.are_same({0, 1, 2, 2, 3, 3, 4, 4}, array) + end) + + it('should return an array with respective column heights, from left to right', function () + local array = tile_collision_data.read_height_array(sprite_id_location(2, 0), vertical_dirs.up) + assert.are_same({8, 6, 4, 3, 2, 2, 1, 1}, array) + end) + + end) + + describe('read_width_array', function () + + it('should return an array with respective column rows, from top to bottom', function () + local array = tile_collision_data.read_width_array(sprite_id_location(1, 0), horizontal_dirs.right) + assert.are_same({0, 0, 0, 0, 2, 4, 6, 7}, array) + end) + + it('should return an array with respective column rows, from top to bottom', function () + local array = tile_collision_data.read_width_array(sprite_id_location(2, 0), horizontal_dirs.left) + assert.are_same({8, 6, 4, 3, 2, 2, 1, 1}, array) + end) + + end) + + describe('check_collision_pixel', function () + + it('(mock tile 1) should return nil when column pixel falls on empty pixel (interior down)', function () + -- note that 5 from top means 6th pixel on this column from the top + local column_height = tile_collision_data.check_collision_pixel(8, 0, 2, 5, vertical_dirs.down, nil, tile_collision_data.evaluate_collision_height) + assert.are_equal(nil, column_height) + end) + + it('(mock tile 1) should return 2 when column pixel falls on collision pixel at height 2 from bottom (interior down)', function () + local column_height = tile_collision_data.check_collision_pixel(8, 0, 2, 6, vertical_dirs.down, nil, tile_collision_data.evaluate_collision_height) + assert.are_equal(2, column_height) + end) + + it('(mock tile 2) should return nil when column pixel falls on empty pixel (interior up)', function () + local column_height = tile_collision_data.check_collision_pixel(16, 0, 2, 4, vertical_dirs.up, nil, tile_collision_data.evaluate_collision_height) + assert.are_equal(nil, column_height) + end) + + it('(mock tile 2) should return 4 when column pixel falls on collision pixel at height 2 from top (interior up)', function () + local column_height = tile_collision_data.check_collision_pixel(16, 0, 2, 3, vertical_dirs.up, nil, tile_collision_data.evaluate_collision_height) + assert.are_equal(4, column_height) + end) + + end) + + end) -- stub sget + + describe('evaluate_collision_height', function () + + it('return tile_size - dy for interior down', function () + local column_height = tile_collision_data.evaluate_collision_height(nil, 2, vertical_dirs.down, nil) + assert.are_equal(6, column_height) + end) + + it('return dy + 1 for interior up', function () + local column_height = tile_collision_data.evaluate_collision_height(nil, 3, vertical_dirs.up, nil) + assert.are_equal(4, column_height) + end) + + end) + + describe('evaluate_collision_width', function () + + it('return tile_size - dx for interior down', function () + local row_width = tile_collision_data.evaluate_collision_width(2, nil, nil, horizontal_dirs.right) + assert.are_equal(6, row_width) + end) + + it('return dx + 1 for interior up', function () + local row_width = tile_collision_data.evaluate_collision_width(3, nil, nil, horizontal_dirs.left) + assert.are_equal(4, row_width) + end) + + end) + +end) diff --git a/src/data/tile_data.lua b/src/data/tile_data.lua deleted file mode 100644 index 3e7806ac..00000000 --- a/src/data/tile_data.lua +++ /dev/null @@ -1,29 +0,0 @@ --- 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 ---#if busted -local tile_test_data = require("test_data/tile_test_data") ---#endif - -tile_symbol_to_ids = { - ['.'] = 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 --- the map since we would need to store a backup of the original map --- and we don't care, since each itest will build its own mock map -function setup_map_data() ---#if busted - tile_test_data.setup() ---#endif -end - -function teardown_map_data() ---#if busted - tile_test_data.teardown() ---#endif -end diff --git a/src/ingame/playercharacter.lua b/src/ingame/playercharacter.lua index 0edd926a..bcc0f2d8 100644 --- a/src/ingame/playercharacter.lua +++ b/src/ingame/playercharacter.lua @@ -1,7 +1,3 @@ -require("engine/application/constants") -require("engine/core/class") -require("engine/core/helper") -require("engine/core/math") --#if log local _logging = require("engine/debug/logging") --#endif @@ -59,12 +55,14 @@ local player_char = new_class() -- 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 +-- quadrant directions down vector of quadrant where character is located (down on floor, up on ceiling, left/right on walls) +-- orientation 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) +-- ascending_slope_time float time before applying full slope factor, when ascending a slope (s) -- 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) @@ -89,14 +87,17 @@ function player_char:_setup() self.motion_mode = motion_modes.platformer --#endif self.motion_state = motion_states.grounded - self.horizontal_dir = horizontal_dirs.right + self.quadrant = directions.down + self.orientation = horizontal_dirs.right self.position = vector.zero() self.ground_speed = 0. self.velocity = vector.zero() self.debug_velocity = vector.zero() - -- slope_angle starts at 0 instead of nil to match grounded state above (first spawn will set this anyway) + -- slope_angle starts at 0 instead of nil to match grounded state above + -- (if spawning in the air, fine, next update will reset angle to nil) self.slope_angle = 0. + self.ascending_slope_time = 0. self.move_intention = vector.zero() self.jump_intention = false @@ -124,6 +125,39 @@ function player_char:get_full_height() return self:is_compact() and pc_data.full_height_compact or pc_data.full_height_standing end +-- return quadrant tangent right (forward) unit vector +function player_char:get_quadrant_right() + return dir_vectors[rotate_dir_90_ccw(self.quadrant)] +end + +-- return quadrant normal down (interior) unit vector +function player_char:get_quadrant_down() + return dir_vectors[self.quadrant] +end + +-- return copy of vector rotated by quadrant right angle +-- this is a forward transformation, and therefore useful for intention (ground motion) +function player_char:quadrant_rotated(v) +--[[#pico8 + return v:rotated(world.quadrant_to_right_angle(self.quadrant)) +--#pico8]] +--#if busted + -- native Lua's floating point numbers cause small precision errors with cos/sin + -- so prefer perfect quadrant rotations (PICO-8 could also use this, but requires more tokens) + -- when testing, make sure to temporarily uncomment the pico8 block above + -- and comment the busted block below, so you can confirm that the pico8 version is valid too + if self.quadrant == directions.down then + return v:copy() + elseif self.quadrant == directions.right then + return v:rotated_90_ccw() + elseif self.quadrant == directions.up then + return -v + else -- self.quadrant == directions.left + return v:rotated_90_cw() + end +--#endif +end + -- spawn character at given position, detecting ground/air on arrival function player_char:spawn_at(position) self:_setup() @@ -155,18 +189,26 @@ function player_char:warp_bottom_to(bottom_position) end -- move the player character so that the bottom center is at the given position +--#if itest function player_char:get_bottom_center() - return self.position + vector(0, self:get_center_height()) + return self.position + self:get_center_height() * self:get_quadrant_down() end +--#endif +--#if busted -- move the player character so that the bottom center is at the given position function player_char:set_bottom_center(bottom_center_position) - self.position = bottom_center_position - vector(0, self:get_center_height()) + self.position = bottom_center_position - self:get_center_height() * self:get_quadrant_down() end +--#endif + +-- set slope angle and update quadrant +function player_char:set_slope_angle_with_quadrant(angle) + assert(angle == nil or 0. <= angle and angle <= 1., "player_char:set_slope_angle_with_quadrant: angle is "..tostr(angle)..", should be nil or between 0 and 1 (apply % 1 is needed)") + + self.slope_angle = angle --- move the player character from delta_vector in px -function player_char:move_by(delta_vector) - self.position = self.position + delta_vector + self.quadrant = world.angle_to_quadrant(angle) end function player_char:update() @@ -212,13 +254,18 @@ 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 + -- 1 -> 2 (debug) + -- 2 -> 1 (platformer) + self:set_motion_mode(self.motion_mode % 2 + 1) +end + +function player_char:set_motion_mode(val) + self.motion_mode = val + if val == motion_modes.platformer then + -- respawn character at current position + -- this will 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 + else -- self.motion_mode == motion_modes.debug self.debug_velocity = vector.zero() end end @@ -252,23 +299,25 @@ function player_char:_compute_ground_sensors_signed_distance(center_position) local highest_ground_slope_angle = nil -- check both ground sensors for ground - for i in all({horizontal_dirs.left, horizontal_dirs.right}) do + for i=1,2 do + -- equivalent to: + -- for i in all({horizontal_dirs.left, horizontal_dirs.right}) do -- check that ground sensor #i is on top of or below the mask column local sensor_position = self:_get_ground_sensor_position_from(center_position, i) local query_info = self:_compute_signed_distance_to_closest_ground(sensor_position) local signed_distance, slope_angle = query_info.signed_distance, query_info.slope_angle - -- apply ground priority rule: highest ground, then velocity x sign breaks tie, then horizontal direction breaks tie + -- apply ground priority rule: highest ground, then ground speed (velocity X in the air) sign breaks tie, + -- then q-horizontal direction breaks tie -- store the biggest penetration height among sensors - if signed_distance < min_signed_distance then - -- this ground is higher than the previous one, store new height and slope angle - min_signed_distance = signed_distance - highest_ground_slope_angle = slope_angle - elseif signed_distance == min_signed_distance and self:_get_prioritized_dir() == i then - -- this ground has the same height as the previous one, but character orientation - -- makes him stand on that one rather than the previous one, so we use its slope + -- case a: this ground is higher than the previous one, store new height and slope angle + -- case b: this ground has the same height as the previous one, but character orientation + -- makes him stand on that one rather than the previous one, so we use its slope + -- check both cases in condition below + if signed_distance < min_signed_distance or signed_distance == min_signed_distance and self:_get_prioritized_dir() == i then + min_signed_distance = signed_distance -- does nothing in case b highest_ground_slope_angle = slope_angle end @@ -280,64 +329,196 @@ end function player_char:_get_prioritized_dir() if self:is_grounded() then + -- on the ground, ground speed decides priority if self.ground_speed ~= 0 then return signed_speed_to_dir(self.ground_speed) end else + -- in the air, no quadrant, just use velocity X if self.velocity.x ~= 0 then return signed_speed_to_dir(self.velocity.x) end end - return self.horizontal_dir + -- if not moving, orientation decides priority + return self.orientation end --- return the position of the ground sensor in horizontal_dir when the character center is at center_position +-- return the position of the ground sensor in quadrant_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) +function player_char:_get_ground_sensor_position_from(center_position, quadrant_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, self:get_center_height()) + -- ignore subpixels from center position in qx (collision checks use Sonic's integer position, + -- but we keep exact qy coordinate to get the exact ground sensor qy, and thus exact distance to ground) + local x = center_position.x + local y = center_position.y + + -- vertical: up (1) and down (3) + if self.quadrant % 2 == 1 then + x = flr(x) + else + y = flr(y) + end + -- from character center, move down by center height to get the character bottom center + local qx_floored_bottom_center = vector(x, y) + self:get_center_height() * self:get_quadrant_down() -- using a ground_sensor_extent_x in .5 and flooring +/- this value allows us to get the checked column x (the x corresponds to the left of that column) - local offset_x = flr(horizontal_dir_signs[horizontal_dir] * pc_data.ground_sensor_extent_x) + -- rotate proper vector (initially horizontal) for quadrant compatibility, but make sure to apply coord flooring + -- *afterward* so it applies to the final coord and we don't rotate a +2.5 -> +2 into a -2 instead of having -3 + local offset_qx_vector = self:quadrant_rotated(vector(horizontal_dir_signs[quadrant_horizontal_dir] * pc_data.ground_sensor_extent_x, 0)) + -- brutal way to floor coordinates are rotation, without having to extract qx, recreating (qx, 0) vector and rotating again + offset_qx_vector = vector(flr(offset_qx_vector.x), flr(offset_qx_vector.y)) + + return qx_floored_bottom_center + offset_qx_vector +end + +-- helper method for _compute_signed_distance_to_closest_ground and _is_blocked_by_ceiling_at +-- it iterates over tiles from start to last (defined via offset from sensor position), providing distance from sensor_position_base + sensor_offset_qy along q-down (foot or head) +-- to q-column q-top (with reverse tile support) to a custom callback +-- pass it a quadrant of interest (direction used to check collisions), iteration start and last tile locations +local function iterate_over_collision_tiles(collision_check_quadrant, start_tile_offset_qy, last_tile_offset_qy, sensor_position_base, sensor_offset_qy, collider_distance_callback, no_collider_callback, ignore_reverse_on_start_tile) + -- get check quadrant down vector (for ceiling check, it's actually up relative to character quadrant) + local collision_check_quadrant_down = dir_vectors[collision_check_quadrant] + + -- apply sensor offset along check quadrant down (only used for ceiling, so actually upward to get head top position) + local sensor_position = sensor_position_base + sensor_offset_qy * collision_check_quadrant_down + + assert(world.get_quadrant_x_coord(sensor_position, collision_check_quadrant) % 1 == 0, "iterate_over_collision_tiles: sensor_position qx must be floored, found "..sensor_position) + + -- deduce start and last tile from offset from the sensor position + -- always oriented with check quadrant (by convention we check from q-top to q-bottom) + -- p8tool has a bug that prevents support of (complex expression):method() syntax (although PICO-8 does support it) + -- so we play on the fact that method = function bound to self and just write the .static_method(self) syntax (same token count) + local start_tile_loc = vector.to_location(sensor_position + start_tile_offset_qy * collision_check_quadrant_down) + local last_tile_loc = vector.to_location(sensor_position + last_tile_offset_qy * collision_check_quadrant_down) + + -- precompute start tile topleft (we're actually only interested in sensor location topleft, + -- and both have the same qx) + local start_tile_topleft = start_tile_loc:to_topleft_position() + + -- we iterate on tiles along quadrant down, so just convert it to tile_vector + -- to allow step addition + local tile_loc_step = tile_vector(collision_check_quadrant_down.x, collision_check_quadrant_down.y) + + -- we *always* iterate on columns from left to right, rows from top to bottom, + -- and columns/rows are stored exactly like that in collision data (not CCW or anything) + -- so unlike other operations, the subtraction from topleft (combined with qx coord) is correct + -- to get column index for qcolumn height later, without the need to quadrant-rotate vectors first + -- note that we use start_tile_topleft instead of the sensor_position:to_location():to_topleft_position() + -- they may differ on qy (ceiling iteration starts a little higher than sensor position) + -- but they have the same qx, so the operation is valid, and equivalent to using sensor location topleft, + -- but with fewer tokens as we don't need the extra conversion + local qcolumn_index0 = world.get_quadrant_x_coord(sensor_position - start_tile_topleft, collision_check_quadrant) -- from 0 to tile_size - 1 + + -- start iteration from start_tile_loc + local curr_tile_loc = start_tile_loc:copy() + + -- keep looping until callback is satisfied (in general we found a collision or neary ground) + -- or we've reached the last tile + while true do + -- Ceiling ignore reverse full tiles on first tile. Comment from _is_column_blocked_by_ceiling_at + -- before extracting iterate_over_collision_tiles + -- on the first tile, we don't cannot really be blocked by a ground + -- with the same interior direction as quadrant <=> opposite to quadrant_opp + -- (imagine Sonic standing on a half-tile; this definitely cannot be ceiling) + -- so we do not consider the reverse collision with full tile_size q-height with them + -- if you're unsure, try to force-set this to false and you'll see utests like + -- '(1 ascending slope 45) should return false for sensor position on the left of the tile' + -- failing + local ignore_reverse = ignore_reverse_on_start_tile and start_tile_loc == curr_tile_loc + + -- get q-bottom of tile to compare heights easily later + -- when iterating q-upward (ceiling check) this is actually a q-top from character's perspective + local current_tile_qbottom = world.get_tile_qbottom(curr_tile_loc, collision_check_quadrant) + + -- check for ground (by q-column) in currently checked tile, at sensor qX + local qcolumn_height, slope_angle = world._compute_qcolumn_height_at(curr_tile_loc, qcolumn_index0, collision_check_quadrant, ignore_reverse) + + -- a q-column height of 0 doesn't mean that there is ground just below relative offset qy = 0, + -- but that the q-column is empty and we don't know what is more below + -- so don't do anything yet but check for the tile one level lower + -- (unless we've reached end of iteration with the last tile, in which case + -- the next tile would be too far to snap down anyway) + if qcolumn_height > 0 then + -- signed distance to closest ground/ceiling is positive when q-above ground/q-below ceiling + -- PICO-8 Y sign is positive up, so to get the current relative height of the sensor + -- in the current tile, you need the opposite of (quadrant-signed) (sensor_position.qy - current_tile_qbottom) + -- then subtract qcolumn_height and you get the signed distance to the current ground q-column + local signed_distance_to_closest_collider = world.sub_qy(current_tile_qbottom, world.get_quadrant_y_coord(sensor_position, collision_check_quadrant), collision_check_quadrant) - qcolumn_height + -- let caller decide how to handle the presence of collider + local result = collider_distance_callback(signed_distance_to_closest_collider, slope_angle) + + -- we cannot 2x return from a called function directly, so instead, we check if a result was returned + -- if so, we return from the caller + if result then + return result + end + + -- else (can only happen in _compute_signed_distance_to_closest_ground): ground has been found, but it is too far below character's q-feet + -- to snap q-down. This can only happen on the last tile we iterate on + -- (since it was computed to be at the snap q-down limit), + -- which means we will enter the "end of iteration" block below + assert(curr_tile_loc == last_tile_loc) + end + + -- since we only iterate on qj, we really only care about qj (which is i when quadrant is horizontal) + -- but it costed more token to define get_quadrant_j_coord than to just compare both coords + if curr_tile_loc == last_tile_loc then + -- let caller decide how to handle the end of iteration without finding any collider + return no_collider_callback() + end + + curr_tile_loc = curr_tile_loc + tile_loc_step + end +end + +-- actual body of _compute_signed_distance_to_closest_ground passed to iterate_over_collision_tiles +-- return nil if no clear result and we must continue to iterate (until the last tile) +local function ground_check_collider_distance_callback(signed_distance_to_closest_ground, slope_angle) + if signed_distance_to_closest_ground < -pc_data.max_ground_escape_height then + -- ground found, but character is too deep inside to snap q-up + -- return edge case (-pc_data.max_ground_escape_height - 1, 0) + -- the slope angle 0 allows to still have character stand straight (world) up visually, + -- but he's probably stuck inside the ground... + return motion.ground_query_info(-pc_data.max_ground_escape_height - 1, 0) + elseif signed_distance_to_closest_ground <= pc_data.max_ground_snap_height then + -- ground found, and close enough to snap up/down, return ground query info + -- to allow snapping + set slope angle + return motion.ground_query_info(signed_distance_to_closest_ground, slope_angle) + end +end - return x_floored_bottom_center + vector(offset_x, 0) +-- actual body of _compute_signed_distance_to_closest_ground passed to iterate_over_collision_tiles +local function ground_check_no_collider_callback() + -- end of iteration, and no ground found or too far below to snap q-down + -- return edge case for ground considered too far below + -- (pc_data.max_ground_snap_height + 1, nil) + return motion.ground_query_info(pc_data.max_ground_snap_height + 1, nil) end -- return (signed_distance, slope_angle) where: --- - signed distance to closest ground from floored sensor_position, +-- - signed distance to closest ground from sensor_position, -- either negative when (in abs, penetration height, clamped to max_ground_escape_height+1) -- or positive (actual distance to ground, clamped to max_ground_snap_height+1) -- if no closest ground is detected, this defaults to max_ground_snap_height+1 (character in the air) -- - slope_angle is the slope angle of the detected ground (whether character is touching it, above or below) -- the closest ground is detected in the range [-max_ground_escape_height-1, max_ground_snap_height+1] --- around the sensor_position.y, so it's easy to know if the character can step up/down, --- and so that it's meaningful to check for ceiling obstacles after the character did his best to step --- the test should be tile-insensitive so it is possible to detect step up/down in vertical-neighboring tiles -function player_char:_compute_signed_distance_to_closest_ground(sensor_position) - - assert(flr(sensor_position.x) == sensor_position.x, "player_char:_compute_signed_distance_to_closest_ground: sensor_position.x must be floored") - initial_y = flr(sensor_position.y) - - -- 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 = 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 = 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 - end - end - - -- return signed distance and slope angle (the latter may be nil) - return query_info +-- around the sensor_position's qy, so it's easy to know if the character can q-step up/down, +-- and so that it's meaningful to check for q-ceiling obstacles after the character did his best to step +-- the test should be tile-insensitive so it is possible to detect q-step up/down in vertical-neighboring tiles +function player_char:_compute_signed_distance_to_closest_ground(sensor_position) + assert(world.get_quadrant_x_coord(sensor_position, self.quadrant) % 1 == 0, "player_char:_compute_signed_distance_to_closest_ground: sensor_position qx must be floored") + + -- we used to flr sensor_position.y (would now be qy) at this point, + -- but actually collision checks don't mind the fractions + -- in addition, we will automatically get the correct signed distance to ground with fractional part! + + -- check the presence of a colliding q-column from q-top to q-bottom, with offset from -(max step up) to +(min step down) + -- because we work by q-columns and not pixels, we iterate over tiles directly, so deduce tile locations + -- from sensor + offset position (in qy) + -- we are effectively finding the tiles covered (even partially) by the q-vertical segment between the edge positions + -- where the character can snap up (escape) and snap down + return iterate_over_collision_tiles(self.quadrant, - (pc_data.max_ground_escape_height + 1), pc_data.max_ground_snap_height, sensor_position, 0, ground_check_collider_distance_callback, ground_check_no_collider_callback) end -- verifies if character is inside ground, and push him upward outside if inside but not too deep inside @@ -348,46 +529,54 @@ end function player_char:_check_escape_from_ground() local query_info = self:_compute_ground_sensors_signed_distance(self.position) local signed_distance_to_closest_ground, next_slope_angle = query_info.signed_distance, query_info.slope_angle - local should_escape = signed_distance_to_closest_ground < 0 and abs(signed_distance_to_closest_ground) <= pc_data.max_ground_escape_height - if should_escape then + if signed_distance_to_closest_ground <= 0 and - signed_distance_to_closest_ground <= pc_data.max_ground_escape_height then + -- character is either just touching ground (signed_distance_to_closest_ground == 0) + -- or inside ground: + -- - snap character up to ground top (it does nothing if already touching ground) + -- - set slope angle to new ground self.position.y = self.position.y + signed_distance_to_closest_ground - end - if signed_distance_to_closest_ground == 0 or should_escape then - -- character was either touching ground, or inside it and escaped - -- so update his slope angle - self.slope_angle = next_slope_angle + self:set_slope_angle_with_quadrant(next_slope_angle) end return signed_distance_to_closest_ground <= 0 end -- enter motion state, reset state vars appropriately +-- refactor: consider separate methods go_airborne or land +-- so you can pass more specific arguments +-- current, self.slope_angle must have been previously set when +-- entering ground state function player_char:_enter_motion_state(next_motion_state) if next_motion_state == motion_states.falling then -- we have just left the ground without jumping, enter falling state -- and since ground speed is now unused, reset it for clarity + self:set_slope_angle_with_quadrant(nil) self.ground_speed = 0 - self.slope_angle = nil self.should_jump = false elseif next_motion_state == motion_states.air_spin then -- we have just jumped, enter air_spin state -- and since ground speed is now unused, reset it for clarity + self:set_slope_angle_with_quadrant(nil) self.ground_speed = 0 - self.slope_angle = nil self.should_jump = false self.anim_spr:play("spin") elseif next_motion_state == motion_states.grounded then + -- Momentum: transfer part of velocity tangential to slope to ground speed (self.slope_angle must have been set previously) + self.ground_speed = self.velocity:dot(vector.unit_from_angle(self.slope_angle)) + self:_clamp_ground_speed() -- 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.anim_spr:play("idle") end + -- store previous compact state before changing motion state local was_compact = self:is_compact() + -- update motion state self.motion_state = next_motion_state + -- adjust center when switching compact mode if not was_compact and self:is_compact() then -- character became compact (e.g. crouching or start jumping), -- move it slightly down to keep center position continuity @@ -405,7 +594,7 @@ function player_char:_update_platformer_motion() -- (as in classic Sonic), but also apply an initial impulse if character starts idle and -- left/right is pressed just when jumping (to fix classic Sonic missing a directional input frame there) if self.motion_state == motion_states.grounded then - self:_check_jump() -- this may change the motion state to air_spin + self:_check_jump() -- this may change the motion state to air_spin and affect branching below end if self:is_grounded() then @@ -429,11 +618,11 @@ function player_char:_update_platformer_motion_grounded() -- update velocity based on new ground speed and old slope angle (positive clockwise and top-left origin, so +cos, -sin) -- we must use the old slope because if character is leaving ground (falling) -- this frame, new slope angle will be nil - self.velocity = self.ground_speed * vector(cos(self.slope_angle), -sin(self.slope_angle)) + self.velocity = self.ground_speed * vector.unit_from_angle(self.slope_angle) -- we can now update position and slope self.position = ground_motion_result.position - self.slope_angle = ground_motion_result.slope_angle + self:set_slope_angle_with_quadrant(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 @@ -450,7 +639,10 @@ function player_char:_update_platformer_motion_grounded() if self.ground_speed == 0 then self.anim_spr:play("idle") else - self.anim_spr:play("run", false, abs(self.ground_speed)) + -- for the run playback speed, we don't follow the SPG which uses flr(max(0, 8-abs(self.ground_speed))) + -- instead, we prefer the more organic approach of continuous playback speed + -- however, to simulat the max duration clamping, we use min playback speed clamping + self.anim_spr:play("run", false, max(pc_data.run_anim_min_play_speed, abs(self.ground_speed))) end end @@ -465,23 +657,54 @@ end -- update ground speed function player_char:_update_ground_speed() - -- we need to update ground speed by intention first so accel/decel is handled correctly - -- otherwise, when starting at speed 0 on an ascending slope, gravity changes speed to < 0 - -- and a right move intention would be handled as a decel, which is fast enough to climb up - -- even the highest slopes - -- FIXME: but that broke low slopes since we apply friction for nothing, - -- then the slope factor... - -- Instead, we need to synthetize by applying the slope factor in the background, - -- then the move intention but not based on the intermediate speed but the old speed (or better, the horizontal dir) - self:_update_ground_speed_by_intention() + -- We apply slope factor *before* move intention because it gives + -- better results when not moving on a low slope (friction will stop you completely). + -- Another side effect is that the ground speed *after* slope factor application + -- will be considered for the move intention effect, such as decelerating + -- when moving forward on an ascending slope if it started make you move down. + -- Also, if ground speed is 0 and we start trying to ascend slope, + -- Progressive Ascending Steep Slope Factor feature won't be applied the first frame. + -- But it should be OK overall. + -- Note that this order is supported by the SPG (http://info.sonicretro.org/SPG:Solid_Tiles) self:_update_ground_speed_by_slope() + self:_update_ground_speed_by_intention() self:_clamp_ground_speed() end -- update ground speed based on current slope function player_char:_update_ground_speed_by_slope() + local is_ascending_slope = false + if self.slope_angle ~= 0 then - self.ground_speed = self.ground_speed - pc_data.slope_accel_factor_frame2 * sin(self.slope_angle) + -- Original feature (not in SPG): Progressive Ascending Steep Slope Factor + -- If character is ascending a slope, do not apply the full slope factor immediately. + -- Instead, linearly increase the applied slope factor from 0 to full over a given duration. + -- We use the ground speed before applying intention to avoid exploid of spamming + -- the left/right (ascending) input to restart the timer thanks to the ground speed + -- being slightly increased by the intention, but actually countered by slope accel in the same frame. + -- Effect: the character can completely cross steep but short slopes + -- Resolves: character was suddenly stopped by longer slopes when starting ascension with low momentum, + -- falling back to the flat ground behind, and repeating, causing a glitch-like oscillation + local ascending_slope_factor = 1 + -- make sure to compare sin in abs value (steep_slope_min_angle is between 0 and 0.25 so we know its sin is negative) + -- since slope angle is module 1 and cannot be directly compared (and you'd need to use (slope_angle + 0.5) % 1 - 0.5 to be sure) + if self.ground_speed ~= 0 and abs(sin(self.slope_angle)) >= sin(-pc_data.steep_slope_min_angle) and sgn(self.ground_speed) ~= sgn(sin(self.slope_angle)) then + is_ascending_slope = true + local ascending_slope_duration = pc_data.progressive_ascending_slope_duration + local progressive_ascending_slope_factor = 1 + -- increase tracking time every frame + self.ascending_slope_time = min(self.ascending_slope_time + delta_time60, ascending_slope_duration) + ascending_slope_factor = self.ascending_slope_time / ascending_slope_duration + end + + -- slope angle is mostly defined with atan2(dx, dy) which follows top-left origin BUT counter-clockwise angle convention + -- sin also follows this convention, so ultimately + is OK + self.ground_speed = self.ground_speed + ascending_slope_factor * pc_data.slope_accel_factor_frame2 * sin(self.slope_angle) + end + + if not is_ascending_slope then + -- reset ascending slope time + self.ascending_slope_time = 0 end end @@ -492,28 +715,54 @@ function player_char:_update_ground_speed_by_intention() 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 + -- face move direction if not already + self.orientation = signed_speed_to_dir(self.move_intention.x) else + -- Original feature (not in SPG): Reduced Deceleration on Steep Descending Slope + -- Apply a fixed factor + -- Effect: a character descending a steep slope will take more time to brake than if + -- considering slope factor alone + -- Resolves: character descending a steep slope was braking and turning back too suddenly + local ground_decel_factor = 1 + -- make sure to compare sin in abs value (steep_slope_min_angle is between 0 and 0.25 so we know its sin is negative) + -- since slope angle is module 1 and cannot be directly compared (and you'd need to use (slope_angle + 0.5) % 1 - 0.5 to be sure) + if abs(sin(self.slope_angle)) >= sin(-pc_data.steep_slope_min_angle) and sgn(self.ground_speed) == sgn(sin(self.slope_angle)) then + -- character is trying to brake on a descending slope + ground_decel_factor = pc_data.ground_decel_descending_slope_factor + end + -- decelerate - self.ground_speed = self.ground_speed + self.move_intention.x * pc_data.ground_decel_frame2 - -- if speed must switch sign this frame, clamp it by ground accel in absolute value to prevent exploit of - -- moving back 1 frame then forward to gain an initial speed boost (mentioned in Sonic Physics Guide as a bug) + self.ground_speed = self.ground_speed + self.move_intention.x * ground_decel_factor * pc_data.ground_decel_frame2 + -- check if speed has switched sign this frame, i.e. character has turned around local has_changed_sign = self.ground_speed ~= 0 and sgn(self.ground_speed) == sgn(self.move_intention.x) - if has_changed_sign and abs(self.ground_speed) > pc_data.ground_accel_frame2 then - 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) + if has_changed_sign then + -- clamp speed after turn around by ground accel in absolute value to prevent exploit of + -- moving back 1 frame then forward to gain an initial speed boost (mentioned in Sonic Physics Guide as a bug) + if abs(self.ground_speed) > pc_data.ground_accel_frame2 then + self.ground_speed = sgn(self.ground_speed) * pc_data.ground_accel_frame2 + end + -- turn around + self.orientation = signed_speed_to_dir(self.move_intention.x) + end 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) + -- no move intention, character is passive + + -- Original feature (not in SPG): No Friction on Steep Descending Slope + -- Do not apply friction when character is descending a steep slope passively; + -- In other words, apply it only on flat ground, low slope and only steep slopes if ascending + -- Effect: the character will automatically run down a steep slope and accumulate acceleration downward + -- without friction + -- Resolves: the character was moving down a steep slope very slowly because of friction + -- make sure to compare sin in abs value (steep_slope_min_angle is between 0 and 0.25 so we know its sin is negative) + -- since slope angle is module 1 and cannot be directly compared (and you'd need to use (slope_angle + 0.5) % 1 - 0.5 to be sure) + if abs(sin(self.slope_angle)) <= sin(-pc_data.steep_slope_min_angle) or sgn(self.ground_speed) ~= sgn(sin(self.slope_angle)) then + self.ground_speed = sgn(self.ground_speed) * max(0, abs(self.ground_speed) - pc_data.ground_friction_frame2) + end end + end -- clamp ground speed to max @@ -536,74 +785,125 @@ function player_char:_compute_ground_motion_result() false, false ) - end - local horizontal_dir = signed_speed_to_dir(self.ground_speed) + end - -- 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 + -- from here we will be considering positions, velocities, angles relatively + -- to the current quadrant to allow Sonic to walk on walls and ceilings + -- when quadrant is rotated by 0.25 (90 degrees CCW), the following transformations occur: + -- - ground move intention x <-> y (+x -> -y, -x -> +y, +y -> +x, -y -> -x) + -- ("intention" matters because we apply a forward rotation as Sonic will try to run on walls and ceilings + -- this is different from transposing an *existing* vector to another frame, which would have the backward (reverse) + -- transformation such as +x -> +y) + -- because the sign of x/y changes, the way we add values also matter, so in some cases + -- x + dx would become y - dy and a simple transposition is not enough + -- therefore, it is more reliable to add rotated vectors, even if only one component is non-zero, + -- and then extract x/y from this vector + -- we then call these coordinates "quadrant x" and "quadrant y", but note that they still + -- follow the positive axis sense of PICO-8 (only ground_speed and ground_based_signed_distance_qx are + -- based on ground orientation, CCW positive) + -- - existing slope angle -> slope angle - 0.25 + -- when quadrant is rotated by 0.5 (e.g. floor to ceiling), x <-> -x and y <-> -y + -- and slope angle -> slope angle - 0.5 (these ops are reflective so we don't need to care about reverse transformation as above) + -- a few examples of quadrant variables: + -- - quadrant horizontal direction: is it left or right from Sonic's point of view? + -- (on a left wall, moving up is "left" and moving down is "right" + -- on the ceiling, moving left is "right" and moving right is "left") + -- - quadrant horizontal axis: horizontal for quadrants up and down, vertical for quadrants left and right + -- (we also define the forward as the counter-clockwise direction in any case, e.g. right on quadrant down + -- and down on quadrant left) + -- - quadrant vertical axis: orthogonal to quadrant horizontal axis + -- (we also define "up" as the direction pointing outside the quadrant interior) + -- - quadrant height: the collision mask column height, in the quadrant's own frame + -- (when quadrant is left or right, this is effectively a row width, where the row extends from left/right resp.) + -- - quadrant slope angle: the slope angle subtracted by the quadrant's angle (quadrant down having angle 0, then steps of 0.25 counter-clockwise) + -- - quadrant columns are rows on walls + -- we prefix values with "q" or "q-" for "quadrant", e.g. "qx" and "qy" + -- we even name floors, walls and ceilings "q-wall" to express the fact they are blocking Sonic's motion + -- relatively to his current quadrant, acting as walls, but may be any solid tile + + -- initialise result with floored coords, it's not to easily visualize + -- pixel by pixel motion at integer coordinates (we will reinject subpixels + -- if character didn't touch a wall) + -- we do this on both coordinates to simplify, but note that Sonic always snaps + -- to the ground quadrant height, so the quadrant vertical coordinate (qy) is already integer, + -- so it really matters for qx (but to reduce tokens we don't add a condition based on quadrant) + -- note that quadrant left and right motion is not completely symmetrical + -- since flr is asymmetrical so there may be up to a 1px difference in how we hit stuff on + -- the left or right (Classic Sonic has a collider with odd width, it may be actually symmetrical + -- on collision checks) local floored_x = flr(self.position.x) + local floored_y = flr(self.position.y) local motion_result = motion.ground_motion_result( - vector(floored_x, self.position.y), + vector(floored_x, floored_y), self.slope_angle, false, false ) - -- 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 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) + local quadrant = self.quadrant + local quadrant_horizontal_dir = signed_speed_to_dir(self.ground_speed) + local qx = world.get_quadrant_x_coord(self.position, quadrant) - -- 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 + -- only full pixels matter for collisions, but subpixels (of last position + delta motion) + -- may sum up to a full pixel, + -- so first estimate how many full pixel columns the character may actually explore this frame + local ground_based_signed_distance_qx = self.ground_speed * cos(self.slope_angle - world.quadrant_to_right_angle(quadrant)) + -- but ground_based_signed_distance_qx is positive when walking a right wall up or ceiling left, + -- which is opposite of the x/y sign convention; project on quadrant right unit vector to get vector + -- with x/y with the correct sign for addition to x/y position later + local ground_velocity_projected_on_quadrant_right = ground_based_signed_distance_qx * self:get_quadrant_right() + -- equivalent to dot expression below, but more compact than it: + -- local ground_velocity_projected_on_quadrant_right = quadrant_right:dot(self.ground_speed * vector.unit_from_angle(self.slope_angle)) * quadrant_right + local projected_velocity_qx = world.get_quadrant_x_coord(ground_velocity_projected_on_quadrant_right, quadrant) + + -- max_distance_qx is always integer + local max_distance_qx = player_char._compute_max_pixel_distance(qx, projected_velocity_qx) + + -- iterate pixel by pixel on the qx direction until max possible distance is reached + -- only stopping if the character is blocked by a q-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_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_before_step = column_distance_before_step + 1 + local qhorizontal_distance_before_step = 0 + while qhorizontal_distance_before_step < max_distance_qx and not motion_result.is_blocked do + self:_next_ground_step(quadrant_horizontal_dir, motion_result) + qhorizontal_distance_before_step = qhorizontal_distance_before_step + 1 end -- check if we need to add or cut subpixels if not motion_result.is_blocked then - -- 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 + -- since subpixels are always counted to the right/down, the subpixel test below is asymmetrical + -- but this is correct, we will simply move backward a bit when moving left/up + local are_subpixels_left = qx + projected_velocity_qx > world.get_quadrant_x_coord(motion_result.position, quadrant) if are_subpixels_left then -- character has not been blocked and has some subpixels left to go - -- 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, + -- unlike Classic Sonic, and *only* when moving right/down, we decide to check if those + -- subpixels would leak to hitting a q-wall on the right/down, 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 + -- when moving left/up, the subpixels are a small "backward" motion to the right/down and should -- never hit a wall back local is_blocked_by_extra_step = false - if signed_distance_x > 0 then + if projected_velocity_qx > 0 then local extra_step_motion_result = motion_result:copy() - self:_next_ground_step(horizontal_dir, extra_step_motion_result) + self:_next_ground_step(quadrant_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 + -- unless moving right/down and hitting a q-wall due to subpixels, apply the remaining subpixels + -- as they cannot affect collision anymore. when moving left/up, 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) + -- character has not touched a q-wall at all, so add the remaining subpixels + -- (it's simpler to just recompute the full motion in qx; don't touch qy though, + -- as it depends on the shape of the ground - we floored it earlier but it should + -- have been integer from the start so it shouldn't have changed anything) -- 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:get(coord) - -- sign(signed_distance_x) * (max_distance_x - distance_to_floored_x) - motion_result.position.x = self.position.x + signed_distance_x + world.set_position_quadrant_x(motion_result.position, qx + projected_velocity_qx, quadrant) end end end @@ -611,7 +911,7 @@ function player_char:_compute_ground_motion_result() return motion_result end --- return the number of new pixel columns explored when moving from initial_position_coord (x or y) +-- return the number of new pixel q-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) @@ -623,26 +923,45 @@ function player_char._compute_max_pixel_distance(initial_position_coord, velocit end -- 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 +-- by 1 pixel step in quadrant_horizontal_dir, taking obstacles into account -- if character is blocked, it doesn't update the position and flag is_blocked -- if character is falling, it updates the position and flag is_falling --- ground_motion_result.position.x should be floored for these steps +-- ground_motion_result.position's qx should be floored for these steps -- (some functions assert when giving subpixel coordinates) -function player_char:_next_ground_step(horizontal_dir, ref_motion_result) +function player_char:_next_ground_step(quadrant_horizontal_dir, ref_motion_result) + log(" _next_ground_step: "..joinstr(", ", quadrant_horizontal_dir, ref_motion_result), "trace") + -- compute candidate position on next step. only flat slopes supported - local step_vec = horizontal_dir_vectors[horizontal_dir] + local step_vec = self:quadrant_rotated(horizontal_dir_vectors[quadrant_horizontal_dir]) local next_position_candidate = ref_motion_result.position + step_vec + log("step_vec: "..step_vec, "trace") + log("next_position_candidate: "..next_position_candidate, "trace") + -- check if next position is inside/above ground 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 - if signed_distance_to_closest_ground < 0 then + + log("signed_distance_to_closest_ground: "..signed_distance_to_closest_ground, "trace") + + -- signed distance is useful, but for quadrant vector ops we need actual vectors + -- to get the right signs (e.g. on floor, signed distance > 0 <=> offset dy < 0 from ground, + -- but on left wall, signed distance > 0 <=> offset dx > 0) + -- signed distance is from character to ground, so get unit vector for quadrant down + local vector_to_closest_ground = signed_distance_to_closest_ground * self:get_quadrant_down() + + -- merge < 0 and == 0 cases together to spare tokens + -- when 0, next_position_candidate.y will simply not change + if signed_distance_to_closest_ground <= 0 then -- position is inside ground, check if we can step up during this step - local penetration_height = - signed_distance_to_closest_ground - if penetration_height <= pc_data.max_ground_escape_height then - -- 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) + -- (note that we kept the name max_ground_escape_height but in quadrant left and right, + -- the escape is done on the X axis so technically we escape row width) + -- refactor: code is similar to _check_escape_from_ground and above all _next_air_step + if - signed_distance_to_closest_ground <= pc_data.max_ground_escape_height then + -- step up or step flat + next_position_candidate:add_inplace(vector_to_closest_ground) + -- if we left the ground during a previous step, cancel that + -- (fall, then touch ground or step up to land, very rare) ref_motion_result.is_falling = false else -- step blocked: step up is too high, character is blocked @@ -652,43 +971,44 @@ function player_char:_next_ground_step(horizontal_dir, ref_motion_result) end elseif signed_distance_to_closest_ground > 0 then -- position is above ground, check if we can step down during this step + -- (step down is during ground motion only) if signed_distance_to_closest_ground <= pc_data.max_ground_snap_height then -- step down - next_position_candidate.y = next_position_candidate.y + signed_distance_to_closest_ground + next_position_candidate:add_inplace(vector_to_closest_ground) -- if character left the ground during a previous step, cancel that (step down land, very rare) 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 - -- caution: we are not updating y at all, which means the character starts + -- caution: we are not updating qy 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) + -- momentum to take over and send the character along qy, preserving + -- velocity qvy from last frame + -- so when adding momentum, consider reusing the last delta qy (e.g. vector_to_closest_ground.y) -- 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) - ref_motion_result.is_falling = false end if not ref_motion_result.is_blocked then - -- 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 + -- character is not blocked by a steep q-step up/q-wall, but we need to check if it is + -- blocked by a q-ceiling too low; in the extreme case, a diagonal tile pattern -- ->X -- X + -- is also considered a ceiling and ignoring it will let Sonic go through and fall -- (unlike Classic Sonic, we do check for ceilings even when Sonic is grounded; - -- this case rarely happens in normally constructed levels though) + -- this case rarely happens in normally constructed levels though; and q-ceilings + -- even more rare) 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 + -- this only works because the q-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 (and adapt y) + -- in addition, because a step is no more than 1px, if we were blocked this step + -- we have not moved at all and therefore there is no need to update slope angle if not ref_motion_result.is_blocked then ref_motion_result.position = next_position_candidate if ref_motion_result.is_falling then @@ -718,6 +1038,26 @@ function player_char:_is_blocked_by_ceiling_at(center_position) return false end + +-- actual body of _is_column_blocked_by_ceiling_at passed to iterate_over_collision_tiles +-- return nil if no clear result and we must continue to iterate (until the last tile) +-- slope_angle is not used, so we aggressively remove it to gain 1 token +local function ceiling_check_collider_distance_callback(signed_distance_to_closest_ceiling) --, slope_angle) + if signed_distance_to_closest_ceiling < 0 then + -- head (or feet) inside ceiling + return true + else + -- head far touching ceiling or has some gap from ceiling + return false + end +end + +-- actual body of _compute_signed_distance_to_closest_ceiling passed to iterate_over_collision_tiles +local function ceiling_check_no_collider_callback() + -- end of iteration, and no ceiling found + return false +end + -- return true iff there is a ceiling above in the column of sensor_position, in a tile above -- sensor_position's tile, within a height lower than a character's height -- note that we return true even if the detected obstacle is lower than one step up's height, @@ -725,51 +1065,28 @@ end -- sensor_position would be the resulting position, so only higher tiles will be considered -- 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_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() - local sensor_location_topleft = curr_tile_loc:to_topleft_position() - local column_index0 = sensor_position.x - sensor_location_topleft.x -- from 0 to tile_size - 1 - - while true do - - -- move 1 tile up from the start, as we can only hit ceiling from a tile above with non-rotated tiles - -- note: when we add rotated tiles, we will need to handle ceiling tiles (tiles rotated by 180) - -- starting from the current tile, because unlike ground tiles, they may actually block - -- the character's head despite being in his current tile location - -- so we'll need to move the decrement statement to the end of the loop and add a tile rotation check - -- in addition we'll need to _compute_column_bottom_height_at() to handle variable ceiling height along a tile - -- rather than just checking if _compute_column_height_at() > 0 - -- to avoid tile rotation check, we can also check if _compute_column_bottom_height_at() is lower than the feet (so we can ignore it) - -- (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 >= self:get_full_height() 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 - -- with non-rotated tiles, we are sure to hit the ceiling at this point - -- because ceiling is always at a tile bottom, and we return false - -- as soon as we go up farther than a character's height - return true - -- 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 height_distance = sensor_position.y - current_tile_bottom - -- return height_distance < self:get_full_height() - end - - end - + assert(world.get_quadrant_x_coord(sensor_position, self.quadrant) % 1 == 0, "player_char:_is_column_blocked_by_ceiling_at: sensor_position qx must be floored") + + -- oppose_dir since we check ceiling by detecting tiles q-above, and their q-column height matters + -- when measured from the q-top (e.g. if there's a top half-tile maybe character head is not hitting it + -- depending on the exact distance; if q-bottom based, it's considered reverse so full q-height and character + -- head will hit it as soon as it enters the tile) + + -- top must be q-above bottom or we will get stuck in infinite loop + -- (because to reduce tokens we compare locations directly instead of sub_qy(curr_tile_qj, last_tile_qy, quadrant_opp) >= 0 + -- which would ensure loop end) + + -- we must at least start checking ceiling 1 px above foot sensor (because when foot is just on top of tile, + -- the current sensor tile is actually the tile *below* the character, which is often a full tile and will bypass + -- ignore_reverse (see world._compute_qcolumn_height_at); in practice +4/+8 is a good offset, we pick max_ground_escape_height + 1 = 5 + -- because it allows us to effectively check the q-higher pixels not already checked in _compute_signed_distance_to_closest_ground) + + -- finally, we check actual collision at head top position, so we pass an offset of self:get_full_height() (argument 5) + -- from here, we need: + -- - (max_ground_escape_height + 1 - full_height) offset for first tile according to explanation above + the fact that we consider this offset from sensor_position base + offset (full_height) + -- - no offset for last tile since we end checking at head top exactly, so argument 3 is 0 + local full_height = self:get_full_height() + return iterate_over_collision_tiles(oppose_dir(self.quadrant), pc_data.max_ground_escape_height + 1 - full_height, 0, sensor_position, full_height, ceiling_check_collider_distance_callback, ceiling_check_no_collider_callback, --[[ignore_reverse_on_start_tile:]] true) end -- if character intends to jump, prepare jump for next frame @@ -795,8 +1112,11 @@ function player_char:_check_jump() -- 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 + -- to support slopes, we use the ground normal (rotate right tangent ccw) + -- we don't have double jumps yet so we assume we are grounded here and + -- self.slope_angle is not nil + local jump_impulse = pc_data.initial_var_jump_speed_frame * vector.unit_from_angle(self.slope_angle):rotated_90_ccw() + self.velocity:add_inplace(jump_impulse) self:_enter_motion_state(motion_states.air_spin) self.has_jumped_this_frame = true return true @@ -814,16 +1134,26 @@ function player_char:_update_platformer_motion_airborne() 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() + -- only allow jump interrupt if character has jumped on its own (no fall) + -- there is no has_jumped flag so the closest is to check for air_spin + if self.motion_state == motion_states.air_spin then + -- 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() + end 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 -- in the air, apply intended motion to direction immediately - self.horizontal_dir = signed_speed_to_dir(self.move_intention.x) + self.orientation = signed_speed_to_dir(self.move_intention.x) + end + + self:apply_air_drag() + + if self.velocity.y > pc_data.max_air_velocity_y then + self.velocity.y = pc_data.max_air_velocity_y end -- apply air motion @@ -841,7 +1171,7 @@ function player_char:_update_platformer_motion_airborne() end if air_motion_result.is_landing then - self.slope_angle = air_motion_result.slope_angle + self:set_slope_angle_with_quadrant(air_motion_result.slope_angle) self:_enter_motion_state(motion_states.grounded) end @@ -866,6 +1196,14 @@ function player_char:_check_hold_jump() end end +function player_char:apply_air_drag() + local vel = self.velocity -- ref + if vel.y < 0 and vel.y > - pc_data.air_drag_max_abs_velocity_y and + abs(vel.x) >= pc_data.air_drag_min_velocity_x then + vel.x = vel.x * pc_data.air_drag_factor_per_frame + 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 @@ -874,7 +1212,7 @@ end function player_char:_compute_air_motion_result() -- if character is not moving, he is not blocked nor landing (we assume the environment is static) -- this is pretty rare in the air, but could happen when being pushed upward by fans - if self.velocity == vector.zero() then + if self.velocity:is_zero() then return motion.air_motion_result( self.position, false, @@ -897,13 +1235,22 @@ function player_char:_compute_air_motion_result() -- 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") + -- and 1 way without iteration: + -- d. compute final position of air motion at the end of the frame, and escape from x and y if needed + + -- We choose b. which is precise enough while always finishing with a potential landing + -- Initially we used c., but Sonic tended to fly above descending slopes as the X motion was applied + -- after Y motion, including snapping, causing a ladder-shaped motion above the slope where the final position + -- was always above the ground. + -- Note, however, that this is a temporary fix: where we add quadrants, X and Y will have more symmetrical roles + -- and we can expect similar issues when trying to land with high speed adherence on a 90-deg wall. + -- Ultimately, I think it will work better with either d. or an Unreal-style multi-mode step approach + -- (i.e. if landing in the middle of the Y move, finish the remaining part of motion as grounded, + -- following the ground as usual). self:_advance_in_air_along(motion_result, self.velocity, "x") log("=> "..motion_result, "trace") + self:_advance_in_air_along(motion_result, self.velocity, "y") + log("=> "..motion_result, "trace") return motion_result end @@ -1006,7 +1353,8 @@ function player_char:_next_air_step(direction, ref_motion_result) 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 + -- we can only hit walls or the ground when stepping left, right or down + -- (horizontal step of diagonal upward motion is OK) 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 @@ -1016,41 +1364,42 @@ function player_char:_next_air_step(direction, ref_motion_result) 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 - -- however, if we want to allow jump from an ascending sheer angle directly onto a platform, - -- as suggested by the SPG (http://info.sonicretro.org/SPG:Solid_Tiles#Ceiling_Sensors_.28C_and_D.29) - -- where ground detection from the air is done when moving downward or when moving upward, but faster horizontally - -- than vertically, then we would not only need to add the x vs y spd check but also enable *snap* to ground - -- from the last step position, since we may miss a few pixels from here to reach the ground - -- (otherwise, the obstacle may was well be considered as a wall, as we're doing now) - -- Then we would have a check more symmetrical to below, with - -- `if self.velocity.y > 0 or abs(self.velocity.x) > abs(self.velocity.y)` - -- 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") + -- Check if the character has hit a ground or a wall + -- First, following SPG (http://info.sonicretro.org/SPG:Solid_Tiles#Ceiling_Sensors_.28C_and_D.29), + -- allow jump from an ascending sheer angle directly onto a platform. This includes moving horizontally. + -- This must be combined with a step up (snap to ground top, but directly from the air) to really work + if self.velocity.y > 0 or abs(self.velocity.x) > abs(self.velocity.y) then + -- check if we are touching or entering ground + if signed_distance_to_closest_ground <= 0 then + -- Just like during ground step, check the step height: if too high, we hit a wall and stay airborne + -- else, we land + -- This step up check is really important, even for low slopes: + -- if not done, when Sonic lands on an ascending slope, it will consider the few pixels up + -- to be a wall! + -- I used to check direction == directions.down only, and indeed if you step 1px down, + -- the penetration distance will be no more than 1 and you will always snap to ground. + -- But this didn't work when direction left/right hit the slope. + -- refactor: code is similar to _check_escape_from_ground and above all _next_ground_step + if - signed_distance_to_closest_ground <= pc_data.max_ground_escape_height then + next_position_candidate.y = next_position_candidate.y + signed_distance_to_closest_ground + -- 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 + -- below deprecated if we <= 0 check + -- 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, ref_motion_result.slope_angle = true, next_slope_angle + log("is landing, setting slope angle to "..next_slope_angle, "trace") + 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, ref_motion_result.slope_angle = false--, nil 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 @@ -1062,11 +1411,23 @@ function player_char:_next_air_step(direction, ref_motion_result) -- then there is no need to check further, though -- The SPG (http://info.sonicretro.org/SPG:Solid_Tiles#Ceiling_Sensors_.28C_and_D.29) -- remarks that ceiling detection is done when moving upward or when moving faster horizontally than vertically + -- (this includes moving horizontally) -- Since it's just for this extra test, we check self.velocity directly instead of passing it as argument -- Note that we don't check the exact step direction, if we happen to hit the ceiling during -- the X motion, that's fine. -- In practice, when approaching a ceiling from a descending direction with a sheer horizontal angle, -- we will hit the block as a wall first; but that's because we consider blocks as wall and ceilings at the same time. + -- If we wanted to be symmetrical with floor check above, we would need to call some check_escape_from_ceiling + -- to snap Sonic slightly down when only hitting the wall by a few pixels, so character can continue moving horizontally + -- under the ceiling, touching it at the beginning. But it doesn't seem to happen in Classic Sonic so we don't implement + -- it unless our stage has ceilings where this often happens and it annoys the player. + -- UPDATE: it doesn't seem to reliable as itest platformer slope ceiling block right + -- would fail by considering character blocked by ascending slope above nothing + -- I'm not sure why that itest used to work, but if having issues with this, + -- add an extra check on ground step if no pixel is found (and extactly at a tile bottom) + -- to see if there is not a collision pixel 1px above (should be on another tile above) + -- and from here compute the actual ground distance... of course, always add supporting ground + -- tile under a ground tile when possible if not ref_motion_result.is_blocked_by_wall and (self.velocity.y < 0 or abs(self.velocity.x) > abs(self.velocity.y)) then local is_blocked_by_ceiling_at_next = self:_is_blocked_by_ceiling_at(next_position_candidate) @@ -1080,7 +1441,9 @@ function player_char:_next_air_step(direction, ref_motion_result) -- 4-quadrant note: if moving diagonally downward, this will actually correspond to the SPG case -- mentioned above where ysp >= 0 but abs(xsp) > abs(ysp) -- in this case, we are really detecting the *ceiling*, but Sonic can also start running on it + -- we should actually test the penetration distance is a symmetrical way to ground, not just the direction ref_motion_result.is_blocked_by_wall = true + log("is blocked by ceiling as wall", "trace") end end end @@ -1104,18 +1467,16 @@ function player_char:_update_debug() -- it's much more complicated to access app from here (e.g. via flow.curr_state) -- just to get delta_time, so we just use the constant as we know we are at 60 FPS -- otherwise we'd have to change utests to init app+flow each time - self:move_by(self.debug_velocity * delta_time60) + self.position = self.position + self.debug_velocity * delta_time60 end function player_char:_update_velocity_debug() -- update velocity from input -- in debug mode, cardinal speeds are independent and max speed applies to each - self:_update_velocity_component_debug("x") - self:_update_velocity_component_debug("y") + self:_update_velocity_component_debug "x" + 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) @@ -1132,10 +1493,14 @@ function player_char:_update_velocity_component_debug(coord) end end +--#endif + -- render the player character sprite at its current position function player_char:render() - local flip_x = self.horizontal_dir == horizontal_dirs.left - self.anim_spr:render(self.position, flip_x) + local flip_x = self.orientation == horizontal_dirs.left + -- for now, no snapping, follow slope a la Freedom Planet / Sonic Mania + local sprite_angle = self.slope_angle + self.anim_spr:render(self.position, flip_x, false, sprite_angle) end return player_char diff --git a/src/ingame/playercharacter_utest.lua b/src/ingame/playercharacter_utest.lua index 529137fa..de6dc42a 100644 --- a/src/ingame/playercharacter_utest.lua +++ b/src/ingame/playercharacter_utest.lua @@ -1,10 +1,10 @@ -require("engine/test/bustedhelper") -require("engine/core/math") +require("test/bustedhelper") local animated_sprite = require("engine/render/animated_sprite") local player_char = require("ingame/playercharacter") local input = require("engine/input/input") local motion = require("platformer/motion") +local world = require("platformer/world") local ground_query_info = motion.ground_query_info local pc_data = require("data/playercharacter_data") local tile_test_data = require("test_data/tile_test_data") @@ -133,12 +133,16 @@ describe('player_char', function () control_modes.human, motion_modes.platformer, motion_states.grounded, + directions.down, horizontal_dirs.right, + vector.zero(), 0, vector.zero(), vector.zero(), 0, + 0, + vector.zero(), false, false, @@ -150,13 +154,15 @@ describe('player_char', function () pc.control_mode, pc.motion_mode, pc.motion_state, - pc.horizontal_dir, + pc.quadrant, + pc.orientation, pc.position, pc.ground_speed, pc.velocity, pc.debug_velocity, pc.slope_angle, + pc.ascending_slope_time, pc.move_intention, pc.jump_intention, @@ -245,6 +251,81 @@ describe('player_char', function () end) + describe('get_quadrant_right', function () + + it('should return vector(1, 0) when quadrant is down', function () + pc.quadrant = directions.down + assert.are_same(vector(1, 0), pc:get_quadrant_right()) + end) + + it('should return vector(-1, 0) when quadrant is up', function () + pc.quadrant = directions.up + assert.are_same(vector(-1, 0), pc:get_quadrant_right()) + end) + + it('should return vector(0, -1) when quadrant is right', function () + pc.quadrant = directions.right + assert.are_same(vector(0, -1), pc:get_quadrant_right()) + end) + + it('should return vector(0, 1) when quadrant is left', function () + pc.quadrant = directions.left + assert.are_same(vector(0, 1), pc:get_quadrant_right()) + end) + + end) + + describe('get_quadrant_down', function () + + it('should return vector(0, 1) when quadrant is down', function () + pc.quadrant = directions.down + assert.are_same(vector(0, 1), pc:get_quadrant_down()) + end) + + it('should return vector(0, -1) when quadrant is up', function () + pc.quadrant = directions.up + assert.are_same(vector(0, -1), pc:get_quadrant_down()) + end) + + it('should return vector(1, 0) when quadrant is right', function () + pc.quadrant = directions.right + assert.are_same(vector(1, 0), pc:get_quadrant_down()) + end) + + it('should return vector(-1, 0) when quadrant is left', function () + pc.quadrant = directions.left + assert.are_same(vector(-1, 0), pc:get_quadrant_down()) + end) + + end) + + describe('quadrant_rotated', function () + + it('should return same vector content when quadrant is down', function () + pc.quadrant = directions.down + assert.are_same(vector(1, -2), pc:quadrant_rotated(vector(1, -2))) + end) + + -- busted implementation is exact and should pass without almost_eq, + -- but they are useful when testing the pico8 implementation + + it('should return vector rotated by 0.25 when quadrant is right', function () + pc.quadrant = directions.right + assert.is_true(almost_eq_with_message(vector(-2, -1), pc:quadrant_rotated(vector(1, -2)))) + end) + + it('should return vector rotated by 0.5 when quadrant is up', function () + pc.quadrant = directions.up + assert.is_true(almost_eq_with_message(vector(-1, 2), pc:quadrant_rotated(vector(1, -2)))) + end) + + it('should return vector rotated by 0.75 when quadrant is left', function () + pc.quadrant = directions.left + assert.is_true(almost_eq_with_message(vector(2, 1), pc:quadrant_rotated(vector(1, -2)))) + end) + + end) + describe('spawn_at', function () setup(function () @@ -315,7 +396,7 @@ describe('player_char', function () it('should set the character\'s position', function () pc:warp_to(vector(56, 12)) - assert.are_equal(vector(56, 12), pc.position) + assert.are_same(vector(56, 12), pc.position) end) describe('(_check_escape_from_ground returns false)', function () @@ -406,9 +487,15 @@ describe('player_char', function () player_char.get_center_height:revert() end) - it('(10 0 3) => at (10 6)', function () + it('(10, 0) => (10, center_height)', function () + pc.position = vector(10, 0) + assert.are_same(vector(10, 11), pc:get_bottom_center()) + end) + + it('(10, 0) quadrant left => (10 - center_height, 0)', function () pc.position = vector(10, 0) - assert.are_equal(vector(10, 0 + 11), pc:get_bottom_center()) + pc.quadrant = directions.left + assert.are_same(vector(-1, 0), pc:get_bottom_center()) end) end) @@ -425,19 +512,87 @@ describe('player_char', function () player_char.get_center_height:revert() end) - it('set_bottom_center (10 6) => at (10 0)', function () - pc:set_bottom_center(vector(10, 0 + 11)) - assert.are_equal(vector(10, 0), pc.position) + it('set_bottom_center (10, center_height) => (10, 0)', function () + pc:set_bottom_center(vector(10, 11)) + assert.are_same(vector(10, 0), pc.position) + end) + + it('set_bottom_center (10 + center_height, 0) quadrant right => (10, 0)', function () + pc.quadrant = directions.right + pc:set_bottom_center(vector(10 + 11, 0)) + assert.are_same(vector(10, 0), pc.position) end) end) - describe('move_by', function () - it('at (4 -4) move_by (-5 4) => at (-1 0)', function () - pc.position = vector(4, -4) - pc:move_by(vector(-5, 4)) - assert.are_equal(vector(-1, 0), pc.position) + describe('set_slope_angle_with_quadrant', function () + + it('should set slope_angle to passed angle', function () + pc.slope_angle = 0.5 + pc:set_slope_angle_with_quadrant(0.25) + assert.are_equal(0.25, pc.slope_angle) + end) + + it('should set quadrant to down for slope_angle: nil', function () + pc.quadrant = nil + pc:set_slope_angle_with_quadrant(nil) + assert.are_equal(directions.down, pc.quadrant) + end) + + it('should set quadrant to down for slope_angle: 1-0.125', function () + pc.quadrant = nil + pc:set_slope_angle_with_quadrant(1-0.125) + assert.are_equal(directions.down, pc.quadrant) + end) + + it('should set quadrant to down for slope_angle: 0', function () + pc.quadrant = nil + pc:set_slope_angle_with_quadrant(0) + assert.are_equal(directions.down, pc.quadrant) + end) + + it('should set quadrant to down for slope_angle: 0.125', function () + pc.quadrant = nil + pc:set_slope_angle_with_quadrant(0.125) + assert.are_equal(directions.down, pc.quadrant) + end) + + it('should set quadrant to right for slope_angle: 0.25-0.124', function () + pc.quadrant = nil + pc:set_slope_angle_with_quadrant(0.25-0.124) + assert.are_equal(directions.right, pc.quadrant) + end) + + it('should set quadrant to right for slope_angle: 0.25+0.124', function () + pc.quadrant = nil + pc:set_slope_angle_with_quadrant(0.25+0.124) + assert.are_equal(directions.right, pc.quadrant) + end) + + it('should set quadrant to up for slope_angle: 0.5-0.125', function () + pc.quadrant = nil + pc:set_slope_angle_with_quadrant(0.5-0.125) + assert.are_equal(directions.up, pc.quadrant) + end) + + it('should set quadrant to up for slope_angle: 0.5+0.125', function () + pc.quadrant = nil + pc:set_slope_angle_with_quadrant(0.5+0.125) + assert.are_equal(directions.up, pc.quadrant) + end) + + it('should set quadrant to left for slope_angle: 0.75-0.124', function () + pc.quadrant = nil + pc:set_slope_angle_with_quadrant(0.75-0.124) + assert.are_equal(directions.left, pc.quadrant) + end) + + it('should set quadrant to left for slope_angle: 0.75+0.124', function () + pc.quadrant = nil + pc:set_slope_angle_with_quadrant(0.75+0.124) + assert.are_equal(directions.left, pc.quadrant) end) + end) describe('update', function () @@ -500,10 +655,10 @@ describe('player_char', function () 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) + assert.are_same(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) + assert.are_same(vector:zero(), pc.move_intention) end) end) @@ -513,67 +668,67 @@ describe('player_char', function () 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) + assert.are_same(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) + assert.are_same(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) + assert.are_same(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) + assert.are_same(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) + assert.are_same(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) + assert.are_same(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) + assert.are_same(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) + assert.are_same(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) + assert.are_same(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) + assert.are_same(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 () @@ -599,14 +754,42 @@ describe('player_char', function () 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)) + assert.spy(player_char._toggle_debug_motion).was_called(1) + assert.spy(player_char._toggle_debug_motion).was_called_with(match.ref(pc)) end) end) describe('_toggle_debug_motion', function () + setup(function () + stub(player_char, "set_motion_mode") + end) + + teardown(function () + player_char.set_motion_mode:revert() + end) + + after_each(function () + input:init() + + player_char.set_motion_mode:clear() + end) + + it('(motion mode is debug) it should toggle motion mode to platformer', function () + pc.motion_mode = motion_modes.platformer + + pc:_toggle_debug_motion() + + -- implementation + assert.spy(player_char.set_motion_mode).was_called(1) + assert.spy(player_char.set_motion_mode).was_called_with(match.ref(pc), 2) + end) + + end) + + describe('set_motion_mode', 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") @@ -622,23 +805,24 @@ describe('player_char', function () player_char.spawn_at:clear() end) - it('(motion mode is platformer) it should toggle motion mode to debug', function () + it('(to debug) should set motion mode to debug a and reset debug velocity', function () pc.motion_mode = motion_modes.platformer + pc.debug_velocity = vector(1, 2) + pc:_toggle_debug_motion() + assert.are_equal(motion_modes.debug, pc.motion_mode) - assert.are_equal(vector.zero(), pc.debug_velocity) + assert.are_same(vector.zero(), pc.debug_velocity) end) - it('(motion mode is debug) it should toggle motion mode to platformer', function () + it('(to platformer) should set motion mode to platformer and respawn as current position', 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) @@ -833,12 +1017,12 @@ describe('player_char', function () end) it('should return left when character is not moving and facing left', function () - pc.horizontal_dir = horizontal_dirs.left + pc.orientation = horizontal_dirs.left assert.are_equal(horizontal_dirs.left, pc:_get_prioritized_dir()) end) it('should return right when character is not moving and facing right', function () - pc.horizontal_dir = horizontal_dirs.right + pc.orientation = horizontal_dirs.right assert.are_equal(horizontal_dirs.right, pc:_get_prioritized_dir()) end) @@ -857,19 +1041,51 @@ describe('player_char', function () end) it('should return the position down-left of the character center when horizontal dir is left', function () - assert.are_equal(vector(7, 10 + 11), pc:_get_ground_sensor_position_from(vector(10, 10), horizontal_dirs.left)) + assert.are_same(vector(7, 10 + 11), pc:_get_ground_sensor_position_from(vector(10, 10), horizontal_dirs.left)) end) it('should return the position down-left of the x-floored character center when horizontal dir is left', function () - assert.are_equal(vector(7, 10 + 11), pc:_get_ground_sensor_position_from(vector(10.9, 10), horizontal_dirs.left)) + assert.are_same(vector(7, 10 + 11), pc:_get_ground_sensor_position_from(vector(10.9, 10), horizontal_dirs.left)) end) it('should return the position down-left of the character center when horizontal dir is right', function () - assert.are_equal(vector(12, 10 + 11), pc:_get_ground_sensor_position_from(vector(10, 10), horizontal_dirs.right)) + assert.are_same(vector(12, 10 + 11), pc:_get_ground_sensor_position_from(vector(10, 10), horizontal_dirs.right)) end) it('should return the position down-left of the x-floored character center when horizontal dir is right', function () - assert.are_equal(vector(12, 10 + 11), pc:_get_ground_sensor_position_from(vector(10.9, 10), horizontal_dirs.right)) + assert.are_same(vector(12, 10 + 11), pc:_get_ground_sensor_position_from(vector(10.9, 10), horizontal_dirs.right)) + end) + + -- for other quadrants, just check the more complex case of coords with fractions + + it('(right wall) should return the position q-down-left of the x-floored character center when horizontal dir is left', function () + pc.quadrant = directions.right + assert.are_same(vector(10 + 11, 12), pc:_get_ground_sensor_position_from(vector(10, 10.9), horizontal_dirs.left)) + end) + + it('(right wall) should return the position q-down-left of the x-floored character center when horizontal dir is right', function () + pc.quadrant = directions.right + assert.are_same(vector(10 + 11, 7), pc:_get_ground_sensor_position_from(vector(10, 10.9), horizontal_dirs.right)) + end) + + it('(ceiling) should return the position q-down-left of the x-floored character center when horizontal dir is left', function () + pc.quadrant = directions.up + assert.are_same(vector(12, 10 - 11), pc:_get_ground_sensor_position_from(vector(10.9, 10), horizontal_dirs.left)) + end) + + it('(ceiling) should return the position q-down-left of the x-floored character center when horizontal dir is right', function () + pc.quadrant = directions.up + assert.are_same(vector(7, 10 - 11), pc:_get_ground_sensor_position_from(vector(10.9, 10), horizontal_dirs.right)) + end) + + it('(left wall) should return the position q-down-left of the x-floored character center when horizontal dir is left', function () + pc.quadrant = directions.left + assert.are_same(vector(10 - 11, 7), pc:_get_ground_sensor_position_from(vector(10, 10.9), horizontal_dirs.left)) + end) + + it('(left wall) should return the position q-down-left of the x-floored character center when horizontal dir is right', function () + pc.quadrant = directions.left + assert.are_same(vector(10 -11, 12), pc:_get_ground_sensor_position_from(vector(10, 10.9), horizontal_dirs.right)) end) end) @@ -880,85 +1096,184 @@ describe('player_char', function () before_each(function () -- create a full tile at (1, 1), i.e. (8, 8) to (15, 15) px - mock_mset(1, 1, 64) + mock_mset(1, 1, full_tile_id) end) -- on the sides - it('+ should return ground_query_info(max_ground_snap_height+1, nil) if just at ground height but slightly on the left', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height+1, nil), pc:_compute_signed_distance_to_closest_ground(vector(7, 8))) + it('should return ground_query_info(max_ground_snap_height+1, nil) if just at ground height but slightly on the left', function () + assert.are_same(ground_query_info(pc_data.max_ground_snap_height+1, nil), pc:_compute_signed_distance_to_closest_ground(vector(7, 8))) end) it('should return ground_query_info(max_ground_snap_height+1, nil) if just at ground height but slightly on the right', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height+1, nil), pc:_compute_signed_distance_to_closest_ground(vector(16, 8))) + assert.are_same(ground_query_info(pc_data.max_ground_snap_height+1, nil), pc:_compute_signed_distance_to_closest_ground(vector(16, 8))) end) -- above it('should return ground_query_info(max_ground_snap_height+1, nil) if above the tile by 8 max_ground_snap_height+2)', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height+1, nil), pc:_compute_signed_distance_to_closest_ground(vector(12, 8 - (pc_data.max_ground_snap_height + 2)))) + assert.are_same(ground_query_info(pc_data.max_ground_snap_height+1, nil), pc:_compute_signed_distance_to_closest_ground(vector(12, 8 - (pc_data.max_ground_snap_height + 2)))) end) it('should return ground_query_info(max_ground_snap_height, 0) if above the tile by max_ground_snap_height', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 8 - pc_data.max_ground_snap_height))) + assert.are_same(ground_query_info(pc_data.max_ground_snap_height, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 8 - pc_data.max_ground_snap_height))) end) it('should return ground_query_info(0.0625, 0) if just a above the tile by 0.0625 (<= max_ground_snap_height)', function () - assert.are_equal(ground_query_info(0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 8 - 0.0625))) + assert.are_same(ground_query_info(0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 8 - 0.0625))) end) -- on top it('should return ground_query_info(0, 0) if just at the top of the topleft-most pixel of the tile', function () - assert.are_equal(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(8, 8))) + assert.are_same(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(8, 8))) end) it('should return ground_query_info(0, 0) if just at the top of tile, in the middle', function () - assert.are_equal(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 8))) + assert.are_same(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 8))) end) it('should return ground_query_info(0, 0) if just at the top of the right-most pixel', function () - assert.are_equal(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 8))) + assert.are_same(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 8))) end) -- just below the top it('should return ground_query_info(-0.0625, 0) if 0.0625 inside the top-left pixel', function () - assert.are_equal(ground_query_info(-0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(8, 8 + 0.0625))) + assert.are_same(ground_query_info(-0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(8, 8 + 0.0625))) end) it('should return ground_query_info(-0.0625, 0) if 0.0625 inside the top-right pixel', function () - assert.are_equal(ground_query_info(-0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 8 + 0.0625))) + assert.are_same(ground_query_info(-0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 8 + 0.0625))) end) -- going deeper it('should return ground_query_info(-1.5, 0) if 1.5 (<= max_ground_escape_height) inside vertically', function () - assert.are_equal(ground_query_info(-1.5, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 8 + 1.5))) + assert.are_same(ground_query_info(-1.5, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 8 + 1.5))) end) it('should return ground_query_info(-max_ground_escape_height, 0) if max_ground_escape_height inside', function () - assert.are_equal(ground_query_info(-pc_data.max_ground_escape_height, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 8 + pc_data.max_ground_escape_height))) + assert.are_same(ground_query_info(-pc_data.max_ground_escape_height, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 8 + pc_data.max_ground_escape_height))) end) it('should return ground_query_info(-max_ground_escape_height - 1, 0) if max_ground_escape_height + 2 inside', function () - assert.are_equal(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 8 + pc_data.max_ground_escape_height + 2))) + assert.are_same(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 8 + pc_data.max_ground_escape_height + 2))) end) -- beyond the tile, still detecting it until step up is reached, including the +1 up to detect a wall (step up too high) - it('should return ground_query_info(-max_ground_escape_height - 1, 0) if max_ground_escape_height - 1 below the bottom', function () - assert.are_equal(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height - 1))) + it('should return ground_query_info(- max_ground_escape_height - 1, 0) if max_ground_escape_height below the bottom', function () + -- we really check 1 extra px above max_ground_escape_height, so even that far from the ground above we still see it as a step too high, not ceiling + assert.are_same(ground_query_info(- pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height))) end) - it('should return ground_query_info(-max_ground_escape_height - 1, 0) if max_ground_escape_height below the bottom', function () - assert.are_equal(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height))) + it('should return ground_query_info(-max_ground_escape_height - 1, 0) (clamped) if max_ground_escape_height - 1 below the bottom', function () + assert.are_same(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height - 1))) end) -- step up distance reached, character considered in the air it('should return ground_query_info(max_ground_snap_height + 1, nil) if max_ground_escape_height + 1 below the bottom', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height + 1))) + assert.are_same(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height + 1))) + end) + + -- other quadrants (only the trickiest cases) + + -- right wall + + it('(right wall) should return ground_query_info(max_ground_snap_height + 1, nil) if too far from the wall', function () + pc.quadrant = directions.right + + assert.are_same(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(0, 12))) + end) + + it('(right wall) should return ground_query_info(2, 0) if 2 pixels from the wall', function () + pc.quadrant = directions.right + + assert.are_same(ground_query_info(2, 0.25), pc:_compute_signed_distance_to_closest_ground(vector(6, 12))) + end) + + it('(right wall) should return ground_query_info(-2, 0) if 2 pixels inside the wall', function () + pc.quadrant = directions.right + + assert.are_same(ground_query_info(-2, 0.25), pc:_compute_signed_distance_to_closest_ground(vector(10, 12))) + end) + + it('(right wall) should return ground_query_info(-max_ground_escape_height - 1, 0) if too far inside the wall', function () + pc.quadrant = directions.right + + assert.are_same(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(14, 12))) + end) + + -- ceiling + + it('(ceiling) should return ground_query_info(max_ground_snap_height + 1, nil) if too far from the wall', function () + pc.quadrant = directions.up + + assert.are_same(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(12, 24))) + end) + + it('(ceiling) should return ground_query_info(2, 0) if 2 pixels from the wall', function () + pc.quadrant = directions.up + + assert.are_same(ground_query_info(2, 0.5), pc:_compute_signed_distance_to_closest_ground(vector(12, 18))) + end) + + it('(ceiling) should return ground_query_info(-2, 0) if 2 pixels inside the wall', function () + pc.quadrant = directions.up + + assert.are_same(ground_query_info(-2, 0.5), pc:_compute_signed_distance_to_closest_ground(vector(12, 14))) + end) + + it('(ceiling) should return ground_query_info(-max_ground_escape_height - 1, 0) if too far inside the wall', function () + pc.quadrant = directions.up + + assert.are_same(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 8))) + end) + + -- left wall + + it('(left wall) should return ground_query_info(max_ground_snap_height + 1, nil) if too far from the wall', function () + pc.quadrant = directions.left + + assert.are_same(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(24, 12))) + end) + + it('(left wall) should return ground_query_info(2, 0) if 2 pixels from the wall', function () + pc.quadrant = directions.left + + assert.are_same(ground_query_info(2, 0.75), pc:_compute_signed_distance_to_closest_ground(vector(18, 12))) + end) + + it('(left wall) should return ground_query_info(-2, 0) if 2 pixels inside the wall', function () + pc.quadrant = directions.left + + assert.are_same(ground_query_info(-2, 0.75), pc:_compute_signed_distance_to_closest_ground(vector(14, 12))) + end) + + it('(left wall) should return ground_query_info(-max_ground_escape_height - 1, 0) if too far inside the wall', function () + pc.quadrant = directions.left + + assert.are_same(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(10, 12))) + end) + + end) + + describe('with 2 full flat tiles', function () + + before_each(function () + mock_mset(0, 0, full_tile_id) + mock_mset(0, 1, full_tile_id) + end) + + -- test below verifies that I check 1 extra px above max_ground_escape_height (see snap_zone_qtop definition) + -- even if it reaches another tile, so I don't think it's over and escape + -- the current tile because current column is just at max_ground_escape_height, + -- only to land inside the tile above + + it('should return ground_query_info(-max_ground_escape_height - 1, 0) if max_ground_escape_height + 1 inside, including max_ground_escape_height in current tile', function () + assert.are_same(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(4, 8 + pc_data.max_ground_escape_height))) end) end) @@ -967,88 +1282,88 @@ 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 - mock_mset(1, 1, 70) + mock_mset(1, 1, half_tile_id) end) -- just above it('should return 0.0625, 0 if just a little above the tile', function () - assert.are_equal(ground_query_info(0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 12 - 0.0625))) + assert.are_same(ground_query_info(0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 12 - 0.0625))) end) -- on top it('+ should return ground_query_info(max_ground_snap_height + 1, nil) if just touching the left of the tile at the ground\'s height', function () -- right ground sensor @ (7.5, 12) - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(7, 12))) + assert.are_same(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(7, 12))) end) it('should return 0, 0 if just at the top of the topleft-most pixel of the tile', function () - assert.are_equal(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(8, 12))) + assert.are_same(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(8, 12))) end) it('should return 0, 0 if just at the top of tile, in the middle', function () - assert.are_equal(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 12))) + assert.are_same(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 12))) end) it('should return 0, 0 if just at the top of the right-most pixel', function () - assert.are_equal(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 12))) + assert.are_same(ground_query_info(0, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 12))) end) it('should return ground_query_info(max_ground_snap_height + 1, nil) if in the air on the right of the tile', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(16, 12))) + assert.are_same(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(16, 12))) end) -- just inside the top it('should return ground_query_info(max_ground_snap_height + 1, nil) if just on the left of the topleft pixel, y at 0.0625 below the top', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(7, 12 + 0.0625))) + assert.are_same(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(7, 12 + 0.0625))) end) it('should return -0.0625, 0 if 0.0625 inside the topleft pixel', function () - assert.are_equal(ground_query_info(-0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(8, 12 + 0.0625))) + assert.are_same(ground_query_info(-0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(8, 12 + 0.0625))) end) it('should return -0.0625, 0 if 0.0625 inside the topright pixel', function () - assert.are_equal(ground_query_info(-0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 12 + 0.0625))) + assert.are_same(ground_query_info(-0.0625, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 12 + 0.0625))) end) it('should return ground_query_info(max_ground_snap_height + 1, nil) if just on the right of the topright pixel, y at 0.0625 below the top', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(16, 12 + 0.0625))) + assert.are_same(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(16, 12 + 0.0625))) end) -- just inside the bottom it('should return ground_query_info(max_ground_snap_height + 1, nil) if just on the left of the topleft pixel, y at 0.0625 above the bottom', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(7, 16 - 0.0625))) + assert.are_same(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(7, 16 - 0.0625))) end) it('should return -(4 - 0.0625), 0 if 0.0625 inside the topleft pixel', function () - assert.are_equal(ground_query_info(-(4 - 0.0625), 0), pc:_compute_signed_distance_to_closest_ground(vector(8, 16 - 0.0625))) + assert.are_same(ground_query_info(-(4 - 0.0625), 0), pc:_compute_signed_distance_to_closest_ground(vector(8, 16 - 0.0625))) end) it('should return -(4 - 0.0625), 0 if 0.0625 inside the topright pixel', function () - assert.are_equal(ground_query_info(-(4 - 0.0625), 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 - 0.0625))) + assert.are_same(ground_query_info(-(4 - 0.0625), 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 - 0.0625))) end) it('should return ground_query_info(max_ground_snap_height + 1, nil) if just on the right of the topright pixel, y at 0.0625 above the bottom', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(16, 16 - 0.0625))) + assert.are_same(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(16, 16 - 0.0625))) end) -- beyond the tile, still detecting it until step up is reached, including the +1 up to detect a wall (step up too high) it('should return ground_query_info(-max_ground_escape_height - 1, 0) if max_ground_escape_height - 1 below the bottom', function () - assert.are_equal(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height - 1))) + assert.are_same(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height - 1))) end) it('should return ground_query_info(-max_ground_escape_height - 1, 0) if max_ground_escape_height below the bottom', function () - assert.are_equal(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height))) + assert.are_same(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height))) end) -- step up distance reached, character considered in the air - it('should return ground_query_info(max_ground_snap_height + 1, nil) if max_ground_escape_height + 1 below the bottom', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height + 1))) + it('should return ground_query_info(max_ground_snap_height + 1, nil) if max_ground_snap_height + 1 below the bottom', function () + assert.are_same(ground_query_info(pc_data.max_ground_escape_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height + 1))) end) end) @@ -1057,67 +1372,79 @@ describe('player_char', function () before_each(function () -- create an ascending slope at (1, 1), i.e. (8, 15) to (15, 8) px - mock_mset(1, 1, 65) + mock_mset(1, 1, asc_slope_45_id) + end) + + it('should return 0.0625, 45/360 if just above slope column 0', function () + assert.are_same(ground_query_info(0.0625, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 15 - 0.0625))) + end) + + it('should return 0, 45/360 if at the top of column 0', function () + assert.are_same(ground_query_info(0, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 15))) + end) + + it('should return 5 (max_ground_snap_height+1 clamping), nil if 7px above column 0, i.e. at top-most pixel of the ascending slope tile', function () + assert.are_same(ground_query_info(5, nil), pc:_compute_signed_distance_to_closest_ground(vector(8, 8))) end) - it('should return 0.0625, -45/360 if just above slope column 0', function () - assert.are_equal(ground_query_info(0.0625, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 15 - 0.0625))) + it('should return 5 (max_ground_snap_height+1), nil if 8px above column 0, i.e. at bottom-most pixel of tile just above the ascending slope tile', function () + assert.are_same(ground_query_info(5, nil), pc:_compute_signed_distance_to_closest_ground(vector(8, 7))) end) - it('should return 0, -45/360 if at the top of column 0', function () - assert.are_equal(ground_query_info(0, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 15))) + it('should return 5 (max_ground_snap_height+1), nil if 15px above column 0, i.e. at top-most pixel of tile just above the ascending slope tile', function () + assert.are_same(ground_query_info(5, nil), pc:_compute_signed_distance_to_closest_ground(vector(8, 0))) end) - it('. should return 0.0625, -45/360 if just above slope column 4', function () - assert.are_equal(ground_query_info(0.0625, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(12, 11 - 0.0625))) + it('. should return 0.0625, 45/360 if just above slope column 4', function () + assert.are_same(ground_query_info(0.0625, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(12, 11 - 0.0625))) end) - it('. should return 0, -45/360 if at the top of column 4', function () - assert.are_equal(ground_query_info(0, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(12, 11))) + it('. should return 0, 45/360 if at the top of column 4', function () + assert.are_same(ground_query_info(0, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(12, 11))) end) - it('should return -2, -45/360 if 2px below column 4', function () - assert.are_equal(ground_query_info(-2, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(12, 13))) + it('should return -2, 45/360 if 2px below column 4', function () + assert.are_same(ground_query_info(-2, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(12, 13))) end) - it('should return 0.0625, -45/360 if right sensor is just above slope column 0', function () - assert.are_equal(ground_query_info(0.0625, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 8 - 0.0625))) + it('should return 0.0625, 45/360 if right sensor is just above slope column 0', function () + assert.are_same(ground_query_info(0.0625, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 8 - 0.0625))) end) - it('should return 0, -45/360 if right sensor is at the top of column 0', function () - assert.are_equal(ground_query_info(0, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 8))) + it('should return 0, 45/360 if right sensor is at the top of column 0', function () + assert.are_same(ground_query_info(0, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 8))) end) - it('should return -3, -45/360 if 3px below column 0', function () - assert.are_equal(ground_query_info(-3, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 11))) + it('should return -3, 45/360 if 3px below column 0', function () + assert.are_same(ground_query_info(-3, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 11))) end) - it('. should return 0.0625, -45/360 if just above slope column 3', function () - assert.are_equal(ground_query_info(0.0625, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(11, 12 - 0.0625))) + it('. should return 0.0625, 45/360 if just above slope column 3', function () + assert.are_same(ground_query_info(0.0625, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(11, 12 - 0.0625))) end) - it('. should return 0, -45/360 if at the top of column 3', function () - assert.are_equal(ground_query_info(0, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(11, 12))) + it('. should return 0, 45/360 if at the top of column 3', function () + assert.are_same(ground_query_info(0, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(11, 12))) end) -- beyond the tile, still detecting it until step up is reached, including the +1 up to detect a wall (step up too high) - it('should return ground_query_info(-4, -45/360) if 4 (<= max_ground_escape_height) below the 2nd column top', function () - assert.are_equal(ground_query_info(-4, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(9, 16 + 2))) + it('should return ground_query_info(-4, 45/360) if 4 (<= max_ground_escape_height) below the 2nd column top', function () + assert.are_same(ground_query_info(-4, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(9, 16 + 2))) end) - it('should return ground_query_info(-max_ground_escape_height - 1, -45/360) if max_ground_escape_height - 1 below the bottom', function () - assert.are_equal(ground_query_info(-pc_data.max_ground_escape_height - 1, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height - 1))) + it('should return ground_query_info(-(max_ground_escape_height - 1), 45/360) if max_ground_escape_height - 1 below the top of column 0', function () + assert.are_same(ground_query_info(-(pc_data.max_ground_escape_height - 1), 45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 15 + pc_data.max_ground_escape_height - 1))) end) - it('should return ground_query_info(-max_ground_escape_height - 1, -45/360) if max_ground_escape_height below the bottom', function () - assert.are_equal(ground_query_info(-pc_data.max_ground_escape_height - 1, -45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height))) + it('should return ground_query_info(-max_ground_escape_height, 45/360) if max_ground_escape_height below the top of column 0', function () + assert.are_same(ground_query_info(-pc_data.max_ground_escape_height, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 15 + pc_data.max_ground_escape_height))) end) -- step up distance reached, character considered in the air - it('should return ground_query_info(max_ground_snap_height + 1, nil) if max_ground_escape_height + 1 below the bottom', function () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(15, 16 + pc_data.max_ground_escape_height + 1))) + it('should return ground_query_info(-max_ground_escape_height - 1, 0) if max_ground_escape_height + 1 below the top of column 0 but only max_ground_snap_height below the bottom of column 0 (of the tile)', function () + assert.are_same(ground_query_info(-pc_data.max_ground_escape_height - 1, 0), pc:_compute_signed_distance_to_closest_ground(vector(8, 15 + pc_data.max_ground_escape_height + 1))) end) end) @@ -1126,63 +1453,63 @@ describe('player_char', function () before_each(function () -- create a descending slope at (1, 1), i.e. (8, 8) to (15, 15) px - mock_mset(1, 1, 66) + mock_mset(1, 1, desc_slope_45_id) end) - it('. should return 0.0625, 45/360 if right sensors are just a little above column 0', function () - assert.are_equal(ground_query_info(0.0625, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 8 - 0.0625))) + it('should return 0.0625, 1-45/360 if right sensors are just a little above column 0', function () + assert.are_same(ground_query_info(0.0625, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 8 - 0.0625))) end) - it('should return 0, 45/360 if right sensors is at the top of column 0', function () - assert.are_equal(ground_query_info(0, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 8))) + it('should return 0, 1-45/360 if right sensors is at the top of column 0', function () + assert.are_same(ground_query_info(0, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 8))) end) - it('should return -1, 45/360 if right sensors is below column 0 by 1px', function () - assert.are_equal(ground_query_info(-1, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 9))) + it('should return -1, 1-45/360 if right sensors is below column 0 by 1px', function () + assert.are_same(ground_query_info(-1, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 9))) end) - it('should return 1, 45/360 if 1px above slope column 1', function () - assert.are_equal(ground_query_info(1, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(9, 8))) + it('should return 1, 1-45/360 if 1px above slope column 1', function () + assert.are_same(ground_query_info(1, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(9, 8))) end) - it('should return 0, 45/360 if at the top of column 1', function () - assert.are_equal(ground_query_info(0, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(9, 9))) + it('should return 0, 1-45/360 if at the top of column 1', function () + assert.are_same(ground_query_info(0, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(9, 9))) end) - it('should return -2, 45/360 if 2px below column 1', function () - assert.are_equal(ground_query_info(-2, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(9, 11))) + it('should return -2, 1-45/360 if 2px below column 1', function () + assert.are_same(ground_query_info(-2, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(9, 11))) end) - it('should return 0.0625, 45/360 if just above slope column 0', function () - assert.are_equal(ground_query_info(0.0625, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 8 - 0.0625))) + it('should return 0.0625, 1-45/360 if just above slope column 0', function () + assert.are_same(ground_query_info(0.0625, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 8 - 0.0625))) end) - it('should return 0, 45/360 if at the top of column 0', function () - assert.are_equal(ground_query_info(0, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 8))) + it('should return 0, 1-45/360 if at the top of column 0', function () + assert.are_same(ground_query_info(0, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 8))) end) - it('should return -3, 45/360 if 3px below column 0', function () - assert.are_equal(ground_query_info(-3, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 11))) + it('should return -3, 1-45/360 if 3px below column 0', function () + assert.are_same(ground_query_info(-3, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(8, 11))) end) - it('. should return 0.0625, 45/360 if just above slope column 3', function () - assert.are_equal(ground_query_info(0.0625, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(11, 11 - 0.0625))) + it('. should return 0.0625, 1-45/360 if just above slope column 3', function () + assert.are_same(ground_query_info(0.0625, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(11, 11 - 0.0625))) end) - it('. should return 0, 45/360 if at the top of column 3', function () - assert.are_equal(ground_query_info(0, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(11, 11))) + it('. should return 0, 1-45/360 if at the top of column 3', function () + assert.are_same(ground_query_info(0, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(11, 11))) end) - it('should return -4, 45/360 if 4px below column 3', function () - assert.are_equal(ground_query_info(-4, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(11, 15))) + it('should return -4, 1-45/360 if 4px below column 3', function () + assert.are_same(ground_query_info(-4, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(11, 15))) end) - it('should return 0.0625, 45/360 if just above slope column 7', function () - assert.are_equal(ground_query_info(0.0625, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 15 - 0.0625))) + it('should return 0.0625, 1-45/360 if just above slope column 7', function () + assert.are_same(ground_query_info(0.0625, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 15 - 0.0625))) end) - it('should return 0 if, 45/360 at the top of column 7', function () - assert.are_equal(ground_query_info(0, 45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 15))) + it('should return 0 if, 1-45/360 at the top of column 7', function () + assert.are_same(ground_query_info(0, 1-45/360), pc:_compute_signed_distance_to_closest_ground(vector(15, 15))) end) end) @@ -1191,11 +1518,11 @@ describe('player_char', function () before_each(function () -- create an ascending slope 22.5 at (1, 1), i.e. (8, 14) to (15, 11) px - mock_mset(1, 1, 67) + mock_mset(1, 1, asc_slope_22_id) end) - it('should return -4, -22.5/360 if below column 7 by 4px)', function () - assert.are_equal(ground_query_info(-4, -22.5/360), pc:_compute_signed_distance_to_closest_ground(vector(14, 15))) + it('should return -4, 22.5/360 if below column 7 by 4px)', function () + assert.are_same(ground_query_info(-4, 22.5/360), pc:_compute_signed_distance_to_closest_ground(vector(14, 15))) end) end) @@ -1205,15 +1532,15 @@ 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 - mock_mset(1, 1, 71) + mock_mset(1, 1, bottom_right_quarter_tile_id) 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 () - assert.are_equal(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(11, 16))) + assert.are_same(ground_query_info(pc_data.max_ground_snap_height + 1, nil), pc:_compute_signed_distance_to_closest_ground(vector(11, 16))) end) it('should return -2, 0 if below tile by 2px', function () - assert.are_equal(ground_query_info(-2, 0), pc:_compute_signed_distance_to_closest_ground(vector(14, 14))) + assert.are_same(ground_query_info(-2, 0), pc:_compute_signed_distance_to_closest_ground(vector(14, 14))) end) end) @@ -1240,13 +1567,13 @@ describe('player_char', function () -- 11111111 -- 11111111 23 - mock_mset(1, 1, 72) - mock_mset(1, 2, 64) + mock_mset(1, 1, flat_low_tile_id) + mock_mset(1, 2, full_tile_id) end) it('should return -4, 0 if below top by 4px, with character crossing 2 tiles', function () -- interface - assert.are_equal(ground_query_info(-4, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 18))) + assert.are_same(ground_query_info(-4, 0), pc:_compute_signed_distance_to_closest_ground(vector(12, 18))) end) end) @@ -1259,7 +1586,7 @@ describe('player_char', function () before_each(function () -- create a full tile at (1, 1), i.e. (8, 8) to (15, 15) px - mock_mset(1, 1, 64) + mock_mset(1, 1, full_tile_id) end) it('should do nothing when character is not touching ground at all, and return false', function () @@ -1272,6 +1599,7 @@ describe('player_char', function () it('should do nothing when character is just on top of the ground, update slope to 0 and return true', function () pc:set_bottom_center(vector(12, 8)) + pc.slope_angle = 0.25 -- just to verify that slope angle is updated local result = pc:_check_escape_from_ground() -- interface @@ -1296,11 +1624,15 @@ describe('player_char', function () end) + -- note that 45 deg slope is considered quadrant down by world.angle_to_quadrant + -- therefore our tests will work as on flat ground + -- otherwise we'd need to adjust the expected get_bottom_center which is affected by quadrant + describe('with descending slope 45', function () before_each(function () -- create a descending slope at (1, 1), i.e. (8, 8) to (15, 15) px - mock_mset(1, 1, 66) + mock_mset(1, 1, desc_slope_45_id) end) it('should do nothing when character is not touching ground at all, and return false', function () @@ -1311,20 +1643,20 @@ describe('player_char', function () assert.are_same({vector(15, 10), 0, false}, {pc:get_bottom_center(), pc.slope_angle, result}) end) - it('should do nothing when character is just on top of the ground, update slope to 45/360 and return true', function () + it('should do nothing when character is just on top of the ground, update slope to 1-45/360 and return true', function () pc:set_bottom_center(vector(15, 12)) local result = pc:_check_escape_from_ground() -- interface - assert.are_same({vector(15, 12), 45/360, true}, {pc:get_bottom_center(), pc.slope_angle, result}) + assert.are_same({vector(15, 12), 1-45/360, true}, {pc:get_bottom_center(), pc.slope_angle, result}) end) - it('should move the character upward just enough to escape ground if character is inside ground, update slope to 45/360 and return true', function () + it('should move the character upward just enough to escape ground if character is inside ground, update slope to 1-45/360 and return true', function () pc:set_bottom_center(vector(15, 13)) local result = pc:_check_escape_from_ground() -- interface - assert.are_same({vector(15, 12), 45/360, true}, {pc:get_bottom_center(), pc.slope_angle, result}) + assert.are_same({vector(15, 12), 1-45/360, true}, {pc:get_bottom_center(), pc.slope_angle, result}) end) it('should do nothing when character is too deep inside the ground, and return true', function () @@ -1343,19 +1675,23 @@ describe('player_char', function () setup(function () spy.on(animated_sprite, "play") + spy.on(player_char, "set_slope_angle_with_quadrant") -- spy not stub in case the resulting slope_angle/quadrant matters end) teardown(function () animated_sprite.play:revert() + player_char.set_slope_angle_with_quadrant: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 + -- for set_slope_angle_with_quadrant, after_each would be fine too before_each(function () animated_sprite.play:clear() + player_char.set_slope_angle_with_quadrant:clear() end) - it('should enter passed state: falling and reset ground-specific state vars', function () + it('should enter passed state: falling, reset ground-specific state vars, no animation change', function () -- character starts grounded pc:_enter_motion_state(motion_states.falling) @@ -1372,7 +1708,15 @@ describe('player_char', function () assert.spy(animated_sprite.play).was_not_called() end) - it('should enter passed state: air_spin and reset ground-specific state vars', function () + it('(grounded -> falling) should call set_slope_angle_with_quadrant(nil)', function () + -- character starts grounded + pc:_enter_motion_state(motion_states.falling) + + assert.spy(player_char.set_slope_angle_with_quadrant).was_called(1) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called_with(match.ref(pc), nil) + end) + + it('should enter passed state: air_spin, reset ground-specific state vars, play spin animation', function () -- character starts grounded pc:_enter_motion_state(motion_states.air_spin) @@ -1390,26 +1734,95 @@ describe('player_char', function () assert.spy(animated_sprite.play).was_called_with(match.ref(pc.anim_spr), "spin") end) + it('(grounded -> air_spin) should call set_slope_angle_with_quadrant(nil)', function () + -- character starts grounded + pc:_enter_motion_state(motion_states.air_spin) + + assert.spy(player_char.set_slope_angle_with_quadrant).was_called(1) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called_with(match.ref(pc), nil) + end) + -- bugfix history: . - it('should enter passed state: grounded and reset speed y and has_interrupted_jump', function () + it('should enter passed state: grounded, reset has_jumped_this_frame/has_interrupted_jump', function () pc.motion_state = motion_states.falling pc:_enter_motion_state(motion_states.grounded) assert.are_same({ motion_states.grounded, - 0, false, false }, { pc.motion_state, - pc.velocity.y, 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) + + it('(falling -> grounded, velocity X = 0 on flat ground) should set ground speed to 0', function () + pc.motion_state = motion_states.falling + pc.velocity.x = 0 + pc.velocity.y = 5 + + pc:_enter_motion_state(motion_states.grounded) + + assert.are_equal(0, pc.ground_speed) + end) + + it('(falling -> grounded, velocity X = 2 on flat ground) should transfer velocity X completely to ground speed', function () + pc.motion_state = motion_states.falling + pc.velocity.x = 2 + pc.velocity.y = 5 + + pc:_enter_motion_state(motion_states.grounded) + + assert.are_equal(2, pc.ground_speed) + end) + + it('(falling -> grounded, velocity X = 5 (over max) on flat ground) should transfer velocity X clamped to ground speed', function () + pc.motion_state = motion_states.falling + pc.velocity.x = pc_data.max_ground_speed + 2 + pc.velocity.y = 5 + + pc:_enter_motion_state(motion_states.grounded) + + assert.are_equal(pc_data.max_ground_speed, pc.ground_speed) + end) + + it('(falling -> grounded, velocity (sqrt(3)/2, 0.5) tangent to slope 30 deg desc) should transfer velocity norm (1) completely to ground speed', function () + pc.motion_state = motion_states.falling + pc.velocity.x = sqrt(3)/2 + pc.velocity.y = 0.5 + pc.slope_angle = 1-1/12 -- 30 deg/360 deg + + pc:_enter_motion_state(motion_states.grounded) + + -- should be OK in PICO-8, but with floating precision we need almost + -- (angle of -1/12 was fine, but 1-1/12 offsets a little) + assert.is_true(almost_eq_with_message(1, pc.ground_speed)) + end) + + it('(falling -> grounded, velocity (-4, 4) orthogonally to slope 45 deg desc) should set ground speed to 0', function () + pc.motion_state = motion_states.falling + pc.velocity.x = -4 + pc.velocity.y = 4 + pc.slope_angle = 1-0.125 -- 45 deg/360 deg + + pc:_enter_motion_state(motion_states.grounded) + + assert.is_true(almost_eq_with_message(0, pc.ground_speed)) + end) + + it('(falling -> grounded, velocity (-4, 5) on slope 45 deg desc) should transfer just the tangent velocity (1/sqrt(2)) to ground speed', function () + pc.motion_state = motion_states.falling + pc.velocity.x = -4 + pc.velocity.y = 5 + pc.slope_angle = 1-0.125 -- -45 deg/360 deg + + pc:_enter_motion_state(motion_states.grounded) + + assert.is_true(almost_eq_with_message(1/sqrt(2), pc.ground_speed)) end) it('should adjust center position down when becoming compact', function () @@ -1586,11 +1999,15 @@ describe('player_char', function () local check_jump_intention_stub local compute_ground_motion_result_mock + -- allows to modify the mock _update_ground_speed without restubbing it for every test section + local new_ground_speed = -2.5 -- use fractional speed to check that fractions are preserved + setup(function () spy.on(animated_sprite, "play") + spy.on(player_char, "set_slope_angle_with_quadrant") -- spy not stub in case the resulting slope_angle/quadrant matters 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 + self.ground_speed = new_ground_speed end) enter_motion_state_stub = stub(player_char, "_enter_motion_state") check_jump_intention_stub = stub(player_char, "_check_jump_intention") @@ -1598,6 +2015,7 @@ describe('player_char', function () teardown(function () animated_sprite.play:revert() + player_char.set_slope_angle_with_quadrant:revert() update_ground_speed_mock:revert() enter_motion_state_stub:revert() @@ -1611,6 +2029,8 @@ describe('player_char', function () end) after_each(function () + player_char.set_slope_angle_with_quadrant:clear() + update_ground_speed_mock:clear() enter_motion_state_stub:clear() check_jump_intention_stub:clear() @@ -1652,7 +2072,7 @@ describe('player_char', function () 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.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}) @@ -1660,13 +2080,14 @@ describe('player_char', function () it('should set the position to vector(3, 4)', function () pc:_update_platformer_motion_grounded() - assert.are_equal(vector(3, 4), pc.position) + assert.are_same(vector(3, 4), pc.position) end) - it('should set the slope angle to 0.25', function () - pc.slope_angle = -0.25 + it('should call set_slope_angle_with_quadrant with 0.25', function () + pc.slope_angle = 1-0.25 pc:_update_platformer_motion_grounded() - assert.are_equal(0.25, pc.slope_angle) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called(1) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called_with(match.ref(pc), 0.25) end) it('should call _check_jump_intention, not _enter_motion_state (not falling)', function () @@ -1687,6 +2108,28 @@ describe('player_char', function () assert.spy(animated_sprite.play).was_called_with(match.ref(pc.anim_spr), "run", false, 2.5) end) + describe('(_update_ground_speed sets ground speed to -pc_data.run_anim_min_play_speed / 2)', function () + + setup(function () + -- something lower than pc_data.run_anim_min_play_speed in abs value to test max + new_ground_speed = -pc_data.run_anim_min_play_speed / 2 + end) + + teardown(function () + -- pretty hacky way to restore the original stub of _update_ground_speed for further tests below + new_ground_speed = -2.5 + end) + + it('should play the run animation at playback speed = run_anim_min_play_speed, if not 0 but lower than run_anim_min_play_speed', function () + 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, pc_data.run_anim_min_play_speed) + end) + + end) + end) 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 () @@ -1728,13 +2171,14 @@ describe('player_char', function () it('should set the position to vector(3, 4)', function () pc:_update_platformer_motion_grounded() - assert.are_equal(vector(3, 4), pc.position) + assert.are_same(vector(3, 4), pc.position) end) - it('should set the slope angle to 0.5', function () - pc.slope_angle = -0.25 + it('should call set_slope_angle_with_quadrant with 0.5', function () + pc.slope_angle = 1-0.25 pc:_update_platformer_motion_grounded() - assert.are_equal(0.5, pc.slope_angle) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called(1) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called_with(match.ref(pc), 0.5) end) it('should play the idle animation (ground speed ~= 0)', function () @@ -1777,7 +2221,7 @@ describe('player_char', function () 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.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}) @@ -1795,12 +2239,14 @@ describe('player_char', function () it('should set the position to vector(3, 4)', function () pc:_update_platformer_motion_grounded() - assert.are_equal(vector(3, 4), pc.position) + assert.are_same(vector(3, 4), pc.position) end) - it('should set the slope angle to nil', function () + it('should call set_slope_angle_with_quadrant to nil', function () + pc.slope_angle = 0 pc:_update_platformer_motion_grounded() - assert.is_nil(pc.slope_angle) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called(1) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called_with(match.ref(pc), nil) end) end) @@ -1845,21 +2291,24 @@ describe('player_char', function () it('should set the position to vector(3, 4)', function () pc:_update_platformer_motion_grounded() - assert.are_equal(vector(3, 4), pc.position) + assert.are_same(vector(3, 4), pc.position) end) - it('should set the slope angle to nil', function () + it('should call set_slope_angle_with_quadrant to nil', function () + pc.slope_angle = 0 pc:_update_platformer_motion_grounded() - assert.is_nil(pc.slope_angle) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called(1) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called_with(match.ref(pc), nil) end) end) end) -- _update_platformer_motion_grounded - describe('_update_ground_speed', function () + describe('update_ground_speed', function () setup(function () + -- the only reason we spy and not stub is to test the interface in the first test below 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") @@ -1877,18 +2326,27 @@ describe('player_char', function () 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() + -- usually we'd only test the interface (calls) + -- but since we cannot easily test the call order with spies, + -- we do a mini itest to check the resulting velocity, + -- which will prove that slope factor is applied before intention - -- interface + it('should apply descending slope factor, then oppose it with strong decel when moving in the ascending direction of 45-degree slope from ground speed 0', function () + -- interface: check overall behavior (mini integration test) pc.ground_speed = 0 - pc.slope_angle = -1/8 -- 45 deg ascending + 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) + -- Note that we have fixed the classic Sonic exploit of decelerating faster when accelerating backward from ground speed 0, + -- so the speed will still be clamped to ground accel on this frame, and not become + -- - pc_data.slope_accel_factor_frame2 * sin(-1/8) + pc_data.ground_decel_frame2 + assert.are_equal(pc_data.ground_accel_frame2, pc.ground_speed) end) it('should update ground speed based on slope, then intention', function () + pc.ground_speed = 2.5 + pc:_update_ground_speed() -- implementation @@ -1907,176 +2365,312 @@ describe('player_char', function () it('should preserve ground speed on flat ground', function () pc.ground_speed = 2 pc.slope_angle = 0 - pc:_update_ground_speed_by_slope() + pc.ascending_slope_time = 77 + + pc:_update_ground_speed_by_slope(1.8) + assert.are_equal(2, pc.ground_speed) - end) - it('should accelerate toward left on an ascending slope', function () - pc.ground_speed = 2 - pc.slope_angle = -0.125 -- sin(0.125) = sqrt(2)/2 - pc:_update_ground_speed_by_slope() - assert.are_equal(2 - pc_data.slope_accel_factor_frame2 * sqrt(2)/2, pc.ground_speed) + assert.are_same({ + 2, + 0 + }, + { + pc.ground_speed, + pc.ascending_slope_time + }) end) - it('should accelerate toward right on an descending slope', function () + -- Original feature (not in SPG): Progressive Ascending Steep Slope Factor + + it('should accelerate toward left on a steep ascending slope, with very reduced slope factor at the beginning of the climb, and increase ascending slope time', function () pc.ground_speed = 2 - pc.slope_angle = 0.125 -- sin(0.125) = sqrt(2)/2 - pc:_update_ground_speed_by_slope() - assert.are_equal(2 + pc_data.slope_accel_factor_frame2 * sqrt(2)/2, pc.ground_speed) - end) + pc.slope_angle = 0.125 -- sin(0.125) = -sqrt(2)/2 + pc.ascending_slope_time = 0 + + pc:_update_ground_speed_by_slope(1.8) + + assert.are_same({ + 2 - delta_time60 / pc_data.progressive_ascending_slope_duration * pc_data.slope_accel_factor_frame2 * sqrt(2)/2, + delta_time60 + }, + { + pc.ground_speed, + pc.ascending_slope_time + }) + end) + + it('should accelerate toward left on a steep ascending slope, with reduced slope factor before ascending slope duration, and increase ascending slope time', function () + pc.ground_speed = 2 + pc.slope_angle = 0.125 -- sin(0.125) = -sqrt(2)/2 + pc.ascending_slope_time = 0.1 + + pc:_update_ground_speed_by_slope(1.8) + + assert.are_same({ + 2 - (0.1 + delta_time60) / pc_data.progressive_ascending_slope_duration * pc_data.slope_accel_factor_frame2 * sqrt(2)/2, + 0.1 + delta_time60 + }, + { + pc.ground_speed, + pc.ascending_slope_time + }) + end) + + it('should accelerate toward left on a steep ascending slope, with full slope factor after ascending slope duration, and clamp time to that duration', function () + pc.ground_speed = 2 + pc.slope_angle = 0.125 -- sin(0.125) = -sqrt(2)/2 + pc.ascending_slope_time = pc_data.progressive_ascending_slope_duration + + pc:_update_ground_speed_by_slope(1.8) + + assert.are_same({ + 2 - pc_data.slope_accel_factor_frame2 * sqrt(2)/2, + pc_data.progressive_ascending_slope_duration + }, + { + pc.ground_speed, + pc.ascending_slope_time + }) + end) + + it('should accelerate toward right on a non-steep ascending slope, and reset any ascending slope time', function () + pc.ground_speed = 2 + pc.slope_angle = 0.0625 + pc.ascending_slope_time = 77 + + pc:_update_ground_speed_by_slope(1.8) + + assert.are_same({ + 2 - pc_data.slope_accel_factor_frame2 * sin(-0.0625), -- note that the sin is positive + 0 + }, + { + pc.ground_speed, + pc.ascending_slope_time + }) + end) + + it('should accelerate toward right on an descending slope, with full slope factor, and reset any ascending slope time', function () + pc.ground_speed = 2 + pc.slope_angle = 1-0.125 -- sin(-0.125) = sqrt(2)/2 + pc.ascending_slope_time = 77 + + pc:_update_ground_speed_by_slope(1.8) + + assert.are_same({ + 2 + pc_data.slope_accel_factor_frame2 * sqrt(2)/2, + 0 + }, + { + pc.ground_speed, + pc.ascending_slope_time + }) + end) end) -- _update_ground_speed_by_slope describe('_update_ground_speed_by_intention', 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.orientation = horizontal_dirs.left pc.move_intention.x = 1 pc:_update_ground_speed_by_intention() assert.are_same({horizontal_dirs.right, pc_data.ground_accel_frame2}, - {pc.horizontal_dir, pc.ground_speed}) + {pc.orientation, pc.ground_speed}) end) 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.orientation = horizontal_dirs.left -- rare to oppose ground speed sense, but possible when running backward e.g. after hitting a spring pc.ground_speed = 1.5 pc.move_intention.x = 1 pc:_update_ground_speed_by_intention() assert.are_same({horizontal_dirs.right, 1.5 + pc_data.ground_accel_frame2}, - {pc.horizontal_dir, pc.ground_speed}) + {pc.orientation, pc.ground_speed}) end) 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.orientation = horizontal_dirs.left -- rare to oppose ground speed sense, but possible when running backward e.g. after hitting a spring pc.ground_speed = -1.5 pc.move_intention.x = -1 pc:_update_ground_speed_by_intention() assert.are_same({horizontal_dirs.left, -1.5 - pc_data.ground_accel_frame2}, - {pc.horizontal_dir, pc.ground_speed}) + {pc.orientation, pc.ground_speed}) end) 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.orientation = 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_same({horizontal_dirs.right, 1.25}, - {pc.horizontal_dir, pc.ground_speed}) + {pc.orientation, pc.ground_speed}) + end) + + -- Original feature (not in SPG): Reduced Deceleration on Steep Descending Slope + + it('should decelerate with decel descending slope factor, keeping same sign and direction when character is on steep descending slope facing right, has high ground speed > ground accel * 1 frame and move intention x < 0', function () + pc.orientation = horizontal_dirs.right + pc.ground_speed = 1.5 + pc.move_intention.x = -1 + pc.slope_angle = 1-0.125 + pc:_update_ground_speed_by_intention() + assert.are_same({horizontal_dirs.right, 1.5 - pc_data.ground_decel_descending_slope_factor * pc_data.ground_decel_frame2}, + {pc.orientation, pc.ground_speed}) + end) + + it('should decelerate without decel descending slope factor, keeping same sign and direction when character is on non-steep descending slope facing right, has high ground speed > ground accel * 1 frame and move intention x < 0', function () + pc.orientation = horizontal_dirs.right + pc.ground_speed = 1.5 + pc.move_intention.x = -1 + pc.slope_angle = 1-0.0625 + pc:_update_ground_speed_by_intention() + assert.are_same({horizontal_dirs.right, 1.5 - pc_data.ground_decel_frame2}, + {pc.orientation, pc.ground_speed}) end) 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.orientation = 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}) + {pc.orientation, 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, '.. + it('should decelerate, turn and start moving to the left 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 + pc.orientation = 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.are_equal(horizontal_dirs.left, pc.orientation) assert.is_true(almost_eq_with_message(-0.01, pc.ground_speed, 1e-16)) end) - it('should change direction, decelerate and clamp to the max ground speed in the opposite sign '.. + it('should decelerate, turn and start moving to the left, 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 + pc.orientation = 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_same({horizontal_dirs.left, -pc_data.ground_accel_frame2}, - {pc.horizontal_dir, pc.ground_speed}) + {pc.orientation, pc.ground_speed}) end) - -- tests below seem symmetrical, but as a twist we have the character running backward + -- tests below seem symmetrical, but as a twist we have the character running backward (e.g. after being hit by a horizontal spring) -- 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 + it('should decelerate keeping same sign and orientation when character is facing right, has high ground speed < 0 and move intention x > 0', function () + pc.orientation = horizontal_dirs.right pc.ground_speed = -1.5 pc.move_intention.x = 1 pc:_update_ground_speed_by_intention() - assert.are_same({horizontal_dirs.left, -1.25}, - {pc.horizontal_dir, pc.ground_speed}) + assert.are_same({horizontal_dirs.right, -1.25}, + {pc.orientation, pc.ground_speed}) end) 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 + pc.orientation = 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.are_equal(horizontal_dirs.right, pc.orientation) 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 + pc.orientation = 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_same({horizontal_dirs.right, pc_data.ground_accel_frame2}, - {pc.horizontal_dir, pc.ground_speed}) + {pc.orientation, pc.ground_speed}) end) 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.orientation = horizontal_dirs.right + pc.ground_speed = 1.5 + pc:_update_ground_speed_by_intention() + assert.are_same({horizontal_dirs.right, 1.5 - pc_data.ground_friction_frame2}, + {pc.orientation, pc.ground_speed}) + end) + + it('should apply friction when character has ground speed > 0, move intention x is 0 and character is descending a low slope', function () + pc.orientation = horizontal_dirs.right + pc.ground_speed = 1.5 + pc.slope_angle = 0.0625 + pc:_update_ground_speed_by_intention() + assert.are_same({horizontal_dirs.right, 1.5 - pc_data.ground_friction_frame2}, + {pc.orientation, pc.ground_speed}) + end) + + it('should apply friction when character has ground speed > 0, move intention x is 0 and character is ascending a steep slope', function () + pc.orientation = horizontal_dirs.right pc.ground_speed = 1.5 + pc.slope_angle = 0.125 pc:_update_ground_speed_by_intention() assert.are_same({horizontal_dirs.right, 1.5 - pc_data.ground_friction_frame2}, - {pc.horizontal_dir, pc.ground_speed}) + {pc.orientation, pc.ground_speed}) + end) + + -- Original feature (not in SPG): No Friction on Steep Descending Slope + + it('should not apply friction when character has ground speed > 0, move intention x is 0 and character is descending a steep slope', function () + pc.orientation = horizontal_dirs.right + pc.ground_speed = 1.5 + pc.slope_angle = 1-0.125 + pc:_update_ground_speed_by_intention() + assert.are_same({horizontal_dirs.right, 1.5}, + {pc.orientation, pc.ground_speed}) end) -- bugfix history: missing tests that check the change of sign of ground speed 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 + pc.orientation = horizontal_dirs.right -- must be < friction pc.ground_speed = 0.01 pc:_update_ground_speed_by_intention() assert.are_same({horizontal_dirs.right, 0}, - {pc.horizontal_dir, pc.ground_speed}) + {pc.orientation, pc.ground_speed}) end) -- 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 + it('should apply friction and preserve direction when character has ground speed < 0 and move intention x is 0', function () + pc.orientation = horizontal_dirs.right pc.ground_speed = -1.5 pc:_update_ground_speed_by_intention() assert.are_same({horizontal_dirs.right, -1.5 + pc_data.ground_friction_frame2}, - {pc.horizontal_dir, pc.ground_speed}) + {pc.orientation, 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 + pc.orientation = horizontal_dirs.right -- must be < friction in abs pc.ground_speed = -0.01 pc:_update_ground_speed_by_intention() assert.are_same({horizontal_dirs.right, 0}, - {pc.horizontal_dir, pc.ground_speed}) + {pc.orientation, pc.ground_speed}) end) 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.orientation = horizontal_dirs.left pc:_update_ground_speed_by_intention() assert.are_same({horizontal_dirs.left, 0}, - {pc.horizontal_dir, pc.ground_speed}) + {pc.orientation, pc.ground_speed}) end) end) -- _update_ground_speed_by_intention @@ -2106,7 +2700,7 @@ describe('player_char', function () pc.position = vector(3, 4) pc.slope_angle = 0.125 - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(3, 4), 0.125, false, @@ -2120,7 +2714,7 @@ describe('player_char', function () pc.position = vector(3.5, 4) pc.slope_angle = 0.125 - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(3.5, 4), 0.125, false, @@ -2129,17 +2723,63 @@ describe('player_char', function () pc:_compute_ground_motion_result() ) end) + + it('(wall right) should return the current position and slope, is_blocked: false, is_falling: false', function () + pc.position = vector(3, 4.5) + pc.quadrant = directions.right + pc.slope_angle = 0.25 + + assert.are_same(motion.ground_motion_result( + vector(3, 4.5), + 0.25, + false, + false + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(ceiling) should return the current position and slope, is_blocked: false, is_falling: false', function () + pc.position = vector(3, 4.5) + pc.quadrant = directions.up + pc.slope_angle = 0.5 + + assert.are_same(motion.ground_motion_result( + vector(3, 4.5), + 0.5, + false, + false + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(wall left) should return the current position and slope, is_blocked: false, is_falling: false', function () + pc.position = vector(3, 4.5) + pc.quadrant = directions.left + pc.slope_angle = 0.75 + + assert.are_same(motion.ground_motion_result( + vector(3, 4.5), + 0.75, + false, + false + ), + pc:_compute_ground_motion_result() + ) + end) + end) - describe('(when _next_ground_step moves motion_result.position.x by 1px in the horizontal_dir without blocking nor falling)', function () + describe('(when _next_ground_step moves motion_result.position by 1px in the quadrant_horizontal_dir without blocking nor falling)', 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] + next_ground_step_mock = stub(player_char, "_next_ground_step", function (self, quadrant_horizontal_dir, motion_result) + local step_vec = self:quadrant_rotated(horizontal_dir_vectors[quadrant_horizontal_dir]) motion_result.position = motion_result.position + step_vec - motion_result.slope_angle = -0.125 + motion_result.slope_angle = (world.quadrant_to_right_angle(self.quadrant) - 0.01) % 1 end) end) @@ -2156,7 +2796,7 @@ describe('player_char', function () -- 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(motion.ground_motion_result( + assert.are_same(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, @@ -2170,12 +2810,12 @@ describe('player_char', function () -- ?? same reason as test above it('(vector(3, 4) at speed 1 on slope cos 0.5) should return vector(3.5, 4), is_blocked: false, is_falling: false', function () pc.position = vector(3, 4) - pc.slope_angle = -1/6 -- cos(-pi/3) = 1/2 + pc.slope_angle = 1-1/6 -- cos(-pi/3) = 1/2 pc.ground_speed = 1 -- * slope cos = 0.5 - assert.are_equal(motion.ground_motion_result( + assert.are_same(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 + 1-1/6, -- character has not moved by a full pixel, so visible position and slope remains the same false, false ), @@ -2188,9 +2828,9 @@ describe('player_char', function () pc.ground_speed = 0.5 -- we assume _compute_max_pixel_distance is correct, so it should return 1 - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(4, 4), - -0.125, + 1-0.01, false, false ), @@ -2202,9 +2842,9 @@ describe('player_char', function () pc.position = vector(3, 4) pc.ground_speed = -2.5 - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(0.5, 4), - -0.125, + 1-0.01, false, false ), @@ -2212,23 +2852,89 @@ describe('player_char', function () ) end) + it('(right wall, vector(3, 4) at speed 2 (going up) on slope cos 0.5) should return vector(3, 3), is_blocked: false, is_falling: false', function () + pc.position = vector(3, 4) + pc.quadrant = directions.right + pc.slope_angle = 0.25-1/6 -- cos(-pi/3) = 1/2 + pc.ground_speed = 2 -- * slope cos = 1 + + assert.are_same(motion.ground_motion_result( + vector(3, 3), + 0.25-0.01, -- 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('(ceiling, vector(3, 4) at speed 2 (going up) on slope cos 0.5) should return vector(2, 4), is_blocked: false, is_falling: false', function () + pc.position = vector(3, 4) + pc.quadrant = directions.up + pc.slope_angle = 0.5-1/6 -- cos(-pi/3) = 1/2 + pc.ground_speed = 2 -- * slope cos = 1 + + -- unfortunately native Lua has small calculation errors + -- so we must check for almost equal on result position x + local result = pc:_compute_ground_motion_result() + assert.is_true(almost_eq_with_message(2, result.position.x)) + + -- then set that position to expected value and check the rest + -- with an are_equal to cover all members + result.position.x = 2 + assert.are_same(motion.ground_motion_result( + vector(2, 4), + 0.5-0.01, + false, + false + ), + result + ) + end) + + it('(left wall, vector(3, 4) at speed 2 (going down) on slope cos 0.5) should return vector(3, 5), is_blocked: false, is_falling: false', function () + pc.position = vector(3, 4) + pc.quadrant = directions.left + pc.slope_angle = 0.75-1/6 -- cos(-pi/3) = 1/2 + pc.ground_speed = 2 -- * slope cos = 1 + + -- unfortunately native Lua has small calculation errors + -- so we must check for almost equal on result position y + local result = pc:_compute_ground_motion_result() + assert.is_true(almost_eq_with_message(5, result.position.y)) + + -- then set that position to expected value and check the rest + -- with an are_equal to cover all members + result.position.y = 5 + assert.are_same(motion.ground_motion_result( + vector(3, 5), + 0.75-0.01, + false, + false + ), + result + ) + end) + 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 or x >= 5)', function () + describe('(when _next_ground_step moves motion_result.position by 1px in the quadrant_quadrant_horizontal_dir, but blocks when motion_result.position.x < -4 (moving left) or x >= 5 (moving right) or y < -4 (moving up) or y >= 5 (moving down))', 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] - -- x < -4 <=> x <= -5 for an integer as passed to step functions, + next_ground_step_mock = stub(player_char, "_next_ground_step", function (self, quadrant_horizontal_dir, motion_result) + + local step_vec = self:quadrant_rotated(horizontal_dir_vectors[quadrant_horizontal_dir]) + -- x/y < -4 <=> x/y <= -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 + if motion_result.position.x < -4 and step_vec.x < 0 or motion_result.position.x >= 5 and step_vec.x > 0 or + motion_result.position.y < -4 and step_vec.y < 0 or motion_result.position.y >= 5 and step_vec.y > 0 then motion_result.is_blocked = true else motion_result.position = motion_result.position + step_vec - motion_result.slope_angle = 0.125 + motion_result.slope_angle = (world.quadrant_to_right_angle(self.quadrant) + 0.01) % 1 end end) end) @@ -2242,9 +2948,9 @@ describe('player_char', function () 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( + assert.are_same(motion.ground_motion_result( vector(5, 4), - 0.125, + 0.01, false, false ), @@ -2257,9 +2963,9 @@ describe('player_char', function () 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( + assert.are_same(motion.ground_motion_result( vector(-5, 4), - 0.125, + 0.01, false, false ), @@ -2274,9 +2980,9 @@ describe('player_char', function () pc.ground_speed = 0.5 -- we assume _compute_max_pixel_distance is correct, so it should return 1 - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(5, 4), - 0.125, + 0.01, false, false ), @@ -2290,9 +2996,9 @@ describe('player_char', function () pc.ground_speed = -1 -- we assume _compute_max_pixel_distance is correct, so it should return 1 - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(-5, 4), - 0.125, + 0.01, false, false ), @@ -2305,12 +3011,12 @@ describe('player_char', 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.slope_angle = 1-1/6 -- cos(-pi/3) = 1/2 pc.ground_speed = 1 -- * slope cos = -0.5 - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(5, 4), - 0.125, -- new slope angle, no relation with initial one + 0.01, -- new slope angle, no relation with initial one false, false ), @@ -2322,12 +3028,12 @@ describe('player_char', function () -- 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.slope_angle = 1-1/6 -- cos(-pi/3) = 1/2 pc.ground_speed = -2 -- * slope cos = -1 - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(-5, 4), - 0.125, -- new slope angle, no relation with initial one + 0.01, -- new slope angle, no relation with initial one false, false ), @@ -2343,9 +3049,9 @@ describe('player_char', function () -- 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( + assert.are_same(motion.ground_motion_result( vector(5, 4), - 0.125, + 0.01, true, false ), @@ -2361,9 +3067,9 @@ describe('player_char', function () -- 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( + assert.are_same(motion.ground_motion_result( vector(-5, 4), - 0.125, + 0.01, true, false ), @@ -2375,13 +3081,13 @@ describe('player_char', function () -- ?? 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 () pc.position = vector(4, 4) - pc.slope_angle = -1/6 -- cos(-pi/3) = 1/2 + pc.slope_angle = 1-1/6 -- cos(-pi/3) = 1/2 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(motion.ground_motion_result( + assert.are_same(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 + 1-1/6, -- character has not moved by a full pixel, so visible position and slope remains the same false, false ), @@ -2392,12 +3098,12 @@ describe('player_char', function () 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.slope_angle = 1-1/6 -- cos(-pi/3) = 1/2 pc.ground_speed = -1.5 -- * slope cos = -0.75 - assert.are_equal(motion.ground_motion_result( + assert.are_same(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 + 1-1/6, -- character has not moved by a full pixel, so visible position and slope remains the same false, false ), @@ -2407,13 +3113,13 @@ describe('player_char', 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.slope_angle = 1-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(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(5, 4), - 0.125, + 0.01, true, false ), @@ -2423,12 +3129,12 @@ describe('player_char', 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.slope_angle = 1-1/6 -- cos(-pi/3) = 1/2 pc.ground_speed = -3 -- * slope cos = -1.5 - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(-5, 4), - 0.125, + 0.01, true, false ), @@ -2446,7 +3152,7 @@ describe('player_char', function () -- 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( + assert.are_same(motion.ground_motion_result( vector(5, 4), 0, -- character couldn't move at all, so we preserved the initial slope angle true, @@ -2463,7 +3169,7 @@ describe('player_char', function () -- 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( + assert.are_same(motion.ground_motion_result( vector(-5, 4), 0, -- character couldn't move at all, so we preserved the initial slope angle true, @@ -2483,7 +3189,7 @@ describe('player_char', function () -- 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( + assert.are_same(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, @@ -2499,7 +3205,7 @@ describe('player_char', function () -- 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( + assert.are_same(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! @@ -2515,7 +3221,7 @@ describe('player_char', function () -- 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( + assert.are_same(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 @@ -2525,15 +3231,15 @@ 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 () + it('(vector(3, 4) at speed 3) should return vector(5, 4), slope before blocked, is_blocked: true, 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( + assert.are_same(motion.ground_motion_result( vector(5, 4), - 0.125, + 0.01, true, false ), @@ -2541,15 +3247,72 @@ 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 () + it('(vector(-3, 4) at speed -3) should return vector(-5, 4), slope before blocked, is_blocked: true, 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 + -- but because of the blocking, we stop at x=-5 instead of -6.5 - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(-5, 4), - 0.125, + 0.01, + true, + false + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(right wall, vector(3, -3) at speed 3 (moving up)) should return vector(3, -5), slope before blocked, is_blocked: true, is_falling: false', function () + pc.position = vector(3, -3) + pc.ground_speed = 3.5 + pc.quadrant = directions.right + pc.slope_angle = 0.25 + + -- we assume _compute_max_pixel_distance is correct, so it should return 3 + -- but because of the blocking, we stop at y=-5 instead of -6.5 + + assert.are_same(motion.ground_motion_result( + vector(3, -5), + 0.25 + 0.01, + true, + false + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(ceiling, vector(-3, 3) at speed 3 (moving left)) should return vector(-5, 3), slope before blocked, is_blocked: true, is_falling: false', function () + pc.position = vector(-3, 3) + pc.ground_speed = 3.5 + pc.quadrant = directions.up + pc.slope_angle = 0.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_same(motion.ground_motion_result( + vector(-5, 3), + 0.5 + 0.01, + true, + false + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(left wall, vector(3, 3) at speed 3 (moving down)) should return vector(3, 5), slope before blocked, is_blocked: true, is_falling: false', function () + pc.position = vector(3, 3) + pc.ground_speed = 3.5 + pc.quadrant = directions.left + pc.slope_angle = 0.75 + + -- we assume _compute_max_pixel_distance is correct, so it should return 3 + -- but because of the blocking, we stop at y=5 instead of 6.5 + + assert.are_same(motion.ground_motion_result( + vector(3, 5), + 0.75 + 0.01, true, false ), @@ -2560,24 +3323,40 @@ describe('player_char', function () end) -- bugfix history: the mock was wrong (was using updated position instead of original_position) - describe('. (when _next_ground_step moves motion_result.position.x by 1px in the horizontal_dir on x < 7, falls on 5 <= x < 7 and blocks on x >= 7)', function () + describe('. (when _next_ground_step moves motion_result.position by 1px in the quadrant_horizontal_dir on x/y < 7, falls on 5 <= x/y < 7 and blocks on x/y >= 7 with x/y matching step direction)', 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] + next_ground_step_mock = stub(player_char, "_next_ground_step", function (self, quadrant_horizontal_dir, motion_result) + local step_vec = self:quadrant_rotated(horizontal_dir_vectors[quadrant_horizontal_dir]) local original_position = motion_result.position - if original_position.x < 7 then - motion_result.position = original_position + step_vec - motion_result.slope_angle = 0.25 - end - if original_position.x >= 5 then + -- quadrant_rotated busted implementation has perfect precision, so don't worry about checking ~= 0 + if step_vec.x ~= 0 then if original_position.x < 7 then - motion_result.is_falling = true - motion_result.slope_angle = nil -- mimic actual implementation - else - motion_result.is_blocked = true + motion_result.position = original_position + step_vec + motion_result.slope_angle = 0.25 + end + if original_position.x >= 5 then + if original_position.x < 7 then + motion_result.is_falling = true + motion_result.slope_angle = nil -- mimic actual implementation + else + motion_result.is_blocked = true + end + end + else -- moving on y (quadrant is left or right) + if original_position.y < 7 then + motion_result.position = original_position + step_vec + motion_result.slope_angle = 0.25 + end + if original_position.y >= 5 then + if original_position.y < 7 then + motion_result.is_falling = true + motion_result.slope_angle = nil -- mimic actual implementation + else + motion_result.is_blocked = true + end end end end) @@ -2587,13 +3366,13 @@ describe('player_char', function () next_ground_step_mock:revert() end) - it('(vector(3, 4) at speed 3) should return vector(6, 4), slope_angle: nil, is_blocked: false, is_falling: false', function () + it('(vector(3, 4) at speed 3) should return vector(6, 4), slope_angle: nil, is_blocked: false, is_falling: true', function () pc.position = vector(3, 4) pc.ground_speed = 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(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(6, 4), nil, false, @@ -2603,13 +3382,13 @@ describe('player_char', function () ) end) - it('(vector(3, 4) at speed 3) should return vector(7, 4), slope_angle: nil, is_blocked: false, is_falling: false', function () + it('(vector(3, 4) at speed 5) should return vector(7, 4), slope_angle: nil, is_blocked: true, is_falling: true', function () pc.position = vector(3, 4) pc.ground_speed = 5 -- we assume _compute_max_pixel_distance is correct, so it should return 3 -- we are falling then blocked on 7 - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(7, 4), nil, true, @@ -2619,6 +3398,120 @@ describe('player_char', function () ) end) + it('(right wall, vector(4, 3) at speed -3 (moving down)) should return vector(4, 6), slope_angle: nil, is_blocked: false, is_falling: true', function () + pc.position = vector(4, 3) + pc.ground_speed = -3 + pc.quadrant = directions.right + pc.slope_angle = 0.25 + + -- 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 y=6 + + assert.are_same(motion.ground_motion_result( + vector(4, 6), + nil, + false, + true + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(right wall, vector(4, 3) at speed -5 (moving down)) should return vector(7, 4), slope_angle: nil, is_blocked: true, is_falling: true', function () + pc.position = vector(4, 3) + pc.ground_speed = -5 + pc.quadrant = directions.right + pc.slope_angle = 0.25 + + -- we assume _compute_max_pixel_distance is correct, so it should return 3 + -- we are falling then blocked on 7 + + assert.are_same(motion.ground_motion_result( + vector(4, 7), + nil, + true, + true + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(ceiling, vector(3, 4) at speed -3 (moving right)) should return vector(4, 6), slope_angle: nil, is_blocked: false, is_falling: true', function () + pc.position = vector(3, 4) + pc.ground_speed = -3 + pc.quadrant = directions.up + pc.slope_angle = 0.5 + + -- 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_same(motion.ground_motion_result( + vector(6, 4), + nil, + false, + true + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(ceiling, vector(3, 4) at speed -5 (moving right)) should return vector(7, 4), slope_angle: nil, is_blocked: true, is_falling: true', function () + pc.position = vector(3, 4) + pc.ground_speed = -5 + pc.quadrant = directions.up + pc.slope_angle = 0.5 + + -- we assume _compute_max_pixel_distance is correct, so it should return 3 + -- we are falling then blocked on 7 + + assert.are_same(motion.ground_motion_result( + vector(7, 4), + nil, + true, + true + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(left wall, vector(4, 3) at speed 3 (moving down)) should return vector(4, 6), slope_angle: nil, is_blocked: false, is_falling: true', function () + pc.position = vector(4, 3) + pc.ground_speed = 3 + pc.quadrant = directions.left + pc.slope_angle = 0.75 + + -- 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 y=6 + + assert.are_same(motion.ground_motion_result( + vector(4, 6), + nil, + false, + true + ), + pc:_compute_ground_motion_result() + ) + end) + + it('(left wall, vector(4, 3) at speed 5 (moving down)) should return vector(7, 4), slope_angle: nil, is_blocked: true, is_falling: true', function () + pc.position = vector(4, 3) + pc.ground_speed = 5 + pc.quadrant = directions.left + pc.slope_angle = 0.75 + + -- we assume _compute_max_pixel_distance is correct, so it should return 3 + -- we are falling then blocked on 7 + + assert.are_same(motion.ground_motion_result( + vector(4, 7), + nil, + true, + true + ), + pc:_compute_ground_motion_result() + ) + end) + end) end) -- _compute_ground_motion_result @@ -2632,7 +3525,9 @@ describe('player_char', function () describe('(with flat ground)', function () before_each(function () - mock_mset(0, 1, 64) -- full tile + -- . + -- # + mock_mset(0, 1, full_tile_id) -- full tile end) -- in the tests below, we can use pc_data.center_height_standing directly instead @@ -2650,7 +3545,7 @@ describe('player_char', function () -- step flat pc:_next_ground_step(horizontal_dirs.left, motion_result) - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(-2, 8 - pc_data.center_height_standing), 0, false, @@ -2660,9 +3555,154 @@ describe('player_char', function () ) end) - it('when stepping right with the left sensor still on the ground, increment x', function () + it('when stepping right with the left sensor still on the ground, increment x', function () + local motion_result = motion.ground_motion_result( + vector(9, 8 - pc_data.center_height_standing), + 0, + false, + false + ) + + -- step flat + pc:_next_ground_step(horizontal_dirs.right, motion_result) + + assert.are_same(motion.ground_motion_result( + vector(10, 8 - pc_data.center_height_standing), + 0, + false, + false + ), + motion_result + ) + end) + + it('when stepping left leaving the ground, decrement x and fall', function () + local motion_result = motion.ground_motion_result( + vector(-2, 8 - pc_data.center_height_standing), + 0, + false, + false + ) + + -- step fall + pc:_next_ground_step(horizontal_dirs.left, motion_result) + + assert.are_same(motion.ground_motion_result( + vector(-3, 8 - pc_data.center_height_standing), + nil, + false, + true + ), + motion_result + ) + end) + + it('when stepping right leaving the ground, increment x and fall', function () + local motion_result = motion.ground_motion_result( + vector(10, 8 - pc_data.center_height_standing), + 0, + false, + false + ) + + -- step fall + pc:_next_ground_step(horizontal_dirs.right, motion_result) + + assert.are_same(motion.ground_motion_result( + vector(11, 8 - pc_data.center_height_standing), + nil, + false, + true + ), + motion_result + ) + end) + + it('when stepping right back on the ground, increment x and cancel fall', function () + local motion_result = motion.ground_motion_result( + vector(-3, 8 - pc_data.center_height_standing), + nil, + false, + true + ) + + -- step land (very rare) + pc:_next_ground_step(horizontal_dirs.right, motion_result) + + assert.are_same(motion.ground_motion_result( + vector(-2, 8 - pc_data.center_height_standing), + 0, + false, + false + ), + motion_result + ) + end) + + -- for other quadrants we only test the most common cases + + it('(right wall) when stepping q-right (up) with the q-left sensor still on the ground, DEcrement y', function () + pc.quadrant = directions.right + pc.slope_angle = 0.25 + + -- remember to place the character on the left of the tile at (0, 1) as if walking on its left side + -- this means the center offset should be subtracted from X this time + local motion_result = motion.ground_motion_result( + vector(0 - pc_data.center_height_standing, 12), + 0, + false, + false + ) + + -- step flat + pc:_next_ground_step(horizontal_dirs.right, motion_result) + + assert.are_same(motion.ground_motion_result( + vector(0 - pc_data.center_height_standing, 11), + 0.25, + false, + false + ), + motion_result + ) + end) + + it('(right wall) when stepping q-right (up) with the q-left sensor leaving the ground, DEcrement y and fall', function () + pc.quadrant = directions.right + pc.slope_angle = 0.25 + + -- remember to place the character on the left of the tile at (0, 1) as if walking on its left side + -- this means the center offset should be subtracted from X this time + local motion_result = motion.ground_motion_result( + vector(0 - pc_data.center_height_standing, 6), + 0, + false, + false + ) + + -- step fall + pc:_next_ground_step(horizontal_dirs.right, motion_result) + + assert.are_same(motion.ground_motion_result( + vector(0 - pc_data.center_height_standing, 5), + nil, + false, + true + ), + motion_result + ) + end) + + -- FIXME + + it('(ceiling) when stepping q-right (left) with the q-left sensor still on the ground, DEcrement x', function () + pc.quadrant = directions.up + pc.slope_angle = 0.25 + + -- remember to place the character on the left of the tile at (0, 1) as if walking on its left side + -- this means the center offset should be subtracted from X this time local motion_result = motion.ground_motion_result( - vector(9, 8 - pc_data.center_height_standing), + vector(-1, 16 + pc_data.center_height_standing), 0, false, false @@ -2671,9 +3711,9 @@ describe('player_char', function () -- step flat pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(motion.ground_motion_result( - vector(10, 8 - pc_data.center_height_standing), - 0, + assert.are_same(motion.ground_motion_result( + vector(-2, 16 + pc_data.center_height_standing), + 0.5, false, false ), @@ -2681,19 +3721,24 @@ describe('player_char', function () ) end) - it('when stepping left leaving the ground, decrement x and fall', function () + it('(ceiling) when stepping q-right (left) with the q-left sensor leaving the ground, DEcrement x and fall', function () + pc.quadrant = directions.up + pc.slope_angle = 0.25 + + -- remember to place the character on the left of the tile at (0, 1) as if walking on its left side + -- this means the center offset should be subtracted from X this time local motion_result = motion.ground_motion_result( - vector(-2, 8 - pc_data.center_height_standing), + vector(-2, 16 + pc_data.center_height_standing), 0, false, false ) -- step fall - pc:_next_ground_step(horizontal_dirs.left, motion_result) + pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(motion.ground_motion_result( - vector(-3, 8 - pc_data.center_height_standing), + assert.are_same(motion.ground_motion_result( + vector(-3, 16 + pc_data.center_height_standing), nil, false, true @@ -2702,60 +3747,72 @@ describe('player_char', function () ) end) - it('when stepping right leaving the ground, increment x and fall', function () + it('(left wall) when stepping q-right (down) with the q-left sensor still on the ground, INcrement y', function () + pc.quadrant = directions.left + pc.slope_angle = 0.75 + + -- remember to place the character on the left of the tile at (0, 1) as if walking on its left side + -- this means the center offset should be subtracted from X this time local motion_result = motion.ground_motion_result( - vector(10, 8 - pc_data.center_height_standing), + vector(8 + pc_data.center_height_standing, 15), 0, false, false ) - -- step fall + -- step flat pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(motion.ground_motion_result( - vector(11, 8 - pc_data.center_height_standing), - nil, + assert.are_same(motion.ground_motion_result( + vector(8 + pc_data.center_height_standing, 16), + 0.75, false, - true + false ), motion_result ) end) - it('when stepping right back on the ground, increment x and cancel fall', function () + it('(left wall) when stepping q-right (down) with the q-left sensor leaving the ground, INcrement y and fall', function () + pc.quadrant = directions.left + pc.slope_angle = 0.75 + + -- remember to place the character on the left of the tile at (0, 1) as if walking on its left side + -- this means the center offset should be subtracted from X this time local motion_result = motion.ground_motion_result( - vector(-3, 8 - pc_data.center_height_standing), - nil, + vector(0 - pc_data.center_height_standing, 16), + 0, false, - true + false ) - -- step land (very rare) + -- step fall pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(motion.ground_motion_result( - vector(-2, 8 - pc_data.center_height_standing), - 0, + assert.are_same(motion.ground_motion_result( + vector(0 - pc_data.center_height_standing, 17), + nil, false, - false + true ), motion_result ) end) + -- TODO: more tests, but test lower-level _compute_ground_sensors_signed_distance first + end) describe('(with walls)', function () before_each(function () - -- X X - -- XXX - 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) + -- # # + -- ### + mock_mset(0, 0, full_tile_id) -- full tile (left wall) + mock_mset(0, 1, full_tile_id) -- full tile + mock_mset(1, 1, full_tile_id) -- full tile + mock_mset(2, 0, full_tile_id) -- full tile + mock_mset(2, 1, full_tile_id) -- full tile (right wall) end) it('when stepping left and hitting the wall, preserve x and block', function () @@ -2769,7 +3826,7 @@ describe('player_char', function () -- step block pc:_next_ground_step(horizontal_dirs.left, motion_result) - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(3, 8 - pc_data.center_height_standing), 0, true, @@ -2790,7 +3847,7 @@ describe('player_char', function () -- step block pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(5, 8 - pc_data.center_height_standing), 0, true, @@ -2805,10 +3862,10 @@ describe('player_char', function () describe('(with wall without ground below)', function () before_each(function () - -- X - -- X - mock_mset(0, 1, 64) -- full tile (ground) - mock_mset(1, 0, 64) -- full tile (wall without ground below) + -- # + -- # + mock_mset(0, 1, full_tile_id) -- full tile (ground) + mock_mset(1, 0, full_tile_id) -- full tile (wall without ground below) end) -- it will fail until _compute_signed_distance_to_closest_ground @@ -2824,7 +3881,7 @@ describe('player_char', function () -- step block pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(5, 8 - pc_data.center_height_standing), 0, true, @@ -2839,10 +3896,10 @@ describe('player_char', function () describe('(with head wall)', function () before_each(function () - -- X + -- # -- = - mock_mset(0, 1, 70) -- bottom half-tile - mock_mset(1, 0, 64) -- full tile (head wall) + mock_mset(0, 1, half_tile_id) -- bottom half-tile + mock_mset(1, 0, full_tile_id) -- full tile (head wall) end) -- it will fail until _compute_signed_distance_to_closest_ground @@ -2858,7 +3915,7 @@ describe('player_char', function () -- step block pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(5, 12 - pc_data.center_height_standing), 0, true, @@ -2877,9 +3934,9 @@ describe('player_char', function () before_each(function () -- / - -- X - mock_mset(0, 1, 64) -- full tile (ground) - mock_mset(1, 0, 65) -- ascending slope 45 + -- # + mock_mset(0, 1, full_tile_id) -- full tile (ground) + mock_mset(1, 0, asc_slope_45_id) -- ascending slope 45 end) it('when stepping right from the bottom of the ascending slope, increment x and adjust y', function () @@ -2893,9 +3950,9 @@ describe('player_char', function () -- step down pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(6, 7 - pc_data.center_height_standing), - -45/360, + 45/360, false, false ), @@ -2908,18 +3965,18 @@ describe('player_char', function () describe('(with ascending slope and wall)', function () before_each(function () - -- X X - -- X/X - 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) + -- # # + -- #/# + mock_mset(0, 0, full_tile_id) -- 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, full_tile_id) -- full tile (wall) + mock_mset(1, 1, asc_slope_45_id) -- ascending slope 45 + mock_mset(2, 0, full_tile_id) -- 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 = motion.ground_motion_result( vector(12, 9 - pc_data.center_height_standing), - -45/360, + 45/360, false, false ) @@ -2927,9 +3984,9 @@ describe('player_char', function () -- step down pc:_next_ground_step(horizontal_dirs.left, motion_result) - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(11, 10 - pc_data.center_height_standing), - -45/360, + 45/360, false, false ), @@ -2940,7 +3997,7 @@ describe('player_char', function () it('when stepping right on the ascending slope without leaving the ground, decrement x and adjust y', function () local motion_result = motion.ground_motion_result( vector(12, 9 - pc_data.center_height_standing), - -45/360, + 45/360, false, false ) @@ -2948,9 +4005,9 @@ describe('player_char', function () -- step up pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(13, 8 - pc_data.center_height_standing), - -45/360, + 45/360, false, false ), @@ -2969,7 +4026,7 @@ describe('player_char', function () -- step up blocked pc:_next_ground_step(horizontal_dirs.right, motion_result) - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(13, 10 - pc_data.center_height_standing), -45/360, true, @@ -2990,7 +4047,7 @@ describe('player_char', function () -- step down blocked pc:_next_ground_step(horizontal_dirs.left, motion_result) - assert.are_equal(motion.ground_motion_result( + assert.are_same(motion.ground_motion_result( vector(11, 10 - pc_data.center_height_standing), -45/360, true, @@ -3055,7 +4112,7 @@ describe('player_char', function () setup(function () stub(player_char, "get_full_height", function () - return 11 + return 16 end) end) @@ -3074,13 +4131,13 @@ describe('player_char', function () describe('(1 full tile)', function () before_each(function () - -- .X - mock_mset(1, 0, 64) -- full tile (act like a full ceiling if position is at bottom) + -- .# + mock_mset(1, 0, full_tile_id) -- 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 () - -- here, the current tile is the full tile, and we only check tiles above, so we detect nothing - assert.is_false(pc:_is_column_blocked_by_ceiling_at(vector(8, 7.9))) + it('should return true for sensor position just above the bottom of the tile', function () + -- with new implementation, we check tile even at foot level + assert.is_true(pc:_is_column_blocked_by_ceiling_at(vector(8, 7.9))) end) it('should return false for sensor position on the left of the tile', function () @@ -3105,7 +4162,7 @@ describe('player_char', function () end) it('should return true for sensor position below the tile, at character height - 1px', function () - assert.is_true(pc:_is_column_blocked_by_ceiling_at(vector(12, 8 + 11 - 1))) + assert.is_true(pc:_is_column_blocked_by_ceiling_at(vector(12, 8 + 16 - 1))) end) -- bugfix history: @@ -3114,7 +4171,66 @@ describe('player_char', function () -- 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 + 11))) + assert.is_false(pc:_is_column_blocked_by_ceiling_at(vector(12, 8 + 16))) + end) + + end) + + describe('(1 half-tile)', function () + + before_each(function () + -- = + mock_mset(0, 0, half_tile_id) + end) + + it('#solo should return false for sensor position in the middle of the tile', function () + -- we now start checking ceiling a few pixels q-above character feet + -- and ignore reverse full height on same tile as sensor, so slope not detected as ceiling + assert.is_false(pc:_is_column_blocked_by_ceiling_at(vector(4, 6))) + end) + + it('should return false for sensor position at the bottom of the tile', function () + -- here we don't detect a ceiling because y = 8 is considered belonging to + -- tile j = 1, but we define ignore_reverse = start_tile_loc == curr_tile_loc + -- not ignore_reverse = curr_tile_loc == curr_tile_loc + assert.is_false(pc:_is_column_blocked_by_ceiling_at(vector(4, 8))) + end) + + it('should return false for sensor position 2 px below tile (so that 4px above is inside tile)', function () + -- this test makes sure that we ignore reverse full height for start tile + -- *not* sensor tile, which is different when sensor is less than 4px of the neighboring tile + -- in iteration direction + assert.is_false(pc:_is_column_blocked_by_ceiling_at(vector(4, 10))) + end) + + it('should return false for quadrant left, sensor position 5 px q-inside tile', function () + pc.quadrant = directions.left + assert.is_false(pc:_is_column_blocked_by_ceiling_at(vector(3, 4))) + end) + + it('should return true for quadrant left, sensor position 6 px q-inside tile', function () + pc.quadrant = directions.left + assert.is_true(pc:_is_column_blocked_by_ceiling_at(vector(2, 4))) + end) + + it('should return false for quadrant right, sensor position 5 px q-inside tile', function () + pc.quadrant = directions.right + assert.is_false(pc:_is_column_blocked_by_ceiling_at(vector(4, 4))) + end) + + it('should return true for quadrant right, sensor position 6 px q-inside tile', function () + -- this test makes sure that we do *not* ignore reverse full height for initial tile if + -- that are full horizontal rectangle (see world._compute_qcolumn_height_at) + -- since slope_angle_to_interiors has a bias 0 -> right so onceiling check, + -- we check on left which is reverse of tile interior_h + -- (if bias was for left, then the test above would check this instead) + pc.quadrant = directions.right + -- note that we also detect ceiling on (5, 4) although it is symmetrical to the (3, 4) + -- test for quadrant left, due to the fact that pixel x = 0 is considered still in tile i = 0 + -- we can fix the disymmetry with some .5 pixel extent in qy in both ground distance and ceiling check + -- (as in the qx direction with ground sensor extent) but we don't mind since Classic Sonic itself + -- has an odd size collider in reality + assert.is_true(pc:_is_column_blocked_by_ceiling_at(vector(6, 4))) end) end) @@ -3123,20 +4239,16 @@ describe('player_char', function () before_each(function () -- / - mock_mset(0, 0, 65) + mock_mset(0, 0, asc_slope_45_id) end) it('should return false for sensor position on the left of the tile', function () - -- normally the character should step up and pass this position during the next-step pass - -- and this returns false so the character won't be blocked assert.is_false(pc:_is_column_blocked_by_ceiling_at(vector(0, 7))) end) it('should return true for sensor position at the bottom-left of the tile', function () - -- technically this is still a step up, but we consider it is the next-step method's fault - -- if it didn't step up correctly so we afford to return true and block the character, - -- as it makes more simple code - assert.is_true(pc:_is_column_blocked_by_ceiling_at(vector(0, 8))) + -- we now start checking ceiling a few pixels q-above character feet, so slope not detected as ceiling + assert.is_false(pc:_is_column_blocked_by_ceiling_at(vector(0, 8))) end) end) @@ -3177,16 +4289,31 @@ describe('player_char', function () assert.are_same({true, vector(4.1, -4.25), motion_states.air_spin, true}, {result, pc.velocity, pc.motion_state, pc.has_jumped_this_frame}) end) + it('should add impulse along ground normal when slope_angle is not 0 (and we should jump)', function () + pc.velocity = vector(2, -2) + pc.should_jump = true + pc.slope_angle = 0.125 + + pc:_check_jump() + + assert.is_true(almost_eq_with_message(2 - pc_data.initial_var_jump_speed_frame / sqrt(2), pc.velocity.x)) + assert.is_true(almost_eq_with_message(-2 - pc_data.initial_var_jump_speed_frame / sqrt(2), pc.velocity.y)) + end) + end) describe('_update_platformer_motion_airborne', function () setup(function () spy.on(player_char, "_enter_motion_state") + spy.on(player_char, "_check_hold_jump") + spy.on(player_char, "set_slope_angle_with_quadrant") end) teardown(function () player_char._enter_motion_state:revert() + player_char._check_hold_jump:revert() + player_char.set_slope_angle_with_quadrant:revert() end) before_each(function () @@ -3194,6 +4321,8 @@ describe('player_char', function () pc:_enter_motion_state(motion_states.falling) -- clear spy just after this instead of after_each to avoid messing the call count player_char._enter_motion_state:clear() + player_char._check_hold_jump:clear() + player_char.set_slope_angle_with_quadrant:clear() 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 () @@ -3219,71 +4348,100 @@ describe('player_char', function () 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.motion_state = motion_states.air_spin 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 + -- call check + assert.spy(player_char._check_hold_jump).was_called(1) + assert.spy(player_char._check_hold_jump).was_called_with(match.ref(pc)) + + -- result check 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.motion_state = motion_states.air_spin 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 + -- call check (but will do nothing) + assert.spy(player_char._check_hold_jump).was_called(1) + assert.spy(player_char._check_hold_jump).was_called_with(match.ref(pc)) + + -- result check 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.motion_state = motion_states.air_spin 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 + -- call check (but will do nothing) + assert.spy(player_char._check_hold_jump).was_called(1) + assert.spy(player_char._check_hold_jump).was_called_with(match.ref(pc)) + + -- result check 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.motion_state = motion_states.air_spin 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 + -- call check (but will do nothing) + assert.spy(player_char._check_hold_jump).was_called(1) + assert.spy(player_char._check_hold_jump).was_called_with(match.ref(pc)) + + -- result check 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.motion_state = motion_states.air_spin 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 + -- call check + assert.spy(player_char._check_hold_jump).was_called(1) + assert.spy(player_char._check_hold_jump).was_called_with(match.ref(pc)) + + -- result check -- 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) + it('should NOT check for speed interrupt at all when running falling (not air_spin)', function () + pc.motion_state = motion_states.falling + 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 - assert.are_same({-1 + pc_data.gravity_frame2, false}, {pc.velocity.y, pc.has_jumped_this_frame}) + -- call check + assert.spy(player_char._check_hold_jump).was_not_called() + + -- result check + assert.are_same({-3 + pc_data.gravity_frame2, false}, {pc.velocity.y, pc.has_jumped_this_frame}) end) it('should apply air accel x', function () @@ -3292,28 +4450,35 @@ describe('player_char', function () 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.orientation = 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) + assert.are_equal(horizontal_dirs.left, pc.orientation) end) it('should set horizontal direction to intended motion direction: right', function () - pc.horizontal_dir = horizontal_dirs.left + pc.orientation = 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) + assert.are_equal(horizontal_dirs.right, pc.orientation) + end) + + it('should clamp velocity Y if beyond limit (positive)', function () + pc.velocity.y = 1000 + + pc:_update_platformer_motion_airborne() + + assert.are_equal(pc_data.max_air_velocity_y, pc.velocity.y) end) -- bugfix history: @@ -3323,7 +4488,7 @@ describe('player_char', function () pc:_update_platformer_motion_airborne() - assert.are_equal(vector(2, 8), pc.position) + assert.are_same(vector(2, 8), pc.position) end) it('should preserve velocity.y', function () @@ -3340,10 +4505,11 @@ describe('player_char', function () 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 () + describe('(when _compute_air_motion_result returns a motion result with is_blocked_by_wall: false, is_blocked_by_ceiling: true) '.. + '(when apply_air_drag multiplies velocity x by 0.9 no matter what)', function () setup(function () - compute_air_motion_result_mock = stub(player_char, "_compute_air_motion_result", function (self) + stub(player_char, "_compute_air_motion_result", function (self) return motion.air_motion_result( vector(2, 8), false, -- not the focus, but verified @@ -3352,14 +4518,19 @@ describe('player_char', function () nil ) end) + stub(player_char, "apply_air_drag", function (self) + self.velocity.x = 0.9 * self.velocity.x + end) end) teardown(function () - compute_air_motion_result_mock:revert() + player_char._compute_air_motion_result:revert() + player_char.apply_air_drag:revert() end) after_each(function () - compute_air_motion_result_mock:clear() + player_char._compute_air_motion_result:clear() + player_char.apply_air_drag:clear() end) it('should set velocity.y to 0', function () @@ -3374,12 +4545,17 @@ describe('player_char', function () assert.are_equal(0, pc.velocity.y) end) - it('should preserve velocity.x', function () + it('should apply air drag, then preserve velocity.x on hit ceiling', function () pc.velocity = vector(10, -10) pc:_update_platformer_motion_airborne() - assert.are_equal(10, pc.velocity.x) + -- spy test (should always be called anyway, but only this test really demonstrates X velocity) + assert.spy(player_char.apply_air_drag).was_called(1) + assert.spy(player_char.apply_air_drag).was_called_with(match.ref(pc)) + + -- value test + assert.are_equal(9, pc.velocity.x) end) end) -- compute_air_motion_result_mock (is_blocked_by_ceiling: true) @@ -3450,14 +4626,17 @@ describe('player_char', function () compute_air_motion_result_mock:clear() end) - it('should enter grounded state with slope_angle: 0.5', function () + it('should enter grounded state and set_slope_angle_with_quadrant: 0.5', function () + pc.slope_angle = 0 + 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) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called(1) + assert.spy(player_char.set_slope_angle_with_quadrant).was_called_with(match.ref(pc), 0.5) end) end) -- compute_air_motion_result_mock (is_blocked_by_wall: true) @@ -3509,11 +4688,61 @@ describe('player_char', function () end) + describe('apply_air_drag', function () + + it('(when velocity is 0.25 0) should do nothing', function () + -- abs(vel.x) >= pc_data.air_drag_min_velocity_x but vel.y >= 0 + pc.velocity = vector(0.25, 0) + + pc:apply_air_drag() + + assert.are_same(vector(0.25, 0), pc.velocity) + end) + + it('(when velocity is 0.25 7) should do nothing', function () + -- abs(vel.x) >= pc_data.air_drag_min_velocity_x but vel.y >= 0 + pc.velocity = vector(0.25, 7) + + pc:apply_air_drag() + + assert.are_same(vector(0.25, 7), pc.velocity) + end) + + it('(when velocity is 0.1 -7) should do nothing', function () + -- vel.y is OK but abs(vel.x) < pc_data.air_drag_min_velocity_x + pc.velocity = vector(0.1, -7) + + pc:apply_air_drag() + + assert.are_same(vector(0.1, -7), pc.velocity) + end) + + it('(when velocity is 0.25 -7) should do nothing', function () + -- both velocity coords match the conditions, apply drag factor + pc.velocity = vector(0.25, -7) + + pc:apply_air_drag() + + -- velocity x should be = 0.2421875 + assert.are_same(vector(0.25 * pc_data.air_drag_factor_per_frame, -7), pc.velocity) + end) + + it('(when velocity is 0.25 -8) should do nothing', function () + -- abs(vel.x) >= pc_data.air_drag_min_velocity_x but vel.y <= - pc_data.air_drag_max_abs_velocity_y + pc.velocity = vector(0.25, -8) + + pc:apply_air_drag() + + assert.are_same(vector(0.25, -8), pc.velocity) + end) + + 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( + assert.are_same(motion.air_motion_result( vector(4, 8), false, false, @@ -3530,9 +4759,9 @@ describe('player_char', function () 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 + -- to make sure we are calling _advance_in_air_along on x before y, we add a check here: + -- if we have already moved from initial pos.y = 8 (see test below), block any motion along y + if ref_motion_result.position.y == 8 then local motion = vector(0, velocity.y / 2) ref_motion_result.position = ref_motion_result.position + motion end @@ -3556,7 +4785,7 @@ describe('player_char', function () -- 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( + assert.are_same(motion.air_motion_result( vector(9.5, 2), false, true, -- hit ceiling @@ -3611,7 +4840,7 @@ describe('player_char', function () -- 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( + assert.are_same(motion.air_motion_result( vector(0.5, 10), false, false, @@ -3633,7 +4862,7 @@ describe('player_char', function () -- 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( + assert.are_same(motion.air_motion_result( vector(0.7, 10), false, false, @@ -3655,7 +4884,7 @@ describe('player_char', function () -- 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( + assert.are_same(motion.air_motion_result( vector(1, 10), false, false, @@ -3677,7 +4906,7 @@ describe('player_char', function () -- 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( + assert.are_same(motion.air_motion_result( vector(3.1, 10), false, false, @@ -3699,7 +4928,7 @@ describe('player_char', function () -- 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( + assert.are_same(motion.air_motion_result( vector(5, 10), true, false, @@ -3749,7 +4978,7 @@ describe('player_char', function () pc:_next_air_step(directions.up, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(2, 6), false, false, @@ -3771,7 +5000,7 @@ describe('player_char', function () pc:_next_air_step(directions.down, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(2, 8), false, false, @@ -3793,7 +5022,7 @@ describe('player_char', function () pc:_next_air_step(directions.left, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(1, 7), false, false, @@ -3815,7 +5044,7 @@ describe('player_char', function () pc:_next_air_step(directions.right, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(3, 7), false, false, @@ -3847,8 +5076,8 @@ describe('player_char', function () describe('(with flat ground)', function () before_each(function () - -- X - mock_mset(0, 0, 64) -- full tile + -- # + mock_mset(0, 0, full_tile_id) -- full tile end) -- in the tests below, we can use pc_data.full/center_height_standing directly instead @@ -3870,7 +5099,7 @@ describe('player_char', function () pc:_next_air_step(directions.up, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(4, 8 + pc_data.full_height_standing - pc_data.center_height_standing), false, true, @@ -3881,7 +5110,7 @@ describe('player_char', function () ) end) - it('direction down into ground should not move, and flag is_landing with slope_angle', function () + it('direction down into ground should not move, and flag is_landing with slope_angle 0', function () pc.velocity.x = 0 pc.velocity.y = 3 @@ -3895,7 +5124,7 @@ describe('player_char', function () pc:_next_air_step(directions.down, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(4, 0 - pc_data.center_height_standing), false, false, @@ -3906,7 +5135,60 @@ describe('player_char', function () ) end) - it('direction left into wall via ground should not move, and flag is_blocked_by_wall', function () + it('direction left exactly onto ground should step left, and flag is_landing with slop_angle 0', function () + pc.velocity.x = -3 + pc.velocity.y = 0 + + local motion_result = motion.air_motion_result( + vector(11, 0 - pc_data.center_height_standing), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.left, motion_result) + + assert.are_same(motion.air_motion_result( + vector(10, 0 - pc_data.center_height_standing), + false, + false, + true, + 0 + ), + motion_result + ) + end) + + it('direction right exactly onto ground should step right, and flag is_landing with slop_angle 0', function () + pc.velocity.x = 3 + pc.velocity.y = 0 + + local motion_result = motion.air_motion_result( + vector(-3, 0 - pc_data.center_height_standing), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.right, motion_result) + + assert.are_same(motion.air_motion_result( + vector(-2, 0 - pc_data.center_height_standing), + false, + false, + true, + 0 + ), + motion_result + ) + end) + + it('direction left into ground not deeper than max_ground_escape_height should step left and up, and flag is_landing with slop_angle 0', function () + pc.velocity.x = -3 + pc.velocity.y = 0 + local motion_result = motion.air_motion_result( vector(11, 1 - pc_data.center_height_standing), false, @@ -3917,18 +5199,73 @@ describe('player_char', function () pc:_next_air_step(directions.left, motion_result) - assert.are_equal(motion.air_motion_result( - vector(11, 1 - pc_data.center_height_standing), + assert.are_same(motion.air_motion_result( + vector(10, 0 - pc_data.center_height_standing), + false, + false, true, + 0 + ), + motion_result + ) + end) + + it('direction right into ground not deeper than max_ground_escape_height should step right and up, and flag is_landing with slop_angle 0', function () + pc.velocity.x = 3 + pc.velocity.y = 0 + + local motion_result = motion.air_motion_result( + vector(-3, 1 - pc_data.center_height_standing), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.right, motion_result) + + assert.are_same(motion.air_motion_result( + vector(-2, 0 - pc_data.center_height_standing), false, false, - nil + true, + 0 + ), + motion_result + ) + end) + + -- extra tests for sheer horizontally velocity check + + it('(at upward velocity, sheer angle) direction right into ground not deeper than max_ground_escape_height should step right and up, and flag is_landing with slop_angle 0', function () + pc.velocity.x = 3 + pc.velocity.y = -1 + + local motion_result = motion.air_motion_result( + vector(-3, 1 - pc_data.center_height_standing), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.right, motion_result) + + assert.are_same(motion.air_motion_result( + vector(-2, 0 - pc_data.center_height_standing), + false, + false, + true, + 0 ), motion_result ) end) - it('direction right into wall via ground should not move, and flag is_blocked_by_wall', function () + it('(at upward velocity, high angle) direction right into ground not deeper than max_ground_escape_height should ignore the floor completely (even during right step)', function () + pc.velocity.x = 3 + pc.velocity.y = -3 + local motion_result = motion.air_motion_result( vector(-3, 1 - pc_data.center_height_standing), false, @@ -3939,8 +5276,58 @@ describe('player_char', function () pc:_next_air_step(directions.right, motion_result) - assert.are_equal(motion.air_motion_result( - vector(-3, 1 - pc_data.center_height_standing), + assert.are_same(motion.air_motion_result( + vector(-2, 1 - pc_data.center_height_standing), + false, + false, + false, + nil + ), + motion_result + ) + end) + + it('direction left into wall deeper than max_ground_escape_height should not move, and flag is_blocked_by_wall', function () + pc.velocity.x = -3 + pc.velocity.y = 0 + + local motion_result = motion.air_motion_result( + vector(11, pc_data.max_ground_escape_height + 1 - pc_data.center_height_standing), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.left, motion_result) + + assert.are_same(motion.air_motion_result( + vector(11, pc_data.max_ground_escape_height + 1 - pc_data.center_height_standing), + true, + false, + false, + nil + ), + motion_result + ) + end) + + it('direction right into wall deeper than max_ground_escape_height should not move, and flag is_blocked_by_wall', function () + pc.velocity.x = 3 + pc.velocity.y = 0 + + local motion_result = motion.air_motion_result( + vector(-3, pc_data.max_ground_escape_height + 1 - pc_data.center_height_standing), + false, + false, + false, + nil + ) + + pc:_next_air_step(directions.right, motion_result) + + assert.are_same(motion.air_motion_result( + vector(-3, pc_data.max_ground_escape_height + 1 - pc_data.center_height_standing), true, false, false, @@ -3950,6 +5337,8 @@ describe('player_char', function () ) end) + -- ceiling tests below also try sheer vs high angle (but no separate test with velocity.y 0 and not 0) + it('direction left into wall via ceiling downward and faster on x than y should not move, and flag is_blocked_by_wall', function () -- important pc.velocity.x = -3 @@ -3965,7 +5354,7 @@ describe('player_char', function () pc:_next_air_step(directions.left, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(11, 7 + pc_data.full_height_standing - pc_data.center_height_standing), true, false, @@ -3991,7 +5380,7 @@ describe('player_char', function () pc:_next_air_step(directions.left, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(10, 7 + pc_data.full_height_standing - pc_data.center_height_standing), false, false, @@ -4017,7 +5406,7 @@ describe('player_char', function () pc:_next_air_step(directions.right, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(-3, 7 + pc_data.full_height_standing - pc_data.center_height_standing), true, false, @@ -4043,7 +5432,7 @@ describe('player_char', function () pc:_next_air_step(directions.right, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(-2, 7 + pc_data.full_height_standing - pc_data.center_height_standing), false, false, @@ -4068,7 +5457,7 @@ describe('player_char', function () pc:_next_air_step(directions.right, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(-2, 0 - pc_data.center_height_standing), false, false, @@ -4093,7 +5482,7 @@ describe('player_char', function () pc:_next_air_step(directions.left, motion_result) - assert.are_equal(motion.air_motion_result( + assert.are_same(motion.air_motion_result( vector(-3, 0 - pc_data.center_height_standing), false, false, @@ -4131,7 +5520,7 @@ describe('player_char', function () pc:_update_debug() assert.spy(update_velocity_debug_mock).was_called(1) assert.spy(update_velocity_debug_mock).was_called_with(match.ref(pc)) - assert.are_equal(vector(1, 2) + vector(4, -3) * delta_time60, pc.position) + assert.are_same(vector(1, 2) + vector(4, -3) * delta_time60, pc.position) end) end) @@ -4187,7 +5576,7 @@ describe('player_char', function () it('when move intention is (-1, 1), update 1 frame => at (3.867 -3.867)', function () pc.move_intention = vector(-1, 1) pc:_update_velocity_debug() - pc:move_by(pc.debug_velocity * delta_time60) + pc.position:add_inplace(pc.debug_velocity * delta_time60) assert.is_true(almost_eq_with_message(vector(3.8667, -3.8667), pc.position)) end) @@ -4195,7 +5584,7 @@ describe('player_char', function () pc.move_intention = vector(-1, 1) for i=1,10 do pc:_update_velocity_debug() - pc:move_by(pc.debug_velocity * delta_time60) + pc.position:add_inplace(pc.debug_velocity * delta_time60) end assert.is_true(almost_eq_with_message(vector(-2.73, 2.73), pc.position)) assert.is_true(almost_eq_with_message(vector(-60, 60), pc.debug_velocity)) -- at max speed @@ -4205,12 +5594,12 @@ describe('player_char', function () pc.move_intention = vector(-1, 1) for i=1,10 do pc:_update_velocity_debug() - pc:move_by(pc.debug_velocity * delta_time60) + pc.position:add_inplace(pc.debug_velocity * delta_time60) end pc.move_intention = vector.zero() for i=1,5 do pc:_update_velocity_debug() - pc:move_by(pc.debug_velocity * delta_time60) + pc.position:add_inplace(pc.debug_velocity * delta_time60) end assert.is_true(almost_eq_with_message(vector(-20, 20), pc.debug_velocity, 0.01)) end) @@ -4219,12 +5608,12 @@ describe('player_char', function () pc.move_intention = vector(-1, 1) for i=1,10 do pc:_update_velocity_debug() - pc:move_by(pc.debug_velocity * delta_time60) + pc.position:add_inplace(pc.debug_velocity * delta_time60) end pc.move_intention = vector.zero() for i=1,8 do pc:_update_velocity_debug() - pc:move_by(pc.debug_velocity * delta_time60) + pc.position:add_inplace(pc.debug_velocity * delta_time60) end assert.is_true(almost_eq_with_message(vector.zero(), pc.debug_velocity)) end) @@ -4248,24 +5637,26 @@ describe('player_char', function () 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 () + it('(when character is facing left) should call render on sonic sprite data: idle with the character\'s position, flipped x, current slope angle', function () pc.position = vector(12, 8) - pc.horizontal_dir = horizontal_dirs.left + pc.orientation = horizontal_dirs.left + pc.slope_angle = 0.125 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) + assert.spy(anim_spr_render_stub).was_called_with(match.ref(pc.anim_spr), vector(12, 8), true, false, 0.125) end) - it('(when character is facing right) should call render on sonic sprite data: idle with the character\'s position, not flipped x', function () + it('(when character is facing right) should call render on sonic sprite data: idle with the character\'s position, not flipped x, current slope angle', function () pc.position = vector(12, 8) - pc.horizontal_dir = horizontal_dirs.right + pc.orientation = horizontal_dirs.right + pc.slope_angle = 1-0.125 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), false) + assert.spy(anim_spr_render_stub).was_called_with(match.ref(pc.anim_spr), vector(12, 8), false, false, 1-0.125) end) end) diff --git a/src/ingame/stage_state.lua b/src/ingame/stage_state.lua index a63f095e..5c0fa291 100644 --- a/src/ingame/stage_state.lua +++ b/src/ingame/stage_state.lua @@ -1,10 +1,7 @@ -require("engine/core/class") require("engine/core/coroutine") -require("engine/core/math") -require("engine/render/color") local flow = require("engine/application/flow") local gamestate = require("engine/application/gamestate") -local ui = require("engine/ui/ui") +local overlay = require("engine/ui/overlay") local player_char = require("ingame/playercharacter") local stage_data = require("data/stage_data") @@ -40,7 +37,7 @@ function stage_state:_init() self.camera_pos = vector.zero() -- title overlay - self.title_overlay = ui.overlay(0) + self.title_overlay = overlay(0) end function stage_state:on_enter() diff --git a/src/ingame/stage_state_utest.lua b/src/ingame/stage_state_utest.lua index f22f6896..cd2765d5 100644 --- a/src/ingame/stage_state_utest.lua +++ b/src/ingame/stage_state_utest.lua @@ -1,9 +1,10 @@ -require("engine/test/bustedhelper") +require("test/bustedhelper") local stage_state = require("ingame/stage_state") local flow = require("engine/application/flow") local gamestate = require("engine/application/gamestate") -local ui = require("engine/ui/ui") +local overlay = require("engine/ui/overlay") +local label = require("engine/ui/label") local picosonic_app = require("application/picosonic_app") local stage_data = require("data/stage_data") @@ -43,7 +44,7 @@ describe('stage_state', function () nil, false, vector.zero(), - ui.overlay(0) + overlay(0) }, { state.type, @@ -95,7 +96,7 @@ describe('stage_state', function () end) it('should initialize camera at origin', function () - assert.are_equal(vector:zero(), state.camera_pos) + assert.are_same(vector:zero(), state.camera_pos) end) it('should call start_coroutine_method on show_stage_title_async', function () @@ -114,19 +115,19 @@ describe('stage_state', function () describe('on_exit', function () setup(function () - stub(ui.overlay, "clear_labels") + stub(overlay, "clear_labels") stub(picosonic_app, "stop_all_coroutines") stub(stage_state, "stop_bgm") end) teardown(function () - ui.overlay.clear_labels:revert() + overlay.clear_labels:revert() picosonic_app.stop_all_coroutines:revert() stage_state.stop_bgm:revert() end) after_each(function () - ui.overlay.clear_labels:clear() + overlay.clear_labels:clear() picosonic_app.stop_all_coroutines:clear() stage_state.stop_bgm:clear() end) @@ -148,7 +149,7 @@ describe('stage_state', function () end) it('should call title_overlay:clear_labels', function () - local s = assert.spy(ui.overlay.clear_labels) + local s = assert.spy(overlay.clear_labels) s.was_called(1) s.was_called_with(match.ref(state.title_overlay)) end) @@ -221,7 +222,7 @@ describe('stage_state', function () it('should move the camera to player position', function () state:update_camera() - assert.are_equal(vector(12, 24), state.camera_pos) + assert.are_same(vector(12, 24), state.camera_pos) end) end) @@ -488,7 +489,7 @@ describe('stage_state', function () for i = 1, stage_data.show_stage_title_delay * state.app.fps - 1 do coresume(on_show_stage_title_async, state) end - assert.are_equal(ui.label(state.curr_stage_data.title, vector(50, 30), colors.white), state.title_overlay.labels["title"]) + assert.are_same(label(state.curr_stage_data.title, vector(50, 30), colors.white), state.title_overlay.labels["title"]) -- reach last frame now to check if label just disappeared coresume(on_show_stage_title_async, state) @@ -508,7 +509,7 @@ describe('stage_state', function () map_stub = stub(_G, "map") spy.on(stage_state, "render_environment") player_char_render_stub = stub(player_char, "render") - title_overlay_draw_labels_stub = stub(ui.overlay, "draw_labels") + title_overlay_draw_labels_stub = stub(overlay, "draw_labels") end) teardown(function () @@ -529,7 +530,7 @@ describe('stage_state', function () it('render_title_overlay should call title_overlay:draw_labels', function () state:render_title_overlay() - assert.are_equal(vector.zero(), vector(pico8.camera_x, pico8.camera_y)) + assert.are_same(vector.zero(), vector(pico8.camera_x, pico8.camera_y)) assert.spy(title_overlay_draw_labels_stub).was_called(1) assert.spy(title_overlay_draw_labels_stub).was_called_with(state.title_overlay) end) @@ -537,7 +538,7 @@ describe('stage_state', function () it('render_background should reset camera position, call rectfill on the whole screen with stage background color', function () state.camera_pos = vector(24, 13) state:render_background() - assert.are_equal(vector(0, 0), vector(pico8.camera_x, pico8.camera_y)) + assert.are_same(vector(0, 0), vector(pico8.camera_x, pico8.camera_y)) assert.spy(rectfill_stub).was_called(1) assert.spy(rectfill_stub).was_called_with(0, 0, 127, 127, state.curr_stage_data.background_color) end) @@ -545,7 +546,7 @@ describe('stage_state', function () it('render_stage_elements should set camera position, call map for environment and player_char:render', function () state.camera_pos = vector(24, 13) state:render_stage_elements() - assert.are_equal(vector(24 - 128 / 2, 13 - 128 / 2), vector(pico8.camera_x, pico8.camera_y)) + assert.are_same(vector(24 - 128 / 2, 13 - 128 / 2), vector(pico8.camera_x, pico8.camera_y)) assert.spy(state.render_environment).was_called(1) assert.spy(state.render_environment).was_called_with(match.ref(state)) assert.spy(player_char_render_stub).was_called(1) @@ -555,7 +556,7 @@ describe('stage_state', function () it('set_camera_offset_stage should set the pico8 camera so that it is centered on the camera position', function () state.camera_pos = vector(24, 13) state:set_camera_offset_stage() - assert.are_equal(vector(24 - 128 / 2, 13 - 128 / 2), vector(pico8.camera_x, pico8.camera_y)) + assert.are_same(vector(24 - 128 / 2, 13 - 128 / 2), vector(pico8.camera_x, pico8.camera_y)) end) describe('(after set_camera_offset_stage)', function () diff --git a/src/itest/itest_dsl.lua b/src/itest/itest_dsl.lua index 1917e999..0fc20f9c 100644 --- a/src/itest/itest_dsl.lua +++ b/src/itest/itest_dsl.lua @@ -26,21 +26,24 @@ expect gp_value_type expect a gameplay value to be equal to (...) --]] -require("engine/core/helper") +require("engine/core/enum") require("engine/test/assertions") local integrationtest = require("engine/test/integrationtest") -local itest_manager, integration_test = get_members(integrationtest, - "itest_manager", "integration_test") +local itest_manager, integration_test = integrationtest.itest_manager, integrationtest.integration_test -local tile_data = require("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 player_char = require("ingame/playercharacter") local pc_data = require("data/playercharacter_data") +require("test_data/tile_representation") +--#if busted +local tile_test_data = require("test_data/tile_test_data") +--#endif -- helper function to access stage_stage quickly if current state -- is stage, as it is not a singleton anymore @@ -94,6 +97,9 @@ itest_dsl.generate_function_table = generate_function_table --#endif -- type of variables that can be parsed +-- those names are *not* parsed at runtime for DSL, so we can minify them +-- to allow this, we do *not* use enum {} and define the table manually +-- it also allows us to access the types without the ["key"] syntax parsable_types = enum { "none", "number", @@ -106,12 +112,54 @@ parsable_types = enum { "gp_value", -- meta-type compounded of [gp_value_type, gp_value_args...] where gp_value_args depend on gp_value_type } +-- Protected enums: map hardcoded strings to members, to support runtime parsing even when member names are minified on the original enums +horizontal_dirs_protected = { + ["left"] = 1, + ["right"] = 2 +} + +control_modes_protected = { + ["human"] = 1, -- player controls character + ["ai"] = 2, -- ai controls character + ["puppet"] = 3 -- itest script controls character +} + +motion_modes_protected = { + ["platformer"] = 1, -- normal in-game + ["debug"] = 2 -- debug "fly" mode +} + +motion_states_protected = { + ["grounded"] = 1, -- character is idle or running on the ground + ["falling"] = 2, -- character is falling in the air, but not spinning + ["air_spin"] = 3 -- character is in the air after a jump +} + +button_ids_protected = { + ["left"] = 0, + ["right"] = 1, + ["up"] = 2, + ["down"] = 3, + ["o"] = 4, + ["x"] = 5 +} + + --#if assert parsable_type_strings = invert_table(parsable_types) --#endif +-- Note: enums below have protected string keys to support aggressive minification +-- When done everywhere though, it makes more sense to just use strings +-- Keeping the enums around allows to track the list of supported values, +-- but consider using direct strings and listing possible values in comment. +-- The only benefit is to index other tables with enum values (so integers) rather than strings, +-- but if we have a table with string keys we do some hashing at some point anyway. + -- type of commands available +-- those names are parsed at runtime for DSL, so we don't want to minify them +-- and using enum {} is fine 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} @@ -134,18 +182,18 @@ command_type_strings = invert_table(command_types) -- 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, + [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"], } @@ -161,68 +209,68 @@ gp_value_types = enum { -- 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), + [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 +-- parsing functions (start with _ to protect against member name minification) -function itest_dsl.parse_none(arg_strings) - assert(#arg_strings == 0, "parse_none: got "..#arg_strings.." args, expected 0") +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]) +function itest_dsl._parse_number(arg_strings) + assert(#arg_strings == 1, "_parse_number: got "..#arg_strings.." args, expected 1") + return 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])) +function itest_dsl._parse_vector(arg_strings) + assert(#arg_strings == 2, "_parse_vector: got "..#arg_strings.." args, expected 2") + return vector(tonum(arg_strings[1]), 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") +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_protected[arg_strings[1]] + assert(horizontal_dir, "horizontal_dirs_protected["..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") +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_protected[arg_strings[1]] + assert(control_mode, "control_modes_protected["..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") +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_protected[arg_strings[1]] + assert(motion_mode, "motion_modes_protected["..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]] +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_protected[arg_strings[1]] + assert(motion_state, "motion_states_protected["..arg_strings[1].."] is not defined") + return motion_state 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]] +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_protected[arg_strings[1]] + assert(button_id, "button_ids_protected["..arg_strings[1].."] is not defined") + return button_id end -function itest_dsl.parse_gp_value(arg_strings) - assert(#arg_strings > 1, "parse_gp_value: got "..#arg_strings.." args, expected at least 2") +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] @@ -244,19 +292,19 @@ function itest_dsl.parse_gp_value(arg_strings) end -- table of parsers for command args and gameplay values, indexed by parsed type -value_parsers = generate_function_table(itest_dsl, parsable_types, "parse_") +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) +function itest_dsl._execute_warp(args) local current_stage_state = get_current_state_as_stage() current_stage_state.player_char:warp_bottom_to(args[1]) end -function itest_dsl.execute_set(args) +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] @@ -264,43 +312,43 @@ function itest_dsl.execute_set(args) setter(new_gp_value) end -function itest_dsl.execute_set_control_mode(args) +function itest_dsl._execute_set_control_mode(args) local current_stage_state = get_current_state_as_stage() current_stage_state.player_char.control_mode = args[1] end -function itest_dsl.execute_set_motion_mode(args) +function itest_dsl._execute_set_motion_mode(args) local current_stage_state = get_current_state_as_stage() - current_stage_state.player_char.motion_mode = args[1] + current_stage_state.player_char:set_motion_mode(args[1]) end -function itest_dsl.execute_move(args) +function itest_dsl._execute_move(args) local current_stage_state = get_current_state_as_stage() current_stage_state.player_char.move_intention = horizontal_dir_vectors[args[1]] end -function itest_dsl.execute_stop(args) +function itest_dsl._execute_stop(args) local current_stage_state = get_current_state_as_stage() current_stage_state.player_char.move_intention = vector.zero() end -function itest_dsl.execute_jump(args) +function itest_dsl._execute_jump(args) local current_stage_state = get_current_state_as_stage() current_stage_state.player_char.jump_intention = true -- will be consumed current_stage_state.player_char.hold_jump_intention = true end -function itest_dsl.execute_stop_jump(args) +function itest_dsl._execute_stop_jump(args) local current_stage_state = get_current_state_as_stage() current_stage_state.player_char.hold_jump_intention = false end -function itest_dsl.execute_press(args) +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) +function itest_dsl._execute_release(args) -- simulate release for player 0 input.simulated_buttons_down[0][args[1]] = false end @@ -308,39 +356,39 @@ 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_") +executors = generate_function_table(itest_dsl, command_types, "_execute_") itest_dsl.executors = executors -- gameplay value evaluation functions -function itest_dsl.eval_pc_bottom_pos() +function itest_dsl._eval_pc_bottom_pos() local current_stage_state = get_current_state_as_stage() return current_stage_state.player_char:get_bottom_center() end -function itest_dsl.eval_pc_velocity() +function itest_dsl._eval_pc_velocity() local current_stage_state = get_current_state_as_stage() return current_stage_state.player_char.velocity end -function itest_dsl.eval_pc_ground_spd() +function itest_dsl._eval_pc_ground_spd() local current_stage_state = get_current_state_as_stage() return current_stage_state.player_char.ground_speed end -function itest_dsl.eval_pc_motion_state() +function itest_dsl._eval_pc_motion_state() local current_stage_state = get_current_state_as_stage() return current_stage_state.player_char.motion_state end -function itest_dsl.eval_pc_slope() +function itest_dsl._eval_pc_slope() local current_stage_state = get_current_state_as_stage() return current_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_") +evaluators = generate_function_table(itest_dsl, gp_value_types, "_eval_") itest_dsl.evaluators = evaluators @@ -564,7 +612,9 @@ function itest_dsl_parser.create_itest(name, dsli) current_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() +--#if busted + tile_test_data.setup() +--#endif dsli.tilemap:load() else -- load stage by name when api is ready @@ -579,16 +629,18 @@ function itest_dsl_parser.create_itest(name, dsli) if dsli.stage_name == '#' then -- clear tilemap and unload tilemap data tilemap.clear_map() - teardown_map_data() +--#if busted + tile_test_data.teardown() +--#endif end end end for cmd in all(dsli.commands) do - if cmd.type == command_types.wait then + if cmd.type == command_types["wait"] then itest_dsl_parser:_wait(cmd.args[1]) - elseif cmd.type == command_types.expect then + 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])) @@ -596,7 +648,9 @@ function itest_dsl_parser.create_itest(name, dsli) else -- common action, store callback for execution during itest_dsl_parser:_act(function () - executors[cmd.type](cmd.args) + local executor = executors[cmd.type] + assert(executor, "executors["..cmd.type.."] (for '"..command_type_strings[cmd.type].."') is not defined") + executor(cmd.args) end) end end diff --git a/src/itest/itest_dsl_utest.lua b/src/itest/itest_dsl_utest.lua index 82199490..2225d79f 100644 --- a/src/itest/itest_dsl_utest.lua +++ b/src/itest/itest_dsl_utest.lua @@ -1,6 +1,4 @@ -require("engine/test/bustedhelper") -require("engine/core/helper") -require("engine/core/math") +require("test/bustedhelper") local flow = require("engine/application/flow") local tilemap = require("engine/data/tilemap") local input = require("engine/input/input") @@ -11,8 +9,9 @@ local itest_manager, time_trigger, integration_test = get_members(integratio local itest_dsl = require("itest/itest_dsl") local gameplay_value_data, generate_function_table = get_members(itest_dsl, "gameplay_value_data", "generate_function_table") +-- get_members is convenient to hide underscores with proxy refs 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") + "_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, @@ -22,7 +21,7 @@ local stage_state = require("ingame/stage_state") local picosonic_app = require("application/picosonic_app") local player_char = require("ingame/playercharacter") local pc_data = require("data/playercharacter_data") - +local tile_test_data = require("test_data/tile_test_data") describe('itest_dsl', function () @@ -63,131 +62,131 @@ describe('itest_dsl', function () end) - describe('parse_', function () + describe('_parse_', function () - describe('parse_none', 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") + 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({})) + assert.is_nil(itest_dsl._parse_none({})) end) end) - describe('parse_number', function () + 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") + 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"})) + assert.are_equal(5, itest_dsl._parse_number({"5"})) end) end) - describe('parse_vector', function () + 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") + 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"})) + assert.are_same(vector(2, -3.5), itest_dsl._parse_vector({"2", "-3.5"})) end) end) - describe('parse_horizontal_dir', function () + 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") + 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"})) + assert.are_equal(horizontal_dirs.right, itest_dsl._parse_horizontal_dir({"right"})) end) end) - describe('parse_control_mode', function () + 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") + 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"})) + assert.are_equal(control_modes.ai, itest_dsl._parse_control_mode({"ai"})) end) end) - describe('parse_motion_mode', function () + 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") + 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"})) + assert.are_equal(motion_modes.debug, itest_dsl._parse_motion_mode({"debug"})) end) end) - describe('parse_button_id', function () + 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") + 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"})) + assert.are_equal(button_ids.o, itest_dsl._parse_button_id({"o"})) end) end) - describe('parse_motion_state', function () + 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") + 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.falling, itest_dsl.parse_motion_state({"falling"})) + assert.are_equal(motion_states.falling, itest_dsl._parse_motion_state({"falling"})) end) end) - describe('parse_gp_value', function () + 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") + 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"})}) + {itest_dsl._parse_gp_value({"pc_bottom_pos", "1", "3"})}) end) end) @@ -216,7 +215,7 @@ describe('itest_dsl', function () end) it('should call warp_bottom_to on the current player character', function () - itest_dsl.execute_warp({vector(1, 3)}) + 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(state.player_char), vector(1, 3)) @@ -227,13 +226,13 @@ describe('itest_dsl', function () describe('"execute_set', function () it('should set pc velocity to (1, -3)', function () - itest_dsl.execute_set({"pc_velocity", vector(1, -3)}) - assert.are_equal(vector(1, -3), state.player_char.velocity) + itest_dsl._execute_set({"pc_velocity", vector(1, -3)}) + assert.are_same(vector(1, -3), state.player_char.velocity) end) it('should fail with unsupported gp_value_type for setting', function () assert.has_error(function () - itest_dsl.execute_set({"pc_slope", -2}) + itest_dsl._execute_set({"pc_slope", -2}) end, "itest_dsl.set_pc_slope is not defined") end) @@ -242,7 +241,7 @@ describe('itest_dsl', function () describe('execute_set_control_mode', function () it('should set the control mode', function () - itest_dsl.execute_set_control_mode({control_modes.puppet}) + itest_dsl._execute_set_control_mode({control_modes.puppet}) assert.are_equal(control_modes.puppet, state.player_char.control_mode) end) @@ -250,9 +249,18 @@ describe('itest_dsl', function () describe('execute_set_motion_mode', function () + setup(function () + stub(player_char, "set_motion_mode") + end) + + teardown(function () + player_char.set_motion_mode:revert() + end) + it('should set the motion mode', function () - itest_dsl.execute_set_motion_mode({motion_modes.debug}) - assert.are_equal(motion_modes.debug, state.player_char.motion_mode) + itest_dsl._execute_set_motion_mode({motion_modes.debug}) + assert.spy(player_char.set_motion_mode).was_called(1) + assert.spy(player_char.set_motion_mode).was_called_with(match.ref(state.player_char), motion_modes.debug) end) end) @@ -260,8 +268,8 @@ describe('itest_dsl', function () 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), state.player_char.move_intention) + itest_dsl._execute_move({horizontal_dirs.right}) + assert.are_same(vector(1, 0), state.player_char.move_intention) end) end) @@ -270,8 +278,8 @@ describe('itest_dsl', function () it('should set the move intention of the current player character to vector zero', function () state.player_char.move_intention = vector(99, -99) - itest_dsl.execute_stop({}) - assert.are_equal(vector.zero(), state.player_char.move_intention) + itest_dsl._execute_stop({}) + assert.are_same(vector.zero(), state.player_char.move_intention) end) end) @@ -279,7 +287,7 @@ describe('itest_dsl', function () describe('execute_jump', function () it('should set the jump intention and hold jump intention to true', function () - itest_dsl.execute_jump({}) + itest_dsl._execute_jump({}) assert.are_same({true, true}, {state.player_char.jump_intention, state.player_char.hold_jump_intention}) end) @@ -290,7 +298,7 @@ describe('itest_dsl', function () it('should set the hold jump intention to false', function () state.player_char.hold_jump_intention = true - itest_dsl.execute_stop_jump({}) + itest_dsl._execute_stop_jump({}) assert.is_false(state.player_char.hold_jump_intention) end) @@ -300,7 +308,7 @@ describe('itest_dsl', 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}) + itest_dsl._execute_press({button_ids.x}) assert.is_true(input.simulated_buttons_down[0][button_ids.x]) end) @@ -310,7 +318,7 @@ describe('itest_dsl', 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}) + itest_dsl._execute_release({button_ids.up}) assert.is_false(input.simulated_buttons_down[0][button_ids.up]) end) @@ -329,7 +337,7 @@ describe('itest_dsl', function () it('should return the bottom position of the current player character', function () state.player_char:set_bottom_center(vector(12, 47)) - assert.are_equal(vector(12, 47), eval_pc_bottom_pos()) + assert.are_same(vector(12, 47), eval_pc_bottom_pos()) end) end) @@ -338,7 +346,7 @@ describe('itest_dsl', function () it('should return the velocity of the current player character', function () state.player_char.velocity = vector(1, -4) - assert.are_equal(vector(1, -4), eval_pc_velocity()) + assert.are_same(vector(1, -4), eval_pc_velocity()) end) end) @@ -384,7 +392,7 @@ describe('itest_dsl', 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), state.player_char.velocity) + assert.are_same(vector(1, -4), state.player_char.velocity) end) end) @@ -443,21 +451,21 @@ describe('itest_dsl', 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") + stub(tile_test_data, "setup") + stub(tile_test_data, "teardown") end) teardown(function () - setup_map_data:revert() - teardown_map_data:revert() + tile_test_data.setup:revert() + tile_test_data.teardown:revert() end) after_each(function () itest_dsl_parser:init() flow:init() pico8:clear_map() - setup_map_data:clear() - teardown_map_data:clear() + tile_test_data.setup:clear() + tile_test_data.teardown:clear() end) describe('init', function () @@ -507,7 +515,7 @@ describe('itest_dsl', function () setup(function () stub(itest_dsl_parser, "parse_gamestate_definition", function (lines) - local tile_id = string_tonum(lines[3]) + local tile_id = tonum(lines[3]) return lines[1], lines[2], tilemap({ @@ -535,7 +543,7 @@ describe('itest_dsl', function () local dsli_source = [[ @stage # -64 +32 warp expect @@ -545,14 +553,14 @@ expect -- interface assert.is_not_nil(dsli) - assert.are_same( + assert.is_true(are_same_with_message( { -- no ':stage' here, it's still interpret as plain text at this point '@stage', '#', tilemap({ - { 0, 64}, - {64, 0} + { 0, 32}, + {32, 0} }), { command(command_types.warp, { vector(1, 2) } ), @@ -564,7 +572,7 @@ expect dsli.stage_name, dsli.tilemap, dsli.commands - }) + })) end) end) @@ -618,8 +626,8 @@ expect setup(function () stub(itest_dsl_parser, "parse_tilemap", function () return tilemap({ - {70, 64}, - {64, 70} + {70, 32}, + {32, 70} }), 5 end) end) @@ -638,13 +646,13 @@ expect local gamestate_type, stage_name, tm, next_line_index = itest_dsl_parser.parse_gamestate_definition(dsli_lines) -- interface - assert.are_same( + assert.is_true(are_same_with_message( { ':stage', '#', tilemap({ - {70, 64}, - {64, 70} + {70, 32}, + {32, 70} }), 5 }, @@ -653,7 +661,7 @@ expect stage_name, tm, next_line_index - }) + })) end) end) @@ -673,12 +681,12 @@ expect ".... (ignored)" } local tm, next_line_index = itest_dsl_parser.parse_tilemap(tilemap_text) - assert.are_same( + assert.is_true(are_same_with_message( { tilemap({}), 3 }, - {tm, next_line_index}) + {tm, next_line_index})) end) it('should return a tilemap data with tiles corresponding to the tile symbols in the string', function () @@ -692,16 +700,16 @@ expect "(ignored)" } local tm, next_line_index = itest_dsl_parser.parse_tilemap(tilemap_text) - assert.are_same( + assert.is_true(are_same_with_message( { tilemap({ { 0, 0, 0, 0}, - {64, 64, 0, 0}, - { 0, 0, 64, 64} + {32, 32, 0, 0}, + { 0, 0, 32, 32} }), 6 }, - {tm, next_line_index}) + {tm, next_line_index})) end) it('should assert if there as fewer than 2 lines', function () @@ -748,7 +756,7 @@ expect describe('parse_action_sequence', function () - it('should return a sequence of commands read in lines, starting at next_line_index', function () + it('#solo should return a sequence of commands read in lines, starting at next_line_index', function () local dsli_lines = { "???", "???", @@ -762,7 +770,7 @@ expect "expect pc_velocity 2 -3.5" } local commands = itest_dsl_parser.parse_action_sequence(dsli_lines, 5) - assert.are_same( + assert.is_true(are_same_with_message( { command(command_types.warp, { vector(12, 45) } ), command(command_types.wait, { 1 } ), @@ -771,7 +779,7 @@ expect command(command_types.expect, {"pc_bottom_pos", vector(10, 45)}), command(command_types.expect, {"pc_velocity", vector(2, -3.5)}), }, - commands) + commands)) end) it('should assert if an unknown command is found', function () @@ -851,16 +859,18 @@ expect -- verify warp callback behavior test.action_sequence[1].callback() assert.is_not_nil(state.player_char) - assert.are_equal(vector(12, 45 - pc_data.center_height_standing), state.player_char.position) + assert.are_same(vector(12, 45 - pc_data.center_height_standing), state.player_char.position) -- verify move callback behavior test.action_sequence[3].callback() - assert.are_equal(vector(-1, 0), state.player_char.move_intention) + assert.are_same(vector(-1, 0), 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 + -- since we added quadrants, even integer coordinates receive float transformation, + -- hence the .0 on passed position 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".. + "Passed in:\nvector(12.0, 45.0)\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()}) @@ -900,7 +910,7 @@ expect tilemap.clear_map:clear() end) - it('setup should call setup_map_data and load on the tilemap if custom stage definition', function () + it('setup should call tile_test_data.setup and load on the tilemap if custom stage definition', function () local dsli = dsl_itest() dsli.gamestate_type = ':stage' dsli.stage_name = "#" @@ -918,7 +928,7 @@ expect assert.are_equal(control_modes.puppet, state.player_char.control_mode) -- implementation - local s_data = assert.spy(setup_map_data) + local s_data = assert.spy(tile_test_data.setup) s_data.was_called(1) s_data.was_called_with() local s_load = assert.spy(tilemap.load) @@ -926,7 +936,7 @@ expect 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 () + it('teardown should call clear_map and tile_test_data.teardown if custom stage definition', function () local dsli = dsl_itest() dsli.gamestate_type = ':stage' dsli.stage_name = "#" @@ -944,7 +954,7 @@ expect 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) + local s_teardown = assert.spy(tile_test_data.teardown) s_teardown.was_called(1) s_teardown.was_called_with() end) @@ -986,7 +996,7 @@ expect 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) + assert.are_same(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 () @@ -995,7 +1005,7 @@ expect 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) + assert.are_same(time_trigger(8, true), itest_dsl_parser._last_time_trigger) end) end) diff --git a/src/itest_main.lua b/src/itest_main.lua index 778a1f3c..5fdc2df5 100644 --- a/src/itest_main.lua +++ b/src/itest_main.lua @@ -2,8 +2,11 @@ -- must require at main top, to be used in any required modules from here require("engine/pico8/api") +require("engine/common") +require("common") -require("engine/test/integrationtest") +local integrationtest = require("engine/test/integrationtest") +local itest_manager = integrationtest.itest_manager --#if log local logging = require("engine/debug/logging") @@ -47,7 +50,7 @@ function _init() picosonic_app.initial_gamestate = ':titlemenu' -- start first itest - init_game_and_start_next_itest() + itest_manager:init_game_and_start_next_itest() end function _update60() @@ -69,7 +72,7 @@ function handle_input() return elseif btnp(button_ids.right) then -- skip current itest - init_game_and_start_next_itest() + itest_manager:init_game_and_start_next_itest() return elseif btnp(button_ids.up) then -- go back 10 itests @@ -86,7 +89,7 @@ function handle_input() 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() + itest_manager:init_game_and_start_next_itest() end end end diff --git a/src/itests/itestplayercharacter.lua b/src/itests/itestplayercharacter.lua index 8c777ada..6f25adc8 100644 --- a/src/itests/itestplayercharacter.lua +++ b/src/itests/itestplayercharacter.lua @@ -1,16 +1,11 @@ -- gamestates: stage local integrationtest = require("engine/test/integrationtest") +local itest_manager, integration_test, time_trigger = integrationtest.itest_manager, integrationtest.integration_test, integrationtest.time_trigger local itest_dsl = require("itest/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 pc_data = require("data/playercharacter_data") -local tile_data = require("data/tile_data") ---#if busted -local tile_test_data = require("test_data/tile_test_data") ---#endif local itest @@ -95,6 +90,15 @@ expect pc_ground_spd 0 expect pc_velocity 0 0 ]]) +-- commented out to fit in 65536 chars +-- but you can run them anytime as headless itests with busted +-- however, note that airborne tests are broken due to the new air drag feature +-- so either set air_drag_factor_per_frame to 0 for the itests, +-- or update all the airborne itests +-- or use broader expectations such as Sonic being in a certain area or reaching a large trigger + +--[=[ + -- 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 @@ -103,12 +107,15 @@ expect pc_velocity 0 0 -- 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: -- . 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 +-- note that I reduced frame count from 15 tp 14 as I didn't want to check slope factor reduction too much +-- eventually it's more a utest than an itest, but fine +-- I will stop doing those super-precise checks anyway, since I may add for Original Features not matching +-- Sonic behavior exactly itest_dsl_parser.register( 'platformer ascending slope right', [[ @stage # @@ -118,15 +125,27 @@ itest_dsl_parser.register( warp 4 16 move right -wait 15 +wait 14 -expect pc_bottom_pos 0x0006.8509 15 +expect pc_bottom_pos 6.36378532203461338585 15 expect pc_motion_state grounded expect pc_slope -0.125 -expect pc_ground_spd 0.26318359375 -expect pc_velocity 0x0000.2fa4 -0x0000.2fa5 +expect pc_ground_spd 0.3266448974609375 +expect pc_velocity 0.23097282203461338585 -0.23097282203461338585 ]]) +-- 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 +-- ]]) + +--[[ +Frame Ground Speed Velocity Bottom Pos + 1 +--]] + -- 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 @@ -149,8 +168,13 @@ expect pc_velocity 0x0000.2fa4 -0x0000.2fa5 -- 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 + +-- from here, we apply Original feature (not in SPG): Progressive Ascending Steep Slope Factor +-- without Original Feature: 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) +-- with Original Feature: at frame 14: bpos (6.333572387695, 15), velocity (0.23097282203461338585, -0.23097282203461338585), ground_speed(0.32665186087253), because slope was current ground at frame start, slope factor was applied with 1/60/0.5*0.0625*sin(45) = -0.001472982 ~ 0xffff.ffd0 (in PICO-8 16.16 fixed point precision) +-- but still applying normal accel 0.0234375 +-- on this slope, divide ground speed in *sqrt(2) on x and y, hence velocity +-- y snaps to integer floor so it's just deduced from x as 15 -- bugfix history: @@ -633,3 +657,5 @@ warp 4 8 move right wait 60 ]]) + +--]=] diff --git a/src/main.lua b/src/main.lua index 251e92ee..33fef94e 100644 --- a/src/main.lua +++ b/src/main.lua @@ -1,7 +1,10 @@ -- main entry file for the game --- we must require engine/pico8/api at the top of our main.lua, so API bridges apply to all modules +-- must require at main top, to be used in any required modules from here require("engine/pico8/api") +require("engine/common") +require("common") + -- we also require codetuner so any file can used tuned() -- if tuner symbol is defined, then we also initialize it in _init local codetuner = require("engine/debug/codetuner") @@ -45,7 +48,8 @@ function _init() ['itest'] = true, ['log'] = true, ['ui'] = true, - -- ['frame'] = nil, + -- ['trace'] = true, + -- ['frame'] = true, -- game -- ['...'] = true, diff --git a/src/menu/credits.lua b/src/menu/credits.lua index db443730..151d05af 100644 --- a/src/menu/credits.lua +++ b/src/menu/credits.lua @@ -1,6 +1,4 @@ require("engine/application/flow") -require("engine/core/class") -require("engine/render/color") local gamestate = require("engine/application/gamestate") diff --git a/src/menu/credits_utest.lua b/src/menu/credits_utest.lua index a66f9d50..b9ab54c6 100644 --- a/src/menu/credits_utest.lua +++ b/src/menu/credits_utest.lua @@ -1,4 +1,4 @@ -require("engine/test/bustedhelper") +require("test/bustedhelper") local picosonic_app = require("application/picosonic_app") local credits = require("menu/credits") diff --git a/src/menu/titlemenu.lua b/src/menu/titlemenu.lua index 145fad2f..3ae5d61a 100644 --- a/src/menu/titlemenu.lua +++ b/src/menu/titlemenu.lua @@ -1,9 +1,7 @@ -require("engine/core/class") -require("engine/render/color") local input = require("engine/input/input") local flow = require("engine/application/flow") local gamestate = require("engine/application/gamestate") -local ui = require("engine/ui/ui") +-- local ui = require("engine/ui/ui") local titlemenu = derived_class(gamestate) diff --git a/src/menu/titlemenu_utest.lua b/src/menu/titlemenu_utest.lua index 7fae05a9..846e530b 100644 --- a/src/menu/titlemenu_utest.lua +++ b/src/menu/titlemenu_utest.lua @@ -1,4 +1,4 @@ -require("engine/test/bustedhelper") +require("test/bustedhelper") local input = require("engine/input/input") local flow = require("engine/application/flow") local gamestate = require("engine/application/gamestate") diff --git a/src/platformer/motion_utest.lua b/src/platformer/motion_utest.lua index 6ff21900..8668fd2b 100644 --- a/src/platformer/motion_utest.lua +++ b/src/platformer/motion_utest.lua @@ -1,4 +1,4 @@ -require("engine/test/bustedhelper") +require("test/bustedhelper") local motion = require("platformer/motion") local ground_query_info = motion.ground_query_info local ground_motion_result, air_motion_result = get_members(motion, diff --git a/src/platformer/tile.lua b/src/platformer/tile.lua deleted file mode 100644 index c41529bd..00000000 --- a/src/platformer/tile.lua +++ /dev/null @@ -1,74 +0,0 @@ -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/platformer/tile_utest.lua b/src/platformer/tile_utest.lua deleted file mode 100644 index 5899808a..00000000 --- a/src/platformer/tile_utest.lua +++ /dev/null @@ -1,119 +0,0 @@ -require("engine/test/bustedhelper") -require("engine/core/math") -local tile = require("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/platformer/world.lua b/src/platformer/world.lua index e2782c24..fada2ad6 100644 --- a/src/platformer/world.lua +++ b/src/platformer/world.lua @@ -1,12 +1,97 @@ -local tile = require("platformer/tile") +local tile_collision_data = require("data/tile_collision_data") local collision_data = require("data/collision_data") local world = {} --- return (column_height, slope_angle) where: --- - column_height is the column height at tile_location on column_index0, or 0 if there is no colliding tile +-- return quadrant in which angle is contained (non-injective) +function world.angle_to_quadrant(angle) + -- priority to vertical quadrants at the boundaries like Classic Sonic + -- (so 45-deg slope is recognized as up/down) + -- note that in those edge cases, the tiles should always be a rectangle to avoid confusion + -- of which side the columns/rows are defined from + -- nil angle (airborne) defaults to down so Sonic will try to "stand up" in the air + if not angle or angle >= 0.875 or angle <= 0.125 then + return directions.down + elseif angle < 0.375 then + return directions.right + elseif angle <= 0.625 then + return directions.up + else -- angle < 0.875 + return directions.left + end +end + +-- return quadrant tangent right angle +-- (not reciprocal of angle_to_quadrant since it is not injective) +-- down -> 0, right -> 0.25, up -> 0.5, left -> 0.75 +function world.quadrant_to_right_angle(quadrant) + -- a math trick to transform direction enum value to angle + -- make sure not to change directions enum values order! + return 0.25 * (3-quadrant) % 4 +end + +-- return the horizontal coordinate of a vector in given quadrant +-- (x if down/up, y if right/left) +-- make sure to always extract quadrant coordinates after doing operations +-- with quadrant-rotated vectors, to always have matching contribution signs +function world.get_quadrant_x_coord(pos, quadrant) + -- directions value-dependent trick: left and right are 0 and 2 (even) + -- whereas up and down are 1 and 3 (odd), so check for parity + return quadrant % 2 == 0 and pos.y or pos.x +end + +-- same, but for qy +function world.get_quadrant_y_coord(pos, quadrant) + return quadrant % 2 == 1 and pos.y or pos.x +end + +-- set the horizontal coordinate of a position vector in current quadrant +-- (x if down/up, y if right/left) to value +function world.set_position_quadrant_x(pos, value, quadrant) + -- directions value-dependent trick: left and right are 0 and 2 (even) + -- whereas up and down are 1 and 3 (odd), so check for parity + if quadrant % 2 == 0 then + pos.y = value + else + pos.x = value + end +end + +-- return the difference from qy1 to qy2, +-- but apply sign change to respect q-up, +-- i.e. if qy1 represents a value higher in quadrant frame than qy2, +-- result should be positive +function world.sub_qy(qy1, qy2, quadrant) + -- directions value-dependent trick: up and left are 0 and 1 (< 2) + -- and only those have a reversed qy + -- quadrant down has the normal operation, as usual + if quadrant < 2 then + return qy2 - qy1 + else + return qy1 - qy2 + end +end + +-- return the qy of the q-bottom edge of a tile for a given quadrant +-- e.g. left edge x if quadrant is left, bottom edge y if quadrant is down +function world.get_tile_qbottom(tile_loc, quadrant) + -- to avoid if/elseif and handle everything in one formula: + -- - start from tile center + -- - move in quadrant (down) direction by a half-tile + -- - get qy + -- tile_size / 2 = 4 + return world.get_quadrant_y_coord(tile_loc:to_center_position() + 4 * dir_vectors[quadrant], quadrant) +end + +-- return (qcolumn_height, slope_angle) where: +-- - qcolumn_height is the qcolumn height at tile_location on qcolumn_index0, or 0 if there is no colliding tile +-- (if quadrant is horizontal, qcolum = row, but indices are always top to bottom, left to right) -- - slope_angle is the slope angle of the corresponding tile, or nil if there is no colliding tile -function world._compute_column_height_at(tile_location, column_index0) +-- if ignore_reverse is true, return 0, nil if the tile interior is opposed to quadrant interior direction +-- this is useful for ceiling check on character's current tile and actually matches Classic Sonic behavior better +function world._compute_qcolumn_height_at(tile_location, qcolumn_index0, quadrant, ignore_reverse) + + assert(0 <= qcolumn_index0 and qcolumn_index0 < 8, "world._compute_qcolumn_height_at: invalid qcolumn_index0 "..qcolumn_index0) -- only consider valid tiles; consider there are no colliding tiles outside the map area if tile_location.i >= 0 and tile_location.i < 128 and tile_location.j >= 0 and tile_location.j < 64 then @@ -18,25 +103,106 @@ function world._compute_column_height_at(tile_location, column_index0) if current_tile_collision_flag then -- get the tile collision mask - local tile_data_value = collision_data.tiles_data[current_tile_id] - assert(tile_data_value, "collision_data.tiles_data does not contain entry for sprite id: "..current_tile_id..", yet it has the collision flag set") - - 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 = tile.height_array(tile_data_value) - return h_array:get_height(column_index0), h_array.slope_angle + local tcd = collision_data.get_tile_collision_data(current_tile_id) + assert(tcd, "collision_data.tiles_collision_data does not contain entry for sprite id: "..current_tile_id..", yet it has the collision flag set") + + if tcd then + -- if quadrant matches interior (h or v) direction, use q-column + -- (character is walking on the "normal" side of the tile) + -- if quadrant is opposed to interior (h or v) direction, use all-or-nothing + -- (character is walking on the "reverse" side of the tile, which is flat + -- on square edge (q-height = 8) except when q-column is completely empty) + -- in addition, the slope_angle is always the quadrant right angle (0, 0.25, 0.5, 0.75) + -- to simulate flat ground + -- full tiles must have an arbitrary angle multiple of 0.25, typically 0, and will be associated + -- to interiors based on angle_to_quadrant (one of the interiors will be arbitrarily chosen + -- since an angle edge case), then the algo will cover the reverse side cases, so 8, 0.25x will always be returned + -- ex: interior right-down, character quadrant left + -- ........ + -- ........ <- sensor here finds nothing (angle still 0.75) + -- ........ + -- ......## <- sensor here finds immediate ground as if row was ######## (and angle 0.75) + -- .....### + -- ....#### + -- ...##### + -- ...##### + + -- walking on the flat part of a tile on the normal side does *not* set + -- the slope to quadrant right angle + -- ........ + -- #....... + -- ###..... + -- #####... <- A + -- ######.. + -- ######## + -- ######## <- the inclined angle used at A will be considered + -- ######## + + -- however, if the tile is made *only* of full columns or full rows + -- (this includes the full tile), quadrant right angle is always used + -- (similarly to reverse sides, because we consider it's an edge case anyway) + -- ........ + -- ........ + -- ........ + -- ........ <- (0, 0.75) + -- ........ + -- ######## + -- ######## <- (8, 0.75) (flat side of rectangle) + -- ######## + + local is_full_vertical_rectangle = tcd:is_full_vertical_rectangle() + local is_full_horizontal_rectangle = tcd:is_full_horizontal_rectangle() + local is_full_rectangle = is_full_vertical_rectangle or is_full_horizontal_rectangle + + if quadrant % 2 == 1 then + -- floor/ceiling (quadrant down/up) + local height = tcd:get_height(qcolumn_index0) + if tcd.interior_v == vertical_dirs.down and quadrant == directions.up or + tcd.interior_v == vertical_dirs.up and quadrant == directions.down then + -- reverse side, all-or-nothing with right angle + -- unless we ignore reverse (still at sensor tile during ceiling check) + -- and the tile is not covering fully spanned vertically, + -- in which case reverse doesn't make sense as angle-to-quadrant conversion + -- is arbitrary on square angles, so the tile interior_v could be up or down + if ignore_reverse and not is_full_vertical_rectangle then + return 0--, nil + end + -- return all-or-nothing, always with angle + -- (not nil even if nothing, let ground motion set slope angle appropriately when falling) + return height > 0 and tile_size or 0, world.quadrant_to_right_angle(quadrant) + elseif is_full_rectangle then + -- flat side of rectangle (or empty region near flat side) + return height, world.quadrant_to_right_angle(quadrant) + end + -- normal side + return height, tcd.slope_angle + else + -- right wall/left wall (quadrant right/left) + local width = tcd:get_width(qcolumn_index0) + if tcd.interior_h == horizontal_dirs.right and quadrant == directions.left or + tcd.interior_h == horizontal_dirs.left and quadrant == directions.right then + if ignore_reverse and not is_full_horizontal_rectangle then + return 0--, nil + end + return width > 0 and tile_size or 0, world.quadrant_to_right_angle(quadrant) + elseif is_full_rectangle then + -- flat side of rectangle (or empty region near flat side) + return width, world.quadrant_to_right_angle(quadrant) + end + -- normal side + return width, tcd.slope_angle + end end end end - -- returning nil is optional in Lua but it makes it clearer than we expect 2 values - return 0, nil + return 0--, nil end +-- DEPRECATED, remove to spare tokens -- return (true, slope_angle) if there is a collision pixel at (x, y), -- where slope_angle is the slope angle in this tile (even if (x, y) is inside ground), -- and (false, nil) if there is no collision @@ -51,7 +217,7 @@ function world.get_pixel_collision_info(x, y) -- environment local column_index0 = x - left -- from 0 to tile_size - 1 - local ground_array_height, slope_angle = world._compute_column_height_at(location, column_index0) + local ground_array_height, slope_angle = world._compute_qcolumn_height_at(location, column_index0, directions.down) -- if column is empty, there cannot be any pixel collision if ground_array_height > 0 then diff --git a/src/platformer/world_utest.lua b/src/platformer/world_utest.lua index 5f15bad7..7c73acdd 100644 --- a/src/platformer/world_utest.lua +++ b/src/platformer/world_utest.lua @@ -1,4 +1,4 @@ -require("engine/test/bustedhelper") +require("test/bustedhelper") local world = require("platformer/world") local tile_test_data = require("test_data/tile_test_data") @@ -17,14 +17,198 @@ describe('world (with mock tiles data setup)', function () pico8:clear_map() end) - describe('_compute_column_height_at', function () + describe('angle_to_quadrant', function () - it('should return (0, nil) if tile location is outside map area', function () - assert.are_same({0, nil}, {world._compute_column_height_at(location(-1, 2), 0)}) + it('should return quadrant down for slope_angle: nil', function () + assert.are_equal(directions.down, world.angle_to_quadrant(nil)) end) - it('should return (0, nil) if tile has collision flag unset', function () - assert.are_same({0, nil}, {world._compute_column_height_at(location(1, 1), 0)}) + it('should return quadrant down for slope_angle: 1-0.125', function () + assert.are_equal(directions.down, world.angle_to_quadrant(1-0.125)) + end) + + it('should return quadrant down for slope_angle: 0', function () + assert.are_equal(directions.down, world.angle_to_quadrant(0)) + end) + + it('should return quadrant down for slope_angle: 0.124', function () + assert.are_equal(directions.down, world.angle_to_quadrant(0.124)) + end) + + it('should return quadrant down for slope_angle: 0.25-0.125', function () + assert.are_equal(directions.down, world.angle_to_quadrant(0.25-0.125)) + end) + + it('should return quadrant right for slope_angle: 0.25-0.124', function () + assert.are_equal(directions.right, world.angle_to_quadrant(0.25-0.124)) + end) + + it('should return quadrant right for slope_angle: 0.25+0.124', function () + assert.are_equal(directions.right, world.angle_to_quadrant(0.25+0.124)) + end) + + it('should return quadrant up for slope_angle: 0.5-0.125', function () + assert.are_equal(directions.up, world.angle_to_quadrant(0.5-0.125)) + end) + + it('should return quadrant to up for slope_angle: 0.5+0.125', function () + assert.are_equal(directions.up, world.angle_to_quadrant(0.5+0.125)) + end) + + it('should return quadrant to left for slope_angle: 0.75-0.124', function () + assert.are_equal(directions.left, world.angle_to_quadrant(0.75-0.124)) + end) + + it('should return quadrant to left for slope_angle: 0.75+0.124', function () + assert.are_equal(directions.left, world.angle_to_quadrant(0.75+0.124)) + end) + + end) + + describe('quadrant_to_right_angle', function () + + -- we had already written utests before extracting world.quadrant_to_right_angle + -- so we kept the tests checking final result instead of spy call + + it('should return 0 when quadrant is down', function () + assert.are_equal(0, world.quadrant_to_right_angle(directions.down)) + end) + + it('should return 0.25 when quadrant is right', function () + assert.are_equal(0.25, world.quadrant_to_right_angle(directions.right)) + end) + + it('should return 0.5 when quadrant is up', function () + assert.are_equal(0.5, world.quadrant_to_right_angle(directions.up)) + end) + + it('should return 0.75 when quadrant is left', function () + assert.are_equal(0.75, world.quadrant_to_right_angle(directions.left)) + end) + + end) + + + describe('get_quadrant_x_coord', function () + + it('should return pos.x when quadrant is down', function () + + assert.are_equal(10, world.get_quadrant_x_coord(vector(10, 20), directions.down)) + end) + + it('should return pos.x when quadrant is up', function () + + assert.are_equal(10, world.get_quadrant_x_coord(vector(10, 20), directions.up)) + end) + + it('should return pos.y when quadrant is right', function () + + assert.are_equal(20, world.get_quadrant_x_coord(vector(10, 20), directions.right)) + end) + + it('should return pos.y when quadrant is left', function () + + assert.are_equal(20, world.get_quadrant_x_coord(vector(10, 20), directions.left)) + end) + + end) + + describe('get_quadrant_y_coord', function () + + it('should return pos.y when quadrant is down', function () + assert.are_equal(20, world.get_quadrant_y_coord(vector(10, 20), directions.down)) + end) + + it('should return pos.y when quadrant is up', function () + assert.are_equal(20, world.get_quadrant_y_coord(vector(10, 20), directions.up)) + end) + + it('should return pos.y when quadrant is right', function () + assert.are_equal(10, world.get_quadrant_y_coord(vector(10, 20), directions.right)) + end) + + it('should return pos.y when quadrant is left', function () + assert.are_equal(10, world.get_quadrant_y_coord(vector(10, 20), directions.left)) + end) + + end) + + describe('set_position_quadrant_x', function () + + it('should set pos.x when quadrant is down', function () + local p = vector(10, 20) + world.set_position_quadrant_x(p, 30, directions.down) + assert.are_same(vector(30, 20), p) + end) + + it('should set pos.x when quadrant is up', function () + local p = vector(10, 20) + world.set_position_quadrant_x(p, 30, directions.up) + assert.are_same(vector(30, 20), p) + end) + + it('should set pos.y when quadrant is right', function () + local p = vector(10, 20) + world.set_position_quadrant_x(p, 30, directions.right) + assert.are_same(vector(10, 30), p) + end) + + it('should set pos.y when quadrant is left', function () + local p = vector(10, 20) + world.set_position_quadrant_x(p, 30, directions.left) + assert.are_same(vector(10, 30), p) + end) + + end) + + describe('sub_qy', function () + + it('should return qy1 - qy2 when quadrant is down', function () + assert.are_equal(7, world.sub_qy(10, 3, directions.down)) + end) + + it('should return qy2 - qy1 when quadrant is up', function () + assert.are_equal(7, world.sub_qy(3, 10, directions.up)) + end) + + it('should return qy1 - qy2 when quadrant is right', function () + assert.are_equal(7, world.sub_qy(10, 3, directions.right)) + end) + + it('should return qy2 - qy1 when quadrant is left', function () + assert.are_equal(7, world.sub_qy(3, 10, directions.left)) + end) + + end) + + describe('get_tile_qbottom', function () + + it('should return tile world bottom when quadrant is down', function () + assert.are_equal(24, world.get_tile_qbottom(location(1, 2), directions.down)) + end) + + it('should return tile world top when quadrant is up', function () + assert.are_equal(16, world.get_tile_qbottom(location(1, 2), directions.up)) + end) + + it('should return world right when quadrant is right', function () + assert.are_equal(16, world.get_tile_qbottom(location(1, 2), directions.right)) + end) + + it('should return world left when quadrant is left', function () + assert.are_equal(8, world.get_tile_qbottom(location(1, 2), directions.left)) + end) + + end) + + describe('_compute_qcolumn_height_at', function () + + it('should return (0, nil) if tile location is outside map area (any quadrant)', function () + assert.are_same({0, nil}, {world._compute_qcolumn_height_at(location(-1, 2), 0, directions.down)}) + end) + + it('should return (0, nil) if tile has collision flag unset (any quadrant)', function () + assert.are_same({0, nil}, {world._compute_qcolumn_height_at(location(1, 1), 0, directions.right)}) end) describe('with invalid tile', function () @@ -34,37 +218,150 @@ describe('world (with mock tiles data setup)', function () mock_mset(1, 1, 1) end) - it('should assert if tile has collision flag set but no collision mask id associated', function () + it('should assert if tile has collision flag set but no collision mask id associated (any quadrant)', function () assert.has_error(function () - world._compute_column_height_at(location(1, 1), 0) + world._compute_qcolumn_height_at(location(1, 1), 0, directions.up) end, - "collision_data.tiles_data does not contain entry for sprite id: 1, yet it has the collision flag set") + "collision_data.tiles_collision_data does not contain entry for sprite id: 1, yet it has the collision flag set") end) end) - describe('with ascending slope 22.5 offset by 2', function () + -- this unrealistic tile is useful to check all-or-nothing in both horizontal and vertical dirs + -- more realistically, you could have an ascending slope that only occupies the bottom-right corner of the tile + describe('with bottom_right_quarter_tile_id offset by 2', function () before_each(function () -- create an ascending slope 22.5 at (1, 1), i.e. (8, 14) to (15, 11) px - mock_mset(1, 1, 67) + mock_mset(1, 1, bottom_right_quarter_tile_id) end) - it('should return 3 on column 3', function () - assert.are_same({3, -22.5 / 360}, {world._compute_column_height_at(location(1, 1), 3)}) + it('should return 0 on column 3 (quadrant down)', function () + assert.are_same({0, 0}, {world._compute_qcolumn_height_at(location(1, 1), 3, directions.down)}) + end) + + it('should return 4 on column 4 (quadrant down)', function () + assert.are_same({4, 0}, {world._compute_qcolumn_height_at(location(1, 1), 4, directions.down)}) + end) + + it('should return 0 (reverse: nothing) on column 3 (quadrant up)', function () + assert.are_same({0, 0.5}, {world._compute_qcolumn_height_at(location(1, 1), 3, directions.up)}) + end) + + it('should return 8 (reverse: all) on column 4 (quadrant up)', function () + assert.are_same({8, 0.5}, {world._compute_qcolumn_height_at(location(1, 1), 4, directions.up)}) + end) + + it('should return 0 (ignore reverse) on column 3 (quadrant up)', function () + assert.are_same({0, nil}, {world._compute_qcolumn_height_at(location(1, 1), 3, directions.up, true)}) + end) + + it('should return 0 (ignore reverse) on column 4 (quadrant up)', function () + assert.are_same({0, nil}, {world._compute_qcolumn_height_at(location(1, 1), 4, directions.up, true)}) + end) + + it('should return 0 on row 3 (quadrant right)', function () + assert.are_same({0, 0}, {world._compute_qcolumn_height_at(location(1, 1), 3, directions.right)}) + end) + + it('should return 4 on row 3 (quadrant right)', function () + assert.are_same({4, 0}, {world._compute_qcolumn_height_at(location(1, 1), 4, directions.right)}) + end) + + it('should return 0 (reverse: nothing) on row 3 (quadrant left)', function () + assert.are_same({0, 0.75}, {world._compute_qcolumn_height_at(location(1, 1), 3, directions.left)}) + end) + + it('should return 8 (reverse: all) on row 4 (quadrant left)', function () + assert.are_same({8, 0.75}, {world._compute_qcolumn_height_at(location(1, 1), 4, directions.left)}) + end) + + it('should return 0 (ignore reverse) on row 3 (quadrant left)', function () + assert.are_same({0, nil}, {world._compute_qcolumn_height_at(location(1, 1), 3, directions.left, true)}) + end) + + it('should return 0 (ignore reverse) on row 4 (quadrant left)', function () + assert.are_same({0, nil}, {world._compute_qcolumn_height_at(location(1, 1), 4, directions.left, true)}) + end) + + end) + + describe('with loop top-left tile', function () + + before_each(function () + mock_mset(1, 1, loop_topleft) + end) + + it('should return 8 on column 6 (quadrant down)', function () + assert.are_same({8, 0}, {world._compute_qcolumn_height_at(location(1, 1), 6, directions.down)}) + end) + + it('should return 6 on column 6 (quadrant up)', function () + assert.are_same({6, atan2(-4, 4)}, {world._compute_qcolumn_height_at(location(1, 1), 6, directions.up)}) + end) + + it('should return 8 on row 6 (quadrant right)', function () + assert.are_same({8, 0.25}, {world._compute_qcolumn_height_at(location(1, 1), 6, directions.right)}) + end) + + it('should return 6 on row 6 (quadrant left)', function () + assert.are_same({6, atan2(-4, 4)}, {world._compute_qcolumn_height_at(location(1, 1), 6, directions.left)}) + end) + + end) + + describe('with half-tile', function () + + before_each(function () + mock_mset(1, 1, half_tile_id) + end) + + -- half-tile allows us to test is_rectangle case + -- in principle we should also have a vertically-split half-tile + -- because we are only testing the quadrant left/right + is_rectangle case + -- and not up/down... but that would only be a hypothetical tile, we don't have such a thing + -- right now in the game + it('should return 4 (rectangle) on column 6 (quadrant down)', function () + assert.are_same({4, 0}, {world._compute_qcolumn_height_at(location(1, 1), 6, directions.down)}) + end) + + it('should return 8 (reverse all) on column 6 (quadrant up)', function () + assert.are_same({8, 0.5}, {world._compute_qcolumn_height_at(location(1, 1), 6, directions.up)}) + end) + + it('should return 0 (ignore reverse) on column 6 (quadrant up)', function () + assert.are_same({0, nil}, {world._compute_qcolumn_height_at(location(1, 1), 6, directions.up, true)}) + end) + + it('should return 0 (rectangle) on row 3 (quadrant right)', function () + assert.are_same({0, 0.25}, {world._compute_qcolumn_height_at(location(1, 1), 3, directions.right)}) + end) + + it('should return 0 (rectangle) on row 4 (quadrant right)', function () + assert.are_same({8, 0.25}, {world._compute_qcolumn_height_at(location(1, 1), 4, directions.right)}) + end) + + it('should return 0 (rectangle) on row 3 (quadrant left)', function () + assert.are_same({0, 0.75}, {world._compute_qcolumn_height_at(location(1, 1), 3, directions.left)}) + end) + + it('should return 8 (rectangle) on row 4 (quadrant left)', function () + assert.are_same({8, 0.75}, {world._compute_qcolumn_height_at(location(1, 1), 4, directions.left)}) end) end) end) + -- deprecated (correct, but not optimized) + describe('get_pixel_collision_info', function () describe('with full flat tile', function () before_each(function () -- create a full tile at (1, 1), i.e. (8, 8) to (15, 15) px - mock_mset(1, 1, 64) + mock_mset(1, 1, full_tile_id) end) it('should return {false, nil} on (7, 7)', function () @@ -143,7 +440,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 - mock_mset(1, 1, 65) + mock_mset(1, 1, asc_slope_45_id) end) it('should return {false, nil} on (8, 14)', function () @@ -151,7 +448,7 @@ describe('world (with mock tiles data setup)', function () end) it('should return true on (8, 15)', function () - assert.are_same({true, -45/360}, {world.get_pixel_collision_info(8, 15)}) + assert.are_same({true, 45/360}, {world.get_pixel_collision_info(8, 15)}) end) it('should return {false, nil} on (8, 16)', function () @@ -163,11 +460,11 @@ describe('world (with mock tiles data setup)', function () end) it('should return true on (9, 14)', function () - assert.are_same({true, -45/360}, {world.get_pixel_collision_info(9, 14)}) + assert.are_same({true, 45/360}, {world.get_pixel_collision_info(9, 14)}) end) it('should return true on (9, 15)', function () - assert.are_same({true, -45/360}, {world.get_pixel_collision_info(9, 15)}) + assert.are_same({true, 45/360}, {world.get_pixel_collision_info(9, 15)}) end) it('should return {false, nil} on (9, 16)', function () @@ -179,11 +476,11 @@ describe('world (with mock tiles data setup)', function () end) it('should return true on (15, 8)', function () - assert.are_same({true, -45/360}, {world.get_pixel_collision_info(15, 8)}) + assert.are_same({true, 45/360}, {world.get_pixel_collision_info(15, 8)}) end) it('should return true on (15, 15)', function () - assert.are_same({true, -45/360}, {world.get_pixel_collision_info(15, 15)}) + assert.are_same({true, 45/360}, {world.get_pixel_collision_info(15, 15)}) end) it('should return {false, nil} on (15, 16)', function () diff --git a/src/resources/visual.lua b/src/resources/visual.lua index b8ad0526..599eb46d 100644 --- a/src/resources/visual.lua +++ b/src/resources/visual.lua @@ -4,7 +4,7 @@ local visual = {} local sprite_data_t = { --#if mouse - cursor = sprite_data(sprite_id_location(1, 0)) + cursor = sprite_data(sprite_id_location(1, 0), nil, nil, colors.pink) --#endif } diff --git a/src/sandbox.lua b/src/sandbox.lua index 4023cd81..afa08bdd 100644 --- a/src/sandbox.lua +++ b/src/sandbox.lua @@ -1,6 +1,3 @@ -require("engine/core/math") -require("engine/core/helper") - -- caveats -- syntax error: malformed number near 27..d diff --git a/src/test/bustedhelper.lua b/src/test/bustedhelper.lua new file mode 100644 index 00000000..8f5074b2 --- /dev/null +++ b/src/test/bustedhelper.lua @@ -0,0 +1,8 @@ +-- engine bustedhelper equivalent for game project +-- it simply adds game common module, since the original bustedhelper.lua +-- is part of engine and therefore cannot reference game modules +-- Usage: +-- in your game utests, always require("test/bustedhelper") at the top +-- instead of "engine/test/bustedhelper" +require("engine/test/bustedhelper") +require("common") diff --git a/src/test_data/tile_data_utest.lua b/src/test_data/tile_data_utest.lua deleted file mode 100644 index 93147b0e..00000000 --- a/src/test_data/tile_data_utest.lua +++ /dev/null @@ -1,38 +0,0 @@ -require("engine/test/bustedhelper") -require("data/tile_data") -local tile_test_data = require("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/src/test_data/tile_representation.lua b/src/test_data/tile_representation.lua new file mode 100644 index 00000000..699bd2fc --- /dev/null +++ b/src/test_data/tile_representation.lua @@ -0,0 +1,30 @@ +-- this file is used by busted tests creating mock tilemaps on the go, +-- but also PICO-8 itests so we extracted it from tile_test_data +-- so it can be required safely from itest_dsl + +-- IDs of tiles used for tests only (black and white in spritesheet, never used in real game) +no_tile_id = 0 +full_tile_id = 32 +half_tile_id = 80 +flat_low_tile_id = 96 +bottom_right_quarter_tile_id = 64 +asc_slope_45_id = 113 +desc_slope_45_id = 116 +asc_slope_22_id = 112 +asc_slope_22_upper_level_id = 117 +loop_topleft = 12 + +-- symbol mapping for itests +-- (could also be used for utests instead of manual mock_mset, but need to extract parse_tilemap +-- from itest_dsl) +tile_symbol_to_ids = { + ['.'] = no_tile_id, -- empty + ['#'] = full_tile_id, -- full tile + ['='] = half_tile_id, -- half tile (4px high) + ['_'] = flat_low_tile_id, -- flat low tile (2px high) + ['r'] = bottom_right_quarter_tile_id, -- bottom-right quarter tile (4px high) + ['/'] = asc_slope_45_id, -- ascending slope 45 + ['\\'] = desc_slope_45_id, -- descending slope 45 + ['<'] = asc_slope_22_id, -- ascending slope 22.5 + ['y'] = asc_slope_22_upper_level_id, -- ascending slope upper level 22.5 +} diff --git a/src/test_data/tile_test_data.lua b/src/test_data/tile_test_data.lua index 0f16596a..c09ba511 100644 --- a/src/test_data/tile_test_data.lua +++ b/src/test_data/tile_test_data.lua @@ -1,62 +1,65 @@ --#if busted -require("engine/test/pico8api") -local tile = require("platformer/tile") +-- pico8api should have been required in an including script, +-- since we are used busted, hence bustedhelper + local collision_data = require("data/collision_data") +local tile_collision_data = require("data/tile_collision_data") local stub = require("luassert.stub") +require("test_data/tile_representation") -local tile_test_data = {} +local mock_raw_tile_collision_data = { + -- collision_data values + PICO-8 spritesheet must match our mockup data + [full_tile_id] = {{8, 8, 8, 8, 8, 8, 8, 8}, {8, 8, 8, 8, 8, 8, 8, 8}, atan2(8, 0)}, + [half_tile_id] = {{4, 4, 4, 4, 4, 4, 4, 4}, {0, 0, 0, 0, 8, 8, 8, 8}, atan2(8, 0)}, + [flat_low_tile_id] = {{2, 2, 2, 2, 2, 2, 2, 2}, {0, 0, 0, 0, 0, 0, 8, 8}, atan2(8, 0)}, + [bottom_right_quarter_tile_id] = {{0, 0, 0, 0, 4, 4, 4, 4}, {0, 0, 0, 0, 4, 4, 4, 4}, atan2(8, 0)}, + [asc_slope_45_id] = {{1, 2, 3, 4, 5, 6, 7, 8}, {1, 2, 3, 4, 5, 6, 7, 8}, atan2(8, -8)}, + [desc_slope_45_id] = {{8, 7, 6, 5, 4, 3, 2, 1}, {1, 2, 3, 4, 5, 6, 7, 8}, atan2(8, 8)}, + [asc_slope_22_id] = {{2, 2, 3, 3, 4, 4, 5, 5}, {0, 0, 0, 2, 4, 6, 8, 8}, 0.0625}, + [asc_slope_22_upper_level_id] = {{5, 5, 6, 6, 7, 7, 8, 8}, {2, 4, 6, 8, 8, 8, 8, 8}, atan2(8, -4)}, + [loop_topleft] = {{8, 8, 8, 8, 8, 7, 6, 5}, {8, 8, 8, 8, 8, 7, 6, 5}, atan2(-4, 4)}, +} + +-- process data above to generate interior_v/h automatically, so we don't have to add them manually +-- for each tile (and it's actually what PICO-8 build does in collision_data to define tiles_collision_data) +local mock_tile_collision_data = transform(mock_raw_tile_collision_data, function(raw_data) + local slope_angle = raw_data[3] + local interior_v, interior_h = tile_collision_data.slope_angle_to_interiors(slope_angle) -local height_array_init_mock + return tile_collision_data( + raw_data[1], + raw_data[2], + slope_angle, + interior_v, + interior_h + ) +end) + +local tile_test_data = {} function tile_test_data.setup() -- mock sprite flags fset(1, sprite_flags.collision, true) -- invalid tile (missing collision mask id location below) - fset(64, sprite_flags.collision, true) -- full tile - fset(65, sprite_flags.collision, true) -- ascending slope 45 - fset(66, sprite_flags.collision, true) -- descending slope 45 - fset(67, sprite_flags.collision, true) -- ascending slope 22.5 offset by 2 - fset(68, sprite_flags.collision, true) -- wavy horizontal almost full tile - fset(70, sprite_flags.collision, true) -- half-tile (bottom half) - fset(71, sprite_flags.collision, true) -- quarter-tile (bottom-right half) - fset(72, sprite_flags.collision, true) -- low-tile (bottom quarter) - fset(73, sprite_flags.collision, true) -- high-tile (3/4 filled) + fset(full_tile_id, sprite_flags.collision, true) -- full tile + fset(half_tile_id, sprite_flags.collision, true) -- half-tile (bottom half) + fset(flat_low_tile_id, sprite_flags.collision, true) -- low-tile (bottom quarter) + fset(bottom_right_quarter_tile_id, sprite_flags.collision, true) -- quarter-tile (bottom-right half) + fset(asc_slope_45_id, sprite_flags.collision, true) -- ascending slope 45 + fset(asc_slope_22_id, sprite_flags.collision, true) -- ascending slope 22.5 offset by 2 + fset(desc_slope_45_id, sprite_flags.collision, true) -- descending slope 45 + fset(loop_topleft, sprite_flags.collision, true) -- low-tile (bottom quarter) -- mock height array _init so it doesn't have to dig in sprite data, inaccessible from busted - 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 - elseif tile_mask_id_location == collision_data.tiles_data[65].id_loc then - self._array = {1, 2, 3, 4, 5, 6, 7, 8} -- ascending slope 45 - elseif tile_mask_id_location == collision_data.tiles_data[66].id_loc then - self._array = {8, 7, 6, 5, 4, 3, 2, 1} -- descending slope 45 - elseif tile_mask_id_location == collision_data.tiles_data[67].id_loc then - self._array = {2, 2, 3, 3, 4, 4, 5, 5} -- ascending slope 22.5 - elseif tile_mask_id_location == collision_data.tiles_data[68].id_loc then - self._array = {8, 8, 7, 6, 6, 7, 6, 7} -- wavy horizontal almost full tile - elseif tile_mask_id_location == collision_data.tiles_data[70].id_loc then - self._array = {4, 4, 4, 4, 4, 4, 4, 4} -- half-tile (bottom half) - elseif tile_mask_id_location == collision_data.tiles_data[71].id_loc then - self._array = {0, 0, 0, 0, 4, 4, 4, 4} -- quarter-tile (bottom-right quarter) - elseif tile_mask_id_location == collision_data.tiles_data[72].id_loc then - self._array = {2, 2, 2, 2, 2, 2, 2, 2} -- low-tile (bottom quarter) - elseif tile_mask_id_location == collision_data.tiles_data[73].id_loc then - self._array = {6, 6, 6, 6, 6, 6, 6, 6} -- high-tile (3/4 filled) - else - self._array = "invalid" - end - -- we trust the collision_data value to match our mockups - -- if they don't, we need to override that value in the cases above - self.slope_angle = tile_data.slope_angle + stub(collision_data, "get_tile_collision_data", function (current_tile_id) + return mock_tile_collision_data[current_tile_id] end) - end function tile_test_data.teardown() pico8:clear_spriteflags() - height_array_init_mock:revert() + collision_data.get_tile_collision_data:revert() end -- helper safety function that verifies that mock tile data is active when creating mock maps for utests @@ -64,10 +67,21 @@ end 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") + assert(collision_data.get_tile_collision_data and not collision_data.get_tile_collision_data.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 + +-- prevent busted from parsing both versions of tile_test_data +--[[#pico8 +-- fallback implementation if busted symbol is not defined +-- (picotool fails on empty file due to empty self._tokens) +--#ifn busted +local tile_test_data = {"symbol tile_test_data is undefined"} --#endif + +--#pico8]] + +return tile_test_data diff --git a/src/tests/headless_itests_utest.lua b/src/tests/headless_itests_utest.lua index cefd5701..3f57b779 100644 --- a/src/tests/headless_itests_utest.lua +++ b/src/tests/headless_itests_utest.lua @@ -1,5 +1,5 @@ -- todo: use busted --helper=.../bustedhelper instead of all the bustedhelper requires! -require("engine/test/bustedhelper") +require("test/bustedhelper") require("engine/test/headless_itest") require("engine/test/integrationtest") local logging = require("engine/debug/logging") diff --git a/src/utest_main.lua b/src/utest_main.lua index cf6f80f9..ef40069d 100644 --- a/src/utest_main.lua +++ b/src/utest_main.lua @@ -3,8 +3,12 @@ -- otherwise busted tests should be enough -- each utest should be put inside the src/utests folder with the name utest{something}.lua -local unittest = require("engine/test/unittest") -local utest_manager, unit_test = unittest.utest_manager, unittest.unit_test +-- must require at main top, to be used in any required modules from here +require("engine/pico8/api") +require("engine/common") +require("common") + +local p8utest = require("engine/test/p8utest") -- tag to add require for pico8 utests files (should be in utests/) --[[add_require]] @@ -14,5 +18,5 @@ logging.logger:register_stream(logging.console_log_stream) --#endif function _init() - utest_manager:run_all_tests() + p8utest.utest_manager:run_all_tests() end diff --git a/src/utests/utestdata.lua b/src/utests/utestdata.lua index c8975120..f8f490e2 100644 --- a/src/utests/utestdata.lua +++ b/src/utests/utestdata.lua @@ -1,8 +1,5 @@ -require("engine/test/unittest") -require("engine/core/math") -local tile = require("platformer/tile") -local height_array = tile.height_array -local tile_data = tile.tile_data +require("engine/test/p8utest") +local tile_collision_data = require("data/tile_collision_data") -- data to test local collision_data = require("data/collision_data") @@ -11,33 +8,34 @@ local collision_data = require("data/collision_data") -- after minification if keys are not protected with ["key"] syntax) local playercharacter_data = require("data/playercharacter_data") -check('sprite_id_location(0, 4) should have collision flag set', function () - local sprite_id = sprite_id_location(0, 4):to_sprite_id() - assert(fget(sprite_id, sprite_flags.collision), "sprite_id_location(0, 4) has collision flag unset") +check('sprite_id_location(8, 0) (loop top-left) should have collision flag set', function (utest_name) + local sprite_id = sprite_id_location(8, 0):to_sprite_id() + assert_log(utest_name, fget(sprite_id, sprite_flags.collision), "sprite_id_location(0, 4) has collision flag unset") end) -check('sprite_id_location(0, 4) should have collision mask id set to location below', function () - local sprite_id = sprite_id_location(0, 4):to_sprite_id() - assert(collision_data.tiles_data[sprite_id] == tile_data(sprite_id_location(0, 5), 0)) +check('sprite_id_location(8, 0) (loop top-left) should have collision arrays of loop top-left, angle atan2(-4, 4), interior up-left', function (utest_name) + local sprite_id = sprite_id_location(8, 0):to_sprite_id() + local tcd = collision_data.get_tile_collision_data(sprite_id) + assert_log(utest_name, are_same_with_message(tcd, tile_collision_data({8, 8, 8, 8, 8, 7, 6, 5}, {8, 8, 8, 8, 8, 7, 6, 5}, atan2(-4, 4), vertical_dirs.up, horizontal_dirs.left))) end) -check('. height_array._fill_array on sprite_id_location(0, 5) should fill the array with tile mask data: full', function () - local array = {} - height_array._fill_array(array, sprite_id_location(0, 5)) - assert(are_same_with_message({8, 8, 8, 8, 8, 8, 8, 8}, array)) +check('tile_collision_data.read_height_array on sprite_id_location(0, 0) should return an array with tile mask data: full', function (utest_name) + local array = tile_collision_data.read_height_array(sprite_id_location(0, 2), vertical_dirs.down) + assert_log(utest_name, are_same_with_message({8, 8, 8, 8, 8, 8, 8, 8}, array)) end) --- bugfix history: after switching to pink transparency, all my tiles became square blocks -check('= height_array._fill_array on sprite_id_location(0, 5) the array with tile mask data: descending slope 45', function () - local array = {} - height_array._fill_array(array, sprite_id_location(1, 5)) - assert(are_same_with_message({1, 2, 3, 4, 5, 6, 7, 8}, array)) +-- bugfix history: +-- = after switching to pink transparency, all my tiles became square blocks +-- warning: it's a proto tile, if you strip it from final build later, test another tile instead +check('tile_collision_data.read_height_array on sprite_id_location(1, 7) return an array with tile mask data: ascending slope 45', function (utest_name) + local array = tile_collision_data.read_height_array(sprite_id_location(1, 7), vertical_dirs.down) + assert_log(utest_name, are_same_with_message({1, 2, 3, 4, 5, 6, 7, 8}, array)) end) -check('sonic_sprite_data_table preserved key "idle"', function () - assert(playercharacter_data.sonic_sprite_data_table["idle"] ~= nil) +check('sonic_sprite_data_table preserved key "idle"', function (utest_name) + assert_log(utest_name, playercharacter_data.sonic_sprite_data_table["idle"] ~= nil, 'Expected playercharacter_data.sonic_sprite_data_table["idle"] to not be nil') end) -check('sonic_animated_sprite_data_table preserved key "idle"', function () - assert(playercharacter_data.sonic_animated_sprite_data_table["idle"] ~= nil) +check('sonic_animated_sprite_data_table preserved key "idle"', function (utest_name) + assert_log(utest_name, playercharacter_data.sonic_animated_sprite_data_table["idle"] ~= nil, 'Expected playercharacter_data.sonic_animated_sprite_data_table["idle"] to not be nil') end)