diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e3611606 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +# Synced from bsmaster. +# EFRO_SYNC_HASH=114884515626536986523570707155141627178 +# +# For configuring supported editors + +# This is the top-most EditorConfig file +root = true + +# Defaults for all files +[*] +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +# Python overrides +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 79 +charset = utf-8 + +# Makefile overrides +[Makefile] +indent_style = tab +max_line_length = 80 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6d3830cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +*.wav filter=lfs diff=lfs merge=lfs -text +*.fdata filter=lfs diff=lfs merge=lfs -text +*.obj filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.psd filter=lfs diff=lfs merge=lfs -text +*.lib filter=lfs diff=lfs merge=lfs -text +*.dll filter=lfs diff=lfs merge=lfs -text +*.icns filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.bmp filter=lfs diff=lfs merge=lfs -text +*.xbm filter=lfs diff=lfs merge=lfs -text +*.jar filter=lfs diff=lfs merge=lfs -text +*.aar filter=lfs diff=lfs merge=lfs -text +*.pyd filter=lfs diff=lfs merge=lfs -text +*.exe filter=lfs diff=lfs merge=lfs -text +*.a filter=lfs diff=lfs merge=lfs -text +/tools/make_bob/mac/* filter=lfs diff=lfs merge=lfs -text +/tools/mali_texture_compression_tool/mac/* filter=lfs diff=lfs merge=lfs -text +/tools/nvidia_texture_tools/mac/* filter=lfs diff=lfs merge=lfs -text +/tools/powervr_tools/mac/* filter=lfs diff=lfs merge=lfs -text + diff --git a/.projectile b/.projectile new file mode 100644 index 00000000..cb2b7d1a --- /dev/null +++ b/.projectile @@ -0,0 +1,4 @@ ++/tools ++/src/ballistica ++/src/generated_src ++/assets/src/data/scripts diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..9a78183c --- /dev/null +++ b/Makefile @@ -0,0 +1,1398 @@ +# This Makefile encompasses most high level functionality you should need when +# working with the game. These rules are also handy as reference or a starting +# point if you need specific funtionality beyond what is here. +# The targets here do not expect -jX to be passed to them and generally +# add that argument to subprocesses as needed. + +# Default is to build/run the mac version. +all: mac + +# We often want one job per core, so try to determine our logical core count. +ifeq ($(wildcard /proc),/proc) # Linux + JOBS = $(shell cat /proc/cpuinfo | awk '/^processor/{print $3}' | wc -l) +else # Mac + JOBS = $(shell sysctl -n hw.ncpu) +endif + +# Prefix used for output of docs/changelogs/etc targets for use in webpages. +DOCPREFIX = "ballisticacore_" + +# Tell make which of these targets don't represent files. +.PHONY: all + + +################################################################################ +# # +# General # +# # +################################################################################ + +# Prerequisites that should be in place before running most any other build; +# things like tool config files, etc. +PREREQS = .dir-locals.el .mypy.ini .pycheckers .pylintrc \ + .style.yapf .clang-format ballisticacore-cmake/.clang-format \ + .irony/compile_commands.json + +# List the targets in this Makefile and basic descriptions for them. +list: + @tools/snippets makefile_target_list Makefile + +# Same as 'list' +help: list + +prereqs: ${PREREQS} + +prereqs-clean: + rm -rf ${PREREQS} .irony + +# Build all assets for all platforms. +assets: + @cd assets && make -j${JOBS} + +# Build only assets required for desktop builds (mac, pc, linux). +assets-desktop: + @cd assets && make -j${JOBS} desktop + +# Build only assets required for ios. +assets-ios: + @cd assets && make -j${JOBS} ios + +# Build only assets required for android. +assets-android: + @cd assets && make -j${JOBS} android + +# Clean all assets. +assets-clean: + @cd assets && make clean + +# Build resources. +resources: resources/Makefile + @cd resources && make -j${JOBS} resources + +# Clean resources. +resources-clean: + @cd resources && make clean + +# Build our generated code. +code: + @cd src/generated_src && make -j${JOBS} generated_code + +# Clean generated code. +code-clean: + @cd src/generated_src && make clean + +# Remove *ALL* files and directories that aren't managed by git +# (except for a few things such as localconfig.json). +clean: + @${CHECK_CLEAN_SAFETY} + @git clean -dfx ${ROOT_CLEAN_IGNORES} + +# Show what clean would delete without actually deleting it. +cleanlist: + @${CHECK_CLEAN_SAFETY} + @git clean -dnx ${ROOT_CLEAN_IGNORES} + +# For spinoff projects: pull in the newest parent project +# and sync changes into ourself. +spinoff-upgrade: + @echo Pulling latest parent project... + @cd submodules/ballistica && git checkout master && git pull + @echo Syncing parent into current project... + @tools/spinoff update + @echo spinoff upgrade successful! + +# Shortcut to run a spinoff upgrade and push to git. +spinoff-upgrade-push: spinoff-upgrade + git add . + git commit -m "spinoff upgrade" + git push + +# Force regenerate the dummy module. +dummymodule: + ./tools/gendummymodule.py --force + +# Tell make which of these targets don't represent files. +.PHONY: list prereqs prereqs-clean assets assets-desktop assets-ios\ + assets-android assets-clean resources resources-clean code code-clean\ + clean cleanlist spinoff-upgrade spinoff-upgrade-push dummymodule + + +################################################################################ +# # +# Syncing # +# # +################################################################################ + +# Sync files to/from local related projects. +# Used for some scripts/etc. where git submodules would be overkill. +# 'sync' pulls files in, 'syncfull' also pushes out, 'synclist' prints a dry +# run, 'syncforce' pulls without looking, 'synccheck' ensures files haven't +# changed since synced, 'syncall' runs syncfull for colon-separated project +# paths defined in the EFTOOLS_SYNC_PROJECTS environment variable.. +sync: + tools/snippets sync + +syncfull: + tools/snippets sync full + +synclist: + tools/snippets sync list + +syncforce: + tools/snippets sync force + +synccheck: + tools/snippets sync check + +syncall: + tools/snippets sync_all + +syncalllist: + tools/snippets sync_all list + +# Tell make which of these targets don't represent files. +.PHONY: sync syncfull synclist syncforce synccheck syncall syncalllist + + +################################################################################ +# # +# Formatting / Checking # +# # +################################################################################ + +# Note: Some of these targets have alternative flavors: +# 'full' - clears caches/etc so all files are reprocessed even if not dirty. +# 'fast' - takes some shortcuts for faster iteration, but may miss things + +# Format code/scripts. + +# Run formatting on all files in the project considered 'dirty'. +format: + @make -j2 formatcode formatscripts + @echo Formatting complete! + +# Same but always formats; ignores dirty state. +formatfull: + @make -j2 formatcodefull formatscriptsfull + @echo Formatting complete! + +# Run formatting for compiled code sources (.cc, .h, etc.). +formatcode: prereqs + @tools/snippets formatcode + +# Same but always formats; ignores dirty state. +formatcodefull: prereqs + @tools/snippets formatcode -full + +# Runs formatting for scripts (.py, etc). +formatscripts: prereqs + @tools/snippets formatscripts + +# Same but always formats; ignores dirty state. +formatscriptsfull: prereqs + @tools/snippets formatscripts -full + +# Note: the '2' varieties include extra inspections such as PyCharm. +# These are useful, but can take significantly longer and/or be a bit flaky. + +check: updatecheck + @make -j3 cpplintcode pylintscripts mypyscripts + @echo ALL CHECKS PASSED! +check2: updatecheck + @make -j4 cpplintcode pylintscripts mypyscripts pycharmscripts + @echo ALL CHECKS PASSED! + +checkfast: updatecheck + @make -j3 cpplintcode pylintscriptsfast mypyscripts + @echo ALL CHECKS PASSED! +checkfast2: updatecheck + @make -j4 cpplintcode pylintscriptsfast mypyscripts pycharmscripts + @echo ALL CHECKS PASSED! + +checkfull: updatecheck + @make -j3 cpplintcodefull pylintscriptsfull mypyscriptsfull + @echo ALL CHECKS PASSED! +checkfull2: updatecheck + @make -j4 cpplintcodefull pylintscriptsfull mypyscriptsfull pycharmscriptsfull + @echo ALL CHECKS PASSED! + +cpplintcode: prereqs + @tools/snippets cpplintcode + +cpplintcodefull: prereqs + @tools/snippets cpplintcode -full + +pylintscripts: prereqs + @tools/snippets pylintscripts + +pylintscriptsfull: prereqs + @tools/snippets pylintscripts -full + +mypyscripts: prereqs + @tools/snippets mypyscripts + +mypyscriptsfull: prereqs + @tools/snippets mypyscripts -full + +pycharmscripts: prereqs + @tools/snippets pycharmscripts + +pycharmscriptsfull: prereqs + @tools/snippets pycharmscripts -full + +# 'Fast' script checking using dependency recursion limits. +# This can require much less re-checking but may miss problems in rare cases. +# Its not a bad idea to run a non-fast check every so often or before pushing. +pylintscriptsfast: prereqs + @tools/snippets pylintscripts -fast + +# Tell make which of these targets don't represent files. +.PHONY: format formatfull formatcode formatcodefull formatscripts \ + formatscriptsfull check check2 checkfast checkfast2 checkfull checkfull2 \ + cpplintcode cpplintcodefull pylintscripts pylintscriptsfull mypyscripts \ + mypyscriptsfull pycharmscripts pycharmscriptsfull + + +################################################################################ +# # +# Updating / Preflighting # +# # +################################################################################ + +# Update any project files that need it (does NOT build projects). +update: prereqs + @tools/update_project + +# Don't update but fail if anything needs it. +updatecheck: prereqs + @tools/update_project --check + +# Run an update and check together; handy while iterating. +# (slightly more efficient than running update/check separately). +updatethencheck: update + @make -j3 cpplintcode pylintscripts mypyscripts + @echo ALL CHECKS PASSED! +updatethencheck2: update + @make -j4 cpplintcode pylintscripts mypyscripts pycharmscripts + @echo ALL CHECKS PASSED! + +updatethencheckfast: update + @make -j3 cpplintcode pylintscriptsfast mypyscripts + @echo ALL CHECKS PASSED! +updatethencheckfast2: update + @make -j4 cpplintcode pylintscriptsfast mypyscripts pycharmscripts + @echo ALL CHECKS PASSED! + +updatethencheckfull: update + @make -j3 cpplintcodefull pylintscriptsfull mypyscriptsfull + @echo ALL CHECKS PASSED! +updatethencheckfull2: update + @make -j4 cpplintcodefull pylintscriptsfull mypyscriptsfull pycharmscriptsfull + @echo ALL CHECKS PASSED! + +# Run a format, an update, and then a check. +# Handy before pushing commits. + +preflight: + @make format + @make updatethencheck + @echo PREFLIGHT SUCCESSFUL! +preflight2: + @make format + @make updatethencheck2 + @echo PREFLIGHT SUCCESSFUL! + +preflightfast: + @make format + @make updatethencheckfast + @echo PREFLIGHT SUCCESSFUL! +preflightfast2: + @make format + @make updatethencheckfast2 + @echo PREFLIGHT SUCCESSFUL! + +preflightfull: + @make formatfull + @make updatethencheckfull + @echo PREFLIGHT SUCCESSFUL! +preflightfull2: + @make formatfull + @make updatethencheckfull2 + @echo PREFLIGHT SUCCESSFUL! + +# Tell make which of these targets don't represent files. +.PHONY: update updatecheck updatethencheck updatethencheck2 \ + updatethencheckfast updatethencheckfast2 updatethencheckfull \ + updatethencheckfull2 preflight preflight2 preflightfast preflightfast2 \ + preflightfull preflightfull2 + + +################################################################################ +# # +# Mac # +# # +################################################################################ + +# Build and run generic mac xcode version. +mac: + # (build_mac handles assets, resources, code) + @tools/build_mac -nospeak + +# Just build; don't run. +mac-build: + # (build_mac handles assets, resources, code) + tools/build_mac -nospeak -norun + +# Clean it. +mac-clean: + @tools/build_mac -clean -nospeak + +# Build and run mac-app-store build. +mac-appstore: + # (build_mac handles assets, resources, code) + @tools/build_mac -appstore -nospeak + +# Build and run mac-app-store build (release config). +mac-appstore-release: + # (build_mac handles assets, resources, code) + @tools/build_mac -appstore -release -nospeak + +# Just build; don't run. +mac-appstore-build: + # (build_mac handles assets, resources, code) + @tools/build_mac -appstore -nospeak -norun + +# Just build; don't run (opt version). +mac-appstore-release-build: + # (build_mac handles assets, resources, code) + @tools/build_mac -appstore -release -nospeak -norun + +# Clean it. +mac-appstore-clean: + @tools/build_mac -appstore -clean -nospeak + +# Build the new mac xcode version. +mac-new-build: assets-desktop resources code + @xcodebuild -project ballisticacore-xcode/BallisticaCore.xcodeproj \ + -scheme "BallisticaCore macOS" -configuration Debug + +# Clean new xcode version. +mac-new-clean: + @xcodebuild -project ballisticacore-xcode/BallisticaCore.xcodeproj \ + -scheme "BallisticaCore macOS" -configuration Debug clean + +# Assemble a mac test-build package. +mac-package: assets-desktop resources code + @rm -f ${DIST_DIR}/${MAC_PACKAGE_NAME}.zip + @xcodebuild -project ballisticacore-mac.xcodeproj \ + -scheme "BallisticaCoreTest macOS Legacy" \ + -configuration Release clean build + @cd ${MAC_DIST_BUILD_DIR} && rm -f ${MAC_PACKAGE_NAME}.zip && zip -rq \ + ${MAC_PACKAGE_NAME}.zip \ + BallisticaCoreTest.app && mkdir -p ${DIST_DIR} && mv \ + ${MAC_PACKAGE_NAME}.zip ${DIST_DIR} + @echo SUCCESS! - created ${MAC_PACKAGE_NAME}.zip + +# Build a server version. +# FIXME: Switch this to the cmake version. +mac-server-build: assets-desktop resources code + @xcodebuild -project ballisticacore-mac.xcodeproj \ + -scheme "bs_headless macOS Legacy" -configuration Debug + +# Clean the mac build. +# It lives outside of our dir so we can't use 'git clean'. +mac-server-clean: + @xcodebuild -project ballisticacore-mac.xcodeproj \ + -scheme "bs_headless macOS Legacy" -configuration Debug clean + +MAC_SERVER_BUILD_DIR = ${DIST_DIR}/${MAC_SERVER_PACKAGE_NAME}_build +MAC_SERVER_PACKAGE_DIR = ${DIST_DIR}/${MAC_SERVER_PACKAGE_NAME} + +mac-server-package: assets-desktop resources code + @rm -rf ${DIST_DIR}/${MAC_SERVER_PACKAGE_NAME}.zip \ + ${MAC_SERVER_BUILD_DIR} ${MAC_SERVER_PACKAGE_DIR} \ + ${MAC_SERVER_PACKAGE_DIR}.tar.gz + @mkdir -p ${MAC_SERVER_BUILD_DIR} ${MAC_SERVER_PACKAGE_DIR} ${DIST_DIR} + @cd ${MAC_SERVER_BUILD_DIR} && cmake -DCMAKE_BUILD_TYPE=Release \ + -DHEADLESS=true ${ROOT_DIR}/ballisticacore-cmake && make -j${JOBS} + @cd ${MAC_SERVER_PACKAGE_DIR} \ + && cp ${MAC_SERVER_BUILD_DIR}/ballisticacore \ + ./bs_headless \ + && cp ${ROOT_DIR}/assets/src/server/server.py \ + ./ballisticacore_server \ + && cp ${ROOT_DIR}/assets/src/server/README.txt ./README.txt \ + && cp ${ROOT_DIR}/assets/src/server/config.py ./config.py \ + && cp ${ROOT_DIR}/CHANGELOG.md ./CHANGELOG.txt \ + && ${STAGE_ASSETS} -cmake-server . + @cd ${MAC_SERVER_PACKAGE_DIR}/.. && zip -rq \ + ${MAC_SERVER_PACKAGE_NAME}.zip ${MAC_SERVER_PACKAGE_NAME} + @rm -rf ${MAC_SERVER_BUILD_DIR} ${MAC_SERVER_PACKAGE_DIR} + @echo SUCCESS! - created ${MAC_SERVER_PACKAGE_NAME}.zip + +# Tell make which of these targets don't represent files. +.PHONY: mac mac-build mac-clean mac-appstore mac-appstore-release \ + mac-appstore-build mac-appstore-release-build mac-appstore-clean \ + mac-new-build mac-new-clean mac-package mac-server-build mac-server-clean \ + mac-server-package + + +################################################################################ +# # +# Windows # +# # +################################################################################ + +# Set these env vars from the command line to influence the build: + +# Can be empty, Headless, or Oculus +WINDOWS_PROJECT ?= + +# Can be Win32 or x64 +WINDOWS_PLATFORM ?= Win32 + +# Can be Debug or Release +WINDOWS_CONFIGURATION ?= Debug + +# Builds win version via VM. Doesn't launch but leaves VM running. +# (hopefully can figure out how to launch at some point) +windows: windows-staging + @VMSHELL_SUSPEND=0 VMSHELL_SHOW=1 tools/vmshell win7 ${WIN_MSBUILD_EXE} \ + \"__INTERNAL_PATH__\\ballisticacore-windows\\BallisticaCore.sln\" \ + /t:BallisticaCore$(WINPRJ) /p:Configuration=$(WINCFG) \ + ${VISUAL_STUDIO_VERSION} && echo WINDOWS $(WINCFG) BUILD SUCCESSFULL! \ + && echo NOW LAUNCH: \ + ballisticacore-windows\\$(WINCFG)\\BallisticaCore$(WINPRJ).exe + +# Build but don't run (shuts down the vm). +windows-build: windows-staging + @tools/vmshell win7 $(WIN_MSBUILD_EXE) \ + \"__INTERNAL_PATH__\\ballisticacore-windows\\BallisticaCore.sln\"\ + /t:BallisticaCore$(WINPRJ) /p:Configuration=$(WINCFG) \ + ${VISUAL_STUDIO_VERSION} && echo WINDOWS $(WINPRJ)$(WINPLT)$(WINCFG) \ + REBUILD SUCCESSFULL! + +windows-rebuild: windows-staging + @tools/vmshell win7 $(WIN_MSBUILD_EXE) \ + \"__INTERNAL_PATH__\\ballisticacore-windows\\BallisticaCore.sln\"\ + /t:BallisticaCore$(WINPRJ):Rebuild /p:Configuration=$(WINCFG) \ + ${VISUAL_STUDIO_VERSION} && echo WINDOWS $(WINPRJ)$(WINPLT)$(WINCFG) \ + REBUILD SUCCESSFULL! + +# Stage assets and other files so a built binary will run. +windows-staging: assets-desktop resources code + ${STAGE_ASSETS} -win-$(WINPLT) \ + ballisticacore-windows/Build/$(WINCFG)_$(WINPLT) + +# Remove all non-git-managed files in windows subdir. +windows-clean: + @${CHECK_CLEAN_SAFETY} + git clean -dfx ballisticacore-windows + +# Show what would be cleaned. +windows-cleanlist: + @${CHECK_CLEAN_SAFETY} + git clean -dnx ballisticacore-windows + +windows-package: + WINDOWS_PROJECT= WINDOWS_PLATFORM=Win32 WINDOWS_CONFIGURATION=Release \ + make _windows-package + +windows-server-package: + WINDOWS_PROJECT=Headless WINDOWS_PLATFORM=Win32 \ + WINDOWS_CONFIGURATION=Release make _windows-server-package + +windows-oculus-package: + WINDOWS_PROJECT=Oculus WINDOWS_PLATFORM=Win32 WINDOWS_CONFIGURATION=Release \ + make _windows-oculus-package + +# Tell make which of these targets don't represent files. +.PHONY: windows windows-build windows-rebuild windows-staging windows-clean \ + windows-cleanlist windows-package windows-server-package \ + windows-oculus-package + + +################################################################################ +# # +# iOS # +# # +################################################################################ + +# Build all dependencies. +# If building/running directly from xcode, run this first. +ios-deps: assets-ios resources code + +# Just build; don't install or run. +ios-build: ios-deps + xcodebuild -project ballisticacore-ios.xcodeproj \ + -scheme "BallisticaCore iOS Legacy" -configuration Debug + +ios-build-release: ios-deps + xcodebuild -project ballisticacore-ios.xcodeproj \ + -scheme "BallisticaCore iOS Legacy" -configuration Release + +ios-clean: + xcodebuild -project ballisticacore-ios.xcodeproj \ + -scheme "BallisticaCore iOS Legacy" -configuration Debug clean + +# Build and push debug build to staging server. +# (Used for iterating, not final releases) +ios-push: ios-build + @tools/snippets push_ipa debug + +# Build and push release build to staging server. +# (Used for iterating, not final releases) +ios-push-release: ios-build-release + @tools/snippets push_ipa release + +# Build and run new ios xcode version. +ios-new-build: ios-deps + @xcodebuild -project ballisticacore-xcode/BallisticaCore.xcodeproj \ + -scheme "BallisticaCore iOS" -configuration Debug + +# Clean new xcode version. +ios-new-clean: + @xcodebuild -project ballisticacore-xcode/BallisticaCore.xcodeproj \ + -scheme "BallisticaCore iOS" -configuration Debug clean + +# Tell make which of these targets don't represent files. +.PHONY: ios-deps ios-build ios-build-release ios-clean ios-push \ + ios-push-release ios-new-build ios-new-clean + + +################################################################################ +# # +# tvOS # +# # +################################################################################ + +# Build all dependencies. +# If building/running directly from xcode, run this first. +tvos-deps: assets-ios resources code + +# Just build; don't install or run. +tvos-build: tvos-deps + xcodebuild -project ballisticacore-xcode/BallisticaCore.xcodeproj \ + -scheme "BallisticaCore tvOS" -configuration Debug + +tvos-clean: + xcodebuild -project ballisticacore-xcode/BallisticaCore.xcodeproj \ + -scheme "BallisticaCore tvOS" -configuration Debug clean + +# Tell make which of these targets don't represent files. +.PHONY: tvos-deps tvos-build tvos-clean + + +################################################################################ +# # +# Android # +# # +################################################################################ + +# Set these env vars from the command line to influence the build: + +# Subplatform: 'google', 'amazon', etc. +ANDROID_PLATFORM ?= generic + +# Build all archs: 'prod'. Build single: 'arm', 'arm64', 'x86', 'x86_64'. +ANDROID_MODE ?= arm + +# 'full' includes all assets; 'none' gives none and 'scripts' only scripts. +# Excluding assets can speed up iterations, but requires the full apk to +# have been installed/run first (which extracts all assets to the device). +ANDROID_ASSETS ?= full + +# Build type: can be debug or release +ANDROID_BUILDTYPE ?= debug + +# FIXME - should probably not require these here +ANDROID_APPID ?= "com.ericfroemling.ballisticacore" +ANDROID_MAIN_ACTIVITY ?= ".MainActivity" + +# Stage, build, install, and run a full apk. +android: android-build + make android-install + make android-run + +# Stage and build but don't install or run. +android-build: android-staging + cd ballisticacore-android && $(AN_ASSEMBLE_CMD) + +# Stage only (assembles/gathers assets & other files). +# Run this before using Android Studio to build/run. +android-staging: _android-sdk assets resources code + cd $(AN_PLATFORM_DIR) && ${STAGE_ASSETS} -android -${ANDROID_ASSETS} + +# Display filtered logs. +# Most of the standard targets do this already but some such as the +# debugger-attach ones don't so it can be handy to run this in a +# separate terminal concurrently. +android-log: + ${ANDROID_ADB} logcat -c + ${ANDROID_FILTERED_LOGCAT} + +# Run whatever is already installed. +android-run: + ${ANDROID_ADB} logcat -c + ${ANDROID_ADB} shell "$(AN_STRT) $(ANDROID_APPID)/${ANDROID_MAIN_ACTIVITY}" + ${ANDROID_FILTERED_LOGCAT} + +# Stop the game if its running. +android-stop: + ${ANDROID_ADB} shell am force-stop $(ANDROID_APPID) + +# Install the game. +android-install: + ${ANDROID_ADB} install -r $(AN_APK) + +# Uninstall the game. +android-uninstall: + ${ANDROID_ADB} uninstall $(ANDROID_APPID) + +# Clean the android build while leaving workspace intact. +# This includes Android Studio workspace files and built resources intact. +android-clean: + cd ballisticacore-android && ./gradlew clean + @echo Cleaning staged assets... + rm -rf ballisticacore-android/BallisticaCore/src/*/assets/ballistica_files + @echo Cleaning things gradle may have left behind... + rm -rf ballisticacore-android/build + rm -rf ballisticacore-android/*/build + rm -rf ballisticacore-android/*/.cxx + rm -rf ballisticacore-android/.idea/caches + +# Remove ALL non-git-managed files in the android subdir. +# This will blow away most IDEA related files, though they should +# be auto-regenerated when opening the project again. +# IMPORTANT: Doing a full re-import of the gradle project in +# android studio will blow away the few intellij files that we *do* +# want to keep, so be careful to undo those changes via git after. +# (via 'git checkout -- .' or whatnot) +android-fullclean: + @${CHECK_CLEAN_SAFETY} + git clean -dfx ballisticacore-android + +# Show what *would* be cleaned. +android-fullcleanlist: + @${CHECK_CLEAN_SAFETY} + git clean -dnx ballisticacore-android + +# Package up a generic android test-build apk. +android-package: + ANDROID_PLATFORM=generic ANDROID_MODE=prod ANDROID_BUILDTYPE=release\ + make _android-package + +# Build and archive a google play apk. +android-archive-google: + ANDROID_PLATFORM=google ANDROID_MODE=prod ANDROID_BUILDTYPE=release\ + make _android-bundle-archive + +# Build and archive a demo apk. +android-archive-demo: + ANDROID_PLATFORM=demo ANDROID_MODE=prod ANDROID_BUILDTYPE=release\ + make _android-archive + +# Build and archive an amazon apk. +android-archive-amazon: + ANDROID_PLATFORM=amazon ANDROID_MODE=prod ANDROID_BUILDTYPE=release\ + make _android-archive + +# Build and archive a cardboard apk. +android-archive-cardboard: + ANDROID_PLATFORM=cardboard ANDROID_MODE=prod ANDROID_BUILDTYPE=release\ + make _android-archive + +# Tell make which of these targets don't represent files. +.PHONY: android android-build android-staging \ + android-log android-run android-stop android-install \ + android-uninstall android-clean android-fullclean android-fullcleanlist \ + android-package android-archive-google android-archive-demo \ + android-archive-amazon android-archive-cardboard + + +################################################################################ +# # +# CMake # +# # +################################################################################ + +# VMshell configs to use when building; override to use your own. +# (just make sure the 32/64-bit-ness matches). +LINUX64_FLAVOR ?= linux64-u18 + +# At some point before long we can stop bothering with 32 bit linux builds. +LINUX32_FLAVOR ?= linux32-u16 + +# Build and run the cmake build. +cmake: cmake-build + @cd ballisticacore-cmake/build/debug && ./ballisticacore + +# Build but don't run it. +cmake-build: assets-desktop resources code + @${STAGE_ASSETS} -cmake ballisticacore-cmake/build/debug + @cd ballisticacore-cmake/build/debug && test -f Makefile \ + || cmake -DCMAKE_BUILD_TYPE=Debug ../.. + @cd ballisticacore-cmake/build/debug && make -j${JOBS} + +# Build and run it with an immediate quit command. +# Tests if the game is able to bootstrap itself. +cmake-launchtest: cmake-build + @cd ballisticacore-cmake/build/debug \ + && ./ballisticacore -exec "ba.quit()" + +# Build, sync to homebook fro, and run there. +cmake-hometest: cmake-build + @rsync --verbose --recursive --links --checksum --delete -e "ssh -p 49136" \ + ballisticacore-cmake/build/ballisticacore/ \ + efro.duckdns.org:/Users/ericf/Documents/remote_ballisticacore_test + # Note: use -t so the game sees a terminal and we get prompts & log output. + @ssh -t -p 49136 efro.duckdns.org \ + cd /Users/ericf/Documents/remote_ballisticacore_test \&\& ./ballisticacore + +cmake-clean: + rm -rf ballisticacore-cmake/build/debug + +cmake-server: cmake-server-build + @cd ballisticacore-cmake/build/server-debug && ./ballisticacore + +cmake-server-build: assets-desktop resources code + @${STAGE_ASSETS} -cmake-server ballisticacore-cmake/build/server-debug + @cd ballisticacore-cmake/build/server-debug && test -f Makefile \ + || cmake -DCMAKE_BUILD_TYPE=Debug -DHEADLESS=true ../.. + @cd ballisticacore-cmake/build/server-debug && make -j${JOBS} + +cmake-server-clean: + rm -rf ballisticacore-cmake/build/server-debug + +# Tell make which of these targets don't represent files. +.PHONY: cmake cmake-build cmake-launchtest cmake-hometest cmake-clean \ + cmake-server cmake-server-build cmake-server-clean + + +################################################################################ +# # +# Linux # +# # +################################################################################ + +# Build and run 64 bit linux version via VM. +# Note: this leaves the vm running when it is done. +linux64: assets-desktop resources code + @${STAGE_ASSETS} -cmake build/linux64 + @VMSHELL_INTERACTIVE=1 VMSHELL_SUSPEND=0 VMSHELL_SHOW=1 \ + tools/vmshell ${LINUX64_FLAVOR} \ + cd __INTERNAL_PATH__ \&\& make _linux + +# Just build; don't run (also kills vm after). +linux64-build: assets-desktop resources code + @${STAGE_ASSETS} -cmake build/linux64 + @tools/vmshell ${LINUX64_FLAVOR} cd __INTERNAL_PATH__ \ + \&\& make _linux-build + +# Clean the 64 bit linux version from mac. +linux64-clean: + rm -rf build/linux64 + +# Assemble a complete 64 bit linux package via vm. +linux64-package: assets-desktop resources code + @tools/vmshell ${LINUX64_FLAVOR} cd __INTERNAL_PATH__ \ + \&\& make _linux-package-build + @ARCH=x86_64 make _linux-package-assemble + +# Assemble a complete 32 bit linux package via vm. +linux32-package: assets-desktop resources code + @tools/vmshell ${LINUX32_FLAVOR} cd __INTERNAL_PATH__ \ + \&\& make _linux-package-build + @ARCH=i386 make _linux-package-assemble + +# Build and run 32 bit linux version from mac (requires vmware) +# note: this leaves the vm running when it is done. +linux32: assets-desktop resources code + @VMSHELL_INTERACTIVE=1 VMSHELL_SUSPEND=0 VMSHELL_SHOW=1 \ + tools/vmshell ${LINUX32_FLAVOR} \ + cd __INTERNAL_PATH__ \&\& make _linux + +# Just build; don't run (also kills vm after). +linux32-build: assets-desktop resources code + @${STAGE_ASSETS} -cmake build/linux32 + @tools/vmshell ${LINUX32_FLAVOR} cd __INTERNAL_PATH__ \ + \&\& make _linux-build + +# Clean the 32 bit linux version from mac. +linux32-clean: + rm -rf build/linux32 + +# Just build; don't run (also kills vm after). +linux64-server-build: assets-desktop resources code + @${STAGE_ASSETS} -cmake-server build/linux64_server + @tools/vmshell ${LINUX64_FLAVOR} cd __INTERNAL_PATH__ \ + \&\& make _linux-server-build + +# Clean the 64 bit linux server version from mac. +linux64-server-clean: + rm -rf build/linux64_server + +# Assemble complete 64 bit linux server package via vm. +linux64-server-package: assets-desktop resources code + @tools/vmshell ${LINUX64_FLAVOR} cd __INTERNAL_PATH__ \ + \&\& make _linux-server-package-build + @ARCH=x86_64 make _linux-server-package-assemble + +# Just build; don't run (also kills vm after). +linux32-server-build: assets-desktop resources code + @${STAGE_ASSETS} -cmake-server build/linux32_server + @tools/vmshell ${LINUX32_FLAVOR} cd __INTERNAL_PATH__ \ + \&\& make _linux-server-build + +# Clean the 32 bit linux server version from mac. +linux32-server-clean: + rm -rf build/linux32_server + +# Assemble complete 32 bit linux server package via vm. +linux32-server-package: assets-desktop resources code + @tools/vmshell ${LINUX32_FLAVOR} cd __INTERNAL_PATH__ \ + \&\& make _linux-server-package-build + @ARCH=x86_32 make _linux-server-package-assemble + +# Assemble a raspberry pi server package. +raspberry-pi-server-package: assets resources code + @tools/build_raspberry_pi + @mkdir -p ${DIST_DIR} && cd ${DIST_DIR} && rm -rf ${RPI_SERVER_PACKAGE_NAME} \ + ${RPI_SERVER_PACKAGE_NAME}.tar.gz && mkdir -p ${RPI_SERVER_PACKAGE_NAME} \ + && cd ${RPI_SERVER_PACKAGE_NAME} && mv ${ROOT_DIR}/build/bs_headless_rpi \ + ./bs_headless && cp ${ROOT_DIR}/assets/src/server/server.py \ + ./ballisticacore_server \ + && cp ${ROOT_DIR}/assets/src/server/README.txt ./README.txt \ + && cp ${ROOT_DIR}/CHANGELOG.md ./CHANGELOG.txt \ + && cp ${ROOT_DIR}/assets/src/server/config.py ./config.py\ + && ${STAGE_ASSETS} -cmake-server . && cd .. \ + && COPYFILE_DISABLE=1 tar -zcf ${RPI_SERVER_PACKAGE_NAME}.tar.gz \ + ${RPI_SERVER_PACKAGE_NAME} && rm -rf ${RPI_SERVER_PACKAGE_NAME} + @echo SUCCESS! - created ${RPI_SERVER_PACKAGE_NAME} + +# Tell make which of these targets don't represent files. +.PHONY: linux64 linux64-build linux64-clean linux64-package linux32-package \ + linux32 linux32-build linux32-clean linux64-server-build \ + linux64-server-clean linux64-server-package linux32-server-build \ + linux32-server-clean linux32-server-package raspberry-pi-server-package + + +################################################################################ +# # +# Admin # +# # +################################################################################ + +# Build and push just the linux64 server. +# For use with our server-manager setup. +# This expects the package to have been built already. +push-server-package: + test ballisticacore = bombsquad # Only allow this from bombsquad spinoff atm. + cd ${DIST_DIR} && tar -xf \ + ${LINUX_SERVER_PACKAGE_BASE_NAME}_64bit_${VERSION}.tar.gz \ + && mv ${LINUX_SERVER_PACKAGE_BASE_NAME}_64bit_${VERSION} ${BUILD_NUMBER} \ + && tar -zcf ${BUILD_NUMBER}.tar.gz ${BUILD_NUMBER} \ + && rm -rf ${BUILD_NUMBER} + scp ${SSH_BATCH_ARGS} ${DIST_DIR}/${BUILD_NUMBER}.tar.gz \ + ${STAGING_SERVER}:files.ballistica.net/test/bsb + rm ${DIST_DIR}/${BUILD_NUMBER}.tar.gz + rm ${DIST_DIR}/${LINUX_SERVER_PACKAGE_BASE_NAME}_64bit_${VERSION}.tar.gz + @echo LINUX64 SERVER ${BUILD_NUMBER} BUILT AND PUSHED! + +# Push and clear our test-build packages. +# This expects them all to have been built already. +push-all-test-packages: + test ballisticacore = bombsquad # Only allow this from bombsquad spinoff atm. + scp ${SSH_BATCH_ARGS} ${ALL_TEST_PACKAGE_FILES} CHANGELOG.md \ + ${STAGING_SERVER}:${STAGING_SERVER_BUILDS_DIR} + rm ${ALL_TEST_PACKAGE_FILES} + ${ARCHIVE_OLD_PUBLIC_BUILDS} + @echo ALL TEST PACKAGES BUILT AND PUSHED! + +# Push and clear our server packages. +# This expects them all to have been built already. +push-all-server-packages: + test ballisticacore = bombsquad # Only allow this from bombsquad spinoff atm. + scp ${SSH_BATCH_ARGS} ${ALL_SERVER_PACKAGE_FILES} CHANGELOG.md \ + ${STAGING_SERVER}:${STAGING_SERVER_BUILDS_DIR} + rm ${ALL_SERVER_PACKAGE_FILES} + ${ARCHIVE_OLD_PUBLIC_BUILDS} + @echo ALL SERVERS PUSHED! + +# Tell make which of these targets don't represent files. +.PHONY: push-server-package push-all-test-packages push-all-server-packages + + +################################################################################ +# # +# Python # +# # +################################################################################ + +# Targets for wrangling special embedded builds of python for platform such +# as iOS and Android. + +# Build all Pythons and gather them into src. +python-all: + make -j${JOBS} python-apple python-android + tools/snippets python_gather + +python-apple: python-mac python-mac-debug \ + python-ios python-ios-debug \ + python-tvos python-tvos-debug + +python-android: python-android-arm python-android-arm-debug \ + python-android-arm64 python-android-arm64-debug \ + python-android-x86 python-android-x86-debug \ + python-android-x86-64 python-android-x86-64-debug + +python-mac: + tools/snippets python_build_apple mac + +python-mac-debug: + tools/snippets python_build_apple_debug mac + +python-ios: + tools/snippets python_build_apple ios + +python-ios-debug: + tools/snippets python_build_apple_debug ios + +python-tvos: + tools/snippets python_build_apple tvos + +python-tvos-debug: + tools/snippets python_build_apple_debug tvos + +python-android-arm: + tools/snippets python_build_android arm + +python-android-arm-debug: + tools/snippets python_build_android_debug arm + +python-android-arm64: + tools/snippets python_build_android arm64 + +python-android-arm64-debug: + tools/snippets python_build_android_debug arm64 + +python-android-x86: + tools/snippets python_build_android x86 + +python-android-x86-debug: + tools/snippets python_build_android_debug x86 + +python-android-x86-64: + tools/snippets python_build_android x86_64 + +python-android-x86-64-debug: + tools/snippets python_build_android_debug x86_64 + +# Tell make which of these targets don't represent files. +.PHONY: python-all python-apple python-android python-mac python-mac-debug \ + python-ios python-ios-debug python-tvos python-tvos-debug \ + python-android-arm python-android-arm-debug python-android-arm64 \ + python-android-x86 python-android-x86-debug python-android-x86-64 \ + python-android-x86-64-debug + + +################################################################################ +# # +# Auxiliary # +# # +################################################################################ + +ROOT_DIR = ${abspath ${CURDIR}} +VERSION = $(shell tools/version_utils version) +BUILD_NUMBER = $(shell tools/version_utils build) +DIST_DIR = ${ROOT_DIR}/build + +# Things to ignore when doing root level cleans. +ROOT_CLEAN_IGNORES = --exclude=assets/src_master \ + --exclude=config/localconfig.json \ + --exclude=.spinoffdata + +CHECK_CLEAN_SAFETY = ${ROOT_DIR}/tools/snippets check_clean_safety +ASSET_CACHE_DIR = ${shell ${ROOT_DIR}/tools/convert_util --get-asset-cache-dir} +ASSET_CACHE_NAME = ${shell ${ROOT_DIR}/tools/convert_util \ + --get-asset-cache-name} +RPI_SERVER_PACKAGE_NAME = BallisticaCore_Server_RaspberryPi_${VERSION} + +MAC_PACKAGE_NAME = BallisticaCore_Mac_${VERSION} +MAC_SERVER_PACKAGE_NAME = BallisticaCore_Server_Mac_${VERSION} +MAC_DIST_BUILD_DIR = ${shell ${ROOT_DIR}/tools/xc_build_path \ + ${ROOT_DIR}/ballisticacore-mac.xcodeproj Release} +MAC_DEBUG_BUILD_DIR = ${shell ${ROOT_DIR}/tools/xc_build_path \ + ${ROOT_DIR}/ballisticacore-mac.xcodeproj Debug} + +STAGE_ASSETS = ${ROOT_DIR}/tools/stage_assets + +# Eww; no way to do multi-line constants in make without spaces :-( +_WMSBE_1 = \"C:\\Program Files \(x86\)\\Microsoft Visual Studio\\2019 +_WMSBE_2 = \\Community\\MSBuild\\Current\\Bin\\MSBuild.exe\" + +WIN_INTERNAL_HOME = $(shell tools/vmshell win7 GET_INTERNAL_HOME) +WIN_MSBUILD_EXE = ${_WMSBE_1}${_WMSBE_2} +VISUAL_STUDIO_VERSION = /p:VisualStudioVersion=16 +WIN_SERVER_PACKAGE_NAME = BallisticaCore_Server_Windows_${VERSION} +WIN_PACKAGE_NAME = BallisticaCore_Windows_${VERSION} +WIN_OCULUS_PACKAGE_NAME = BallisticaCore_Windows_Oculus +WINPRJ = $(WINDOWS_PROJECT) +WINPLT = $(WINDOWS_PLATFORM) +WINCFG = $(WINDOWS_CONFIGURATION) + +# Assemble a package for a standard desktop build +_windows-package: windows-rebuild + @mkdir -p ${DIST_DIR} && cd ${DIST_DIR} && \ + rm -rf ${WIN_PACKAGE_NAME}.zip ${WIN_PACKAGE_NAME} \ + && mkdir ${WIN_PACKAGE_NAME} + @cd ${ROOT_DIR}/ballisticacore-windows/Build/$(WINCFG)_$(WINPLT) \ + && cp -r DLLs Lib data BallisticaCore$(WINPRJ).exe \ + VC_redist.*.exe *.dll ${DIST_DIR}/${WIN_PACKAGE_NAME} + @cd ${DIST_DIR} && zip -rq ${WIN_PACKAGE_NAME}.zip \ + ${WIN_PACKAGE_NAME} && rm -rf ${WIN_PACKAGE_NAME} + @echo SUCCESS! - created ${WIN_PACKAGE_NAME}.zip + +# Assemble a package containing server components. +_windows-server-package: windows-rebuild + @mkdir -p ${DIST_DIR} && cd ${DIST_DIR} && \ + rm -rf ${WIN_SERVER_PACKAGE_NAME}.zip ${WIN_SERVER_PACKAGE_NAME} \ + && mkdir ${WIN_SERVER_PACKAGE_NAME} + @cd ${ROOT_DIR}/ballisticacore-windows/Build/$(WINCFG)_$(WINPLT) \ + && mv BallisticaCore$(WINPRJ).exe bs_headless.exe \ + && cp -r DLLs Lib data bs_headless.exe \ + python.exe VC_redist.*.exe python37.dll \ + ${DIST_DIR}/${WIN_SERVER_PACKAGE_NAME}/ + @cd ${DIST_DIR}/${WIN_SERVER_PACKAGE_NAME} \ + && cp ${ROOT_DIR}/assets/src/server/server.py \ + ./ballisticacore_server.py \ + && cp ${ROOT_DIR}/assets/src/server/server.bat \ + ./launch_ballisticacore_server.bat \ + && cp ${ROOT_DIR}/assets/src/server/README.txt ./README.txt \ + && cp ${ROOT_DIR}/CHANGELOG.md ./CHANGELOG.txt \ + && cp ${ROOT_DIR}/assets/src/server/config.py ./config.py + @cd ${DIST_DIR}/${WIN_SERVER_PACKAGE_NAME} && unix2dos CHANGELOG.txt \ + README.txt config.py + @cd ${DIST_DIR} && zip -rq ${WIN_SERVER_PACKAGE_NAME}.zip \ + ${WIN_SERVER_PACKAGE_NAME} && rm -rf ${WIN_SERVER_PACKAGE_NAME} + @echo SUCCESS! - created ${WIN_SERVER_PACKAGE_NAME}.zip + +# Assemble a package for uploading to the oculus store. +_windows-oculus-package: windows-rebuild + @mkdir -p ${DIST_DIR} && cd ${DIST_DIR} && \ + rm -rf ${WIN_OCULUS_PACKAGE_NAME}.zip ${WIN_OCULUS_PACKAGE_NAME} \ + && mkdir ${WIN_OCULUS_PACKAGE_NAME} + @cd ${ROOT_DIR}/ballisticacore-windows/Build/$(WINCFG)_$(WINPLT)\ + && cp -r DLLs Lib data BallisticaCore$(WINPRJ).exe *.dll \ + ${DIST_DIR}/${WIN_OCULUS_PACKAGE_NAME} + @cd ${DIST_DIR} && zip -rq ${WIN_OCULUS_PACKAGE_NAME}.zip \ + ${WIN_OCULUS_PACKAGE_NAME} && rm -rf ${WIN_OCULUS_PACKAGE_NAME} + @echo SUCCESS! - created ${WIN_OCULUS_PACKAGE_NAME}.zip + +ANDROID_ADB = ${shell tools/android_sdk_utils get-adb-path} +ANDROID_FILTERED_LOGCAT = ${ANDROID_ADB} logcat -v color SDL:V \ + BallisticaCore:V VrLib:V VrApi:V VrApp:V TimeWarp:V EyeBuf:V GlUtils:V \ + DirectRender:V HmdInfo:V IabHelper:V CrashAnrDetector:V DEBUG:V *:S +AN_STRT = am start -a android.intent.action.MAIN -n +AN_BLD_OUT_DIR = ballisticacore-android/BallisticaCore/build/outputs +AN_BLDTP = $(ANDROID_BUILDTYPE) +AN_BLDTP_C = $(shell $(ROOT_DIR)/tools/snippets capitalize $(ANDROID_BUILDTYPE)) +AN_PLAT = $(ANDROID_PLATFORM) +AN_PLAT_C = $(shell $(ROOT_DIR)/tools/snippets capitalize $(ANDROID_PLATFORM)) +AN_MODE = $(ANDROID_MODE) +AN_MODE_C = $(shell $(ROOT_DIR)/tools/snippets capitalize $(ANDROID_MODE)) +AN_ASSEMBLE_CMD = ./gradlew assemble$(AN_PLAT_C)$(AN_MODE_C)$(AN_BLDTP_C) +AN_PLATFORM_DIR = ballisticacore-android/BallisticaCore/src/${ANDROID_PLATFORM} +# (public facing name; doesn't reflect build settings) +ANDROID_PACKAGE_NAME = BallisticaCore_Android_Generic_$(VERSION) + +AN_APK_DIR = ${AN_BLD_OUT_DIR}/apk/$(AN_PLAT)$(AN_MODE_C)/$(AN_BLDTP) +AN_APK = $(AN_APK_DIR)/BallisticaCore-$(AN_PLAT)-$(AN_MODE)-$(AN_BLDTP).apk +AN_BNDL_DIR = $(AN_BLD_OUT_DIR)/bundle/$(AN_PLAT)$(AN_MODE_C)$(AN_BLDTP_C) +AN_BNDL = $(AN_BNDL_DIR)/BallisticaCore-$(AN_PLAT)-$(AN_MODE)-$(AN_BLDTP).aab + +BA_ARCHIVE_ROOT ?= $(HOME)/build_archives +AN_ARCHIVE_ROOT = $(BA_ARCHIVE_ROOT)/ballisticacore/android +AN_ARCHIVE_DIR = $(AN_ARCHIVE_ROOT)/$(AN_PLAT)/$(VERSION)_$(BUILD_NUMBER) + +ARCH ?= $(shell uname -m) +ifeq ($(ARCH),x86_64) + ARCH_NAME?=64bit + ARCH_NAME_SHORT?=linux64 + ARCH_NAME_SERVER_SHORT?=linux64_server +else + ARCH_NAME?=32bit + ARCH_NAME_SHORT?=linux32 + ARCH_NAME_SERVER_SHORT?=linux32_server +endif + +# For our ssh/scp/rsh stuff we want to rely on public key auth +# and just fail if anything is off instead of prompting. +SSH_BATCH_ARGS = -oBatchMode=yes -oStrictHostKeyChecking=yes + +# Target run from within linux vm to build and run. +_linux: _linux-build + cd build/${ARCH_NAME_SHORT}/ballisticacore && \ + DISPLAY=:0 ./ballisticacore + +# Build only. +_linux-build: + @mkdir -p build/${ARCH_NAME_SHORT} && cd build/${ARCH_NAME_SHORT} \ + && test -f Makefile || cmake -DCMAKE_BUILD_TYPE=Debug \ + ${PWD}/ballisticacore-cmake + cd build/${ARCH_NAME_SHORT} && make -j${JOBS} + +# Target used from within linux vm to build a package. +# NOTE: building in place from linux over hgfs seems slightly flaky +# (for instance, have seen tar complain source changing under it +# when assembling a package).. so when possible we build on the local +# disk and then copy the results back over hgfs. +# We just need to make sure we only launch one build at a time per VM, +# but that should already be the case. +LINUX_BUILD_BASE = ~/ballisticacore_builds +LINUX_PACKAGE_BASE_NAME = BallisticaCore_Linux +LINUX_PACKAGE_NAME = ${LINUX_PACKAGE_BASE_NAME}_${ARCH_NAME}_${VERSION} +LINUX_BUILD_DIR = ${LINUX_BUILD_BASE}/${LINUX_PACKAGE_NAME}_build +LINUX_PACKAGE_DIR = build/${LINUX_PACKAGE_NAME} + +# Build for linux package (to be run under vm). +_linux-package-build: + @rm -rf ${DIST_DIR}/${LINUX_PACKAGE_NAME}.tar.gz ${LINUX_BUILD_DIR} \ + ${LINUX_PACKAGE_DIR} ${LINUX_PACKAGE_DIR}.tar.gz + @mkdir -p ${LINUX_BUILD_DIR} ${LINUX_PACKAGE_DIR} ${DIST_DIR} + @cd ${LINUX_BUILD_DIR} && cmake -DCMAKE_BUILD_TYPE=Release -DTEST_BUILD=true \ + ${ROOT_DIR}/ballisticacore-cmake && make -j${JOBS} + @cd ${LINUX_PACKAGE_DIR} && \ + cp ${LINUX_BUILD_DIR}/ballisticacore . + @rm -rf ${LINUX_BUILD_DIR} + +# Complete linux package (to be run on mac). +_linux-package-assemble: + @cd ${LINUX_PACKAGE_DIR} && ${STAGE_ASSETS} -cmake . + @cd build && tar -zcf ${LINUX_PACKAGE_NAME}.tar.gz ${LINUX_PACKAGE_NAME} + @rm -rf ${LINUX_PACKAGE_DIR} + @echo SUCCESS! - created ${LINUX_PACKAGE_NAME}.tar.gz + +# Build only. +_linux-server-build: + @mkdir -p build/${ARCH_NAME_SERVER_SHORT} \ + && cd build/${ARCH_NAME_SERVER_SHORT} \ + && test -f Makefile || cmake -DCMAKE_BUILD_TYPE=Debug -DHEADLESS=true \ + ${PWD}/ballisticacore-cmake + cd build/${ARCH_NAME_SERVER_SHORT} && make -j${JOBS} + +# Used from within linux vm to build a server package. +LINUX_SERVER_PACKAGE_BASE_NAME = BallisticaCore_Server_Linux +LINUX_SERVER_PACKAGE_NAME = \ + ${LINUX_SERVER_PACKAGE_BASE_NAME}_${ARCH_NAME}_${VERSION} +LINUX_SERVER_BUILD_DIR = ${LINUX_BUILD_BASE}/${LINUX_SERVER_PACKAGE_NAME}_build +LINUX_SERVER_PACKAGE_DIR = build/${LINUX_SERVER_PACKAGE_NAME} + +_linux-server-package-build: + @rm -rf ${DIST_DIR}/${LINUX_SERVER_PACKAGE_NAME}.tar.gz \ + ${LINUX_SERVER_BUILD_DIR} ${LINUX_SERVER_PACKAGE_DIR} \ + ${LINUX_SERVER_PACKAGE_DIR}.tar.gz + @mkdir -p ${LINUX_SERVER_BUILD_DIR} ${LINUX_SERVER_PACKAGE_DIR} ${DIST_DIR} + @cd ${LINUX_SERVER_BUILD_DIR} && cmake -DCMAKE_BUILD_TYPE=Release \ + -DHEADLESS=true ${ROOT_DIR}/ballisticacore-cmake && make -j${JOBS} + @cd ${LINUX_SERVER_PACKAGE_DIR} \ + && cp ${LINUX_SERVER_BUILD_DIR}/ballisticacore \ + ./bs_headless + @rm -rf ${LINUX_SERVER_BUILD_DIR} + +_linux-server-package-assemble: + @cd ${LINUX_SERVER_PACKAGE_DIR} \ + && cp ${ROOT_DIR}/assets/src/server/server.py \ + ./ballisticacore_server \ + && cp ${ROOT_DIR}/assets/src/server/README.txt ./README.txt \ + && cp ${ROOT_DIR}/assets/src/server/config.py ./config.py \ + && cp ${ROOT_DIR}/CHANGELOG.md ./CHANGELOG.txt \ + && ${STAGE_ASSETS} -cmake-server . + @cd ${LINUX_SERVER_PACKAGE_DIR}/.. && tar -zcf \ + ${LINUX_SERVER_PACKAGE_NAME}.tar.gz ${LINUX_SERVER_PACKAGE_NAME} + @rm -rf ${LINUX_SERVER_PACKAGE_DIR} + @echo SUCCESS! - created ${LINUX_SERVER_PACKAGE_NAME}.tar.gz + +# This target attempts to verify that we have a valid android sdk setup going +# and creates our local.properties file if need be so gradle builds will go +# through. +_android-sdk: + @tools/android_sdk_utils check + +# FIXME: needs updating to find unstripped libs for new cmake setup +_android-archive: android-build + make android-fullclean + tools/spinoff update + rm -rf $(AN_ARCHIVE_DIR) + mkdir -p $(AN_ARCHIVE_DIR) + mkdir -p $(AN_ARCHIVE_DIR)/unstripped_libs + make android-build + cp $(AN_APK) $(AN_ARCHIVE_DIR) + git log -n 5 > $(AN_ARCHIVE_DIR)/gitlog.txt + test -e submodules/ballistica && cd submodules/ballistica \ + && git log -n 5 > $(AN_ARCHIVE_DIR)/gitlogcore.txt || true + cp ballisticacore-android/BallisticaCore/build/outputs/\ +mapping/$(AN_PLAT)$(AN_MODE_C)/$(AN_BLDTP)/mapping.txt $(AN_ARCHIVE_DIR) + open $(AN_ARCHIVE_DIR) + +# FIXME: needs updating to find unstripped libs for new cmake setup +_android-bundle-archive: + make android-fullclean + tools/spinoff update + rm -rf $(AN_ARCHIVE_DIR) + mkdir -p $(AN_ARCHIVE_DIR) + mkdir -p $(AN_ARCHIVE_DIR)/unstripped_libs + make android-staging + cd ballisticacore-android\ + && ./gradlew bundle$(AN_PLAT_C)$(AN_MODE_C)$(AN_BLDTP_C) + cp $(AN_BNDL) $(AN_ARCHIVE_DIR) + git log -n 5 > $(AN_ARCHIVE_DIR)/gitlog.txt + test -e submodules/ballistica && cd submodules/ballistica \ + && git log -n 5 > $(AN_ARCHIVE_DIR)/gitlogcore.txt || true + cp ballisticacore-android/BallisticaCore/build/outputs/\ +mapping/$(AN_PLAT)$(AN_MODE_C)/$(AN_BLDTP)/mapping.txt $(AN_ARCHIVE_DIR) + open $(AN_ARCHIVE_DIR) + +_android-package: + make android-fullclean + tools/spinoff update + @rm -f ${DIST_DIR}/${ANDROID_PACKAGE_NAME}.apk + make android-build + @mkdir -p ${DIST_DIR} + @cp ballisticacore-android/BallisticaCore/build/outputs/\ +apk/$(AN_PLAT)$(AN_MODE_C)/$(AN_BLDTP)/\ +BallisticaCore-$(AN_PLAT)-$(AN_MODE)-$(AN_BLDTP).apk \ +${DIST_DIR}/${ANDROID_PACKAGE_NAME}.apk + @echo SUCCESS! - created ${ANDROID_PACKAGE_NAME}.apk + +# Efro specific: runs spinoff upgrade on a few local projects. +# (ideally should pull this out of here or abstract it ala syncall). +spinoff-upgrade-push-all: + @echo UPGRADING SPINOFF TEMPLATE + @cd ~/Documents/spinoff-template && make spinoff-upgrade-push + @echo UPGRADING BOMBSQUAD + @cd ~/Documents/bombsquad && make spinoff-upgrade-push + +# Generate and push our changelog to the staging server. +pushchangelog: + @echo GENERATING CHANGELOG HTML... + @mkdir -p ${DIST_DIR} + @./tools/gen_changelog + @echo UPLOADING CHANGELOG... + @scp ${SSH_BATCH_ARGS} ${DIST_DIR}/changelog.html \ + ${BLOG_SERVER}:blog_code/${DOCPREFIX}changelog.html\ + +# Generate docs. +docs: + @echo GENERATING DOCS HTML... + @mkdir -p ${DIST_DIR} + @./tools/gendocs.py + +# Generate and push docs to the staging server. +pushdocs: docs + @echo UPLOADING DOCS... + @scp ${SSH_BATCH_ARGS} ${DIST_DIR}/docs.html \ + ${BLOG_SERVER}:blog_code/${DOCPREFIX}docs.html + +# Some tool configs that need filtering (mainly injecting projroot path). +TOOL_CFG_INST = tools/snippets tool_config_install + +# Anything that affects tool-config generation. +TOOL_CFG_SRC = tools/efrotools/snippets.py config/config.json + +.clang-format: config/toolconfigsrc/clang-format ${TOOL_CFG_SRC} + ${TOOL_CFG_INST} $< $@ + +# When using CLion, our cmake dir is root. Expose .clang-format there too. +ballisticacore-cmake/.clang-format: .clang-format + cd ballisticacore-cmake && ln -sf ../.clang-format . + +.style.yapf: config/toolconfigsrc/style.yapf ${TOOL_CFG_SRC} + ${TOOL_CFG_INST} $< $@ + +.pylintrc: config/toolconfigsrc/pylintrc ${TOOL_CFG_SRC} + ${TOOL_CFG_INST} $< $@ + +.dir-locals.el: config/toolconfigsrc/dir-locals.el ${TOOL_CFG_SRC} + ${TOOL_CFG_INST} $< $@ + +.mypy.ini: config/toolconfigsrc/mypy.ini ${TOOL_CFG_SRC} + ${TOOL_CFG_INST} $< $@ + +.pycheckers: config/toolconfigsrc/pycheckers ${TOOL_CFG_SRC} + ${TOOL_CFG_INST} $< $@ + +# Irony in emacs requires us to use cmake to generate a full +# list of compile commands for all files; lets keep it up to date +# whenever CMakeLists changes. +.irony/compile_commands.json: ballisticacore-cmake/CMakeLists.txt + @echo Generating Irony compile-commands-list... + @mkdir -p .irony + @cd .irony \ + && cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_BUILD_TYPE=Debug \ + ../ballisticacore-cmake + @mv .irony/compile_commands.json . && rm -rf .irony && mkdir .irony \ + && mkdir .irony/ballisticacore .irony/make_bob .irony/ode \ + && mv compile_commands.json .irony + @echo Created $@ + +# Clear and build our assets to update cache timestamps. +# files, and then prune non-recent cache files which should pretty much limit +# it to the current set which we then package and upload as a 'starter-pack' +# for new builds to use (takes full asset builds from an hour down to a +# minute or so). +asset_cache_refresh: + @echo REFRESHING ASSET CACHE: ${ASSET_CACHE_NAME} + @echo REBUILDING ASSETS... + @make assets-clean && make assets + @test -d "${ASSET_CACHE_DIR}" && echo ARCHIVING ASSET CACHE FROM \ + ${ASSET_CACHE_DIR}... + @${ROOT_DIR}/tools/convert_util --prune-to-recent-assets + @mkdir -p ${DIST_DIR} + @cd ${ASSET_CACHE_DIR} && tar -zcf \ + ${DIST_DIR}/${ASSET_CACHE_NAME}.tar.gz * + @echo UPLOADING ASSET CACHE... + @scp ${SSH_BATCH_ARGS} ${DIST_DIR}/${ASSET_CACHE_NAME}.tar.gz \ + ${STAGING_SERVER}:files.ballistica.net/misc/\ +${ASSET_CACHE_NAME}.tar.gz.new + @ssh ${SSH_BATCH_ARGS} ${STAGING_SERVER} mv \ + files.ballistica.net/misc/${ASSET_CACHE_NAME}.tar.gz.new \ + files.ballistica.net/misc/${ASSET_CACHE_NAME}.tar.gz + @echo SUCCESSFULLY UPDATED ASSET CACHE: ${ASSET_CACHE_NAME} + +STAGING_SERVER ?= ubuntu@ballistica.net +STAGING_SERVER_BUILDS_DIR = files.ballistica.net/ballisticacore/builds +BLOG_SERVER ?= ecfroeml@froemling.net + +# Ensure we can sign in to staging server. +# Handy to do before starting lengthy operations instead of failing +# after wasting a bunch of time. +verify_staging_server_auth: + @echo -n Verifying staging server auth... + @ssh ${SSH_BATCH_ARGS} ${STAGING_SERVER} true + @echo ok. + +ARCHIVE_OLD_PUBLIC_BUILDS = \ + ${ROOT_DIR}/tools/snippets archive_old_builds ${STAGING_SERVER} \ + ${STAGING_SERVER_BUILDS_DIR} ${SSH_BATCH_ARGS} + +ALL_TEST_PACKAGE_FILES = \ + ${DIST_DIR}/${LINUX_PACKAGE_BASE_NAME}_64bit_${VERSION}.tar.gz \ + ${DIST_DIR}/${WIN_PACKAGE_NAME}.zip \ + ${DIST_DIR}/${MAC_PACKAGE_NAME}.zip \ + ${DIST_DIR}/${ANDROID_PACKAGE_NAME}.apk + +# Note: currently not including rpi until we figure out the py3.7 situation. +ALL_SERVER_PACKAGE_FILES = \ + ${DIST_DIR}/${LINUX_SERVER_PACKAGE_BASE_NAME}_64bit_${VERSION}.tar.gz \ + ${DIST_DIR}/${WIN_SERVER_PACKAGE_NAME}.zip \ + ${DIST_DIR}/${MAC_SERVER_PACKAGE_NAME}.zip \ + ${DIST_DIR}/${RPI_SERVER_PACKAGE_NAME}.tar.gz + +# Tell make which of these targets don't represent files. +.PHONY: _windows-package _windows-server-package _windows-oculus-package \ + _linux _linux-build _linux-package-build _linux-package-assemble \ + _android-sdk _android-archive _android-bundle-archive _android-package \ + _linux-server-build _linux-server-package-build \ + _linux-server-package-assemble spinoff-upgrade-push-all pushchangelog \ + docs pushdocs asset_cache_refresh verify_staging_server_auth + diff --git a/assets/src/data/scripts/_ba.py b/assets/src/data/scripts/_ba.py new file mode 100644 index 00000000..376bf80f --- /dev/null +++ b/assets/src/data/scripts/_ba.py @@ -0,0 +1,3838 @@ +"""A dummy stub module for the real _bs. + +The real _bs is a compiled extension module and only available +in the live game. This dummy module allows Pylint/Mypy/etc. to +function reasonably well outside of the game. + +NOTE: This file was autogenerated by gendummymodule; do not edit by hand. +""" + +# (hash we can use to see if this file is out of date) +# SOURCES_HASH=263007419767527489115215049118001842367 + +# I'm sorry Pylint. I know this file saddens you. Be strong. +# pylint: disable=useless-suppression +# pylint: disable=unnecessary-pass +# pylint: disable=unused-argument +# pylint: disable=missing-docstring +# pylint: disable=too-many-locals +# pylint: disable=redefined-builtin +# pylint: disable=too-many-lines +# pylint: disable=redefined-outer-name +# pylint: disable=invalid-name +# pylint: disable=no-self-use +# pylint: disable=no-value-for-parameter + +from __future__ import annotations + +from typing import TYPE_CHECKING, overload, Sequence + +from ba._enums import TimeFormat, TimeType + +if TYPE_CHECKING: + from typing import (Any, Dict, Callable, Tuple, List, Optional, Union, + List, Type) + from ba._app import App + import ba + +app: App + + +def _uninferrable() -> Any: + """Get an "Any" in mypy and "uninferrable" in Pylint.""" + # pylint: disable=undefined-variable + return _not_a_real_variable # type: ignore + + +class ActivityData: + """(internal)""" + + def destroy(self) -> None: + """destroy() -> None + + Destroys the internal data for the activity + """ + return None + + def exists(self) -> bool: + """exists() -> bool + + Returns whether the ActivityData still exists. + Most functionality will fail on a nonexistent instance. + """ + return bool() + + def make_foreground(self) -> None: + """make_foreground() -> None + + Sets this activity as the foreground one in its session. + """ + return None + + def start(self) -> None: + """start() -> None + + Begins the activity running + """ + return None + + +class CollideModel: + """A reference to a collide-model. + + Category: Asset Classes + + Use ba.getcollidemodel() to instantiate one. + """ + pass + + +class Context: + """Context(source: Any) + + A game context state. + + Category: General Utility Classes + + Many operations such as ba.newnode() or ba.gettexture() operate + implicitly on the current context. Each ba.Activity has its own + Context and objects within that activity (nodes, media, etc) can only + interact with other objects from that context. + + In general, as a modder, you should not need to worry about contexts, + since timers and other callbacks will take care of saving and + restoring the context automatically, but there may be rare cases where + you need to deal with them, such as when loading media in for use in + the UI (there is a special 'ui' context for all user-interface-related + functionality) + + When instantiating a ba.Context instance, a single 'source' argument + is passed, which can be one of the following strings/objects: + + 'empty': + Gives an empty context; it can be handy to run code here to ensure + it does no loading of media, creation of nodes, etc. + + 'current': + Sets the context object to the current context. + + 'ui': + Sets to the UI context. UI functions as well as loading of media to + be used in said functions must happen in the UI context. + + a ba.Activity instance: + Gives the context for the provided ba.Activity. + Most all code run during a game happens in an Activity's Context. + + a ba.Session instance: + Gives the context for the provided ba.Session. + Generally a user should not need to run anything here. + + + Usage: + + Contexts are generally used with the python 'with' statement, which + sets the context as current on entry and resets it to the previous + value on exit. + + # example: load a few textures into the UI context + # (for use in widgets, etc) + with ba.Context('ui'): + tex1 = ba.gettexture('foo_tex_1') + tex2 = ba.gettexture('foo_tex_2') + """ + + def __init__(self, source: Any): + pass + + def __enter__(self) -> None: + """Support for "with" statement.""" + pass + + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any: + """Support for "with" statement.""" + pass + + +class ContextCall: + """ContextCall(call: Callable) + + A context-preserving callable. + + Category: General Utility Classes + + A ContextCall wraps a callable object along with a reference + to the current context (see ba.Context); it handles restoring the + context when run and automatically clears itself if the context + it belongs to shuts down. + + Generally you should not need to use this directly; all standard + Ballistica callbacks involved with timers, materials, UI functions, + etc. handle this under-the-hood you don't have to worry about it. + The only time it may be necessary is if you are implementing your + own callbacks, such as a worker thread that does some action and then + runs some game code when done. By wrapping said callback in one of + these, you can ensure that you will not inadvertently be keeping the + current activity alive or running code in a torn-down (expired) + context. + + You can also use ba.WeakCall for similar functionality, but + ContextCall has the added bonus that it will not run during context + shutdown, whereas ba.WeakCall simply looks at whether the target + object still exists. + + # example A: code like this can inadvertently prevent our activity + # (self) from ending until the operation completes, since the bound + # method we're passing (self.dosomething) contains a strong-reference + # to self). + start_some_long_action(callback_when_done=self.dosomething) + + # example B: in this case our activity (self) can still die + # properly; the callback will clear itself when the activity starts + # shutting down, becoming a harmless no-op and releasing the reference + # to our activity. + start_long_action(callback_when_done=ba.ContextCall(self.mycallback)) + """ + + def __init__(self, call: Callable): + pass + + +class Data: + """A reference to a data object. + + Category: Asset Classes + + Use ba.getdata() to instantiate one. + """ + + def getvalue(self) -> Any: + """getvalue() -> Any + + Return the data object's value. + + This can consist of anything representable by json (dicts, lists, + numbers, bools, None, etc). + Note that this call will block if the data has not yet been loaded, + so it can be beneficial to plan a short bit of time between when + the data object is requested and when it's value is accessed. + """ + return _uninferrable() + + +class InputDevice: + """An input-device such as a gamepad, touchscreen, or keyboard. + + Category: Gameplay Classes + + Attributes: + + exists: bool + Whether the underlying device for this object is still present. + + allows_configuring: bool + Whether the input-device can be configured. + + player: Optional[ba.Player] + The player associated with this input device. + + client_id: int + The numeric client-id this device is associated with. + This is only meaningful for remote client inputs; for + all local devices this will be -1. + + name: str + The name of the device. + + unique_identifier: str + A string that can be used to persistently identify the device, + even among other devices of the same type. Used for saving + prefs, etc. + + id: int + The unique numeric id of this device. + + instance_number: int + The number of this device among devices of the same type. + + is_controller_app: bool + Whether this input-device represents a locally-connected + controller-app. + + is_remote_client: bool + Whether this input-device represents a remotely-connected + client. + + """ + exists: bool + allows_configuring: bool + player: Optional[ba.Player] + client_id: int + name: str + unique_identifier: str + id: int + instance_number: int + is_controller_app: bool + is_remote_client: bool + + def get_account_name(self, full: bool) -> str: + """get_account_name(full: bool) -> str + + Returns the account name associated with this device. + + (can be used to get account names for remote players) + """ + return str() + + def get_axis_name(self, axis_id: int) -> str: + """get_axis_name(axis_id: int) -> str + + Given an axis ID, returns the name of the axis on this device. + """ + return str() + + def get_button_name(self, button_id: int) -> str: + """get_button_name(button_id: int) -> str + + Given a button ID, returns the name of the key/button on this device. + """ + return str() + + def get_default_player_name(self) -> str: + """get_default_player_name() -> str + + (internal) + + Returns the default player name for this device. (used for the 'random' + profile) + """ + return str() + + def get_player_profiles(self) -> dict: + """get_player_profiles() -> dict + + (internal) + """ + return dict() + + def is_connected_to_remote_player(self) -> bool: + """is_connected_to_remote_player() -> bool + + (internal) + """ + return bool() + + def remove_remote_player_from_game(self) -> None: + """remove_remote_player_from_game() -> None + + (internal) + """ + return None + + +class Material: + """Material(label: str = None) + + An entity applied to game objects to modify collision behavior. + + Category: Gameplay Classes + + A material can affect physical characteristics, generate sounds, + or trigger callback functions when collisions occur. + + Materials are applied to 'parts', which are groups of one or more + rigid bodies created as part of a ba.Node. Nodes can have any number + of parts, each with its own set of materials. Generally materials are + specified as array attributes on the Node. The 'spaz' node, for + example, has various attributes such as 'materials', + 'roller_materials', and 'punch_materials', which correspond to the + various parts it creates. + + Use ba.Material() to instantiate a blank material, and then use its + add_actions() method to define what the material does. + + Attributes: + + label: str + A label for the material; only used for debugging. + """ + + def __init__(self, label: str = None): + pass + + label: str + + def add_actions(self, actions: Tuple, + conditions: Optional[Tuple] = None) -> None: + """add_actions(actions: Tuple, conditions: Optional[Tuple] = None) + -> None + + Add one or more actions to the material, optionally with conditions. + + Conditions: + + Conditions are provided as tuples which can be combined to form boolean + logic. A single condition might look like ('condition_name', cond_arg), + or a more complex nested one might look like (('some_condition', + cond_arg), 'or', ('another_condition', cond2_arg)). + + 'and', 'or', and 'xor' are available to chain together 2 conditions, as + seen above. + + Available Conditions: + + ('they_have_material', material) - does the part we're hitting have a + given ba.Material? + + ('they_dont_have_material', material) - does the part we're hitting + not have a given ba.Material? + + ('eval_colliding') - is 'collide' true at this point in material + evaluation? (see the modify_part_collision action) + + ('eval_not_colliding') - is 'collide' false at this point in material + evaluation? (see the modify_part_collision action) + + ('we_are_younger_than', age) - is our part younger than 'age' + (in milliseconds)? + + ('we_are_older_than', age) - is our part older than 'age' + (in milliseconds)? + + ('they_are_younger_than', age) - is the part we're hitting younger than + 'age' (in milliseconds)? + + ('they_are_older_than', age) - is the part we're hitting older than + 'age' (in milliseconds)? + + ('they_are_same_node_as_us') - does the part we're hitting belong to + the same ba.Node as us? + + ('they_are_different_node_than_us') - does the part we're hitting + belong to a different ba.Node than us? + + Actions: + + In a similar manner, actions are specified as tuples. Multiple actions + can be specified by providing a tuple of tuples. + + Available Actions: + + ('call', when, callable) - calls the provided callable; 'when' can be + either 'at_connect' or 'at_disconnect'. 'at_connect' means to fire + when the two parts first come in contact; 'at_disconnect' means to + fire once they cease being in contact. + + ('message', who, when, message_obj) - sends a message object; 'who' can + be either 'our_node' or 'their_node', 'when' can be 'at_connect' or + 'at_disconnect', and message_obj is the message object to send. + This has the same effect as calling the node's handlemessage() + method. + + ('modify_part_collision', attr, value) - changes some characteristic + of the physical collision that will occur between our part and their + part. This change will remain in effect as long as the two parts + remain overlapping. This means if you have a part with a material + that turns 'collide' off against parts younger than 100ms, and it + touches another part that is 50ms old, it will continue to not + collide with that part until they separate, even if the 100ms + threshold is passed. Options for attr/value are: 'physical' (boolean + value; whether a *physical* response will occur at all), 'friction' + (float value; how friction-y the physical response will be), + 'collide' (boolean value; whether *any* collision will occur at all, + including non-physical stuff like callbacks), 'use_node_collide' + (boolean value; whether to honor modify_node_collision overrides for + this collision), 'stiffness' (float value, how springy the physical + response is), 'damping' (float value, how damped the physical + response is), 'bounce' (float value; how bouncy the physical response + is). + + ('modify_node_collision', attr, value) - similar to + modify_part_collision, but operates at a node-level. + collision attributes set here will remain in effect as long as + *anything* from our part's node and their part's node overlap. + A key use of this functionality is to prevent new nodes from + colliding with each other if they appear overlapped; + if modify_part_collision is used, only the individual parts that + were overlapping would avoid contact, but other parts could still + contact leaving the two nodes 'tangled up'. Using + modify_node_collision ensures that the nodes must completely + separate before they can start colliding. Currently the only attr + available here is 'collide' (a boolean value). + + ('sound', sound, volume) - plays a ba.Sound when a collision occurs, at + a given volume, regardless of the collision speed/etc. + + ('impact_sound', sound, targetImpulse, volume) - plays a sound when a + collision occurs, based on the speed of impact. Provide a ba.Sound, a + target-impulse, and a volume. + + ('skid_sound', sound, targetImpulse, volume) - plays a sound during a + collision when parts are 'scraping' against each other. Provide a + ba.Sound, a target-impulse, and a volume. + + ('roll_sound', sound, targetImpulse, volume) - plays a sound during a + collision when parts are 'rolling' against each other. Provide a + ba.Sound, a target-impulse, and a volume. + + # example 1: create a material that lets us ignore + # collisions against any nodes we touch in the first + # 100 ms of our existence; handy for preventing us from + # exploding outward if we spawn on top of another object: + m = ba.Material() + m.add_actions(conditions=(('we_are_younger_than', 100), + 'or',('they_are_younger_than', 100)), + actions=('modify_node_collision', 'collide', False)) + + # example 2: send a DieMessage to anything we touch, but cause + # no physical response. This should cause any ba.Actor to drop dead: + m = ba.Material() + m.add_actions(actions=(('modify_part_collision', 'physical', False), + ('message', 'their_node', 'at_connect', + ba.DieMessage()))) + + # example 3: play some sounds when we're contacting the ground: + m = ba.Material() + m.add_actions(conditions=('they_have_material', + ba.sharedobj('footing_material')), + actions=(('impact_sound', ba.getsound('metalHit'), 2, 5), + ('skid_sound', ba.getsound('metalSkid'), 2, 5))) + + """ + return None + + +class Model: + """A reference to a model. + + Category: Asset Classes + + Models are used for drawing. + Use ba.getmodel() to instantiate one. + """ + pass + + +class Node: + """Reference to a Node; the low level building block of the game. + + Category: Gameplay Classes + + At its core, a game is nothing more than a scene of Nodes + with attributes getting interconnected or set over time. + + A ba.Node instance should be thought of as a weak-reference + to a game node; *not* the node itself. This means a Node's + lifecycle is completely independent of how many Python references + to it exist. To explicitly add a new node to the game, use + ba.newnode(), and to explicitly delete one, use ba.Node.delete(). + ba.Node.exists() can be used to determine if a Node still points to + a live node in the game. + + You can use ba.Node(None) to instantiate an invalid + Node reference (sometimes used as attr values/etc). + """ + + # Adding attrs as needed. + # FIXME: These should be instance attrs. + # NOTE: Am just adding all possible node attrs here. + # It would be nicer to make a distinct class for each + # node type at some point so we get better type checks. + color: Sequence[float] = (0.0, 0.0, 0.0) + size: Sequence[float] = (0.0, 0.0, 0.0) + position: Sequence[float] = (0.0, 0.0, 0.0) + position_forward: Sequence[float] = (0.0, 0.0, 0.0) + punch_position: Sequence[float] = (0.0, 0.0, 0.0) + punch_velocity: Sequence[float] = (0.0, 0.0, 0.0) + velocity: Sequence[float] = (0.0, 0.0, 0.0) + name_color: Sequence[float] = (0.0, 0.0, 0.0) + tint_color: Sequence[float] = (0.0, 0.0, 0.0) + tint2_color: Sequence[float] = (0.0, 0.0, 0.0) + text: Union[ba.Lstr, str] = "" + texture: Optional[ba.Texture] = None + tint_texture: Optional[ba.Texture] = None + times: Sequence[int] = (1, 2, 3, 4, 5) + values: Sequence[float] = (1.0, 2.0, 3.0, 4.0) + offset: float = 0.0 + input0: float = 0.0 + input1: float = 0.0 + input2: float = 0.0 + input3: float = 0.0 + flashing: bool = False + scale: Union[float, Sequence[float]] = 0.0 + opacity: float = 0.0 + loop: bool = False + time1: int = 0 + time2: int = 0 + timemax: int = 0 + client_only: bool = False + materials: Sequence[Material] = () + roller_materials: Sequence[Material] = () + name: str = "" + punch_materials: Sequence[ba.Material] = () + pickup_materials: Sequence[ba.Material] = () + extras_material: Sequence[ba.Material] = () + rotate: float = 0.0 + hold_node: Optional[ba.Node] = None + hold_body: int = 0 + host_only: bool = False + premultiplied: bool = False + source_player: Optional[ba.Player] = None + model_opaque: Optional[ba.Model] = None + model_transparent: Optional[ba.Model] = None + damage_smoothed: float = 0.0 + punch_power: float = 0.0 + punch_momentum_linear: Sequence[float] = (0.0, 0.0, 0.0) + punch_momentum_angular: float = 0.0 + rate: int = 0 + vr_depth: float = 0.0 + is_area_of_interest: bool = False + jump_pressed: bool = False + pickup_pressed: bool = False + punch_pressed: bool = False + bomb_pressed: bool = False + fly_pressed: bool = False + hold_position_pressed: bool = False + knockout: float = 0.0 + invincible: bool = False + damage: int = 0 + run: float = 0.0 + move_up_down: float = 0.0 + move_left_right: float = 0.0 + curse_death_time: int = 0 + boxing_gloves: bool = False + hurt: float = 0.0 + always_show_health_bar: bool = False + mini_billboard_1_texture: Optional[ba.Texture] = None + mini_billboard_1_start_time: int = 0 + mini_billboard_1_end_time: int = 0 + mini_billboard_2_texture: Optional[ba.Texture] = None + mini_billboard_2_start_time: int = 0 + mini_billboard_2_end_time: int = 0 + mini_billboard_3_texture: Optional[ba.Texture] = None + mini_billboard_3_start_time: int = 0 + mini_billboard_3_end_time: int = 0 + boxing_gloves_flashing: bool = False + dead: bool = False + frozen: bool = False + counter_text: str = "" + counter_texture: Optional[ba.Texture] = None + shattered: int = 0 + billboard_texture: Optional[ba.Texture] = None + billboard_cross_out: bool = False + billboard_opacity: float = 0.0 + + def add_death_action(self, action: Callable[[], None]) -> None: + """add_death_action(action: Callable[[], None]) -> None + + Add a callable object to be called upon this node's death. + Note that these actions are run just after the node dies, not before. + """ + return None + + def connectattr(self, srcattr: str, dstnode: Node, dstattr: str) -> None: + """connectattr(srcattr: str, dstnode: Node, dstattr: str) -> None + + Connect one of this node's attributes to an attribute on another node. + This will immediately set the target attribute's value to that of the + source attribute, and will continue to do so once per step as long as + the two nodes exist. The connection can be severed by setting the + target attribute to any value or connecting another node attribute + to it. + + # example: create a locator and attach a light to it + light = ba.newnode('light') + loc = ba.newnode('locator', attrs={'position': (0,10,0)}) + loc.connectattr('position', light, 'position') + """ + return None + + def delete(self, ignore_missing: bool = True) -> None: + """delete(ignore_missing: bool = True) -> None + + Delete the node. Ignores already-deleted nodes unless ignore_missing + is False, in which case an Exception is thrown. + """ + return None + + def exists(self) -> bool: + """exists() -> bool + + Returns whether the Node still exists. + Most functionality will fail on a nonexistent Node, so it's never a bad + idea to check this. + + Note that you can also use the boolean operator for this same + functionality, so a statement such as "if mynode" will do + the right thing both for Node objects and values of None. + """ + return bool() + + def get_name(self) -> str: + """get_name() -> str + + Return the name assigned to a Node; used mainly for debugging + """ + return str() + + def getdelegate(self) -> Any: + """getdelegate() -> Any + + Returns the node's current delegate, which is the Python object + designated to handle the Node's messages. + """ + return _uninferrable() + + def getnodetype(self) -> str: + """getnodetype() -> str + + Return the type of Node referenced by this object as a string. + (Note this is different from the Python type which is always ba.Node) + """ + return str() + + def handlemessage(self, *args: Any) -> None: + """handlemessage(*args: Any) -> None + + General message handling; can be passed any message object. + + All standard message objects are forwarded along to the ba.Node's + delegate for handling (generally the ba.Actor that made the node). + + ba.Nodes are unique, however, in that they can be passed a second + form of message; 'node-messages'. These consist of a string type-name + as a first argument along with the args specific to that type name + as additional arguments. + Node-messages communicate directly with the low-level node layer + and are delivered simultaneously on all game clients, + acting as an alternative to setting node attributes. + """ + return None + + +class Player: + """A reference to a player in the game. + + Category: Gameplay Classes + + These are created and managed internally and + provided to your Session/Activity instances. + Be aware that, like ba.Nodes, ba.Player objects are 'weak' + references under-the-hood; a player can leave the game at + any point. For this reason, you should make judicious use of the + ba.Player.exists attribute (or boolean operator) to ensure that a + Player is still present if retaining references to one for any + length of time. + + Attributes: + + actor: Optional[ba.Actor] + The current ba.Actor associated with this Player. + This may be None + + node: Optional[ba.Node] + A ba.Node of type 'player' associated with this Player. + This Node exists in the currently active game and can be used + to get a generic player position/etc. + This will be None if the Player is not in a game. + + exists: bool + Whether the player still exists. + Most functionality will fail on a nonexistent player. + + Note that you can also use the boolean operator for this same + functionality, so a statement such as "if player" will do + the right thing both for Player objects and values of None. + + in_game: bool + This bool value will be True once the Player has completed + any lobby character/team selection. + + team: ba.Team + The ba.Team this Player is on. If the Player is + still in its lobby selecting a team/etc. then a + ba.TeamNotFoundError will be raised. + + sessiondata: Dict + A dict for use by the current ba.Session for + storing data associated with this player. + This persists for the duration of the session. + + gamedata: Dict + A dict for use by the current ba.Activity for + storing data associated with this Player. + This gets cleared for each new ba.Activity. + + color: Sequence[float] + The base color for this Player. + In team games this will match the ba.Team's color. + + highlight: Sequence[float] + A secondary color for this player. + This is used for minor highlights and accents + to allow a player to stand apart from his teammates + who may all share the same team (primary) color. + + character: str + The character this player has selected in their profile. + """ + actor: Optional[ba.Actor] + node: Optional[ba.Node] + exists: bool + in_game: bool + team: ba.Team + sessiondata: Dict + gamedata: Dict + color: Sequence[float] + highlight: Sequence[float] + character: str + + def assign_input_call(self, type: Union[str, Tuple[str, ...]], + call: Callable) -> None: + """assign_input_call(type: Union[str, Tuple[str, ...]], + call: Callable) -> None + + Set the python callable to be run for one or more types of input. + Valid type values are: 'jumpPress', 'jumpRelease', 'punchPress', + 'punchRelease','bombPress', 'bombRelease', 'pickUpPress', + 'pickUpRelease', 'upDown','leftRight','upPress', 'upRelease', + 'downPress', 'downRelease', 'leftPress','leftRelease','rightPress', + 'rightRelease', 'run', 'flyPress', 'flyRelease', 'startPress', + 'startRelease' + """ + return None + + def get_account_id(self) -> str: + """get_account_id() -> str + + Return the Account ID this player is signed in under, if + there is one and it can be determined with relative certainty. + Returns None otherwise. Note that this may require an active + internet connection (especially for network-connected players) + and may return None for a short while after a player initially + joins (while verification occurs). + """ + return str() + + def get_icon(self) -> Dict[str, Any]: + """get_icon() -> Dict[str, Any] + + Returns the character's icon (images, colors, etc contained in a dict) + """ + return {"foo": "bar"} + + def get_icon_info(self) -> Dict[str, Any]: + """get_icon_info() -> Dict[str, Any] + + (internal) + """ + return {"foo": "bar"} + + def get_id(self) -> int: + """get_id() -> int + + Returns the unique numeric player ID for this player. + """ + return int() + + def get_input_device(self) -> ba.InputDevice: + """get_input_device() -> ba.InputDevice + + Returns the player's input device. + """ + import ba # pylint: disable=cyclic-import + return ba.InputDevice() + + def get_name(self, full: bool = False, icon: bool = True) -> str: + """get_name(full: bool = False, icon: bool = True) -> str + + Returns the player's name. If icon is True, the long version of the + name may include an icon. + """ + return str() + + def is_alive(self) -> bool: + """is_alive() -> bool + + Returns True if the player has a ba.Actor assigned and its + is_alive() method return True. False is returned otherwise. + """ + return bool() + + def remove_from_game(self) -> None: + """remove_from_game() -> None + + Removes the player from the game. + """ + return None + + def reset(self) -> None: + """reset() -> None + + (internal) + """ + return None + + def reset_input(self) -> None: + """reset_input() -> None + + Clears out the player's assigned input actions. + """ + return None + + def set_activity(self, activity: Optional[ba.Activity]) -> None: + """set_activity(activity: Optional[ba.Activity]) -> None + + (internal) + """ + return None + + def set_actor(self, actor: Optional[ba.Actor]) -> None: + """set_actor(actor: Optional[ba.Actor]) -> None + + Set the player's associated ba.Actor. + """ + return None + + def set_data(self, team: ba.Team, character: str, color: Sequence[float], + highlight: Sequence[float]) -> None: + """set_data(team: ba.Team, character: str, color: Sequence[float], + highlight: Sequence[float]) -> None + + (internal) + """ + return None + + def set_icon_info(self, texture: str, tint_texture: str, + tint_color: Sequence[float], + tint2_color: Sequence[float]) -> None: + """set_icon_info(texture: str, tint_texture: str, + tint_color: Sequence[float], tint2_color: Sequence[float]) -> None + + (internal) + """ + return None + + def set_name(self, name: str, full_name: str = None, + real: bool = True) -> None: + """set_name(name: str, full_name: str = None, real: bool = True) + -> None + + Set the player's name to the provided string. + A number will automatically be appended if the name is not unique from + other players. + """ + return None + + def set_node(self, node: Optional[Node]) -> None: + """set_node(node: Optional[Node]) -> None + + (internal) + """ + return None + + +class SessionData: + """(internal)""" + + def exists(self) -> bool: + """exists() -> bool + + Returns whether the SessionData still exists. + Most functionality will fail on a nonexistent instance. + """ + return bool() + + +class Sound: + """A reference to a sound. + + Category: Asset Classes + + Use ba.getsound() to instantiate one. + """ + pass + + +class Texture: + """A reference to a texture. + + Category: Asset Classes + + Use ba.gettexture() to instantiate one. + """ + pass + + +class Timer: + """Timer(time: float, call: Callable[[], Any], repeat: bool = False, + timetype: ba.TimeType = TimeType.SIM, + timeformat: ba.TimeFormat = TimeFormat.SECONDS, + suppress_format_warning: bool = False) + + Timers are used to run code at later points in time. + + Category: General Utility Classes + + This class encapsulates a timer in the current ba.Context. + The underlying timer will be destroyed when either this object is + no longer referenced or when its Context (Activity, etc.) dies. If you + do not want to worry about keeping a reference to your timer around, + you should use the ba.timer() function instead. + + time: length of time (in seconds by default) that the timer will wait + before firing. Note that the actual delay experienced may vary + depending on the timetype. (see below) + + call: A callable Python object. Note that the timer will retain a + strong reference to the callable for as long as it exists, so you + may want to look into concepts such as ba.WeakCall if that is not + desired. + + repeat: if True, the timer will fire repeatedly, with each successive + firing having the same delay as the first. + + timetype can be either 'sim', 'base', or 'real'. It defaults to + 'sim'. Types are explained below: + + 'sim' time maps to local simulation time in ba.Activity or ba.Session + Contexts. This means that it may progress slower in slow-motion play + modes, stop when the game is paused, etc. This time type is not + available in UI contexts. + + 'base' time is also linked to gameplay in ba.Activity or ba.Session + Contexts, but it progresses at a constant rate regardless of + slow-motion states or pausing. It can, however, slow down or stop + in certain cases such as network outages or game slowdowns due to + cpu load. Like 'sim' time, this is unavailable in UI contexts. + + 'real' time always maps to actual clock time with a bit of filtering + added, regardless of Context. (the filtering prevents it from going + backwards or jumping forward by large amounts due to the app being + backgrounded, system time changing, etc.) + Real time timers are currently only available in the UI context. + + the 'timeformat' arg defaults to SECONDS but can also be MILLISECONDS + if you want to pass time as milliseconds. + + # example: use a Timer object to print repeatedly for a few seconds: + def say_it(): + ba.screenmessage('BADGER!') + def stop_saying_it(): + self.t = None + ba.screenmessage('MUSHROOM MUSHROOM!') + # create our timer; it will run as long as we hold self.t + self.t = ba.Timer(0.3, say_it, repeat=True) + # now fire off a one-shot timer to kill it + ba.timer(3.89, stop_saying_it) + """ + + def __init__(self, + time: float, + call: Callable[[], Any], + repeat: bool = False, + timetype: ba.TimeType = TimeType.SIM, + timeformat: ba.TimeFormat = TimeFormat.SECONDS, + suppress_format_warning: bool = False): + pass + + +class Vec3(Sequence[float]): + """A vector of 3 floats. + + Category: General Utility Classes + + These can be created the following ways (checked in this order): + - with no args, all values are set to 0 + - with a single numeric arg, all values are set to that value + - with a single three-member sequence arg, sequence values are copied + - otherwise assumes individual x/y/z args (positional or keywords) + Attributes: + + x: float + The vector's X component. + + y: float + The vector's Y component. + + z: float + The vector's Z component. + """ + x: float + y: float + z: float + + # pylint: disable=function-redefined + + @overload + def __init__(self) -> None: + pass + + @overload + def __init__(self, value: float): + pass + + @overload + def __init__(self, values: Sequence[float]): + pass + + @overload + def __init__(self, x: float, y: float, z: float): + pass + + def __init__(self, *args: Any, **kwds: Any): + pass + + def __add__(self, other: Vec3) -> Vec3: + return self + + def __sub__(self, other: Vec3) -> Vec3: + return self + + @overload + def __mul__(self, other: float) -> Vec3: + return self + + @overload + def __mul__(self, other: Sequence[float]) -> Vec3: + return self + + def __mul__(self, other: Any) -> Any: + return self + + @overload + def __rmul__(self, other: float) -> Vec3: + return self + + @overload + def __rmul__(self, other: Sequence[float]) -> Vec3: + return self + + def __rmul__(self, other: Any) -> Any: + return self + + # (for index access) + def __getitem__(self, typeargs: Any) -> Any: + return 0.0 + + def __len__(self) -> int: + return 3 + + # (for iterator access) + def __iter__(self) -> Any: + return self + + def __next__(self) -> float: + return 0.0 + + def __neg__(self) -> Vec3: + return self + + def __setitem__(self, index: int, val: float) -> None: + pass + + def cross(self, other: Vec3) -> Vec3: + """cross(other: Vec3) -> Vec3 + + Returns the cross product of this vector and another. + """ + return Vec3() + + def dot(self, other: Vec3) -> float: + """dot(other: Vec3) -> float + + Returns the dot product of this vector and another. + """ + return float() + + def length(self) -> float: + """length() -> float + + Returns the length of the vector. + """ + return float() + + def normalized(self) -> Vec3: + """normalized() -> Vec3 + + Returns a normalized version of the vector. + """ + return Vec3() + + +class Widget: + """Internal type for low level UI elements; buttons, windows, etc. + + Category: User Interface Classes + + This class represents a weak reference to a widget object + in the internal c++ layer. Currently, functions such as + ba.buttonwidget() must be used to instantiate or edit these. + """ + + def activate(self) -> None: + """activate() -> None + + Activates a widget; the same as if it had been clicked. + """ + return None + + def add_delete_callback(self, call: Callable) -> None: + """add_delete_callback(call: Callable) -> None + + Add a call to be run immediately after this widget is destroyed. + """ + return None + + def delete(self, ignore_missing: bool = True) -> None: + """delete(ignore_missing: bool = True) -> None + + Delete the Widget. Ignores already-deleted Widgets if ignore_missing + is True; otherwise an Exception is thrown. + """ + return None + + def exists(self) -> bool: + """exists() -> bool + + Returns whether the Widget still exists. + Most functionality will fail on a nonexistent widget. + + Note that you can also use the boolean operator for this same + functionality, so a statement such as "if mywidget" will do + the right thing both for Widget objects and values of None. + """ + return bool() + + def get_children(self) -> List[ba.Widget]: + """get_children() -> List[ba.Widget] + + Returns any child Widgets of this Widget. + """ + return [Widget()] + + def get_screen_space_center(self) -> Tuple[float, float]: + """get_screen_space_center() -> Tuple[float, float] + + Returns the coords of the Widget center relative to the center of the + screen. This can be useful for placing pop-up windows and other special + cases. + """ + return (0.0, 0.0) + + def get_selected_child(self) -> Optional[ba.Widget]: + """get_selected_child() -> Optional[ba.Widget] + + Returns the selected child Widget or None if nothing is selected. + """ + return Widget() + + def get_widget_type(self) -> str: + """get_widget_type() -> str + + Return the internal type of the Widget as a string. Note that this is + different from the Python ba.Widget type, which is the same for all + widgets. + """ + return str() + + +def _app() -> ba.App: + """_app() -> ba.App + + (internal) + """ + import ba # pylint: disable=cyclic-import + return ba.App() + + +def accept_party_invitation(invite_id: str) -> None: + """accept_party_invitation(invite_id: str) -> None + + (internal) + """ + return None + + +def add_clean_frame_callback(call: Callable) -> None: + """add_clean_frame_callback(call: Callable) -> None + + (internal) + + Provide an object to be called once the next non-progress-bar-frame has + been rendered. Useful for queueing things to load in the background + without elongating any current progress-bar-load. + """ + return None + + +def add_transaction(transaction: dict, callback: Callable = None) -> None: + """add_transaction(transaction: dict, callback: Callable = None) -> None + + (internal) + """ + return None + + +def android_get_external_storage_path() -> str: + """android_get_external_storage_path() -> str + + (internal) + + Returns the android external storage path, or None if there is none on + this device + """ + return str() + + +def android_media_scan_file(file_name: str) -> None: + """android_media_scan_file(file_name: str) -> None + + (internal) + + Refreshes Android MTP Index for a file; use this to get file + modifications to be reflected in Android File Transfer. + """ + return None + + +def android_show_wifi_settings() -> None: + """android_show_wifi_settings() -> None + + (internal) + """ + return None + + +def apply_config() -> None: + """apply_config() -> None + + (internal) + """ + return None + + +def back_press() -> None: + """back_press() -> None + + (internal) + """ + return None + + +def bless() -> None: + """bless() -> None + + (internal) + """ + return None + + +def buttonwidget(edit: ba.Widget = None, + parent: ba.Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + on_activate_call: Callable = None, + label: Union[str, ba.Lstr] = None, + color: Sequence[float] = None, + down_widget: ba.Widget = None, + up_widget: ba.Widget = None, + left_widget: ba.Widget = None, + right_widget: ba.Widget = None, + texture: ba.Texture = None, + text_scale: float = None, + textcolor: Sequence[float] = None, + enable_sound: bool = None, + model_transparent: ba.Model = None, + model_opaque: ba.Model = None, + repeat: bool = None, + scale: float = None, + transition_delay: float = None, + on_select_call: Callable = None, + button_type: str = None, + extra_touch_border_scale: float = None, + selectable: bool = None, + show_buffer_top: float = None, + icon: ba.Texture = None, + iconscale: float = None, + icon_tint: float = None, + icon_color: Sequence[float] = None, + autoselect: bool = None, + mask_texture: ba.Texture = None, + tint_texture: ba.Texture = None, + tint_color: Sequence[float] = None, + tint2_color: Sequence[float] = None, + text_flatness: float = None, + text_res_scale: float = None, + enabled: bool = None) -> ba.Widget: + """buttonwidget(edit: ba.Widget = None, + parent: ba.Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + on_activate_call: Callable = None, + label: Union[str, ba.Lstr] = None, + color: Sequence[float] = None, + down_widget: ba.Widget = None, + up_widget: ba.Widget = None, + left_widget: ba.Widget = None, + right_widget: ba.Widget = None, + texture: ba.Texture = None, + text_scale: float = None, + textcolor: Sequence[float] = None, + enable_sound: bool = None, + model_transparent: ba.Model = None, + model_opaque: ba.Model = None, + repeat: bool = None, + scale: float = None, + transition_delay: float = None, + on_select_call: Callable = None, + button_type: str = None, + extra_touch_border_scale: float = None, + selectable: bool = None, + show_buffer_top: float = None, + icon: ba.Texture = None, + iconscale: float = None, + icon_tint: float = None, + icon_color: Sequence[float] = None, + autoselect: bool = None, + mask_texture: ba.Texture = None, + tint_texture: ba.Texture = None, + tint_color: Sequence[float] = None, + tint2_color: Sequence[float] = None, + text_flatness: float = None, + text_res_scale: float = None, + enabled: bool = None) -> ba.Widget + + Create or edit a button widget. + + Category: User Interface Functions + + Pass a valid existing ba.Widget as 'edit' to modify it; otherwise + a new one is created and returned. Arguments that are not set to None + are applied to the Widget. + """ + import ba # pylint: disable=cyclic-import + return ba.Widget() + + +def camerashake(intensity: float = 1.0) -> None: + """camerashake(intensity: float = 1.0) -> None + + Shake the camera. + + Category: Gameplay Functions + + Note that some cameras and/or platforms (such as VR) may not display + camera-shake, so do not rely on this always being visible to the + player as a gameplay cue. + """ + return None + + +def can_show_ad() -> bool: + """can_show_ad() -> bool + + (internal) + """ + return bool() + + +def capture_gamepad_input(call: Callable[[dict], None]) -> None: + """capture_gamepad_input(call: Callable[[dict], None]) -> None + + (internal) + + Add a callable to be called for subsequent gamepad events. + The method is passed a dict containing info about the event. + """ + return None + + +def capture_keyboard_input(call: Callable[[dict], None]) -> None: + """capture_keyboard_input(call: Callable[[dict], None]) -> None + + (internal) + + Add a callable to be called for subsequent keyboard-game-pad events. + The method is passed a dict containing info about the event. + """ + return None + + +def charstr(char_id: ba.SpecialChar) -> str: + """charstr(char_id: ba.SpecialChar) -> str + + Get a unicode string representing a special character. + + Category: General Utility Functions + + Note that these utilize the private-use block of unicode characters + (U+E000-U+F8FF) and are specific to the game; exporting or rendering + them elsewhere will be meaningless. + + see ba.SpecialChar for the list of available characters. + """ + return str() + + +def chat_message(message: Union[str, ba.Lstr]) -> None: + """chat_message(message: Union[str, ba.Lstr]) -> None + + (internal) + """ + return None + + +def checkboxwidget(edit: ba.Widget = None, + parent: ba.Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + text: Union[ba.Lstr, str] = None, + value: bool = None, + on_value_change_call: Callable[[bool], None] = None, + on_select_call: Callable[[], None] = None, + text_scale: float = None, + textcolor: Sequence[float] = None, + scale: float = None, + is_radio_button: bool = None, + maxwidth: float = None, + autoselect: bool = None, + color: Sequence[float] = None) -> ba.Widget: + """checkboxwidget(edit: ba.Widget = None, + parent: ba.Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + text: Union[ba.Lstr, str] = None, + value: bool = None, + on_value_change_call: Callable[[bool], None] = None, + on_select_call: Callable[[], None] = None, + text_scale: float = None, + textcolor: Sequence[float] = None, + scale: float = None, + is_radio_button: bool = None, + maxwidth: float = None, + autoselect: bool = None, + color: Sequence[float] = None) -> ba.Widget + + Create or edit a check-box widget. + + Category: User Interface Functions + + Pass a valid existing ba.Widget as 'edit' to modify it; otherwise + a new one is created and returned. Arguments that are not set to None + are applied to the Widget. + """ + import ba # pylint: disable=cyclic-import + return ba.Widget() + + +def client_info_query_response(token: str, response: Any) -> None: + """client_info_query_response(token: str, response: Any) -> None + + (internal) + """ + return None + + +def columnwidget(edit: ba.Widget = None, + parent: ba.Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + background: bool = None, + selected_child: ba.Widget = None, + visible_child: ba.Widget = None, + single_depth: bool = None, + print_list_exit_instructions: bool = None, + left_border: float = None, + top_border: float = None, + bottom_border: float = None) -> ba.Widget: + """columnwidget(edit: ba.Widget = None, + parent: ba.Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + background: bool = None, + selected_child: ba.Widget = None, + visible_child: ba.Widget = None, + single_depth: bool = None, + print_list_exit_instructions: bool = None, + left_border: float = None, + top_border: float = None, + bottom_border: float = None) -> ba.Widget + + Create or edit a column widget. + + Category: User Interface Functions + + Pass a valid existing ba.Widget as 'edit' to modify it; otherwise + a new one is created and returned. Arguments that are not set to None + are applied to the Widget. + """ + import ba # pylint: disable=cyclic-import + return ba.Widget() + + +def commit_config(config: str) -> None: + """commit_config(config: str) -> None + + (internal) + """ + return None + + +def connect_to_party(address: str, + port: int = None, + print_progress: bool = True) -> None: + """connect_to_party(address: str, port: int = None, + print_progress: bool = True) -> None + + (internal) + """ + return None + + +def console_print(*args: Any) -> None: + """console_print(*args: Any) -> None + + (internal) + + Print the provided args to the game console (using str()). + For most debugging/info purposes you should just use Python's standard + print, which will show up in the game console as well. + """ + return None + + +def containerwidget(edit: ba.Widget = None, + parent: ba.Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + background: bool = None, + selected_child: ba.Widget = None, + transition: str = None, + cancel_button: ba.Widget = None, + start_button: ba.Widget = None, + root_selectable: bool = None, + on_activate_call: Callable[[], None] = None, + claims_left_right: bool = None, + claims_tab: bool = None, + selection_loops: bool = None, + selection_loop_to_parent: bool = None, + scale: float = None, + on_outside_click_call: Callable[[], None] = None, + single_depth: bool = None, + visible_child: ba.Widget = None, + stack_offset: Sequence[float] = None, + color: Sequence[float] = None, + on_cancel_call: Callable[[], None] = None, + print_list_exit_instructions: bool = None, + click_activate: bool = None, + always_highlight: bool = None, + selectable: bool = None, + scale_origin_stack_offset: Sequence[float] = None, + toolbar_visibility: str = None, + on_select_call: Callable[[], None] = None, + claim_outside_clicks: bool = None, + claims_up_down: bool = None) -> ba.Widget: + """containerwidget(edit: ba.Widget = None, + parent: ba.Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + background: bool = None, + selected_child: ba.Widget = None, + transition: str = None, + cancel_button: ba.Widget = None, + start_button: ba.Widget = None, + root_selectable: bool = None, + on_activate_call: Callable[[], None] = None, + claims_left_right: bool = None, + claims_tab: bool = None, + selection_loops: bool = None, + selection_loop_to_parent: bool = None, + scale: float = None, + on_outside_click_call: Callable[[], None] = None, + single_depth: bool = None, + visible_child: ba.Widget = None, + stack_offset: Sequence[float] = None, + color: Sequence[float] = None, + on_cancel_call: Callable[[], None] = None, + print_list_exit_instructions: bool = None, + click_activate: bool = None, + always_highlight: bool = None, + selectable: bool = None, + scale_origin_stack_offset: Sequence[float] = None, + toolbar_visibility: str = None, + on_select_call: Callable[[], None] = None, + claim_outside_clicks: bool = None, + claims_up_down: bool = None) -> ba.Widget + + Create or edit a container widget. + + Category: User Interface Functions + + Pass a valid existing ba.Widget as 'edit' to modify it; otherwise + a new one is created and returned. Arguments that are not set to None + are applied to the Widget. + """ + import ba # pylint: disable=cyclic-import + return ba.Widget() + + +def debug_print_py_err() -> None: + """debug_print_py_err() -> None + + (internal) + + Debugging func for tracking leaked Python errors in the C++ layer.. + """ + return None + + +def disconnect_client(client_id: int, ban_time: int = 300) -> bool: + """disconnect_client(client_id: int, ban_time: int = 300) -> bool + + (internal) + """ + return bool() + + +def disconnect_from_host() -> None: + """disconnect_from_host() -> None + + (internal) + + Category: General Utility Functions + """ + return None + + +def do_once() -> bool: + """do_once() -> bool + + Register a call at a location and return whether one already happened. + + Category: General Utility Functions + + This is used by 'print_once()' type calls to keep from overflowing + logs. The call functions by registering the filename and line where + The call is made from. Returns True if this location has not been + registered already, and False if it has. + """ + return bool() + + +def ehv() -> None: + """ehv() -> None + + (internal) + """ + return None + + +def emitfx(position: Sequence[float], + velocity: Optional[Sequence[float]] = None, + count: int = 10, + scale: float = 1.0, + spread: float = 1.0, + chunk_type: str = 'rock', + emit_type: str = 'chunks', + tendril_type: str = 'smoke') -> None: + """emitfx(position: Sequence[float], + velocity: Optional[Sequence[float]] = None, + count: int = 10, scale: float = 1.0, spread: float = 1.0, + chunk_type: str = 'rock', emit_type: str ='chunks', + tendril_type: str = 'smoke') -> None + + Emit particles, smoke, etc. into the fx sim layer. + + Category: Gameplay Functions + + The fx sim layer is a secondary dynamics simulation that runs in + the background and just looks pretty; it does not affect gameplay. + Note that the actual amount emitted may vary depending on graphics + settings, exiting element counts, or other factors. + """ + return None + + +def end_host_scanning() -> None: + """end_host_scanning() -> None + + (internal) + + Category: General Utility Functions + """ + return None + + +def env() -> dict: + """env() -> dict + + (internal) + + Returns a dict containing general info about the operating environment + such as version, platform, etc. + This info is now exposed through ba.App; refer to those docs for + info on specific elements. + """ + return dict() + + +def evaluate_lstr(value: str) -> str: + """evaluate_lstr(value: str) -> str + + (internal) + """ + return str() + + +def fade_screen(to: int = 0, + time: float = 0.25, + endcall: Optional[Callable[[], None]] = None) -> None: + """fade_screen(to: int = 0, time: float = 0.25, + endcall: Optional[Callable[[], None]] = None) -> None + + (internal) + + Fade the local game screen in our out from black over a duration of + time. if "to" is 0, the screen will fade out to black. Otherwise it + will fade in from black. If endcall is provided, it will be run after a + completely faded frame is drawn. + """ + return None + + +def focus_window() -> None: + """focus_window() -> None + + (internal) + + A workaround for some unintentional backgrounding that occurs on mac + """ + return None + + +def game_service_has_leaderboard(game: str, config: str) -> bool: + """game_service_has_leaderboard(game: str, config: str) -> bool + + (internal) + + Given a game and config string, returns whether there is a leaderboard + for it on the game service. + """ + return bool() + + +def get_account_display_string(full: bool = True) -> str: + """get_account_display_string(full: bool = True) -> str + + (internal) + """ + return str() + + +def get_account_misc_read_val(name: str, default_value: Any) -> Any: + """get_account_misc_read_val(name: str, default_value: Any) -> Any + + (internal) + """ + return _uninferrable() + + +def get_account_misc_read_val_2(name: str, default_value: Any) -> Any: + """get_account_misc_read_val_2(name: str, default_value: Any) -> Any + + (internal) + """ + return _uninferrable() + + +def get_account_misc_val(name: str, default_value: Any) -> Any: + """get_account_misc_val(name: str, default_value: Any) -> Any + + (internal) + """ + return _uninferrable() + + +def get_account_name() -> str: + """get_account_name() -> str + + (internal) + """ + return str() + + +def get_account_state() -> str: + """get_account_state() -> str + + (internal) + """ + return str() + + +def get_account_state_num() -> int: + """get_account_state_num() -> int + + (internal) + """ + return int() + + +def get_account_ticket_count() -> int: + """get_account_ticket_count() -> int + + (internal) + + Returns the number of tickets for the current account. + """ + return int() + + +def get_account_type() -> str: + """get_account_type() -> str + + (internal) + """ + return str() + + +def get_appconfig_builtin_keys() -> List[str]: + """get_appconfig_builtin_keys() -> List[str] + + (internal) + """ + return ["blah", "blah2"] + + +def get_appconfig_default_value(key: str) -> Any: + """get_appconfig_default_value(key: str) -> Any + + (internal) + """ + return _uninferrable() + + +def get_chat_messages() -> List[str]: + """get_chat_messages() -> List[str] + + (internal) + """ + return ["blah", "blah2"] + + +def get_collision_info(*args: Any) -> Any: + """get_collision_info(*args: Any) -> Any + + Return collision related values + + Category: Gameplay Functions + + Returns a single collision value or tuple of values such as location, + depth, nodes involved, etc. Only call this in the handler of a + collision-triggered callback or message + """ + return _uninferrable() + + +def get_configurable_game_pads() -> list: + """get_configurable_game_pads() -> list + + (internal) + + Returns a list of the currently connected gamepads that can be + configured. + """ + return list() + + +def get_connection_to_host_info() -> dict: + """get_connection_to_host_info() -> dict + + (internal) + """ + return dict() + + +def get_display_resolution() -> Tuple[int, int]: + """get_display_resolution() -> Tuple[int, int] + + (internal) + + Return the currently selected display resolution for fullscreen + display. Returns None if resolutions cannot be directly set. + """ + return (0, 0) + + +def get_foreground_host_activity() -> ba.Activity: + """get_foreground_host_activity() -> ba.Activity + + (internal) + + Returns the ba.Activity currently in the foreground, or None if there + is none. + """ + import ba # pylint: disable=cyclic-import + return ba.Activity({}) + + +def get_foreground_host_session() -> ba.Session: + """get_foreground_host_session() -> ba.Session + + (internal) + + Returns the ba.Session currently being displayed, or None if there is + none. + """ + import ba # pylint: disable=cyclic-import + return ba.Session([]) + + +def get_game_port() -> int: + """get_game_port() -> int + + (internal) + + Return the port ballistica is hosting on. + """ + return int() + + +def get_game_roster() -> List[Dict[str, Any]]: + """get_game_roster() -> List[Dict[str, Any]] + + (internal) + """ + return [{"foo": "bar"}] + + +def get_google_play_party_client_count() -> int: + """get_google_play_party_client_count() -> int + + (internal) + """ + return int() + + +def get_idle_time() -> int: + """get_idle_time() -> int + + (internal) + + Returns the amount of time since any game input has been processed + """ + return int() + + +def get_input_device(name: str, unique_id: str, + doraise: bool = True) -> ba.InputDevice: + """get_input_device(name: str, unique_id: str, doraise: bool = True) + -> ba.InputDevice + + (internal) + + Given a type name and a unique identifier, returns an InputDevice. + Throws an Exception if the input-device is not found, or returns None + if 'doraise' is False. + """ + import ba # pylint: disable=cyclic-import + return ba.InputDevice() + + +def get_local_active_input_devices_count() -> int: + """get_local_active_input_devices_count() -> int + + (internal) + """ + return int() + + +def get_log() -> str: + """get_log() -> str + + (internal) + """ + return str() + + +def get_log_file_path() -> str: + """get_log_file_path() -> str + + (internal) + + Return the path to the app log file. + """ + return str() + + +def get_low_level_config_value(key: str, default_value: int) -> int: + """get_low_level_config_value(key: str, default_value: int) -> int + + (internal) + """ + return int() + + +def get_master_server_address(source: int = -1) -> str: + """get_master_server_address(source: int = -1) -> str + + (internal) + + Return the address of the master server. + """ + return str() + + +def get_max_graphics_quality() -> str: + """get_max_graphics_quality() -> str + + (internal) + + Return the max graphics-quality supported on the current hardware. + """ + return str() + + +def get_news_show() -> str: + """get_news_show() -> str + + (internal) + """ + return str() + + +def get_package_collide_model(package: ba.AssetPackage, + name: str) -> ba.CollideModel: + """get_package_collide_model(package: ba.AssetPackage, name: str) + -> ba.CollideModel + + (internal) + """ + import ba # pylint: disable=cyclic-import + return ba.CollideModel() + + +def get_package_data(package: ba.AssetPackage, name: str) -> ba.Data: + """get_package_data(package: ba.AssetPackage, name: str) -> ba.Data + + (internal). + """ + import ba # pylint: disable=cyclic-import + return ba.Data() + + +def get_package_model(package: ba.AssetPackage, name: str) -> ba.Model: + """get_package_model(package: ba.AssetPackage, name: str) -> ba.Model + + (internal) + """ + import ba # pylint: disable=cyclic-import + return ba.Model() + + +def get_package_sound(package: ba.AssetPackage, name: str) -> ba.Sound: + """get_package_sound(package: ba.AssetPackage, name: str) -> ba.Sound + + (internal). + """ + import ba # pylint: disable=cyclic-import + return ba.Sound() + + +def get_package_texture(package: ba.AssetPackage, name: str) -> ba.Texture: + """get_package_texture(package: ba.AssetPackage, name: str) -> ba.Texture + + (internal) + """ + import ba # pylint: disable=cyclic-import + return ba.Texture() + + +def get_price(item: str) -> str: + """get_price(item: str) -> str + + (internal) + """ + return str() + + +def get_public_login_id() -> str: + """get_public_login_id() -> str + + (internal) + """ + return str() + + +def get_public_party_enabled() -> bool: + """get_public_party_enabled() -> bool + + (internal) + """ + return bool() + + +def get_public_party_max_size() -> int: + """get_public_party_max_size() -> int + + (internal) + """ + return int() + + +def get_purchased(item: str) -> bool: + """get_purchased(item: str) -> bool + + (internal) + """ + return bool() + + +def get_purchases_state() -> int: + """get_purchases_state() -> int + + (internal) + """ + return int() + + +def get_qrcode_texture(url: str) -> ba.Texture: + """get_qrcode_texture(url: str) -> ba.Texture + + (internal) + """ + import ba # pylint: disable=cyclic-import + return ba.Texture() + + +def get_random_names() -> list: + """get_random_names() -> list + + (internal) + + Returns the random names used by the game. + """ + return list() + + +def get_replay_speed_exponent() -> int: + """get_replay_speed_exponent() -> int + + (internal) + + Returns current replay speed value. Actual displayed speed is pow(2,speed). + """ + return int() + + +def get_replays_dir() -> str: + """get_replays_dir() -> str + + (internal) + """ + return str() + + +def get_scores_to_beat(level: str, config: str, callback: Callable) -> None: + """get_scores_to_beat(level: str, config: str, callback: Callable) -> None + + (internal) + """ + return None + + +def get_special_widget(name: str) -> Widget: + """get_special_widget(name: str) -> Widget + + (internal) + """ + return Widget() + + +def get_string_height(string: str, suppress_warning: bool = False) -> float: + """get_string_height(string: str, suppress_warning: bool = False) -> float + + (internal) + + Given a string, returns its height using the standard small app + font. + """ + return float() + + +def get_string_width(string: str, suppress_warning: bool = False) -> float: + """get_string_width(string: str, suppress_warning: bool = False) -> float + + (internal) + + Given a string, returns its width using the standard small app + font. + """ + return float() + + +def get_thread_name() -> str: + """get_thread_name() -> str + + (internal) + + Returns the name of the current thread. + This may vary depending on platform and should not be used in logic; + only for debugging. + """ + return str() + + +def get_ui_input_device() -> ba.InputDevice: + """get_ui_input_device() -> ba.InputDevice + + (internal) + + Returns the input-device that currently owns the user interface, or + None if there is none. + """ + import ba # pylint: disable=cyclic-import + return ba.InputDevice() + + +def getactivity(doraise: bool = True) -> ba.Activity: + """getactivity(doraise: bool = True) -> ba.Activity + + Returns the current ba.Activity instance. + + Category: Gameplay Functions + + Note that this is based on context; thus code run in a timer generated + in Activity 'foo' will properly return 'foo' here, even if another + Activity has since been created or is transitioning in. + If there is no current Activity an Exception is raised, or if doraise is + False then None is returned instead. + """ + import ba # pylint: disable=cyclic-import + return ba.Activity({}) + + +def getcollidemodel(name: str) -> ba.CollideModel: + """getcollidemodel(name: str) -> ba.CollideModel + + Return a collide-model, loading it if necessary. + + Category: Asset Functions + + Collide-models are used in physics calculations for such things as + terrain. + + Note that this function returns immediately even if the media has yet + to be loaded. To avoid hitches, instantiate your media objects in + advance of when you will be using them, allowing time for them to load + in the background if necessary. + """ + import ba # pylint: disable=cyclic-import + return ba.CollideModel() + + +def getdata(name: str) -> ba.Data: + """getdata(name: str) -> ba.Data + + Return a data, loading it if necessary. + + Category: Asset Functions + + Note that this function returns immediately even if the media has yet + to be loaded. To avoid hitches, instantiate your media objects in + advance of when you will be using them, allowing time for them to load + in the background if necessary. + """ + import ba # pylint: disable=cyclic-import + return ba.Data() + + +def getmodel(name: str) -> ba.Model: + """getmodel(name: str) -> ba.Model + + Return a model, loading it if necessary. + + Category: Asset Functions + + Note that this function returns immediately even if the media has yet + to be loaded. To avoid hitches, instantiate your media objects in + advance of when you will be using them, allowing time for them to load + in the background if necessary. + """ + import ba # pylint: disable=cyclic-import + return ba.Model() + + +def getnodes() -> list: + """getnodes() -> list + + Return all nodes in the current ba.Context. + Category: Gameplay Functions + """ + return list() + + +def getsession(doraise: bool = True) -> ba.Session: + """getsession(doraise: bool = True) -> ba.Session + + Category: Gameplay Functions + + Returns the current ba.Session instance. + Note that this is based on context; thus code being run in the UI + context will return the UI context here even if a game Session also + exists, etc. If there is no current Session, an Exception is raised, or + if doraise is False then None is returned instead. + """ + import ba # pylint: disable=cyclic-import + return ba.Session([]) + + +def getsound(name: str) -> ba.Sound: + """getsound(name: str) -> ba.Sound + + Return a sound, loading it if necessary. + + Category: Asset Functions + + Note that this function returns immediately even if the media has yet + to be loaded. To avoid hitches, instantiate your media objects in + advance of when you will be using them, allowing time for them to load + in the background if necessary. + """ + import ba # pylint: disable=cyclic-import + return ba.Sound() + + +def gettexture(name: str) -> ba.Texture: + """gettexture(name: str) -> ba.Texture + + Return a texture, loading it if necessary. + + Category: Asset Functions + + Note that this function returns immediately even if the media has yet + to be loaded. To avoid hitches, instantiate your media objects in + advance of when you will be using them, allowing time for them to load + in the background if necessary. + """ + import ba # pylint: disable=cyclic-import + return ba.Texture() + + +def has_gamma_control() -> bool: + """has_gamma_control() -> bool + + (internal) + + Returns whether the system can adjust overall screen gamma) + """ + return bool() + + +def has_user_mods() -> bool: + """has_user_mods() -> bool + + (internal) + + Returns whether the system varies from default configuration + (by user mods, etc) + """ + return bool() + + +def has_user_run_commands() -> bool: + """has_user_run_commands() -> bool + + (internal) + """ + return bool() + + +def has_video_ads() -> bool: + """has_video_ads() -> bool + + (internal) + """ + return bool() + + +def have_chars(text: str) -> bool: + """have_chars(text: str) -> bool + + (internal) + """ + return bool() + + +def have_connected_clients() -> bool: + """have_connected_clients() -> bool + + (internal) + + Category: General Utility Functions + """ + return bool() + + +def have_incentivized_ad() -> bool: + """have_incentivized_ad() -> bool + + (internal) + """ + return bool() + + +def have_outstanding_transactions() -> bool: + """have_outstanding_transactions() -> bool + + (internal) + """ + return bool() + + +def have_permission(permission: ba.Permission) -> bool: + """have_permission(permission: ba.Permission) -> bool + + (internal) + """ + return bool() + + +def have_touchscreen_input() -> bool: + """have_touchscreen_input() -> bool + + (internal) + + Returns whether or not a touch-screen input is present + """ + return bool() + + +def host_scan_cycle() -> list: + """host_scan_cycle() -> list + + (internal) + """ + return list() + + +def hscrollwidget(edit: ba.Widget = None, + parent: ba.Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + background: bool = None, + selected_child: ba.Widget = None, + capture_arrows: bool = None, + on_select_call: Callable[[], None] = None, + center_small_content: bool = None, + color: Sequence[float] = None, + highlight: bool = None, + border_opacity: float = None, + simple_culling_h: float = None) -> ba.Widget: + """hscrollwidget(edit: ba.Widget = None, parent: ba.Widget = None, + size: Sequence[float] = None, position: Sequence[float] = None, + background: bool = None, selected_child: ba.Widget = None, + capture_arrows: bool = None, + on_select_call: Callable[[], None] = None, + center_small_content: bool = None, color: Sequence[float] = None, + highlight: bool = None, border_opacity: float = None, + simple_culling_h: float = None) -> ba.Widget + + Create or edit a horizontal scroll widget. + + Category: User Interface Functions + + Pass a valid existing ba.Widget as 'edit' to modify it; otherwise + a new one is created and returned. Arguments that are not set to None + are applied to the Widget. + """ + import ba # pylint: disable=cyclic-import + return ba.Widget() + + +def imagewidget(edit: ba.Widget = None, + parent: ba.Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + color: Sequence[float] = None, + texture: ba.Texture = None, + opacity: float = None, + model_transparent: ba.Model = None, + model_opaque: ba.Model = None, + has_alpha_channel: bool = True, + tint_texture: ba.Texture = None, + tint_color: Sequence[float] = None, + transition_delay: float = None, + draw_controller: ba.Widget = None, + tint2_color: Sequence[float] = None, + tilt_scale: float = None, + mask_texture: ba.Texture = None, + radial_amount: float = None) -> ba.Widget: + """imagewidget(edit: ba.Widget = None, parent: ba.Widget = None, + size: Sequence[float] = None, position: Sequence[float] = None, + color: Sequence[float] = None, texture: ba.Texture = None, + opacity: float = None, model_transparent: ba.Model = None, + model_opaque: ba.Model = None, has_alpha_channel: bool = True, + tint_texture: ba.Texture = None, tint_color: Sequence[float] = None, + transition_delay: float = None, draw_controller: ba.Widget = None, + tint2_color: Sequence[float] = None, tilt_scale: float = None, + mask_texture: ba.Texture = None, radial_amount: float = None) + -> ba.Widget + + Create or edit an image widget. + + Category: User Interface Functions + + Pass a valid existing ba.Widget as 'edit' to modify it; otherwise + a new one is created and returned. Arguments that are not set to None + are applied to the Widget. + """ + import ba # pylint: disable=cyclic-import + return ba.Widget() + + +def in_game_purchase(item: str, price: int) -> None: + """in_game_purchase(item: str, price: int) -> None + + (internal) + """ + return None + + +def in_game_thread() -> bool: + """in_game_thread() -> bool + + (internal) + + Returns whether or not the current thread is the game thread. + """ + return bool() + + +def increment_analytics_count(name: str, increment: int = 1) -> None: + """increment_analytics_count(name: str, increment: int = 1) -> None + + (internal) + """ + return None + + +def increment_analytics_count_raw_2(name: str, + uses_increment: bool = True, + increment: int = 1) -> None: + """increment_analytics_count_raw_2(name: str, + uses_increment: bool = True, increment: int = 1) -> None + + (internal) + """ + return None + + +def increment_analytics_counts_raw(name: str, increment: int = 1) -> None: + """increment_analytics_counts_raw(name: str, increment: int = 1) -> None + + (internal) + """ + return None + + +def invite_players() -> None: + """invite_players() -> None + + (internal) + Category: General Utility Functions + """ + return None + + +def is_in_replay() -> bool: + """is_in_replay() -> bool + + (internal) + """ + return bool() + + +def is_log_full() -> bool: + """is_log_full() -> bool + + (internal) + """ + return bool() + + +def is_os_playing_music() -> bool: + """is_os_playing_music() -> bool + + (internal) + + Tells whether the OS is currently playing music of some sort. + + (Used to determine whether the game should avoid playing its own) + """ + return bool() + + +def is_ouya_build() -> bool: + """is_ouya_build() -> bool + + (internal) + + Returns whether we're running the ouya-specific version + """ + return bool() + + +def is_party_icon_visible() -> bool: + """is_party_icon_visible() -> bool + + (internal) + """ + return bool() + + +def is_running_on_fire_tv() -> bool: + """is_running_on_fire_tv() -> bool + + (internal) + """ + return bool() + + +def is_running_on_ouya() -> bool: + """is_running_on_ouya() -> bool + + (internal) + """ + return bool() + + +def itunes_get_library_source() -> None: + """itunes_get_library_source() -> None + + (internal) + """ + return None + + +def itunes_get_playlists() -> List[str]: + """itunes_get_playlists() -> List[str] + + (internal) + """ + return ["blah", "blah2"] + + +def itunes_get_volume() -> int: + """itunes_get_volume() -> int + + (internal) + """ + return int() + + +def itunes_init() -> None: + """itunes_init() -> None + + (internal) + """ + return None + + +def itunes_play_playlist(playlist: str) -> bool: + """itunes_play_playlist(playlist: str) -> bool + + (internal) + """ + return bool() + + +def itunes_set_volume(volume: int) -> None: + """itunes_set_volume(volume: int) -> None + + (internal) + """ + return None + + +def itunes_stop() -> None: + """itunes_stop() -> None + + (internal) + """ + return None + + +def lock_all_input() -> None: + """lock_all_input() -> None + + (internal) + + Prevents all keyboard, mouse, and gamepad events from being processed. + """ + return None + + +def log(message: str, + to_console: bool = True, + newline: bool = True, + to_server: bool = True) -> None: + """log(message: str, to_console: bool = True, newline: bool = True, + to_server: bool = True) -> None + + Category: General Utility Functions + + Log a message. This goes to the default logging mechanism depending + on the platform (stdout on mac, android log on android, etc). + + Log messages also go to the in-game console unless 'to_console' + is False. They are also sent to the master-server for use in analyzing + issues unless to_server is False. + + Python's standard print() is wired to call this (with default values) + so in most cases you can just use that. + """ + return None + + +def mark_config_dirty() -> None: + """mark_config_dirty() -> None + + (internal) + + Category: General Utility Functions + """ + return None + + +def mark_log_sent() -> None: + """mark_log_sent() -> None + + (internal) + """ + return None + + +def music_player_play(files: Any) -> None: + """music_player_play(files: Any) -> None + + (internal) + + Starts internal music file playback (for internal use) + """ + return None + + +def music_player_set_volume(volume: float) -> None: + """music_player_set_volume(volume: float) -> None + + (internal) + + Sets internal music player volume (for internal use) + """ + return None + + +def music_player_shutdown() -> None: + """music_player_shutdown() -> None + + (internal) + + Finalizes internal music file playback (for internal use) + """ + return None + + +def music_player_stop() -> None: + """music_player_stop() -> None + + (internal) + + Stops internal music file playback (for internal use) + """ + return None + + +def new_activity(activity_type: Type[ba.Activity], + settings: dict = None) -> ba.Activity: + """new_activity(activity_type: Type[ba.Activity], + settings: dict = None) -> ba.Activity + + Instantiates a ba.Activity given a type object. + + Category: General Utility Functions + + Activities require special setup and thus cannot be directly + instantiated; You must go through this function. + """ + import ba # pylint: disable=cyclic-import + return ba.Activity({}) + + +def new_host_session(sessiontype: Type[ba.Session], + benchmark_type: str = None) -> None: + """new_host_session(sessiontype: Type[ba.Session], + benchmark_type: str = None) -> None + + (internal) + """ + return None + + +def new_replay_session(file_name: str) -> None: + """new_replay_session(file_name: str) -> None + + (internal) + """ + return None + + +def newnode(type: str, + owner: Union[Node, ba.Actor] = None, + attrs: dict = None, + name: str = None, + delegate: Any = None) -> Node: + """newnode(type: str, owner: Union[Node, ba.Actor] = None, + attrs: dict = None, name: str = None, delegate: Any = None) + -> Node + + Add a node of the given type to the game. + + Category: Gameplay Functions + + If a dict is provided for 'attributes', the node's initial attributes + will be set based on them. + + 'name', if provided, will be stored with the node purely for debugging + purposes. If no name is provided, an automatic one will be generated + such as 'terrain@foo.py:30'. + + If 'delegate' is provided, Python messages sent to the node will go to + that object's handlemessage() method. Note that the delegate is stored + as a weak-ref, so the node itself will not keep the object alive. + + if 'owner' is provided, the node will be automatically killed when that + object dies. 'owner' can be another node or a ba.Actor + """ + return Node() + + +def open_dir_externally(path: str) -> None: + """open_dir_externally(path: str) -> None + + (internal) + + Open the provided dir in the default external app. + """ + return None + + +def open_file_externally(path: str) -> None: + """open_file_externally(path: str) -> None + + (internal) + + Open the provided file in the default external app. + """ + return None + + +def open_url(address: str) -> None: + """open_url(address: str) -> None + + Open a provided URL. + + Category: General Utility Functions + + Open the provided url in a web-browser, or display the URL + string in a window if that isn't possible. + """ + return None + + +def playsound(sound: Sound, + volume: float = 1.0, + position: Sequence[float] = None, + host_only: bool = False) -> None: + """playsound(sound: Sound, volume: float = 1.0, + position: Sequence[float] = None, host_only: bool = False) -> None + + Play a ba.Sound a single time. + + Category: Gameplay Functions + + If position is not provided, the sound will be at a constant volume + everywhere. Position should be a float tuple of size 3. + """ + return None + + +def power_ranking_query(callback: Callable, season: Any = None) -> None: + """power_ranking_query(callback: Callable, season: Any = None) -> None + + (internal) + """ + return None + + +def print_context() -> None: + """print_context() -> None + + (internal) + + Prints info about the current context state; for debugging. + """ + return None + + +def print_load_info() -> None: + """print_load_info() -> None + + (internal) + + Category: General Utility Functions + """ + return None + + +def printnodes() -> None: + """printnodes() -> None + + Print various info about existing nodes; useful for debugging. + + Category: Gameplay Functions + """ + return None + + +def printobjects() -> None: + """printobjects() -> None + + Print debugging info about game objects. + + Category: General Utility Functions + + This call only functions in debug builds of the game. + It prints various info about the current object count, etc. + """ + return None + + +def purchase(item: str) -> None: + """purchase(item: str) -> None + + (internal) + """ + return None + + +def pushcall(call: Callable, from_other_thread: bool = False) -> None: + """pushcall(call: Callable, from_other_thread: bool = False) -> None + + Pushes a call onto the event loop to be run during the next cycle. + + Category: General Utility Functions + + This can be handy for calls that are disallowed from within other + callbacks, etc. + + This call expects to be used in the game thread, and will automatically + save and restore the ba.Context to behave seamlessly. + + If you want to push a call from outside of the game thread, + however, you can pass 'from_other_thread' as True. In this case + the call will always run in the UI context on the game thread. + """ + return None + + +def quit(soft: bool = False, back: bool = False) -> None: + """quit(soft: bool = False, back: bool = False) -> None + + Quit the game. + + Category: General Utility Functions + + On systems like android, 'soft' will end the activity but keep the + app running. + """ + return None + + +def register_activity(activity: ba.Activity) -> ActivityData: + """register_activity(activity: ba.Activity) -> ActivityData + + (internal) + """ + return ActivityData() + + +def register_session(session: ba.Session) -> SessionData: + """register_session(session: ba.Session) -> SessionData + + (internal) + """ + return SessionData() + + +def release_gamepad_input() -> None: + """release_gamepad_input() -> None + + (internal) + + Resumes normal gamepad event processing. + """ + return None + + +def release_keyboard_input() -> None: + """release_keyboard_input() -> None + + (internal) + + Resumes normal keyboard event processing. + """ + return None + + +def reload_media() -> None: + """reload_media() -> None + + (internal) + + Reload all currently loaded game media; useful for + development/debugging. + """ + return None + + +def report_achievement(achievement: str, pass_to_account: bool = True) -> None: + """report_achievement(achievement: str, pass_to_account: bool = True) + -> None + + (internal) + """ + return None + + +def request_permission(permission: ba.Permission) -> None: + """request_permission(permission: ba.Permission) -> None + + (internal) + """ + return None + + +def reset_achievements() -> None: + """reset_achievements() -> None + + (internal) + """ + return None + + +def reset_game_activity_tracking() -> None: + """reset_game_activity_tracking() -> None + + (internal) + """ + return None + + +def reset_random_player_names() -> None: + """reset_random_player_names() -> None + + (internal) + """ + return None + + +def resolve_appconfig_value(key: str) -> Any: + """resolve_appconfig_value(key: str) -> Any + + (internal) + """ + return _uninferrable() + + +def restore_purchases() -> None: + """restore_purchases() -> None + + (internal) + """ + return None + + +def rowwidget(edit: Widget = None, + parent: Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + background: bool = None, + selected_child: Widget = None, + visible_child: Widget = None) -> Widget: + """rowwidget(edit: Widget =None, parent: Widget =None, + size: Sequence[float] = None, + position: Sequence[float] = None, + background: bool = None, selected_child: Widget = None, + visible_child: Widget = None) -> Widget + + Create or edit a row widget. + + Category: User Interface Functions + + Pass a valid existing ba.Widget as 'edit' to modify it; otherwise + a new one is created and returned. Arguments that are not set to None + are applied to the Widget. + """ + return Widget() + + +def run_transactions() -> None: + """run_transactions() -> None + + (internal) + """ + return None + + +def safecolor(color: Sequence[float], + target_intensity: float = 0.6) -> Tuple[float, ...]: + """safecolor(color: Sequence[float], target_intensity: float = 0.6) + -> Tuple[float, ...] + + Given a color tuple, return a color safe to display as text. + + Category: General Utility Functions + + Accepts tuples of length 3 or 4. This will slightly brighten very + dark colors, etc. + """ + return (0.0, 0.0, 0.0) + + +def screenmessage(message: Union[str, ba.Lstr], + color: Sequence[float] = None, + top: bool = False, + image: Dict[str, Any] = None, + log: bool = False, + clients: Sequence[int] = None, + transient: bool = False) -> None: + """screenmessage(message: Union[str, ba.Lstr], + color: Sequence[float] = None, top: bool = False, + image: Dict[str, Any] = None, log: bool = False, + clients: Sequence[int] = None, transient: bool = False) -> None + + Print a message to the local client's screen, in a given color. + + Category: General Utility Functions + + If 'top' is True, the message will go to the top message area. + For 'top' messages, 'image' can be a texture to display alongside the + message. + If 'log' is True, the message will also be printed to the output log + 'clients' can be a list of client-ids the message should be sent to, + or None to specify that everyone should receive it. + If 'transient' is True, the message will not be included in the + game-stream and thus will not show up when viewing replays. + Currently the 'clients' option only works for transient messages. + """ + return None + + +def scrollwidget(edit: ba.Widget = None, + parent: ba.Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + background: bool = None, + selected_child: ba.Widget = None, + capture_arrows: bool = False, + on_select_call: Callable = None, + center_small_content: bool = None, + color: Sequence[float] = None, + highlight: bool = None, + border_opacity: float = None, + simple_culling_v: float = None) -> ba.Widget: + """scrollwidget(edit: ba.Widget = None, parent: ba.Widget = None, + size: Sequence[float] = None, position: Sequence[float] = None, + background: bool = None, selected_child: ba.Widget = None, + capture_arrows: bool = False, on_select_call: Callable = None, + center_small_content: bool = None, color: Sequence[float] = None, + highlight: bool = None, border_opacity: float = None, + simple_culling_v: float = None) -> ba.Widget + + Create or edit a scroll widget. + + Category: User Interface Functions + + Pass a valid existing ba.Widget as 'edit' to modify it; otherwise + a new one is created and returned. Arguments that are not set to None + are applied to the Widget. + """ + import ba # pylint: disable=cyclic-import + return ba.Widget() + + +def set_analytics_screen(screen: str) -> None: + """set_analytics_screen(screen: str) -> None + + Used for analytics to see where in the app players spend their time. + + Category: General Utility Functions + + Generally called when opening a new window or entering some UI. + 'screen' should be a string description of an app location + ('Main Menu', etc.) + """ + return None + + +def set_debug_speed_exponent(speed: int) -> None: + """set_debug_speed_exponent(speed: int) -> None + + (internal) + + Sets the debug speed scale for the game. Actual speed is pow(2,speed). + """ + return None + + +def set_have_mods(have_mods: bool) -> None: + """set_have_mods(have_mods: bool) -> None + + (internal) + """ + return None + + +def set_internal_language_keys(listobj: List[Tuple[str, str]], + random_names_list: List[Tuple[str, str]] + ) -> None: + """set_internal_language_keys(listobj: List[Tuple[str, str]], + random_names_list: List[Tuple[str, str]]) -> None + + (internal) + """ + return None + + +def set_low_level_config_value(key: str, value: int) -> None: + """set_low_level_config_value(key: str, value: int) -> None + + (internal) + """ + return None + + +def set_map_bounds(bounds: Tuple[float, float, float, float, float, float] + ) -> None: + """set_map_bounds(bounds: Tuple[float, float, float, float, float, float]) + -> None + + (internal) + + Set map bounds. Generally nodes that go outside of this box are killed. + """ + return None + + +def set_master_server_source(source: int) -> None: + """set_master_server_source(source: int) -> None + + (internal) + """ + return None + + +def set_party_icon_always_visible(value: bool) -> None: + """set_party_icon_always_visible(value: bool) -> None + + (internal) + """ + return None + + +def set_party_window_open(value: bool) -> None: + """set_party_window_open(value: bool) -> None + + (internal) + """ + return None + + +def set_platform_misc_read_vals(mode: str) -> None: + """set_platform_misc_read_vals(mode: str) -> None + + (internal) + """ + return None + + +def set_public_party_enabled(enabled: bool) -> None: + """set_public_party_enabled(enabled: bool) -> None + + (internal) + """ + return None + + +def set_public_party_max_size(max_size: int) -> None: + """set_public_party_max_size(max_size: int) -> None + + (internal) + """ + return None + + +def set_public_party_name(name: str) -> None: + """set_public_party_name(name: str) -> None + + (internal) + """ + return None + + +def set_public_party_stats_url(url: str) -> None: + """set_public_party_stats_url(url: str) -> None + + (internal) + """ + return None + + +def set_replay_speed_exponent(speed: int) -> None: + """set_replay_speed_exponent(speed: int) -> None + + (internal) + + Set replay speed. Actual displayed speed is pow(2,speed). + """ + return None + + +def set_stress_testing(testing: bool, player_count: int) -> None: + """set_stress_testing(testing: bool, player_count: int) -> None + + (internal) + """ + return None + + +def set_telnet_access_enabled(enable: bool) -> None: + """set_telnet_access_enabled(enable: bool) + -> None + + (internal) + """ + return None + + +def set_thread_name(name: str) -> None: + """set_thread_name(name: str) -> None + + (internal) + + Sets the name of the current thread (on platforms where this is + available). Thread names are only for debugging and should not be + used in logic, as naming behavior can vary across platforms. + """ + return None + + +def set_touchscreen_editing(editing: bool) -> None: + """set_touchscreen_editing(editing: bool) -> None + + (internal) + """ + return None + + +def set_ui_input_device(input_device: Optional[ba.InputDevice]) -> None: + """set_ui_input_device(input_device: Optional[ba.InputDevice]) -> None + + (internal) + + Sets the input-device that currently owns the user interface. + """ + return None + + +def show_ad(purpose: str, + on_completion_call: Callable[[], None] = None, + pass_actually_showed: bool = False) -> None: + """show_ad(purpose: str, on_completion_call: Callable[[], None] = None, + pass_actually_showed: bool = False) -> None + + (internal) + """ + return None + + +def show_app_invite(title: Union[str, ba.Lstr], message: Union[str, ba.Lstr], + code: str) -> None: + """show_app_invite(title: Union[str, ba.Lstr], + message: Union[str, ba.Lstr], + code: str) -> None + + (internal) + + Category: General Utility Functions + """ + return None + + +def show_invites_ui() -> None: + """show_invites_ui() -> None + + (internal) + + Category: General Utility Functions + """ + return None + + +def show_online_score_ui(show: str = 'general', + game: str = None, + game_version: str = None) -> None: + """show_online_score_ui(show: str = 'general', game: str = None, + game_version: str = None) -> None + + (internal) + """ + return None + + +def show_progress_bar() -> None: + """show_progress_bar() -> None + + (internal) + + Category: General Utility Functions + """ + return None + + +def sign_in(account_type: str) -> None: + """sign_in(account_type: str) -> None + + (internal) + + Category: General Utility Functions + """ + return None + + +def sign_out() -> None: + """sign_out() -> None + + (internal) + + Category: General Utility Functions + """ + return None + + +def start_listening_for_wii_remotes() -> None: + """start_listening_for_wii_remotes() -> None + + (internal) + + Start listening for connections from wii remotes. + """ + return None + + +def stop_listening_for_wii_remotes() -> None: + """stop_listening_for_wii_remotes() -> None + + (internal) + + Stop listening for connections from wii remotes. + """ + return None + + +def submit_analytics_counts() -> None: + """submit_analytics_counts() -> None + + (internal) + """ + return None + + +def submit_score(game: str, + config: str, + name: Any, + score: int, + callback: Callable, + friend_callback: Optional[Callable], + order: str = 'increasing', + tournament_id: Optional[str] = None, + score_type: str = 'points', + campaign: Optional[ba.Campaign] = None, + level: Optional[ba.Level] = None) -> None: + """submit_score(game: str, config: str, name: Any, score: int, + callback: Callable, friend_callback: Optional[Callable], + order: str = 'increasing', tournament_id: Optional[str] = None, + score_type: str = 'points', + campaign: Optional[ba.Campaign] = None, + level: Optional[ba.Level] = None) -> None + + (internal) + + Submit a score to the server; callback will be called with the results. + As a courtesy, please don't send fake scores to the server. I'd prefer + to devote my time to improving the game instead of trying to make the + score server more mischief-proof. + """ + return None + + +def textwidget(edit: Widget = None, + parent: Widget = None, + size: Sequence[float] = None, + position: Sequence[float] = None, + text: Union[str, ba.Lstr] = None, + v_align: str = None, + h_align: str = None, + editable: bool = None, + padding: float = None, + on_return_press_call: Callable[[], None] = None, + on_activate_call: Callable[[], None] = None, + selectable: bool = None, + query: Widget = None, + max_chars: int = None, + color: Sequence[float] = None, + click_activate: bool = None, + on_select_call: Callable[[], None] = None, + always_highlight: bool = None, + draw_controller: Widget = None, + scale: float = None, + corner_scale: float = None, + description: Union[str, ba.Lstr] = None, + transition_delay: float = None, + maxwidth: float = None, + max_height: float = None, + flatness: float = None, + shadow: float = None, + autoselect: bool = None, + rotate: float = None, + enabled: bool = None, + force_internal_editing: bool = None, + always_show_carat: bool = None, + big: bool = None, + extra_touch_border_scale: float = None, + res_scale: float = None) -> Widget: + """textwidget(edit: Widget = None, parent: Widget = None, + size: Sequence[float] = None, position: Sequence[float] = None, + text: Union[str, ba.Lstr] = None, v_align: str = None, + h_align: str = None, editable: bool = None, padding: float = None, + on_return_press_call: Callable[[], None] = None, + on_activate_call: Callable[[], None] = None, + selectable: bool = None, query: Widget = None, max_chars: int = None, + color: Sequence[float] = None, click_activate: bool = None, + on_select_call: Callable[[], None] = None, + always_highlight: bool = None, draw_controller: Widget = None, + scale: float = None, corner_scale: float = None, + description: Union[str, ba.Lstr] = None, + transition_delay: float = None, maxwidth: float = None, + max_height: float = None, flatness: float = None, + shadow: float = None, autoselect: bool = None, rotate: float = None, + enabled: bool = None, force_internal_editing: bool = None, + always_show_carat: bool = None, big: bool = None, + extra_touch_border_scale: float = None, res_scale: float = None) + -> Widget + + Create or edit a text widget. + + Category: User Interface Functions + + Pass a valid existing ba.Widget as 'edit' to modify it; otherwise + a new one is created and returned. Arguments that are not set to None + are applied to the Widget. + """ + return Widget() + + +def time(timetype: ba.TimeType = TimeType.SIM, + timeformat: ba.TimeFormat = TimeFormat.SECONDS) -> Union[float, int]: + """time(timetype: ba.TimeType = TimeType.SIM, + timeformat: ba.TimeFormat = TimeFormat.SECONDS) + -> Union[float, int] + + Return the current time. + + Category: General Utility Functions + + The time returned depends on the current ba.Context and timetype. + + timetype can be either SIM, BASE, or REAL. It defaults to + SIM. Types are explained below: + + SIM time maps to local simulation time in ba.Activity or ba.Session + Contexts. This means that it may progress slower in slow-motion play + modes, stop when the game is paused, etc. This time type is not + available in UI contexts. + + BASE time is also linked to gameplay in ba.Activity or ba.Session + Contexts, but it progresses at a constant rate regardless of + slow-motion states or pausing. It can, however, slow down or stop + in certain cases such as network outages or game slowdowns due to + cpu load. Like 'sim' time, this is unavailable in UI contexts. + + REAL time always maps to actual clock time with a bit of filtering + added, regardless of Context. (the filtering prevents it from going + backwards or jumping forward by large amounts due to the app being + backgrounded, system time changing, etc.) + + the 'timeformat' arg defaults to SECONDS which returns float seconds, + but it can also be MILLISECONDS to return integer milliseconds. + + Note: If you need pure unfiltered clock time, just use the standard + Python functions such as time.time(). + """ + return 0.0 + + +def time_format_check(time_format: ba.TimeFormat, + length: Union[float, int]) -> None: + """time_format_check(time_format: ba.TimeFormat, length: Union[float, int]) + -> None + + (internal) + + Logs suspicious time values for timers or animate calls. + + (for helping with the transition from milliseconds-based time calls + to seconds-based ones) + """ + return None + + +def timer(time: float, + call: Callable[[], Any], + repeat: bool = False, + timetype: ba.TimeType = TimeType.SIM, + timeformat: ba.TimeFormat = TimeFormat.SECONDS, + suppress_format_warning: bool = False) -> None: + """timer(time: float, call: Callable[[], Any], repeat: bool = False, + timetype: ba.TimeType = TimeType.SIM, + timeformat: ba.TimeFormat = TimeFormat.SECONDS, + suppress_format_warning: bool = False) + -> None + + Schedule a call to run at a later point in time. + + Category: General Utility Functions + + This function adds a timer to the current ba.Context. + This timer cannot be canceled or modified once created. If you + require the ability to do so, use the ba.Timer class instead. + + time: length of time (in seconds by default) that the timer will wait + before firing. Note that the actual delay experienced may vary + depending on the timetype. (see below) + + call: A callable Python object. Note that the timer will retain a + strong reference to the callable for as long as it exists, so you + may want to look into concepts such as ba.WeakCall if that is not + desired. + + repeat: if True, the timer will fire repeatedly, with each successive + firing having the same delay as the first. + + timetype can be either 'sim', 'base', or 'real'. It defaults to + 'sim'. Types are explained below: + + 'sim' time maps to local simulation time in ba.Activity or ba.Session + Contexts. This means that it may progress slower in slow-motion play + modes, stop when the game is paused, etc. This time type is not + available in UI contexts. + + 'base' time is also linked to gameplay in ba.Activity or ba.Session + Contexts, but it progresses at a constant rate regardless of + slow-motion states or pausing. It can, however, slow down or stop + in certain cases such as network outages or game slowdowns due to + cpu load. Like 'sim' time, this is unavailable in UI contexts. + + 'real' time always maps to actual clock time with a bit of filtering + added, regardless of Context. (the filtering prevents it from going + backwards or jumping forward by large amounts due to the app being + backgrounded, system time changing, etc.) + Real time timers are currently only available in the UI context. + + the 'timeformat' arg defaults to seconds but can also be milliseconds. + + # timer example: print some stuff through time: + ba.screenmessage('hello from now!') + ba.timer(1.0, ba.Call(ba.screenmessage, 'hello from the future!')) + ba.timer(2.0, ba.Call(ba.screenmessage, 'hello from the future 2!')) + """ + return None + + +def tournament_query(callback: Callable[[Optional[Dict]], None], + args: Dict) -> None: + """tournament_query(callback: Callable[[Optional[Dict]], None], + args: Dict) -> None + + (internal) + """ + return None + + +def uibounds() -> Tuple[float, float, float, float]: + """uibounds() -> Tuple[float, float, float, float] + + (internal) + + Returns a tuple of 4 values: (x-min, x-max, y-min, y-max) representing + the range of values that can be plugged into a root level + ba.ContainerWidget's stack_offset value while guaranteeing that its + center remains onscreen. + """ + return (0.0, 0.0, 0.0, 0.0) + + +def unlock_all_input() -> None: + """unlock_all_input() -> None + + (internal) + + Resumes normal keyboard, mouse, and gamepad event processing. + """ + return None + + +def value_test(arg: str, change: float = None, + absolute: float = None) -> float: + """value_test(arg: str, change: float = None, absolute: float = None) + -> float + + (internal) + """ + return float() + + +def widget(edit: ba.Widget = None, + up_widget: ba.Widget = None, + down_widget: ba.Widget = None, + left_widget: ba.Widget = None, + right_widget: ba.Widget = None, + show_buffer_top: float = None, + show_buffer_bottom: float = None, + show_buffer_left: float = None, + show_buffer_right: float = None, + autoselect: bool = None) -> None: + """widget(edit: ba.Widget = None, up_widget: ba.Widget = None, + down_widget: ba.Widget = None, left_widget: ba.Widget = None, + right_widget: ba.Widget = None, show_buffer_top: float = None, + show_buffer_bottom: float = None, show_buffer_left: float = None, + show_buffer_right: float = None, autoselect: bool = None) -> None + + Edit common attributes of any widget. + + Category: User Interface Functions + + Unlike other UI calls, this can only be used to edit, not to create. + """ + return None diff --git a/assets/src/data/scripts/ba/__init__.py b/assets/src/data/scripts/ba/__init__.py new file mode 100644 index 00000000..c54d9e01 --- /dev/null +++ b/assets/src/data/scripts/ba/__init__.py @@ -0,0 +1,78 @@ +"""The public face of Ballistica. + +This top level module is a collection of most commonly used functionality. +For many modding purposes, the bits exposed here are all you'll need. +In some specific cases you may need to pull in individual submodules instead. +""" + +# pylint: disable=unused-import +# pylint: disable=redefined-builtin + +from _ba import (CollideModel, Context, ContextCall, Data, InputDevice, + Material, Model, Node, Player, Sound, Texture, Timer, Vec3, + Widget, buttonwidget, camerashake, checkboxwidget, + columnwidget, containerwidget, do_once, emitfx, + get_collision_info, getactivity, getcollidemodel, getmodel, + getnodes, getsession, getsound, gettexture, hscrollwidget, + imagewidget, log, new_activity, newnode, playsound, + printnodes, printobjects, pushcall, quit, rowwidget, + safecolor, screenmessage, scrollwidget, set_analytics_screen, + charstr, textwidget, time, timer, open_url, widget) +from ba._activity import Activity +from ba._actor import Actor +from ba._app import App +from ba._coopgame import CoopGameActivity +from ba._coopsession import CoopSession +from ba._dep import Dep, Dependency, DepComponent, DepSet, AssetPackage +from ba._enums import TimeType, Permission, TimeFormat, SpecialChar +from ba._error import (UNHANDLED, print_exception, print_error, NotFoundError, + PlayerNotFoundError, NodeNotFoundError, + ActorNotFoundError, InputDeviceNotFoundError, + WidgetNotFoundError, ActivityNotFoundError, + TeamNotFoundError, SessionNotFoundError, + DependencyError) +from ba._freeforallsession import FreeForAllSession +from ba._gameactivity import GameActivity +from ba._gameresults import TeamGameResults +from ba._lang import Lstr, setlanguage, get_valid_languages +from ba._maps import Map, getmaps +from ba._session import Session +from ba._stats import PlayerScoredMessage, PlayerRecord, Stats +from ba._team import Team +from ba._teamgame import TeamGameActivity +from ba._teamssession import TeamsSession +from ba._achievement import Achievement +from ba._appconfig import AppConfig +from ba._appdelegate import AppDelegate +from ba._apputils import is_browser_likely_available +from ba._campaign import Campaign +from ba._gameutils import (animate, animate_array, show_damage_count, + sharedobj, timestring, cameraflash) +from ba._general import WeakCall, Call +from ba._level import Level +from ba._lobby import Lobby, Chooser +from ba._math import normalized_color, is_point_in_box, vec3validate +from ba._messages import (OutOfBoundsMessage, DieMessage, StandMessage, + PickUpMessage, DropMessage, PickedUpMessage, + DroppedMessage, ShouldShatterMessage, + ImpactDamageMessage, FreezeMessage, ThawMessage, + HitMessage) +from ba._music import setmusic, MusicPlayer +from ba._powerup import PowerupMessage, PowerupAcceptMessage +from ba._teambasesession import TeamBaseSession +from ba.ui import (OldWindow, UILocation, UILocationWindow, UIController, + uicleanupcheck) + +app: App + + +# Change everything's listed module to ba (instead of ba.foo.bar.etc). +def _simplify_module_names() -> None: + for attr, obj in globals().items(): + if not attr.startswith('_'): + if getattr(obj, '__module__', None) not in [None, 'ba']: + obj.__module__ = 'ba' + + +_simplify_module_names() +del _simplify_module_names diff --git a/assets/src/data/scripts/ba/_account.py b/assets/src/data/scripts/ba/_account.py new file mode 100644 index 00000000..8f74e07e --- /dev/null +++ b/assets/src/data/scripts/ba/_account.py @@ -0,0 +1,195 @@ +"""Account related functionality.""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Any, Optional, Dict, List + + +def handle_account_gained_tickets(count: int) -> None: + """Called when the current account has been awarded tickets. + + (internal) + """ + from ba._lang import Lstr + _ba.screenmessage(Lstr(resource='getTicketsWindow.receivedTicketsText', + subs=[('${COUNT}', str(count))]), + color=(0, 1, 0)) + _ba.playsound(_ba.getsound('cashRegister')) + + +def cache_league_rank_data(data: Any) -> None: + """(internal)""" + _ba.app.league_rank_cache['info'] = copy.deepcopy(data) + + +def get_cached_league_rank_data() -> Any: + """(internal)""" + return _ba.app.league_rank_cache.get('info', None) + + +def get_league_rank_points(data: Optional[Dict[str, Any]], + subset: str = None) -> int: + """(internal)""" + if data is None: + return 0 + + # If the data contains an achievement total, use that. otherwise calc + # locally. + if data['at'] is not None: + total_ach_value = data['at'] + else: + total_ach_value = 0 + for ach in _ba.app.achievements: + if ach.complete: + total_ach_value += ach.power_ranking_value + + trophies_total: int = (data['t0a'] * data['t0am'] + + data['t0b'] * data['t0bm'] + + data['t1'] * data['t1m'] + + data['t2'] * data['t2m'] + + data['t3'] * data['t3m'] + data['t4'] * data['t4m']) + if subset == 'trophyCount': + val: int = (data['t0a'] + data['t0b'] + data['t1'] + data['t2'] + + data['t3'] + data['t4']) + assert isinstance(val, int) + return val + if subset == 'trophies': + assert isinstance(trophies_total, int) + return trophies_total + if subset is not None: + raise Exception("invalid subset value: " + str(subset)) + + if data['p']: + pro_mult = 1.0 + float( + _ba.get_account_misc_read_val('proPowerRankingBoost', 0.0)) * 0.01 + else: + pro_mult = 1.0 + + # For final value, apply our pro mult and activeness-mult. + return int((total_ach_value + trophies_total) * + (data['act'] if data['act'] is not None else 1.0) * pro_mult) + + +def cache_tournament_info(info: Any) -> None: + """(internal)""" + from ba._enums import TimeType, TimeFormat + for entry in info: + cache_entry = _ba.app.tournament_info[entry['tournamentID']] = ( + copy.deepcopy(entry)) + + # Also store the time we received this, so we can adjust + # time-remaining values/etc. + cache_entry['timeReceived'] = _ba.time(TimeType.REAL, + TimeFormat.MILLISECONDS) + cache_entry['valid'] = True + + +def get_purchased_icons() -> List[str]: + """(internal)""" + # pylint: disable=cyclic-import + from ba import _store + if _ba.get_account_state() != 'signed_in': + return [] + icons = [] + store_items = _store.get_store_items() + for item_name, item in list(store_items.items()): + if item_name.startswith('icons.') and _ba.get_purchased(item_name): + icons.append(item['icon']) + return icons + + +def ensure_have_account_player_profile() -> None: + """ + Ensure the standard account-named player profile exists; + creating if needed. + """ + # This only applies when we're signed in. + if _ba.get_account_state() != 'signed_in': + return + + # If the short version of our account name currently cant be + # displayed by the game, cancel. + if not _ba.have_chars(_ba.get_account_display_string(full=False)): + return + + config = _ba.app.config + if ('Player Profiles' not in config + or '__account__' not in config['Player Profiles']): + + # Create a spaz with a nice default purply color. + _ba.add_transaction({ + 'type': 'ADD_PLAYER_PROFILE', + 'name': '__account__', + 'profile': { + 'character': 'Spaz', + 'color': [0.5, 0.25, 1.0], + 'highlight': [0.5, 0.25, 1.0] + } + }) + _ba.run_transactions() + + +def have_pro() -> bool: + """Return whether pro is currently unlocked.""" + + # Check our tickets-based pro upgrade and our two real-IAP based upgrades. + return bool( + _ba.get_purchased('upgrades.pro') or _ba.get_purchased('static.pro') + or _ba.get_purchased('static.pro_sale')) + + +def have_pro_options() -> bool: + """Return whether pro-options are present. + + This is True for owners of Pro or old installs + before Pro was a requirement for these. + """ + + # We expose pro options if the server tells us to + # (which is generally just when we own pro), + # or also if we've been grandfathered in. + return bool( + _ba.get_account_misc_read_val_2('proOptionsUnlocked', False) + or _ba.app.config.get('lc14292', 0) > 1) + + +def show_post_purchase_message() -> None: + """(internal)""" + from ba._lang import Lstr + from ba._enums import TimeType + app = _ba.app + cur_time = _ba.time(TimeType.REAL) + if (app.last_post_purchase_message_time is None + or cur_time - app.last_post_purchase_message_time > 3.0): + app.last_post_purchase_message_time = cur_time + with _ba.Context('ui'): + _ba.screenmessage(Lstr(resource='updatingAccountText', + fallback_resource='purchasingText'), + color=(0, 1, 0)) + _ba.playsound(_ba.getsound('click01')) + + +def on_account_state_changed() -> None: + """(internal)""" + import time + from ba import _lang + app = _ba.app + + # Run any pending promo codes we had queued up while not signed in. + if _ba.get_account_state() == 'signed_in' and app.pending_promo_codes: + for code in app.pending_promo_codes: + _ba.screenmessage(_lang.Lstr(resource='submittingPromoCodeText'), + color=(0, 1, 0)) + _ba.add_transaction({ + 'type': 'PROMO_CODE', + 'expire_time': time.time() + 5, + 'code': code + }) + _ba.run_transactions() + app.pending_promo_codes = [] diff --git a/assets/src/data/scripts/ba/_achievement.py b/assets/src/data/scripts/ba/_achievement.py new file mode 100644 index 00000000..be0ff83e --- /dev/null +++ b/assets/src/data/scripts/ba/_achievement.py @@ -0,0 +1,1192 @@ +"""Various functionality related to achievements.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Any, Sequence, List, Dict, Union, Optional + import ba + +# This could use some cleanup. +# We wear the cone of shame. +# pylint: disable=too-many-lines +# pylint: disable=too-many-statements +# pylint: disable=too-many-locals +# pylint: disable=too-many-branches + +# FIXME: We should probably point achievements +# at coop levels instead of hard-coding names. +# (so level name substitution works right and whatnot). +ACH_LEVEL_NAMES = { + 'Boom Goes the Dynamite': 'Pro Onslaught', + 'Boxer': 'Onslaught Training', + 'Flawless Victory': 'Rookie Onslaught', + 'Gold Miner': 'Uber Onslaught', + 'Got the Moves': 'Uber Football', + 'Last Stand God': 'The Last Stand', + 'Last Stand Master': 'The Last Stand', + 'Last Stand Wizard': 'The Last Stand', + 'Mine Games': 'Rookie Onslaught', + 'Off You Go Then': 'Onslaught Training', + 'Onslaught God': 'Infinite Onslaught', + 'Onslaught Master': 'Infinite Onslaught', + 'Onslaught Training Victory': 'Onslaught Training', + 'Onslaught Wizard': 'Infinite Onslaught', + 'Precision Bombing': 'Pro Runaround', + 'Pro Boxer': 'Pro Onslaught', + 'Pro Football Shutout': 'Pro Football', + 'Pro Football Victory': 'Pro Football', + 'Pro Onslaught Victory': 'Pro Onslaught', + 'Pro Runaround Victory': 'Pro Runaround', + 'Rookie Football Shutout': 'Rookie Football', + 'Rookie Football Victory': 'Rookie Football', + 'Rookie Onslaught Victory': 'Rookie Onslaught', + 'Runaround God': 'Infinite Runaround', + 'Runaround Master': 'Infinite Runaround', + 'Runaround Wizard': 'Infinite Runaround', + 'Stayin\' Alive': 'Uber Runaround', + 'Super Mega Punch': 'Pro Football', + 'Super Punch': 'Rookie Football', + 'TNT Terror': 'Uber Onslaught', + 'The Great Wall': 'Uber Runaround', + 'The Wall': 'Pro Runaround', + 'Uber Football Shutout': 'Uber Football', + 'Uber Football Victory': 'Uber Football', + 'Uber Onslaught Victory': 'Uber Onslaught', + 'Uber Runaround Victory': 'Uber Runaround' +} + + +def award_local_achievement(achname: str) -> None: + """For non-game-based achievements such as controller-connection ones.""" + try: + ach = get_achievement(achname) + if ach is not None and not ach.complete: + + # Report new achievements to the game-service. + _ba.report_achievement(achname) + + # And to our account. + _ba.add_transaction({'type': 'ACHIEVEMENT', 'name': achname}) + + # Now attempt to show a banner. + display_achievement_banner(achname) + + except Exception: + from ba import _error + _error.print_exception() + + +def display_achievement_banner(achname: str) -> None: + """Display a completion banner for an achievement. + + Used for server-driven achievements. + """ + try: + # FIXME: Need to get these using the UI context or some other + # purely local context somehow instead of trying to inject these + # into whatever activity happens to be active + # (since that won't work while in client mode). + activity = _ba.get_foreground_host_activity() + if activity is not None: + with _ba.Context(activity): + get_achievement(achname).announce_completion() + except Exception: + from ba import _error + _error.print_exception('error showing server ach') + + +# This gets called whenever game-center/game-circle/etc tells us which +# achievements we currently have. We always defer to them, even if that +# means we have to un-set an achievement we think we have + + +def set_completed_achievements(achs: Sequence[str]) -> None: + """Set the current state of completed achievements. + + All achievements not included here will be set incomplete. + """ + cfg = _ba.app.config + cfg['Achievements'] = {} + for a_name in achs: + get_achievement(a_name).set_complete(True) + cfg.commit() + + +def get_achievement(name: str) -> Achievement: + """Return an Achievement by name.""" + achs = [a for a in _ba.app.achievements if a.name == name] + assert len(achs) < 2 + if not achs: + raise Exception("Invalid achievement name: '" + name + "'") + return achs[0] + + +def _get_ach_mult(include_pro_bonus: bool = False) -> int: + """Return the multiplier for achievement pts. + + (just for display; changing this here won't affect actual rewards) + """ + from ba import _account + val: int = _ba.get_account_misc_read_val('achAwardMult', 5) + assert isinstance(val, int) + if include_pro_bonus and _account.have_pro(): + val *= 2 + return val + + +def get_achievements_for_coop_level(level_name: str) -> List[Achievement]: + """Given a level name, return achievements available for it.""" + + # For the Easy campaign we return achievements for the Default + # campaign too. (want the user to see what achievements are part of the + # level even if they can't unlock them all on easy mode). + return [ + a for a in _ba.app.achievements + if a.level_name in (level_name, level_name.replace('Easy', 'Default')) + ] + + +def _display_next_achievement() -> None: + + # Pull the first achievement off the list and display it, or kill the + # display-timer if the list is empty. + app = _ba.app + if app.achievements_to_display: + try: + ach, sound = app.achievements_to_display.pop(0) + ach.show_completion_banner(sound) + except Exception: + from ba import _error + _error.print_exception("error showing next achievement") + app.achievements_to_display = [] + app.achievement_display_timer = None + else: + app.achievement_display_timer = None + + +class Achievement: + """Represents attributes and state for an individual achievement.""" + + def __init__(self, + name: str, + icon_name: str, + icon_color: Sequence[float], + level_name: str, + award: int, + hard_mode_only: bool = False): + self._name = name + self._icon_name = icon_name + self._icon_color: Sequence[float] = list(icon_color) + [1] + self._level_name = level_name + self._completion_banner_slot: Optional[int] = None + self._award = award + self._hard_mode_only = hard_mode_only + + @property + def name(self) -> str: + """The name of this achievement.""" + return self._name + + @property + def level_name(self) -> str: + """The name of the level this achievement applies to.""" + return self._level_name + + def get_icon_texture(self, complete: bool) -> ba.Texture: + """Return the icon texture to display for this achievement""" + return _ba.gettexture( + self._icon_name if complete else 'achievementEmpty') + + def get_icon_color(self, complete: bool) -> Sequence[float]: + """Return the color tint for this Achievement's icon.""" + if complete: + return self._icon_color + return 1.0, 1.0, 1.0, 0.6 + + @property + def hard_mode_only(self) -> bool: + """Whether this Achievement is only unlockable in hard-mode.""" + return self._hard_mode_only + + @property + def complete(self) -> bool: + """Whether this Achievement is currently complete.""" + val: bool = self._getconfig()['Complete'] + assert isinstance(val, bool) + return val + + def announce_completion(self, sound: bool = True) -> None: + """Kick off an announcement for this achievement's completion.""" + from ba._enums import TimeType + app = _ba.app + + # Even though there are technically achievements when we're not + # signed in, lets not show them (otherwise we tend to get + # confusing 'controller connected' achievements popping up while + # waiting to log in which can be confusing). + if _ba.get_account_state() != 'signed_in': + return + + # If we're being freshly complete, display/report it and whatnot. + if (self, sound) not in app.achievements_to_display: + app.achievements_to_display.append((self, sound)) + + # If there's no achievement display timer going, kick one off + # (if one's already running it will pick this up before it dies). + + # Need to check last time too; its possible our timer wasn't able to + # clear itself if an activity died and took it down with it. + if ((app.achievement_display_timer is None or + _ba.time(TimeType.REAL) - app.last_achievement_display_time > 2.0) + and _ba.getactivity(doraise=False) is not None): + app.achievement_display_timer = _ba.Timer( + 1.0, + _display_next_achievement, + repeat=True, + timetype=TimeType.BASE) + + # Show the first immediately. + _display_next_achievement() + + def set_complete(self, complete: bool = True) -> None: + """Set an achievement's completed state. + + note this only sets local state; use a transaction to + actually award achievements. + """ + config = self._getconfig() + if complete != config['Complete']: + config['Complete'] = complete + + @property + def display_name(self) -> ba.Lstr: + """Return a ba.Lstr for this Achievement's name.""" + from ba._lang import Lstr + name: Union[ba.Lstr, str] + try: + if self._level_name != '': + from ba._campaign import get_campaign + campaignname, campaign_level = self._level_name.split(':') + name = get_campaign(campaignname).get_level( + campaign_level).displayname + else: + name = '' + except Exception: + from ba import _error + name = '' + _error.print_exception() + return Lstr(resource='achievements.' + self._name + '.name', + subs=[('${LEVEL}', name)]) + + @property + def description(self) -> ba.Lstr: + """Get a ba.Lstr for the Achievement's brief description.""" + from ba._lang import Lstr, get_resource + if 'description' in get_resource('achievements')[self._name]: + return Lstr(resource='achievements.' + self._name + '.description') + return Lstr(resource='achievements.' + self._name + '.descriptionFull') + + @property + def description_complete(self) -> ba.Lstr: + """Get a ba.Lstr for the Achievement's description when completed.""" + from ba._lang import Lstr, get_resource + if 'descriptionComplete' in get_resource('achievements')[self._name]: + return Lstr(resource='achievements.' + self._name + + '.descriptionComplete') + return Lstr(resource='achievements.' + self._name + + '.descriptionFullComplete') + + @property + def description_full(self) -> ba.Lstr: + """Get a ba.Lstr for the Achievement's full description.""" + from ba._lang import Lstr + return Lstr(resource='achievements.' + self._name + '.descriptionFull', + subs=[('${LEVEL}', + Lstr(translate=[ + 'coopLevelNames', + ACH_LEVEL_NAMES.get(self._name, '?') + ]))]) + + @property + def description_full_complete(self) -> ba.Lstr: + """Get a ba.Lstr for the Achievement's full desc. when completed.""" + from ba._lang import Lstr + return Lstr(resource='achievements.' + self._name + + '.descriptionFullComplete', + subs=[('${LEVEL}', + Lstr(translate=[ + 'coopLevelNames', + ACH_LEVEL_NAMES.get(self._name, '?') + ]))]) + + def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int: + """Get the ticket award value for this achievement.""" + val: int = (_ba.get_account_misc_read_val('achAward.' + self._name, + self._award) * + _get_ach_mult(include_pro_bonus)) + assert isinstance(val, int) + return val + + @property + def power_ranking_value(self) -> int: + """Get the power-ranking award value for this achievement.""" + val: int = _ba.get_account_misc_read_val( + 'achLeaguePoints.' + self._name, self._award) + assert isinstance(val, int) + return val + + def create_display(self, + x: float, + y: float, + delay: float, + outdelay: float = None, + color: Sequence[float] = None, + style: str = 'post_game') -> List[ba.Actor]: + """Create a display for the Achievement. + + Shows the Achievement icon, name, and description. + """ + # pylint: disable=cyclic-import + from ba._lang import Lstr + from ba._enums import SpecialChar + from bastd.actor.image import Image + from bastd.actor.text import Text + + # Yeah this needs cleaning up. + if style == 'post_game': + in_game_colors = False + in_main_menu = False + h_attach = v_attach = attach = 'center' + elif style == 'in_game': + in_game_colors = True + in_main_menu = False + h_attach = 'left' + v_attach = 'top' + attach = 'topLeft' + elif style == 'news': + in_game_colors = True + in_main_menu = True + h_attach = 'center' + v_attach = 'top' + attach = 'topCenter' + else: + raise Exception('invalid style "' + style + '"') + + # Attempt to determine what campaign we're in + # (so we know whether to show "hard mode only"). + if in_main_menu: + hmo = False + else: + try: + campaign = _ba.getsession().campaign + assert campaign is not None + hmo = (self._hard_mode_only and campaign.name == 'Easy') + except Exception: + from ba import _error + _error.print_exception("unable to determine campaign") + hmo = False + + objs: List[ba.Actor] + + if in_game_colors: + objs = [] + out_delay_fin = (delay + + outdelay) if outdelay is not None else None + if color is not None: + cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], + color[3]) + cl2 = color + else: + cl1 = (1.5, 1.5, 2, 1.0) + cl2 = (0.8, 0.8, 1.0, 1.0) + + if hmo: + cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6) + cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2) + + objs.append( + Image(self.get_icon_texture(False), + host_only=True, + color=cl1, + position=(x - 25, y + 5), + attach=attach, + transition='fade_in', + transition_delay=delay, + vr_depth=4, + transition_out_delay=out_delay_fin, + scale=(40, 40)).autoretain()) + txt = self.display_name + txt_s = 0.85 + txt_max_w = 300 + objs.append( + Text(txt, + host_only=True, + maxwidth=txt_max_w, + position=(x, y + 2), + transition='fade_in', + scale=txt_s, + flatness=0.6, + shadow=0.5, + h_attach=h_attach, + v_attach=v_attach, + color=cl2, + transition_delay=delay + 0.05, + transition_out_delay=out_delay_fin).autoretain()) + txt2_s = 0.62 + txt2_max_w = 400 + objs.append( + Text(self.description_full + if in_main_menu else self.description, + host_only=True, + maxwidth=txt2_max_w, + position=(x, y - 14), + transition='fade_in', + vr_depth=-5, + h_attach=h_attach, + v_attach=v_attach, + scale=txt2_s, + flatness=1.0, + shadow=0.5, + color=cl2, + transition_delay=delay + 0.1, + transition_out_delay=out_delay_fin).autoretain()) + + if hmo: + txtactor = Text( + Lstr(resource='difficultyHardOnlyText'), + host_only=True, + maxwidth=txt2_max_w * 0.7, + position=(x + 60, y + 5), + transition='fade_in', + vr_depth=-5, + h_attach=h_attach, + v_attach=v_attach, + h_align='center', + v_align='center', + scale=txt_s * 0.8, + flatness=1.0, + shadow=0.5, + color=(1, 1, 0.6, 1), + transition_delay=delay + 0.1, + transition_out_delay=out_delay_fin).autoretain() + assert txtactor.node + txtactor.node.rotate = 10 + objs.append(txtactor) + + # Ticket-award. + award_x = -100 + objs.append( + Text(_ba.charstr(SpecialChar.TICKET), + host_only=True, + position=(x + award_x + 33, y + 7), + transition='fade_in', + scale=1.5, + h_attach=h_attach, + v_attach=v_attach, + h_align='center', + v_align='center', + color=(1, 1, 1, 0.2 if hmo else 0.4), + transition_delay=delay + 0.05, + transition_out_delay=out_delay_fin).autoretain()) + objs.append( + Text('+' + str(self.get_award_ticket_value()), + host_only=True, + position=(x + award_x + 28, y + 16), + transition='fade_in', + scale=0.7, + flatness=1, + h_attach=h_attach, + v_attach=v_attach, + h_align='center', + v_align='center', + color=cl2, + transition_delay=delay + 0.05, + transition_out_delay=out_delay_fin).autoretain()) + + else: + complete = self.complete + objs = [] + c_icon = self.get_icon_color(complete) + if hmo and not complete: + c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3) + objs.append( + Image(self.get_icon_texture(complete), + host_only=True, + color=c_icon, + position=(x - 25, y + 5), + attach=attach, + vr_depth=4, + transition='in_right', + transition_delay=delay, + transition_out_delay=None, + scale=(40, 40)).autoretain()) + if complete: + objs.append( + Image(_ba.gettexture('achievementOutline'), + host_only=True, + model_transparent=_ba.getmodel('achievementOutline'), + color=(2, 1.4, 0.4, 1), + vr_depth=8, + position=(x - 25, y + 5), + attach=attach, + transition='in_right', + transition_delay=delay, + transition_out_delay=None, + scale=(40, 40)).autoretain()) + else: + if not complete: + award_x = -100 + objs.append( + Text(_ba.charstr(SpecialChar.TICKET), + host_only=True, + position=(x + award_x + 33, y + 7), + transition='in_right', + scale=1.5, + h_attach=h_attach, + v_attach=v_attach, + h_align='center', + v_align='center', + color=(1, 1, 1, 0.4) if complete else + (1, 1, 1, (0.1 if hmo else 0.2)), + transition_delay=delay + 0.05, + transition_out_delay=None).autoretain()) + objs.append( + Text('+' + str(self.get_award_ticket_value()), + host_only=True, + position=(x + award_x + 28, y + 16), + transition='in_right', + scale=0.7, + flatness=1, + h_attach=h_attach, + v_attach=v_attach, + h_align='center', + v_align='center', + color=((0.8, 0.93, 0.8, 1.0) if complete else + (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))), + transition_delay=delay + 0.05, + transition_out_delay=None).autoretain()) + + # Show 'hard-mode-only' only over incomplete achievements + # when that's the case. + if hmo: + txtactor = Text( + Lstr(resource='difficultyHardOnlyText'), + host_only=True, + maxwidth=300 * 0.7, + position=(x + 60, y + 5), + transition='fade_in', + vr_depth=-5, + h_attach=h_attach, + v_attach=v_attach, + h_align='center', + v_align='center', + scale=0.85 * 0.8, + flatness=1.0, + shadow=0.5, + color=(1, 1, 0.6, 1), + transition_delay=delay + 0.05, + transition_out_delay=None).autoretain() + assert txtactor.node + txtactor.node.rotate = 10 + objs.append(txtactor) + + objs.append( + Text(self.display_name, + host_only=True, + maxwidth=300, + position=(x, y + 2), + transition='in_right', + scale=0.85, + flatness=0.6, + h_attach=h_attach, + v_attach=v_attach, + color=((0.8, 0.93, 0.8, 1.0) if complete else + (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))), + transition_delay=delay + 0.05, + transition_out_delay=None).autoretain()) + objs.append( + Text(self.description_complete + if complete else self.description, + host_only=True, + maxwidth=400, + position=(x, y - 14), + transition='in_right', + vr_depth=-5, + h_attach=h_attach, + v_attach=v_attach, + scale=0.62, + flatness=1.0, + color=((0.6, 0.6, 0.6, 1.0) if complete else + (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))), + transition_delay=delay + 0.1, + transition_out_delay=None).autoretain()) + return objs + + def _getconfig(self) -> Dict[str, Any]: + """ + Return the sub-dict in settings where this achievement's + state is stored, creating it if need be. + """ + val: Dict[str, Any] = (_ba.app.config.setdefault( + 'Achievements', {}).setdefault(self._name, {'Complete': False})) + assert isinstance(val, dict) + return val + + def _remove_banner_slot(self) -> None: + assert self._completion_banner_slot is not None + _ba.app.achievement_completion_banner_slots.remove( + self._completion_banner_slot) + self._completion_banner_slot = None + + def show_completion_banner(self, sound: bool = True) -> None: + """Create the banner/sound for an acquired achievement announcement.""" + from ba import _account + from ba import _gameutils + from bastd.actor.text import Text + from bastd.actor.image import Image + from ba._general import Call, WeakCall + from ba._lang import Lstr + from ba._messages import DieMessage + from ba._enums import TimeType, SpecialChar + app = _ba.app + app.last_achievement_display_time = _ba.time(TimeType.REAL) + + # Just piggy-back onto any current activity + # (should we use the session instead?..) + activity = _ba.getactivity(doraise=False) + + # If this gets called while this achievement is occupying a slot + # already, ignore it. (probably should never happen in real + # life but whatevs). + if self._completion_banner_slot is not None: + return + + if activity is None: + print('show_completion_banner() called with no current activity!') + return + + if sound: + _ba.playsound(_ba.getsound('achievement'), host_only=True) + else: + _ba.timer( + 0.5, Call(_ba.playsound, _ba.getsound('ding'), host_only=True)) + + in_time = 0.300 + out_time = 3.5 + + base_vr_depth = 200 + + # Find the first free slot. + i = 0 + while True: + if i not in app.achievement_completion_banner_slots: + app.achievement_completion_banner_slots.add(i) + self._completion_banner_slot = i + + # Remove us from that slot when we close. + # Use a real-timer in the UI context so the removal runs even + # if our activity/session dies. + with _ba.Context('ui'): + _ba.timer(in_time + out_time, + self._remove_banner_slot, + timetype=TimeType.REAL) + break + i += 1 + assert self._completion_banner_slot is not None + y_offs = 110 * self._completion_banner_slot + objs: List[ba.Actor] = [] + obj = Image(_ba.gettexture('shadow'), + position=(-30, 30 + y_offs), + front=True, + attach='bottomCenter', + transition='in_bottom', + vr_depth=base_vr_depth - 100, + transition_delay=in_time, + transition_out_delay=out_time, + color=(0.0, 0.1, 0, 1), + scale=(1000, 300)).autoretain() + objs.append(obj) + assert obj.node + obj.node.host_only = True + obj = Image(_ba.gettexture('light'), + position=(-180, 60 + y_offs), + front=True, + attach='bottomCenter', + vr_depth=base_vr_depth, + transition='in_bottom', + transition_delay=in_time, + transition_out_delay=out_time, + color=(1.8, 1.8, 1.0, 0.0), + scale=(40, 300)).autoretain() + objs.append(obj) + assert obj.node + obj.node.host_only = True + obj.node.premultiplied = True + combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 2}) + _gameutils.animate( + combine, 'input0', { + in_time: 0, + in_time + 0.4: 30, + in_time + 0.5: 40, + in_time + 0.6: 30, + in_time + 2.0: 0 + }) + _gameutils.animate( + combine, 'input1', { + in_time: 0, + in_time + 0.4: 200, + in_time + 0.5: 500, + in_time + 0.6: 200, + in_time + 2.0: 0 + }) + combine.connectattr('output', obj.node, 'scale') + _gameutils.animate(obj.node, + 'rotate', { + 0: 0.0, + 0.35: 360.0 + }, + loop=True) + obj = Image(self.get_icon_texture(True), + position=(-180, 60 + y_offs), + attach='bottomCenter', + front=True, + vr_depth=base_vr_depth - 10, + transition='in_bottom', + transition_delay=in_time, + transition_out_delay=out_time, + scale=(100, 100)).autoretain() + objs.append(obj) + assert obj.node + obj.node.host_only = True + + # Flash. + color = self.get_icon_color(True) + combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 3}) + keys = { + in_time: 1.0 * color[0], + in_time + 0.4: 1.5 * color[0], + in_time + 0.5: 6.0 * color[0], + in_time + 0.6: 1.5 * color[0], + in_time + 2.0: 1.0 * color[0] + } + _gameutils.animate(combine, 'input0', keys) + keys = { + in_time: 1.0 * color[1], + in_time + 0.4: 1.5 * color[1], + in_time + 0.5: 6.0 * color[1], + in_time + 0.6: 1.5 * color[1], + in_time + 2.0: 1.0 * color[1] + } + _gameutils.animate(combine, 'input1', keys) + keys = { + in_time: 1.0 * color[2], + in_time + 0.4: 1.5 * color[2], + in_time + 0.5: 6.0 * color[2], + in_time + 0.6: 1.5 * color[2], + in_time + 2.0: 1.0 * color[2] + } + _gameutils.animate(combine, 'input2', keys) + combine.connectattr('output', obj.node, 'color') + + obj = Image(_ba.gettexture('achievementOutline'), + model_transparent=_ba.getmodel('achievementOutline'), + position=(-180, 60 + y_offs), + front=True, + attach='bottomCenter', + vr_depth=base_vr_depth, + transition='in_bottom', + transition_delay=in_time, + transition_out_delay=out_time, + scale=(100, 100)).autoretain() + assert obj.node + obj.node.host_only = True + + # Flash. + color = (2, 1.4, 0.4, 1) + combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 3}) + keys = { + in_time: 1.0 * color[0], + in_time + 0.4: 1.5 * color[0], + in_time + 0.5: 6.0 * color[0], + in_time + 0.6: 1.5 * color[0], + in_time + 2.0: 1.0 * color[0] + } + _gameutils.animate(combine, 'input0', keys) + keys = { + in_time: 1.0 * color[1], + in_time + 0.4: 1.5 * color[1], + in_time + 0.5: 6.0 * color[1], + in_time + 0.6: 1.5 * color[1], + in_time + 2.0: 1.0 * color[1] + } + _gameutils.animate(combine, 'input1', keys) + keys = { + in_time: 1.0 * color[2], + in_time + 0.4: 1.5 * color[2], + in_time + 0.5: 6.0 * color[2], + in_time + 0.6: 1.5 * color[2], + in_time + 2.0: 1.0 * color[2] + } + _gameutils.animate(combine, 'input2', keys) + combine.connectattr('output', obj.node, 'color') + objs.append(obj) + + objt = Text(Lstr(value='${A}:', + subs=[('${A}', Lstr(resource='achievementText'))]), + position=(-120, 91 + y_offs), + front=True, + v_attach='bottom', + vr_depth=base_vr_depth - 10, + transition='in_bottom', + flatness=0.5, + transition_delay=in_time, + transition_out_delay=out_time, + color=(1, 1, 1, 0.8), + scale=0.65).autoretain() + objs.append(objt) + assert objt.node + objt.node.host_only = True + + objt = Text(self.display_name, + position=(-120, 50 + y_offs), + front=True, + v_attach='bottom', + transition='in_bottom', + vr_depth=base_vr_depth, + flatness=0.5, + transition_delay=in_time, + transition_out_delay=out_time, + flash=True, + color=(1, 0.8, 0, 1.0), + scale=1.5).autoretain() + objs.append(objt) + assert objt.node + objt.node.host_only = True + + objt = Text(_ba.charstr(SpecialChar.TICKET), + position=(-120 - 170 + 5, 75 + y_offs - 20), + front=True, + v_attach='bottom', + h_align='center', + v_align='center', + transition='in_bottom', + vr_depth=base_vr_depth, + transition_delay=in_time, + transition_out_delay=out_time, + flash=True, + color=(0.5, 0.5, 0.5, 1), + scale=3.0).autoretain() + objs.append(objt) + assert objt.node + objt.node.host_only = True + + objt = Text('+' + str(self.get_award_ticket_value()), + position=(-120 - 180 + 5, 80 + y_offs - 20), + v_attach='bottom', + front=True, + h_align='center', + v_align='center', + transition='in_bottom', + vr_depth=base_vr_depth, + flatness=0.5, + shadow=1.0, + transition_delay=in_time, + transition_out_delay=out_time, + flash=True, + color=(0, 1, 0, 1), + scale=1.5).autoretain() + objs.append(objt) + assert objt.node + objt.node.host_only = True + + # Add the 'x 2' if we've got pro. + if _account.have_pro(): + objt = Text('x 2', + position=(-120 - 180 + 45, 80 + y_offs - 50), + v_attach='bottom', + front=True, + h_align='center', + v_align='center', + transition='in_bottom', + vr_depth=base_vr_depth, + flatness=0.5, + shadow=1.0, + transition_delay=in_time, + transition_out_delay=out_time, + flash=True, + color=(0.4, 0, 1, 1), + scale=0.9).autoretain() + objs.append(objt) + assert objt.node + objt.node.host_only = True + + objt = Text(self.description_complete, + position=(-120, 30 + y_offs), + front=True, + v_attach='bottom', + transition='in_bottom', + vr_depth=base_vr_depth - 10, + flatness=0.5, + transition_delay=in_time, + transition_out_delay=out_time, + color=(1.0, 0.7, 0.5, 1.0), + scale=0.8).autoretain() + objs.append(objt) + assert objt.node + objt.node.host_only = True + + for actor in objs: + _ba.timer(out_time + 1.000, + WeakCall(actor.handlemessage, DieMessage())) + + +def init_achievements() -> None: + """Fill in available achievements.""" + + achs = _ba.app.achievements + + # 5 + achs.append( + Achievement('In Control', 'achievementInControl', (1, 1, 1), '', 5)) + # 15 + achs.append( + Achievement('Sharing is Caring', 'achievementSharingIsCaring', + (1, 1, 1), '', 15)) + # 10 + achs.append( + Achievement('Dual Wielding', 'achievementDualWielding', (1, 1, 1), '', + 10)) + + # 10 + achs.append( + Achievement('Free Loader', 'achievementFreeLoader', (1, 1, 1), '', 10)) + # 20 + achs.append( + Achievement('Team Player', 'achievementTeamPlayer', (1, 1, 1), '', 20)) + + # 5 + achs.append( + Achievement('Onslaught Training Victory', 'achievementOnslaught', + (1, 1, 1), 'Default:Onslaught Training', 5)) + # 5 + achs.append( + Achievement('Off You Go Then', 'achievementOffYouGo', (1, 1.1, 1.3), + 'Default:Onslaught Training', 5)) + # 10 + achs.append( + Achievement('Boxer', + 'achievementBoxer', (1, 0.6, 0.6), + 'Default:Onslaught Training', + 10, + hard_mode_only=True)) + + # 10 + achs.append( + Achievement('Rookie Onslaught Victory', 'achievementOnslaught', + (0.5, 1.4, 0.6), 'Default:Rookie Onslaught', 10)) + # 10 + achs.append( + Achievement('Mine Games', 'achievementMine', (1, 1, 1.4), + 'Default:Rookie Onslaught', 10)) + # 15 + achs.append( + Achievement('Flawless Victory', + 'achievementFlawlessVictory', (1, 1, 1), + 'Default:Rookie Onslaught', + 15, + hard_mode_only=True)) + + # 10 + achs.append( + Achievement('Rookie Football Victory', 'achievementFootballVictory', + (1.0, 1, 0.6), 'Default:Rookie Football', 10)) + # 10 + achs.append( + Achievement('Super Punch', 'achievementSuperPunch', (1, 1, 1.8), + 'Default:Rookie Football', 10)) + # 15 + achs.append( + Achievement('Rookie Football Shutout', + 'achievementFootballShutout', (1, 1, 1), + 'Default:Rookie Football', + 15, + hard_mode_only=True)) + + # 15 + achs.append( + Achievement('Pro Onslaught Victory', 'achievementOnslaught', + (0.3, 1, 2.0), 'Default:Pro Onslaught', 15)) + # 15 + achs.append( + Achievement('Boom Goes the Dynamite', 'achievementTNT', + (1.4, 1.2, 0.8), 'Default:Pro Onslaught', 15)) + # 20 + achs.append( + Achievement('Pro Boxer', + 'achievementBoxer', (2, 2, 0), + 'Default:Pro Onslaught', + 20, + hard_mode_only=True)) + + # 15 + achs.append( + Achievement('Pro Football Victory', 'achievementFootballVictory', + (1.3, 1.3, 2.0), 'Default:Pro Football', 15)) + # 15 + achs.append( + Achievement('Super Mega Punch', 'achievementSuperPunch', (2, 1, 0.6), + 'Default:Pro Football', 15)) + # 20 + achs.append( + Achievement('Pro Football Shutout', + 'achievementFootballShutout', (0.7, 0.7, 2.0), + 'Default:Pro Football', + 20, + hard_mode_only=True)) + + # 15 + achs.append( + Achievement('Pro Runaround Victory', 'achievementRunaround', (1, 1, 1), + 'Default:Pro Runaround', 15)) + # 20 + achs.append( + Achievement('Precision Bombing', + 'achievementCrossHair', (1, 1, 1.3), + 'Default:Pro Runaround', + 20, + hard_mode_only=True)) + # 25 + achs.append( + Achievement('The Wall', + 'achievementWall', (1, 0.7, 0.7), + 'Default:Pro Runaround', + 25, + hard_mode_only=True)) + + # 30 + achs.append( + Achievement('Uber Onslaught Victory', 'achievementOnslaught', + (2, 2, 1), 'Default:Uber Onslaught', 30)) + # 30 + achs.append( + Achievement('Gold Miner', + 'achievementMine', (2, 1.6, 0.2), + 'Default:Uber Onslaught', + 30, + hard_mode_only=True)) + # 30 + achs.append( + Achievement('TNT Terror', + 'achievementTNT', (2, 1.8, 0.3), + 'Default:Uber Onslaught', + 30, + hard_mode_only=True)) + + # 30 + achs.append( + Achievement('Uber Football Victory', 'achievementFootballVictory', + (1.8, 1.4, 0.3), 'Default:Uber Football', 30)) + # 30 + achs.append( + Achievement('Got the Moves', + 'achievementGotTheMoves', (2, 1, 0), + 'Default:Uber Football', + 30, + hard_mode_only=True)) + # 40 + achs.append( + Achievement('Uber Football Shutout', + 'achievementFootballShutout', (2, 2, 0), + 'Default:Uber Football', + 40, + hard_mode_only=True)) + + # 30 + achs.append( + Achievement('Uber Runaround Victory', 'achievementRunaround', + (1.5, 1.2, 0.2), 'Default:Uber Runaround', 30)) + # 40 + achs.append( + Achievement('The Great Wall', + 'achievementWall', (2, 1.7, 0.4), + 'Default:Uber Runaround', + 40, + hard_mode_only=True)) + # 40 + achs.append( + Achievement('Stayin\' Alive', + 'achievementStayinAlive', (2, 2, 1), + 'Default:Uber Runaround', + 40, + hard_mode_only=True)) + + # 20 + achs.append( + Achievement('Last Stand Master', + 'achievementMedalSmall', (2, 1.5, 0.3), + 'Default:The Last Stand', + 20, + hard_mode_only=True)) + # 40 + achs.append( + Achievement('Last Stand Wizard', + 'achievementMedalMedium', (2, 1.5, 0.3), + 'Default:The Last Stand', + 40, + hard_mode_only=True)) + # 60 + achs.append( + Achievement('Last Stand God', + 'achievementMedalLarge', (2, 1.5, 0.3), + 'Default:The Last Stand', + 60, + hard_mode_only=True)) + + # 5 + achs.append( + Achievement('Onslaught Master', 'achievementMedalSmall', (0.7, 1, 0.7), + 'Challenges:Infinite Onslaught', 5)) + # 15 + achs.append( + Achievement('Onslaught Wizard', 'achievementMedalMedium', + (0.7, 1.0, 0.7), 'Challenges:Infinite Onslaught', 15)) + # 30 + achs.append( + Achievement('Onslaught God', 'achievementMedalLarge', (0.7, 1.0, 0.7), + 'Challenges:Infinite Onslaught', 30)) + + # 5 + achs.append( + Achievement('Runaround Master', 'achievementMedalSmall', + (1.0, 1.0, 1.2), 'Challenges:Infinite Runaround', 5)) + # 15 + achs.append( + Achievement('Runaround Wizard', 'achievementMedalMedium', + (1.0, 1.0, 1.2), 'Challenges:Infinite Runaround', 15)) + # 30 + achs.append( + Achievement('Runaround God', 'achievementMedalLarge', (1.0, 1.0, 1.2), + 'Challenges:Infinite Runaround', 30)) + + +def _test() -> None: + """For testing achievement animations.""" + from ba._enums import TimeType + + def testcall1() -> None: + app = _ba.app + app.achievements[0].announce_completion() + app.achievements[1].announce_completion() + app.achievements[2].announce_completion() + + def testcall2() -> None: + app = _ba.app + app.achievements[3].announce_completion() + app.achievements[4].announce_completion() + app.achievements[5].announce_completion() + + _ba.timer(3.0, testcall1, timetype=TimeType.BASE) + _ba.timer(7.0, testcall2, timetype=TimeType.BASE) diff --git a/assets/src/data/scripts/ba/_activity.py b/assets/src/data/scripts/ba/_activity.py new file mode 100644 index 00000000..7e7d1f62 --- /dev/null +++ b/assets/src/data/scripts/ba/_activity.py @@ -0,0 +1,638 @@ +"""Defines Activity class.""" +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +import _ba +from ba._dep import InstancedDepComponent + +if TYPE_CHECKING: + from weakref import ReferenceType + from typing import Optional, Type, Any, Dict, List + import ba + from bastd.actor.respawnicon import RespawnIcon + + +class Activity(InstancedDepComponent): + """Units of execution wrangled by a ba.Session. + + Category: Gameplay Classes + + Examples of Activities include games, score-screens, cutscenes, etc. + A ba.Session has one 'current' Activity at any time, though their existence + can overlap during transitions. + + Attributes: + + settings + The settings dict passed in when the activity was made. + + teams + The list of ba.Teams in the Activity. This gets populated just before + before on_begin() is called and is updated automatically as players + join or leave the game. (at least in free-for-all mode where every + player gets their own team; in teams mode there are always 2 teams + regardless of the player count). + + players + The list of ba.Players in the Activity. This gets populated just + before on_begin() is called and is updated automatically as players + join or leave the game. + """ + + # pylint: disable=too-many-public-methods + + # Annotating attr types at the class level lets us introspect them. + settings: Dict[str, Any] + teams: List[ba.Team] + players: List[_ba.Player] + + def __init__(self, settings: Dict[str, Any]): + """Creates an activity in the current ba.Session. + + The activity will not be actually run until ba.Session.set_activity() + is called. 'settings' should be a dict of key/value pairs specific + to the activity. + + Activities should preload as much of their media/etc as possible in + their constructor, but none of it should actually be used until they + are transitioned in. + """ + super().__init__() + + # FIXME: Relocate this stuff. + self.sharedobjs: Dict[str, Any] = {} + self.paused_text: Optional[ba.Actor] = None + self.spaz_respawn_icons_right: Dict[int, RespawnIcon] + + # Create our internal engine data. + self._activity_data = _ba.register_activity(self) + + session = _ba.getsession() + if session is None: + raise Exception("No current session") + self._session = weakref.ref(session) + + # Preloaded data for actors, maps, etc; indexed by type. + self.preloads: Dict[Type, Any] = {} + + if not isinstance(settings, dict): + raise Exception("expected dict for settings") + if _ba.getactivity(doraise=False) is not self: + raise Exception('invalid context state') + + self.settings = settings + + self._has_transitioned_in = False + self._has_begun = False + self._has_ended = False + self._should_end_immediately = False + self._should_end_immediately_results: ( + Optional[ba.TeamGameResults]) = None + self._should_end_immediately_delay = 0.0 + self._called_activity_on_transition_in = False + self._called_activity_on_begin = False + + self._activity_death_check_timer: Optional[ba.Timer] = None + self._expired = False + + # Whether to print every time a player dies. This can be pertinent + # in games such as Death-Match but can be annoying in games where it + # doesn't matter. + self.announce_player_deaths = False + + # Joining activities are for waiting for initial player joins. + # They are treated slightly differently than regular activities, + # mainly in that all players are passed to the activity at once + # instead of as each joins. + self.is_joining_activity = False + + # Whether game-time should still progress when in menus/etc. + self.allow_pausing = False + + # Whether idle players can potentially be kicked (should not happen in + # menus/etc). + self.allow_kick_idle_players = True + + # In vr mode, this determines whether overlay nodes (text, images, etc) + # are created at a fixed position in space or one that moves based on + # the current map. Generally this should be on for games and off for + # transitions/score-screens/etc. that persist between maps. + self.use_fixed_vr_overlay = False + + # If True, runs in slow motion and turns down sound pitch. + self.slow_motion = False + + # Set this to True to inherit slow motion setting from previous + # activity (useful for transitions to avoid hitches). + self.inherits_slow_motion = False + + # Set this to True to keep playing the music from the previous activity + # (without even restarting it). + self.inherits_music = False + + # Set this to true to inherit VR camera offsets from the previous + # activity (useful for preventing sporadic camera movement + # during transitions). + self.inherits_camera_vr_offset = False + + # Set this to true to inherit (non-fixed) VR overlay positioning from + # the previous activity (useful for prevent sporadic overlay jostling + # during transitions). + self.inherits_vr_overlay_center = False + + # Set this to true to inherit screen tint/vignette colors from the + # previous activity (useful to prevent sudden color changes during + # transitions). + self.inherits_tint = False + + # If the activity fades or transitions in, it should set the length of + # time here so that previous activities will be kept alive for that + # long (avoiding 'holes' in the screen) + # This value is given in real-time seconds. + self.transition_time = 0.0 + + # Is it ok to show an ad after this activity ends before showing + # the next activity? + self.can_show_ad_on_death = False + + # This gets set once another activity has begun transitioning in but + # before this one is killed. The on_transition_out() method is also + # called at this time. Make sure to not assign player inputs, + # change music, or anything else with global implications once this + # happens. + self._transitioning_out = False + + # A handy place to put most actors; this list is pruned of dead + # actors regularly and these actors are insta-killed as the activity + # is dying. + self._actor_refs: List[ba.Actor] = [] + self._actor_weak_refs: List[ReferenceType[ba.Actor]] = [] + self._last_dead_object_prune_time = _ba.time() + + # This stuff gets filled in just before on_begin() is called. + self.teams = [] + self.players = [] + self._stats: Optional[ba.Stats] = None + + self.lobby = None + self._prune_dead_objects_timer: Optional[ba.Timer] = None + + @property + def stats(self) -> ba.Stats: + """The stats instance accessible while the activity is running. + + If access is attempted before or after, raises a ba.NotFoundError. + """ + if self._stats is None: + from ba._error import NotFoundError + raise NotFoundError() + return self._stats + + def on_expire(self) -> None: + """Called when your activity is being expired. + + If your activity has created anything explicitly that may be retaining + a strong reference to the activity and preventing it from dying, you + should clear that out here. From this point on your activity's sole + purpose in life is to hit zero references and die so the next activity + can begin. + """ + + def is_expired(self) -> bool: + """Return whether the activity is expired. + + An activity is set as expired when shutting down. + At this point no new nodes, timers, etc should be made, + run, etc, and the activity should be considered a 'zombie'. + """ + return self._expired + + def __del__(self) -> None: + + from ba._apputils import garbage_collect, call_after_ad + + # If the activity has been run then we should have already cleaned + # it up, but we still need to run expire calls for un-run activities. + if not self._expired: + with _ba.Context('empty'): + self._expire() + + # Since we're mostly between activities at this point, lets run a cycle + # of garbage collection; hopefully it won't cause hitches here. + garbage_collect(session_end=False) + + # Now that our object is officially gonna be dead, tell the session it + # can fire up the next activity. + if self._transitioning_out: + session = self._session() + if session is not None: + with _ba.Context(session): + if self.can_show_ad_on_death: + call_after_ad(session.begin_next_activity) + else: + _ba.pushcall(session.begin_next_activity) + + def set_has_ended(self, val: bool) -> None: + """(internal)""" + self._has_ended = val + + def set_immediate_end(self, results: ba.TeamGameResults, delay: float, + force: bool) -> None: + """Set the activity to die immediately after beginning. + + (internal) + """ + if self.has_begun(): + raise Exception('This should only be called for Activities' + 'that have not yet begun.') + if not self._should_end_immediately or force: + self._should_end_immediately = True + self._should_end_immediately_results = results + self._should_end_immediately_delay = delay + + def _get_player_icon(self, player: ba.Player) -> Dict[str, Any]: + + # Do we want to cache these somehow? + info = player.get_icon_info() + return { + 'texture': _ba.gettexture(info['texture']), + 'tint_texture': _ba.gettexture(info['tint_texture']), + 'tint_color': info['tint_color'], + 'tint2_color': info['tint2_color'] + } + + def _destroy(self) -> None: + from ba._general import Call + from ba._enums import TimeType + + # Create a real-timer that watches a weak-ref of this activity + # and reports any lingering references keeping it alive. + # We store the timer on the activity so as soon as the activity dies + # it gets cleaned up. + with _ba.Context('ui'): + ref = weakref.ref(self) + self._activity_death_check_timer = _ba.Timer( + 5.0, + Call(self._check_activity_death, ref, [0]), + repeat=True, + timetype=TimeType.REAL) + + # Run _expire in an empty context; nothing should be happening in + # there except deleting things which requires no context. + # (plus, _expire() runs in the destructor for un-run activities + # and we can't properly provide context in that situation anyway; might + # as well be consistent). + if not self._expired: + with _ba.Context('empty'): + self._expire() + else: + raise Exception("_destroy() called multiple times") + + @classmethod + def _check_activity_death(cls, activity_ref: ReferenceType[Activity], + counter: List[int]) -> None: + """Sanity check to make sure an Activity was destroyed properly. + + Receives a weakref to a ba.Activity which should have torn itself + down due to no longer being referenced anywhere. Will complain + and/or print debugging info if the Activity still exists. + """ + try: + import gc + import types + activity = activity_ref() + print('ERROR: Activity is not dying when expected:', activity, + '(warning ' + str(counter[0] + 1) + ')') + print('This means something is still strong-referencing it.') + counter[0] += 1 + + # FIXME: Running the code below shows us references but winds up + # keeping the object alive; need to figure out why. + # For now we just print refs if the count gets to 3, and then we + # kill the app at 4 so it doesn't matter anyway. + if counter[0] == 3: + print('Activity references for', activity, ':') + refs = list(gc.get_referrers(activity)) + i = 1 + for ref in refs: + if isinstance(ref, types.FrameType): + continue + print(' reference', i, ':', ref) + i += 1 + if counter[0] == 4: + print('Killing app due to stuck activity... :-(') + _ba.quit() + + except Exception: + from ba import _error + _error.print_exception('exception on _check_activity_death:') + + def _expire(self) -> None: + from ba import _error + self._expired = True + + # Do some default cleanup. + try: + try: + self.on_expire() + except Exception: + _error.print_exception('Error in activity on_expire()', self) + + # Send finalize notices to all remaining actors. + for actor_ref in self._actor_weak_refs: + try: + actor = actor_ref() + if actor is not None: + actor.on_expire() + except Exception: + _error.print_exception( + 'Exception on ba.Activity._expire()' + ' in actor on_expire():', actor_ref()) + + # Reset all players. + # (releases any attached actors, clears game-data, etc) + for player in self.players: + if player: + try: + player.reset() + player.set_activity(None) + except Exception: + _error.print_exception( + 'Exception on ba.Activity._expire()' + ' resetting player:', player) + + # Ditto with teams. + for team in self.teams: + try: + team.reset() + except Exception: + _error.print_exception( + 'Exception on ba.Activity._expire() resetting team:', + team) + + except Exception: + _error.print_exception('Exception during ba.Activity._expire():') + + # Regardless of what happened here, we want to destroy our data, as + # our activity might not go down if we don't. This will kill all + # Timers, Nodes, etc, which should clear up any remaining refs to our + # Actors and Activity and allow us to die peacefully. + try: + self._activity_data.destroy() + except Exception: + _error.print_exception( + 'Exception during ba.Activity._expire() destroying data:') + + def _prune_dead_objects(self) -> None: + self._actor_refs = [a for a in self._actor_refs if a] + self._actor_weak_refs = [a for a in self._actor_weak_refs if a()] + self._last_dead_object_prune_time = _ba.time() + + def retain_actor(self, actor: ba.Actor) -> None: + """Add a strong-reference to a ba.Actor to this Activity. + + The reference will be lazily released once ba.Actor.exists() + returns False for the Actor. The ba.Actor.autoretain() method + is a convenient way to access this same functionality. + """ + from ba import _actor as bsactor + from ba import _error + if not isinstance(actor, bsactor.Actor): + raise Exception("non-actor passed to _retain_actor") + if (self.has_transitioned_in() + and _ba.time() - self._last_dead_object_prune_time > 10.0): + _error.print_error('it looks like nodes/actors are not' + ' being pruned in your activity;' + ' did you call Activity.on_transition_in()' + ' from your subclass?; ' + str(self) + + ' (loc. a)') + self._actor_refs.append(actor) + + def add_actor_weak_ref(self, actor: ba.Actor) -> None: + """Add a weak-reference to a ba.Actor to the ba.Activity. + + (called by the ba.Actor base class) + """ + from ba import _actor as bsactor + from ba import _error + if not isinstance(actor, bsactor.Actor): + raise Exception("non-actor passed to _add_actor_weak_ref") + if (self.has_transitioned_in() + and _ba.time() - self._last_dead_object_prune_time > 10.0): + _error.print_error('it looks like nodes/actors are ' + 'not being pruned in your activity;' + ' did you call Activity.on_transition_in()' + ' from your subclass?; ' + str(self) + + ' (loc. b)') + self._actor_weak_refs.append(weakref.ref(actor)) + + @property + def session(self) -> ba.Session: + """The ba.Session this ba.Activity belongs go. + + Raises a ba.SessionNotFoundError if the Session no longer exists. + """ + session = self._session() + if session is None: + from ba._error import SessionNotFoundError + raise SessionNotFoundError() + return session + + def on_player_join(self, player: ba.Player) -> None: + """Called when a new ba.Player has joined the Activity. + + (including the initial set of Players) + """ + + def on_player_leave(self, player: ba.Player) -> None: + """Called when a ba.Player is leaving the Activity.""" + + def on_team_join(self, team: ba.Team) -> None: + """Called when a new ba.Team joins the Activity. + + (including the initial set of Teams) + """ + + def on_team_leave(self, team: ba.Team) -> None: + """Called when a ba.Team leaves the Activity.""" + + def on_transition_in(self) -> None: + """Called when the Activity is first becoming visible. + + Upon this call, the Activity should fade in backgrounds, + start playing music, etc. It does not yet have access to ba.Players + or ba.Teams, however. They remain owned by the previous Activity + up until ba.Activity.on_begin() is called. + """ + from ba._general import WeakCall + + self._called_activity_on_transition_in = True + + # Start pruning our transient actors periodically. + self._prune_dead_objects_timer = _ba.Timer( + 5.17, WeakCall(self._prune_dead_objects), repeat=True) + self._prune_dead_objects() + + # Also start our low-level scene-graph running. + self._activity_data.start() + + def on_transition_out(self) -> None: + """Called when your activity begins transitioning out. + + Note that this may happen at any time even if finish() has not been + called. + """ + + def on_begin(self) -> None: + """Called once the previous ba.Activity has finished transitioning out. + + At this point the activity's initial players and teams are filled in + and it should begin its actual game logic. + """ + self._called_activity_on_begin = True + + def handlemessage(self, msg: Any) -> Any: + """General message handling; can be passed any message object.""" + + def end(self, results: Any = None, delay: float = 0.0, + force: bool = False) -> None: + """Commences Activity shutdown and delivers results to the ba.Session. + + 'delay' is the time delay before the Activity actually ends + (in seconds). Further calls to end() will be ignored up until + this time, unless 'force' is True, in which case the new results + will replace the old. + """ + + # Ask the session to end us. + self.session.end_activity(self, results, delay, force) + + def has_transitioned_in(self) -> bool: + """Return whether on_transition_in() has been called.""" + return self._has_transitioned_in + + def has_begun(self) -> bool: + """Return whether on_begin() has been called.""" + return self._has_begun + + def has_ended(self) -> bool: + """Return whether the activity has commenced ending.""" + return self._has_ended + + def is_transitioning_out(self) -> bool: + """Return whether on_transition_out() has been called.""" + return self._transitioning_out + + def start_transition_in(self) -> None: + """Called by Session to kick of transition-in. + + (internal) + """ + assert not self._has_transitioned_in + self._has_transitioned_in = True + self.on_transition_in() + + def create_player_node(self, player: ba.Player) -> ba.Node: + """Create the 'player' node associated with the provided ba.Player.""" + from ba import _actor + with _ba.Context(self): + node = _ba.newnode('player', attrs={'playerID': player.get_id()}) + # FIXME: Should add a dedicated slot for this on ba.Player + # instead of cluttering up their gamedata dict. + player.gamedata['_playernode'] = _actor.Actor(node) + return node + + def begin(self, session: ba.Session) -> None: + """Begin the activity. (should only be called by Session). + + (internal)""" + + # pylint: disable=too-many-branches + from ba import _error + + if self._has_begun: + _error.print_error("_begin called twice; this shouldn't happen") + return + + self._stats = session.stats + + # Operate on the subset of session players who have passed team/char + # selection. + players = [] + chooser_players = [] + for player in session.players: + assert player # should we ever have invalid players?.. + if player: + try: + team: Optional[ba.Team] = player.team + except _error.TeamNotFoundError: + team = None + + if team is not None: + player.reset_input() + players.append(player) + else: + # Simply ignore players sitting in the lobby. + # (though this technically shouldn't happen anymore since + # choosers now get cleared when starting new activities.) + print('unexpected: got no-team player in _begin') + chooser_players.append(player) + else: + _error.print_error( + "got nonexistent player in Activity._begin()") + + # Add teams in one by one and send team-joined messages for each. + for team in session.teams: + if team in self.teams: + raise Exception("Duplicate Team Entry") + self.teams.append(team) + try: + with _ba.Context(self): + self.on_team_join(team) + except Exception: + _error.print_exception('Error in on_team_join for', self) + + # Now add each player to the activity and to its team's list, + # and send player-joined messages for each. + for player in players: + self.players.append(player) + player.team.players.append(player) + player.set_activity(self) + pnode = self.create_player_node(player) + player.set_node(pnode) + try: + with _ba.Context(self): + self.on_player_join(player) + except Exception: + _error.print_exception('Error in on_player_join for', self) + + with _ba.Context(self): + # And finally tell the game to start. + self._has_begun = True + self.on_begin() + + # Make sure that ba.Activity.on_transition_in() got called + # at some point. + if not self._called_activity_on_transition_in: + _error.print_error( + "ba.Activity.on_transition_in() never got called for " + + str(self) + "; did you forget to call it" + " in your on_transition_in override?") + + # Make sure that ba.Activity.on_begin() got called at some point. + if not self._called_activity_on_begin: + _error.print_error( + "ba.Activity.on_begin() never got called for " + str(self) + + "; did you forget to call it in your on_begin override?") + + # If the whole session wants to die and was waiting on us, can get + # that going now. + if session.wants_to_end: + session.launch_end_session_activity() + else: + # Otherwise, if we've already been told to die, do so now. + if self._should_end_immediately: + self.end(self._should_end_immediately_results, + self._should_end_immediately_delay) diff --git a/assets/src/data/scripts/ba/_activitytypes.py b/assets/src/data/scripts/ba/_activitytypes.py new file mode 100644 index 00000000..1e604b6b --- /dev/null +++ b/assets/src/data/scripts/ba/_activitytypes.py @@ -0,0 +1,252 @@ +"""Some handy base class and special purpose Activity types.""" +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import _ba +from ba import _activity + +if TYPE_CHECKING: + from typing import Any, Dict, Optional + import ba + from ba._lobby import JoinInfo + + +class EndSessionActivity(_activity.Activity): + """Special ba.Activity to fade out and end the current ba.Session.""" + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings) + + # Keeps prev activity alive while we fadeout. + self.transition_time = 0.25 + self.inherits_tint = True + self.inherits_slow_motion = True + self.inherits_camera_vr_offset = True + self.inherits_vr_overlay_center = True + + def on_transition_in(self) -> None: + super().on_transition_in() + _ba.fade_screen(False) + _ba.lock_all_input() + + def on_begin(self) -> None: + # pylint: disable=cyclic-import + from bastd.mainmenu import MainMenuSession + from ba._apputils import call_after_ad + from ba._general import Call + super().on_begin() + _ba.unlock_all_input() + call_after_ad(Call(_ba.new_host_session, MainMenuSession)) + + +class JoiningActivity(_activity.Activity): + """Standard activity for waiting for players to join. + + It shows tips and other info and waits for all players to check ready. + """ + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings) + + # This activity is a special 'joiner' activity. + # It will get shut down as soon as all players have checked ready. + self.is_joining_activity = True + + # Players may be idle waiting for joiners; lets not kick them for it. + self.allow_kick_idle_players = False + + # In vr mode we don't want stuff moving around. + self.use_fixed_vr_overlay = True + + self._background: Optional[ba.Actor] = None + self._tips_text: Optional[ba.Actor] = None + self._join_info: Optional[JoinInfo] = None + + def on_transition_in(self) -> None: + # pylint: disable=cyclic-import + from bastd.actor.tipstext import TipsText + from bastd.actor.background import Background + from ba import _music + super().on_transition_in() + self._background = Background(fade_time=0.5, + start_faded=True, + show_logo=True) + self._tips_text = TipsText() + _music.setmusic('CharSelect') + self._join_info = self.session.lobby.create_join_info() + _ba.set_analytics_screen('Joining Screen') + + +class TransitionActivity(_activity.Activity): + """A simple overlay fade out/in. + + Useful as a bare minimum transition between two level based activities. + """ + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings) + + # Keep prev activity alive while we fade in. + self.transition_time = 0.5 + self.inherits_slow_motion = True # Don't change. + self.inherits_tint = True # Don't change. + self.inherits_camera_vr_offset = True # Don't change. + self.inherits_vr_overlay_center = True + self.use_fixed_vr_overlay = True + self._background: Optional[ba.Actor] = None + + def on_transition_in(self) -> None: + # pylint: disable=cyclic-import + from bastd.actor import background # FIXME: Don't use bastd from ba. + super().on_transition_in() + self._background = background.Background(fade_time=0.5, + start_faded=False, + show_logo=False) + + def on_begin(self) -> None: + super().on_begin() + + # Die almost immediately. + _ba.timer(0.1, self.end) + + +class ScoreScreenActivity(_activity.Activity): + """A standard score screen that fades in and shows stuff for a while. + + After a specified delay, player input is assigned to end the activity. + """ + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings) + self.transition_time = 0.5 + self._birth_time = _ba.time() + self._min_view_time = 5.0 + self.inherits_tint = True + self.inherits_camera_vr_offset = True + self.use_fixed_vr_overlay = True + self._allow_server_restart = False + self._background: Optional[ba.Actor] = None + self._tips_text: Optional[ba.Actor] = None + self._kicked_off_server_shutdown = False + self._kicked_off_server_restart = False + + def on_player_join(self, player: ba.Player) -> None: + from ba import _general + super().on_player_join(player) + time_till_assign = max( + 0, self._birth_time + self._min_view_time - _ba.time()) + + # If we're still kicking at the end of our assign-delay, assign this + # guy's input to trigger us. + _ba.timer(time_till_assign, _general.WeakCall(self._safe_assign, + player)) + + def on_transition_in(self, + music: Optional[str] = 'Scores', + show_tips: bool = True) -> None: + # FIXME: Unify args. + # pylint: disable=arguments-differ + # pylint: disable=cyclic-import + from bastd.actor import tipstext + from bastd.actor import background + from ba import _music as bs_music + super().on_transition_in() + self._background = background.Background(fade_time=0.5, + start_faded=False, + show_logo=True) + if show_tips: + self._tips_text = tipstext.TipsText() + bs_music.setmusic(music) + + def on_begin(self, custom_continue_message: ba.Lstr = None) -> None: + # FIXME: Unify args. + # pylint: disable=arguments-differ + # pylint: disable=cyclic-import + from bastd.actor import text + from ba import _lang + super().on_begin() + + # Pop up a 'press any button to continue' statement after our + # min-view-time show a 'press any button to continue..' + # thing after a bit. + if _ba.app.interface_type == 'large': + # FIXME: Need a better way to determine whether we've probably + # got a keyboard. + sval = _lang.Lstr(resource='pressAnyKeyButtonText') + else: + sval = _lang.Lstr(resource='pressAnyButtonText') + + text.Text(custom_continue_message if custom_continue_message else sval, + v_attach='bottom', + h_align='center', + flash=True, + vr_depth=50, + position=(0, 10), + scale=0.8, + color=(0.5, 0.7, 0.5, 0.5), + transition='in_bottom_slow', + transition_delay=self._min_view_time).autoretain() + + def _player_press(self) -> None: + + # If we're running in server-mode and it wants to shut down + # or restart, this is a good place to do it + if self._handle_server_restarts(): + return + self.end() + + def _safe_assign(self, player: ba.Player) -> None: + + # Just to be extra careful, don't assign if we're transitioning out. + # (though theoretically that would be ok). + if not self.is_transitioning_out() and player: + player.assign_input_call( + ('jumpPress', 'punchPress', 'bombPress', 'pickUpPress'), + self._player_press) + + def _handle_server_restarts(self) -> bool: + """Handle automatic restarts/shutdowns in server mode. + + Returns True if an action was taken; otherwise default action + should occur (starting next round, etc). + """ + # pylint: disable=cyclic-import + + # FIXME: Move server stuff to its own module. + if self._allow_server_restart and _ba.app.server_config_dirty: + from ba import _server + from ba._lang import Lstr + from ba._general import Call + from ba._enums import TimeType + if _ba.app.server_config.get('quit', False): + if not self._kicked_off_server_shutdown: + if _ba.app.server_config.get( + 'quit_reason') == 'restarting': + # FIXME: Should add a server-screen-message call + # or something. + _ba.chat_message( + Lstr(resource='internal.serverRestartingText'). + evaluate()) + print(('Exiting for server-restart at ' + + time.strftime('%c'))) + else: + print(('Exiting for server-shutdown at ' + + time.strftime('%c'))) + with _ba.Context('ui'): + _ba.timer(2.0, _ba.quit, timetype=TimeType.REAL) + self._kicked_off_server_shutdown = True + return True + else: + if not self._kicked_off_server_restart: + print(('Running updated server config at ' + + time.strftime('%c'))) + with _ba.Context('ui'): + _ba.timer(1.0, + Call(_ba.pushcall, + _server.launch_server_session), + timetype=TimeType.REAL) + self._kicked_off_server_restart = True + return True + return False diff --git a/assets/src/data/scripts/ba/_actor.py b/assets/src/data/scripts/ba/_actor.py new file mode 100644 index 00000000..0866a8b3 --- /dev/null +++ b/assets/src/data/scripts/ba/_actor.py @@ -0,0 +1,211 @@ +"""Defines base Actor class.""" + +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING, TypeVar + +import _ba + +if TYPE_CHECKING: + from typing import Any, Optional + import ba + +T = TypeVar('T', bound='Actor') + + +class Actor: + """High level logical entities in a game/activity. + + category: Gameplay Classes + + Actors act as controllers, combining some number of ba.Nodes, + ba.Textures, ba.Sounds, etc. into one cohesive unit. + + Some example actors include ba.Bomb, ba.Flag, and ba.Spaz. + + One key feature of Actors is that they generally 'die' + (killing off or transitioning out their nodes) when the last Python + reference to them disappears, so you can use logic such as: + + # create a flag Actor in our game activity + self.flag = ba.Flag(position=(0, 10, 0)) + + # later, destroy the flag.. + # (provided nothing else is holding a reference to it) + # we could also just assign a new flag to this value. + # either way, the old flag disappears. + self.flag = None + + This is in contrast to the behavior of the more low level ba.Nodes, + which are always explicitly created and destroyed and don't care + how many Python references to them exist. + + Note, however, that you can use the ba.Actor.autoretain() method + if you want an Actor to stick around until explicitly killed + regardless of references. + + Another key feature of ba.Actor is its handlemessage() method, which + takes a single arbitrary object as an argument. This provides a safe way + to communicate between ba.Actor, ba.Activity, ba.Session, and any other + class providing a handlemessage() method. The most universally handled + message type for actors is the ba.DieMessage. + + # another way to kill the flag from the example above: + # we can safely call this on any type with a 'handlemessage' method + # (though its not guaranteed to always have a meaningful effect) + # in this case the Actor instance will still be around, but its exists() + # and is_alive() methods will both return False + self.flag.handlemessage(ba.DieMessage()) + """ + + def __init__(self, node: ba.Node = None): + """Instantiates an Actor in the current ba.Activity. + + If 'node' is provided, it is stored as the 'node' attribute + and the default ba.Actor.handlemessage() and ba.Actor.exists() + implementations will apply to it. This allows the creation of + simple node-wrapping Actors without having to create a new subclass. + """ + self.node: Optional[ba.Node] = None + activity = _ba.getactivity() + self._activity = weakref.ref(activity) + activity.add_actor_weak_ref(self) + if node is not None: + self.node = node + + def __del__(self) -> None: + try: + # Non-expired Actors send themselves a DieMessage when going down. + # That way we can treat DieMessage handling as the single + # point-of-action for death. + if not self.is_expired(): + from ba import _messages + self.handlemessage(_messages.DieMessage()) + except Exception: + from ba import _error + _error.print_exception('exception in ba.Actor.__del__() for', self) + + def handlemessage(self, msg: Any) -> Any: + """General message handling; can be passed any message object. + + The default implementation will handle ba.DieMessages by + calling self.node.delete() if self contains a 'node' attribute. + """ + from ba import _messages + from ba import _error + if isinstance(msg, _messages.DieMessage): + node = getattr(self, 'node', None) + if node is not None: + node.delete() + return None + return _error.UNHANDLED + + def _handlemessage_sanity_check(self) -> None: + if self.is_expired(): + from ba import _error + _error.print_error( + f'handlemessage called on expired actor: {self}') + + def autoretain(self: T) -> T: + """Keep this Actor alive without needing to hold a reference to it. + + This keeps the ba.Actor in existence by storing a reference to it + with the ba.Activity it was created in. The reference is lazily + released once ba.Actor.exists() returns False for it or when the + Activity is set as expired. This can be a convenient alternative + to storing references explicitly just to keep a ba.Actor from dying. + For convenience, this method returns the ba.Actor it is called with, + enabling chained statements such as: myflag = ba.Flag().autoretain() + """ + activity = self._activity() + if activity is None: + from ba._error import ActivityNotFoundError + raise ActivityNotFoundError() + activity.retain_actor(self) + return self + + def on_expire(self) -> None: + """Called for remaining ba.Actors when their ba.Activity shuts down. + + Actors can use this opportunity to clear callbacks + or other references which have the potential of keeping the + ba.Activity alive inadvertently (Activities can not exit cleanly while + any Python references to them remain.) + + Once an actor is expired (see ba.Actor.is_expired()) it should no + longer perform any game-affecting operations (creating, modifying, + or deleting nodes, media, timers, etc.) Attempts to do so will + likely result in errors. + """ + + def is_expired(self) -> bool: + """Returns whether the Actor is expired. + + (see ba.Actor.on_expire()) + """ + activity = self.getactivity(doraise=False) + return True if activity is None else activity.is_expired() + + def exists(self) -> bool: + """Returns whether the Actor is still present in a meaningful way. + + Note that a dying character should still return True here as long as + their corpse is visible; this is about presence, not being 'alive' + (see ba.Actor.is_alive() for that). + + If this returns False, it is assumed the Actor can be completely + deleted without affecting the game; this call is often used + when pruning lists of Actors, such as with ba.Actor.autoretain() + + The default implementation of this method returns 'node.exists()' + if the Actor has a 'node' attr; otherwise True. + + Note that the boolean operator for the Actor class calls this method, + so a simple "if myactor" test will conveniently do the right thing + even if myactor is set to None. + """ + + # As a default, if we have a 'node' attr, return whether it exists. + node: ba.Node = getattr(self, 'node', None) + if node is not None: + return node.exists() + return True + + def __bool__(self) -> bool: + # Cleaner way to test existence; friendlier to None values. + return self.exists() + + def is_alive(self) -> bool: + """Returns whether the Actor is 'alive'. + + What this means is up to the Actor. + It is not a requirement for Actors to be + able to die; just that they report whether + they are Alive or not. + """ + return True + + @property + def activity(self) -> ba.Activity: + """The Activity this Actor was created in. + + Raises a ba.ActivityNotFoundError if the Activity no longer exists. + """ + activity = self._activity() + if activity is None: + from ba._error import ActivityNotFoundError + raise ActivityNotFoundError() + return activity + + def getactivity(self, doraise: bool = True) -> Optional[ba.Activity]: + """Return the ba.Activity this Actor is associated with. + + If the Activity no longer exists, raises a ba.ActivityNotFoundError + or returns None depending on whether 'doraise' is set. + """ + activity = self._activity() + if activity is None and doraise: + from ba._error import ActivityNotFoundError + raise ActivityNotFoundError() + return activity diff --git a/assets/src/data/scripts/ba/_app.py b/assets/src/data/scripts/ba/_app.py new file mode 100644 index 00000000..dfb043c0 --- /dev/null +++ b/assets/src/data/scripts/ba/_app.py @@ -0,0 +1,832 @@ +"""Functionality related to the high level state of the app.""" +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + import ba + from ba import _lang as bs_lang + from bastd.actor import spazappearance + from typing import (Optional, Dict, Tuple, Set, Any, List, Type, Tuple, + Callable) + + +class App: + """A class for high level app functionality and state. + + category: General Utility Classes + + Use ba.app to access the single shared instance of this class. + + Note that properties not documented here should be considered internal + and subject to change without warning. + """ + # pylint: disable=too-many-public-methods + + # Note: many values here are simple method attrs and thus don't show + # up in docs. If there's any that'd be useful to expose publicly, they + # should be converted to properties so its possible to validate values + # and provide docs. + + @property + def build_number(self) -> int: + """Integer build number. + + This value increases by at least 1 with each release of the game. + It is independent of the human readable ba.App.version string. + """ + return self._build_number + + @property + def config_file_path(self) -> str: + """Where the game's config file is stored on disk.""" + return self._config_file_path + + @property + def locale(self) -> str: + """Raw country/language code detected by the game (such as 'en_US'). + + Generally for language-specific code you should look at + ba.App.language, which is the language the game is using + (which may differ from locale if the user sets a language, etc.) + """ + return self._locale + + def can_display_language(self, language: str) -> bool: + """Tell whether we can display a particular language. + + (internal) + + On some platforms we don't have unicode rendering yet + which limits the languages we can draw. + """ + + # We don't yet support full unicode display on windows or linux :-(. + if (language in ('Chinese', 'Persian', 'Korean', 'Arabic', 'Hindi') + and self.platform in ('windows', 'linux')): + return False + return True + + def _get_default_language(self) -> str: + languages = { + 'de': 'German', + 'es': 'Spanish', + 'it': 'Italian', + 'nl': 'Dutch', + 'da': 'Danish', + 'pt': 'Portuguese', + 'fr': 'French', + 'el': 'Greek', + 'ru': 'Russian', + 'pl': 'Polish', + 'sv': 'Swedish', + 'eo': 'Esperanto', + 'cs': 'Czech', + 'hr': 'Croatian', + 'hu': 'Hungarian', + 'be': 'Belarussian', + 'ro': 'Romanian', + 'ko': 'Korean', + 'fa': 'Persian', + 'ar': 'Arabic', + 'zh': 'Chinese', + 'tr': 'Turkish', + 'id': 'Indonesian', + 'sr': 'Serbian', + 'uk': 'Ukrainian', + 'hi': 'Hindi' + } + language = languages.get(self.locale[:2], 'English') + if not self.can_display_language(language): + language = 'English' + return language + + @property + def language(self) -> str: + """The name of the language the game is running in. + + This can be selected explicitly by the user or may be set + automatically based on ba.App.locale or other factors. + """ + assert isinstance(self.config, dict) + return self.config.get('Lang', self.default_language) + + @property + def user_agent_string(self) -> str: + """String containing various bits of info about OS/device/etc.""" + return self._user_agent_string + + @property + def version(self) -> str: + """Human-readable version string; something like '1.3.24'. + + This should not be interpreted as a number; it may contain + string elements such as 'alpha', 'beta', 'test', etc. + If a numeric version is needed, use 'ba.App.build_number'. + """ + return self._version + + @property + def debug_build(self) -> bool: + """Whether the game was compiled in debug mode. + + Debug builds generally run substantially slower than non-debug + builds due to compiler optimizations being disabled and extra + checks being run. + """ + return self._debug_build + + @property + def test_build(self) -> bool: + """Whether the game was compiled in test mode. + + Test mode enables extra checks and features that are useful for + release testing but which do not slow the game down significantly. + """ + return self._test_build + + @property + def user_scripts_directory(self) -> str: + """Path where the game is looking for custom user scripts.""" + return self._user_scripts_directory + + @property + def system_scripts_directory(self) -> str: + """Path where the game is looking for its bundled scripts.""" + return self._system_scripts_directory + + @property + def config(self) -> ba.AppConfig: + """The ba.AppConfig instance representing the app's config state.""" + assert self._config is not None + return self._config + + @property + def platform(self) -> str: + """Name of the current platform. + + Examples are: 'mac', 'windows', android'. + """ + return self._platform + + @property + def subplatform(self) -> str: + """String for subplatform. + + Can be empty. For the 'android' platform, subplatform may + be 'google', 'amazon', etc. + """ + return self._subplatform + + @property + def interface_type(self) -> str: + """Interface mode the game is in; can be 'large', 'medium', or 'small'. + + 'large' is used by system such as desktop PC where elements on screen + remain usable even at small sizes, allowing more to be shown. + 'small' is used by small devices such as phones, where elements on + screen must be larger to remain readable and usable. + 'medium' is used by tablets and other middle-of-the-road situations + such as VR or TV. + """ + return self._interface_type + + @property + def on_tv(self) -> bool: + """Bool value for if the game is running on a TV.""" + return self._on_tv + + @property + def vr_mode(self) -> bool: + """Bool value for if the game is running in VR.""" + return self._vr_mode + + @property + def ui_bounds(self) -> Tuple[float, float, float, float]: + """Bounds of the 'safe' screen area in ui space. + + This tuple contains: (x-min, x-max, y-min, y-max) + """ + return _ba.uibounds() + + def __init__(self) -> None: + """(internal) + + Do not instantiate this class; use ba.app to access + the single shared instance. + """ + # pylint: disable=too-many-statements + + test_https = False + if test_https: + # Testing https support (would be nice to get this working on + # our custom python builds; need to wrangle certificates somehow). + import urllib.request + try: + val = urllib.request.urlopen('https://example.com').read() + print("HTTPS SUCCESS", len(val)) + except Exception as exc: + print("GOT EXC", exc) + + try: + import sqlite3 + print("GOT SQLITE", sqlite3) + except Exception as exc: + print("EXC IMPORTING SQLITE", exc) + + try: + import csv + print("GOT CSV", csv) + except Exception as exc: + print("EXC IMPORTING CSV", exc) + + try: + import lzma + print("GOT LZMA", lzma) + except Exception as exc: + print("EXC IMPORTING LZMA", exc) + + # Config. + self.config_file_healthy = False + + # This is incremented any time the app is backgrounded/foregrounded; + # can be a simple way to determine if network data should be + # refreshed/etc. + self.fg_state = 0 + + # Environment stuff (pulling these out as attrs so we can type-check + # them). + env = _ba.env() + self._build_number: int = env['build_number'] + self._config_file_path: str = env['config_file_path'] + self._locale: str = env['locale'] + self._user_agent_string: str = env['user_agent_string'] + self._version: str = env['version'] + self._debug_build: bool = env['debug_build'] + self._test_build: bool = env['test_build'] + self._user_scripts_directory: str = env['user_scripts_directory'] + self._system_scripts_directory: str = env['system_scripts_directory'] + self._platform: str = env['platform'] + self._subplatform: str = env['subplatform'] + self._interface_type: str = env['interface_type'] + self._on_tv: bool = env['on_tv'] # + self._vr_mode: bool = env['vr_mode'] + self.protocol_version: int = env['protocol_version'] + self.toolbar_test: bool = env['toolbar_test'] + self.kiosk_mode: bool = env['kiosk_mode'] + + # Misc. + self.default_language = self._get_default_language() + self.metascan: Optional[Dict[str, Any]] = None + self.tips: List[str] = [] + self.stress_test_reset_timer: Optional[ba.Timer] = None + self.suppress_debug_reports = False + self.last_ad_completion_time: Optional[float] = None + self.last_ad_was_short = False + self.did_weak_call_warning = False + self.ran_on_launch = False + + # If we try to run promo-codes due to launch-args/etc we might + # not be signed in yet; go ahead and queue them up in that case. + self.pending_promo_codes: List[str] = [] + self.last_in_game_ad_remove_message_show_time: Optional[float] = None + self.log_have_new = False + self.log_upload_timer_started = False + self._config: Optional[ba.AppConfig] = None + self.printed_live_object_warning = False + self.last_post_purchase_message_time: Optional[float] = None + + # We include this extra hash with shared input-mapping names so + # that we don't share mappings between differently-configured + # systems. For instance, different android devices may give different + # key values for the same controller type so we keep their mappings + # distinct. + self.input_map_hash: Optional[str] = None + + # Co-op Campaigns. + self.campaigns: Dict[str, ba.Campaign] = {} + + # Server-Mode. + self.server_config: Dict[str, Any] = {} + self.server_config_dirty = False + self.run_server_wait_timer: Optional[ba.Timer] = None + self.server_playlist_fetch: Optional[Dict[str, Any]] = None + self.launched_server = False + self.run_server_first_run = True + + # Ads. + self.last_ad_network = 'unknown' + self.last_ad_network_set_time = time.time() + self.ad_amt: Optional[float] = None + self.last_ad_purpose = 'invalid' + self.attempted_first_ad = False + + # Music. + self.music: Optional[ba.Node] = None + self.music_mode: str = 'regular' + self.music_player: Optional[ba.MusicPlayer] = None + self.music_player_type: Optional[Type[ba.MusicPlayer]] = None + self.music_types: Dict[str, Optional[str]] = { + 'regular': None, + 'test': None + } + + # Language. + self.language_target: Optional[bs_lang.AttrDict] = None + self.language_merged: Optional[bs_lang.AttrDict] = None + + # Achievements. + self.achievements: List[ba.Achievement] = [] + self.achievements_to_display: (List[Tuple[ba.Achievement, bool]]) = [] + self.achievement_display_timer: Optional[_ba.Timer] = None + self.last_achievement_display_time: float = 0.0 + self.achievement_completion_banner_slots: Set[int] = set() + + # Lobby. + self.lobby_random_profile_index: int = 1 + self.lobby_random_char_index_offset: Optional[int] = None + self.lobby_account_profile_device_id: Optional[int] = None + + # Main Menu. + self.main_menu_did_initial_transition = False + self.main_menu_last_news_fetch_time: Optional[float] = None + + # Spaz. + self.spaz_appearances: Dict[str, spazappearance.Appearance] = {} + self.last_spaz_turbo_warn_time: float = -99999.0 + + # Maps. + self.maps: Dict[str, Type[ba.Map]] = {} + + # Gameplay. + self.teams_series_length = 7 + self.ffa_series_length = 24 + self.coop_session_args: dict = {} + + # UI. + self.uicontroller: Optional[ba.UIController] = None + self.main_menu_window: Optional[_ba.Widget] = None # FIXME: Kill this. + self.window_states: dict = {} # FIXME: Kill this. + self.windows: dict = {} # FIXME: Kill this. + self.main_window: Optional[str] = None # FIXME: Kill this. + self.main_menu_selection: Optional[str] = None # FIXME: Kill this. + self.have_party_queue_window = False + self.quit_window: Any = None + self.dismiss_wii_remotes_window_call: ( + Optional[Callable[[], Any]]) = None + self.value_test_defaults: dict = {} + self.main_menu_window_refresh_check_count = 0 + self.first_main_menu = True # FIXME: Move to mainmenu class. + self.did_menu_intro = False # FIXME: Move to mainmenu class. + self.main_menu_resume_callbacks: list = [] # can probably go away + self.special_offer = None + self.league_rank_cache: dict = {} + self.tournament_info: dict = {} + self.account_tournament_list: Optional[Tuple[int, List[str]]] = None + self.ping_thread_count = 0 + self.invite_confirm_windows: List[Any] = [] # FIXME: Don't use Any. + self.store_layout: Optional[Dict[str, List[Dict[str, Any]]]] = None + self.store_items: Optional[Dict[str, Dict]] = None + self.pro_sale_start_time: Optional[int] = None + self.pro_sale_start_val: Optional[int] = None + self.party_window: Any = None # FIXME: Don't use Any. + self.title_color = (0.72, 0.7, 0.75) + self.heading_color = (0.72, 0.7, 0.75) + self.infotextcolor = (0.7, 0.9, 0.7) + self.uicleanupchecks: List[dict] = [] + self.uiupkeeptimer: Optional[ba.Timer] = None + self.delegate: Optional[ba.AppDelegate] = None + + # A few shortcuts. + self.small_ui = env['interface_type'] == 'small' + self.med_ui = env['interface_type'] == 'medium' + self.large_ui = env['interface_type'] == 'large' + self.toolbars = env.get('toolbar_test', True) + + def on_launch(self) -> None: + """Runs after the app finishes bootstrapping. + + (internal)""" + # FIXME: Break this up. + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + from ba import _apputils + from ba._general import Call + from ba import _appconfig + from ba import ui as bsui + from ba import _achievement + from ba import _maps + from ba import _meta + from ba import _music + from ba import _campaign + from bastd import appdelegate + from bastd import maps as stdmaps + from bastd.actor import spazappearance + from ba._enums import TimeType + + cfg = self.config + + # Set up our app delegate. + self.delegate = appdelegate.AppDelegate() + self.uicontroller = bsui.UIController() + _achievement.init_achievements() + spazappearance.register_appearances() + _campaign.init_campaigns() + if _ba.env()['platform'] == 'android': + self.music_player_type = _music.InternalMusicPlayer + elif _ba.env()['platform'] == 'mac' and hasattr(_ba, 'itunes_init'): + self.music_player_type = _music.MacITunesMusicPlayer + for maptype in [ + stdmaps.HockeyStadium, stdmaps.FootballStadium, + stdmaps.Bridgit, stdmaps.BigG, stdmaps.Roundabout, + stdmaps.MonkeyFace, stdmaps.ZigZag, stdmaps.ThePad, + stdmaps.DoomShroom, stdmaps.LakeFrigid, stdmaps.TipTop, + stdmaps.CragCastle, stdmaps.TowerD, stdmaps.HappyThoughts, + stdmaps.StepRightUp, stdmaps.Courtyard, stdmaps.Rampage + ]: + _maps.register_map(maptype) + + if self.debug_build: + _apputils.suppress_debug_reports() + + # IMPORTANT - if tweaking UI stuff, you need to make sure it behaves + # for small, medium, and large UI modes. (doesn't run off screen, etc). + # Set these to 1 to test with different sizes. Generally small is used + # on phones, medium is used on tablets, and large is on desktops or + # large tablets. + + # Kick off our periodic UI upkeep. + # FIXME: Can probably kill this if we do immediate UI death checks. + self.uiupkeeptimer = _ba.Timer(2.6543, + bsui.upkeep, + timetype=TimeType.REAL, + repeat=True) + + # pylint: disable=using-constant-test + # noinspection PyUnreachableCode + if 0: # force-test small UI + self.small_ui = True + self.med_ui = False + with _ba.Context('ui'): + _ba.pushcall( + Call(_ba.screenmessage, + 'FORCING SMALL UI FOR TESTING', + color=(1, 0, 1), + log=True)) + # noinspection PyUnreachableCode + if 0: # force-test medium UI + self.small_ui = False + self.med_ui = True + with _ba.Context('ui'): + _ba.pushcall( + Call(_ba.screenmessage, + 'FORCING MEDIUM UI FOR TESTING', + color=(1, 0, 1), + log=True)) + # noinspection PyUnreachableCode + if 0: # force-test large UI + self.small_ui = False + self.med_ui = False + with _ba.Context('ui'): + _ba.pushcall( + Call(_ba.screenmessage, + 'FORCING LARGE UI FOR TESTING', + color=(1, 0, 1), + log=True)) + # pylint: enable=using-constant-test + + # If there's a leftover log file, attempt to upload + # it to the server and/or get rid of it. + _apputils.handle_leftover_log_file() + try: + _apputils.handle_leftover_log_file() + except Exception: + from ba import _error + _error.print_exception('Error handling leftover log file') + + # Notify the user if we're using custom system scripts. + # FIXME: This no longer works since sys-scripts is an absolute path; + # need to just add a proper call to query this. + # if env['system_scripts_directory'] != 'data/scripts': + # ba.screenmessage("Using custom system scripts...", + # color=(0, 1, 0)) + + # Only do this stuff if our config file is healthy so we don't + # overwrite a broken one or whatnot and wipe out data. + if not self.config_file_healthy: + if self.platform in ('mac', 'linux', 'windows'): + from bastd.ui import configerror + configerror.ConfigErrorWindow() + return + + # For now on other systems we just overwrite the bum config. + # At this point settings are already set; lets just commit them + # to disk. + _appconfig.commit_app_config(force=True) + + # If we're using a non-default playlist, lets go ahead and get our + # music-player going since it may hitch (better while we're faded + # out than later). + try: + if ('Soundtrack' in cfg and cfg['Soundtrack'] not in [ + '__default__', 'Default Soundtrack' + ]): + _music.get_music_player() + except Exception: + from ba import _error + _error.print_exception('error prepping music-player') + + launch_count = cfg.get('launchCount', 0) + launch_count += 1 + + for key in ('lc14173', 'lc14292'): + cfg.setdefault(key, launch_count) + + # Debugging - make note if we're using the local test server so we + # don't accidentally leave it on in a release. + server_addr = _ba.get_master_server_address() + if 'localhost' in server_addr: + _ba.timer(2.0, + Call(_ba.screenmessage, + "Note: using local server", (1, 1, 0), + log=True), + timetype=TimeType.REAL) + elif 'test' in server_addr: + _ba.timer(2.0, + Call(_ba.screenmessage, + "Note: using test server-module", (1, 1, 0), + log=True), + timetype=TimeType.REAL) + + cfg['launchCount'] = launch_count + cfg.commit() + + # Run a test in a few seconds to see if we should pop up an existing + # pending special offer. + def check_special_offer() -> None: + from bastd.ui import specialoffer + config = self.config + if ('pendingSpecialOffer' in config and _ba.get_public_login_id() + == config['pendingSpecialOffer']['a']): + self.special_offer = config['pendingSpecialOffer']['o'] + specialoffer.show_offer() + + if self.subplatform != 'headless': + _ba.timer(3.0, check_special_offer, timetype=TimeType.REAL) + + _meta.startscan() + + # Start scanning for stuff available in our scripts. + # meta.get_game_types() + + # Auto-sign-in to a local account in a moment if we're set to. + def do_auto_sign_in() -> None: + if self.subplatform == 'headless': + _ba.sign_in('Local') + elif cfg.get('Auto Account State') == 'Local': + _ba.sign_in('Local') + + _ba.pushcall(do_auto_sign_in) + + self.ran_on_launch = True + + from ba._dep import test_depset + test_depset() + # print('GAME TYPES ARE', meta.get_game_types()) + # _bs.quit() + + def read_config(self) -> None: + """(internal)""" + from ba import _appconfig + self._config, self.config_file_healthy = _appconfig.read_config() + + def pause(self) -> None: + """Pause the game due to a user request or menu popping up. + + If there's a foreground host-activity that says it's pausable, tell it + to pause ..we now no longer pause if there are connected clients. + """ + # pylint: disable=cyclic-import + activity = _ba.get_foreground_host_activity() + if (activity is not None and activity.allow_pausing + and not _ba.have_connected_clients()): + # FIXME: Shouldn't be touching scene stuff here; + # should just pass the request on to the host-session. + import ba + with ba.Context(activity): + globs = ba.sharedobj('globals') + if not globs.paused: + ba.playsound(ba.getsound('refWhistle')) + globs.paused = True + + # FIXME: This should not be an attr on Actor. + activity.paused_text = ba.Actor( + ba.newnode( + 'text', + attrs={ + 'text': ba.Lstr(resource='pausedByHostText'), + 'client_only': True, + 'flatness': 1.0, + 'h_align': 'center' + })) + + def resume(self) -> None: + """Resume the game due to a user request or menu closing. + + If there's a foreground host-activity that's currently paused, tell it + to resume. + """ + from ba import _gameutils + # FIXME: Shouldn't be touching scene stuff here; + # should just pass the request on to the host-session. + activity = _ba.get_foreground_host_activity() + if activity is not None: + with _ba.Context(activity): + globs = _gameutils.sharedobj('globals') + if globs.paused: + _ba.playsound(_ba.getsound('refWhistle')) + globs.paused = False + + # FIXME: This should not be an actor attr. + activity.paused_text = None + + def return_to_main_menu_session_gracefully(self) -> None: + """Attempt to cleanly get back to the main menu.""" + # pylint: disable=cyclic-import + from ba import _benchmark + from ba._general import Call + from bastd import mainmenu + _ba.app.main_window = None + if isinstance(_ba.get_foreground_host_session(), + mainmenu.MainMenuSession): + # It may be possible we're on the main menu but the screen is faded + # so fade back in. + _ba.fade_screen(True) + return + + _benchmark.stop_stress_test() # Stop stress-test if in progress. + + # If we're in a host-session, tell them to end. + # This lets them tear themselves down gracefully. + host_session = _ba.get_foreground_host_session() + if host_session is not None: + + # Kick off a little transaction so we'll hopefully have all the + # latest account state when we get back to the menu. + _ba.add_transaction({ + 'type': 'END_SESSION', + 'sType': str(type(host_session)) + }) + _ba.run_transactions() + + host_session.end() + + # Otherwise just force the issue. + else: + _ba.pushcall(Call(_ba.new_host_session, mainmenu.MainMenuSession)) + + def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None: + """(internal)""" + + # If there's no main menu up, just call immediately. + if not self.main_menu_window: + with _ba.Context('ui'): + call() + else: + self.main_menu_resume_callbacks.append(call) + + def handle_app_pause(self) -> None: + """Called when the app goes to a suspended state.""" + + def handle_app_resume(self) -> None: + """Run when the app resumes from a suspended state.""" + + # If there's music playing externally, make sure we aren't playing + # ours. + from ba import _music + _music.handle_app_resume() + self.fg_state += 1 + + # Mark our cached tourneys as invalid so anyone using them knows + # they might be out of date. + for entry in list(self.tournament_info.values()): + entry['valid'] = False + + def launch_coop_game(self, + game: str, + force: bool = False, + args: Dict = None) -> bool: + """High level way to launch a co-op session locally.""" + # pylint: disable=cyclic-import + from ba._campaign import get_campaign + from bastd.ui.coop.level import CoopLevelLockedWindow + if args is None: + args = {} + if game == '': + raise Exception("empty game name") + campaignname, levelname = game.split(':') + campaign = get_campaign(campaignname) + levels = campaign.get_levels() + + # If this campaign is sequential, make sure we've completed the + # one before this. + if campaign.sequential and not force: + for level in levels: + if level.name == levelname: + break + if not level.complete: + CoopLevelLockedWindow( + campaign.get_level(levelname).displayname, + campaign.get_level(level.name).displayname) + return False + + # Ok, we're good to go. + self.coop_session_args = {'campaign': campaignname, 'level': levelname} + for arg_name, arg_val in list(args.items()): + self.coop_session_args[arg_name] = arg_val + + def _fade_end() -> None: + from ba import _coopsession + try: + _ba.new_host_session(_coopsession.CoopSession) + except Exception: + from ba import _error + _error.print_exception() + from bastd import mainmenu + _ba.new_host_session(mainmenu.MainMenuSession) + + _ba.fade_screen(False, endcall=_fade_end) + return True + + def do_remove_in_game_ads_message(self) -> None: + """(internal)""" + from ba._lang import Lstr + from ba._general import Call + from ba._enums import TimeType + + # Print this message once every 10 minutes at most. + tval = _ba.time(TimeType.REAL) + if (self.last_in_game_ad_remove_message_show_time is None or + (tval - self.last_in_game_ad_remove_message_show_time > 60 * 10)): + self.last_in_game_ad_remove_message_show_time = tval + with _ba.Context('ui'): + _ba.timer( + 1.0, + Call(_ba.screenmessage, + Lstr( + resource='removeInGameAdsText', + subs=[ + ('${PRO}', + Lstr(resource='store.bombSquadProNameText')), + ('${APP_NAME}', Lstr(resource='titleText')) + ]), + color=(1, 1, 0)), + timetype=TimeType.REAL) + + def shutdown(self) -> None: + """(internal)""" + if self.music_player is not None: + self.music_player.shutdown() + + def handle_deep_link(self, url: str) -> None: + """Handle a deep link URL.""" + from ba._lang import Lstr + from ba._enums import TimeType + if url.startswith('ballisticacore://code/'): + code = url.replace('ballisticacore://code/', '') + + # If we're not signed in, queue up the code to run the next time we + # are and issue a warning if we haven't signed in within the next + # few seconds. + if _ba.get_account_state() != 'signed_in': + + def check_pending_codes() -> None: + """(internal)""" + + # If we're still not signed in and have pending codes, + # inform the user that they need to sign in to use them. + if _ba.app.pending_promo_codes: + _ba.screenmessage( + Lstr(resource='signInForPromoCodeText'), + color=(1, 0, 0)) + _ba.playsound(_ba.getsound('error')) + + _ba.app.pending_promo_codes.append(code) + _ba.timer(6.0, check_pending_codes, timetype=TimeType.REAL) + return + _ba.screenmessage(Lstr(resource='submittingPromoCodeText'), + color=(0, 1, 0)) + _ba.add_transaction({ + 'type': 'PROMO_CODE', + 'expire_time': time.time() + 5, + 'code': code + }) + _ba.run_transactions() + else: + _ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0)) + _ba.playsound(_ba.getsound('error')) diff --git a/assets/src/data/scripts/ba/_appconfig.py b/assets/src/data/scripts/ba/_appconfig.py new file mode 100644 index 00000000..844d306c --- /dev/null +++ b/assets/src/data/scripts/ba/_appconfig.py @@ -0,0 +1,164 @@ +"""Provides the AppConfig class.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Any, List, Tuple + + +class AppConfig(dict): + """A special dict that holds the game's persistent configuration values. + + Category: General Utility Classes + + It also provides methods for fetching values with app-defined fallback + defaults, applying contained values to the game, and committing the + config to storage. + + Call ba.appconfig() to get the single shared instance of this class. + + AppConfig data is stored as json on disk on so make sure to only place + json-friendly values in it (dict, list, str, float, int, bool). + Be aware that tuples will be quietly converted to lists. + """ + + def resolve(self, key: str) -> Any: + """Given a string key, return a config value (type varies). + + This will substitute application defaults for values not present in + the config dict, filter some invalid values, etc. Note that these + values do not represent the state of the app; simply the state of its + config. Use ba.App to access actual live state. + + Raises an Exception for unrecognized key names. To get the list of keys + supported by this method, use ba.AppConfig.builtin_keys(). Note that it + is perfectly legal to store other data in the config; it just needs to + be accessed through standard dict methods and missing values handled + manually. + """ + return _ba.resolve_appconfig_value(key) + + def default_value(self, key: str) -> Any: + """Given a string key, return its predefined default value. + + This is the value that will be returned by ba.AppConfig.resolve() if + the key is not present in the config dict or of an incompatible type. + + Raises an Exception for unrecognized key names. To get the list of keys + supported by this method, use ba.AppConfig.builtin_keys(). Note that it + is perfectly legal to store other data in the config; it just needs to + be accessed through standard dict methods and missing values handled + manually. + """ + return _ba.get_appconfig_default_value(key) + + def builtin_keys(self) -> List[str]: + """Return the list of valid key names recognized by ba.AppConfig. + + This set of keys can be used with resolve(), default_value(), etc. + It does not vary across platforms and may include keys that are + obsolete or not relevant on the current running version. (for instance, + VR related keys on non-VR platforms). This is to minimize the amount + of platform checking necessary) + + Note that it is perfectly legal to store arbitrary named data in the + config, but in that case it is up to the user to test for the existence + of the key in the config dict, fall back to consistent defaults, etc. + """ + return _ba.get_appconfig_builtin_keys() + + def apply(self) -> None: + """Apply config values to the running app.""" + _ba.apply_config() + + def commit(self) -> None: + """Commits the config to local storage. + + Note that this call is asynchronous so the actual write to disk may not + occur immediately. + """ + commit_app_config() + + def apply_and_commit(self) -> None: + """Run apply() followed by commit(); for convenience. + + (This way the commit() will not occur if apply() hits invalid data) + """ + self.apply() + self.commit() + + +def read_config() -> Tuple[AppConfig, bool]: + """Read the game config.""" + import os + import json + from ba._enums import TimeType + + config_file_healthy = False + + # NOTE: it is assumed that this only gets called once and the + # config object will not change from here on out + config_file_path = _ba.app.config_file_path + config_contents = '' + try: + if os.path.exists(config_file_path): + with open(config_file_path) as infile: + config_contents = infile.read() + config = AppConfig(json.loads(config_contents)) + else: + config = AppConfig() + config_file_healthy = True + + except Exception as exc: + print(('error reading config file at time ' + + str(_ba.time(TimeType.REAL)) + ': \'' + config_file_path + + '\':\n'), exc) + + # Whenever this happens lets back up the broken one just in case it + # gets overwritten accidentally. + print(('backing up current config file to \'' + config_file_path + + ".broken\'")) + try: + import shutil + shutil.copyfile(config_file_path, config_file_path + '.broken') + except Exception as exc: + print('EXC copying broken config:', exc) + try: + _ba.log('broken config contents:\n' + + config_contents.replace('\000', ''), + to_console=False) + except Exception as exc: + print('EXC logging broken config contents:', exc) + config = AppConfig() + + # Now attempt to read one of our 'prev' backup copies. + prev_path = config_file_path + '.prev' + try: + if os.path.exists(prev_path): + with open(prev_path) as infile: + config_contents = infile.read() + config = AppConfig(json.loads(config_contents)) + else: + config = AppConfig() + config_file_healthy = True + print('successfully read backup config.') + except Exception as exc: + print('EXC reading prev backup config:', exc) + return config, config_file_healthy + + +def commit_app_config(force: bool = False) -> None: + """Commit the config to persistent storage. + + Category: General Utility Functions + + (internal) + """ + if not _ba.app.config_file_healthy and not force: + print("Current config file is broken; " + "skipping write to avoid losing settings.") + return + _ba.mark_config_dirty() diff --git a/assets/src/data/scripts/ba/_appdelegate.py b/assets/src/data/scripts/ba/_appdelegate.py new file mode 100644 index 00000000..eb299d10 --- /dev/null +++ b/assets/src/data/scripts/ba/_appdelegate.py @@ -0,0 +1,27 @@ +"""Defines AppDelegate class for handling high level app functionality.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Type, Optional, Any, Dict, Callable + import ba + + +class AppDelegate: + """Defines handlers for high level app functionality.""" + + def create_default_game_config_ui( + self, gameclass: Type[ba.GameActivity], + sessionclass: Type[ba.Session], config: Optional[Dict[str, Any]], + completion_call: Callable[[Optional[Dict[str, Any]]], None] + ) -> None: + """Launch a UI to configure the given game config. + + It should manipulate the contents of config and call completion_call + when done. + """ + del gameclass, sessionclass, config, completion_call # unused + from ba import _error + _error.print_error( + "create_default_game_config_ui needs to be overridden") diff --git a/assets/src/data/scripts/ba/_apputils.py b/assets/src/data/scripts/ba/_apputils.py new file mode 100644 index 00000000..c2b9363e --- /dev/null +++ b/assets/src/data/scripts/ba/_apputils.py @@ -0,0 +1,394 @@ +"""Utility functionality related to the overall operation of the app.""" +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import List, Any, Callable, Optional + import ba + + +def is_browser_likely_available() -> bool: + """Return whether a browser likely exists on the current device. + + category: General Utility Functions + + If this returns False you may want to avoid calling ba.show_url() + with any lengthy addresses. (ba.show_url() will display an address + as a string in a window if unable to bring up a browser, but that + is only useful for simple URLs.) + """ + app = _ba.app + platform = app.platform + touchscreen = _ba.get_input_device('TouchScreen', '#1', doraise=False) + + # If we're on a vr device or an android device with no touchscreen, + # assume no browser. + # FIXME: Might not be the case anymore; should make this definable + # at the platform level. + if app.vr_mode or (platform == 'android' and touchscreen is None): + return False + + # Anywhere else assume we've got one. + return True + + +def get_remote_app_name() -> ba.Lstr: + """(internal)""" + from ba import _lang + return _lang.Lstr(resource='remote_app.app_name') + + +def should_submit_debug_info() -> bool: + """(internal)""" + return _ba.app.config.get('Submit Debug Info', True) + + +def suppress_debug_reports() -> None: + """Turn debug-reporting to the master server off. + + This should be called in devel/debug situations to avoid spamming + the master server with spurious logs. + """ + _ba.screenmessage("Suppressing debug reports.", color=(1, 0, 0)) + _ba.app.suppress_debug_reports = True + + +def handle_log() -> None: + """Called on debug log prints. + + When this happens, we can upload our log to the server + after a short bit if desired. + """ + from ba._netutils import serverput + from ba._enums import TimeType + app = _ba.app + app.log_have_new = True + if not app.log_upload_timer_started: + + def _put_log() -> None: + if not app.suppress_debug_reports: + try: + sessionname = str(_ba.get_foreground_host_session()) + except Exception: + sessionname = 'unavailable' + try: + activityname = str(_ba.get_foreground_host_activity()) + except Exception: + activityname = 'unavailable' + info = { + 'log': _ba.get_log(), + 'version': app.version, + 'build': app.build_number, + 'userAgentString': app.user_agent_string, + 'session': sessionname, + 'activity': activityname, + 'fatal': 0, + 'userRanCommands': _ba.has_user_run_commands(), + 'time': _ba.time(TimeType.REAL), + 'userModded': _ba.has_user_mods() + } + + def response(data: Any) -> None: + # A non-None response means success; lets + # take note that we don't need to report further + # log info this run + if data is not None: + app.log_have_new = False + _ba.mark_log_sent() + + serverput('bsLog', info, response) + + app.log_upload_timer_started = True + + # Delay our log upload slightly in case other + # pertinent info gets printed between now and then. + with _ba.Context('ui'): + _ba.timer(3.0, _put_log, timetype=TimeType.REAL) + + # After a while, allow another log-put. + def _reset() -> None: + app.log_upload_timer_started = False + if app.log_have_new: + handle_log() + + if not _ba.is_log_full(): + with _ba.Context('ui'): + _ba.timer(600.0, + _reset, + timetype=TimeType.REAL, + suppress_format_warning=True) + + +def handle_leftover_log_file() -> None: + """Handle an un-uploaded log from a previous run.""" + import json + from ba._netutils import serverput + + if os.path.exists(_ba.get_log_file_path()): + with open(_ba.get_log_file_path()) as infile: + info = json.loads(infile.read()) + infile.close() + do_send = should_submit_debug_info() + if do_send: + + def response(data: Any) -> None: + # Non-None response means we were successful; + # lets kill it. + if data is not None: + os.remove(_ba.get_log_file_path()) + + serverput('bsLog', info, response) + else: + # If they don't want logs uploaded just kill it. + os.remove(_ba.get_log_file_path()) + + +def garbage_collect(session_end: bool = True) -> None: + """Run an explicit pass of garbage collection.""" + import gc + gc.collect() + + # Can be handy to print this to check for leaks between games. + # noinspection PyUnreachableCode + if False: # pylint: disable=using-constant-test + print('PY OBJ COUNT', len(gc.get_objects())) + if gc.garbage: + print('PYTHON GC FOUND', len(gc.garbage), 'UNCOLLECTIBLE OBJECTS:') + for i, obj in enumerate(gc.garbage): + print(str(i) + ':', obj) + if session_end: + print_live_object_warnings('after session shutdown') + + +def print_live_object_warnings(when: Any, + ignore_session: ba.Session = None, + ignore_activity: ba.Activity = None) -> None: + """Print warnings for remaining objects in the current context.""" + # pylint: disable=too-many-branches + # pylint: disable=cyclic-import + import gc + from ba import _session as bs_session + from ba import _actor as bs_actor + from ba import _activity as bs_activity + sessions: List[ba.Session] = [] + activities: List[ba.Activity] = [] + actors = [] + if _ba.app.printed_live_object_warning: + # print 'skipping live obj check due to previous found live object(s)' + return + for obj in gc.get_objects(): + if isinstance(obj, bs_actor.Actor): + actors.append(obj) + elif isinstance(obj, bs_session.Session): + sessions.append(obj) + elif isinstance(obj, bs_activity.Activity): + activities.append(obj) + + # Complain about any remaining sessions. + for session in sessions: + if session is ignore_session: + continue + _ba.app.printed_live_object_warning = True + print('ERROR: Session found', when, ':', session) + # refs = list(gc.get_referrers(session)) + # i = 1 + # for ref in refs: + # if type(ref) is types.FrameType: continue + # print ' ref', i, ':', ref + # i += 1 + # if type(ref) is list or type(ref) is tuple or type(ref) is dict: + # refs2 = list(gc.get_referrers(ref)) + # j = 1 + # for ref2 in refs2: + # if type(ref2) is types.FrameType: continue + # print ' ref\'s ref', j, ':', ref2 + # j += 1 + + # Complain about any remaining activities. + for activity in activities: + if activity is ignore_activity: + continue + _ba.app.printed_live_object_warning = True + print('ERROR: Activity found', when, ':', activity) + # refs = list(gc.get_referrers(activity)) + # i = 1 + # for ref in refs: + # if type(ref) is types.FrameType: continue + # print ' ref', i, ':', ref + # i += 1 + # if type(ref) is list or type(ref) is tuple or type(ref) is dict: + # refs2 = list(gc.get_referrers(ref)) + # j = 1 + # for ref2 in refs2: + # if type(ref2) is types.FrameType: continue + # print ' ref\'s ref', j, ':', ref2 + # j += 1 + + # Complain about any remaining actors. + for actor in actors: + _ba.app.printed_live_object_warning = True + print('ERROR: Actor found', when, ':', actor) + if isinstance(actor, bs_actor.Actor): + try: + if actor.node: + print(' - contains node:', actor.node.getnodetype(), ';', + actor.node.get_name()) + except Exception as exc: + print(' - exception checking actor node:', exc) + # refs = list(gc.get_referrers(actor)) + # i = 1 + # for ref in refs: + # if type(ref) is types.FrameType: continue + # print ' ref', i, ':', ref + # i += 1 + # if type(ref) is list or type(ref) is tuple or type(ref) is dict: + # refs2 = list(gc.get_referrers(ref)) + # j = 1 + # for ref2 in refs2: + # if type(ref2) is types.FrameType: continue + # print ' ref\'s ref', j, ':', ref2 + # j += 1 + + +def print_corrupt_file_error() -> None: + """Print an error if a corrupt file is found.""" + from ba._lang import get_resource + from ba._general import Call + from ba._enums import TimeType + _ba.timer(2.0, + Call(_ba.screenmessage, + get_resource('internal.corruptFileText').replace( + '${EMAIL}', 'support@froemling.net'), + color=(1, 0, 0)), + timetype=TimeType.REAL) + _ba.timer(2.0, + Call(_ba.playsound, _ba.getsound('error')), + timetype=TimeType.REAL) + + +def show_ad(purpose: str, + on_completion_call: Callable[[], Any] = None, + pass_actually_showed: bool = False) -> None: + """(internal)""" + _ba.app.last_ad_purpose = purpose + _ba.show_ad(purpose, on_completion_call, pass_actually_showed) + + +def call_after_ad(call: Callable[[], Any]) -> None: + """Run a call after potentially showing an ad.""" + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + from ba._account import have_pro + from ba._enums import TimeType + import time + app = _ba.app + show = True + + # No ads without net-connections, etc. + if not _ba.can_show_ad(): + show = False + if have_pro(): + show = False # Pro disables interstitials. + try: + is_tournament = (_ba.get_foreground_host_session().tournament_id is + not None) + except Exception: + is_tournament = False + if is_tournament: + show = False # Never show ads during tournaments. + + if show: + interval: Optional[float] + launch_count = app.config.get('launchCount', 0) + + # If we're seeing short ads we may want to space them differently. + interval_mult = (_ba.get_account_misc_read_val( + 'ads.shortIntervalMult', 1.0) if app.last_ad_was_short else 1.0) + if app.ad_amt is None: + if launch_count <= 1: + app.ad_amt = _ba.get_account_misc_read_val( + 'ads.startVal1', 0.99) + else: + app.ad_amt = _ba.get_account_misc_read_val( + 'ads.startVal2', 1.0) + interval = None + else: + # So far we're cleared to show; now calc our ad-show-threshold and + # see if we should *actually* show (we reach our threshold faster + # the longer we've been playing). + base = 'ads' if _ba.has_video_ads() else 'ads2' + min_lc = _ba.get_account_misc_read_val(base + '.minLC', 0.0) + max_lc = _ba.get_account_misc_read_val(base + '.maxLC', 5.0) + min_lc_scale = (_ba.get_account_misc_read_val( + base + '.minLCScale', 0.25)) + max_lc_scale = (_ba.get_account_misc_read_val( + base + '.maxLCScale', 0.34)) + min_lc_interval = (_ba.get_account_misc_read_val( + base + '.minLCInterval', 360)) + max_lc_interval = (_ba.get_account_misc_read_val( + base + '.maxLCInterval', 300)) + if launch_count < min_lc: + lc_amt = 0.0 + elif launch_count > max_lc: + lc_amt = 1.0 + else: + lc_amt = ((float(launch_count) - min_lc) / (max_lc - min_lc)) + incr = (1.0 - lc_amt) * min_lc_scale + lc_amt * max_lc_scale + interval = ((1.0 - lc_amt) * min_lc_interval + + lc_amt * max_lc_interval) + app.ad_amt += incr + assert app.ad_amt is not None + if app.ad_amt >= 1.0: + app.ad_amt = app.ad_amt % 1.0 + app.attempted_first_ad = True + + # After we've reached the traditional show-threshold once, + # try again whenever its been INTERVAL since our last successful show. + elif (app.attempted_first_ad + and (app.last_ad_completion_time is None or + (interval is not None + and _ba.time(TimeType.REAL) - app.last_ad_completion_time > + (interval * interval_mult)))): + # Reset our other counter too in this case. + app.ad_amt = 0.0 + else: + show = False + + # If we're *still* cleared to show, actually tell the system to show. + if show: + # As a safety-check, set up an object that will run + # the completion callback if we've returned and sat for 10 seconds + # (in case some random ad network doesn't properly deliver its + # completion callback). + class _Payload: + + def __init__(self, pcall: Callable[[], Any]): + self._call = pcall + self._ran = False + + def run(self, fallback: bool = False) -> None: + """Run the fallback call (and issues a warning about it).""" + if not self._ran: + if fallback: + print(( + 'ERROR: relying on fallback ad-callback! ' + 'last network: ' + app.last_ad_network + ' (set ' + + str(int(time.time() - + app.last_ad_network_set_time)) + + 's ago); purpose=' + app.last_ad_purpose)) + _ba.pushcall(self._call) + self._ran = True + + payload = _Payload(call) + with _ba.Context('ui'): + _ba.timer(5.0, + lambda: payload.run(fallback=True), + timetype=TimeType.REAL) + show_ad('between_game', on_completion_call=payload.run) + else: + _ba.pushcall(call) # Just run the callback without the ad. diff --git a/assets/src/data/scripts/ba/_benchmark.py b/assets/src/data/scripts/ba/_benchmark.py new file mode 100644 index 00000000..076da994 --- /dev/null +++ b/assets/src/data/scripts/ba/_benchmark.py @@ -0,0 +1,165 @@ +"""Benchmark/Stress-Test related functionality.""" +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Dict, Any, Sequence + import ba + + +def run_cpu_benchmark() -> None: + """Run a cpu benchmark.""" + # pylint: disable=cyclic-import + from bastd import tutorial + from ba._session import Session + + class BenchmarkSession(Session): + """Session type for cpu benchmark.""" + + def __init__(self) -> None: + + print('FIXME: BENCHMARK SESSION WOULD CALC DEPS.') + depsets: Sequence[ba.DepSet] = [] + + super().__init__(depsets) + + # Store old graphics settings. + self._old_quality = _ba.app.config.resolve('Graphics Quality') + cfg = _ba.app.config + cfg['Graphics Quality'] = "Low" + cfg.apply() + self.benchmark_type = 'cpu' + self.set_activity(_ba.new_activity(tutorial.TutorialActivity)) + + def __del__(self) -> None: + + # When we're torn down, restore old graphics settings. + cfg = _ba.app.config + cfg['Graphics Quality'] = self._old_quality + cfg.apply() + + def on_player_request(self, player: ba.Player) -> bool: + return False + + _ba.new_host_session(BenchmarkSession, benchmark_type='cpu') + + +def run_stress_test(playlist_type: str = 'Random', + playlist_name: str = '__default__', + player_count: int = 8, + round_duration: int = 30) -> None: + """Run a stress test.""" + from ba import _modutils + from ba._general import Call + from ba._enums import TimeType + _ba.screenmessage( + 'Beginning stress test.. use ' + '\'End Game\' to stop testing.', + color=(1, 1, 0)) + with _ba.Context('ui'): + start_stress_test({ + 'playlist_type': playlist_type, + 'playlist_name': playlist_name, + 'player_count': player_count, + 'round_duration': round_duration + }) + _ba.timer(7.0, + Call(_ba.screenmessage, + ('stats will be written to ' + + _modutils.get_human_readable_user_scripts_path() + + '/stressTestStats.csv')), + timetype=TimeType.REAL) + + +def stop_stress_test() -> None: + """End a running stress test.""" + _ba.set_stress_testing(False, 0) + try: + if _ba.app.stress_test_reset_timer is not None: + _ba.screenmessage("Ending stress test...", color=(1, 1, 0)) + except Exception: + pass + _ba.app.stress_test_reset_timer = None + + +def start_stress_test(args: Dict[str, Any]) -> None: + """(internal)""" + from ba._general import Call + from ba._teamssession import TeamsSession + from ba._freeforallsession import FreeForAllSession + from ba._enums import TimeType, TimeFormat + bs_config = _ba.app.config + playlist_type = args['playlist_type'] + if playlist_type == 'Random': + if random.random() < 0.5: + playlist_type = 'Teams' + else: + playlist_type = 'Free-For-All' + _ba.screenmessage('Running Stress Test (listType="' + playlist_type + + '", listName="' + args['playlist_name'] + '")...') + if playlist_type == 'Teams': + bs_config['Team Tournament Playlist Selection'] = args['playlist_name'] + bs_config['Team Tournament Playlist Randomize'] = 1 + _ba.timer(1.0, + Call(_ba.pushcall, Call(_ba.new_host_session, TeamsSession)), + timetype=TimeType.REAL) + else: + bs_config['Free-for-All Playlist Selection'] = args['playlist_name'] + bs_config['Free-for-All Playlist Randomize'] = 1 + _ba.timer(1.0, + Call(_ba.pushcall, + Call(_ba.new_host_session, FreeForAllSession)), + timetype=TimeType.REAL) + _ba.set_stress_testing(True, args['player_count']) + _ba.app.stress_test_reset_timer = _ba.Timer( + args['round_duration'] * 1000, + Call(_reset_stress_test, args), + timetype=TimeType.REAL, + timeformat=TimeFormat.MILLISECONDS) + + +def _reset_stress_test(args: Dict[str, Any]) -> None: + from ba._general import Call + from ba._enums import TimeType + _ba.set_stress_testing(False, args['player_count']) + _ba.screenmessage('Resetting stress test...') + _ba.get_foreground_host_session().end() + _ba.timer(1.0, Call(start_stress_test, args), timetype=TimeType.REAL) + + +def run_gpu_benchmark() -> None: + """Kick off a benchmark to test gpu speeds.""" + _ba.screenmessage("FIXME: Not wired up yet.", color=(1, 0, 0)) + + +def run_media_reload_benchmark() -> None: + """Kick off a benchmark to test media reloading speeds.""" + from ba._general import Call + from ba._enums import TimeType + _ba.reload_media() + _ba.show_progress_bar() + + def delay_add(start_time: float) -> None: + + def doit(start_time_2: float) -> None: + from ba import _lang + _ba.screenmessage( + _lang.get_resource('debugWindow.totalReloadTimeText').replace( + '${TIME}', str(_ba.time(TimeType.REAL) - start_time_2))) + _ba.print_load_info() + if _ba.app.config.resolve("Texture Quality") != 'High': + _ba.screenmessage(_lang.get_resource( + 'debugWindow.reloadBenchmarkBestResultsText'), + color=(1, 1, 0)) + + _ba.add_clean_frame_callback(Call(doit, start_time)) + + # The reload starts (should add a completion callback to the + # reload func to fix this). + _ba.timer(0.05, + Call(delay_add, _ba.time(TimeType.REAL)), + timetype=TimeType.REAL) diff --git a/assets/src/data/scripts/ba/_campaign.py b/assets/src/data/scripts/ba/_campaign.py new file mode 100644 index 00000000..3f03c31a --- /dev/null +++ b/assets/src/data/scripts/ba/_campaign.py @@ -0,0 +1,340 @@ +"""Functionality related to co-op campaigns.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Any, List, Dict + import ba + + +def register_campaign(campaign: ba.Campaign) -> None: + """Register a new campaign.""" + _ba.app.campaigns[campaign.name] = campaign + + +def get_campaign(name: str) -> ba.Campaign: + """Return a campaign by name.""" + return _ba.app.campaigns[name] + + +class Campaign: + """Represents a unique set or series of ba.Levels.""" + + def __init__(self, name: str, sequential: bool = True): + self._name = name + self._levels: List[ba.Level] = [] + self._sequential = sequential + + @property + def name(self) -> str: + """The name of the Campaign.""" + return self._name + + @property + def sequential(self) -> bool: + """Whether this Campaign's levels must be played in sequence.""" + return self._sequential + + def add_level(self, level: ba.Level) -> None: + """Adds a ba.Level to the Campaign.""" + if level.get_campaign() is not None: + raise Exception("level already belongs to a campaign") + level.set_campaign(self, len(self._levels)) + self._levels.append(level) + + def get_levels(self) -> List[ba.Level]: + """Return the set of ba.Levels in the Campaign.""" + return self._levels + + def get_level(self, name: str) -> ba.Level: + """Return a contained ba.Level by name.""" + for level in self._levels: + if level.name == name: + return level + raise Exception("Level '" + name + "' not found in campaign '" + + self.name + "'") + + def reset(self) -> None: + """Reset state for the Campaign.""" + _ba.app.config.setdefault('Campaigns', {})[self._name] = {} + + # FIXME should these give/take ba.Level instances instead of level names?.. + def set_selected_level(self, levelname: str) -> None: + """Set the Level currently selected in the UI (by name).""" + self.get_config_dict()['Selection'] = levelname + _ba.app.config.commit() + + def get_selected_level(self) -> str: + """Return the name of the Level currently selected in the UI.""" + return self.get_config_dict().get('Selection', self._levels[0].name) + + def get_config_dict(self) -> Dict[str, Any]: + """Return the live config dict for this campaign.""" + val: Dict[str, Any] = (_ba.app.config.setdefault('Campaigns', + {}).setdefault( + self._name, {})) + assert isinstance(val, dict) + return val + + +def init_campaigns() -> None: + """Fill out initial default Campaigns.""" + # pylint: disable=too-many-statements + # pylint: disable=cyclic-import + from ba import _level + from bastd.game.onslaught import OnslaughtGame + from bastd.game.football import FootballCoopGame + from bastd.game.runaround import RunaroundGame + from bastd.game.thelaststand import TheLastStandGame + from bastd.game.race import RaceGame + from bastd.game.targetpractice import TargetPracticeGame + from bastd.game.meteorshower import MeteorShowerGame + from bastd.game.easteregghunt import EasterEggHuntGame + from bastd.game.ninjafight import NinjaFightGame + + # FIXME: Once translations catch up, we can convert these to use the + # generic display-name '${GAME} Training' type stuff. + campaign = Campaign('Easy') + campaign.add_level( + _level.Level('Onslaught Training', + gametype=OnslaughtGame, + settings={'preset': 'training_easy'}, + preview_texture_name='doomShroomPreview')) + campaign.add_level( + _level.Level('Rookie Onslaught', + gametype=OnslaughtGame, + settings={'preset': 'rookie_easy'}, + preview_texture_name='courtyardPreview')) + campaign.add_level( + _level.Level('Rookie Football', + gametype=FootballCoopGame, + settings={'preset': 'rookie_easy'}, + preview_texture_name='footballStadiumPreview')) + campaign.add_level( + _level.Level('Pro Onslaught', + gametype=OnslaughtGame, + settings={'preset': 'pro_easy'}, + preview_texture_name='doomShroomPreview')) + campaign.add_level( + _level.Level('Pro Football', + gametype=FootballCoopGame, + settings={'preset': 'pro_easy'}, + preview_texture_name='footballStadiumPreview')) + campaign.add_level( + _level.Level('Pro Runaround', + gametype=RunaroundGame, + settings={'preset': 'pro_easy'}, + preview_texture_name='towerDPreview')) + campaign.add_level( + _level.Level('Uber Onslaught', + gametype=OnslaughtGame, + settings={'preset': 'uber_easy'}, + preview_texture_name='courtyardPreview')) + campaign.add_level( + _level.Level('Uber Football', + gametype=FootballCoopGame, + settings={'preset': 'uber_easy'}, + preview_texture_name='footballStadiumPreview')) + campaign.add_level( + _level.Level('Uber Runaround', + gametype=RunaroundGame, + settings={'preset': 'uber_easy'}, + preview_texture_name='towerDPreview')) + register_campaign(campaign) + + # "hard" mode + campaign = Campaign('Default') + campaign.add_level( + _level.Level('Onslaught Training', + gametype=OnslaughtGame, + settings={'preset': 'training'}, + preview_texture_name='doomShroomPreview')) + campaign.add_level( + _level.Level('Rookie Onslaught', + gametype=OnslaughtGame, + settings={'preset': 'rookie'}, + preview_texture_name='courtyardPreview')) + campaign.add_level( + _level.Level('Rookie Football', + gametype=FootballCoopGame, + settings={'preset': 'rookie'}, + preview_texture_name='footballStadiumPreview')) + campaign.add_level( + _level.Level('Pro Onslaught', + gametype=OnslaughtGame, + settings={'preset': 'pro'}, + preview_texture_name='doomShroomPreview')) + campaign.add_level( + _level.Level('Pro Football', + gametype=FootballCoopGame, + settings={'preset': 'pro'}, + preview_texture_name='footballStadiumPreview')) + campaign.add_level( + _level.Level('Pro Runaround', + gametype=RunaroundGame, + settings={'preset': 'pro'}, + preview_texture_name='towerDPreview')) + campaign.add_level( + _level.Level('Uber Onslaught', + gametype=OnslaughtGame, + settings={'preset': 'uber'}, + preview_texture_name='courtyardPreview')) + campaign.add_level( + _level.Level('Uber Football', + gametype=FootballCoopGame, + settings={'preset': 'uber'}, + preview_texture_name='footballStadiumPreview')) + campaign.add_level( + _level.Level('Uber Runaround', + gametype=RunaroundGame, + settings={'preset': 'uber'}, + preview_texture_name='towerDPreview')) + campaign.add_level( + _level.Level('The Last Stand', + gametype=TheLastStandGame, + settings={}, + preview_texture_name='rampagePreview')) + register_campaign(campaign) + + # challenges: our 'official' random extra co-op levels + campaign = Campaign('Challenges', sequential=False) + campaign.add_level( + _level.Level('Infinite Onslaught', + gametype=OnslaughtGame, + settings={'preset': 'endless'}, + preview_texture_name='doomShroomPreview')) + campaign.add_level( + _level.Level('Infinite Runaround', + gametype=RunaroundGame, + settings={'preset': 'endless'}, + preview_texture_name='towerDPreview')) + campaign.add_level( + _level.Level('Race', + displayname='${GAME}', + gametype=RaceGame, + settings={ + 'map': 'Big G', + 'Laps': 3, + 'Bomb Spawning': 0 + }, + preview_texture_name='bigGPreview')) + campaign.add_level( + _level.Level('Pro Race', + displayname='Pro ${GAME}', + gametype=RaceGame, + settings={ + 'map': 'Big G', + 'Laps': 3, + 'Bomb Spawning': 1000 + }, + preview_texture_name='bigGPreview')) + campaign.add_level( + _level.Level('Lake Frigid Race', + displayname='${GAME}', + gametype=RaceGame, + settings={ + 'map': 'Lake Frigid', + 'Laps': 6, + 'Mine Spawning': 2000, + 'Bomb Spawning': 0 + }, + preview_texture_name='lakeFrigidPreview')) + campaign.add_level( + _level.Level('Football', + displayname='${GAME}', + gametype=FootballCoopGame, + settings={'preset': 'tournament'}, + preview_texture_name='footballStadiumPreview')) + campaign.add_level( + _level.Level('Pro Football', + displayname='Pro ${GAME}', + gametype=FootballCoopGame, + settings={'preset': 'tournament_pro'}, + preview_texture_name='footballStadiumPreview')) + campaign.add_level( + _level.Level('Runaround', + displayname='${GAME}', + gametype=RunaroundGame, + settings={'preset': 'tournament'}, + preview_texture_name='towerDPreview')) + campaign.add_level( + _level.Level('Uber Runaround', + displayname='Uber ${GAME}', + gametype=RunaroundGame, + settings={'preset': 'tournament_uber'}, + preview_texture_name='towerDPreview')) + campaign.add_level( + _level.Level('The Last Stand', + displayname='${GAME}', + gametype=TheLastStandGame, + settings={'preset': 'tournament'}, + preview_texture_name='rampagePreview')) + campaign.add_level( + _level.Level('Tournament Infinite Onslaught', + displayname='Infinite Onslaught', + gametype=OnslaughtGame, + settings={'preset': 'endless_tournament'}, + preview_texture_name='doomShroomPreview')) + campaign.add_level( + _level.Level('Tournament Infinite Runaround', + displayname='Infinite Runaround', + gametype=RunaroundGame, + settings={'preset': 'endless_tournament'}, + preview_texture_name='towerDPreview')) + campaign.add_level( + _level.Level('Target Practice', + displayname='Pro ${GAME}', + gametype=TargetPracticeGame, + settings={}, + preview_texture_name='doomShroomPreview')) + campaign.add_level( + _level.Level('Target Practice B', + displayname='${GAME}', + gametype=TargetPracticeGame, + settings={ + 'Target Count': 2, + 'Enable Impact Bombs': False, + 'Enable Triple Bombs': False + }, + preview_texture_name='doomShroomPreview')) + campaign.add_level( + _level.Level('Meteor Shower', + displayname='${GAME}', + gametype=MeteorShowerGame, + settings={}, + preview_texture_name='rampagePreview')) + campaign.add_level( + _level.Level('Epic Meteor Shower', + displayname='${GAME}', + gametype=MeteorShowerGame, + settings={'Epic Mode': True}, + preview_texture_name='rampagePreview')) + campaign.add_level( + _level.Level('Easter Egg Hunt', + displayname='${GAME}', + gametype=EasterEggHuntGame, + settings={}, + preview_texture_name='towerDPreview')) + campaign.add_level( + _level.Level('Pro Easter Egg Hunt', + displayname='Pro ${GAME}', + gametype=EasterEggHuntGame, + settings={'Pro Mode': True}, + preview_texture_name='towerDPreview')) + campaign.add_level( + _level.Level( + name='Ninja Fight', # (unique id not seen by player) + displayname='${GAME}', # (readable name seen by player) + gametype=NinjaFightGame, + settings={'preset': 'regular'}, + preview_texture_name='courtyardPreview')) + campaign.add_level( + _level.Level(name='Pro Ninja Fight', + displayname='Pro ${GAME}', + gametype=NinjaFightGame, + settings={'preset': 'pro'}, + preview_texture_name='courtyardPreview')) + register_campaign(campaign) diff --git a/assets/src/data/scripts/ba/_coopgame.py b/assets/src/data/scripts/ba/_coopgame.py new file mode 100644 index 00000000..103005bd --- /dev/null +++ b/assets/src/data/scripts/ba/_coopgame.py @@ -0,0 +1,270 @@ +"""Functionality related to co-op games.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +from ba._gameactivity import GameActivity + +if TYPE_CHECKING: + from typing import Type, Dict, Any, Set, List, Sequence, Optional + from bastd.actor.playerspaz import PlayerSpaz + import ba + + +class CoopGameActivity(GameActivity): + """Base class for cooperative-mode games. + + Category: Gameplay Classes + """ + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + from ba import _coopsession + return issubclass(sessiontype, _coopsession.CoopSession) + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings) + + # Cache these for efficiency. + self._achievements_awarded: Set[str] = set() + + self._life_warning_beep: Optional[ba.Actor] = None + self._life_warning_beep_timer: Optional[ba.Timer] = None + self._warn_beeps_sound = _ba.getsound('warnBeeps') + + def on_begin(self) -> None: + from ba import _general + super().on_begin() + + # Show achievements remaining. + if not _ba.app.kiosk_mode: + _ba.timer(3.8, + _general.WeakCall(self._show_remaining_achievements)) + + # Preload achievement images in case we get some. + _ba.timer(2.0, _general.WeakCall(self._preload_achievements)) + + # Let's ask the server for a 'time-to-beat' value. + levelname = self._get_coop_level_name() + campaign = self.session.campaign + assert campaign is not None + config_str = (str(len(self.players)) + "p" + campaign.get_level( + self.settings['name']).get_score_version_string().replace( + ' ', '_')) + _ba.get_scores_to_beat(levelname, config_str, + _general.WeakCall(self._on_got_scores_to_beat)) + + def _on_got_scores_to_beat(self, scores: List[Dict[str, Any]]) -> None: + pass + + def _show_standard_scores_to_beat_ui(self, + scores: List[Dict[str, Any]]) -> None: + from ba import _gameutils + from ba import _actor + from ba._enums import TimeFormat + display_type = self.get_score_type() + if scores is not None: + + # Sort by originating date so that the most recent is first. + scores.sort(reverse=True, key=lambda s: s['time']) + + # Now make a display for the most recent challenge. + for score in scores: + if score['type'] == 'score_challenge': + tval = ( + score['player'] + ': ' + + (_gameutils.timestring( + int(score['value']) * 10, + timeformat=TimeFormat.MILLISECONDS).evaluate() + if display_type == 'time' else str(score['value']))) + hattach = 'center' if display_type == 'time' else 'left' + halign = 'center' if display_type == 'time' else 'left' + pos = (20, -70) if display_type == 'time' else (20, -130) + txt = _actor.Actor( + _ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': hattach, + 'h_align': halign, + 'color': (0.7, 0.4, 1, 1), + 'shadow': 0.5, + 'flatness': 1.0, + 'position': pos, + 'scale': 0.6, + 'text': tval + })).autoretain() + assert txt.node is not None + _gameutils.animate(txt.node, 'scale', { + 1.0: 0.0, + 1.1: 0.7, + 1.2: 0.6 + }) + break + + # FIXME: this is now redundant with activityutils.get_score_info(); + # need to kill this. + def get_score_type(self) -> str: + """ + Return the score unit this co-op game uses ('point', 'seconds', etc.) + """ + return 'points' + + def _get_coop_level_name(self) -> str: + assert self.session.campaign is not None + return self.session.campaign.name + ":" + str(self.settings['name']) + + def celebrate(self, duration: float) -> None: + """Tells all existing player-controlled characters to celebrate. + + Can be useful in co-op games when the good guys score or complete + a wave. + duration is given in seconds. + """ + for player in self.players: + if player.actor is not None and player.actor.node: + player.actor.node.handlemessage('celebrate', + int(duration * 1000)) + + def _preload_achievements(self) -> None: + from ba import _achievement + achievements = _achievement.get_achievements_for_coop_level( + self._get_coop_level_name()) + for ach in achievements: + ach.get_icon_texture(True) + + def _show_remaining_achievements(self) -> None: + # pylint: disable=cyclic-import + from ba import _achievement + from ba import _lang + from bastd.actor.text import Text + ts_h_offs = 30 + v_offs = -200 + achievements = [ + a for a in _achievement.get_achievements_for_coop_level( + self._get_coop_level_name()) if not a.complete + ] + vrmode = _ba.app.vr_mode + if achievements: + Text(_lang.Lstr(resource='achievementsRemainingText'), + host_only=True, + position=(ts_h_offs - 10 + 40, v_offs - 10), + transition='fade_in', + scale=1.1, + h_attach="left", + v_attach="top", + color=(1, 1, 1.2, 1) if vrmode else (0.8, 0.8, 1.0, 1.0), + flatness=1.0 if vrmode else 0.6, + shadow=1.0 if vrmode else 0.5, + transition_delay=0.0, + transition_out_delay=1.3 + if self.slow_motion else 4000).autoretain() + hval = 70 + vval = -50 + tdelay = 0 + for ach in achievements: + tdelay += 50 + ach.create_display(hval + 40, + vval + v_offs, + 0 + tdelay, + outdelay=1300 if self.slow_motion else 4000, + style='in_game') + vval -= 55 + + def spawn_player_spaz(self, + player: ba.Player, + position: Sequence[float] = (0.0, 0.0, 0.0), + angle: float = None) -> PlayerSpaz: + """Spawn and wire up a standard player spaz.""" + spaz = super().spawn_player_spaz(player, position, angle) + + # Deaths are noteworthy in co-op games. + spaz.play_big_death_sound = True + return spaz + + def _award_achievement(self, achievement_name: str, + sound: bool = True) -> None: + """Award an achievement. + + Returns True if a banner will be shown; + False otherwise + """ + from ba import _achievement + + if achievement_name in self._achievements_awarded: + return + + ach = _achievement.get_achievement(achievement_name) + + # If we're in the easy campaign and this achievement is hard-mode-only, + # ignore it. + try: + campaign = self.session.campaign + assert campaign is not None + if ach.hard_mode_only and campaign.name == 'Easy': + return + except Exception: + from ba import _error + _error.print_exception() + + # If we haven't awarded this one, check to see if we've got it. + # If not, set it through the game service *and* add a transaction + # for it. + if not ach.complete: + self._achievements_awarded.add(achievement_name) + + # Report new achievements to the game-service. + _ba.report_achievement(achievement_name) + + # ...and to our account. + _ba.add_transaction({ + 'type': 'ACHIEVEMENT', + 'name': achievement_name + }) + + # Now bring up a celebration banner. + ach.announce_completion(sound=sound) + + def fade_to_red(self) -> None: + """Fade the screen to red; (such as when the good guys have lost).""" + from ba import _gameutils + c_existing = _gameutils.sharedobj('globals').tint + cnode = _ba.newnode("combine", + attrs={ + 'input0': c_existing[0], + 'input1': c_existing[1], + 'input2': c_existing[2], + 'size': 3 + }) + _gameutils.animate(cnode, 'input1', {0: c_existing[1], 2.0: 0}) + _gameutils.animate(cnode, 'input2', {0: c_existing[2], 2.0: 0}) + cnode.connectattr('output', _gameutils.sharedobj('globals'), 'tint') + + def setup_low_life_warning_sound(self) -> None: + """Set up a beeping noise to play when any players are near death.""" + from ba import _general + self._life_warning_beep = None + self._life_warning_beep_timer = _ba.Timer( + 1.0, _general.WeakCall(self._update_life_warning), repeat=True) + + def _update_life_warning(self) -> None: + # Beep continuously if anyone is close to death. + should_beep = False + for player in self.players: + if player.is_alive(): + # FIXME: Should abstract this instead of + # reading hitpoints directly. + if getattr(player.actor, 'hitpoints', 999) < 200: + should_beep = True + break + if should_beep and self._life_warning_beep is None: + from ba import _actor + self._life_warning_beep = _actor.Actor( + _ba.newnode('sound', + attrs={ + 'sound': self._warn_beeps_sound, + 'positional': False, + 'loop': True + })) + if self._life_warning_beep is not None and not should_beep: + self._life_warning_beep = None diff --git a/assets/src/data/scripts/ba/_coopsession.py b/assets/src/data/scripts/ba/_coopsession.py new file mode 100644 index 00000000..01e1ba81 --- /dev/null +++ b/assets/src/data/scripts/ba/_coopsession.py @@ -0,0 +1,382 @@ +"""Functionality related to coop-mode sessions.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +from ba._session import Session + +if TYPE_CHECKING: + from typing import Any, List, Dict, Optional, Callable, Sequence + import ba + +TEAM_COLORS = ((0.2, 0.4, 1.6), ) +TEAM_NAMES = ("Good Guys", ) + + +class CoopSession(Session): + """A ba.Session which runs cooperative-mode games. + + Category: Gameplay Classes + + These generally consist of 1-4 players against + the computer and include functionality such as + high score lists. + """ + + def __init__(self) -> None: + """Instantiate a co-op mode session.""" + # pylint: disable=cyclic-import + from ba._campaign import get_campaign + from bastd.activity.coopjoinscreen import CoopJoiningActivity + + _ba.increment_analytics_count('Co-op session start') + + app = _ba.app + + # If they passed in explicit min/max, honor that. + # Otherwise defer to user overrides or defaults. + if 'min_players' in app.coop_session_args: + min_players = app.coop_session_args['min_players'] + else: + min_players = 1 + if 'max_players' in app.coop_session_args: + max_players = app.coop_session_args['max_players'] + else: + try: + max_players = app.config['Coop Game Max Players'] + except Exception: + # Old pref value. + try: + max_players = app.config['Challenge Game Max Players'] + except Exception: + max_players = 4 + + print('FIXME: COOP SESSION WOULD CALC DEPS.') + depsets: Sequence[ba.DepSet] = [] + + super().__init__(depsets, + team_names=TEAM_NAMES, + team_colors=TEAM_COLORS, + use_team_colors=False, + min_players=min_players, + max_players=max_players, + allow_mid_activity_joins=False) + + # Tournament-ID if we correspond to a co-op tournament (otherwise None) + self.tournament_id = (app.coop_session_args['tournament_id'] + if 'tournament_id' in app.coop_session_args else + None) + + # FIXME: Could be nice to pass this in as actual args. + self.campaign_state = { + 'campaign': (app.coop_session_args['campaign']), + 'level': app.coop_session_args['level'] + } + self.campaign = get_campaign(self.campaign_state['campaign']) + + self._ran_tutorial_activity = False + self._tutorial_activity: Optional[ba.Activity] = None + self._custom_menu_ui: List[Dict[str, Any]] = [] + + # Start our joining screen. + self.set_activity(_ba.new_activity(CoopJoiningActivity)) + + self._next_game_instance: Optional[ba.GameActivity] = None + self._next_game_name: Optional[str] = None + self._update_on_deck_game_instances() + + def get_current_game_instance(self) -> ba.GameActivity: + """Get the game instance currently being played.""" + return self._current_game_instance + + def _update_on_deck_game_instances(self) -> None: + # pylint: disable=cyclic-import + from ba._gameactivity import GameActivity + + # Instantiates levels we might be running soon + # so they have time to load. + + # Build an instance for the current level. + assert self.campaign is not None + level = self.campaign.get_level(self.campaign_state['level']) + gametype = level.gametype + settings = level.get_settings() + + # Make sure all settings the game expects are present. + neededsettings = gametype.get_settings(type(self)) + for settingname, setting in neededsettings: + if settingname not in settings: + settings[settingname] = setting['default'] + + newactivity = _ba.new_activity(gametype, settings) + assert isinstance(newactivity, GameActivity) + self._current_game_instance: GameActivity = newactivity + + # Find the next level and build an instance for it too. + levels = self.campaign.get_levels() + level = self.campaign.get_level(self.campaign_state['level']) + + nextlevel: Optional[ba.Level] + if level.index < len(levels) - 1: + nextlevel = levels[level.index + 1] + else: + nextlevel = None + if nextlevel: + gametype = nextlevel.gametype + settings = nextlevel.get_settings() + + # Make sure all settings the game expects are present. + neededsettings = gametype.get_settings(type(self)) + for settingname, setting in neededsettings: + if settingname not in settings: + settings[settingname] = setting['default'] + + # We wanna be in the activity's context while taking it down. + newactivity = _ba.new_activity(gametype, settings) + assert isinstance(newactivity, GameActivity) + self._next_game_instance = newactivity + self._next_game_name = nextlevel.name + else: + self._next_game_instance = None + self._next_game_name = None + + # Special case: + # If our current level is 'onslaught training', instantiate + # our tutorial so its ready to go. (if we haven't run it yet). + if (self.campaign_state['level'] == 'Onslaught Training' + and self._tutorial_activity is None + and not self._ran_tutorial_activity): + from bastd.tutorial import TutorialActivity + self._tutorial_activity = _ba.new_activity(TutorialActivity) + + def get_custom_menu_entries(self) -> List[Dict[str, Any]]: + return self._custom_menu_ui + + def on_player_leave(self, player: ba.Player) -> None: + from ba._general import WeakCall + super().on_player_leave(player) + + # If all our players leave we wanna quit out of the session. + _ba.timer(2.0, WeakCall(self._end_session_if_empty)) + + def _end_session_if_empty(self) -> None: + activity = self.getactivity() + if activity is None: + return # Hmm what should we do in this case? + + # If there's still players in the current activity, we're good. + if activity.players: + return + + # If there's *no* players left in the current activity but there *is* + # in the session, restart the activity to pull them into the game + # (or quit if they're just in the lobby). + if activity is not None and not activity.players and self.players: + + # Special exception for tourney games; don't auto-restart these. + if self.tournament_id is not None: + self.end() + else: + # Don't restart joining activities; this probably means there's + # someone with a chooser up in that case. + if not activity.is_joining_activity: + self.restart() + + # Hmm; no players anywhere. lets just end the session. + else: + self.end() + + def _on_tournament_restart_menu_press(self, + resume_callback: Callable[[], Any] + ) -> None: + # pylint: disable=cyclic-import + from bastd.ui.tournamententry import TournamentEntryWindow + from ba._gameactivity import GameActivity + activity = self.getactivity() + if activity is not None and not activity.is_expired(): + assert self.tournament_id is not None + assert isinstance(activity, GameActivity) + TournamentEntryWindow(tournament_id=self.tournament_id, + tournament_activity=activity, + on_close_call=resume_callback) + + def restart(self) -> None: + """Restart the current game activity.""" + + # Tell the current activity to end with a 'restart' outcome. + # We use 'force' so that we apply even if end has already been called + # (but is in its delay period). + + # Make an exception if there's no players left. Otherwise this + # can override the default session end that occurs in that case. + if not self.players: + return + + # This method may get called from the UI context so make sure we + # explicitly run in the activity's context. + activity = self.getactivity() + if activity is not None and not activity.is_expired(): + activity.can_show_ad_on_death = True + with _ba.Context(activity): + activity.end(results={'outcome': 'restart'}, force=True) + + def on_activity_end(self, activity: ba.Activity, results: Any) -> None: + """Method override for co-op sessions. + + Jumps between co-op games and score screens. + """ + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + # pylint: disable=cyclic-import + from ba._activitytypes import JoiningActivity, TransitionActivity + from ba._lang import Lstr + from ba._general import WeakCall + from ba._coopgame import CoopGameActivity + from ba._gameresults import TeamGameResults + from bastd.tutorial import TutorialActivity + from bastd.activity.coopscorescreen import CoopScoreScreen + + app = _ba.app + + # If we're running a TeamGameActivity we'll have a TeamGameResults + # as results. Otherwise its an old CoopGameActivity so its giving + # us a dict of random stuff. + if isinstance(results, TeamGameResults): + outcome = 'defeat' # This can't be 'beaten'. + else: + try: + outcome = results['outcome'] + except Exception: + outcome = '' + + # If at any point we have no in-game players, quit out of the session + # (this can happen if someone leaves in the tutorial for instance). + active_players = [p for p in self.players if p.in_game] + if not active_players: + self.end() + return + + # If we're in a between-round activity or a restart-activity, + # hop into a round. + if (isinstance( + activity, + (JoiningActivity, CoopScoreScreen, TransitionActivity))): + + if outcome == 'next_level': + if self._next_game_instance is None: + raise Exception() + assert self._next_game_name is not None + self.campaign_state['level'] = self._next_game_name + next_game = self._next_game_instance + else: + next_game = self._current_game_instance + + # Special case: if we're coming from a joining-activity + # and will be going into onslaught-training, show the + # tutorial first. + if (isinstance(activity, JoiningActivity) + and self.campaign_state['level'] == 'Onslaught Training' + and not app.kiosk_mode): + if self._tutorial_activity is None: + raise Exception("tutorial not preloaded properly") + self.set_activity(self._tutorial_activity) + self._tutorial_activity = None + self._ran_tutorial_activity = True + self._custom_menu_ui = [] + + # Normal case; launch the next round. + else: + + # Reset stats for the new activity. + self.stats.reset() + for player in self.players: + + # Skip players that are still choosing a team. + if player.in_game: + self.stats.register_player(player) + self.stats.set_activity(next_game) + + # Now flip the current activity. + self.set_activity(next_game) + + if not app.kiosk_mode: + if self.tournament_id is not None: + self._custom_menu_ui = [{ + 'label': + Lstr(resource='restartText'), + 'resume_on_call': + False, + 'call': + WeakCall(self._on_tournament_restart_menu_press + ) + }] + else: + self._custom_menu_ui = [{ + 'label': Lstr(resource='restartText'), + 'call': WeakCall(self.restart) + }] + + # If we were in a tutorial, just pop a transition to get to the + # actual round. + elif isinstance(activity, TutorialActivity): + self.set_activity(_ba.new_activity(TransitionActivity)) + else: + + # Generic team games. + if isinstance(results, TeamGameResults): + player_info = results.get_player_info() + score = results.get_team_score(results.get_teams()[0]) + fail_message = None + score_order = ('decreasing' if results.get_lower_is_better() + else 'increasing') + if results.get_score_type() in ('seconds', 'milliseconds', + 'time'): + score_type = 'time' + # Results contains milliseconds; ScoreScreen wants + # hundredths; need to fix :-/ + if score is not None: + score //= 10 + else: + if results.get_score_type() != 'points': + print(("Unknown score type: '" + + results.get_score_type() + "'")) + score_type = 'points' + + # Old coop-game-specific results; should migrate away from these. + else: + player_info = (results['player_info'] + if 'player_info' in results else None) + score = results['score'] if 'score' in results else None + fail_message = (results['fail_message'] + if 'fail_message' in results else None) + score_order = (results['score_order'] + if 'score_order' in results else 'increasing') + activity_score_type = (activity.get_score_type() if isinstance( + activity, CoopGameActivity) else None) + assert activity_score_type is not None + score_type = activity_score_type + + # Looks like we were in a round - check the outcome and + # go from there. + if outcome == 'restart': + + # This will pop up back in the same round. + self.set_activity(_ba.new_activity(TransitionActivity)) + else: + self.set_activity( + _ba.new_activity( + CoopScoreScreen, { + 'player_info': player_info, + 'score': score, + 'fail_message': fail_message, + 'score_order': score_order, + 'score_type': score_type, + 'outcome': outcome, + 'campaign': self.campaign, + 'level': self.campaign_state['level'] + })) + + # No matter what, get the next 2 levels ready to go. + self._update_on_deck_game_instances() diff --git a/assets/src/data/scripts/ba/_dep.py b/assets/src/data/scripts/ba/_dep.py new file mode 100644 index 00000000..a52ad23a --- /dev/null +++ b/assets/src/data/scripts/ba/_dep.py @@ -0,0 +1,507 @@ +"""Functionality related to object/asset dependencies.""" +# pylint: disable=redefined-builtin + +from __future__ import annotations + +import weakref +from typing import (Generic, TypeVar, TYPE_CHECKING, cast, Type, overload) + +import _ba +from ba import _general + +if TYPE_CHECKING: + from typing import Optional, Any, Dict, List, Set + import ba + +T = TypeVar('T', bound='DepComponent') +TI = TypeVar('TI', bound='InstancedDepComponent') +TS = TypeVar('TS', bound='StaticDepComponent') + + +class Dependency(Generic[T]): + """A dependency on a DepComponent (with an optional config). + + Category: Dependency Classes + + This class is used to request and access functionality provided + by other DepComponent classes from a DepComponent class. + The class functions as a descriptor, allowing dependencies to + be added at a class level much the same as properties or methods + and then used with class instances to access those dependencies. + For instance, if you do 'floofcls = ba.Dependency(FloofClass)' you + would then be able to instantiate a FloofClass in your class's + methods via self.floofcls(). + """ + + def __init__(self, cls: Type[T], config: Any = None): + """Instantiate a Dependency given a ba.DepComponent subtype. + + Optionally, an arbitrary object can be passed as 'config' to + influence dependency calculation for the target class. + """ + self.cls: Type[T] = cls + self.config = config + self._hash: Optional[int] = None + + def get_hash(self) -> int: + """Return the dependency's hash, calculating it if necessary.""" + if self._hash is None: + self._hash = _general.make_hash((self.cls, self.config)) + return self._hash + + # NOTE: it appears that mypy is currently not able to do overloads based + # on the type of 'self', otherwise we could just overload this to + # return different things based on self's type and avoid the need for + # the fake dep classes below. + # See https://github.com/python/mypy/issues/5320 + # noinspection PyShadowingBuiltins + def __get__(self, obj: Any, type: Any = None) -> Any: + if obj is None: + raise TypeError("Dependency must be accessed through an instance.") + + # We expect to be instantiated from an already living DepComponent + # with valid dep-data in place.. + assert type is not None + depdata = getattr(obj, '_depdata') + if depdata is None: + raise RuntimeError("Invalid dependency access.") + assert isinstance(depdata, DepData) + + # Now look up the data for this particular dep + depset = depdata.depset() + assert isinstance(depset, DepSet) + assert self._hash in depset.depdatas + depdata = depset.depdatas[self._hash] + assert isinstance(depdata, DepData) + if depdata.valid is False: + raise RuntimeError( + f'Accessing DepComponent {depdata.cls} in an invalid state.') + assert self.cls.dep_get_payload(depdata) is not None + return self.cls.dep_get_payload(depdata) + + +# We define a 'Dep' which at runtime simply aliases the Dependency class +# but in type-checking points to two overloaded functions based on the argument +# type. This lets the type system know what type of object the Dep represents. +# (object instances in the case of StaticDep classes or object types in the +# case of regular deps) At some point hopefully we can replace this with a +# simple overload in Dependency.__get__ based on the type of self +# (see note above). +if not TYPE_CHECKING: + Dep = Dependency +else: + + class _InstanceDep(Dependency[TI]): + """Fake stub we use to tell the type system we provide a type.""" + + # noinspection PyShadowingBuiltins + def __get__(self, obj: Any, type: Any = None) -> Type[TI]: + return cast(Type[TI], None) + + class _StaticDep(Dependency[TS]): + """Fake stub we use to tell the type system we provide an instance.""" + + # noinspection PyShadowingBuiltins + def __get__(self, obj: Any, type: Any = None) -> TS: + return cast(TS, None) + + # pylint: disable=invalid-name + # noinspection PyPep8Naming + @overload + def Dep(cls: Type[TI], config: Any = None) -> _InstanceDep[TI]: + """test""" + return _InstanceDep(cls, config) + + # noinspection PyPep8Naming + @overload + def Dep(cls: Type[TS], config: Any = None) -> _StaticDep[TS]: + """test""" + return _StaticDep(cls, config) + + # noinspection PyPep8Naming + def Dep(cls: Any, config: Any = None) -> Any: + """test""" + return Dependency(cls, config) + + # pylint: enable=invalid-name + + +class BoundDepComponent: + """A DepComponent class bound to its DepSet data. + + Can be called to instantiate the class with its data properly in place.""" + + def __init__(self, cls: Any, depdata: DepData): + self.cls = cls + # BoundDepComponents can be stored on depdatas so we use weakrefs + # to avoid dependency cycles. + self.depdata = weakref.ref(depdata) + + def __call__(self, *args: Any, **keywds: Any) -> Any: + # We don't simply call our target type to instantiate it; + # instead we manually call __new__ and then __init__. + # This allows us to inject its data properly before __init__(). + obj = self.cls.__new__(self.cls, *args, **keywds) + obj._depdata = self.depdata() + assert isinstance(obj._depdata, DepData) + obj.__init__(*args, **keywds) + return obj + + +class DepComponent: + """Base class for all classes that can act as dependencies. + + category: Dependency Classes + """ + + _depdata: DepData + + def __init__(self) -> None: + """Instantiate a DepComponent.""" + + # For now lets issue a warning if these are instantiated without + # data; we'll make this an error once we're no longer seeing warnings. + depdata = getattr(self, '_depdata', None) + if depdata is None: + print(f'FIXME: INSTANTIATING DEP CLASS {type(self)} DIRECTLY.') + + self.context = _ba.Context('current') + + @classmethod + def is_present(cls, config: Any = None) -> bool: + """Return whether this component/config is present on this device.""" + del config # Unused here. + return True + + @classmethod + def get_dynamic_deps(cls, config: Any = None) -> List[Dependency]: + """Return any dynamically-calculated deps for this component/config. + + Deps declared statically as part of the class do not need to be + included here; this is only for additional deps that may vary based + on the dep config value. (for instance a map required by a game type) + """ + del config # Unused here. + return [] + + @classmethod + def dep_get_payload(cls, depdata: DepData) -> Any: + """Return user-facing data for a loaded dep. + + If this dep does not yet have a 'payload' value, it should + be generated and cached. Otherwise the existing value + should be returned. + This is the value given for a DepComponent when accessed + through a Dependency instance on a live object, etc. + """ + del depdata # Unused here. + + +class DepData: + """Data associated with a dependency in a dependency set.""" + + def __init__(self, depset: DepSet, dep: Dependency[T]): + # Note: identical Dep/config pairs will share data, so the dep + # entry on a given Dep may not point to. + self.cls = dep.cls + self.config = dep.config + + # Arbitrary data for use by dependencies in the resolved set + # (the static instance for static-deps, etc). + self.payload: Any = None + self.valid: bool = False + + # Weakref to the depset that includes us (to avoid ref loop). + self.depset = weakref.ref(depset) + + +class DepSet(Generic[TI]): + """Set of resolved dependencies and their associated data.""" + + def __init__(self, root: Dependency[TI]): + self.root = root + self._resolved = False + + # Dependency data indexed by hash. + self.depdatas: Dict[int, DepData] = {} + + # Instantiated static-components. + self.static_instances: List[StaticDepComponent] = [] + + def __del__(self) -> None: + # When our dep-set goes down, clear out all dep-data payloads + # so we can throw errors if anyone tries to use them anymore. + for depdata in self.depdatas.values(): + depdata.payload = None + depdata.valid = False + + def resolve(self) -> None: + """Resolve the total set of required dependencies for the set. + + Raises a ba.DependencyError if dependencies are missing (or other + Exception types on other errors). + """ + + if self._resolved: + raise Exception("DepSet has already been resolved.") + + print('RESOLVING DEP SET') + + # First, recursively expand out all dependencies. + self._resolve(self.root, 0) + + # Now, if any dependencies are not present, raise an Exception + # telling exactly which ones (so hopefully they'll be able to be + # downloaded/etc. + missing = [ + Dependency(entry.cls, entry.config) + for entry in self.depdatas.values() + if not entry.cls.is_present(entry.config) + ] + if missing: + from ba._error import DependencyError + raise DependencyError(missing) + + self._resolved = True + print('RESOLVE SUCCESS!') + + def get_asset_package_ids(self) -> Set[str]: + """Return the set of asset-package-ids required by this dep-set. + + Must be called on a resolved dep-set. + """ + ids: Set[str] = set() + if not self._resolved: + raise Exception('Must be called on a resolved dep-set.') + for entry in self.depdatas.values(): + if issubclass(entry.cls, AssetPackage): + assert isinstance(entry.config, str) + ids.add(entry.config) + return ids + + def load(self) -> Type[TI]: + """Attach the resolved set to the current context. + + Returns a wrapper which can be used to instantiate the root dep. + """ + # NOTE: stuff below here should probably go in a separate 'instantiate' + # method or something. + if not self._resolved: + raise Exception("Can't instantiate an unresolved DepSet") + + # Go through all of our dep entries and give them a chance to + # preload whatever they want. + for entry in self.depdatas.values(): + # First mark everything as valid so recursive loads don't fail. + assert entry.valid is False + entry.valid = True + for entry in self.depdatas.values(): + # Do a get on everything which will init all payloads + # in the proper order recursively. + # NOTE: should we guard for recursion here?... + entry.cls.dep_get_payload(entry) + + # NOTE: like above, we're cheating here and telling the type + # system we're simply returning the root dependency class, when + # actually it's a bound-dependency wrapper containing its data/etc. + # ..Should fix if/when mypy is smart enough to preserve type safety + # on the wrapper's __call__() + rootdata = self.depdatas[self.root.get_hash()] + return cast(Type[TI], BoundDepComponent(self.root.cls, rootdata)) + + def _resolve(self, dep: Dependency[T], recursion: int) -> None: + + # Watch for wacky infinite dep loops. + if recursion > 10: + raise Exception('Max recursion reached') + + hashval = dep.get_hash() + + if hashval in self.depdatas: + # Found an already resolved one; we're done here. + return + + # Add our entry before we recurse so we don't repeat add it if + # there's a dependency loop. + self.depdatas[hashval] = DepData(self, dep) + + # Grab all Dependency instances we find in the class. + subdeps = [ + cls for cls in dep.cls.__dict__.values() + if isinstance(cls, Dependency) + ] + + # ..and add in any dynamic ones it provides. + subdeps += dep.cls.get_dynamic_deps(dep.config) + for subdep in subdeps: + self._resolve(subdep, recursion + 1) + + +class InstancedDepComponent(DepComponent): + """Base class for DepComponents intended to be instantiated as needed.""" + + @classmethod + def dep_get_payload(cls, depdata: DepData) -> Any: + """Data provider override; returns a BoundDepComponent.""" + if depdata.payload is None: + # The payload we want for ourself in the dep-set is simply + # the bound-def that users can use to instantiate our class + # with its data properly intact. We could also just store + # the class and instantiate one of these each time. + depdata.payload = BoundDepComponent(cls, depdata) + return depdata.payload + + +class StaticDepComponent(DepComponent): + """Base for DepComponents intended to be instanced once and shared.""" + + @classmethod + def dep_get_payload(cls, depdata: DepData) -> Any: + """Data provider override; returns shared instance.""" + if depdata.payload is None: + # We want to share a single instance of our object with anything + # in the set that requested it, so create a temp bound-dep and + # create an instance from that. + depcls = BoundDepComponent(cls, depdata) + + # Instances have a strong ref to depdata so we can't give + # depdata a strong reference to it without creating a cycle. + # We also can't just weak-ref the instance or else it won't be + # kept alive. Our solution is to stick strong refs to all static + # components somewhere on the DepSet. + instance = depcls() + assert depdata.depset + depset2 = depdata.depset() + assert depset2 is not None + depset2.static_instances.append(instance) + depdata.payload = weakref.ref(instance) + assert isinstance(depdata.payload, weakref.ref) + payload = depdata.payload() + if payload is None: + raise RuntimeError( + f'Accessing DepComponent {cls} in an invalid state.') + return payload + + +class AssetPackage(StaticDepComponent): + """DepComponent representing a bundled package of game assets.""" + + def __init__(self) -> None: + super().__init__() + # pylint: disable=no-member + assert isinstance(self._depdata.config, str) + self.package_id = self._depdata.config + print(f'LOADING ASSET PACKAGE {self.package_id}') + + @classmethod + def is_present(cls, config: Any = None) -> bool: + assert isinstance(config, str) + + # Temp: hard-coding for a single asset-package at the moment. + if config == 'stdassets@1': + return True + return False + + def gettexture(self, name: str) -> ba.Texture: + """Load a named ba.Texture from the AssetPackage. + + Behavior is similar to ba.gettexture() + """ + return _ba.get_package_texture(self, name) + + def getmodel(self, name: str) -> ba.Model: + """Load a named ba.Model from the AssetPackage. + + Behavior is similar to ba.getmodel() + """ + return _ba.get_package_model(self, name) + + def getcollidemodel(self, name: str) -> ba.CollideModel: + """Load a named ba.CollideModel from the AssetPackage. + + Behavior is similar to ba.getcollideModel() + """ + return _ba.get_package_collide_model(self, name) + + def getsound(self, name: str) -> ba.Sound: + """Load a named ba.Sound from the AssetPackage. + + Behavior is similar to ba.getsound() + """ + return _ba.get_package_sound(self, name) + + def getdata(self, name: str) -> ba.Data: + """Load a named ba.Data from the AssetPackage. + + Behavior is similar to ba.getdata() + """ + return _ba.get_package_data(self, name) + + +class TestClassFactory(StaticDepComponent): + """Another test dep-obj.""" + + _assets = Dep(AssetPackage, 'stdassets@1') + + def __init__(self) -> None: + super().__init__() + print("Instantiating TestClassFactory") + self.tex = self._assets.gettexture('black') + self.model = self._assets.getmodel('landMine') + self.sound = self._assets.getsound('error') + self.data = self._assets.getdata('langdata') + + +class TestClassObj(InstancedDepComponent): + """Another test dep-obj.""" + + +class TestClass(InstancedDepComponent): + """A test dep-obj.""" + + _actorclass = Dep(TestClassObj) + _factoryclass = Dep(TestClassFactory, 123) + _factoryclass2 = Dep(TestClassFactory, 124) + + def __init__(self, arg: int) -> None: + super().__init__() + del arg + self._actor = self._actorclass() + print('got actor', self._actor) + print('have factory', self._factoryclass) + print('have factory2', self._factoryclass2) + + +def test_depset() -> None: + """Test call to try this stuff out...""" + # noinspection PyUnreachableCode + if False: # pylint: disable=using-constant-test + print('running test_depset()...') + + def doit() -> None: + from ba._error import DependencyError + depset = DepSet(Dep(TestClass)) + resolved = False + try: + depset.resolve() + resolved = True + except DependencyError as exc: + for dep in exc.deps: + if dep.cls is AssetPackage: + print('MISSING PACKAGE', dep.config) + else: + raise Exception('unknown dependency error for ' + + str(dep.cls)) + except Exception as exc: + print('DepSet resolve failed with exc type:', type(exc)) + if resolved: + testclass = depset.load() + instance = testclass(123) + print("INSTANTIATED ROOT:", instance) + + doit() + + # To test this, add prints on __del__ for stuff used above; + # everything should be dead at this point if we have no cycles. + print('everything should be cleaned up...') + _ba.quit() diff --git a/assets/src/data/scripts/ba/_enums.py b/assets/src/data/scripts/ba/_enums.py new file mode 100644 index 00000000..4304df09 --- /dev/null +++ b/assets/src/data/scripts/ba/_enums.py @@ -0,0 +1,139 @@ +"""Enums generated by tools/update_python_enums_module in core.""" + +from enum import Enum + + +class TimeType(Enum): + """Specifies the type of time for various operations to target/use. + + Category: Enums + + 'sim' time is the local simulation time for an activity or session. + It can proceed at different rates depending on game speed, stops + for pauses, etc. + + 'base' is the baseline time for an activity or session. It proceeds + consistently regardless of game speed or pausing, but may stop during + occurrences such as network outages. + + 'real' time is mostly based on clock time, with a few exceptions. It may + not advance while the app is backgrounded for instance. (the engine + attempts to prevent single large time jumps from occurring) + """ + SIM = 0 + BASE = 1 + REAL = 2 + + +class TimeFormat(Enum): + """Specifies the format time values are provided in. + + Category: Enums + """ + SECONDS = 0 + MILLISECONDS = 1 + + +class Permission(Enum): + """Permissions that can be requested from the OS. + + Category: Enums + """ + STORAGE = 0 + + +class SpecialChar(Enum): + """Special characters the game can print. + + Category: Enums + """ + DOWN_ARROW = 0 + UP_ARROW = 1 + LEFT_ARROW = 2 + RIGHT_ARROW = 3 + TOP_BUTTON = 4 + LEFT_BUTTON = 5 + RIGHT_BUTTON = 6 + BOTTOM_BUTTON = 7 + DELETE = 8 + SHIFT = 9 + BACK = 10 + LOGO_FLAT = 11 + REWIND_BUTTON = 12 + PLAY_PAUSE_BUTTON = 13 + FAST_FORWARD_BUTTON = 14 + DPAD_CENTER_BUTTON = 15 + OUYA_BUTTON_O = 16 + OUYA_BUTTON_U = 17 + OUYA_BUTTON_Y = 18 + OUYA_BUTTON_A = 19 + OUYA_LOGO = 20 + LOGO = 21 + TICKET = 22 + GOOGLE_PLAY_GAMES_LOGO = 23 + GAME_CENTER_LOGO = 24 + DICE_BUTTON1 = 25 + DICE_BUTTON2 = 26 + DICE_BUTTON3 = 27 + DICE_BUTTON4 = 28 + GAME_CIRCLE_LOGO = 29 + PARTY_ICON = 30 + TEST_ACCOUNT = 31 + TICKET_BACKING = 32 + TROPHY1 = 33 + TROPHY2 = 34 + TROPHY3 = 35 + TROPHY0A = 36 + TROPHY0B = 37 + TROPHY4 = 38 + LOCAL_ACCOUNT = 39 + ALIBABA_LOGO = 40 + FLAG_UNITED_STATES = 41 + FLAG_MEXICO = 42 + FLAG_GERMANY = 43 + FLAG_BRAZIL = 44 + FLAG_RUSSIA = 45 + FLAG_CHINA = 46 + FLAG_UNITED_KINGDOM = 47 + FLAG_CANADA = 48 + FLAG_INDIA = 49 + FLAG_JAPAN = 50 + FLAG_FRANCE = 51 + FLAG_INDONESIA = 52 + FLAG_ITALY = 53 + FLAG_SOUTH_KOREA = 54 + FLAG_NETHERLANDS = 55 + FEDORA = 56 + HAL = 57 + CROWN = 58 + YIN_YANG = 59 + EYE_BALL = 60 + SKULL = 61 + HEART = 62 + DRAGON = 63 + HELMET = 64 + MUSHROOM = 65 + NINJA_STAR = 66 + VIKING_HELMET = 67 + MOON = 68 + SPIDER = 69 + FIREBALL = 70 + FLAG_UNITED_ARAB_EMIRATES = 71 + FLAG_QATAR = 72 + FLAG_EGYPT = 73 + FLAG_KUWAIT = 74 + FLAG_ALGERIA = 75 + FLAG_SAUDI_ARABIA = 76 + FLAG_MALAYSIA = 77 + FLAG_CZECH_REPUBLIC = 78 + FLAG_AUSTRALIA = 79 + FLAG_SINGAPORE = 80 + OCULUS_LOGO = 81 + STEAM_LOGO = 82 + NVIDIA_LOGO = 83 + FLAG_IRAN = 84 + FLAG_POLAND = 85 + FLAG_ARGENTINA = 86 + FLAG_PHILIPPINES = 87 + FLAG_CHILE = 88 + MIKIROG = 89 diff --git a/assets/src/data/scripts/ba/_error.py b/assets/src/data/scripts/ba/_error.py new file mode 100644 index 00000000..4df44d1c --- /dev/null +++ b/assets/src/data/scripts/ba/_error.py @@ -0,0 +1,194 @@ +"""Error related functionality.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Any, List + import ba + + +class _UnhandledType: + pass + + +# A special value that should be returned from handlemessage() +# functions for unhandled message types. This may result +# in fallback message types being attempted/etc. +UNHANDLED = _UnhandledType() + + +class DependencyError(Exception): + """Exception raised when one or more ba.Dependency items are missing. + + category: Exception Classes + + (this will generally be missing assets). + """ + + def __init__(self, deps: List[ba.Dependency]): + super().__init__() + self._deps = deps + + @property + def deps(self) -> List[ba.Dependency]: + """The list of missing dependencies causing this error.""" + return self._deps + + +class NotFoundError(Exception): + """Exception raised when a referenced object does not exist. + + category: Exception Classes + """ + + +class PlayerNotFoundError(NotFoundError): + """Exception raised when an expected ba.Player does not exist. + + category: Exception Classes + """ + + +class TeamNotFoundError(NotFoundError): + """Exception raised when an expected ba.Team does not exist. + + category: Exception Classes + """ + + +class NodeNotFoundError(NotFoundError): + """Exception raised when an expected ba.Node does not exist. + + category: Exception Classes + """ + + +class ActorNotFoundError(NotFoundError): + """Exception raised when an expected ba.Actor does not exist. + + category: Exception Classes + """ + + +class ActivityNotFoundError(NotFoundError): + """Exception raised when an expected ba.Activity does not exist. + + category: Exception Classes + """ + + +class SessionNotFoundError(NotFoundError): + """Exception raised when an expected ba.Session does not exist. + + category: Exception Classes + """ + + +class InputDeviceNotFoundError(NotFoundError): + """Exception raised when an expected ba.InputDevice does not exist. + + category: Exception Classes + """ + + +class WidgetNotFoundError(NotFoundError): + """Exception raised when an expected ba.Widget does not exist. + + category: Exception Classes + """ + + +def exc_str() -> str: + """Returns a tidied up string for the current exception. + + This performs some minor cleanup such as printing paths relative + to script dirs (full paths are often unwieldy in game installs). + """ + import traceback + excstr = traceback.format_exc() + for path in sys.path: + excstr = excstr.replace(path + '/', '') + return excstr + + +def print_exception(*args: Any, **keywds: Any) -> None: + """Print info about an exception along with pertinent context state. + + category: General Utility Functions + + Prints all arguments provided along with various info about the + current context and the outstanding exception. + Pass the keyword 'once' as True if you want the call to only happen + one time from an exact calling location. + """ + import traceback + if keywds: + allowed_keywds = ['once'] + if any(keywd not in allowed_keywds for keywd in keywds): + raise Exception("invalid keyword(s)") + try: + # If we're only printing once and already have, bail. + if keywds.get('once', False): + if not _ba.do_once(): + return + + # Most tracebacks are gonna have ugly long install directories in them; + # lets strip those out when we can. + err_str = ' '.join([str(a) for a in args]) + print('ERROR:', err_str) + _ba.print_context() + print('PRINTED-FROM:') + + # Basically the output of traceback.print_stack() slightly prettified: + stackstr = ''.join(traceback.format_stack()) + for path in sys.path: + stackstr = stackstr.replace(path + '/', '') + print(stackstr, end='') + print('EXCEPTION:') + + # Basically the output of traceback.print_exc() slightly prettified: + excstr = traceback.format_exc() + for path in sys.path: + excstr = excstr.replace(path + '/', '') + print('\n'.join(' ' + l for l in excstr.splitlines())) + except Exception: + # I suppose using print_exception here would be a bad idea. + print('ERROR: exception in ba.print_exception():') + traceback.print_exc() + + +def print_error(err_str: str, once: bool = False) -> None: + """Print info about an error along with pertinent context state. + + category: General Utility Functions + + Prints all positional arguments provided along with various info about the + current context. + Pass the keyword 'once' as True if you want the call to only happen + one time from an exact calling location. + """ + import traceback + try: + # If we're only printing once and already have, bail. + if once: + if not _ba.do_once(): + return + + # Most tracebacks are gonna have ugly long install directories in them; + # lets strip those out when we can. + print('ERROR:', err_str) + _ba.print_context() + + # Basically the output of traceback.print_stack() slightly prettified: + stackstr = ''.join(traceback.format_stack()) + for path in sys.path: + stackstr = stackstr.replace(path + '/', '') + print(stackstr, end='') + except Exception: + print('ERROR: exception in ba.print_error():') + traceback.print_exc() diff --git a/assets/src/data/scripts/ba/_freeforallsession.py b/assets/src/data/scripts/ba/_freeforallsession.py new file mode 100644 index 00000000..1f5a3118 --- /dev/null +++ b/assets/src/data/scripts/ba/_freeforallsession.py @@ -0,0 +1,95 @@ +"""Functionality related to free-for-all sessions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +from ba._teambasesession import TeamBaseSession + +if TYPE_CHECKING: + from typing import Dict + import ba + + +class FreeForAllSession(TeamBaseSession): + """ba.Session type for free-for-all mode games. + + Category: Gameplay Classes + """ + _use_teams = False + _playlist_selection_var = 'Free-for-All Playlist Selection' + _playlist_randomize_var = 'Free-for-All Playlist Randomize' + _playlists_var = 'Free-for-All Playlists' + + def get_ffa_point_awards(self) -> Dict[int, int]: + """Return the number of points awarded for different rankings. + + This is based on the current number of players. + """ + point_awards: Dict[int, int] + if len(self.players) == 1: + point_awards = {} + elif len(self.players) == 2: + point_awards = {0: 6} + elif len(self.players) == 3: + point_awards = {0: 6, 1: 3} + elif len(self.players) == 4: + point_awards = {0: 8, 1: 4, 2: 2} + elif len(self.players) == 5: + point_awards = {0: 8, 1: 4, 2: 2} + elif len(self.players) == 6: + point_awards = {0: 8, 1: 4, 2: 2} + else: + point_awards = {0: 8, 1: 4, 2: 2, 3: 1} + return point_awards + + def __init__(self) -> None: + _ba.increment_analytics_count('Free-for-all session start') + super().__init__() + + def _switch_to_score_screen(self, results: ba.TeamGameResults) -> None: + # pylint: disable=cyclic-import + from bastd.activity import drawscreen + from bastd.activity import multiteamendscreen + from bastd.activity import freeforallendscreen + winners = results.get_winners() + + # If there's multiple players and everyone has the same score, + # call it a draw. + if len(self.players) > 1 and len(winners) < 2: + self.set_activity( + _ba.new_activity(drawscreen.DrawScoreScreenActivity, + {'results': results})) + else: + # Award different point amounts based on number of players. + point_awards = self.get_ffa_point_awards() + + for i, winner in enumerate(winners): + for team in winner.teams: + points = (point_awards[i] if i in point_awards else 0) + team.sessiondata['previous_score'] = ( + team.sessiondata['score']) + team.sessiondata['score'] += points + + series_winners = [ + team for team in self.teams + if team.sessiondata['score'] >= self._ffa_series_length + ] + series_winners.sort(reverse=True, + key=lambda tm: (tm.sessiondata['score'])) + if (len(series_winners) == 1 + or (len(series_winners) > 1 + and series_winners[0].sessiondata['score'] != + series_winners[1].sessiondata['score'])): + self.set_activity( + _ba.new_activity( + multiteamendscreen. + TeamSeriesVictoryScoreScreenActivity, + {'winner': series_winners[0]})) + else: + self.set_activity( + _ba.new_activity( + freeforallendscreen. + FreeForAllVictoryScoreScreenActivity, + {'results': results})) diff --git a/assets/src/data/scripts/ba/_gameactivity.py b/assets/src/data/scripts/ba/_gameactivity.py new file mode 100644 index 00000000..c9659f91 --- /dev/null +++ b/assets/src/data/scripts/ba/_gameactivity.py @@ -0,0 +1,1357 @@ +"""Provides GameActivity class.""" +# pylint: disable=too-many-lines + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import _ba +from ba._activity import Activity +from ba._lang import Lstr + +if TYPE_CHECKING: + from typing import (List, Optional, Dict, Type, Any, Callable, Sequence, + Tuple, Union) + from bastd.actor.playerspaz import PlayerSpaz + import ba + + +class GameActivity(Activity): + """Common base class for all game ba.Activities. + + category: Gameplay Classes + """ + # pylint: disable=too-many-public-methods + + tips: List[Union[str, Dict[str, Any]]] = [] + + @classmethod + def create_config_ui( + cls, sessionclass: Type[ba.Session], + config: Optional[Dict[str, Any]], + completion_call: Callable[[Optional[Dict[str, Any]]], None] + ) -> None: + """Launch an in-game UI to configure settings for a game type. + + 'sessionclass' should be the ba.Session class the game will be used in. + + 'config' should be an existing config dict (specifies 'edit' ui mode) + or None (specifies 'add' ui mode). + + 'completion_call' will be called with a filled-out config dict on + success or None on cancel. + + Generally subclasses don't need to override this; if they override + ba.GameActivity.get_settings() and ba.GameActivity.get_supported_maps() + they can just rely on the default implementation here which calls those + methods. + """ + delegate = _ba.app.delegate + assert delegate is not None + delegate.create_default_game_config_ui(cls, sessionclass, config, + completion_call) + + @classmethod + def get_score_info(cls) -> Dict[str, Any]: + """Return info about game scoring setup; should be overridden by games. + + They should return a dict containing any of the following (missing + values will be default): + + 'score_name': a label shown to the user for scores; 'Score', + 'Time Survived', etc. 'Score' is the default. + + 'lower_is_better': a boolean telling whether lower scores are + preferable instead of higher (the default). + + 'none_is_winner': specifies whether a score value of None is considered + better than other scores or worse. Default is False. + + 'score_type': can be 'seconds', 'milliseconds', or 'points'. + + 'score_version': to change high-score lists used by a game without + renaming the game, change this. Defaults to empty string. + """ + return {} + + @classmethod + def get_resolved_score_info(cls) -> Dict[str, Any]: + """ + Call this to return a game's score info with all missing values + filled in with defaults. This should not be overridden; override + get_score_info() instead. + """ + values = cls.get_score_info() + if 'score_name' not in values: + values['score_name'] = 'Score' + if 'lower_is_better' not in values: + values['lower_is_better'] = False + if 'none_is_winner' not in values: + values['none_is_winner'] = False + if 'score_type' not in values: + values['score_type'] = 'points' + if 'score_version' not in values: + values['score_version'] = '' + + if values['score_type'] not in ['seconds', 'milliseconds', 'points']: + raise Exception("invalid score_type value: '" + + values['score_type'] + "'") + + # make sure they didn't misspell anything in there.. + for name in list(values.keys()): + if name not in ('score_name', 'lower_is_better', 'none_is_winner', + 'score_type', 'score_version'): + print('WARNING: invalid key in score_info: "' + name + '"') + + return values + + @classmethod + def get_name(cls) -> str: + """Return a str name for this game type.""" + try: + return cls.__module__.replace('_', ' ') + except Exception: + return 'Untitled Game' + + @classmethod + def get_display_string(cls, settings: Optional[Dict] = None) -> ba.Lstr: + """Return a descriptive name for this game/settings combo. + + Subclasses should override get_name(); not this. + """ + name = Lstr(translate=('gameNames', cls.get_name())) + + # A few substitutions for 'Epic', 'Solo' etc. modes. + # FIXME: Should provide a way for game types to define filters of + # their own. + if settings is not None: + if 'Solo Mode' in settings and settings['Solo Mode']: + name = Lstr(resource='soloNameFilterText', + subs=[('${NAME}', name)]) + if 'Epic Mode' in settings and settings['Epic Mode']: + name = Lstr(resource='epicNameFilterText', + subs=[('${NAME}', name)]) + + return name + + @classmethod + def get_team_display_string(cls, name: str) -> ba.Lstr: + """Given a team name, returns a localized version of it.""" + return Lstr(translate=('teamNames', name)) + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + """ + Subclasses should override this to return a description for this + activity type (in English) within the context of the given + ba.Session type. + """ + del sessiontype # unused arg + return '' + + @classmethod + def get_description_display_string(cls, sessiontype: Type[ba.Session] + ) -> ba.Lstr: + """Return a translated version of get_description(). + + Sub-classes should override get_description(); not this. + """ + description = cls.get_description(sessiontype) + return Lstr(translate=('gameDescriptions', description)) + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + """ + Called by the default ba.GameActivity.create_config_ui() + implementation; should return a dict of config options to be presented + to the user for the given ba.Session type. + + The format for settings is a list of 2-member tuples consisting + of a name and a dict of options. + + Available Setting Options: + + 'default': This determines the default value as well as the + type (int, float, or bool) + + 'min_value': Minimum value for int/float settings. + + 'max_value': Maximum value for int/float settings. + + 'choices': A list of name/value pairs the user can choose from by name. + + 'increment': Value increment for int/float settings. + + # example get_settings() implementation for a capture-the-flag game: + @classmethod + def get_settings(cls,sessiontype): + return [("Score to Win", { + 'default': 3, + 'min_value': 1 + }), + ("Flag Touch Return Time", { + 'default': 0, + 'min_value': 0, + 'increment': 1 + }), + ("Flag Idle Return Time", { + 'default': 30, + 'min_value': 5, + 'increment': 5 + }), + ("Time Limit", { + 'default': 0, + 'choices': [ + ('None', 0), ('1 Minute', 60), ('2 Minutes', 120), + ('5 Minutes', 300), ('10 Minutes', 600), + ('20 Minutes', 1200) + ] + }), + ("Respawn Times", { + 'default': 1.0, + 'choices': [ + ('Shorter', 0.25), ('Short', 0.5), ('Normal', 1.0), + ('Long', 2.0), ('Longer', 4.0) + ] + }), + ("Epic Mode", { + 'default': False + })] + """ + del sessiontype # unused arg + return [] + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + """ + Called by the default ba.GameActivity.create_config_ui() + implementation; should return a list of map names valid + for this game-type for the given ba.Session type. + """ + from ba import _maps + del sessiontype # unused arg + return _maps.getmaps("melee") + + @classmethod + def get_config_display_string(cls, config: Dict[str, Any]) -> ba.Lstr: + """Given a game config dict, return a short description for it. + + This is used when viewing game-lists or showing what game + is up next in a series. + """ + from ba import _maps + name = cls.get_display_string(config['settings']) + + # in newer configs, map is in settings; it used to be in the + # config root + if 'map' in config['settings']: + sval = Lstr(value="${NAME} @ ${MAP}", + subs=[('${NAME}', name), + ('${MAP}', + _maps.get_map_display_string( + _maps.get_filtered_map_name( + config['settings']['map'])))]) + elif 'map' in config: + sval = Lstr(value="${NAME} @ ${MAP}", + subs=[('${NAME}', name), + ('${MAP}', + _maps.get_map_display_string( + _maps.get_filtered_map_name(config['map']))) + ]) + else: + print('invalid game config - expected map entry under settings') + sval = Lstr(value='???') + return sval + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + """Return whether this game supports the provided Session type.""" + from ba import _teambasesession + + # By default, games support any versus mode + return issubclass(sessiontype, _teambasesession.TeamBaseSession) + + def __init__(self, settings: Dict[str, Any]): + """Instantiate the Activity.""" + from ba import _maps + super().__init__(settings) + + # Set some defaults. + self.allow_pausing = True + self.allow_kick_idle_players = True + self._spawn_sound = _ba.getsound('spawn') + + # Whether to show points for kills. + self._show_kill_points = True + + # Go ahead and get our map loading. + map_name: str + if 'map' in settings: + map_name = settings['map'] + else: + # If settings doesn't specify a map, pick a random one from the + # list of supported ones. + unowned_maps = _maps.get_unowned_maps() + valid_maps: List[str] = [ + m for m in self.get_supported_maps(type(self.session)) + if m not in unowned_maps + ] + if not valid_maps: + _ba.screenmessage(Lstr(resource='noValidMapsErrorText')) + raise Exception("No valid maps") + map_name = valid_maps[random.randrange(len(valid_maps))] + self._map_type = _maps.get_map_class(map_name) + self._map_type.preload() + self._map: Optional[ba.Map] = None + self._powerup_drop_timer: Optional[ba.Timer] = None + self._tnt_objs: Optional[Dict[int, Any]] = None + self._tnt_drop_timer: Optional[ba.Timer] = None + self.initial_player_info: Optional[List[Dict[str, Any]]] = None + self._game_scoreboard_name_text: Optional[ba.Actor] = None + self._game_scoreboard_description_text: Optional[ba.Actor] = None + self._standard_time_limit_time: Optional[int] = None + self._standard_time_limit_timer: Optional[ba.Timer] = None + self._standard_time_limit_text: Optional[ba.Actor] = None + self._standard_time_limit_text_input: Optional[ba.Actor] = None + self._tournament_time_limit: Optional[int] = None + self._tournament_time_limit_timer: Optional[ba.Timer] = None + self._tournament_time_limit_title_text: Optional[ba.Actor] = None + self._tournament_time_limit_text: Optional[ba.Actor] = None + self._tournament_time_limit_text_input: Optional[ba.Actor] = None + self._zoom_message_times: Dict[int, float] = {} + self._is_waiting_for_continue = False + + self._continue_cost = _ba.get_account_misc_read_val( + 'continueStartCost', 25) + self._continue_cost_mult = _ba.get_account_misc_read_val( + 'continuesMult', 2) + self._continue_cost_offset = _ba.get_account_misc_read_val( + 'continuesOffset', 0) + + @property + def map(self) -> ba.Map: + """The map being used for this game. + + Raises a ba.NotFoundError if the map does not currently exist. + """ + if self._map is None: + from ba._error import NotFoundError + raise NotFoundError + return self._map + + def get_instance_display_string(self) -> ba.Lstr: + """Return a name for this particular game instance.""" + return self.get_display_string(self.settings) + + def get_instance_scoreboard_display_string(self) -> ba.Lstr: + """Return a name for this particular game instance. + + This name is used above the game scoreboard in the corner + of the screen, so it should be as concise as possible. + """ + # If we're in a co-op session, use the level name. + # FIXME: Should clean this up. + try: + from ba._coopsession import CoopSession + if isinstance(self.session, CoopSession): + campaign = self.session.campaign + assert campaign is not None + return campaign.get_level( + self.session.campaign_state['level']).displayname + except Exception: + from ba import _error + _error.print_error('error getting campaign level name') + return self.get_instance_display_string() + + def get_instance_description(self) -> Union[str, Sequence]: + """Return a description for this game instance, in English. + + This is shown in the center of the screen below the game name at the + start of a game. It should start with a capital letter and end with a + period, and can be a bit more verbose than the version returned by + get_instance_scoreboard_description(). + + Note that translation is applied by looking up the specific returned + value as a key, so the number of returned variations should be limited; + ideally just one or two. To include arbitrary values in the + description, you can return a sequence of values in the following + form instead of just a string: + + # this will give us something like 'Score 3 goals.' in English + # and can properly translate to 'Anota 3 goles.' in Spanish. + # If we just returned the string 'Score 3 Goals' here, there would + # have to be a translation entry for each specific number. ew. + return ['Score ${ARG1} goals.', self.settings['Score to Win']] + + This way the first string can be consistently translated, with any arg + values then substituted into the result. ${ARG1} will be replaced with + the first value, ${ARG2} with the second, etc. + """ + return self.get_description(type(self.session)) + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + """Return a short description for this game instance in English. + + This description is used above the game scoreboard in the + corner of the screen, so it should be as concise as possible. + It should be lowercase and should not contain periods or other + punctuation. + + Note that translation is applied by looking up the specific returned + value as a key, so the number of returned variations should be limited; + ideally just one or two. To include arbitrary values in the + description, you can return a sequence of values in the following form + instead of just a string: + + # this will give us something like 'score 3 goals' in English + # and can properly translate to 'anota 3 goles' in Spanish. + # If we just returned the string 'score 3 goals' here, there would + # have to be a translation entry for each specific number. ew. + return ['score ${ARG1} goals', self.settings['Score to Win']] + + This way the first string can be consistently translated, with any arg + values then substituted into the result. ${ARG1} will be replaced + with the first value, ${ARG2} with the second, etc. + + """ + return '' + + def on_transition_in(self, music: str = None) -> None: + """ + Method override; optionally can + be passed a 'music' string which is the suggested type of + music to play during the game. + Note that in some cases music may be overridden by + the map or other factors, which is why you should pass + it in here instead of simply playing it yourself. + """ + # FIXME: Unify args. + # pylint: disable=arguments-differ + + super().on_transition_in() + + # make our map + self._map = self._map_type() + + # give our map a chance to override the music + # (for happy-thoughts and other such themed maps) + override_music = self._map_type.get_music_type() + if override_music is not None: + music = override_music + + if music is not None: + from ba import _music as bsmusic + bsmusic.setmusic(music) + + def on_continue(self) -> None: + """ + This is called if a game supports and offers a continue and the player + accepts. In this case the player should be given an extra life or + whatever is relevant to keep the game going. + """ + + def _continue_choice(self, do_continue: bool) -> None: + self._is_waiting_for_continue = False + if self.has_ended(): + return + with _ba.Context(self): + if do_continue: + _ba.playsound(_ba.getsound('shieldUp')) + _ba.playsound(_ba.getsound('cashRegister')) + _ba.add_transaction({ + 'type': 'CONTINUE', + 'cost': self._continue_cost + }) + _ba.run_transactions() + self._continue_cost = ( + self._continue_cost * self._continue_cost_mult + + self._continue_cost_offset) + self.on_continue() + else: + self.end_game() + + def is_waiting_for_continue(self) -> bool: + """Returns whether or not this activity is currently waiting for the + player to continue (or timeout)""" + return self._is_waiting_for_continue + + def continue_or_end_game(self) -> None: + """If continues are allowed, prompts the player to purchase a continue + and calls either end_game or continue_game depending on the result""" + # pylint: disable=too-many-nested-blocks + # pylint: disable=cyclic-import + from bastd.ui import continues + from ba import _gameutils + from ba import _general + from ba._coopsession import CoopSession + from ba._enums import TimeType + + try: + if _ba.get_account_misc_read_val('enableContinues', False): + + session = self.session + + # We only support continuing in non-tournament games. + tournament_id = session.tournament_id + if tournament_id is None: + + # We currently only support continuing in sequential + # co-op campaigns. + if isinstance(session, CoopSession): + assert session.campaign is not None + if session.campaign.sequential: + gnode = _gameutils.sharedobj('globals') + + # Only attempt this if we're not currently paused + # and there appears to be no UI. + if (not gnode.paused + and _ba.app.main_menu_window is None + or not _ba.app.main_menu_window): + self._is_waiting_for_continue = True + with _ba.Context('ui'): + _ba.timer( + 0.5, + lambda: continues.ContinuesWindow( + self, + self._continue_cost, + continue_call=_general.WeakCall( + self._continue_choice, True), + cancel_call=_general.WeakCall( + self._continue_choice, False)), + timetype=TimeType.REAL) + return + + except Exception: + from ba import _error + _error.print_exception("error continuing game") + + self.end_game() + + def _game_begin_analytics(self) -> None: + """Update analytics events for the start of the game.""" + # pylint: disable=too-many-branches + from ba._teamssession import TeamsSession + from ba._freeforallsession import FreeForAllSession + from ba._coopsession import CoopSession + session = self.session + campaign = session.campaign + if isinstance(session, CoopSession): + assert campaign is not None + _ba.set_analytics_screen( + 'Coop Game: ' + campaign.name + ' ' + + campaign.get_level(_ba.app.coop_session_args['level']).name) + _ba.increment_analytics_count('Co-op round start') + if len(self.players) == 1: + _ba.increment_analytics_count( + 'Co-op round start 1 human player') + elif len(self.players) == 2: + _ba.increment_analytics_count( + 'Co-op round start 2 human players') + elif len(self.players) == 3: + _ba.increment_analytics_count( + 'Co-op round start 3 human players') + elif len(self.players) >= 4: + _ba.increment_analytics_count( + 'Co-op round start 4+ human players') + elif isinstance(session, TeamsSession): + _ba.set_analytics_screen('Teams Game: ' + self.get_name()) + _ba.increment_analytics_count('Teams round start') + if len(self.players) == 1: + _ba.increment_analytics_count( + 'Teams round start 1 human player') + elif 1 < len(self.players) < 8: + _ba.increment_analytics_count('Teams round start ' + + str(len(self.players)) + + ' human players') + elif len(self.players) >= 8: + _ba.increment_analytics_count( + 'Teams round start 8+ human players') + elif isinstance(session, FreeForAllSession): + _ba.set_analytics_screen('FreeForAll Game: ' + self.get_name()) + _ba.increment_analytics_count('Free-for-all round start') + if len(self.players) == 1: + _ba.increment_analytics_count( + 'Free-for-all round start 1 human player') + elif 1 < len(self.players) < 8: + _ba.increment_analytics_count('Free-for-all round start ' + + str(len(self.players)) + + ' human players') + elif len(self.players) >= 8: + _ba.increment_analytics_count( + 'Free-for-all round start 8+ human players') + + # For some analytics tracking on the c layer. + _ba.reset_game_activity_tracking() + + def on_begin(self) -> None: + from ba._general import WeakCall + super().on_begin() + + try: + self._game_begin_analytics() + except Exception: + from ba import _error + _error.print_exception("error in game-begin-analytics") + + # We don't do this in on_transition_in because it may depend on + # players/teams which aren't available until now. + _ba.timer(0.001, WeakCall(self.show_scoreboard_info)) + _ba.timer(1.0, WeakCall(self.show_info)) + _ba.timer(2.5, WeakCall(self._show_tip)) + + # Store some basic info about players present at start time. + self.initial_player_info = [{ + 'name': p.get_name(full=True), + 'character': p.character + } for p in self.players] + + # Sort this by name so high score lists/etc will be consistent + # regardless of player join order. + self.initial_player_info.sort(key=lambda x: x['name']) + + # If this is a tournament, query info about it such as how much + # time is left. + tournament_id = self.session.tournament_id + + if tournament_id is not None: + _ba.tournament_query(args={ + 'tournamentIDs': [tournament_id], + 'source': 'in-game time remaining query' + }, + callback=WeakCall( + self._on_tournament_query_response)) + + def _on_tournament_query_response(self, + data: Optional[Dict[str, Any]]) -> None: + from ba._account import cache_tournament_info + if data is not None: + data_t = data['t'] # This used to be the whole payload. + + # Keep our cached tourney info up to date + cache_tournament_info(data_t) + self._setup_tournament_time_limit( + max(5, data_t[0]['timeRemaining'])) + + def on_player_join(self, player: ba.Player) -> None: + super().on_player_join(player) + + # By default, just spawn a dude. + self.spawn_player(player) + + def on_player_leave(self, player: ba.Player) -> None: + from ba._general import Call + from ba._messages import DieMessage + + super().on_player_leave(player) + + # If the player has an actor, send it a deferred die message. + # This way the player will be completely gone from the game + # when the message goes through, making it less likely games + # will incorrectly try to respawn them, etc. + actor = player.actor + if actor is not None: + _ba.pushcall(Call(actor.handlemessage, DieMessage(how='leftGame'))) + player.set_actor(None) + + def handlemessage(self, msg: Any) -> Any: + from bastd.actor.playerspaz import PlayerSpazDeathMessage + if isinstance(msg, PlayerSpazDeathMessage): + + player = msg.spaz.player + killer = msg.killerplayer + + # Inform our score-set of the demise. + self.stats.player_lost_spaz(player, + killed=msg.killed, + killer=killer) + + # Award the killer points if he's on a different team. + if killer and killer.team is not player.team: + pts, importance = msg.spaz.get_death_points(msg.how) + if not self.has_ended(): + self.stats.player_scored(killer, + pts, + kill=True, + victim_player=player, + importance=importance, + showpoints=self._show_kill_points) + + def show_scoreboard_info(self) -> None: + """Create the game info display. + + This is the thing in the top left corner showing the name + and short description of the game. + """ + # pylint: disable=too-many-locals + from ba._freeforallsession import FreeForAllSession + from ba._gameutils import animate + from ba._actor import Actor + sb_name = self.get_instance_scoreboard_display_string() + + # the description can be either a string or a sequence with args + # to swap in post-translation + sb_desc_in = self.get_instance_scoreboard_description() + sb_desc_l: Sequence + if isinstance(sb_desc_in, str): + sb_desc_l = [sb_desc_in] # handle simple string case + else: + sb_desc_l = sb_desc_in + if not isinstance(sb_desc_l[0], str): + raise Exception("Invalid format for instance description") + + is_empty = (sb_desc_l[0] == '') + subs = [] + for i in range(len(sb_desc_l) - 1): + subs.append(('${ARG' + str(i + 1) + '}', str(sb_desc_l[i + 1]))) + translation = Lstr(translate=('gameDescriptions', sb_desc_l[0]), + subs=subs) + sb_desc = translation + + vrmode = _ba.app.vr_mode + + yval = -34 if is_empty else -20 + yval -= 16 + sbpos = ((15, yval) if isinstance(self.session, FreeForAllSession) else + (15, yval)) + self._game_scoreboard_name_text = Actor( + _ba.newnode("text", + attrs={ + 'text': sb_name, + 'maxwidth': 300, + 'position': sbpos, + 'h_attach': "left", + 'vr_depth': 10, + 'v_attach': "top", + 'v_align': 'bottom', + 'color': (1.0, 1.0, 1.0, 1.0), + 'shadow': 1.0 if vrmode else 0.6, + 'flatness': 1.0 if vrmode else 0.5, + 'scale': 1.1 + })) + + assert self._game_scoreboard_name_text.node + animate(self._game_scoreboard_name_text.node, 'opacity', { + 0: 0.0, + 1.0: 1.0 + }) + + descpos = (((17, -44 + + 10) if isinstance(self.session, FreeForAllSession) else + (17, -44 + 10))) + self._game_scoreboard_description_text = Actor( + _ba.newnode( + "text", + attrs={ + 'text': sb_desc, + 'maxwidth': 480, + 'position': descpos, + 'scale': 0.7, + 'h_attach': "left", + 'v_attach': "top", + 'v_align': 'top', + 'shadow': 1.0 if vrmode else 0.7, + 'flatness': 1.0 if vrmode else 0.8, + 'color': (1, 1, 1, 1) if vrmode else (0.9, 0.9, 0.9, 1.0) + })) + + assert self._game_scoreboard_description_text.node + animate(self._game_scoreboard_description_text.node, 'opacity', { + 0: 0.0, + 1.0: 1.0 + }) + + def show_info(self) -> None: + """Show the game description.""" + from ba._gameutils import animate + from ba._general import Call + from bastd.actor.zoomtext import ZoomText + name = self.get_instance_display_string() + ZoomText(name, + maxwidth=800, + lifespan=2.5, + jitter=2.0, + position=(0, 180), + flash=False, + color=(0.93 * 1.25, 0.9 * 1.25, 1.0 * 1.25), + trailcolor=(0.15, 0.05, 1.0, 0.0)).autoretain() + _ba.timer(0.2, Call(_ba.playsound, _ba.getsound('gong'))) + + # The description can be either a string or a sequence with args + # to swap in post-translation. + desc_in = self.get_instance_description() + desc_l: Sequence + if isinstance(desc_in, str): + desc_l = [desc_in] # handle simple string case + else: + desc_l = desc_in + if not isinstance(desc_l[0], str): + raise Exception("Invalid format for instance description") + subs = [] + for i in range(len(desc_l) - 1): + subs.append(('${ARG' + str(i + 1) + '}', str(desc_l[i + 1]))) + translation = Lstr(translate=('gameDescriptions', desc_l[0]), + subs=subs) + + # do some standard filters (epic mode, etc) + if 'Epic Mode' in self.settings and self.settings['Epic Mode']: + translation = Lstr(resource='epicDescriptionFilterText', + subs=[('${DESCRIPTION}', translation)]) + vrmode = _ba.app.vr_mode + dnode = _ba.newnode('text', + attrs={ + 'v_attach': 'center', + 'h_attach': 'center', + 'h_align': 'center', + 'color': (1, 1, 1, 1), + 'shadow': 1.0 if vrmode else 0.5, + 'flatness': 1.0 if vrmode else 0.5, + 'vr_depth': -30, + 'position': (0, 80), + 'scale': 1.2, + 'maxwidth': 700, + 'text': translation + }) + cnode = _ba.newnode("combine", + owner=dnode, + attrs={ + 'input0': 1.0, + 'input1': 1.0, + 'input2': 1.0, + 'size': 4 + }) + cnode.connectattr('output', dnode, 'color') + keys = {0.5: 0, 1.0: 1.0, 2.5: 1.0, 4.0: 0.0} + animate(cnode, "input3", keys) + _ba.timer(4.0, dnode.delete) + + def _show_tip(self) -> None: + # pylint: disable=too-many-locals + from ba._gameutils import animate + from ba._enums import SpecialChar + # if there's any tips left on the list, display one.. + if self.tips: + tip = self.tips.pop(random.randrange(len(self.tips))) + tip_title = Lstr(value='${A}:', + subs=[('${A}', Lstr(resource='tipText'))]) + icon = None + sound = None + if isinstance(tip, dict): + if 'icon' in tip: + icon = tip['icon'] + if 'sound' in tip: + sound = tip['sound'] + tip = tip['tip'] + + # a few subs.. + tip_lstr = Lstr(translate=('tips', tip), + subs=[('${PICKUP}', + _ba.charstr(SpecialChar.TOP_BUTTON))]) + base_position = (75, 50) + tip_scale = 0.8 + tip_title_scale = 1.2 + vrmode = _ba.app.vr_mode + + t_offs = -350.0 + tnode = _ba.newnode('text', + attrs={ + 'text': tip_lstr, + 'scale': tip_scale, + 'maxwidth': 900, + 'position': (base_position[0] + t_offs, + base_position[1]), + 'h_align': 'left', + 'vr_depth': 300, + 'shadow': 1.0 if vrmode else 0.5, + 'flatness': 1.0 if vrmode else 0.5, + 'v_align': 'center', + 'v_attach': 'bottom' + }) + t2pos = (base_position[0] + t_offs - (20 if icon is None else 82), + base_position[1] + 2) + t2node = _ba.newnode('text', + owner=tnode, + attrs={ + 'text': tip_title, + 'scale': tip_title_scale, + 'position': t2pos, + 'h_align': 'right', + 'vr_depth': 300, + 'shadow': 1.0 if vrmode else 0.5, + 'flatness': 1.0 if vrmode else 0.5, + 'maxwidth': 140, + 'v_align': 'center', + 'v_attach': 'bottom' + }) + if icon is not None: + ipos = (base_position[0] + t_offs - 40, base_position[1] + 1) + img = _ba.newnode('image', + attrs={ + 'texture': icon, + 'position': ipos, + 'scale': (50, 50), + 'opacity': 1.0, + 'vr_depth': 315, + 'color': (1, 1, 1), + 'absolute_scale': True, + 'attach': 'bottomCenter' + }) + animate(img, 'opacity', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0}) + _ba.timer(5.0, img.delete) + if sound is not None: + _ba.playsound(sound) + + combine = _ba.newnode("combine", + owner=tnode, + attrs={ + 'input0': 1.0, + 'input1': 0.8, + 'input2': 1.0, + 'size': 4 + }) + combine.connectattr('output', tnode, 'color') + combine.connectattr('output', t2node, 'color') + animate(combine, 'input3', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0}) + _ba.timer(5.0, tnode.delete) + + def end(self, results: Any = None, delay: float = 0.0, + force: bool = False) -> None: + from ba._gameresults import TeamGameResults + + # if results is a standard team-game-results, associate it with us + # so it can grab our score prefs + if isinstance(results, TeamGameResults): + results.set_game(self) + + # if we had a standard time-limit that had not expired, stop it so + # it doesnt tick annoyingly + if (self._standard_time_limit_time is not None + and self._standard_time_limit_time > 0): + self._standard_time_limit_timer = None + self._standard_time_limit_text = None + + # ditto with tournament time limits + if (self._tournament_time_limit is not None + and self._tournament_time_limit > 0): + self._tournament_time_limit_timer = None + self._tournament_time_limit_text = None + self._tournament_time_limit_title_text = None + + super().end(results, delay, force) + + def end_game(self) -> None: + """ + Tells the game to wrap itself up and call ba.Activity.end() + immediately. This method should be overridden by subclasses. + + A game should always be prepared to end and deliver results, even if + there is no 'winner' yet; this way things like the standard time-limit + (ba.GameActivity.setup_standard_time_limit()) will work with the game. + """ + print('WARNING: default end_game() implementation called;' + ' your game should override this.') + + def spawn_player_if_exists(self, player: ba.Player) -> None: + """ + A utility method which calls self.spawn_player() *only* if the + ba.Player provided still exists; handy for use in timers and whatnot. + + There is no need to override this; just override spawn_player(). + """ + if player: + self.spawn_player(player) + + def spawn_player(self, player: ba.Player) -> ba.Actor: + """Spawn *something* for the provided ba.Player. + + The default implementation simply calls spawn_player_spaz(). + """ + if not player: + raise Exception('spawn_player() called for nonexistent player') + + return self.spawn_player_spaz(player) + + def respawn_player(self, + player: ba.Player, + respawn_time: Optional[float] = None) -> None: + """ + Given a ba.Player, sets up a standard respawn timer, + along with the standard counter display, etc. + At the end of the respawn period spawn_player() will + be called if the Player still exists. + An explicit 'respawn_time' can optionally be provided + (in seconds). + """ + # pylint: disable=cyclic-import + + assert player + if respawn_time is None: + teamsize = len(player.team.players) + if teamsize == 1: + respawn_time = 3.0 + elif teamsize == 2: + respawn_time = 5.0 + elif teamsize == 3: + respawn_time = 6.0 + else: + respawn_time = 7.0 + + # if this standard setting is present, factor it in + if 'Respawn Times' in self.settings: + respawn_time *= self.settings['Respawn Times'] + + # we want whole seconds + assert respawn_time is not None + respawn_time = round(max(1.0, respawn_time), 0) + + if player.actor and not self.has_ended(): + from ba._general import WeakCall + from bastd.actor.respawnicon import RespawnIcon + player.gamedata['respawn_timer'] = _ba.Timer( + respawn_time, WeakCall(self.spawn_player_if_exists, player)) + player.gamedata['respawn_icon'] = RespawnIcon(player, respawn_time) + + def spawn_player_spaz(self, + player: ba.Player, + position: Sequence[float] = (0, 0, 0), + angle: float = None) -> PlayerSpaz: + """Create and wire up a ba.PlayerSpaz for the provided ba.Player.""" + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + from ba import _math + from ba import _messages + from ba._gameutils import animate + from ba._coopsession import CoopSession + from bastd.actor import playerspaz + name = player.get_name() + color = player.color + highlight = player.highlight + + light_color = _math.normalized_color(color) + display_color = _ba.safecolor(color, target_intensity=0.75) + spaz = playerspaz.PlayerSpaz(color=color, + highlight=highlight, + character=player.character, + player=player) + player.set_actor(spaz) + assert spaz.node + + # If this is co-op and we're on Courtyard or Runaround, add the + # material that allows us to collide with the player-walls. + # FIXME: Need to generalize this. + if isinstance(self.session, CoopSession) and self.map.get_name() in [ + 'Courtyard', 'Tower D' + ]: + mat = self.map.preloaddata['collide_with_wall_material'] + assert isinstance(spaz.node.materials, tuple) + assert isinstance(spaz.node.roller_materials, tuple) + spaz.node.materials += (mat, ) + spaz.node.roller_materials += (mat, ) + + spaz.node.name = name + spaz.node.name_color = display_color + spaz.connect_controls_to_player() + self.stats.player_got_new_spaz(player, spaz) + + # Move to the stand position and add a flash of light. + spaz.handlemessage( + _messages.StandMessage( + position, + angle if angle is not None else random.uniform(0, 360))) + _ba.playsound(self._spawn_sound, 1, position=spaz.node.position) + light = _ba.newnode('light', attrs={'color': light_color}) + spaz.node.connectattr('position', light, 'position') + animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0}) + _ba.timer(0.5, light.delete) + return spaz + + def project_flag_stand(self, pos: Sequence[float]) -> None: + """Project a flag-stand onto the ground at the given position. + + Useful for games such as capture-the-flag to show where a + movable flag originated from. + """ + from ba._general import WeakCall + + # Need to do this in a timer for it to work.. need to look into that. + # (might not still be the case?...) + _ba.pushcall(WeakCall(self._project_flag_stand, pos[:3])) + + def _project_flag_stand(self, pos: Sequence[float]) -> None: + _ba.emitfx(position=pos, emit_type='flag_stand') + + def setup_standard_powerup_drops(self, enable_tnt: bool = True) -> None: + """Create standard powerup drops for the current map.""" + # pylint: disable=cyclic-import + from bastd.actor import powerupbox + from ba import _general + self._powerup_drop_timer = _ba.Timer( + powerupbox.DEFAULT_POWERUP_INTERVAL, + _general.WeakCall(self._standard_drop_powerups), + repeat=True) + self._standard_drop_powerups() + if enable_tnt: + self._tnt_objs = {} + self._tnt_drop_timer = _ba.Timer(5.5, + _general.WeakCall( + self._standard_drop_tnt), + repeat=True) + self._standard_drop_tnt() + + def _standard_drop_powerup(self, index: int, expire: bool = True) -> None: + # pylint: disable=cyclic-import + from bastd.actor import powerupbox + powerupbox.PowerupBox( + position=self.map.powerup_spawn_points[index], + poweruptype=powerupbox.get_factory().get_random_powerup_type(), + expire=expire).autoretain() + + def _standard_drop_powerups(self) -> None: + """Standard powerup drop.""" + from ba import _general + + # Drop one powerup per point. + points = self.map.powerup_spawn_points + for i in range(len(points)): + _ba.timer(i * 0.4, _general.WeakCall(self._standard_drop_powerup, + i)) + + def _standard_drop_tnt(self) -> None: + """Standard tnt drop.""" + # pylint: disable=cyclic-import + from bastd.actor import bomb + + # Drop TNT on the map for any tnt location with no existing tnt box. + for i, point in enumerate(self.map.tnt_points): + assert self._tnt_objs is not None + if i not in self._tnt_objs: + self._tnt_objs[i] = {'absent_ticks': 9999, 'obj': None} + tnt_obj = self._tnt_objs[i] + + # Respawn once its been dead for a while. + if not tnt_obj['obj']: + tnt_obj['absent_ticks'] += 1 + if tnt_obj['absent_ticks'] > 3: + tnt_obj['obj'] = bomb.Bomb(position=point, bomb_type='tnt') + tnt_obj['absent_ticks'] = 0 + + def setup_standard_time_limit(self, duration: float) -> None: + """ + Create a standard game time-limit given the provided + duration in seconds. + This will be displayed at the top of the screen. + If the time-limit expires, end_game() will be called. + """ + from ba._gameutils import sharedobj + from ba._general import WeakCall + from ba._actor import Actor + if duration <= 0.0: + return + self._standard_time_limit_time = int(duration) + self._standard_time_limit_timer = _ba.Timer( + 1.0, WeakCall(self._standard_time_limit_tick), repeat=True) + self._standard_time_limit_text = Actor( + _ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'left', + 'color': (1.0, 1.0, 1.0, 0.5), + 'position': (-25, -30), + 'flatness': 1.0, + 'scale': 0.9 + })) + self._standard_time_limit_text_input = Actor( + _ba.newnode('timedisplay', + attrs={ + 'time2': duration * 1000, + 'timemin': 0 + })) + sharedobj('globals').connectattr( + 'time', self._standard_time_limit_text_input.node, 'time1') + assert self._standard_time_limit_text_input.node + assert self._standard_time_limit_text.node + self._standard_time_limit_text_input.node.connectattr( + 'output', self._standard_time_limit_text.node, 'text') + + def _standard_time_limit_tick(self) -> None: + from ba._gameutils import animate + assert self._standard_time_limit_time is not None + self._standard_time_limit_time -= 1 + if self._standard_time_limit_time <= 10: + if self._standard_time_limit_time == 10: + assert self._standard_time_limit_text is not None + assert self._standard_time_limit_text.node + self._standard_time_limit_text.node.scale = 1.3 + self._standard_time_limit_text.node.position = (-30, -45) + cnode = _ba.newnode('combine', + owner=self._standard_time_limit_text.node, + attrs={'size': 4}) + cnode.connectattr('output', + self._standard_time_limit_text.node, 'color') + animate(cnode, "input0", {0: 1, 0.15: 1}, loop=True) + animate(cnode, "input1", {0: 1, 0.15: 0.5}, loop=True) + animate(cnode, "input2", {0: 0.1, 0.15: 0.0}, loop=True) + cnode.input3 = 1.0 + _ba.playsound(_ba.getsound('tick')) + if self._standard_time_limit_time <= 0: + self._standard_time_limit_timer = None + self.end_game() + node = _ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'center', + 'color': (1, 0.7, 0, 1), + 'position': (0, -90), + 'scale': 1.2, + 'text': Lstr(resource='timeExpiredText') + }) + _ba.playsound(_ba.getsound('refWhistle')) + animate(node, "scale", {0.0: 0.0, 0.1: 1.4, 0.15: 1.2}) + + def _setup_tournament_time_limit(self, duration: float) -> None: + """ + Create a tournament game time-limit given the provided + duration in seconds. + This will be displayed at the top of the screen. + If the time-limit expires, end_game() will be called. + """ + from ba._general import WeakCall + from ba._actor import Actor + from ba._enums import TimeType + if duration <= 0.0: + return + self._tournament_time_limit = int(duration) + # we want this timer to match the server's time as close as possible, + # so lets go with base-time.. theoretically we should do real-time but + # then we have to mess with contexts and whatnot since its currently + # not available in activity contexts... :-/ + self._tournament_time_limit_timer = _ba.Timer( + 1.0, + WeakCall(self._tournament_time_limit_tick), + repeat=True, + timetype=TimeType.BASE) + self._tournament_time_limit_title_text = Actor( + _ba.newnode('text', + attrs={ + 'v_attach': 'bottom', + 'h_attach': 'left', + 'h_align': 'center', + 'v_align': 'center', + 'vr_depth': 300, + 'maxwidth': 100, + 'color': (1.0, 1.0, 1.0, 0.5), + 'position': (60, 50), + 'flatness': 1.0, + 'scale': 0.5, + 'text': Lstr(resource='tournamentText') + })) + self._tournament_time_limit_text = Actor( + _ba.newnode('text', + attrs={ + 'v_attach': 'bottom', + 'h_attach': 'left', + 'h_align': 'center', + 'v_align': 'center', + 'vr_depth': 300, + 'maxwidth': 100, + 'color': (1.0, 1.0, 1.0, 0.5), + 'position': (60, 30), + 'flatness': 1.0, + 'scale': 0.9 + })) + self._tournament_time_limit_text_input = Actor( + _ba.newnode('timedisplay', + attrs={ + 'timemin': 0, + 'time2': self._tournament_time_limit * 1000 + })) + assert self._tournament_time_limit_text.node + assert self._tournament_time_limit_text_input.node + self._tournament_time_limit_text_input.node.connectattr( + 'output', self._tournament_time_limit_text.node, 'text') + + def _tournament_time_limit_tick(self) -> None: + from ba._gameutils import animate + assert self._tournament_time_limit is not None + self._tournament_time_limit -= 1 + if self._tournament_time_limit <= 10: + if self._tournament_time_limit == 10: + assert self._tournament_time_limit_title_text is not None + assert self._tournament_time_limit_title_text.node + assert self._tournament_time_limit_text is not None + assert self._tournament_time_limit_text.node + self._tournament_time_limit_title_text.node.scale = 1.0 + self._tournament_time_limit_text.node.scale = 1.3 + self._tournament_time_limit_title_text.node.position = (80, 85) + self._tournament_time_limit_text.node.position = (80, 60) + cnode = _ba.newnode( + 'combine', + owner=self._tournament_time_limit_text.node, + attrs={'size': 4}) + cnode.connectattr('output', + self._tournament_time_limit_title_text.node, + 'color') + cnode.connectattr('output', + self._tournament_time_limit_text.node, + 'color') + animate(cnode, "input0", {0: 1, 0.15: 1}, loop=True) + animate(cnode, "input1", {0: 1, 0.15: 0.5}, loop=True) + animate(cnode, "input2", {0: 0.1, 0.15: 0.0}, loop=True) + cnode.input3 = 1.0 + _ba.playsound(_ba.getsound('tick')) + if self._tournament_time_limit <= 0: + self._tournament_time_limit_timer = None + self.end_game() + tval = Lstr(resource='tournamentTimeExpiredText', + fallback_resource='timeExpiredText') + node = _ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'center', + 'color': (1, 0.7, 0, 1), + 'position': (0, -200), + 'scale': 1.6, + 'text': tval + }) + _ba.playsound(_ba.getsound('refWhistle')) + animate(node, "scale", {0: 0.0, 0.1: 1.4, 0.15: 1.2}) + + # Normally we just connect this to time, but since this is a bit of a + # funky setup we just update it manually once per second. + assert self._tournament_time_limit_text_input is not None + assert self._tournament_time_limit_text_input.node + self._tournament_time_limit_text_input.node.time2 = ( + self._tournament_time_limit * 1000) + + def show_zoom_message(self, + message: ba.Lstr, + color: Sequence[float] = (0.9, 0.4, 0.0), + scale: float = 0.8, + duration: float = 2.0, + trail: bool = False) -> None: + """Zooming text used to announce game names and winners.""" + # pylint: disable=cyclic-import + from bastd.actor.zoomtext import ZoomText + + # Reserve a spot on the screen (in case we get multiple of these so + # they don't overlap). + i = 0 + cur_time = _ba.time() + while True: + if (i not in self._zoom_message_times + or self._zoom_message_times[i] < cur_time): + self._zoom_message_times[i] = cur_time + duration + break + i += 1 + ZoomText(message, + lifespan=duration, + jitter=2.0, + position=(0, 200 - i * 100), + scale=scale, + maxwidth=800, + trail=trail, + color=color).autoretain() diff --git a/assets/src/data/scripts/ba/_gameresults.py b/assets/src/data/scripts/ba/_gameresults.py new file mode 100644 index 00000000..5ce50d87 --- /dev/null +++ b/assets/src/data/scripts/ba/_gameresults.py @@ -0,0 +1,190 @@ +"""Functionality related to game results.""" +from __future__ import annotations + +import copy +import weakref +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from weakref import ReferenceType + from typing import Sequence, Tuple, Any, Optional, Dict, List + import ba + + +@dataclass +class WinnerGroup: + """Entry for a winning team or teams calculated by game-results.""" + score: Optional[int] + teams: Sequence[ba.Team] + + +class TeamGameResults: + """ + Results for a completed ba.TeamGameActivity. + + Category: Gameplay Classes + + Upon completion, a game should fill one of these out and pass it to its + ba.Activity.end() call. + """ + + def __init__(self) -> None: + """Instantiate a results instance.""" + self._game_set = False + self._scores: Dict[int, Tuple[ReferenceType[ba.Team], int]] = {} + self._teams: Optional[List[ReferenceType[ba.Team]]] = None + self._player_info: Optional[List[Dict[str, Any]]] = None + self._lower_is_better: Optional[bool] = None + self._score_name: Optional[str] = None + self._none_is_winner: Optional[bool] = None + self._score_type: Optional[str] = None + + def set_game(self, game: ba.GameActivity) -> None: + """Set the game instance these results are applying to.""" + if self._game_set: + raise RuntimeError("Game set twice for TeamGameResults.") + self._game_set = True + self._teams = [weakref.ref(team) for team in game.teams] + score_info = game.get_resolved_score_info() + self._player_info = copy.deepcopy(game.initial_player_info) + self._lower_is_better = score_info['lower_is_better'] + self._score_name = score_info['score_name'] + self._none_is_winner = score_info['none_is_winner'] + self._score_type = score_info['score_type'] + + def set_team_score(self, team: ba.Team, score: int) -> None: + """Set the score for a given ba.Team. + + This can be a number or None. + (see the none_is_winner arg in the constructor) + """ + self._scores[team.get_id()] = (weakref.ref(team), score) + + def get_team_score(self, team: ba.Team) -> Optional[int]: + """Return the score for a given team.""" + for score in list(self._scores.values()): + if score[0]() is team: + return score[1] + + # If we have no score value, assume None. + return None + + def get_teams(self) -> List[ba.Team]: + """Return all ba.Teams in the results.""" + if not self._game_set: + raise RuntimeError("Can't get teams until game is set.") + teams = [] + assert self._teams is not None + for team_ref in self._teams: + team = team_ref() + if team is not None: + teams.append(team) + return teams + + def has_score_for_team(self, team: ba.Team) -> bool: + """Return whether there is a score for a given team.""" + for score in list(self._scores.values()): + if score[0]() is team: + return True + return False + + def get_team_score_str(self, team: ba.Team) -> ba.Lstr: + """Return the score for the given ba.Team as an Lstr. + + (properly formatted for the score type.) + """ + from ba._gameutils import timestring + from ba._lang import Lstr + from ba._enums import TimeFormat + if not self._game_set: + raise RuntimeError("Can't get team-score-str until game is set.") + for score in list(self._scores.values()): + if score[0]() is team: + if score[1] is None: + return Lstr(value='-') + if self._score_type == 'seconds': + return timestring(score[1] * 1000, + centi=False, + timeformat=TimeFormat.MILLISECONDS) + if self._score_type == 'milliseconds': + return timestring(score[1], + centi=True, + timeformat=TimeFormat.MILLISECONDS) + return Lstr(value=str(score[1])) + return Lstr(value='-') + + def get_player_info(self) -> List[Dict[str, Any]]: + """Get info about the players represented by the results.""" + if not self._game_set: + raise RuntimeError("Can't get player-info until game is set.") + assert self._player_info is not None + return self._player_info + + def get_score_type(self) -> str: + """Get the type of score.""" + if not self._game_set: + raise RuntimeError("Can't get score-type until game is set.") + assert self._score_type is not None + return self._score_type + + def get_score_name(self) -> str: + """Get the name associated with scores ('points', etc).""" + if not self._game_set: + raise RuntimeError("Can't get score-name until game is set.") + assert self._score_name is not None + return self._score_name + + def get_lower_is_better(self) -> bool: + """Return whether lower scores are better.""" + if not self._game_set: + raise RuntimeError("Can't get lower-is-better until game is set.") + assert self._lower_is_better is not None + return self._lower_is_better + + def get_winning_team(self) -> Optional[ba.Team]: + """Get the winning ba.Team if there is exactly one; None otherwise.""" + if not self._game_set: + raise RuntimeError("Can't get winners until game is set.") + winners = self.get_winners() + if winners and len(winners[0].teams) == 1: + return winners[0].teams[0] + return None + + def get_winners(self) -> List[WinnerGroup]: + """Get an ordered list of winner groups.""" + if not self._game_set: + raise RuntimeError("Can't get winners until game is set.") + + # Group by best scoring teams. + winners: Dict[int, List[ba.Team]] = {} + scores = [ + score for score in self._scores.values() + if score[0]() is not None and score[1] is not None + ] + for score in scores: + sval = winners.setdefault(score[1], []) + team = score[0]() + assert team is not None + sval.append(team) + results: List[Tuple[Optional[int], List[ba.Team]]] = list( + winners.items()) + results.sort(reverse=not self._lower_is_better) + + # Also group the 'None' scores. + none_teams: List[ba.Team] = [] + for score in self._scores.values(): + if score[0]() is not None and score[1] is None: + none_teams.append(score[0]()) + + # Add the Nones to the list (either as winners or losers + # depending on the rules). + if none_teams: + nones: List[Tuple[Optional[int], List[ba.Team]]] = [(None, + none_teams)] + if self._none_is_winner: + results = nones + results + else: + results = results + nones + + return [WinnerGroup(score, team) for score, team in results] diff --git a/assets/src/data/scripts/ba/_gameutils.py b/assets/src/data/scripts/ba/_gameutils.py new file mode 100644 index 00000000..9527e54b --- /dev/null +++ b/assets/src/data/scripts/ba/_gameutils.py @@ -0,0 +1,489 @@ +"""Utility functionality pertaining to gameplay.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +from ba._enums import TimeType, TimeFormat, SpecialChar + +if TYPE_CHECKING: + from typing import Any, Dict, Sequence + import ba + +TROPHY_CHARS = { + '1': SpecialChar.TROPHY1, + '2': SpecialChar.TROPHY2, + '3': SpecialChar.TROPHY3, + '0a': SpecialChar.TROPHY0A, + '0b': SpecialChar.TROPHY0B, + '4': SpecialChar.TROPHY4 +} + + +def get_trophy_string(trophy_id: str) -> str: + """Given a trophy id, returns a string to visualize it.""" + if trophy_id in TROPHY_CHARS: + return _ba.charstr(TROPHY_CHARS[trophy_id]) + return '?' + + +def sharedobj(name: str) -> Any: + """Return a predefined object for the current Activity, creating if needed. + + Category: Gameplay Functions + + Available values for 'name': + + 'globals': returns the 'globals' ba.Node, containing various global + controls & values. + + 'object_material': a ba.Material that should be applied to any small, + normal, physical objects such as bombs, boxes, players, etc. Other + materials often check for the presence of this material as a + prerequisite for performing certain actions (such as disabling collisions + between initially-overlapping objects) + + 'player_material': a ba.Material to be applied to player parts. Generally, + materials related to the process of scoring when reaching a goal, etc + will look for the presence of this material on things that hit them. + + 'pickup_material': a ba.Material; collision shapes used for picking things + up will have this material applied. To prevent an object from being + picked up, you can add a material that disables collisions against things + containing this material. + + 'footing_material': anything that can be 'walked on' should have this + ba.Material applied; generally just terrain and whatnot. A character will + snap upright whenever touching something with this material so it should + not be applied to props, etc. + + 'attack_material': a ba.Material applied to explosion shapes, punch + shapes, etc. An object not wanting to receive impulse/etc messages can + disable collisions against this material. + + 'death_material': a ba.Material that sends a ba.DieMessage() to anything + that touches it; handy for terrain below a cliff, etc. + + 'region_material': a ba.Material used for non-physical collision shapes + (regions); collisions can generally be allowed with this material even + when initially overlapping since it is not physical. + + 'railing_material': a ba.Material with a very low friction/stiffness/etc + that can be applied to invisible 'railings' useful for gently keeping + characters from falling off of cliffs. + """ + # pylint: disable=too-many-branches + from ba._messages import DieMessage + + # We store these on the current context; whether its an activity or + # session. + activity = _ba.getactivity(doraise=False) + if activity is not None: + + # Grab shared-objs dict. + sharedobjs = getattr(activity, 'sharedobjs', None) + + # Grab item out of it. + try: + return sharedobjs[name] + except Exception: + pass + + obj: Any + + # Hmm looks like it doesn't yet exist; create it if its a valid value. + if name == 'globals': + node_obj = _ba.newnode('globals') + obj = node_obj + elif name in [ + 'object_material', 'player_material', 'pickup_material', + 'footing_material', 'attack_material' + ]: + obj = _ba.Material() + elif name == 'death_material': + mat = obj = _ba.Material() + mat.add_actions( + ('message', 'their_node', 'at_connect', DieMessage())) + elif name == 'region_material': + obj = _ba.Material() + elif name == 'railing_material': + mat = obj = _ba.Material() + mat.add_actions(('modify_part_collision', 'collide', False)) + mat.add_actions(('modify_part_collision', 'stiffness', 0.003)) + mat.add_actions(('modify_part_collision', 'damping', 0.00001)) + mat.add_actions(conditions=('they_have_material', + sharedobj('player_material')), + actions=(('modify_part_collision', 'collide', + True), ('modify_part_collision', + 'friction', 0.0))) + else: + raise Exception( + "unrecognized shared object (activity context): '" + name + + "'") + else: + session = _ba.getsession(doraise=False) + if session is not None: + + # Grab shared-objs dict (creating if necessary). + sharedobjs = session.sharedobjs + + # Grab item out of it. + obj = sharedobjs.get(name) + if obj is not None: + return obj + + # Hmm looks like it doesn't yet exist; create if its a valid value. + if name == 'globals': + obj = _ba.newnode('sessionglobals') + else: + raise Exception("unrecognized shared object " + "(session context): '" + name + "'") + else: + raise Exception("no current activity or session context") + + # Ok, got a shiny new shared obj; store it for quick access next time. + sharedobjs[name] = obj + return obj + + +def animate(node: ba.Node, + attr: str, + keys: Dict[float, float], + loop: bool = False, + offset: float = 0, + timetype: ba.TimeType = TimeType.SIM, + timeformat: ba.TimeFormat = TimeFormat.SECONDS, + suppress_format_warning: bool = False) -> ba.Node: + """Animate values on a target ba.Node. + + Category: Gameplay Functions + + Creates an 'animcurve' node with the provided values and time as an input, + connect it to the provided attribute, and set it to die with the target. + Key values are provided as time:value dictionary pairs. Time values are + relative to the current time. By default, times are specified in seconds, + but timeformat can also be set to MILLISECONDS to recreate the old behavior + (prior to ba 1.5) of taking milliseconds. Returns the animcurve node. + """ + if timetype is TimeType.SIM: + driver = 'time' + else: + raise Exception("FIXME; only SIM timetype is supported currently.") + items = list(keys.items()) + items.sort() + + # Temp sanity check while we transition from milliseconds to seconds + # based time values. + if _ba.app.test_build and not suppress_format_warning: + for item in items: + # (PyCharm seems to think item is a float, not a tuple) + # noinspection PyUnresolvedReferences + _ba.time_format_check(timeformat, item[0]) + + curve = _ba.newnode("animcurve", + owner=node, + name='Driving ' + str(node) + ' \'' + attr + '\'') + + if timeformat is TimeFormat.SECONDS: + mult = 1000 + elif timeformat is TimeFormat.MILLISECONDS: + mult = 1 + else: + raise Exception(f'invalid timeformat value: {timeformat}') + + curve.times = [int(mult * time) for time, val in items] + curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int( + mult * offset) + curve.values = [val for time, val in items] + curve.loop = loop + + # If we're not looping, set a timer to kill this curve + # after its done its job. + # FIXME: Even if we are looping we should have a way to die once we + # get disconnected. + if not loop: + # (PyCharm seems to think item is a float, not a tuple) + # noinspection PyUnresolvedReferences + _ba.timer(int(mult * items[-1][0]) + 1000, + curve.delete, + timeformat=TimeFormat.MILLISECONDS) + + # Do the connects last so all our attrs are in place when we push initial + # values through. + sharedobj('globals').connectattr(driver, curve, "in") + curve.connectattr("out", node, attr) + return curve + + +def animate_array(node: ba.Node, + attr: str, + size: int, + keys: Dict[float, Sequence[float]], + loop: bool = False, + offset: float = 0, + timetype: ba.TimeType = TimeType.SIM, + timeformat: ba.TimeFormat = TimeFormat.SECONDS, + suppress_format_warning: bool = False) -> None: + """Animate an array of values on a target ba.Node. + + Category: Gameplay Functions + + Like ba.animate(), but operates on array attributes. + """ + # pylint: disable=too-many-locals + combine = _ba.newnode('combine', owner=node, attrs={'size': size}) + if timetype is TimeType.SIM: + driver = 'time' + else: + raise Exception("FIXME: Only SIM timetype is supported currently.") + items = list(keys.items()) + items.sort() + + # Temp sanity check while we transition from milliseconds to seconds + # based time values. + if _ba.app.test_build and not suppress_format_warning: + for item in items: + # (PyCharm seems to think item is a float, not a tuple) + # noinspection PyUnresolvedReferences + _ba.time_format_check(timeformat, item[0]) + + if timeformat is TimeFormat.SECONDS: + mult = 1000 + elif timeformat is TimeFormat.MILLISECONDS: + mult = 1 + else: + raise Exception('invalid timeformat value: "' + str(timeformat) + '"') + + for i in range(size): + curve = _ba.newnode("animcurve", + owner=node, + name=('Driving ' + str(node) + ' \'' + attr + + '\' member ' + str(i))) + sharedobj('globals').connectattr(driver, curve, "in") + curve.times = [int(mult * time) for time, val in items] + curve.values = [val[i] for time, val in items] + curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int( + mult * offset) + curve.loop = loop + curve.connectattr("out", combine, 'input' + str(i)) + + # If we're not looping, set a timer to kill this + # curve after its done its job. + if not loop: + # (PyCharm seems to think item is a float, not a tuple) + # noinspection PyUnresolvedReferences + _ba.timer(int(mult * items[-1][0]) + 1000, + curve.delete, + timeformat=TimeFormat.MILLISECONDS) + combine.connectattr('output', node, attr) + + # If we're not looping, set a timer to kill the combine once + # the job is done. + # FIXME: Even if we are looping we should have a way to die + # once we get disconnected. + if not loop: + # (PyCharm seems to think item is a float, not a tuple) + # noinspection PyUnresolvedReferences + _ba.timer(int(mult * items[-1][0]) + 1000, + combine.delete, + timeformat=TimeFormat.MILLISECONDS) + + +def show_damage_count(damage: str, position: Sequence[float], + direction: Sequence[float]) -> None: + """Pop up a damage count at a position in space.""" + lifespan = 1.0 + app = _ba.app + + # FIXME: Should never vary game elements based on local config. + # (connected clients may have differing configs so they won't + # get the intended results). + do_big = app.interface_type == 'small' or app.vr_mode + txtnode = _ba.newnode('text', + attrs={ + 'text': damage, + 'in_world': True, + 'h_align': 'center', + 'flatness': 1.0, + 'shadow': 1.0 if do_big else 0.7, + 'color': (1, 0.25, 0.25, 1), + 'scale': 0.015 if do_big else 0.01 + }) + # Translate upward. + tcombine = _ba.newnode("combine", owner=txtnode, attrs={'size': 3}) + tcombine.connectattr('output', txtnode, 'position') + v_vals = [] + pval = 0.0 + vval = 0.07 + count = 6 + for i in range(count): + v_vals.append((float(i) / count, pval)) + pval += vval + vval *= 0.5 + p_start = position[0] + p_dir = direction[0] + animate(tcombine, "input0", + {i[0] * lifespan: p_start + p_dir * i[1] + for i in v_vals}) + p_start = position[1] + p_dir = direction[1] + animate(tcombine, "input1", + {i[0] * lifespan: p_start + p_dir * i[1] + for i in v_vals}) + p_start = position[2] + p_dir = direction[2] + animate(tcombine, "input2", + {i[0] * lifespan: p_start + p_dir * i[1] + for i in v_vals}) + animate(txtnode, 'opacity', {0.7 * lifespan: 1.0, lifespan: 0.0}) + _ba.timer(lifespan, txtnode.delete) + + +def timestring(timeval: float, + centi: bool = True, + timeformat: ba.TimeFormat = TimeFormat.SECONDS, + suppress_format_warning: bool = False) -> ba.Lstr: + """Generate a ba.Lstr for displaying a time value. + + Category: General Utility Functions + + Given a time value, returns a ba.Lstr with: + (hours if > 0 ) : minutes : seconds : (centiseconds if centi=True). + + Time 'timeval' is specified in seconds by default, or 'timeformat' can + be set to ba.TimeFormat.MILLISECONDS to accept milliseconds instead. + + WARNING: the underlying Lstr value is somewhat large so don't use this + to rapidly update Node text values for an onscreen timer or you may + consume significant network bandwidth. For that purpose you should + use a 'timedisplay' Node and attribute connections. + + """ + from ba._lang import Lstr + + # Temp sanity check while we transition from milliseconds to seconds + # based time values. + if _ba.app.test_build and not suppress_format_warning: + _ba.time_format_check(timeformat, timeval) + + # We operate on milliseconds internally. + if timeformat is TimeFormat.SECONDS: + timeval = int(1000 * timeval) + elif timeformat is TimeFormat.MILLISECONDS: + pass + else: + raise Exception(f'invalid timeformat: {timeformat}') + if not isinstance(timeval, int): + timeval = int(timeval) + bits = [] + subs = [] + hval = (timeval // 1000) // (60 * 60) + if hval != 0: + bits.append('${H}') + subs.append(('${H}', + Lstr(resource='timeSuffixHoursText', + subs=[('${COUNT}', str(hval))]))) + mval = ((timeval // 1000) // 60) % 60 + if mval != 0: + bits.append('${M}') + subs.append(('${M}', + Lstr(resource='timeSuffixMinutesText', + subs=[('${COUNT}', str(mval))]))) + + # We add seconds if its non-zero *or* we haven't added anything else. + if centi: + sval = (timeval / 1000.0 % 60.0) + if sval >= 0.005 or not bits: + bits.append('${S}') + subs.append(('${S}', + Lstr(resource='timeSuffixSecondsText', + subs=[('${COUNT}', ('%.2f' % sval))]))) + else: + sval = (timeval // 1000 % 60) + if sval != 0 or not bits: + bits.append('${S}') + subs.append(('${S}', + Lstr(resource='timeSuffixSecondsText', + subs=[('${COUNT}', str(sval))]))) + return Lstr(value=' '.join(bits), subs=subs) + + +def cameraflash(duration: float = 999.0) -> None: + """Create a strobing camera flash effect. + + Category: Gameplay Functions + + (as seen when a team wins a game) + Duration is in seconds. + """ + # pylint: disable=too-many-locals + import random + from ba._actor import Actor + x_spread = 10 + y_spread = 5 + positions = [[-x_spread, -y_spread], [0, -y_spread], [0, y_spread], + [x_spread, -y_spread], [x_spread, y_spread], + [-x_spread, y_spread]] + times = [0, 2700, 1000, 1800, 500, 1400] + + # Store this on the current activity so we only have one at a time. + # FIXME: Need a type safe way to do this. + activity = _ba.getactivity() + # noinspection PyTypeHints + activity.camera_flash_data = [] # type: ignore + for i in range(6): + light = Actor( + _ba.newnode("light", + attrs={ + 'position': (positions[i][0], 0, positions[i][1]), + 'radius': 1.0, + 'lights_volumes': False, + 'height_attenuated': False, + 'color': (0.2, 0.2, 0.8) + })) + sval = 1.87 + iscale = 1.3 + tcombine = _ba.newnode("combine", + owner=light.node, + attrs={ + 'size': 3, + 'input0': positions[i][0], + 'input1': 0, + 'input2': positions[i][1] + }) + assert light.node + tcombine.connectattr('output', light.node, 'position') + xval = positions[i][0] + yval = positions[i][1] + spd = 0.5 + random.random() + spd2 = 0.5 + random.random() + animate(tcombine, + 'input0', { + 0.0: xval + 0, + 0.069 * spd: xval + 10.0, + 0.143 * spd: xval - 10.0, + 0.201 * spd: xval + 0 + }, + loop=True) + animate(tcombine, + 'input2', { + 0.0: yval + 0, + 0.15 * spd2: yval + 10.0, + 0.287 * spd2: yval - 10.0, + 0.398 * spd2: yval + 0 + }, + loop=True) + animate(light.node, + "intensity", { + 0.0: 0, + 0.02 * sval: 0, + 0.05 * sval: 0.8 * iscale, + 0.08 * sval: 0, + 0.1 * sval: 0 + }, + loop=True, + offset=times[i]) + _ba.timer((times[i] + random.randint(1, int(duration)) * 40 * sval), + light.node.delete, + timeformat=TimeFormat.MILLISECONDS) + activity.camera_flash_data.append(light) # type: ignore diff --git a/assets/src/data/scripts/ba/_general.py b/assets/src/data/scripts/ba/_general.py new file mode 100644 index 00000000..9a0666a4 --- /dev/null +++ b/assets/src/data/scripts/ba/_general.py @@ -0,0 +1,247 @@ +"""Utility snippets applying to generic Python code.""" +from __future__ import annotations + +import copy +import types +import weakref +from typing import TYPE_CHECKING, TypeVar + +import _ba + +if TYPE_CHECKING: + from typing import Any, Type + +T = TypeVar('T') + + +def getclass(name: str, subclassof: Type[T]) -> Type[T]: + """Given a full class name such as foo.bar.MyClass, return the class. + + Category: General Utility Functions + + If 'subclassof' is given, the class will be checked to make sure + it is a subclass of the provided class, and a TypeError will be + raised if not. + """ + import importlib + splits = name.split('.') + modulename = '.'.join(splits[:-1]) + classname = splits[-1] + module = importlib.import_module(modulename) + cls: Type = getattr(module, classname) + + if subclassof is not None and not issubclass(cls, subclassof): + raise TypeError(name + ' is not a subclass of ' + str(subclassof)) + return cls + + +def json_prep(data: Any) -> Any: + """Return a json-friendly version of the provided data. + + This converts any tuples to lists and any bytes to strings + (interpreted as utf-8, ignoring errors). Logs errors (just once) + if any data is modified/discarded/unsupported. + """ + + if isinstance(data, dict): + return dict((json_prep(key), json_prep(value)) + for key, value in list(data.items())) + if isinstance(data, list): + return [json_prep(element) for element in data] + if isinstance(data, tuple): + from ba import _error + _error.print_error('json_prep encountered tuple', once=True) + return [json_prep(element) for element in data] + if isinstance(data, bytes): + try: + return data.decode(errors='ignore') + except Exception: + from ba import _error + _error.print_error('json_prep encountered utf-8 decode error', + once=True) + return data.decode(errors='ignore') + if not isinstance(data, (str, float, bool, type(None), int)): + from ba import _error + _error.print_error('got unsupported type in json_prep:' + + str(type(data)), + once=True) + return data + + +def utf8_all(data: Any) -> Any: + """Convert any unicode data in provided sequence(s)to utf8 bytes.""" + if isinstance(data, dict): + return dict((utf8_all(key), utf8_all(value)) + for key, value in list(data.items())) + if isinstance(data, list): + return [utf8_all(element) for element in data] + if isinstance(data, tuple): + return tuple(utf8_all(element) for element in data) + if isinstance(data, str): + return data.encode('utf-8', errors='ignore') + return data + + +def print_refs(obj: Any) -> None: + """Print a list of known live references to an object.""" + import gc + + # Hmmm; I just noticed that calling this on an object + # seems to keep it alive. Should figure out why. + print('REFERENCES FOR', obj, ':') + refs = list(gc.get_referrers(obj)) + i = 1 + for ref in refs: + print(' ref', i, ':', ref) + i += 1 + + +def get_type_name(cls: Type) -> str: + """Return a full type name including module for a class.""" + return cls.__module__ + '.' + cls.__name__ + + +class WeakCall: + """Wrap a callable and arguments into a single callable object. + + Category: General Utility Classes + + When passed a bound method as the callable, the instance portion + of it is weak-referenced, meaning the underlying instance is + free to die if all other references to it go away. Should this + occur, calling the WeakCall is simply a no-op. + + Think of this as a handy way to tell an object to do something + at some point in the future if it happens to still exist. + + # EXAMPLE A: this code will create a FooClass instance and call its + # bar() method 5 seconds later; it will be kept alive even though + # we overwrite its variable with None because the bound method + # we pass as a timer callback (foo.bar) strong-references it + foo = FooClass() + ba.timer(5.0, foo.bar) + foo = None + + # EXAMPLE B: this code will *not* keep our object alive; it will die + # when we overwrite it with None and the timer will be a no-op when it + # fires + foo = FooClass() + ba.timer(5.0, ba.WeakCall(foo.bar)) + foo = None + + Note: additional args and keywords you provide to the WeakCall() + constructor are stored as regular strong-references; you'll need + to wrap them in weakrefs manually if desired. + """ + + def __init__(self, *args: Any, **keywds: Any) -> None: + """ + Instantiate a WeakCall; pass a callable as the first + arg, followed by any number of arguments or keywords. + + # example: wrap a method call with some positional and keyword args: + myweakcall = ba.WeakCall(myobj.dostuff, argval1, namedarg=argval2) + + # Now we have a single callable to run that whole mess. + # This is the same as calling myobj.dostuff(argval1, namedarg=argval2) + # (provided my_obj still exists; this will do nothing otherwise) + myweakcall() + """ + if hasattr(args[0], '__func__'): + self._call = WeakMethod(args[0]) + else: + app = _ba.app + if not app.did_weak_call_warning: + print(('Warning: callable passed to ba.WeakCall() is not' + ' weak-referencable (' + str(args[0]) + + '); use ba.Call() instead to avoid this ' + 'warning. Stack-trace:')) + import traceback + traceback.print_stack() + app.did_weak_call_warning = True + self._call = args[0] + self._args = args[1:] + self._keywds = keywds + + def __call__(self, *args_extra: Any) -> Any: + return self._call(*self._args + args_extra, **self._keywds) + + def __str__(self) -> str: + return ('') + + +class Call: + """Wraps a callable and arguments into a single callable object. + + Category: General Utility Classes + + The callable is strong-referenced so it won't die until this object does. + Note that a bound method (ex: myobj.dosomething) contains a reference + to 'self' (myobj in that case), so you will be keeping that object alive + too. Use ba.WeakCall if you want to pass a method to callback without + keeping its object alive. + """ + + def __init__(self, *args: Any, **keywds: Any): + """ + Instantiate a Call; pass a callable as the first + arg, followed by any number of arguments or keywords. + + # example: wrap a method call with 1 positional and 1 keyword arg + mycall = ba.Call(myobj.dostuff, argval1, namedarg=argval2) + + # now we have a single callable to run that whole mess + # this is the same as calling myobj.dostuff(argval1, namedarg=argval2) + mycall() + """ + self._call = args[0] + self._args = args[1:] + self._keywds = keywds + + def __call__(self, *args_extra: Any) -> Any: + return self._call(*self._args + args_extra, **self._keywds) + + def __str__(self) -> str: + return ('') + + +class WeakMethod: + """A weak-referenced bound method. + + Wraps a bound method using weak references so that the original is + free to die. If called with a dead target, is simply a no-op. + """ + + def __init__(self, call: types.MethodType): + assert isinstance(call, types.MethodType) + self._func = call.__func__ + self._obj = weakref.ref(call.__self__) + + def __call__(self, *args: Any, **keywds: Any) -> Any: + obj = self._obj() + if obj is None: + return None + return self._func(*((obj, ) + args), **keywds) + + def __str__(self) -> str: + return '' + + +def make_hash(obj: Any) -> int: + """Makes a hash from a dictionary, list, tuple or set to any level, + that contains only other hashable types (including any lists, tuples, + sets, and dictionaries). + """ + + if isinstance(obj, (set, tuple, list)): + return hash(tuple([make_hash(e) for e in obj])) + if not isinstance(obj, dict): + return hash(obj) + + new_obj = copy.deepcopy(obj) + for k, v in new_obj.items(): + new_obj[k] = make_hash(v) + + return hash(tuple(frozenset(sorted(new_obj.items())))) diff --git a/assets/src/data/scripts/ba/_hooks.py b/assets/src/data/scripts/ba/_hooks.py new file mode 100644 index 00000000..aa5d01d0 --- /dev/null +++ b/assets/src/data/scripts/ba/_hooks.py @@ -0,0 +1,331 @@ +"""Snippets of code for use by the internal C++ layer. + +History: originally I would dynamically compile/eval bits of Python text +from within C++ code, but the major downside there was that I would +never catch code breakage until the code was next run. By defining all +snippets I use here and then capturing references to them all at launch +I can verify everything I'm looking for exists and pylint can do +its magic on this file. +""" +# (most of these are self-explanatory) +# pylint: disable=missing-function-docstring +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import List, Sequence, Optional + import ba + + +def reset_to_main_menu() -> None: + """Reset the game to the main menu gracefully.""" + _ba.app.return_to_main_menu_session_gracefully() + + +def set_config_fullscreen_on() -> None: + """The app has set fullscreen on its own and we should note it.""" + _ba.app.config['Fullscreen'] = True + _ba.app.config.commit() + + +def set_config_fullscreen_off() -> None: + """The app has set fullscreen on its own and we should note it.""" + _ba.app.config['Fullscreen'] = False + _ba.app.config.commit() + + +def not_signed_in_screen_message() -> None: + from ba._lang import Lstr + _ba.screenmessage(Lstr(resource='notSignedInErrorText')) + + +def connecting_to_party_message() -> None: + from ba._lang import Lstr + _ba.screenmessage(Lstr(resource='internal.connectingToPartyText'), + color=(1, 1, 1)) + + +def rejecting_invite_already_in_party_message() -> None: + from ba._lang import Lstr + _ba.screenmessage( + Lstr(resource='internal.rejectingInviteAlreadyInPartyText'), + color=(1, 0.5, 0)) + + +def connection_failed_message() -> None: + from ba._lang import Lstr + _ba.screenmessage(Lstr(resource='internal.connectionFailedText'), + color=(1, 0.5, 0)) + + +def temporarily_unavailable_message() -> None: + from ba._lang import Lstr + _ba.playsound(_ba.getsound('error')) + _ba.screenmessage( + Lstr(resource='getTicketsWindow.unavailableTemporarilyText'), + color=(1, 0, 0)) + + +def in_progress_message() -> None: + from ba._lang import Lstr + _ba.playsound(_ba.getsound('error')) + _ba.screenmessage(Lstr(resource='getTicketsWindow.inProgressText'), + color=(1, 0, 0)) + + +def error_message() -> None: + from ba._lang import Lstr + _ba.playsound(_ba.getsound('error')) + _ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0)) + + +def purchase_not_valid_error() -> None: + from ba._lang import Lstr + _ba.playsound(_ba.getsound('error')) + _ba.screenmessage(Lstr(resource='store.purchaseNotValidError', + subs=[('${EMAIL}', 'support@froemling.net')]), + color=(1, 0, 0)) + + +def purchase_already_in_progress_error() -> None: + from ba._lang import Lstr + _ba.playsound(_ba.getsound('error')) + _ba.screenmessage(Lstr(resource='store.purchaseAlreadyInProgressText'), + color=(1, 0, 0)) + + +def gear_vr_controller_warning() -> None: + from ba._lang import Lstr + _ba.playsound(_ba.getsound('error')) + _ba.screenmessage(Lstr(resource='usesExternalControllerText'), + color=(1, 0, 0)) + + +def orientation_reset_cb_message() -> None: + from ba._lang import Lstr + _ba.screenmessage( + Lstr(resource='internal.vrOrientationResetCardboardText'), + color=(0, 1, 0)) + + +def orientation_reset_message() -> None: + from ba._lang import Lstr + _ba.screenmessage(Lstr(resource='internal.vrOrientationResetText'), + color=(0, 1, 0)) + + +def handle_app_resume() -> None: + _ba.app.handle_app_resume() + + +def launch_main_menu_session() -> None: + from bastd.mainmenu import MainMenuSession + _ba.new_host_session(MainMenuSession) + + +def language_test_toggle() -> None: + from ba._lang import setlanguage + setlanguage('Gibberish' if _ba.app.language == 'English' else 'English') + + +def award_in_control_achievement() -> None: + from ba._achievement import award_local_achievement + award_local_achievement('In Control') + + +def award_dual_wielding_achievement() -> None: + from ba._achievement import award_local_achievement + award_local_achievement('Dual Wielding') + + +def play_gong_sound() -> None: + _ba.playsound(_ba.getsound('gong')) + + +def launch_coop_game(name: str) -> None: + _ba.app.launch_coop_game(name) + + +def purchases_restored_message() -> None: + from ba._lang import Lstr + _ba.screenmessage(Lstr(resource='getTicketsWindow.purchasesRestoredText'), + color=(0, 1, 0)) + + +def dismiss_wii_remotes_window() -> None: + call = _ba.app.dismiss_wii_remotes_window_call + if call is not None: + # Weird; this seems to trigger pylint only sometimes. + # pylint: disable=useless-suppression + # pylint: disable=not-callable + call() + + +def unavailable_message() -> None: + from ba._lang import Lstr + _ba.screenmessage(Lstr(resource='getTicketsWindow.unavailableText'), + color=(1, 0, 0)) + + +def submit_analytics_counts(sval: str) -> None: + _ba.add_transaction({'type': 'ANALYTICS_COUNTS', 'values': sval}) + _ba.run_transactions() + + +def set_last_ad_network(sval: str) -> None: + import time + _ba.app.last_ad_network = sval + _ba.app.last_ad_network_set_time = time.time() + + +def no_game_circle_message() -> None: + from ba._lang import Lstr + _ba.screenmessage(Lstr(resource='noGameCircleText'), color=(1, 0, 0)) + + +def empty_call() -> None: + pass + + +def level_icon_press() -> None: + print('LEVEL ICON PRESSED') + + +def trophy_icon_press() -> None: + print('TROPHY ICON PRESSED') + + +def coin_icon_press() -> None: + print('COIN ICON PRESSED') + + +def ticket_icon_press() -> None: + from bastd.ui.resourcetypeinfo import ResourceTypeInfoWindow + ResourceTypeInfoWindow( + origin_widget=_ba.get_special_widget('tickets_info_button')) + + +def back_button_press() -> None: + _ba.back_press() + + +def friends_button_press() -> None: + print('FRIEND BUTTON PRESSED!') + + +def print_trace() -> None: + import traceback + print('Python Traceback (most recent call last):') + traceback.print_stack() + + +def toggle_fullscreen() -> None: + cfg = _ba.app.config + cfg['Fullscreen'] = not cfg.resolve('Fullscreen') + cfg.apply_and_commit() + + +def party_icon_activate(origin: Sequence[float]) -> None: + import weakref + from bastd.ui.party import PartyWindow + app = _ba.app + _ba.playsound(_ba.getsound('swish')) + + # If it exists, dismiss it; otherwise make a new one. + if app.party_window is not None and app.party_window() is not None: + app.party_window().close() + else: + app.party_window = weakref.ref(PartyWindow(origin=origin)) + + +def read_config() -> None: + _ba.app.read_config() + + +def ui_remote_press() -> None: + """Handle a press by a remote device that is only usable for nav.""" + from ba._lang import Lstr + _ba.screenmessage(Lstr(resource="internal.controllerForMenusOnlyText"), + color=(1, 0, 0)) + _ba.playsound(_ba.getsound('error')) + + +def quit_window() -> None: + from bastd.ui.confirm import QuitWindow + QuitWindow() + + +def remove_in_game_ads_message() -> None: + _ba.app.do_remove_in_game_ads_message() + + +def telnet_access_request() -> None: + from bastd.ui.telnet import TelnetAccessRequestWindow + TelnetAccessRequestWindow() + + +def app_pause() -> None: + _ba.app.handle_app_pause() + + +def do_quit() -> None: + _ba.quit() + + +def shutdown() -> None: + _ba.app.shutdown() + + +def gc_disable() -> None: + import gc + gc.disable() + + +def device_menu_press(device: ba.InputDevice) -> None: + from bastd.ui.mainmenu import MainMenuWindow + in_main_menu = bool(_ba.app.main_menu_window) + if not in_main_menu: + _ba.set_ui_input_device(device) + _ba.playsound(_ba.getsound('swish')) + _ba.app.main_menu_window = (MainMenuWindow().get_root_widget()) + + +def show_url_window(address: str) -> None: + from bastd.ui.url import ShowURLWindow + ShowURLWindow(address) + + +def party_invite_revoke(invite_id: str) -> None: + # If there's a confirm window up for joining this particular + # invite, kill it. + for winref in _ba.app.invite_confirm_windows: + win = winref() + if win is not None and win.ew_party_invite_id == invite_id: + _ba.containerwidget(edit=win.get_root_widget(), + transition='out_right') + + +def filter_chat_message(msg: str, client_id: int) -> Optional[str]: + """Intercept/filter chat messages. + + Called for all chat messages while hosting. + Messages originating from the host will have clientID -1. + Should filter and return the string to be displayed, or return None + to ignore the message. + """ + del client_id # Unused by default. + return msg + + +def local_chat_message(msg: str) -> None: + if (_ba.app.party_window is not None + and _ba.app.party_window() is not None): + _ba.app.party_window().on_chat_message(msg) + + +def handle_remote_achievement_list(completed_achievements: List[str]) -> None: + from ba import _achievement + _achievement.set_completed_achievements(completed_achievements) diff --git a/assets/src/data/scripts/ba/_input.py b/assets/src/data/scripts/ba/_input.py new file mode 100644 index 00000000..97045b89 --- /dev/null +++ b/assets/src/data/scripts/ba/_input.py @@ -0,0 +1,618 @@ +"""Input related functionality""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Any, Dict, Tuple + import ba + + +def get_device_value(device: ba.InputDevice, name: str) -> Any: + """Returns a mapped value for an input device. + + This checks the user config and falls back to default values + where available. + """ + # pylint: disable=too-many-statements + # pylint: disable=too-many-return-statements + # pylint: disable=too-many-branches + devicename = device.name + unique_id = device.unique_identifier + app = _ba.app + useragentstring = app.user_agent_string + platform = app.platform + subplatform = app.subplatform + bs_config = _ba.app.config + + # If there's an entry in our config for this controller, use it. + if "Controllers" in bs_config: + ccfgs = bs_config["Controllers"] + if devicename in ccfgs: + mapping = None + if unique_id in ccfgs[devicename]: + mapping = ccfgs[devicename][unique_id] + elif "default" in ccfgs[devicename]: + mapping = ccfgs[devicename]["default"] + if mapping is not None: + return mapping.get(name, -1) + + if platform == 'windows': + + # XInput (hopefully this mapping is consistent?...) + if devicename.startswith('XInput Controller'): + return { + 'triggerRun2': 3, + 'unassignedButtonsRun': False, + 'buttonPickUp': 4, + 'buttonBomb': 2, + 'buttonStart': 8, + 'buttonIgnored2': 7, + 'triggerRun1': 6, + 'buttonPunch': 3, + 'buttonRun2': 5, + 'buttonRun1': 6, + 'buttonJump': 1, + 'buttonIgnored': 11 + }.get(name, -1) + + # Ps4 controller. + if devicename == 'Wireless Controller': + return { + 'triggerRun2': 4, + 'unassignedButtonsRun': False, + 'buttonPickUp': 4, + 'buttonBomb': 3, + 'buttonJump': 2, + 'buttonStart': 10, + 'buttonPunch': 1, + 'buttonRun2': 5, + 'buttonRun1': 6, + 'triggerRun1': 5 + }.get(name, -1) + + # Look for some exact types. + if _ba.is_running_on_fire_tv(): + if devicename in ['Thunder', 'Amazon Fire Game Controller']: + return { + 'triggerRun2': 23, + 'unassignedButtonsRun': False, + 'buttonPickUp': 101, + 'buttonBomb': 98, + 'buttonJump': 97, + 'analogStickDeadZone': 0.0, + 'startButtonActivatesDefaultWidget': False, + 'buttonStart': 83, + 'buttonPunch': 100, + 'buttonRun2': 103, + 'buttonRun1': 104, + 'triggerRun1': 24 + }.get(name, -1) + if devicename == 'NYKO PLAYPAD PRO': + return { + 'triggerRun2': 23, + 'triggerRun1': 24, + 'buttonPickUp': 101, + 'buttonBomb': 98, + 'buttonJump': 97, + 'buttonUp': 20, + 'buttonLeft': 22, + 'buttonRight': 23, + 'buttonStart': 83, + 'buttonPunch': 100, + 'buttonDown': 21 + }.get(name, -1) + if devicename == 'Logitech Dual Action': + return { + 'triggerRun2': 23, + 'triggerRun1': 24, + 'buttonPickUp': 98, + 'buttonBomb': 101, + 'buttonJump': 100, + 'buttonStart': 109, + 'buttonPunch': 97 + }.get(name, -1) + if devicename == 'Xbox 360 Wireless Receiver': + return { + 'triggerRun2': 23, + 'triggerRun1': 24, + 'buttonPickUp': 101, + 'buttonBomb': 98, + 'buttonJump': 97, + 'buttonUp': 20, + 'buttonLeft': 22, + 'buttonRight': 23, + 'buttonStart': 83, + 'buttonPunch': 100, + 'buttonDown': 21 + }.get(name, -1) + if devicename == 'Microsoft X-Box 360 pad': + return { + 'triggerRun2': 23, + 'triggerRun1': 24, + 'buttonPickUp': 101, + 'buttonBomb': 98, + 'buttonJump': 97, + 'buttonStart': 83, + 'buttonPunch': 100 + }.get(name, -1) + if devicename in [ + 'Amazon Remote', 'Amazon Bluetooth Dev', + 'Amazon Fire TV Remote' + ]: + return { + 'triggerRun2': 23, + 'triggerRun1': 24, + 'buttonPickUp': 24, + 'buttonBomb': 91, + 'buttonJump': 86, + 'buttonUp': 20, + 'buttonLeft': 22, + 'startButtonActivatesDefaultWidget': False, + 'buttonRight': 23, + 'buttonStart': 83, + 'buttonPunch': 90, + 'buttonDown': 21 + }.get(name, -1) + + elif 'NVIDIA SHIELD;' in useragentstring: + if 'NVIDIA Controller' in devicename: + return { + 'triggerRun2': 19, + 'triggerRun1': 18, + 'buttonPickUp': 101, + 'buttonBomb': 98, + 'buttonJump': 97, + 'analogStickDeadZone': 0.0, + 'buttonStart': 109, + 'buttonPunch': 100, + 'buttonIgnored': 184, + 'buttonIgnored2': 86 + }.get(name, -1) + elif platform == 'mac': + if devicename == 'PLAYSTATION(R)3 Controller': + return { + 'buttonLeft': 8, + 'buttonUp': 5, + 'buttonRight': 6, + 'buttonDown': 7, + 'buttonJump': 15, + 'buttonPunch': 16, + 'buttonBomb': 14, + 'buttonPickUp': 13, + 'buttonStart': 4, + 'buttonIgnored': 17 + }.get(name, -1) + if devicename in ['Wireless 360 Controller', 'Controller']: + + # Xbox360 gamepads + return { + 'analogStickDeadZone': 1.2, + 'buttonBomb': 13, + 'buttonDown': 2, + 'buttonJump': 12, + 'buttonLeft': 3, + 'buttonPickUp': 15, + 'buttonPunch': 14, + 'buttonRight': 4, + 'buttonStart': 5, + 'buttonUp': 1, + 'triggerRun1': 5, + 'triggerRun2': 6, + 'buttonIgnored': 11 + }.get(name, -1) + if (devicename in [ + 'Logitech Dual Action', 'Logitech Cordless RumblePad 2' + ]): + return { + 'buttonJump': 2, + 'buttonPunch': 1, + 'buttonBomb': 3, + 'buttonPickUp': 4, + 'buttonStart': 10 + }.get(name, -1) + + # Old gravis gamepad. + if devicename == 'GamePad Pro USB ': + return { + 'buttonJump': 2, + 'buttonPunch': 1, + 'buttonBomb': 3, + 'buttonPickUp': 4, + 'buttonStart': 10 + }.get(name, -1) + + if devicename == 'Microsoft SideWinder Plug & Play Game Pad': + return { + 'buttonJump': 1, + 'buttonPunch': 3, + 'buttonBomb': 2, + 'buttonPickUp': 4, + 'buttonStart': 6 + }.get(name, -1) + + # Saitek P2500 Rumble Force Pad.. (hopefully works for others too?..) + if devicename == 'Saitek P2500 Rumble Force Pad': + return { + 'buttonJump': 3, + 'buttonPunch': 1, + 'buttonBomb': 4, + 'buttonPickUp': 2, + 'buttonStart': 11 + }.get(name, -1) + + # Some crazy 'Senze' dual gamepad. + if devicename == 'Twin USB Joystick': + return { + 'analogStickLR': 3, + 'analogStickLR_B': 7, + 'analogStickUD': 4, + 'analogStickUD_B': 8, + 'buttonBomb': 2, + 'buttonBomb_B': 14, + 'buttonJump': 3, + 'buttonJump_B': 15, + 'buttonPickUp': 1, + 'buttonPickUp_B': 13, + 'buttonPunch': 4, + 'buttonPunch_B': 16, + 'buttonRun1': 7, + 'buttonRun1_B': 19, + 'buttonRun2': 8, + 'buttonRun2_B': 20, + 'buttonStart': 10, + 'buttonStart_B': 22, + 'enableSecondary': 1, + 'unassignedButtonsRun': False + }.get(name, -1) + if devicename == 'USB Gamepad ': # some weird 'JITE' gamepad + return { + 'analogStickLR': 4, + 'analogStickUD': 5, + 'buttonJump': 3, + 'buttonPunch': 4, + 'buttonBomb': 2, + 'buttonPickUp': 1, + 'buttonStart': 10 + }.get(name, -1) + + default_android_mapping = { + 'triggerRun2': 19, + 'unassignedButtonsRun': False, + 'buttonPickUp': 101, + 'buttonBomb': 98, + 'buttonJump': 97, + 'buttonStart': 83, + 'buttonStart2': 109, + 'buttonPunch': 100, + 'buttonRun2': 104, + 'buttonRun1': 103, + 'triggerRun1': 18, + 'buttonLeft': 22, + 'buttonRight': 23, + 'buttonUp': 20, + 'buttonDown': 21, + 'buttonVRReorient': 110 + } + + # Generic android... + if platform == 'android': + + # Steelseries stratus xl. + if devicename == 'SteelSeries Stratus XL': + return { + 'triggerRun2': 23, + 'unassignedButtonsRun': False, + 'buttonPickUp': 101, + 'buttonBomb': 98, + 'buttonJump': 97, + 'buttonStart': 83, + 'buttonStart2': 109, + 'buttonPunch': 100, + 'buttonRun2': 104, + 'buttonRun1': 103, + 'triggerRun1': 24, + 'buttonLeft': 22, + 'buttonRight': 23, + 'buttonUp': 20, + 'buttonDown': 21, + 'buttonVRReorient': 108 + }.get(name, -1) + + # Adt-1 gamepad (use funky 'mode' button for start). + if devicename == 'Gamepad': + return { + 'triggerRun2': 19, + 'unassignedButtonsRun': False, + 'buttonPickUp': 101, + 'buttonBomb': 98, + 'buttonJump': 97, + 'buttonStart': 111, + 'buttonPunch': 100, + 'startButtonActivatesDefaultWidget': False, + 'buttonRun2': 104, + 'buttonRun1': 103, + 'triggerRun1': 18 + }.get(name, -1) + # Nexus player remote. + if devicename == 'Nexus Remote': + return { + 'triggerRun2': 19, + 'unassignedButtonsRun': False, + 'buttonPickUp': 101, + 'buttonBomb': 98, + 'buttonJump': 97, + 'buttonUp': 20, + 'buttonLeft': 22, + 'buttonDown': 21, + 'buttonRight': 23, + 'buttonStart': 83, + 'buttonStart2': 109, + 'buttonPunch': 24, + 'buttonRun2': 104, + 'buttonRun1': 103, + 'triggerRun1': 18 + }.get(name, -1) + + if devicename == "virtual-remote": + return { + 'triggerRun2': 19, + 'unassignedButtonsRun': False, + 'buttonPickUp': 101, + 'buttonBomb': 98, + 'buttonStart': 83, + 'buttonJump': 24, + 'buttonUp': 20, + 'buttonLeft': 22, + 'buttonRight': 23, + 'triggerRun1': 18, + 'buttonStart2': 109, + 'buttonPunch': 100, + 'buttonRun2': 104, + 'buttonRun1': 103, + 'buttonDown': 21, + 'startButtonActivatesDefaultWidget': False, + 'uiOnly': True + }.get(name, -1) + + # flag particular gamepads to use exact android defaults.. + # (so they don't even ask to configure themselves) + if devicename in ['Samsung Game Pad EI-GP20', 'ASUS Gamepad' + ] or devicename.startswith('Freefly VR Glide'): + return default_android_mapping.get(name, -1) + + # Nvidia controller is default, but gets some strange + # keypresses we want to ignore.. touching the touchpad, + # so lets ignore those. + if 'NVIDIA Controller' in devicename: + return { + 'triggerRun2': 19, + 'unassignedButtonsRun': False, + 'buttonPickUp': 101, + 'buttonIgnored': 126, + 'buttonIgnored2': 1, + 'buttonBomb': 98, + 'buttonJump': 97, + 'buttonStart': 83, + 'buttonStart2': 109, + 'buttonPunch': 100, + 'buttonRun2': 104, + 'buttonRun1': 103, + 'triggerRun1': 18 + }.get(name, -1) + + # Default keyboard vals across platforms.. + if devicename == 'Keyboard' and unique_id == '#2': + if platform == 'mac' and subplatform == 'appstore': + return { + 'buttonJump': 258, + 'buttonPunch': 257, + 'buttonBomb': 262, + 'buttonPickUp': 261, + 'buttonUp': 273, + 'buttonDown': 274, + 'buttonLeft': 276, + 'buttonRight': 275, + 'buttonStart': 263 + }.get(name, -1) + return { + 'buttonPickUp': 1073741917, + 'buttonBomb': 1073741918, + 'buttonJump': 1073741914, + 'buttonUp': 1073741906, + 'buttonLeft': 1073741904, + 'buttonRight': 1073741903, + 'buttonStart': 1073741919, + 'buttonPunch': 1073741913, + 'buttonDown': 1073741905 + }.get(name, -1) + if devicename == 'Keyboard' and unique_id == '#1': + return { + 'buttonJump': 107, + 'buttonPunch': 106, + 'buttonBomb': 111, + 'buttonPickUp': 105, + 'buttonUp': 119, + 'buttonDown': 115, + 'buttonLeft': 97, + 'buttonRight': 100 + }.get(name, -1) + + # Ok, this gamepad's not in our specific preset list; + # fall back to some (hopefully) reasonable defaults. + + # Leaving these in here for now but not gonna add any more now that we have + # fancy-pants config sharing across the internet. + if platform == 'mac': + if 'PLAYSTATION' in devicename: # ps3 gamepad?.. + return { + 'buttonLeft': 8, + 'buttonUp': 5, + 'buttonRight': 6, + 'buttonDown': 7, + 'buttonJump': 15, + 'buttonPunch': 16, + 'buttonBomb': 14, + 'buttonPickUp': 13, + 'buttonStart': 4 + }.get(name, -1) + + # Dual Action Config - hopefully applies to more... + if 'Logitech' in devicename: + return { + 'buttonJump': 2, + 'buttonPunch': 1, + 'buttonBomb': 3, + 'buttonPickUp': 4, + 'buttonStart': 10 + }.get(name, -1) + + # Saitek P2500 Rumble Force Pad.. (hopefully works for others too?..) + if 'Saitek' in devicename: + return { + 'buttonJump': 3, + 'buttonPunch': 1, + 'buttonBomb': 4, + 'buttonPickUp': 2, + 'buttonStart': 11 + }.get(name, -1) + + # Gravis stuff?... + if 'GamePad' in devicename: + return { + 'buttonJump': 2, + 'buttonPunch': 1, + 'buttonBomb': 3, + 'buttonPickUp': 4, + 'buttonStart': 10 + }.get(name, -1) + + # Reasonable defaults. + if platform == 'android': + if _ba.is_running_on_fire_tv(): + + # Mostly same as default firetv controller. + return { + 'triggerRun2': 23, + 'triggerRun1': 24, + 'buttonPickUp': 101, + 'buttonBomb': 98, + 'buttonJump': 97, + 'buttonStart': 83, + 'buttonPunch': 100, + 'buttonDown': 21, + 'buttonUp': 20, + 'buttonLeft': 22, + 'buttonRight': 23, + 'startButtonActivatesDefaultWidget': False, + }.get(name, -1) + + # Mostly same as 'Gamepad' except with 'menu' for default start + # button instead of 'mode'. + return default_android_mapping.get(name, -1) + + # Is there a point to any sort of fallbacks here?.. should check. + return { + 'buttonJump': 1, + 'buttonPunch': 2, + 'buttonBomb': 3, + 'buttonPickUp': 4, + 'buttonStart': 5 + }.get(name, -1) + + +def _gen_android_input_hash() -> str: + import os + import hashlib + md5 = hashlib.md5() + + # Currently we just do a single hash of *all* inputs on android + # and that's it.. good enough. + # (grabbing mappings for a specific device looks to be non-trivial) + for dirname in [ + '/system/usr/keylayout', '/data/usr/keylayout', + '/data/system/devices/keylayout' + ]: + try: + if os.path.isdir(dirname): + for f_name in os.listdir(dirname): + # This is usually volume keys and stuff; + # assume we can skip it?.. + # (since it'll vary a lot across devices) + if f_name == 'gpio-keys.kl': + continue + with open(dirname + '/' + f_name, 'rb') as infile: + md5.update(infile.read()) + except Exception: + from ba import _error + _error.print_exception( + 'error in _gen_android_input_hash inner loop') + return md5.hexdigest() + + +def get_input_map_hash(inputdevice: ba.InputDevice) -> str: + """Given an input device, return a hash based on its raw input values. + + This lets us avoid sharing mappings across devices that may + have the same name but actually produce different input values. + (Different Android versions, for example, may return different + key codes for button presses on a given type of controller) + """ + del inputdevice # Currently unused. + app = _ba.app + try: + if app.input_map_hash is None: + if app.platform == 'android': + app.input_map_hash = _gen_android_input_hash() + else: + app.input_map_hash = '' + return app.input_map_hash + except Exception: + from ba import _error + _error.print_exception('Exception in get_input_map_hash') + return '' + + +def get_input_device_config(device: ba.InputDevice, + default: bool) -> Tuple[Dict, str]: + """Given an input device, return its config dict in the app config. + + The dict will be created if it does not exist. + """ + cfg = _ba.app.config + name = device.name + ccfgs: Dict[str, Any] = cfg.setdefault("Controllers", {}) + ccfgs.setdefault(name, {}) + unique_id = device.unique_identifier + if default: + if unique_id in ccfgs[name]: + del ccfgs[name][unique_id] + if 'default' not in ccfgs[name]: + ccfgs[name]['default'] = {} + return ccfgs[name], 'default' + if unique_id not in ccfgs[name]: + ccfgs[name][unique_id] = {} + return ccfgs[name], unique_id + + +def get_last_player_name_from_input_device(device: ba.InputDevice) -> str: + """Return a reasonable player name associated with a device. + + (generally the last one used there) + """ + bs_config = _ba.app.config + + # Look for a default player profile name for them; + # otherwise default to their current random name. + profilename = '_random' + key_name = device.name + ' ' + device.unique_identifier + if ('Default Player Profiles' in bs_config + and key_name in bs_config['Default Player Profiles']): + profilename = bs_config['Default Player Profiles'][key_name] + if profilename == '_random': + profilename = device.get_default_player_name() + if profilename == '__account__': + profilename = _ba.get_account_display_string() + return profilename diff --git a/assets/src/data/scripts/ba/_lang.py b/assets/src/data/scripts/ba/_lang.py new file mode 100644 index 00000000..d36b3c4d --- /dev/null +++ b/assets/src/data/scripts/ba/_lang.py @@ -0,0 +1,411 @@ +"""Language related functionality.""" +from __future__ import annotations + +import json +import os +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Any, Dict, List, Optional + + +class Lstr: + """Used to specify strings in a language-independent way. + + category: General Utility Classes + + These should be used whenever possible in place of hard-coded strings + so that in-game or UI elements show up correctly on all clients in their + currently-active language. + + To see available resource keys, look at any of the bs_language_*.py files + in the game or the translations pages at bombsquadgame.com/translate. + + # EXAMPLE 1: specify a string from a resource path + mynode.text = ba.Lstr(resource='audioSettingsWindow.titleText') + + # EXAMPLE 2: specify a translated string via a category and english value; + # if a translated value is available, it will be used; otherwise the + # english value will be. To see available translation categories, look + # under the 'translations' resource section. + mynode.text = ba.Lstr(translate=('gameDescriptions', 'Defeat all enemies')) + + # EXAMPLE 3: specify a raw value and some substitutions. Substitutions can + # be used with resource and translate modes as well. + mynode.text = ba.Lstr(value='${A} / ${B}', + subs=[('${A}', str(score)), ('${B}', str(total))]) + + # EXAMPLE 4: Lstrs can be nested. This example would display the resource + # at res_a but replace ${NAME} with the value of the resource at res_b + mytextnode.text = ba.Lstr(resource='res_a', + subs=[('${NAME}', ba.Lstr(resource='res_b'))]) + """ + + def __init__(self, *args: Any, **keywds: Any) -> None: + """Instantiate a Lstr. + + Pass a value for either 'resource', 'translate', + or 'value'. (see Lstr help for examples). + 'subs' can be a sequence of 2-member sequences consisting of values + and replacements. + 'fallback_resource' can be a resource key that will be used if the + main one is not present for + the current language in place of falling back to the english value + ('resource' mode only). + 'fallback_value' can be a literal string that will be used if neither + the resource nor the fallback resource is found ('resource' mode only). + """ + # pylint: disable=too-many-branches + if args: + raise Exception('Lstr accepts only keyword arguments') + + # Basically just store the exact args they passed. + # However if they passed any Lstr values for subs, + # replace them with that Lstr's dict. + self.args = keywds + our_type = type(self) + + if isinstance(self.args.get('value'), our_type): + raise Exception("'value' must be a regular string; not an Lstr") + + if 'subs' in self.args: + subs_new = [] + for key, value in keywds['subs']: + if isinstance(value, our_type): + subs_new.append((key, value.args)) + else: + subs_new.append((key, value)) + self.args['subs'] = subs_new + + # As of protocol 31 we support compact key names + # ('t' instead of 'translate', etc). Convert as needed. + if 'translate' in keywds: + keywds['t'] = keywds['translate'] + del keywds['translate'] + if 'resource' in keywds: + keywds['r'] = keywds['resource'] + del keywds['resource'] + if 'value' in keywds: + keywds['v'] = keywds['value'] + del keywds['value'] + if 'fallback' in keywds: + from ba import _error + _error.print_error( + 'deprecated "fallback" arg passed to Lstr(); use ' + 'either "fallback_resource" or "fallback_value"', + once=True) + keywds['f'] = keywds['fallback'] + del keywds['fallback'] + if 'fallback_resource' in keywds: + keywds['f'] = keywds['fallback_resource'] + del keywds['fallback_resource'] + if 'subs' in keywds: + keywds['s'] = keywds['subs'] + del keywds['subs'] + if 'fallback_value' in keywds: + keywds['fv'] = keywds['fallback_value'] + del keywds['fallback_value'] + + def evaluate(self) -> str: + """Evaluate the Lstr and returns a flat string in the current language. + + You should avoid doing this as much as possible and instead pass + and store Lstr values. + """ + return _ba.evaluate_lstr(self._get_json()) + + def is_flat_value(self) -> bool: + """Return whether the Lstr is a 'flat' value. + + This is defined as a simple string value incorporating no translations, + resources, or substitutions. In this case it may be reasonable to + replace it with a raw string value, perform string manipulation on it, + etc. + """ + return bool('v' in self.args and not self.args.get('s', [])) + + def _get_json(self) -> str: + try: + return json.dumps(self.args, separators=(',', ':')) + except Exception: + from ba import _error + _error.print_exception('_get_json failed for', self.args) + return 'JSON_ERR' + + def __str__(self) -> str: + return '' + + def __repr__(self) -> str: + return '' + + +def setlanguage(language: Optional[str], + print_change: bool = True, + store_to_config: bool = True) -> None: + """Set the active language used for the game. + + category: General Utility Functions + + Pass None to use OS default language. + """ + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + cfg = _ba.app.config + cur_language = cfg.get('Lang', None) + + # Store this in the config if its changing. + if language != cur_language and store_to_config: + if language is None: + if 'Lang' in cfg: + del cfg['Lang'] # Clear it out for default. + else: + cfg['Lang'] = language + cfg.commit() + switched = True + else: + switched = False + + with open('data/data/languages/english.json') as infile: + lenglishvalues = json.loads(infile.read()) + + # None implies default. + if language is None: + language = _ba.app.default_language + try: + if language == 'English': + lmodvalues = None + else: + lmodfile = 'data/data/languages/' + language.lower() + '.json' + with open(lmodfile) as infile: + lmodvalues = json.loads(infile.read()) + except Exception: + from ba import _error + _error.print_exception('Exception importing language:', language) + _ba.screenmessage("Error setting language to '" + language + + "'; see log for details", + color=(1, 0, 0)) + switched = False + lmodvalues = None + + # Create an attrdict of *just* our target language. + _ba.app.language_target = AttrDict() + langtarget = _ba.app.language_target + assert langtarget is not None + _add_to_attr_dict(langtarget, + lmodvalues if lmodvalues is not None else lenglishvalues) + + # Create an attrdict of our target language overlaid on our base (english). + languages = [lenglishvalues] + if lmodvalues is not None: + languages.append(lmodvalues) + lfull = AttrDict() + for lmod in languages: + _add_to_attr_dict(lfull, lmod) + _ba.app.language_merged = lfull + + # Pass some keys/values in for low level code to use; + # start with everything in their 'internal' section. + internal_vals = [ + v for v in list(lfull['internal'].items()) if isinstance(v[1], str) + ] + + # Cherry-pick various other values to include. + # (should probably get rid of the 'internal' section + # and do everything this way) + for value in [ + 'replayNameDefaultText', 'replayWriteErrorText', + 'replayVersionErrorText', 'replayReadErrorText' + ]: + internal_vals.append((value, lfull[value])) + internal_vals.append( + ('axisText', lfull['configGamepadWindow']['axisText'])) + lmerged = _ba.app.language_merged + assert lmerged is not None + random_names = [ + n.strip() for n in lmerged['randomPlayerNamesText'].split(',') + ] + random_names = [n for n in random_names if n != ''] + _ba.set_internal_language_keys(internal_vals, random_names) + if switched and print_change: + _ba.screenmessage(Lstr(resource='languageSetText', + subs=[('${LANGUAGE}', + Lstr(translate=('languages', language))) + ]), + color=(0, 1, 0)) + + +def _add_to_attr_dict(dst: AttrDict, src: Dict) -> None: + for key, value in list(src.items()): + if isinstance(value, dict): + try: + dst_dict = dst[key] + except Exception: + dst_dict = dst[key] = AttrDict() + if not isinstance(dst_dict, AttrDict): + raise Exception("language key '" + key + + "' is defined both as a dict and value") + _add_to_attr_dict(dst_dict, value) + else: + if not isinstance(value, (float, int, bool, str, str, type(None))): + raise Exception("invalid value type for res '" + key + "': " + + str(type(value))) + dst[key] = value + + +class AttrDict(dict): + """A dict that can be accessed with dot notation. + + (so foo.bar is equivalent to foo['bar']) + """ + + def __getattr__(self, attr: str) -> Any: + val = self[attr] + assert not isinstance(val, bytes) + return val + + def __setattr__(self, attr: str, value: Any) -> None: + raise Exception() + + +def get_resource(resource: str, + fallback_resource: str = None, + fallback_value: Any = None) -> Any: + """Return a translation resource by name.""" + try: + # If we have no language set, go ahead and set it. + if _ba.app.language_merged is None: + language = _ba.app.language + try: + setlanguage(language, + print_change=False, + store_to_config=False) + except Exception: + from ba import _error + _error.print_exception('exception setting language to', + language) + + # Try english as a fallback. + if language != 'English': + print('Resorting to fallback language (English)') + try: + setlanguage('English', + print_change=False, + store_to_config=False) + except Exception: + _error.print_exception( + 'error setting language to english fallback') + + # If they provided a fallback_resource value, try the + # target-language-only dict first and then fall back to trying the + # fallback_resource value in the merged dict. + if fallback_resource is not None: + try: + values = _ba.app.language_target + splits = resource.split('.') + dicts = splits[:-1] + key = splits[-1] + for dct in dicts: + assert values is not None + values = values[dct] + assert values is not None + val = values[key] + return val + except Exception: + # FIXME: Shouldn't we try the fallback resource in the merged + # dict AFTER we try the main resource in the merged dict? + try: + values = _ba.app.language_merged + splits = fallback_resource.split('.') + dicts = splits[:-1] + key = splits[-1] + for dct in dicts: + assert values is not None + values = values[dct] + assert values is not None + val = values[key] + return val + + except Exception: + # If we got nothing for fallback_resource, default to the + # normal code which checks or primary value in the merge + # dict; there's a chance we can get an english value for + # it (which we weren't looking for the first time through). + pass + + values = _ba.app.language_merged + splits = resource.split('.') + dicts = splits[:-1] + key = splits[-1] + for dct in dicts: + assert values is not None + values = values[dct] + assert values is not None + val = values[key] + return val + + except Exception: + # Ok, looks like we couldn't find our main or fallback resource + # anywhere. Now if we've been given a fallback value, return it; + # otherwise fail. + if fallback_value is not None: + return fallback_value + raise Exception("resource not found: '" + resource + "'") + + +def translate(category: str, + strval: str, + raise_exceptions: bool = False, + print_errors: bool = False) -> str: + """Translate a value (or return the value if no translation available) + + Generally you should use ba.Lstr which handles dynamic translation, + as opposed to this which returns a flat string. + """ + try: + translated = get_resource('translations')[category][strval] + except Exception as exc: + if raise_exceptions: + raise + if print_errors: + print(('Translate error: category=\'' + category + '\' name=\'' + + strval + '\' exc=' + str(exc) + '')) + translated = None + translated_out: str + if translated is None: + translated_out = strval + else: + translated_out = translated + assert isinstance(translated_out, str) + return translated_out + + +def get_valid_languages() -> List[str]: + """Return a list containing names of all available languages. + + category: General Utility Functions + + Languages that may be present but are not displayable on the running + version of the game are ignored. + """ + langs = set() + app = _ba.app + try: + names = os.listdir('data/data/languages') + names = [n.replace('.json', '').capitalize() for n in names] + except Exception: + from ba import _error + _error.print_exception() + names = [] + for name in names: + if app.can_display_language(name): + langs.add(name) + return sorted(name for name in names if app.can_display_language(name)) + + +def is_custom_unicode_char(char: str) -> bool: + """Return whether a char is in the custom unicode range we use.""" + if not isinstance(char, str) or len(char) != 1: + raise Exception("Invalid Input; not unicode or not length 1") + return 0xE000 <= ord(char) <= 0xF8FF diff --git a/assets/src/data/scripts/ba/_level.py b/assets/src/data/scripts/ba/_level.py new file mode 100644 index 00000000..58b8b025 --- /dev/null +++ b/assets/src/data/scripts/ba/_level.py @@ -0,0 +1,167 @@ +"""Functionality related to individual levels in a campaign.""" +from __future__ import annotations + +import copy +import weakref +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from weakref import ReferenceType + from typing import Type, Any, Dict, Optional + import ba + + +class Level: + """An entry in a ba.Campaign consisting of a name, game type, and settings. + + category: Gameplay Classes + """ + + def __init__(self, + name: str, + gametype: Type[ba.GameActivity], + settings: Dict[str, Any], + preview_texture_name: str, + displayname: str = None): + """Initializes a Level object with the provided values.""" + self._name = name + self._gametype = gametype + self._settings = settings + self._preview_texture_name = preview_texture_name + self._displayname = displayname + self._campaign: Optional[ReferenceType[ba.Campaign]] = None + self._index: Optional[int] = None + self._score_version_string: Optional[str] = None + + @property + def name(self) -> str: + """The unique name for this Level.""" + return self._name + + def get_settings(self) -> Dict[str, Any]: + """Returns the settings for this Level.""" + settings = copy.deepcopy(self._settings) + + # So the game knows what the level is called. + # Hmm; seems hacky; I think we should take this out. + settings['name'] = self._name + return settings + + @property + def preview_texture_name(self) -> str: + """The preview texture name for this Level.""" + return self._preview_texture_name + + def get_preview_texture(self) -> ba.Texture: + """Load/return the preview Texture for this Level.""" + return _ba.gettexture(self._preview_texture_name) + + @property + def displayname(self) -> ba.Lstr: + """The localized name for this Level.""" + from ba import _lang + return _lang.Lstr( + translate=('coopLevelNames', self._displayname + if self._displayname is not None else self._name), + subs=[('${GAME}', + self._gametype.get_display_string(self._settings))]) + + @property + def gametype(self) -> Type[ba.GameActivity]: + """The type of game used for this Level.""" + return self._gametype + + def get_campaign(self) -> Optional[ba.Campaign]: + """Return the ba.Campaign this Level is associated with, or None.""" + return None if self._campaign is None else self._campaign() + + @property + def index(self) -> int: + """The zero-based index of this Level in its ba.Campaign. + + Access results in a RuntimeError if the Level is not assigned to a + Campaign. + """ + if self._index is None: + raise RuntimeError("Level is not part of a Campaign") + return self._index + + @property + def complete(self) -> bool: + """Whether this Level has been completed.""" + config = self._get_config_dict() + return config.get('Complete', False) + + def set_complete(self, val: bool) -> None: + """Set whether or not this level is complete.""" + old_val = self.complete + assert isinstance(old_val, bool) and isinstance(val, bool) + if val != old_val: + config = self._get_config_dict() + config['Complete'] = val + + def get_high_scores(self) -> Dict: + """Return the current high scores for this Level.""" + config = self._get_config_dict() + high_scores_key = 'High Scores' + self.get_score_version_string() + if high_scores_key not in config: + return {} + return copy.deepcopy(config[high_scores_key]) + + def set_high_scores(self, high_scores: Dict) -> None: + """Set high scores for this level.""" + config = self._get_config_dict() + high_scores_key = 'High Scores' + self.get_score_version_string() + config[high_scores_key] = high_scores + + def get_score_version_string(self) -> str: + """Return the score version string for this Level. + + If a Level's gameplay changes significantly, its version string + can be changed to separate its new high score lists/etc. from the old. + """ + if self._score_version_string is None: + scorever = ( + self._gametype.get_resolved_score_info()['score_version']) + if scorever != '': + scorever = ' ' + scorever + self._score_version_string = scorever + assert self._score_version_string is not None + return self._score_version_string + + @property + def rating(self) -> float: + """The current rating for this Level.""" + return self._get_config_dict().get('Rating', 0.0) + + def set_rating(self, rating: float) -> None: + """Set a rating for this Level, replacing the old ONLY IF higher.""" + old_rating = self.rating + config = self._get_config_dict() + config['Rating'] = max(old_rating, rating) + + def _get_config_dict(self) -> Dict[str, Any]: + """Return/create the persistent state dict for this level. + + The referenced dict exists under the game's config dict and + can be modified in place.""" + campaign = self.get_campaign() + if campaign is None: + raise Exception("level is not in a campaign") + campaign_config = campaign.get_config_dict() + val: Dict[str, Any] = campaign_config.setdefault( + self._name, { + 'Rating': 0.0, + 'Complete': False + }) + assert isinstance(val, dict) + return val + + def set_campaign(self, campaign: ba.Campaign, index: int) -> None: + """For use by ba.Campaign when adding levels to itself. + + (internal)""" + self._campaign = weakref.ref(campaign) + self._index = index diff --git a/assets/src/data/scripts/ba/_lobby.py b/assets/src/data/scripts/ba/_lobby.py new file mode 100644 index 00000000..60e01c61 --- /dev/null +++ b/assets/src/data/scripts/ba/_lobby.py @@ -0,0 +1,992 @@ +"""Implements lobby system for gathering before games, char select, etc.""" + +from __future__ import annotations + +import random +import weakref +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Optional, List, Dict, Any, Sequence, Union + import ba + + +# Hmm should we move this to actors?.. +class JoinInfo: + """Display useful info for joiners.""" + + def __init__(self, lobby: ba.Lobby): + # pylint: disable=too-many-locals + from ba import _input + from ba._lang import Lstr + from ba import _actor + from ba import _general + from ba._enums import SpecialChar + can_switch_teams = (len(lobby.teams) > 1) + self._state = 0 + press_to_punch: Union[str, ba.Lstr] = _ba.charstr( + SpecialChar.LEFT_BUTTON) + press_to_bomb: Union[str, ba.Lstr] = _ba.charstr( + SpecialChar.RIGHT_BUTTON) + + # If we have a keyboard, grab keys for punch and pickup. + # FIXME: This of course is only correct on the local device; + # Should change this for net games. + keyboard = _ba.get_input_device('Keyboard', '#1', doraise=False) + if keyboard is not None: + punch_key = keyboard.get_button_name( + _input.get_device_value(keyboard, 'buttonPunch')) + press_to_punch = Lstr(resource='orText', + subs=[('${A}', + Lstr(value='\'${K}\'', + subs=[('${K}', punch_key)])), + ('${B}', press_to_punch)]) + bomb_key = keyboard.get_button_name( + _input.get_device_value(keyboard, 'buttonBomb')) + press_to_bomb = Lstr(resource='orText', + subs=[('${A}', + Lstr(value='\'${K}\'', + subs=[('${K}', bomb_key)])), + ('${B}', press_to_bomb)]) + join_str = Lstr(value='${A} < ${B} >', + subs=[('${A}', + Lstr(resource='pressPunchToJoinText')), + ('${B}', press_to_punch)]) + else: + join_str = Lstr(resource='pressAnyButtonToJoinText') + + flatness = 1.0 if _ba.app.vr_mode else 0.0 + self._text = _actor.Actor( + _ba.newnode('text', + attrs={ + 'position': (0, -40), + 'h_attach': 'center', + 'v_attach': 'top', + 'h_align': 'center', + 'color': (0.7, 0.7, 0.95, 1.0), + 'flatness': flatness, + 'text': join_str + })) + + if _ba.app.kiosk_mode: + self._messages = [join_str] + else: + msg1 = Lstr(resource='pressToSelectProfileText', + subs=[ + ('${BUTTONS}', _ba.charstr(SpecialChar.UP_ARROW) + + ' ' + _ba.charstr(SpecialChar.DOWN_ARROW)) + ]) + msg2 = Lstr(resource='pressToOverrideCharacterText', + subs=[('${BUTTONS}', Lstr(resource='bombBoldText'))]) + msg3 = Lstr(value='${A} < ${B} >', + subs=[('${A}', msg2), ('${B}', press_to_bomb)]) + self._messages = (([ + Lstr(resource='pressToSelectTeamText', + subs=[('${BUTTONS}', _ba.charstr(SpecialChar.LEFT_ARROW) + + ' ' + _ba.charstr(SpecialChar.RIGHT_ARROW))]) + ] if can_switch_teams else []) + [msg1] + [msg3] + [join_str]) + + self._timer = _ba.Timer(4.0, + _general.WeakCall(self._update), + repeat=True) + + def _update(self) -> None: + assert self._text.node + self._text.node.text = self._messages[self._state] + self._state = (self._state + 1) % len(self._messages) + + +class PlayerReadyMessage: + """Tells an object a player has been selected from the given chooser.""" + + def __init__(self, chooser: ba.Chooser): + self.chooser = chooser + + +class ChangeMessage: + """Tells an object a selection is being changed.""" + + def __init__(self, what: str, value: int): + self.what = what + self.value = value + + +class Chooser: + """A character/team selector for a single player.""" + + def __del__(self) -> None: + + # Just kill off our base node; the rest should go down with it. + if self._text_node: + self._text_node.delete() + + def __init__(self, vpos: float, player: _ba.Player, + lobby: 'Lobby') -> None: + # FIXME: Tidy up around here. + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + from ba import _gameutils + from ba import _profile + from ba import _lang + app = _ba.app + self._deek_sound = _ba.getsound('deek') + self._click_sound = _ba.getsound('click01') + self._punchsound = _ba.getsound('punch01') + self._swish_sound = _ba.getsound('punchSwish') + self._errorsound = _ba.getsound('error') + self._mask_texture = _ba.gettexture('characterIconMask') + self._vpos = vpos + self._lobby = weakref.ref(lobby) + self._player = player + self._inited = False + self._dead = False + self._text_node: Optional[ba.Node] = None + self._profilename = '' + self._profilenames: List[str] = [] + self._ready: bool = False + self.character_names: List[str] = [] + + # Hmm does this need to be public? + self.profiles: Dict[str, Dict[str, Any]] = {} + + # Load available profiles either from the local config or from the + # remote device. + self.reload_profiles() + + # Note: this is just our local index out of available teams; *not* + # the team-id! + self._selected_team_index: int = self.lobby.next_add_team + + # Store a persistent random character index; we'll use this for the + # '_random' profile. Let's use their input_device id to seed it. This + # will give a persistent character for them between games and will + # distribute characters nicely if everyone is random. + try: + input_device_id = self._player.get_input_device().id + except Exception: + from ba import _error + _error.print_exception('Error getting device-id on chooser create') + input_device_id = 0 + + if app.lobby_random_char_index_offset is None: + + # We want the first device that asks for a chooser to always get + # spaz as a random character.. + # scratch that.. we now kinda accomplish the same thing with + # account profiles so lets just be fully random here. + app.lobby_random_char_index_offset = (random.randrange(1000)) + + # To calc our random index we pick a random character out of our + # unlocked list and then locate that character's index in the full + # list. + char_index_offset = app.lobby_random_char_index_offset + assert char_index_offset is not None + self._random_character_index = ((input_device_id + char_index_offset) % + len(self.character_names)) + self._random_color, self._random_highlight = ( + _profile.get_player_profile_colors(None)) + + # Attempt to pick an initial profile based on what's been stored + # for this input device. + input_device = self._player.get_input_device() + try: + name = input_device.name + unique_id = input_device.unique_identifier + self._profilename = ( + app.config['Default Player Profiles'][name + ' ' + unique_id]) + self._profileindex = self._profilenames.index(self._profilename) + + # If this one is __account__ and is local and we haven't marked + # anyone as the account-profile device yet, mark this guy as it. + # (prevents the next joiner from getting the account profile too). + if (self._profilename == '__account__' + and not input_device.is_remote_client + and app.lobby_account_profile_device_id is None): + app.lobby_account_profile_device_id = input_device_id + + # Well hmm that didn't work.. pick __account__, _random, or some + # other random profile. + except Exception: + + profilenames = self._profilenames + + # We want the first local input-device in the game to latch on to + # the account profile. + if (not input_device.is_remote_client + and not input_device.is_controller_app): + if (app.lobby_account_profile_device_id is None + and '__account__' in profilenames): + app.lobby_account_profile_device_id = input_device_id + + # If this is the designated account-profile-device, try to default + # to the account profile. + if (input_device_id == app.lobby_account_profile_device_id + and '__account__' in profilenames): + self._profileindex = profilenames.index('__account__') + else: + + # If this is the controller app, it defaults to using a random + # profile (since we can pull the random name from the app). + if input_device.is_controller_app: + self._profileindex = profilenames.index('_random') + else: + + # If its a client connection, for now just force + # the account profile if possible.. (need to provide a + # way for clients to specify/remember their default + # profile on remote servers that do not already know them). + if (input_device.is_remote_client + and '__account__' in profilenames): + self._profileindex = profilenames.index('__account__') + else: + + # Cycle through our non-random profiles once; after + # that, everyone gets random. + while (app.lobby_random_profile_index < + len(profilenames) and + profilenames[app.lobby_random_profile_index] in + ('_random', '__account__', '_edit')): + app.lobby_random_profile_index += 1 + if (app.lobby_random_profile_index < + len(profilenames)): + self._profileindex = ( + app.lobby_random_profile_index) + app.lobby_random_profile_index += 1 + else: + self._profileindex = profilenames.index('_random') + + self._profilename = profilenames[self._profileindex] + + self.character_index = self._random_character_index + self._color = self._random_color + self._highlight = self._random_highlight + self._text_node = _ba.newnode('text', + delegate=self, + attrs={ + 'position': (-100, self._vpos), + 'maxwidth': 160, + 'shadow': 0.5, + 'vr_depth': -20, + 'h_align': 'left', + 'v_align': 'center', + 'v_attach': 'top' + }) + + _gameutils.animate(self._text_node, 'scale', {0: 0, 0.1: 1.0}) + self.icon = _ba.newnode('image', + owner=self._text_node, + attrs={ + 'position': (-130, self._vpos + 20), + 'mask_texture': self._mask_texture, + 'vr_depth': -10, + 'attach': 'topCenter' + }) + + _gameutils.animate_array(self.icon, 'scale', 2, { + 0: (0, 0), + 0.1: (45, 45) + }) + + self._set_ready(False) + + # Set our initial name to '' in case anyone asks. + self._player.set_name( + _lang.Lstr(resource='choosingPlayerText').evaluate(), real=False) + + self.update_from_player_profiles() + self.update_position() + self._inited = True + + @property + def player(self) -> ba.Player: + """The ba.Player associated with this chooser.""" + return self._player + + @property + def ready(self) -> bool: + """Whether this chooser is checked in as ready.""" + return self._ready + + def set_vpos(self, vpos: float) -> None: + """(internal)""" + self._vpos = vpos + + def set_dead(self, val: bool) -> None: + """(internal)""" + self._dead = val + + def get_team(self) -> ba.Team: + """Return this chooser's selected ba.Team.""" + return self.lobby.teams[self._selected_team_index] + + @property + def lobby(self) -> ba.Lobby: + """The chooser's ba.Lobby.""" + lobby = self._lobby() + if lobby is None: + raise Exception('Lobby does not exist.') + return lobby + + def get_lobby(self) -> Optional[ba.Lobby]: + """Return this chooser's lobby if it still exists; otherwise None.""" + return self._lobby() + + def update_from_player_profiles(self) -> None: + """Set character based on profile; otherwise use pre-picked random.""" + try: + from ba import _profile + + # Store the name even though we usually use index (in case + # the profile list changes) + self._profilename = self._profilenames[self._profileindex] + character = self.profiles[self._profilename]['character'] + + # Hmmm; at the moment we're not properly pulling the list + # of available characters from clients, so profiles might use a + # character not in their list. for now, just go ahead and add + # the character name to their list as long as we're aware of it + # UPDATE: actually we now should be getting their character list, + # so this should no longer be happening; adding warning print + # for now and can delete later. + if (character not in self.character_names + and character in _ba.app.spaz_appearances): + print('got remote character not in their character list:', + character, self.character_names) + self.character_names.append(character) + self.character_index = self.character_names.index(character) + self._color, self._highlight = (_profile.get_player_profile_colors( + self._profilename, profiles=self.profiles)) + except Exception: + # FIXME: Should never use top level Exception for logic; only + # error catching (and they should always be logged). + self.character_index = self._random_character_index + self._color = self._random_color + self._highlight = self._random_highlight + self._update_icon() + self._update_text() + + def reload_profiles(self) -> None: + """Reload all player profiles.""" + from ba import _general + app = _ba.app + + # Re-construct our profile index and other stuff since the profile + # list might have changed. + input_device = self._player.get_input_device() + is_remote = input_device.is_remote_client + is_test_input = (input_device is not None + and input_device.name.startswith('TestInput')) + + # Pull this player's list of unlocked characters. + if is_remote: + # FIXME: Pull this from remote player (but make sure to + # filter it to ones we've got). + self.character_names = ['Spaz'] + else: + self.character_names = self.lobby.character_names_local_unlocked + + # If we're a local player, pull our local profiles from the config. + # Otherwise ask the remote-input-device for its profile list. + if is_remote: + self.profiles = input_device.get_player_profiles() + else: + self.profiles = app.config.get('Player Profiles', {}) + + # These may have come over the wire from an older + # (non-unicode/non-json) version. + # Make sure they conform to our standards + # (unicode strings, no tuples, etc) + self.profiles = _general.json_prep(self.profiles) + + # Filter out any characters we're unaware of. + for profile in list(self.profiles.items()): + if profile[1].get('character', '') not in app.spaz_appearances: + profile[1]['character'] = 'Spaz' + + # Add in a random one so we're ok even if there's no + # user-created profiles. + self.profiles['_random'] = {} + + # In kiosk mode we disable account profiles to force random. + if app.kiosk_mode: + if '__account__' in self.profiles: + del self.profiles['__account__'] + + # For local devices, add it an 'edit' option which will pop up + # the profile window. + if not is_remote and not is_test_input and not app.kiosk_mode: + self.profiles['_edit'] = {} + + # Build a sorted name list we can iterate through. + self._profilenames = list(self.profiles.keys()) + self._profilenames.sort(key=lambda x: x.lower()) + + if self._profilename in self._profilenames: + self._profileindex = self._profilenames.index(self._profilename) + else: + self._profileindex = 0 + self._profilename = self._profilenames[self._profileindex] + + def update_position(self) -> None: + """Update this chooser's position.""" + from ba import _gameutils + + # Hmmm this shouldn't be happening. + if not self._text_node: + print('Error: chooser text nonexistent..') + import traceback + traceback.print_stack() + return + spacing = 350 + teams = self.lobby.teams + offs = (spacing * -0.5 * len(teams) + + spacing * self._selected_team_index + 250) + if len(teams) > 1: + offs -= 35 + _gameutils.animate_array(self._text_node, 'position', 2, { + 0: self._text_node.position, + 0.1: (-100 + offs, self._vpos + 23) + }) + _gameutils.animate_array(self.icon, 'position', 2, { + 0: self.icon.position, + 0.1: (-130 + offs, self._vpos + 22) + }) + + def get_character_name(self) -> str: + """Return the selected character name.""" + return self.character_names[self.character_index] + + def _do_nothing(self) -> None: + """Does nothing! (hacky way to disable callbacks)""" + + def _get_name(self, full: bool = False) -> str: + # FIXME: Needs cleanup. + # pylint: disable=too-many-branches + from ba._lang import Lstr + from ba._enums import SpecialChar + name_raw = name = self._profilenames[self._profileindex] + clamp = False + if name == '_random': + input_device: Optional[ba.InputDevice] + try: + input_device = self._player.get_input_device() + except Exception: + input_device = None + if input_device is not None: + name = input_device.get_default_player_name() + else: + name = 'Invalid' + if not full: + clamp = True + elif name == '__account__': + try: + input_device = self._player.get_input_device() + except Exception: + input_device = None + if input_device is not None: + name = input_device.get_account_name(full) + else: + name = 'Invalid' + if not full: + clamp = True + elif name == '_edit': + # FIXME: This causes problems as an Lstr, but its ok to + # explicitly translate for now since this is only shown on the + # host. (also should elaborate; don't remember what problems this + # caused) + name = (Lstr( + resource='createEditPlayerText', + fallback_resource='editProfileWindow.titleNewText').evaluate()) + else: + + # If we have a regular profile marked as global with an icon, + # use it (for full only). + if full: + try: + if self.profiles[name_raw].get('global', False): + icon = (self.profiles[name_raw]['icon'] + if 'icon' in self.profiles[name_raw] else + _ba.charstr(SpecialChar.LOGO)) + name = icon + name + except Exception: + from ba import _error + _error.print_exception('Error applying global icon') + else: + + # We now clamp non-full versions of names so there's at + # least some hope of reading them in-game. + clamp = True + + if clamp: + if len(name) > 10: + name = name[:10] + '...' + return name + + def _set_ready(self, ready: bool) -> None: + # pylint: disable=cyclic-import + from bastd.ui.profile import browser as pbrowser + from ba import _general + profilename = self._profilenames[self._profileindex] + + # Handle '_edit' as a special case. + if profilename == '_edit' and ready: + with _ba.Context('ui'): + pbrowser.ProfileBrowserWindow(in_main_menu=False) + + # give their input-device UI ownership too + # (prevent someone else from snatching it in crowded games) + _ba.set_ui_input_device(self._player.get_input_device()) + return + + if not ready: + self._player.assign_input_call( + 'leftPress', + _general.Call(self.handlemessage, ChangeMessage('team', -1))) + self._player.assign_input_call( + 'rightPress', + _general.Call(self.handlemessage, ChangeMessage('team', 1))) + self._player.assign_input_call( + 'bombPress', + _general.Call(self.handlemessage, + ChangeMessage('character', 1))) + self._player.assign_input_call( + 'upPress', + _general.Call(self.handlemessage, + ChangeMessage('profileindex', -1))) + self._player.assign_input_call( + 'downPress', + _general.Call(self.handlemessage, + ChangeMessage('profileindex', 1))) + self._player.assign_input_call( + ('jumpPress', 'pickUpPress', 'punchPress'), + _general.Call(self.handlemessage, ChangeMessage('ready', 1))) + self._ready = False + self._update_text() + self._player.set_name('untitled', real=False) + else: + self._player.assign_input_call( + ('leftPress', 'rightPress', 'upPress', 'downPress', + 'jumpPress', 'bombPress', 'pickUpPress'), self._do_nothing) + self._player.assign_input_call( + ('jumpPress', 'bombPress', 'pickUpPress', 'punchPress'), + _general.Call(self.handlemessage, ChangeMessage('ready', 0))) + + # Store the last profile picked by this input for reuse. + input_device = self._player.get_input_device() + name = input_device.name + unique_id = input_device.unique_identifier + device_profiles = _ba.app.config.setdefault( + 'Default Player Profiles', {}) + + # Make an exception if we have no custom profiles and are set + # to random; in that case we'll want to start picking up custom + # profiles if/when one is made so keep our setting cleared. + special = ('_random', '_edit', '__account__') + have_custom_profiles = any(p not in special for p in self.profiles) + + profilekey = name + ' ' + unique_id + if profilename == '_random' and not have_custom_profiles: + if profilekey in device_profiles: + del device_profiles[profilekey] + else: + device_profiles[profilekey] = profilename + _ba.app.config.commit() + + # Set this player's short and full name. + self._player.set_name(self._get_name(), + self._get_name(full=True), + real=True) + self._ready = True + self._update_text() + + # Inform the session that this player is ready. + _ba.getsession().handlemessage(PlayerReadyMessage(self)) + + def _handle_ready_msg(self, ready: bool) -> None: + force_team_switch = False + + # Team auto-balance kicks us to another team if we try to + # join the team with the most players. + if not self._ready: + if _ba.app.config.get('Auto Balance Teams', False): + lobby = self.lobby + teams = lobby.teams + if len(teams) > 1: + + # First, calc how many players are on each team + # ..we need to count both active players and + # choosers that have been marked as ready. + team_player_counts = {} + for team in teams: + team_player_counts[team.get_id()] = (len(team.players)) + for chooser in lobby.choosers: + if chooser.ready: + team_player_counts[ + chooser.get_team().get_id()] += 1 + largest_team_size = max(team_player_counts.values()) + smallest_team_size = (min(team_player_counts.values())) + + # Force switch if we're on the biggest team + # and there's a smaller one available. + if (largest_team_size != smallest_team_size + and team_player_counts[self.get_team().get_id()] >= + largest_team_size): + force_team_switch = True + + # Either force switch teams, or actually for realsies do the set-ready. + if force_team_switch: + _ba.playsound(self._errorsound) + self.handlemessage(ChangeMessage('team', 1)) + else: + _ba.playsound(self._punchsound) + self._set_ready(ready) + + def handlemessage(self, msg: Any) -> Any: + """Standard generic message handler.""" + if isinstance(msg, ChangeMessage): + + # If we've been removed from the lobby, ignore this stuff. + if self._dead: + from ba import _error + _error.print_error("chooser got ChangeMessage after dying") + return + + if not self._text_node: + from ba import _error + _error.print_error('got ChangeMessage after nodes died') + return + + if msg.what == 'team': + teams = self.lobby.teams + if len(teams) > 1: + _ba.playsound(self._swish_sound) + self._selected_team_index = ( + (self._selected_team_index + msg.value) % len(teams)) + self._update_text() + self.update_position() + self._update_icon() + + elif msg.what == 'profileindex': + if len(self._profilenames) == 1: + + # This should be pretty hard to hit now with + # automatic local accounts. + _ba.playsound(_ba.getsound('error')) + else: + + # Pick the next player profile and assign our name + # and character based on that. + _ba.playsound(self._deek_sound) + self._profileindex = ((self._profileindex + msg.value) % + len(self._profilenames)) + self.update_from_player_profiles() + + elif msg.what == 'character': + _ba.playsound(self._click_sound) + # update our index in our local list of characters + self.character_index = ((self.character_index + msg.value) % + len(self.character_names)) + self._update_text() + self._update_icon() + + elif msg.what == 'ready': + self._handle_ready_msg(bool(msg.value)) + + def _update_text(self) -> None: + from ba import _gameutils + from ba._lang import Lstr + assert self._text_node is not None + if self._ready: + + # Once we're ready, we've saved the name, so lets ask the system + # for it so we get appended numbers and stuff. + text = Lstr(value=self._player.get_name(full=True)) + text = Lstr(value='${A} (${B})', + subs=[('${A}', text), + ('${B}', Lstr(resource='readyText'))]) + else: + text = Lstr(value=self._get_name(full=True)) + + can_switch_teams = len(self.lobby.teams) > 1 + + # Flash as we're coming in. + fin_color = _ba.safecolor(self.get_color()) + (1, ) + if not self._inited: + _gameutils.animate_array(self._text_node, 'color', 4, { + 0.15: fin_color, + 0.25: (2, 2, 2, 1), + 0.35: fin_color + }) + else: + + # Blend if we're in teams mode; switch instantly otherwise. + if can_switch_teams: + _gameutils.animate_array(self._text_node, 'color', 4, { + 0: self._text_node.color, + 0.1: fin_color + }) + else: + self._text_node.color = fin_color + + self._text_node.text = text + + def get_color(self) -> Sequence[float]: + """Return the currently selected color.""" + val: Sequence[float] + # if self._profilenames[self._profileindex] == '_edit': + # val = (0, 1, 0) + if self.lobby.use_team_colors: + val = self.lobby.teams[self._selected_team_index].color + else: + val = self._color + if len(val) != 3: + print('get_color: ignoring invalid color of len', len(val)) + val = (0, 1, 0) + return val + + def get_highlight(self) -> Sequence[float]: + """Return the currently selected highlight.""" + if self._profilenames[self._profileindex] == '_edit': + return 0, 1, 0 + + # If we're using team colors we wanna make sure our highlight color + # isn't too close to any other team's color. + highlight = list(self._highlight) + if self.lobby.use_team_colors: + for i, team in enumerate(self.lobby.teams): + if i != self._selected_team_index: + + # Find the dominant component of this team's color + # and adjust ours so that the component is + # not super-dominant. + max_val = 0.0 + max_index = 0 + for j in range(3): + if team.color[j] > max_val: + max_val = team.color[j] + max_index = j + that_color_for_us = highlight[max_index] + our_second_biggest = max(highlight[(max_index + 1) % 3], + highlight[(max_index + 2) % 3]) + diff = (that_color_for_us - our_second_biggest) + if diff > 0: + highlight[max_index] -= diff * 0.6 + highlight[(max_index + 1) % 3] += diff * 0.3 + highlight[(max_index + 2) % 3] += diff * 0.2 + return highlight + + def getplayer(self) -> ba.Player: + """Return the player associated with this chooser.""" + return self._player + + def _update_icon(self) -> None: + from ba import _gameutils + if self._profilenames[self._profileindex] == '_edit': + tex = _ba.gettexture('black') + tint_tex = _ba.gettexture('black') + self.icon.color = (1, 1, 1) + self.icon.texture = tex + self.icon.tint_texture = tint_tex + self.icon.tint_color = (0, 1, 0) + return + + try: + tex_name = (_ba.app.spaz_appearances[self.character_names[ + self.character_index]].icon_texture) + tint_tex_name = (_ba.app.spaz_appearances[self.character_names[ + self.character_index]].icon_mask_texture) + except Exception: + from ba import _error + _error.print_exception('Error updating char icon list') + tex_name = 'neoSpazIcon' + tint_tex_name = 'neoSpazIconColorMask' + + tex = _ba.gettexture(tex_name) + tint_tex = _ba.gettexture(tint_tex_name) + + self.icon.color = (1, 1, 1) + self.icon.texture = tex + self.icon.tint_texture = tint_tex + clr = self.get_color() + clr2 = self.get_highlight() + + can_switch_teams = len(self.lobby.teams) > 1 + + # If we're initing, flash. + if not self._inited: + _gameutils.animate_array(self.icon, 'color', 3, { + 0.15: (1, 1, 1), + 0.25: (2, 2, 2), + 0.35: (1, 1, 1) + }) + + # Blend in teams mode; switch instantly in ffa-mode. + if can_switch_teams: + _gameutils.animate_array(self.icon, 'tint_color', 3, { + 0: self.icon.tint_color, + 0.1: clr + }) + else: + self.icon.tint_color = clr + self.icon.tint2_color = clr2 + + # Store the icon info the the player. + self._player.set_icon_info(tex_name, tint_tex_name, clr, clr2) + + +class Lobby: + """Container for choosers.""" + + def __del__(self) -> None: + + # Reset any players that still have a chooser in us + # (should allow the choosers to die). + players = [ + chooser.player for chooser in self.choosers if chooser.player + ] + for player in players: + player.reset() + + def __init__(self) -> None: + from ba import _team as bs_team + from ba import _coopsession + session = _ba.getsession() + teams = session.teams if session.use_teams else None + self._use_team_colors = session.use_team_colors + if teams is not None: + self._teams = [weakref.ref(team) for team in teams] + else: + self._dummy_teams = bs_team.Team() + self._teams = [weakref.ref(self._dummy_teams)] + v_offset = (-150 + if isinstance(session, _coopsession.CoopSession) else -50) + self.choosers: List[Chooser] = [] + self.base_v_offset = v_offset + self.update_positions() + self._next_add_team = 0 + self.character_names_local_unlocked: List[str] = [] + self._vpos = 0 + + # Grab available profiles. + self.reload_profiles() + + self._join_info_text = None + + @property + def next_add_team(self) -> int: + """(internal)""" + return self._next_add_team + + @property + def use_team_colors(self) -> bool: + """A bool for whether this lobby is using team colors. + + If False, inidividual player colors are used instead. + """ + return self._use_team_colors + + @property + def teams(self) -> List[ba.Team]: + """Teams available in this lobby.""" + allteams = [] + for tref in self._teams: + team = tref() + assert team is not None + allteams.append(team) + return allteams + + def get_choosers(self) -> List[Chooser]: + """Return the lobby's current choosers.""" + return self.choosers + + def create_join_info(self) -> JoinInfo: + """Create a display of on-screen information for joiners. + + (how to switch teams, players, etc.) + Intended for use in initial joining-screens. + """ + return JoinInfo(self) + + def reload_profiles(self) -> None: + """Reload available player profiles.""" + # pylint: disable=cyclic-import + from ba._account import ensure_have_account_player_profile + from bastd.actor.spazappearance import get_appearances + + # We may have gained or lost character names if the user + # bought something; reload these too. + self.character_names_local_unlocked = get_appearances() + self.character_names_local_unlocked.sort(key=lambda x: x.lower()) + + # Do any overall prep we need to such as creating account profile. + ensure_have_account_player_profile() + for chooser in self.choosers: + try: + chooser.reload_profiles() + chooser.update_from_player_profiles() + except Exception: + from ba import _error + _error.print_exception('error reloading profiles') + + def update_positions(self) -> None: + """Update positions for all choosers.""" + self._vpos = -100 + self.base_v_offset + for chooser in self.choosers: + chooser.set_vpos(self._vpos) + chooser.update_position() + self._vpos -= 48 + + def check_all_ready(self) -> bool: + """Return whether all choosers are marked ready.""" + return all(chooser.ready for chooser in self.choosers) + + def add_chooser(self, player: ba.Player) -> None: + """Add a chooser to the lobby for the provided player.""" + self.choosers.append( + Chooser(vpos=self._vpos, player=player, lobby=self)) + self._next_add_team = (self._next_add_team + 1) % len(self._teams) + self._vpos -= 48 + + def remove_chooser(self, player: ba.Player) -> None: + """Remove a single player's chooser; does not kick him. + + This is used when a player enters the game and no longer + needs a chooser.""" + found = False + chooser = None + for chooser in self.choosers: + if chooser.getplayer() is player: + found = True + + # Mark it as dead since there could be more + # change-commands/etc coming in still for it; + # want to avoid duplicate player-adds/etc. + chooser.set_dead(True) + self.choosers.remove(chooser) + break + if not found: + from ba import _error + _error.print_error(f'remove_chooser did not find player {player}') + elif chooser in self.choosers: + from ba import _error + _error.print_error(f'chooser remains after removal for {player}') + self.update_positions() + + def remove_all_choosers(self) -> None: + """Remove all choosers without kicking players. + + This is called after all players check in and enter a game. + """ + self.choosers = [] + self.update_positions() + + def remove_all_choosers_and_kick_players(self) -> None: + """Remove all player choosers and kick attached players.""" + + # Copy the list; it can change under us otherwise. + for chooser in list(self.choosers): + if chooser.player: + chooser.player.remove_from_game() + self.remove_all_choosers() diff --git a/assets/src/data/scripts/ba/_maps.py b/assets/src/data/scripts/ba/_maps.py new file mode 100644 index 00000000..9263930a --- /dev/null +++ b/assets/src/data/scripts/ba/_maps.py @@ -0,0 +1,406 @@ +"""Map related functionality.""" +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import _ba +from ba import _math +from ba._actor import Actor + +if TYPE_CHECKING: + from typing import Set, List, Type, Optional, Sequence, Any, Tuple + import ba + + +def preload_map_preview_media() -> None: + """Preload media needed for map preview UIs. + + Category: Asset Functions + """ + _ba.getmodel('level_select_button_opaque') + _ba.getmodel('level_select_button_transparent') + for maptype in list(_ba.app.maps.values()): + map_tex_name = maptype.get_preview_texture_name() + if map_tex_name is not None: + _ba.gettexture(map_tex_name) + + +def get_filtered_map_name(name: str) -> str: + """Filter a map name to account for name changes, etc. + + Category: Asset Functions + + This can be used to support old playlists, etc. + """ + # Some legacy name fallbacks... can remove these eventually. + if name in ('AlwaysLand', 'Happy Land'): + name = 'Happy Thoughts' + if name == 'Hockey Arena': + name = 'Hockey Stadium' + return name + + +def get_map_display_string(name: str) -> ba.Lstr: + """Return a ba.Lstr for displaying a given map\'s name. + + Category: Asset Functions + """ + from ba import _lang + return _lang.Lstr(translate=('mapsNames', name)) + + +def getmaps(playtype: str) -> List[str]: + """Return a list of ba.Map types supporting a playtype str. + + Category: Asset Functions + + Maps supporting a given playtype must provide a particular set of + features and lend themselves to a certain style of play. + + Play Types: + + 'melee' + General fighting map. + Has one or more 'spawn' locations. + + 'team_flag' + For games such as Capture The Flag where each team spawns by a flag. + Has two or more 'spawn' locations, each with a corresponding 'flag' + location (based on index). + + 'single_flag' + For games such as King of the Hill or Keep Away where multiple teams + are fighting over a single flag. + Has two or more 'spawn' locations and 1 'flag_default' location. + + 'conquest' + For games such as Conquest where flags are spread throughout the map + - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations. + + 'king_of_the_hill' - has 2+ 'spawn' locations, 1+ 'flag_default' locations, + and 1+ 'powerup_spawn' locations + + 'hockey' + For hockey games. + Has two 'goal' locations, corresponding 'spawn' locations, and one + 'flag_default' location (for where puck spawns) + + 'football' + For football games. + Has two 'goal' locations, corresponding 'spawn' locations, and one + 'flag_default' location (for where flag/ball/etc. spawns) + + 'race' + For racing games where players much touch each region in order. + Has two or more 'race_point' locations. + """ + return sorted(key for key, val in _ba.app.maps.items() + if playtype in val.get_play_types()) + + +def get_unowned_maps() -> List[str]: + """Return the list of local maps not owned by the current account. + + Category: Asset Functions + """ + from ba import _store + unowned_maps: Set[str] = set() + if _ba.app.subplatform != 'headless': + for map_section in _store.get_store_layout()['maps']: + for mapitem in map_section['items']: + if not _ba.get_purchased(mapitem): + m_info = _store.get_store_item(mapitem) + unowned_maps.add(m_info['map_type'].name) + return sorted(unowned_maps) + + +def get_map_class(name: str) -> Type[ba.Map]: + """Return a map type given a name. + + Category: Asset Functions + """ + name = get_filtered_map_name(name) + try: + return _ba.app.maps[name] + except Exception: + raise Exception("Map not found: '" + name + "'") + + +class Map(Actor): + """A game map. + + Category: Gameplay Classes + + Consists of a collection of terrain nodes, metadata, and other + functionality comprising a game map. + """ + defs: Any = None + name = "Map" + _playtypes: List[str] = [] + + @classmethod + def preload(cls) -> None: + """Preload map media. + + This runs the class's on_preload() method as needed to prep it to run. + Preloading should generally be done in a ba.Activity's __init__ method. + Note that this is a classmethod since it is not operate on map + instances but rather on the class itself before instances are made + """ + activity = _ba.getactivity() + if cls not in activity.preloads: + activity.preloads[cls] = cls.on_preload() + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return [] + + @classmethod + def get_preview_texture_name(cls) -> Optional[str]: + """Return the name of the preview texture for this map.""" + return None + + @classmethod + def on_preload(cls) -> Any: + """Called when the map is being preloaded. + + It should return any media/data it requires to operate + """ + return None + + @classmethod + def get_name(cls) -> str: + """Return the unique name of this map, in English.""" + return cls.name + + @classmethod + def get_music_type(cls) -> Optional[str]: + """Return a music-type string that should be played on this map. + + If None is returned, default music will be used. + """ + return None + + def __init__(self, + vr_overlay_offset: Optional[Sequence[float]] = None) -> None: + """Instantiate a map.""" + from ba import _gameutils + super().__init__() + + # This is expected to always be a ba.Node object (whether valid or not) + # should be set to something meaningful by child classes. + self.node = None + + # Make our class' preload-data available to us + # (and instruct the user if we weren't preloaded properly). + try: + self.preloaddata = _ba.getactivity().preloads[type(self)] + except Exception: + raise Exception('Preload data not found for ' + str(type(self)) + + '; make sure to call the type\'s preload()' + ' staticmethod in the activity constructor') + + # Set various globals. + gnode = _gameutils.sharedobj('globals') + + # Set area-of-interest bounds. + aoi_bounds = self.get_def_bound_box("area_of_interest_bounds") + if aoi_bounds is None: + print('WARNING: no "aoi_bounds" found for map:', self.get_name()) + aoi_bounds = (-1, -1, -1, 1, 1, 1) + gnode.area_of_interest_bounds = aoi_bounds + + # Set map bounds. + map_bounds = self.get_def_bound_box("map_bounds") + if map_bounds is None: + print('WARNING: no "map_bounds" found for map:', self.get_name()) + map_bounds = (-30, -10, -30, 30, 100, 30) + _ba.set_map_bounds(map_bounds) + + # Set shadow ranges. + try: + gnode.shadow_range = [ + self.defs.points[v][1] for v in [ + 'shadow_lower_bottom', 'shadow_lower_top', + 'shadow_upper_bottom', 'shadow_upper_top' + ] + ] + except Exception: + pass + + # In vr, set a fixed point in space for the overlay to show up at. + # By default we use the bounds center but allow the map to override it. + center = ((aoi_bounds[0] + aoi_bounds[3]) * 0.5, + (aoi_bounds[1] + aoi_bounds[4]) * 0.5, + (aoi_bounds[2] + aoi_bounds[5]) * 0.5) + if vr_overlay_offset is not None: + center = (center[0] + vr_overlay_offset[0], + center[1] + vr_overlay_offset[1], + center[2] + vr_overlay_offset[2]) + gnode.vr_overlay_center = center + gnode.vr_overlay_center_enabled = True + + self.spawn_points = (self.get_def_points('spawn') + or [(0, 0, 0, 0, 0, 0)]) + self.ffa_spawn_points = (self.get_def_points('ffa_spawn') + or [(0, 0, 0, 0, 0, 0)]) + self.spawn_by_flag_points = (self.get_def_points('spawn_by_flag') + or [(0, 0, 0, 0, 0, 0)]) + self.flag_points = self.get_def_points("flag") or [(0, 0, 0)] + + # We just want points. + self.flag_points = [p[:3] for p in self.flag_points] + self.flag_points_default = (self.get_def_point('flag_default') + or (0, 1, 0)) + self.powerup_spawn_points = self.get_def_points('powerup_spawn') or [ + (0, 0, 0) + ] + + # We just want points. + self.powerup_spawn_points = ([ + p[:3] for p in self.powerup_spawn_points + ]) + self.tnt_points = self.get_def_points("tnt") or [] + + # We just want points. + self.tnt_points = [p[:3] for p in self.tnt_points] + + self.is_hockey = False + self.is_flying = False + + # FIXME: this should be part of game; not map. + self._next_ffa_start_index = 0 + + def is_point_near_edge(self, point: ba.Vec3, + running: bool = False) -> bool: + """Return whether the provided point is near an edge of the map. + + Simple bot logic uses this call to determine if they + are approaching a cliff or wall. If this returns True they will + generally not walk/run any farther away from the origin. + If 'running' is True, the buffer should be a bit larger. + """ + del point, running # Unused. + return False + + def get_def_bound_box( + self, name: str + ) -> Optional[Tuple[float, float, float, float, float, float]]: + """Return a 6 member bounds tuple or None if it is not defined.""" + try: + box = self.defs.boxes[name] + return (box[0] - box[6] / 2.0, box[1] - box[7] / 2.0, + box[2] - box[8] / 2.0, box[0] + box[6] / 2.0, + box[1] + box[7] / 2.0, box[2] + box[8] / 2.0) + except Exception: + return None + + def get_def_point(self, name: str) -> Optional[Sequence[float]]: + """Return a single defined point or a default value in its absence.""" + val = self.defs.points.get(name) + return (None if val is None else + _math.vec3validate(val) if __debug__ else val) + + def get_def_points(self, name: str) -> List[Sequence[float]]: + """Return a list of named points. + + Return as many sequential ones are defined (flag1, flag2, flag3), etc. + If none are defined, returns an empty list. + """ + point_list = [] + if self.defs and name + "1" in self.defs.points: + i = 1 + while name + str(i) in self.defs.points: + pts = self.defs.points[name + str(i)] + if len(pts) == 6: + point_list.append(pts) + else: + if len(pts) != 3: + raise Exception("invalid point") + point_list.append(pts + (0, 0, 0)) + i += 1 + return point_list + + def get_start_position(self, team_index: int) -> Sequence[float]: + """Return a random starting position for the given team index.""" + pnt = self.spawn_points[team_index % len(self.spawn_points)] + x_range = (-0.5, 0.5) if pnt[3] == 0.0 else (-pnt[3], pnt[3]) + z_range = (-0.5, 0.5) if pnt[5] == 0.0 else (-pnt[5], pnt[5]) + pnt = (pnt[0] + random.uniform(*x_range), pnt[1], + pnt[2] + random.uniform(*z_range)) + return pnt + + def get_ffa_start_position(self, players: Sequence[ba.Player] + ) -> Sequence[float]: + """Return a random starting position in one of the FFA spawn areas. + + If a list of ba.Players is provided; the returned points will be + as far from these players as possible. + """ + + # Get positions for existing players. + player_pts = [] + for player in players: + try: + if player.actor is not None and player.actor.is_alive(): + assert player.actor.node + pnt = _ba.Vec3(player.actor.node.position) + player_pts.append(pnt) + except Exception as exc: + print('EXC in get_ffa_start_position:', exc) + + def _getpt() -> Sequence[float]: + point = self.ffa_spawn_points[self._next_ffa_start_index] + self._next_ffa_start_index = ((self._next_ffa_start_index + 1) % + len(self.ffa_spawn_points)) + x_range = (-0.5, 0.5) if point[3] == 0.0 else (-point[3], point[3]) + z_range = (-0.5, 0.5) if point[5] == 0.0 else (-point[5], point[5]) + point = (point[0] + random.uniform(*x_range), point[1], + point[2] + random.uniform(*z_range)) + return point + + if not player_pts: + return _getpt() + + # Let's calc several start points and then pick whichever is + # farthest from all existing players. + farthestpt_dist = -1.0 + farthestpt = None + for _i in range(10): + testpt = _ba.Vec3(_getpt()) + closest_player_dist = 9999.0 + for ppt in player_pts: + dist = (ppt - testpt).length() + if dist < closest_player_dist: + closest_player_dist = dist + if closest_player_dist > farthestpt_dist: + farthestpt_dist = closest_player_dist + farthestpt = testpt + assert farthestpt is not None + return tuple(farthestpt) + + def get_flag_position(self, team_index: int = None) -> Sequence[float]: + """Return a flag position on the map for the given team index. + + Pass None to get the default flag point. + (used for things such as king-of-the-hill) + """ + if team_index is None: + return self.flag_points_default[:3] + return self.flag_points[team_index % len(self.flag_points)][:3] + + def handlemessage(self, msg: Any) -> Any: + from ba import _messages + if isinstance(msg, _messages.DieMessage): + if self.node: + self.node.delete() + super().handlemessage(msg) + + +def register_map(maptype: Type[Map]) -> None: + """Register a map class with the game.""" + if maptype.name in _ba.app.maps: + raise Exception("map \"" + maptype.name + "\" already registered") + _ba.app.maps[maptype.name] = maptype diff --git a/assets/src/data/scripts/ba/_math.py b/assets/src/data/scripts/ba/_math.py new file mode 100644 index 00000000..e2834ac1 --- /dev/null +++ b/assets/src/data/scripts/ba/_math.py @@ -0,0 +1,53 @@ +"""Math related functionality.""" + +from __future__ import annotations + +from collections import abc +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Tuple, Sequence + + +def vec3validate(value: Sequence[float]) -> Sequence[float]: + """Ensure a value is valid for use as a Vec3. + + category: General Utility Functions + + Raises a TypeError exception if not. + Valid values include any type of sequence consisting of 3 numeric values. + Returns the same value as passed in (but with a definite type + so this can be used to disambiguate 'Any' types). + Generally this should be used in 'if __debug__' or assert clauses + to keep runtime overhead minimal. + """ + from numbers import Number + if not isinstance(value, abc.Sequence): + raise TypeError(f"Expected a sequence; got {type(value)}") + if len(value) != 3: + raise TypeError(f"Expected a length-3 sequence (got {len(value)})") + if not all(isinstance(i, Number) for i in value): + raise TypeError(f"Non-numeric value passed for vec3: {value}") + return value + + +def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool: + """Return whether a given point is within a given box. + + category: General Utility Functions + + For use with standard def boxes (position|rotate|scale). + """ + return ((abs(pnt[0] - box[0]) <= box[6] * 0.5) + and (abs(pnt[1] - box[1]) <= box[7] * 0.5) + and (abs(pnt[2] - box[2]) <= box[8] * 0.5)) + + +def normalized_color(color: Sequence[float]) -> Tuple[float, ...]: + """Scale a color so its largest value is 1; useful for coloring lights. + + category: General Utility Functions + """ + color_biased = tuple(max(c, 0.01) for c in color) # account for black + mult = 1.0 / max(color_biased) + return tuple(c * mult for c in color_biased) diff --git a/assets/src/data/scripts/ba/_messages.py b/assets/src/data/scripts/ba/_messages.py new file mode 100644 index 00000000..8374736d --- /dev/null +++ b/assets/src/data/scripts/ba/_messages.py @@ -0,0 +1,200 @@ +"""Defines some standard message objects for use with handlemessage() calls.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Sequence + import ba + + +@dataclass +class OutOfBoundsMessage: + """A message telling an object that it is out of bounds. + + Category: Message Classes + """ + + +@dataclass +class DieMessage: + """A message telling an object to die. + + Category: Message Classes + + Most ba.Actors respond to this. + + Attributes: + + immediate + If this is set to True, the actor should disappear immediately. + This is for 'removing' stuff from the game more so than 'killing' + it. If False, the actor should die a 'normal' death and can take + its time with lingering corpses, sound effects, etc. + + how + The particular reason for death; 'fall', 'impact', 'leftGame', etc. + This can be examined for scoring or other purposes. + + """ + immediate: bool = False + how: str = 'generic' + + +@dataclass +class StandMessage: + """A message telling an object to move to a position in space. + + Category: Message Classes + + Used when teleporting players to home base, etc. + + Attributes: + + position + Where to move to. + + angle + The angle to face (in degrees) + """ + position: Sequence[float] = (0.0, 0.0, 0.0) + angle: float = 0.0 + + +@dataclass +class PickUpMessage: + """Tells an object that it has picked something up. + + Category: Message Classes + + Attributes: + + node + The ba.Node that is getting picked up. + """ + node: ba.Node + + +@dataclass +class DropMessage: + """Tells an object that it has dropped what it was holding. + + Category: Message Classes + """ + + +@dataclass +class PickedUpMessage: + """Tells an object that it has been picked up by something. + + Category: Message Classes + + Attributes: + + node + The ba.Node doing the picking up. + """ + node: ba.Node + + +@dataclass +class DroppedMessage: + """Tells an object that it has been dropped. + + Category: Message Classes + + Attributes: + + node + The ba.Node doing the dropping. + """ + node: ba.Node + + +@dataclass +class ShouldShatterMessage: + """Tells an object that it should shatter. + + Category: Message Classes + """ + + +@dataclass +class ImpactDamageMessage: + """Tells an object that it has been jarred violently. + + Category: Message Classes + + Attributes: + + intensity + The intensity of the impact. + """ + intensity: float + + +@dataclass +class FreezeMessage: + """Tells an object to become frozen. + + Category: Message Classes + + As seen in the effects of an ice ba.Bomb. + """ + + +@dataclass +class ThawMessage: + """Tells an object to stop being frozen. + + Category: Message Classes + """ + + +@dataclass(init=False) +class HitMessage: + """Tells an object it has been hit in some way. + + Category: Message Classes + + This is used by punches, explosions, etc to convey + their effect to a target. + """ + + def __init__(self, + srcnode: ba.Node = None, + pos: Sequence[float] = None, + velocity: Sequence[float] = None, + magnitude: float = 1.0, + velocity_magnitude: float = 0.0, + radius: float = 1.0, + source_player: ba.Player = None, + kick_back: float = 1.0, + flat_damage: float = None, + hit_type: str = 'generic', + force_direction: Sequence[float] = None, + hit_subtype: str = 'default'): + """Instantiate a message with given values.""" + + self.srcnode = srcnode + self.pos = pos if pos is not None else _ba.Vec3() + self.velocity = velocity if velocity is not None else _ba.Vec3() + self.magnitude = magnitude + self.velocity_magnitude = velocity_magnitude + self.radius = radius + self.source_player = source_player + self.kick_back = kick_back + self.flat_damage = flat_damage + self.hit_type = hit_type + self.hit_subtype = hit_subtype + self.force_direction = (force_direction + if force_direction is not None else velocity) + + +@dataclass +class PlayerProfilesChangedMessage: + """Signals player profiles may have changed and should be reloaded.""" diff --git a/assets/src/data/scripts/ba/_meta.py b/assets/src/data/scripts/ba/_meta.py new file mode 100644 index 00000000..fe994aba --- /dev/null +++ b/assets/src/data/scripts/ba/_meta.py @@ -0,0 +1,342 @@ +"""Functionality related to dynamic discoverability of classes.""" + +from __future__ import annotations + +import os +import pathlib +import threading +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import (Any, Dict, List, Tuple, Union, Optional, Type, Set) + import ba + +# The API version of this build of the game. +# Only packages and modules requiring this exact api version +# will be considered when scanning directories. +# See bombsquadgame.com/apichanges for differences between api versions. +CURRENT_API_VERSION = 6 + + +def startscan() -> None: + """Begin scanning script directories for scripts containing metadata. + + Should be called only once at launch.""" + app = _ba.app + if app.metascan is not None: + print('WARNING: meta scan run more than once.') + scriptdirs = [app.system_scripts_directory, app.user_scripts_directory] + thread = ScanThread(scriptdirs) + thread.start() + + +def handle_scan_results(results: Dict[str, Any]) -> None: + """Called in the game thread with results of a completed scan.""" + from ba import _lang + + # Warnings generally only get printed locally for users' benefit + # (things like out-of-date scripts being ignored, etc.) + # Errors are more serious and will get included in the regular log + warnings = results.get('warnings', '') + errors = results.get('errors', '') + if warnings != '' or errors != '': + _ba.screenmessage(_lang.Lstr(resource='scanScriptsErrorText'), + color=(1, 0, 0)) + _ba.playsound(_ba.getsound('error')) + if warnings != '': + _ba.log(warnings, to_server=False) + if errors != '': + _ba.log(errors) + + +class ScanThread(threading.Thread): + """Thread to scan script dirs for metadata.""" + + def __init__(self, dirs: List[str]): + super().__init__() + self._dirs = dirs + + def run(self) -> None: + from ba import _general + try: + scan = DirectoryScan(self._dirs) + scan.scan() + results = scan.results + except Exception as exc: + results = {'errors': 'Scan exception: ' + str(exc)} + + # Push a call to the game thread to print warnings/errors + # or otherwise deal with scan results. + _ba.pushcall(_general.Call(handle_scan_results, results), + from_other_thread=True) + + # We also, however, immediately make results available. + # This is because the game thread may be blocked waiting + # for them so we can't push a call or we'd get deadlock. + _ba.app.metascan = results + + +class DirectoryScan: + """Handles scanning directories for metadata.""" + + def __init__(self, paths: List[str]): + """Given one or more paths, parses available meta information. + + It is assumed that these paths are also in PYTHONPATH. + It is also assumed that any subdirectories are Python packages. + The returned dict contains the following: + 'powerups': list of ba.Powerup classes found. + 'campaigns': list of ba.Campaign classes found. + 'modifiers': list of ba.Modifier classes found. + 'maps': list of ba.Map classes found. + 'games': list of ba.GameActivity classes found. + 'warnings': warnings from scan; should be printed for local feedback + 'errors': errors encountered during scan; should be fully logged + """ + self.paths = [pathlib.Path(p) for p in paths] + self.results: Dict[str, Any] = { + 'errors': '', + 'warnings': '', + 'games': [] + } + + def _get_path_module_entries( + self, path: pathlib.Path, subpath: Union[str, pathlib.Path], + modules: List[Tuple[pathlib.Path, pathlib.Path]]) -> None: + """Scan provided path and add module entries to provided list.""" + try: + # Special case: let's save some time and skip the whole 'ba' + # package since we know it doesn't contain any meta tags. + fullpath = pathlib.Path(path, subpath) + entries = [(path, pathlib.Path(subpath, name)) + for name in os.listdir(fullpath) if name != 'ba'] + except PermissionError: + # Expected sometimes. + entries = [] + except Exception as exc: + # Unexpected; report this. + self.results['errors'] += str(exc) + '\n' + entries = [] + + # Now identify python packages/modules out of what we found. + for entry in entries: + if entry[1].name.endswith('.py'): + modules.append(entry) + elif (pathlib.Path(entry[0], entry[1]).is_dir() and pathlib.Path( + entry[0], entry[1], '__init__.py').is_file()): + modules.append(entry) + + def scan(self) -> None: + """Scan provided paths.""" + modules: List[Tuple[pathlib.Path, pathlib.Path]] = [] + for path in self.paths: + self._get_path_module_entries(path, '', modules) + for moduledir, subpath in modules: + try: + self.scan_module(moduledir, subpath) + except Exception: + from ba import _error + self.results['warnings'] += ("Error scanning '" + + str(subpath) + "': " + + _error.exc_str() + '\n') + + def scan_module(self, moduledir: pathlib.Path, + subpath: pathlib.Path) -> None: + """Scan an individual module and add the findings to results.""" + if subpath.name.endswith('.py'): + fpath = pathlib.Path(moduledir, subpath) + ispackage = False + else: + fpath = pathlib.Path(moduledir, subpath, '__init__.py') + ispackage = True + with fpath.open() as infile: + flines = infile.readlines() + meta_lines = { + lnum: l[1:].split() + for lnum, l in enumerate(flines) if 'bs_meta' in l + } + toplevel = len(subpath.parts) <= 1 + required_api = self.get_api_requirement(subpath, meta_lines, toplevel) + + # Top level modules with no discernible api version get ignored. + if toplevel and required_api is None: + return + + # If we find a module requiring a different api version, warn + # and ignore. + if required_api is not None and required_api != CURRENT_API_VERSION: + self.results['warnings'] += ('Warning: ' + str(subpath) + + ' requires api ' + str(required_api) + + ' but we are running ' + + str(CURRENT_API_VERSION) + + '; ignoring module.\n') + return + + # Ok; can proceed with a full scan of this module. + self._process_module_meta_tags(subpath, flines, meta_lines) + + # If its a package, recurse into its subpackages. + if ispackage: + try: + submodules: List[Tuple[pathlib.Path, pathlib.Path]] = [] + self._get_path_module_entries(moduledir, subpath, submodules) + for submodule in submodules: + self.scan_module(submodule[0], submodule[1]) + except Exception: + from ba import _error + self.results['warnings'] += ("Error scanning '" + + str(subpath) + "': " + + _error.exc_str() + '\n') + + def _process_module_meta_tags(self, subpath: pathlib.Path, + flines: List[str], + meta_lines: Dict[int, str]) -> None: + """Pull data from a module based on its bs_meta tags.""" + for lindex, mline in meta_lines.items(): + # meta_lines is just anything containing 'bs_meta'; make sure + # the bs_meta is in the right place. + if mline[0] != 'bs_meta': + self.results['warnings'] += ( + 'Warning: ' + str(subpath) + + ': malformed bs_meta statement on line ' + + str(lindex + 1) + '.\n') + elif (len(mline) == 4 and mline[1] == 'require' + and mline[2] == 'api'): + # Ignore 'require api X' lines in this pass. + pass + elif len(mline) != 3 or mline[1] != 'export': + # Currently we only support 'bs_meta export FOO'; + # complain for anything else we see. + self.results['warnings'] += ( + 'Warning: ' + str(subpath) + + ': unrecognized bs_meta statement on line ' + + str(lindex + 1) + '.\n') + else: + # Looks like we've got a valid export line! + modulename = '.'.join(subpath.parts) + if subpath.name.endswith('.py'): + modulename = modulename[:-3] + exporttype = mline[2] + export_class_name = self._get_export_class_name( + subpath, flines, lindex) + if export_class_name is not None: + classname = modulename + '.' + export_class_name + if exporttype == 'game': + self.results['games'].append(classname) + else: + self.results['warnings'] += ( + 'Warning: ' + str(subpath) + + ': unrecognized export type "' + exporttype + + '" on line ' + str(lindex + 1) + '.\n') + + def _get_export_class_name(self, subpath: pathlib.Path, lines: List[str], + lindex: int) -> Optional[str]: + """Given line num of an export tag, returns its operand class name.""" + lindexorig = lindex + classname = None + while True: + lindex += 1 + if lindex >= len(lines): + break + lbits = lines[lindex].split() + if not lbits: + continue # Skip empty lines. + if lbits[0] != 'class': + break + if len(lbits) > 1: + cbits = lbits[1].split('(') + if len(cbits) > 1 and cbits[0].isidentifier(): + classname = cbits[0] + break # success! + if classname is None: + self.results['warnings'] += ( + 'Warning: ' + str(subpath) + ': class definition not found' + ' below "bs_meta export" statement on line ' + + str(lindexorig + 1) + '.\n') + return classname + + def get_api_requirement(self, subpath: pathlib.Path, + meta_lines: Dict[int, str], + toplevel: bool) -> Optional[int]: + """Return an API requirement integer or None if none present. + + Malformed api requirement strings will be logged as warnings. + """ + lines = [ + l for l in meta_lines.values() if len(l) == 4 and l[0] == 'bs_meta' + and l[1] == 'require' and l[2] == 'api' and l[3].isdigit() + ] + + # we're successful if we find exactly one properly formatted line + if len(lines) == 1: + return int(lines[0][3]) + + # Ok; not successful. lets issue warnings for a few error cases. + if len(lines) > 1: + self.results['warnings'] += ( + 'Warning: ' + str(subpath) + + ': multiple "# bs_meta api require " lines found;' + ' ignoring module.\n') + elif not lines and toplevel and meta_lines: + # If we're a top-level module containing meta lines but + # no valid api require, complain. + self.results['warnings'] += ( + 'Warning: ' + str(subpath) + + ': no valid "# bs_meta api require " line found;' + ' ignoring module.\n') + return None + + +def getscanresults() -> Dict[str, Any]: + """Return meta scan results; blocking if the scan is not yet complete.""" + import time + app = _ba.app + if app.metascan is None: + print('WARNING: ba.meta.getscanresults() called before scan completed.' + ' This can cause hitches.') + + # Now wait a bit for the scan to complete. + # Eventually error though if it doesn't. + starttime = time.time() + while app.metascan is None: + time.sleep(0.05) + if time.time() - starttime > 10.0: + raise Exception('timeout waiting for meta scan to complete.') + return app.metascan + + +def get_game_types() -> List[Type[ba.GameActivity]]: + """Return available game types.""" + from ba import _general + from ba import _gameactivity + gameclassnames = getscanresults().get('games', []) + gameclasses = [] + for gameclassname in gameclassnames: + try: + cls = _general.getclass(gameclassname, _gameactivity.GameActivity) + gameclasses.append(cls) + except Exception: + from ba import _error + _error.print_exception('error importing ' + str(gameclassname)) + unowned = get_unowned_game_types() + return [cls for cls in gameclasses if cls not in unowned] + + +def get_unowned_game_types() -> Set[Type[ba.GameActivity]]: + """Return present game types not owned by the current account.""" + try: + from ba import _store + unowned_games: Set[Type[ba.GameActivity]] = set() + if _ba.app.subplatform != 'headless': + for section in _store.get_store_layout()['minigames']: + for mname in section['items']: + if not _ba.get_purchased(mname): + m_info = _store.get_store_item(mname) + unowned_games.add(m_info['gametype']) + return unowned_games + except Exception: + from ba import _error + _error.print_exception("error calcing un-owned games") + return set() diff --git a/assets/src/data/scripts/ba/_modutils.py b/assets/src/data/scripts/ba/_modutils.py new file mode 100644 index 00000000..22b63531 --- /dev/null +++ b/assets/src/data/scripts/ba/_modutils.py @@ -0,0 +1,129 @@ +"""Functionality related to modding.""" + +import os + +import _ba + + +def get_human_readable_user_scripts_path() -> str: + """Return a human readable location of user-scripts. + + This is NOT a valid filesystem path; may be something like "(SD Card)". + """ + from ba import _lang + app = _ba.app + path = app.user_scripts_directory + if path is None: + return '' + + # On newer versions of android, the user's external storage dir is probably + # only visible to the user's processes and thus not really useful printed + # in its entirety; lets print it as /myfilepath. + if app.platform == 'android': + ext_storage_path = (_ba.android_get_external_storage_path()) + if (ext_storage_path is not None + and app.user_scripts_directory.startswith(ext_storage_path)): + path = ('<' + + _lang.Lstr(resource='externalStorageText').evaluate() + + '>' + app.user_scripts_directory[len(ext_storage_path):]) + return path + + +def show_user_scripts() -> None: + """Open or nicely print the location of the user-scripts directory.""" + from ba import _lang + from ba._enums import Permission + app = _ba.app + + # First off, if we need permission for this, ask for it. + if not _ba.have_permission(Permission.STORAGE): + _ba.playsound(_ba.getsound('error')) + _ba.screenmessage(_lang.Lstr(resource='storagePermissionAccessText'), + color=(1, 0, 0)) + _ba.request_permission(Permission.STORAGE) + return + + # Secondly, if the dir doesn't exist, attempt to make it. + if not os.path.exists(app.user_scripts_directory): + os.makedirs(app.user_scripts_directory) + + # On android, attempt to write a file in their user-scripts dir telling + # them about modding. This also has the side-effect of allowing us to + # media-scan that dir so it shows up in android-file-transfer, since it + # doesn't seem like there's a way to inform the media scanner of an empty + # directory, which means they would have to reboot their device before + # they can see it. + if app.platform == 'android': + try: + usd = app.user_scripts_directory + if usd is not None and os.path.isdir(usd): + file_name = usd + '/about_this_folder.txt' + with open(file_name, 'w') as outfile: + outfile.write('You can drop files in here to mod the game.' + ' See settings/advanced' + ' in the game for more info.') + _ba.android_media_scan_file(file_name) + except Exception: + from ba import _error + _error.print_exception('error writing about_this_folder stuff') + + # On a few platforms we try to open the dir in the UI. + if app.platform in ['mac', 'windows']: + _ba.open_dir_externally(app.user_scripts_directory) + + # Otherwise we just print a pretty version of it. + else: + _ba.screenmessage(get_human_readable_user_scripts_path()) + + +def create_user_system_scripts() -> None: + """Set up a copy of Ballistica system scripts under your user scripts dir. + + (for editing and experiment with) + """ + app = _ba.app + import shutil + path = (app.user_scripts_directory + '/sys/' + app.version) + if os.path.exists(path): + shutil.rmtree(path) + if os.path.exists(path + "_tmp"): + shutil.rmtree(path + "_tmp") + os.makedirs(path + '_tmp', exist_ok=True) + + # Hmm; shutil.copytree doesn't seem to work nicely on android, + # so lets do it manually. + src_dir = app.system_scripts_directory + dst_dir = path + "_tmp" + filenames = os.listdir(app.system_scripts_directory) + for fname in filenames: + print('COPYING', src_dir + '/' + fname, '->', dst_dir) + shutil.copyfile(src_dir + '/' + fname, dst_dir + '/' + fname) + + print('MOVING', path + "_tmp", path) + shutil.move(path + "_tmp", path) + print( + ('Created system scripts at :\'' + path + + '\'\nRestart Ballistica to use them. (use ba.quit() to exit the game)' + )) + if app.platform == 'android': + print('Note: the new files may not be visible via ' + 'android-file-transfer until you restart your device.') + + +def delete_user_system_scripts() -> None: + """Clean out the scripts created by create_user_system_scripts().""" + import shutil + app = _ba.app + path = (app.user_scripts_directory + '/sys/' + app.version) + if os.path.exists(path): + shutil.rmtree(path) + print( + 'User system scripts deleted.\nRestart Ballistica to use internal' + ' scripts. (use ba.quit() to exit the game)') + else: + print('User system scripts not found.') + + # If the sys path is empty, kill it. + dpath = app.user_scripts_directory + '/sys' + if os.path.isdir(dpath) and not os.listdir(dpath): + os.rmdir(dpath) diff --git a/assets/src/data/scripts/ba/_music.py b/assets/src/data/scripts/ba/_music.py new file mode 100644 index 00000000..f814202a --- /dev/null +++ b/assets/src/data/scripts/ba/_music.py @@ -0,0 +1,732 @@ +"""Music related functionality.""" +from __future__ import annotations + +import copy +import os +import random +import threading +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Callable, Any, List, Optional, Dict + + +class MusicPlayer: + """Wrangles soundtrack music playback. + + Music can be played either through the game itself + or via a platform-specific external player. + """ + + def __init__(self) -> None: + self._have_set_initial_volume = False + self._entry_to_play = None + self._volume = 1.0 + self._actually_playing = False + + def select_entry(self, callback: Callable[[Any], None], current_entry: Any, + selection_target_name: str) -> Any: + """Summons a UI to select a new soundtrack entry.""" + return self.on_select_entry(callback, current_entry, + selection_target_name) + + def set_volume(self, volume: float) -> None: + """Set player volume (value should be between 0 and 1).""" + self._volume = volume + self.on_set_volume(volume) + self._update_play_state() + + def play(self, entry: Any) -> None: + """Play provided entry.""" + if not self._have_set_initial_volume: + self._volume = _ba.app.config.resolve('Music Volume') + self.on_set_volume(self._volume) + self._have_set_initial_volume = True + self._entry_to_play = copy.deepcopy(entry) + + # If we're currently *actually* playing something, + # switch to the new thing. + # Otherwise update state which will start us playing *only* + # if proper (volume > 0, etc). + if self._actually_playing: + self.on_play(self._entry_to_play) + else: + self._update_play_state() + + def stop(self) -> None: + """Stop any playback that is occurring.""" + self._entry_to_play = None + self._update_play_state() + + def shutdown(self) -> None: + """Shutdown music playback completely.""" + self.on_shutdown() + + def on_select_entry(self, callback: Callable[[Any], None], + current_entry: Any, selection_target_name: str) -> Any: + """Present a GUI to select an entry. + + The callback should be called with a valid entry or None to + signify that the default soundtrack should be used..""" + + # Subclasses should override the following: + def on_set_volume(self, volume: float) -> None: + """Called when the volume should be changed.""" + + def on_play(self, entry: Any) -> None: + """Called when a new song/playlist/etc should be played.""" + + def on_stop(self) -> None: + """Called when the music should stop.""" + + def on_shutdown(self) -> None: + """Called on final app shutdown.""" + + def _update_play_state(self) -> None: + + # If we aren't playing, should be, and have positive volume, do so. + if not self._actually_playing: + if self._entry_to_play is not None and self._volume > 0.0: + self.on_play(self._entry_to_play) + self._actually_playing = True + else: + if self._actually_playing and (self._entry_to_play is None + or self._volume <= 0.0): + self.on_stop() + self._actually_playing = False + + +class InternalMusicPlayer(MusicPlayer): + """Music player that talks to internal c layer functionality. + + (internal)""" + + def __init__(self) -> None: + super().__init__() + self._want_to_play = False + self._actually_playing = False + + def on_select_entry(self, callback: Callable[[Any], None], + current_entry: Any, selection_target_name: str) -> Any: + # pylint: disable=cyclic-import + from bastd.ui.soundtrack.entrytypeselect import ( + SoundtrackEntryTypeSelectWindow) + return SoundtrackEntryTypeSelectWindow(callback, current_entry, + selection_target_name) + + def on_set_volume(self, volume: float) -> None: + _ba.music_player_set_volume(volume) + + class _PickFolderSongThread(threading.Thread): + + def __init__(self, path: str, callback: Callable): + super().__init__() + self._callback = callback + self._path = path + + def run(self) -> None: + from ba import _lang + from ba._general import Call + try: + _ba.set_thread_name("BA_PickFolderSongThread") + all_files: List[str] = [] + valid_extensions = [ + '.' + x for x in get_valid_music_file_extensions() + ] + for root, _subdirs, filenames in os.walk(self._path): + for fname in filenames: + if any(fname.lower().endswith(ext) + for ext in valid_extensions): + all_files.insert( + random.randrange(len(all_files) + 1), + root + '/' + fname) + if not all_files: + raise Exception( + _lang.Lstr(resource='internal.noMusicFilesInFolderText' + ).evaluate()) + _ba.pushcall(Call(self._callback, result=all_files), + from_other_thread=True) + except Exception as exc: + from ba import _error + _error.print_exception() + try: + err_str = str(exc) + except Exception: + err_str = '' + _ba.pushcall(Call(self._callback, + result=self._path, + error=err_str), + from_other_thread=True) + + def on_play(self, entry: Any) -> None: + entry_type = get_soundtrack_entry_type(entry) + name = get_soundtrack_entry_name(entry) + assert name is not None + if entry_type == 'musicFile': + self._want_to_play = self._actually_playing = True + _ba.music_player_play(name) + elif entry_type == 'musicFolder': + + # Launch a thread to scan this folder and give us a random + # valid file within. + self._want_to_play = True + self._actually_playing = False + self._PickFolderSongThread(name, self._on_play_folder_cb).start() + + def _on_play_folder_cb(self, result: str, + error: Optional[str] = None) -> None: + from ba import _lang + if error is not None: + rstr = (_lang.Lstr( + resource='internal.errorPlayingMusicText').evaluate()) + err_str = (rstr.replace('${MUSIC}', os.path.basename(result)) + + '; ' + str(error)) + _ba.screenmessage(err_str, color=(1, 0, 0)) + return + + # There's a chance a stop could have been issued before our thread + # returned. If that's the case, don't play. + if not self._want_to_play: + print('_on_play_folder_cb called with _want_to_play False') + else: + self._actually_playing = True + _ba.music_player_play(result) + + def on_stop(self) -> None: + self._want_to_play = False + self._actually_playing = False + _ba.music_player_stop() + + def on_shutdown(self) -> None: + _ba.music_player_shutdown() + + +# For internal music player. +def get_valid_music_file_extensions() -> List[str]: + """Return file extensions for types playable on this device.""" + return ['mp3', 'ogg', 'm4a', 'wav', 'flac', 'mid'] + + +class ITunesThread(threading.Thread): + """Thread which wrangles iTunes/Music.app playback""" + + def __init__(self) -> None: + super().__init__() + self._commands_available = threading.Event() + self._commands: List[List] = [] + self._volume = 1.0 + self._current_playlist: Optional[str] = None + self._orig_volume: Optional[int] = None + + def run(self) -> None: + """Run the iTunes/Music.app thread.""" + from ba._general import Call + from ba._lang import Lstr + from ba._enums import TimeType + _ba.set_thread_name("BA_ITunesThread") + _ba.itunes_init() + + # It looks like launching iTunes here on 10.7/10.8 knocks us + # out of fullscreen; ick. That might be a bug, but for now we + # can work around it by reactivating ourself after. + def do_print() -> None: + _ba.timer(1.0, + Call(_ba.screenmessage, Lstr(resource='usingItunesText'), + (0, 1, 0)), + timetype=TimeType.REAL) + + _ba.pushcall(do_print, from_other_thread=True) + + # Here we grab this to force the actual launch. + # Currently (on 10.8 at least) this is causing a switch + # away from our fullscreen window. to work around this we + # explicitly focus our main window to bring ourself back. + _ba.itunes_get_volume() + _ba.pushcall(_ba.focus_window, from_other_thread=True) + _ba.itunes_get_library_source() + done = False + while not done: + self._commands_available.wait() + self._commands_available.clear() + + # We're not protecting this list with a mutex but we're + # just using it as a simple queue so it should be fine. + while self._commands: + cmd = self._commands.pop(0) + if cmd[0] == 'DIE': + + self._handle_die_command() + done = True + break + if cmd[0] == 'PLAY': + self._handle_play_command(target=cmd[1]) + elif cmd[0] == 'GET_PLAYLISTS': + self._handle_get_playlists_command(target=cmd[1]) + + del cmd # Allows the command data/callback/etc to be freed. + + def set_volume(self, volume: float) -> None: + """Set volume to a value between 0 and 1.""" + old_volume = self._volume + self._volume = volume + + # If we've got nothing we're supposed to be playing, + # don't touch itunes/music. + if self._current_playlist is None: + return + + # If volume is going to zero, stop actually playing + # but don't clear playlist. + if old_volume > 0.0 and volume == 0.0: + try: + assert self._orig_volume is not None + _ba.itunes_stop() + _ba.itunes_set_volume(self._orig_volume) + except Exception as exc: + print('Error stopping iTunes music:', exc) + elif self._volume > 0: + + # If volume was zero, store pre-playing volume and start + # playing. + if old_volume == 0.0: + self._orig_volume = _ba.itunes_get_volume() + self._update_itunes_volume() + if old_volume == 0.0: + self._play_current_playlist() + + def play_playlist(self, musictype: Optional[str]) -> None: + """Play the given playlist.""" + self._commands.append(['PLAY', musictype]) + self._commands_available.set() + + def shutdown(self) -> None: + """Request that the player shuts down.""" + self._commands.append(['DIE']) + self._commands_available.set() + self.join() + + def get_playlists(self, callback: Callable[[Any], None]) -> None: + """Request the list of playlists.""" + self._commands.append(['GET_PLAYLISTS', callback]) + self._commands_available.set() + + def _handle_get_playlists_command(self, target: str) -> None: + from ba._general import Call + try: + playlists = _ba.itunes_get_playlists() + playlists = [ + p for p in playlists if p not in [ + 'Music', 'Movies', 'TV Shows', 'Podcasts', 'iTunes\xa0U', + 'Books', 'Genius', 'iTunes DJ', 'Music Videos', + 'Home Videos', 'Voice Memos', 'Audiobooks' + ] + ] + playlists.sort(key=lambda x: x.lower()) + except Exception as exc: + print('Error getting iTunes playlists:', exc) + playlists = [] + _ba.pushcall(Call(target, playlists), from_other_thread=True) + + def _handle_play_command(self, target: Optional[str]) -> None: + if target is None: + if self._current_playlist is not None and self._volume > 0: + try: + assert self._orig_volume is not None + _ba.itunes_stop() + _ba.itunes_set_volume(self._orig_volume) + except Exception as exc: + print('Error stopping iTunes music:', exc) + self._current_playlist = None + else: + # If we've got something playing with positive + # volume, stop it. + if self._current_playlist is not None and self._volume > 0: + try: + assert self._orig_volume is not None + _ba.itunes_stop() + _ba.itunes_set_volume(self._orig_volume) + except Exception as exc: + print('Error stopping iTunes music:', exc) + + # Set our playlist and play it if our volume is up. + self._current_playlist = target + if self._volume > 0: + self._orig_volume = (_ba.itunes_get_volume()) + self._update_itunes_volume() + self._play_current_playlist() + + def _handle_die_command(self) -> None: + + # Only stop if we've actually played something + # (we don't want to kill music the user has playing). + if self._current_playlist is not None and self._volume > 0: + try: + assert self._orig_volume is not None + _ba.itunes_stop() + _ba.itunes_set_volume(self._orig_volume) + except Exception as exc: + print('Error stopping iTunes music:', exc) + + def _play_current_playlist(self) -> None: + try: + from ba import _lang + from ba._general import Call + assert self._current_playlist is not None + if _ba.itunes_play_playlist(self._current_playlist): + pass + else: + _ba.pushcall(Call( + _ba.screenmessage, + _lang.get_resource('playlistNotFoundText') + ': \'' + + self._current_playlist + '\'', (1, 0, 0)), + from_other_thread=True) + except Exception: + from ba import _error + _error.print_exception( + f"error playing playlist {self._current_playlist}") + + def _update_itunes_volume(self) -> None: + _ba.itunes_set_volume(max(0, min(100, int(100.0 * self._volume)))) + + +class MacITunesMusicPlayer(MusicPlayer): + """A music-player that utilizes iTunes/Music.app for playback. + + Allows selecting playlists as entries. + """ + + def __init__(self) -> None: + super().__init__() + self._thread = ITunesThread() + self._thread.start() + + def on_select_entry(self, callback: Callable[[Any], None], + current_entry: Any, selection_target_name: str) -> Any: + # pylint: disable=cyclic-import + from bastd.ui.soundtrack import entrytypeselect as etsel + return etsel.SoundtrackEntryTypeSelectWindow(callback, current_entry, + selection_target_name) + + def on_set_volume(self, volume: float) -> None: + self._thread.set_volume(volume) + + def get_playlists(self, callback: Callable) -> None: + """Asynchronously fetch the list of available iTunes playlists.""" + self._thread.get_playlists(callback) + + def on_play(self, entry: Any) -> None: + entry_type = get_soundtrack_entry_type(entry) + if entry_type == 'iTunesPlaylist': + self._thread.play_playlist(get_soundtrack_entry_name(entry)) + else: + print('MacITunesMusicPlayer passed unrecognized entry type:', + entry_type) + + def on_stop(self) -> None: + self._thread.play_playlist(None) + + def on_shutdown(self) -> None: + self._thread.shutdown() + + +def have_music_player() -> bool: + """Returns whether a music player is present.""" + return _ba.app.music_player_type is not None + + +def get_music_player() -> MusicPlayer: + """Returns the system music player, instantiating if necessary.""" + app = _ba.app + if app.music_player is None: + if app.music_player_type is None: + raise Exception("no music player type set") + app.music_player = app.music_player_type() + return app.music_player + + +def music_volume_changed(val: float) -> None: + """Should be called when changing the music volume.""" + app = _ba.app + if app.music_player is not None: + app.music_player.set_volume(val) + + +def set_music_play_mode(mode: str, force_restart: bool = False) -> None: + """Sets music play mode; used for soundtrack testing/etc.""" + app = _ba.app + old_mode = app.music_mode + app.music_mode = mode + if old_mode != app.music_mode or force_restart: + + # If we're switching into test mode we don't + # actually play anything until its requested. + # If we're switching *out* of test mode though + # we want to go back to whatever the normal song was. + if mode == 'regular': + do_play_music(app.music_types['regular']) + + +def supports_soundtrack_entry_type(entry_type: str) -> bool: + """Return whether the provided soundtrack entry type is supported here.""" + uas = _ba.app.user_agent_string + if entry_type == 'iTunesPlaylist': + return 'Mac' in uas + if entry_type in ('musicFile', 'musicFolder'): + return ('android' in uas + and _ba.android_get_external_storage_path() is not None) + if entry_type == 'default': + return True + return False + + +def get_soundtrack_entry_type(entry: Any) -> str: + """Given a soundtrack entry, returns its type, taking into + account what is supported locally.""" + try: + if entry is None: + entry_type = 'default' + + # Simple string denotes iTunesPlaylist (legacy format). + elif isinstance(entry, str): + entry_type = 'iTunesPlaylist' + + # For other entries we expect type and name strings in a dict. + elif (isinstance(entry, dict) and 'type' in entry + and isinstance(entry['type'], str) and 'name' in entry + and isinstance(entry['name'], str)): + entry_type = entry['type'] + else: + raise Exception("invalid soundtrack entry: " + str(entry) + + " (type " + str(type(entry)) + ")") + if supports_soundtrack_entry_type(entry_type): + return entry_type + raise Exception("invalid soundtrack entry:" + str(entry)) + except Exception as exc: + print('EXC on get_soundtrack_entry_type', exc) + return 'default' + + +def get_soundtrack_entry_name(entry: Any) -> str: + """Given a soundtrack entry, returns its name.""" + try: + if entry is None: + raise Exception('entry is None') + + # Simple string denotes an iTunesPlaylist name (legacy entry). + if isinstance(entry, str): + return entry + + # For other entries we expect type and name strings in a dict. + if (isinstance(entry, dict) and 'type' in entry + and isinstance(entry['type'], str) and 'name' in entry + and isinstance(entry['name'], str)): + return entry['name'] + raise Exception("invalid soundtrack entry:" + str(entry)) + except Exception: + from ba import _error + _error.print_exception() + return 'default' + + +def setmusic(musictype: Optional[str], continuous: bool = False) -> None: + """Set or stop the current music based on a string musictype. + + category: Gameplay Functions + + Current valid values for 'musictype': 'Menu', 'Victory', 'CharSelect', + 'RunAway', 'Onslaught', 'Keep Away', 'Race', 'Epic Race', 'Scores', + 'GrandRomp', 'ToTheDeath', 'Chosen One', 'ForwardMarch', 'FlagCatcher', + 'Survival', 'Epic', 'Sports', 'Hockey', 'Football', 'Flying', 'Scary', + 'Marching'. + + This function will handle loading and playing sound media as necessary, + and also supports custom user soundtracks on specific platforms so the + user can override particular game music with their own. + + Pass None to stop music. + + if 'continuous' is True the musictype passed is the same as what is already + playing, the playing track will not be restarted. + """ + from ba import _gameutils + + # All we do here now is set a few music attrs on the current globals + # node. The foreground globals' current playing music then gets fed to + # the do_play_music call below. This way we can seamlessly support custom + # soundtracks in replays/etc since we're replaying an attr value set; + # not an actual sound node create. + gnode = _gameutils.sharedobj('globals') + gnode.music_continuous = continuous + gnode.music = '' if musictype is None else musictype + gnode.music_count += 1 + + +def handle_app_resume() -> None: + """Should be run when the app resumes from a suspended state.""" + if _ba.is_os_playing_music(): + do_play_music(None) + + +def do_play_music(musictype: Optional[str], + continuous: bool = False, + mode: str = 'regular', + testsoundtrack: Dict = None) -> None: + """Plays the requested music type/mode. + + For most cases setmusic() is the proper call to use, which itself calls + this. Certain cases, however, such as soundtrack testing, may require + calling this directly. + """ + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + app = _ba.app + with _ba.Context('ui'): + + # If they don't want to restart music and we're already + # playing what's requested, we're done. + if continuous and app.music_types[mode] == musictype: + return + app.music_types[mode] = musictype + cfg = app.config + + # If the OS tells us there's currently music playing, + # all our operations default to playing nothing. + if _ba.is_os_playing_music(): + musictype = None + + # If we're not in the mode this music is being set for, + # don't actually change what's playing. + if mode != app.music_mode: + return + + # Some platforms have a special music-player for things like iTunes + # soundtracks, mp3s, etc. if this is the case, attempt to grab an + # entry for this music-type, and if we have one, have the music-player + # play it. If not, we'll play game music ourself. + if musictype is not None and app.music_player_type is not None: + try: + soundtrack: Dict + if testsoundtrack is not None: + soundtrack = testsoundtrack + else: + soundtrack = cfg['Soundtracks'][cfg['Soundtrack']] + entry = soundtrack[musictype] + except Exception: + entry = None + else: + entry = None + + # Go through music-player. + if entry is not None: + + # Stop any existing internal music. + if app.music is not None: + app.music.delete() + app.music = None + + # Play music-player music. + get_music_player().play(entry) + + # Handle via internal music. + else: + if musictype is not None: + loop = True + if musictype == 'Menu': + filename = 'menuMusic' + volume = 5.0 + elif musictype == 'Victory': + filename = 'victoryMusic' + volume = 6.0 + loop = False + elif musictype == 'CharSelect': + filename = 'charSelectMusic' + volume = 2.0 + elif musictype == 'RunAway': + filename = 'runAwayMusic' + volume = 6.0 + elif musictype == 'Onslaught': + filename = 'runAwayMusic' + volume = 6.0 + elif musictype == 'Keep Away': + filename = 'runAwayMusic' + volume = 6.0 + elif musictype == 'Race': + filename = 'runAwayMusic' + volume = 6.0 + elif musictype == 'Epic Race': + filename = 'slowEpicMusic' + volume = 6.0 + elif musictype == 'Scores': + filename = 'scoresEpicMusic' + volume = 3.0 + loop = False + elif musictype == 'GrandRomp': + filename = 'grandRompMusic' + volume = 6.0 + elif musictype == 'ToTheDeath': + filename = 'toTheDeathMusic' + volume = 6.0 + elif musictype == 'Chosen One': + filename = 'survivalMusic' + volume = 4.0 + elif musictype == 'ForwardMarch': + filename = 'forwardMarchMusic' + volume = 4.0 + elif musictype == 'FlagCatcher': + filename = 'flagCatcherMusic' + volume = 6.0 + elif musictype == 'Survival': + filename = 'survivalMusic' + volume = 4.0 + elif musictype == 'Epic': + filename = 'slowEpicMusic' + volume = 6.0 + elif musictype == 'Sports': + filename = 'sportsMusic' + volume = 4.0 + elif musictype == 'Hockey': + filename = 'sportsMusic' + volume = 4.0 + elif musictype == 'Football': + filename = 'sportsMusic' + volume = 4.0 + elif musictype == 'Flying': + filename = 'flyingMusic' + volume = 4.0 + elif musictype == 'Scary': + filename = 'scaryMusic' + volume = 4.0 + elif musictype == 'Marching': + filename = 'whenJohnnyComesMarchingHomeMusic' + volume = 4.0 + else: + print("Unknown music: '" + musictype + "'") + filename = 'flagCatcherMusic' + volume = 6.0 + + # Stop any existing music-player playback. + if app.music_player is not None: + app.music_player.stop() + + # Stop any existing internal music. + if app.music: + app.music.delete() + app.music = None + + # Start up new internal music. + if musictype is not None: + + # FIXME: Currently this won't start playing if we're paused + # since attr values don't get updated until + # node updates happen. :-( + # Update: hmm I don't think that's true anymore. Should check. + app.music = _ba.newnode(type='sound', + attrs={ + 'sound': _ba.getsound(filename), + 'positional': False, + 'music': True, + 'volume': volume, + 'loop': loop + }) diff --git a/assets/src/data/scripts/ba/_netutils.py b/assets/src/data/scripts/ba/_netutils.py new file mode 100644 index 00000000..d6c45ea8 --- /dev/null +++ b/assets/src/data/scripts/ba/_netutils.py @@ -0,0 +1,156 @@ +"""Networking related functionality.""" +from __future__ import annotations + +import copy +import threading +import weakref +from enum import Enum +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Any, Dict, Union, Callable, Optional + import socket + ServerCallbackType = Callable[[Union[None, Dict[str, Any]]], None] + + +def get_ip_address_type(addr: str) -> socket.AddressFamily: + """Return socket.AF_INET6 or socket.AF_INET4 for the provided address.""" + import socket + socket_type = None + + # First try it as an ipv4 address. + try: + socket.inet_pton(socket.AF_INET, addr) + socket_type = socket.AF_INET + except OSError: + pass + + # Hmm apparently not ipv4; try ipv6. + if socket_type is None: + try: + socket.inet_pton(socket.AF_INET6, addr) + socket_type = socket.AF_INET6 + except OSError: + pass + if socket_type is None: + raise Exception("addr seems to be neither v4 or v6: " + str(addr)) + return socket_type + + +class ServerResponseType(Enum): + """How to interpret responses from the server.""" + JSON = 0 + + +class ServerCallThread(threading.Thread): + """Thread to communicate with the master server.""" + + def __init__(self, request: str, request_type: str, + data: Optional[Dict[str, Any]], + callback: Optional[ServerCallbackType], + response_type: ServerResponseType): + super().__init__() + self._request = request + self._request_type = request_type + if not isinstance(response_type, ServerResponseType): + raise Exception(f'Invalid response type: {response_type}') + self._response_type = response_type + self._data = {} if data is None else copy.deepcopy(data) + self._callback: Optional[ServerCallbackType] = callback + self._context = _ba.Context('current') + + # Save and restore the context we were created from. + activity = _ba.getactivity(doraise=False) + self._activity = weakref.ref( + activity) if activity is not None else None + + def _run_callback(self, arg: Union[None, Dict[str, Any]]) -> None: + + # If we were created in an activity context and that activity has + # since died, do nothing (hmm should we be using a context-call + # instead of doing this manually?). + activity = None if self._activity is None else self._activity() + if activity is None or activity.is_expired(): + return + + # Technically we could do the same check for session contexts, + # but not gonna worry about it for now. + assert self._callback is not None + with self._context: + self._callback(arg) + + def run(self) -> None: + import urllib.request + import urllib.error + import json + from ba import _general + try: + self._data = _general.utf8_all(self._data) + _ba.set_thread_name("BA_ServerCallThread") + + # Seems pycharm doesn't know about urllib.parse. + # noinspection PyUnresolvedReferences + parse = urllib.parse + if self._request_type == 'get': + response = urllib.request.urlopen( + urllib.request.Request( + (_ba.get_master_server_address() + '/' + + self._request + '?' + parse.urlencode(self._data)), + None, {'User-Agent': _ba.app.user_agent_string})) + elif self._request_type == 'post': + response = urllib.request.urlopen( + urllib.request.Request( + _ba.get_master_server_address() + '/' + self._request, + parse.urlencode(self._data).encode(), + {'User-Agent': _ba.app.user_agent_string})) + else: + raise Exception("Invalid request_type: " + self._request_type) + + # If html request failed. + if response.getcode() != 200: + response_data = None + elif self._response_type == ServerResponseType.JSON: + raw_data = response.read() + + # Empty string here means something failed server side. + if raw_data == b'': + response_data = None + else: + # Json.loads requires str in python < 3.6. + raw_data_s = raw_data.decode() + response_data = json.loads(raw_data_s) + else: + raise Exception(f'invalid responsetype: {self._response_type}') + except (urllib.error.URLError, ConnectionError): + # Server rejected us, broken pipe, etc. It happens. Ignoring. + response_data = None + except Exception as exc: + # Any other error here is unexpected, so let's make a note of it. + print('Exc in ServerCallThread:', exc) + import traceback + traceback.print_exc() + response_data = None + + if self._callback is not None: + _ba.pushcall(_general.Call(self._run_callback, response_data), + from_other_thread=True) + + +def serverget(request: str, + data: Dict[str, Any], + callback: Optional[ServerCallbackType] = None, + response_type: ServerResponseType = ServerResponseType.JSON + ) -> None: + """Make a call to the master server via a http GET.""" + ServerCallThread(request, 'get', data, callback, response_type).start() + + +def serverput(request: str, + data: Dict[str, Any], + callback: Optional[ServerCallbackType] = None, + response_type: ServerResponseType = ServerResponseType.JSON + ) -> None: + """Make a call to the master server via a http POST.""" + ServerCallThread(request, 'post', data, callback, response_type).start() diff --git a/assets/src/data/scripts/ba/_playlist.py b/assets/src/data/scripts/ba/_playlist.py new file mode 100644 index 00000000..3c236b30 --- /dev/null +++ b/assets/src/data/scripts/ba/_playlist.py @@ -0,0 +1,522 @@ +"""Playlist related functionality.""" + +from __future__ import annotations + +import copy +from typing import Any, TYPE_CHECKING, Dict, List + +if TYPE_CHECKING: + from typing import Type, Sequence + from ba import _session + +PlaylistType = List[Dict[str, Any]] + + +def filter_playlist(playlist: PlaylistType, + sessiontype: Type[_session.Session], + add_resolved_type: bool = False, + remove_unowned: bool = True, + mark_unowned: bool = False) -> PlaylistType: + """Return a filtered version of a playlist. + + Strips out or replaces invalid or unowned game types, makes sure all + settings are present, and adds in a 'resolved_type' which is the actual + type. + """ + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + from ba import _meta + from ba import _maps + from ba import _general + from ba import _gameactivity + goodlist: List[Dict] = [] + unowned_maps: Sequence[str] + if remove_unowned or mark_unowned: + unowned_maps = _maps.get_unowned_maps() + unowned_game_types = _meta.get_unowned_game_types() + else: + unowned_maps = [] + unowned_game_types = set() + + for entry in copy.deepcopy(playlist): + # 'map' used to be called 'level' here + if 'level' in entry: + entry['map'] = entry['level'] + del entry['level'] + # we now stuff map into settings instead of it being its own thing + if 'map' in entry: + entry['settings']['map'] = entry['map'] + del entry['map'] + # update old map names to new ones + entry['settings']['map'] = _maps.get_filtered_map_name( + entry['settings']['map']) + if remove_unowned and entry['settings']['map'] in unowned_maps: + continue + # ok, for each game in our list, try to import the module and grab + # the actual game class. add successful ones to our initial list + # to present to the user + if not isinstance(entry['type'], str): + raise Exception("invalid entry format") + try: + # do some type filters for backwards compat. + if entry['type'] in ('Assault.AssaultGame', + 'Happy_Thoughts.HappyThoughtsGame', + 'bsAssault.AssaultGame', + 'bs_assault.AssaultGame'): + entry['type'] = 'bastd.game.assault.AssaultGame' + if entry['type'] in ('King_of_the_Hill.KingOfTheHillGame', + 'bsKingOfTheHill.KingOfTheHillGame', + 'bs_king_of_the_hill.KingOfTheHillGame'): + entry['type'] = 'bastd.game.kingofthehill.KingOfTheHillGame' + if entry['type'] in ('Capture_the_Flag.CTFGame', + 'bsCaptureTheFlag.CTFGame', + 'bs_capture_the_flag.CTFGame'): + entry['type'] = ( + 'bastd.game.capturetheflag.CaptureTheFlagGame') + if entry['type'] in ('Death_Match.DeathMatchGame', + 'bsDeathMatch.DeathMatchGame', + 'bs_death_match.DeathMatchGame'): + entry['type'] = 'bastd.game.deathmatch.DeathMatchGame' + if entry['type'] in ('ChosenOne.ChosenOneGame', + 'bsChosenOne.ChosenOneGame', + 'bs_chosen_one.ChosenOneGame'): + entry['type'] = 'bastd.game.chosenone.ChosenOneGame' + if entry['type'] in ('Conquest.Conquest', 'Conquest.ConquestGame', + 'bsConquest.ConquestGame', + 'bs_conquest.ConquestGame'): + entry['type'] = 'bastd.game.conquest.ConquestGame' + if entry['type'] in ('Elimination.EliminationGame', + 'bsElimination.EliminationGame', + 'bs_elimination.EliminationGame'): + entry['type'] = 'bastd.game.elimination.EliminationGame' + if entry['type'] in ('Football.FootballGame', + 'bsFootball.FootballTeamGame', + 'bs_football.FootballTeamGame'): + entry['type'] = 'bastd.game.football.FootballTeamGame' + if entry['type'] in ('Hockey.HockeyGame', 'bsHockey.HockeyGame', + 'bs_hockey.HockeyGame'): + entry['type'] = 'bastd.game.hockey.HockeyGame' + if entry['type'] in ('Keep_Away.KeepAwayGame', + 'bsKeepAway.KeepAwayGame', + 'bs_keep_away.KeepAwayGame'): + entry['type'] = 'bastd.game.keepaway.KeepAwayGame' + if entry['type'] in ('Race.RaceGame', 'bsRace.RaceGame', + 'bs_race.RaceGame'): + entry['type'] = 'bastd.game.race.RaceGame' + if entry['type'] in ('bsEasterEggHunt.EasterEggHuntGame', + 'bs_easter_egg_hunt.EasterEggHuntGame'): + entry['type'] = 'bastd.game.easteregghunt.EasterEggHuntGame' + if entry['type'] in ('bsMeteorShower.MeteorShowerGame', + 'bs_meteor_shower.MeteorShowerGame'): + entry['type'] = 'bastd.game.meteorshower.MeteorShowerGame' + if entry['type'] in ('bsTargetPractice.TargetPracticeGame', + 'bs_target_practice.TargetPracticeGame'): + entry['type'] = ( + 'bastd.game.targetpractice.TargetPracticeGame') + + gameclass = _general.getclass(entry['type'], + _gameactivity.GameActivity) + + if remove_unowned and gameclass in unowned_game_types: + continue + if add_resolved_type: + entry['resolved_type'] = gameclass + if mark_unowned and entry['settings']['map'] in unowned_maps: + entry['is_unowned_map'] = True + if mark_unowned and gameclass in unowned_game_types: + entry['is_unowned_game'] = True + + # make sure all settings the game defines are present + neededsettings = gameclass.get_settings(sessiontype) + for setting_name, setting in neededsettings: + if (setting_name not in entry['settings'] + and 'default' in setting): + entry['settings'][setting_name] = setting['default'] + goodlist.append(entry) + except Exception: + from ba import _error + _error.print_exception() + return goodlist + + +def get_default_free_for_all_playlist() -> PlaylistType: + """Return a default playlist for free-for-all mode.""" + # NOTE: these are currently using old type/map names, + # but filtering translates them properly to the new ones. + # (is kinda a handy way to ensure filtering is working). + # Eventually should update these though. + return [{ + 'settings': { + 'Epic Mode': False, + 'Kills to Win Per Player': 10, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Doom Shroom' + }, + 'type': 'bs_death_match.DeathMatchGame' + }, + { + 'settings': { + 'Chosen One Gets Gloves': True, + 'Chosen One Gets Shield': False, + 'Chosen One Time': 30, + 'Epic Mode': 0, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Monkey Face' + }, + 'type': 'bs_chosen_one.ChosenOneGame' + }, + { + 'settings': { + 'Hold Time': 30, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Zigzag' + }, + 'type': 'bs_king_of_the_hill.KingOfTheHillGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'map': 'Rampage' + }, + 'type': 'bs_meteor_shower.MeteorShowerGame' + }, + { + 'settings': { + 'Epic Mode': 1, + 'Lives Per Player': 1, + 'Respawn Times': 1.0, + 'Time Limit': 120, + 'map': 'Tip Top' + }, + 'type': 'bs_elimination.EliminationGame' + }, + { + 'settings': { + 'Hold Time': 30, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'The Pad' + }, + 'type': 'bs_keep_away.KeepAwayGame' + }, + { + 'settings': { + 'Epic Mode': True, + 'Kills to Win Per Player': 10, + 'Respawn Times': 0.25, + 'Time Limit': 120, + 'map': 'Rampage' + }, + 'type': 'bs_death_match.DeathMatchGame' + }, + { + 'settings': { + 'Bomb Spawning': 1000, + 'Epic Mode': False, + 'Laps': 3, + 'Mine Spawn Interval': 4000, + 'Mine Spawning': 4000, + 'Time Limit': 300, + 'map': 'Big G' + }, + 'type': 'bs_race.RaceGame' + }, + { + 'settings': { + 'Hold Time': 30, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Happy Thoughts' + }, + 'type': 'bs_king_of_the_hill.KingOfTheHillGame' + }, + { + 'settings': { + 'Enable Impact Bombs': 1, + 'Enable Triple Bombs': False, + 'Target Count': 2, + 'map': 'Doom Shroom' + }, + 'type': 'bs_target_practice.TargetPracticeGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'Lives Per Player': 5, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Step Right Up' + }, + 'type': 'bs_elimination.EliminationGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'Kills to Win Per Player': 10, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Crag Castle' + }, + 'type': 'bs_death_match.DeathMatchGame' + }, + { + 'map': 'Lake Frigid', + 'settings': { + 'Bomb Spawning': 0, + 'Epic Mode': False, + 'Laps': 6, + 'Mine Spawning': 2000, + 'Time Limit': 300, + 'map': 'Lake Frigid' + }, + 'type': 'bs_race.RaceGame' + }] # yapf: disable + + +def get_default_teams_playlist() -> PlaylistType: + """Return a default playlist for teams mode.""" + + # NOTE: these are currently using old type/map names, + # but filtering translates them properly to the new ones. + # (is kinda a handy way to ensure filtering is working). + # Eventually should update these though. + return [{ + 'settings': { + 'Epic Mode': False, + 'Flag Idle Return Time': 30, + 'Flag Touch Return Time': 0, + 'Respawn Times': 1.0, + 'Score to Win': 3, + 'Time Limit': 600, + 'map': 'Bridgit' + }, + 'type': 'bs_capture_the_flag.CTFGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'Respawn Times': 1.0, + 'Score to Win': 3, + 'Time Limit': 600, + 'map': 'Step Right Up' + }, + 'type': 'bs_assault.AssaultGame' + }, + { + 'settings': { + 'Balance Total Lives': False, + 'Epic Mode': False, + 'Lives Per Player': 3, + 'Respawn Times': 1.0, + 'Solo Mode': True, + 'Time Limit': 600, + 'map': 'Rampage' + }, + 'type': 'bs_elimination.EliminationGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'Kills to Win Per Player': 5, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Roundabout' + }, + 'type': 'bs_death_match.DeathMatchGame' + }, + { + 'settings': { + 'Respawn Times': 1.0, + 'Score to Win': 1, + 'Time Limit': 600, + 'map': 'Hockey Stadium' + }, + 'type': 'bs_hockey.HockeyGame' + }, + { + 'settings': { + 'Hold Time': 30, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Monkey Face' + }, + 'type': 'bs_keep_away.KeepAwayGame' + }, + { + 'settings': { + 'Balance Total Lives': False, + 'Epic Mode': True, + 'Lives Per Player': 1, + 'Respawn Times': 1.0, + 'Solo Mode': False, + 'Time Limit': 120, + 'map': 'Tip Top' + }, + 'type': 'bs_elimination.EliminationGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'Respawn Times': 1.0, + 'Score to Win': 3, + 'Time Limit': 300, + 'map': 'Crag Castle' + }, + 'type': 'bs_assault.AssaultGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'Kills to Win Per Player': 5, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Doom Shroom' + }, + 'type': 'bs_death_match.DeathMatchGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'map': 'Rampage' + }, + 'type': 'bs_meteor_shower.MeteorShowerGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'Flag Idle Return Time': 30, + 'Flag Touch Return Time': 0, + 'Respawn Times': 1.0, + 'Score to Win': 2, + 'Time Limit': 600, + 'map': 'Roundabout' + }, + 'type': 'bs_capture_the_flag.CTFGame' + }, + { + 'settings': { + 'Respawn Times': 1.0, + 'Score to Win': 21, + 'Time Limit': 600, + 'map': 'Football Stadium' + }, + 'type': 'bs_football.FootballTeamGame' + }, + { + 'settings': { + 'Epic Mode': True, + 'Respawn Times': 0.25, + 'Score to Win': 3, + 'Time Limit': 120, + 'map': 'Bridgit' + }, + 'type': 'bs_assault.AssaultGame' + }, + { + 'map': 'Doom Shroom', + 'settings': { + 'Enable Impact Bombs': 1, + 'Enable Triple Bombs': False, + 'Target Count': 2, + 'map': 'Doom Shroom' + }, + 'type': 'bs_target_practice.TargetPracticeGame' + }, + { + 'settings': { + 'Hold Time': 30, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Tip Top' + }, + 'type': 'bs_king_of_the_hill.KingOfTheHillGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'Respawn Times': 1.0, + 'Score to Win': 2, + 'Time Limit': 300, + 'map': 'Zigzag' + }, + 'type': 'bs_assault.AssaultGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'Flag Idle Return Time': 30, + 'Flag Touch Return Time': 0, + 'Respawn Times': 1.0, + 'Score to Win': 3, + 'Time Limit': 300, + 'map': 'Happy Thoughts' + }, + 'type': 'bs_capture_the_flag.CTFGame' + }, + { + 'settings': { + 'Bomb Spawning': 1000, + 'Epic Mode': True, + 'Laps': 1, + 'Mine Spawning': 2000, + 'Time Limit': 300, + 'map': 'Big G' + }, + 'type': 'bs_race.RaceGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'Kills to Win Per Player': 5, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Monkey Face' + }, + 'type': 'bs_death_match.DeathMatchGame' + }, + { + 'settings': { + 'Hold Time': 30, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Lake Frigid' + }, + 'type': 'bs_keep_away.KeepAwayGame' + }, + { + 'settings': { + 'Epic Mode': False, + 'Flag Idle Return Time': 30, + 'Flag Touch Return Time': 3, + 'Respawn Times': 1.0, + 'Score to Win': 2, + 'Time Limit': 300, + 'map': 'Tip Top' + }, + 'type': 'bs_capture_the_flag.CTFGame' + }, + { + 'settings': { + 'Balance Total Lives': False, + 'Epic Mode': False, + 'Lives Per Player': 3, + 'Respawn Times': 1.0, + 'Solo Mode': False, + 'Time Limit': 300, + 'map': 'Crag Castle' + }, + 'type': 'bs_elimination.EliminationGame' + }, + { + 'settings': { + 'Epic Mode': True, + 'Respawn Times': 0.25, + 'Time Limit': 120, + 'map': 'Zigzag' + }, + 'type': 'bs_conquest.ConquestGame' + }] # yapf: disable diff --git a/assets/src/data/scripts/ba/_powerup.py b/assets/src/data/scripts/ba/_powerup.py new file mode 100644 index 00000000..17067aea --- /dev/null +++ b/assets/src/data/scripts/ba/_powerup.py @@ -0,0 +1,52 @@ +"""Powerup related functionality.""" +from __future__ import annotations + +from typing import TYPE_CHECKING +from dataclasses import dataclass + +if TYPE_CHECKING: + from typing import Sequence, Tuple, Optional + import ba + + +@dataclass +class PowerupMessage: + # noinspection PyUnresolvedReferences + """A message telling an object to accept a powerup. + + Category: Message Classes + + This message is normally received by touching a ba.PowerupBox. + + Attributes: + + poweruptype + The type of powerup to be granted (a string). + See ba.Powerup.poweruptype for available type values. + + source_node + The node the powerup game from, or None otherwise. + If a powerup is accepted, a ba.PowerupAcceptMessage should be sent + back to the source_node to inform it of the fact. This will generally + cause the powerup box to make a sound and disappear or whatnot. + """ + poweruptype: str + source_node: Optional[ba.Node] = None + + +@dataclass +class PowerupAcceptMessage: + """A message informing a ba.Powerup that it was accepted. + + Category: Message Classes + + This is generally sent in response to a ba.PowerupMessage + to inform the box (or whoever granted it) that it can go away. + """ + + +def get_default_powerup_distribution() -> Sequence[Tuple[str, int]]: + """Standard set of powerups.""" + return (('triple_bombs', 3), ('ice_bombs', 3), ('punch', 3), + ('impact_bombs', 3), ('land_mines', 2), ('sticky_bombs', 3), + ('shield', 2), ('health', 1), ('curse', 1)) diff --git a/assets/src/data/scripts/ba/_profile.py b/assets/src/data/scripts/ba/_profile.py new file mode 100644 index 00000000..02ddd614 --- /dev/null +++ b/assets/src/data/scripts/ba/_profile.py @@ -0,0 +1,90 @@ +"""Functionality related to player profiles.""" +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import List, Tuple, Any, Dict, Optional + +# NOTE: player color options are enforced server-side for non-pro accounts +# so don't change these or they won't stick... +PLAYER_COLORS = [(1, 0.15, 0.15), (0.2, 1, 0.2), (0.1, 0.1, 1), (0.2, 1, 1), + (0.5, 0.25, 1.0), (1, 1, 0), (1, 0.5, 0), (1, 0.3, 0.5), + (0.1, 0.1, 0.5), (0.4, 0.2, 0.1), (0.1, 0.35, 0.1), + (1, 0.8, 0.5), (0.4, 0.05, 0.05), (0.13, 0.13, 0.13), + (0.5, 0.5, 0.5), (1, 1, 1)] # yapf: disable + + +def get_player_colors() -> List[Tuple[float, float, float]]: + """Return user-selectable player colors.""" + return PLAYER_COLORS + + +def get_player_profile_icon(profilename: str) -> str: + """Given a profile name, returns an icon string for it. + + (non-account profiles only) + """ + from ba._enums import SpecialChar + + bs_config = _ba.app.config + icon: str + try: + is_global = bs_config['Player Profiles'][profilename]['global'] + except Exception: + is_global = False + if is_global: + try: + icon = bs_config['Player Profiles'][profilename]['icon'] + except Exception: + icon = _ba.charstr(SpecialChar.LOGO) + else: + icon = '' + return icon + + +def get_player_profile_colors( + profilename: Optional[str], profiles: Dict[str, Dict[str, Any]] = None +) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]: + """Given a profile, return colors for them.""" + bs_config = _ba.app.config + if profiles is None: + profiles = bs_config['Player Profiles'] + + # special case - when being asked for a random color in kiosk mode, + # always return default purple + if _ba.app.kiosk_mode and profilename is None: + color = (0.5, 0.4, 1.0) + highlight = (0.4, 0.4, 0.5) + else: + try: + assert profilename is not None + color = profiles[profilename]['color'] + except Exception: + # key off name if possible + if profilename is None: + # first 6 are bright-ish + color = PLAYER_COLORS[random.randrange(6)] + else: + # first 6 are bright-ish + color = PLAYER_COLORS[sum([ord(c) for c in profilename]) % 6] + + try: + assert profilename is not None + highlight = profiles[profilename]['highlight'] + except Exception: + # key off name if possible + if profilename is None: + # last 2 are grey and white; ignore those or we + # get lots of old-looking players + highlight = PLAYER_COLORS[random.randrange( + len(PLAYER_COLORS) - 2)] + else: + highlight = PLAYER_COLORS[sum( + [ord(c) + 1 + for c in profilename]) % (len(PLAYER_COLORS) - 2)] + + return color, highlight diff --git a/assets/src/data/scripts/ba/_server.py b/assets/src/data/scripts/ba/_server.py new file mode 100644 index 00000000..76c18f9a --- /dev/null +++ b/assets/src/data/scripts/ba/_server.py @@ -0,0 +1,227 @@ +"""Functionality related to running the game in server-mode.""" +from __future__ import annotations + +import copy +import json +import os +import sys +import time +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Optional, Dict, Any, Type + import ba + + +def config_server(config_file: str = None) -> None: + """Run the game in server mode with the provided server config file.""" + + from ba._enums import TimeType + + app = _ba.app + + # Read and store the provided server config and then delete the file it + # came from. + if config_file is not None: + with open(config_file) as infile: + app.server_config = json.loads(infile.read()) + os.remove(config_file) + else: + app.server_config = {} + + # Make note if they want us to import a playlist; + # we'll need to do that first if so. + playlist_code = app.server_config.get('playlist_code') + if playlist_code is not None: + app.server_playlist_fetch = { + 'sent_request': False, + 'got_response': False, + 'playlist_code': str(playlist_code) + } + + # Apply config stuff that can take effect immediately (party name, etc). + _config_server() + + # Launch the server only the first time through; + # after that it will be self-sustaining. + if not app.launched_server: + + # Now sit around until we're signed in and then kick off the server. + with _ba.Context('ui'): + + def do_it() -> None: + if _ba.get_account_state() == 'signed_in': + can_launch = False + + # If we're trying to fetch a playlist, we do that first. + if app.server_playlist_fetch is not None: + + # Send request if we haven't. + if not app.server_playlist_fetch['sent_request']: + + def on_playlist_fetch_response( + result: Optional[Dict[str, Any]]) -> None: + if result is None: + print('Error fetching playlist; aborting.') + sys.exit(-1) + + # Once we get here we simply modify our + # config to use this playlist. + type_name = ( + 'teams' if + result['playlistType'] == 'Team Tournament' + else 'ffa' if result['playlistType'] == + 'Free-for-All' else '??') + print(('Playlist \'' + result['playlistName'] + + '\' (' + type_name + + ') downloaded; running...')) + assert app.server_playlist_fetch is not None + app.server_playlist_fetch['got_response'] = ( + True) + app.server_config['session_type'] = type_name + app.server_config['playlist_name'] = ( + result['playlistName']) + + print(('Requesting shared-playlist ' + str( + app.server_playlist_fetch['playlist_code']) + + '...')) + app.server_playlist_fetch['sent_request'] = True + _ba.add_transaction( + { + 'type': + 'IMPORT_PLAYLIST', + 'code': + app. + server_playlist_fetch['playlist_code'], + 'overwrite': + True + }, + callback=on_playlist_fetch_response) + _ba.run_transactions() + + # If we got a valid result, forget the fetch ever + # existed and move on. + if app.server_playlist_fetch['got_response']: + app.server_playlist_fetch = None + can_launch = True + else: + can_launch = True + if can_launch: + app.run_server_wait_timer = None + _ba.pushcall(launch_server_session) + + app.run_server_wait_timer = _ba.Timer(0.25, + do_it, + timetype=TimeType.REAL, + repeat=True) + app.launched_server = True + + +def launch_server_session() -> None: + """Kick off a host-session based on the current server config.""" + from ba._netutils import serverget + from ba import _freeforallsession + from ba import _teamssession + app = _ba.app + servercfg = copy.deepcopy(app.server_config) + appcfg = app.config + + # Convert string session type to the class. + # Hmm should we just keep this as a string? + session_type_name = servercfg.get('session_type', 'ffa') + sessiontype: Type[ba.Session] + if session_type_name == 'ffa': + sessiontype = _freeforallsession.FreeForAllSession + elif session_type_name == 'teams': + sessiontype = _teamssession.TeamsSession + else: + raise Exception('invalid session_type value: ' + session_type_name) + + if _ba.get_account_state() != 'signed_in': + print('WARNING: launch_server_session() expects to run ' + 'with a signed in server account') + + if app.run_server_first_run: + print((('BallisticaCore headless ' + if app.subplatform == 'headless' else 'BallisticaCore ') + + str(app.version) + ' (' + str(app.build_number) + + ') entering server-mode ' + time.strftime('%c'))) + + playlist_shuffle = servercfg.get('playlist_shuffle', True) + appcfg['Show Tutorial'] = False + appcfg['Free-for-All Playlist Selection'] = (servercfg.get( + 'playlist_name', '__default__') if session_type_name == 'ffa' else + '__default__') + appcfg['Free-for-All Playlist Randomize'] = playlist_shuffle + appcfg['Team Tournament Playlist Selection'] = (servercfg.get( + 'playlist_name', '__default__') if session_type_name == 'teams' else + '__default__') + appcfg['Team Tournament Playlist Randomize'] = playlist_shuffle + appcfg['Port'] = servercfg.get('port', 43210) + + # Set series lengths. + app.teams_series_length = servercfg.get('teams_series_length', 7) + app.ffa_series_length = servercfg.get('ffa_series_length', 24) + + # And here we go. + _ba.new_host_session(sessiontype) + + # Also lets fire off an access check if this is our first time + # through (and they want a public party). + if app.run_server_first_run: + + def access_check_response(data: Optional[Dict[str, Any]]) -> None: + gameport = _ba.get_game_port() + if data is None: + print('error on UDP port access check (internet down?)') + else: + if data['accessible']: + print('UDP port', gameport, + ('access check successful. Your ' + 'server appears to be joinable ' + 'from the internet.')) + else: + print('UDP port', gameport, + ('access check failed. Your server ' + 'does not appear to be joinable ' + 'from the internet.')) + + port = _ba.get_game_port() + serverget('bsAccessCheck', { + 'port': port, + 'b': app.build_number + }, + callback=access_check_response) + app.run_server_first_run = False + app.server_config_dirty = False + + +def _config_server() -> None: + """Apply server config changes that can take effect immediately. + + (party name, etc) + """ + config = copy.deepcopy(_ba.app.server_config) + + # FIXME: Should make a proper low level config entry for this or + # else not store in in app.config. Probably shouldn't be going through + # the app config for this anyway since it should just be for this run. + _ba.app.config['Auto Balance Teams'] = (config.get('auto_balance_teams', + True)) + + _ba.set_public_party_max_size(config.get('max_party_size', 9)) + _ba.set_public_party_name(config.get('party_name', 'party')) + _ba.set_public_party_stats_url(config.get('stats_url', '')) + + # Call set-enabled last (will push state). + _ba.set_public_party_enabled(config.get('party_is_public', True)) + + if not _ba.app.run_server_first_run: + print('server config updated.') + + # FIXME: We could avoid setting this as dirty if the only changes have + # been ones here we can apply immediately. Could reduce cases where + # players have to rejoin. + _ba.app.server_config_dirty = True diff --git a/assets/src/data/scripts/ba/_session.py b/assets/src/data/scripts/ba/_session.py new file mode 100644 index 00000000..d2449b1a --- /dev/null +++ b/assets/src/data/scripts/ba/_session.py @@ -0,0 +1,796 @@ +"""Defines base session class.""" +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from weakref import ReferenceType + from typing import Sequence, List, Dict, Any, Optional, Set + import ba + + +class Session: + """Defines a high level series of activities with a common purpose. + + category: Gameplay Classes + + Examples of sessions are ba.FreeForAllSession, ba.TeamsSession, and + ba.CoopSession. + + A Session is responsible for wrangling and transitioning between various + ba.Activity instances such as mini-games and score-screens, and for + maintaining state between them (players, teams, score tallies, etc). + + Attributes: + + teams + All the ba.Teams in the Session. Most things should use the team + list in ba.Activity; not this. + + players + All ba.Players in the Session. Most things should use the player + list in ba.Activity; not this. Some players, such as those who have + not yet selected a character, will only appear on this list. + + min_players + The minimum number of Players who must be present for the Session + to proceed past the initial joining screen. + + max_players + The maximum number of Players allowed in the Session. + + lobby + The ba.Lobby instance where new ba.Players go to select a + Profile/Team/etc. before being added to games. + Be aware this value may be None if a Session does not allow + any such selection. + + campaign + The ba.Campaign instance this Session represents, or None if + there is no associated Campaign. + + """ + + # Annotate our attrs at class level so they're available for introspection. + teams: List[ba.Team] + campaign: Optional[ba.Campaign] + lobby: ba.Lobby + min_players: int + max_players: int + players: List[ba.Player] + + def __init__(self, + depsets: Sequence[ba.DepSet], + team_names: Sequence[str] = None, + team_colors: Sequence[Sequence[float]] = None, + use_team_colors: bool = True, + min_players: int = 1, + max_players: int = 8, + allow_mid_activity_joins: bool = True): + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + """Instantiate a session. + + depsets should be a sequence of successfully resolved ba.DepSet + instances; one for each ba.Activity the session may potentially run. + """ + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + from ba._lobby import Lobby + from ba._stats import Stats + from ba._gameutils import sharedobj + from ba._gameactivity import GameActivity + from ba._team import Team + from ba._error import DependencyError + from ba._dep import Dep, AssetPackage + + print(' WOULD LOOK AT DEP SETS', depsets) + + # first off, resolve all dep-sets we were passed. + # if things are missing, we'll try to gather them into + # a single missing-deps exception if possible + # to give the caller a clean path to download missing + # stuff and try again. + missing_asset_packages: Set[str] = set() + for depset in depsets: + try: + depset.resolve() + except DependencyError as exc: + # we gather/report missing assets only; barf on anything else + if all(issubclass(d.cls, AssetPackage) for d in exc.deps): + for dep in exc.deps: + assert isinstance(dep.config, str) + missing_asset_packages.add(dep.config) + else: + missing_info = [(d.cls, d.config) for d in exc.deps] + raise Exception( + f'Missing non-asset dependencies: {missing_info}') + # throw a combined exception if we found anything missing + if missing_asset_packages: + raise DependencyError([ + Dep(AssetPackage, set_id) for set_id in missing_asset_packages + ]) + + # ok; looks like our dependencies check out. + # now give the engine a list of asset-set-ids to pass along to clients + required_asset_packages: Set[str] = set() + for depset in depsets: + required_asset_packages.update(depset.get_asset_package_ids()) + print('Would set host-session asset-reqs to:', required_asset_packages) + + if team_names is None: + team_names = ['Good Guys'] + if team_colors is None: + team_colors = [(0.6, 0.2, 1.0)] + + # First thing, wire up our internal engine data. + self._sessiondata = _ba.register_session(self) + + self.tournament_id: Optional[str] = None + + # FIXME: This stuff shouldn't be here. + self.sharedobjs: Dict[str, Any] = {} + + # TeamGameActivity uses this to display a help overlay on + # the first activity only. + self.have_shown_controls_help_overlay = False + + self.campaign = None + + # FIXME: Should be able to kill this I think. + self.campaign_state: Dict[str, str] = {} + + self._use_teams = (team_names is not None) + self._use_team_colors = use_team_colors + self._in_set_activity = False + self._allow_mid_activity_joins = allow_mid_activity_joins + + self.teams = [] + self.players = [] + self._next_team_id = 0 + self._activity_retained: Optional[ba.Activity] = None + self.launch_end_session_activity_time: Optional[float] = None + self._activity_end_timer: Optional[ba.Timer] = None + + # Hacky way to create empty weak ref; must be a better way. + class _EmptyObj: + pass + + self._activity_weak: ReferenceType[ba.Activity] + self._activity_weak = weakref.ref(_EmptyObj()) # type: ignore + + if self._activity_weak() is not None: + raise Exception("error creating empty weak ref") + + self._next_activity: Optional[ba.Activity] = None + self.wants_to_end = False + self._ending = False + self.min_players = min_players + self.max_players = max_players + + if self._use_teams: + for i, color in enumerate(team_colors): + team = Team(team_id=self._next_team_id, + name=GameActivity.get_team_display_string( + team_names[i]), + color=color) + self.teams.append(team) + self._next_team_id += 1 + + try: + with _ba.Context(self): + self.on_team_join(team) + except Exception: + from ba import _error + _error.print_exception('exception in on_team_join for', + self) + + self.lobby = Lobby() + self.stats = Stats() + + # instantiates our session globals node.. (so it can apply + # default settings) + sharedobj('globals') + + @property + def use_teams(self) -> bool: + """(internal)""" + return self._use_teams + + @property + def use_team_colors(self) -> bool: + """(internal)""" + return self._use_team_colors + + def on_player_request(self, player: ba.Player) -> bool: + """Called when a new ba.Player wants to join the Session. + + This should return True or False to accept/reject. + """ + from ba._lang import Lstr + # limit player counts *unless* we're in a stress test + if _ba.app.stress_test_reset_timer is None: + + if len(self.players) >= self.max_players: + + # print a rejection message *only* to the client trying to join + # (prevents spamming everyone else in the game) + _ba.playsound(_ba.getsound('error')) + _ba.screenmessage( + Lstr(resource='playerLimitReachedText', + subs=[('${COUNT}', str(self.max_players))]), + color=(0.8, 0.0, 0.0), + clients=[player.get_input_device().client_id], + transient=True) + return False + + _ba.playsound(_ba.getsound('dripity')) + return True + + def on_player_leave(self, player: ba.Player) -> None: + """Called when a previously-accepted ba.Player leaves the session.""" + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=cyclic-import + from ba._freeforallsession import FreeForAllSession + from ba._lang import Lstr + from ba import _error + + # remove them from the game rosters + if player in self.players: + + _ba.playsound(_ba.getsound('playerLeft')) + + team: Optional[ba.Team] + + # the player will have no team if they are still in the lobby + try: + team = player.team + except _error.TeamNotFoundError: + team = None + + activity = self._activity_weak() + + # If he had no team, he's in the lobby. + # If we have a current activity with a lobby, ask them to + # remove him. + if team is None: + with _ba.Context(self): + try: + self.lobby.remove_chooser(player) + except Exception: + _error.print_exception( + 'Error in Lobby.remove_chooser()') + + # *if* he was actually in the game, announce his departure + if team is not None: + _ba.screenmessage( + Lstr(resource='playerLeftText', + subs=[('${PLAYER}', player.get_name(full=True))])) + + # Remove him from his team and session lists. + # (he may not be on the team list since player are re-added to + # team lists every activity) + if team is not None and player in team.players: + + # testing.. can remove this eventually + if isinstance(self, FreeForAllSession): + if len(team.players) != 1: + _error.print_error("expected 1 player in FFA team") + team.players.remove(player) + + # Remove player from any current activity. + if activity is not None and player in activity.players: + activity.players.remove(player) + + # Run the activity callback unless its been expired. + if not activity.is_expired(): + try: + with _ba.Context(activity): + activity.on_player_leave(player) + except Exception: + _error.print_exception( + 'exception in on_player_leave for activity', + activity) + else: + _error.print_error("expired activity in on_player_leave;" + " shouldn't happen") + + player.set_activity(None) + player.set_node(None) + + # reset the player - this will remove its actor-ref and clear + # its calls/etc + try: + with _ba.Context(activity): + player.reset() + except Exception: + _error.print_exception( + 'exception in player.reset in' + ' on_player_leave for player', player) + + # If we're a non-team session, remove the player's team completely. + if not self._use_teams and team is not None: + + # If the team's in an activity, call its on_team_leave + # callback. + if activity is not None and team in activity.teams: + activity.teams.remove(team) + + if not activity.is_expired(): + try: + with _ba.Context(activity): + activity.on_team_leave(team) + except Exception: + _error.print_exception( + 'exception in on_team_leave for activity', + activity) + else: + _error.print_error( + "expired activity in on_player_leave p2" + "; shouldn't happen") + + # Clear the team's game-data (so dying stuff will + # have proper context). + try: + with _ba.Context(activity): + team.reset_gamedata() + except Exception: + _error.print_exception( + 'exception clearing gamedata for team:', team, + 'for player:', player, 'in activity:', activity) + + # Remove the team from the session. + self.teams.remove(team) + try: + with _ba.Context(self): + self.on_team_leave(team) + except Exception: + _error.print_exception( + 'exception in on_team_leave for session', self) + # Clear the team's session-data (so dying stuff will + # have proper context). + try: + with _ba.Context(self): + team.reset_sessiondata() + except Exception: + _error.print_exception( + 'exception clearing sessiondata for team:', team, + 'in session:', self) + + # Now remove them from the session list. + self.players.remove(player) + + else: + print('ERROR: Session.on_player_leave called' + ' for player not in our list.') + + def end(self) -> None: + """Initiates an end to the session and a return to the main menu. + + Note that this happens asynchronously, allowing the + session and its activities to shut down gracefully. + """ + self.wants_to_end = True + if self._next_activity is None: + self.launch_end_session_activity() + + def launch_end_session_activity(self) -> None: + """(internal)""" + from ba import _error + from ba._activitytypes import EndSessionActivity + from ba._enums import TimeType + with _ba.Context(self): + curtime = _ba.time(TimeType.REAL) + if self._ending: + # ignore repeats unless its been a while.. + assert self.launch_end_session_activity_time is not None + since_last = (curtime - self.launch_end_session_activity_time) + if since_last < 30.0: + return + _error.print_error( + "launch_end_session_activity called twice (since_last=" + + str(since_last) + ")") + self.launch_end_session_activity_time = curtime + self.set_activity(_ba.new_activity(EndSessionActivity)) + self.wants_to_end = False + self._ending = True # prevents further activity-mucking + + def on_team_join(self, team: ba.Team) -> None: + """Called when a new ba.Team joins the session.""" + + def on_team_leave(self, team: ba.Team) -> None: + """Called when a ba.Team is leaving the session.""" + + def _complete_end_activity(self, activity: ba.Activity, + results: Any) -> None: + # run the subclass callback in the session context + try: + with _ba.Context(self): + self.on_activity_end(activity, results) + except Exception: + from ba import _error + _error.print_exception( + 'exception in on_activity_end() for session', self, 'activity', + activity, 'with results', results) + + def end_activity(self, activity: ba.Activity, results: Any, delay: float, + force: bool) -> None: + """Commence shutdown of a ba.Activity (if not already occurring). + + 'delay' is the time delay before the Activity actually ends + (in seconds). Further calls to end() will be ignored up until + this time, unless 'force' is True, in which case the new results + will replace the old. + """ + from ba._general import Call + from ba._enums import TimeType + # only pay attention if this is coming from our current activity.. + if activity is not self._activity_retained: + return + + # if this activity hasn't begun yet, just set it up to end immediately + # once it does + if not activity.has_begun(): + activity.set_immediate_end(results, delay, force) + + # the activity has already begun; get ready to end it.. + else: + if (not activity.has_ended()) or force: + activity.set_has_ended(True) + # set a timer to set in motion this activity's demise + self._activity_end_timer = _ba.Timer( + delay, + Call(self._complete_end_activity, activity, results), + timetype=TimeType.BASE) + + def handlemessage(self, msg: Any) -> Any: + """General message handling; can be passed any message object.""" + from ba._lobby import PlayerReadyMessage + from ba._error import UNHANDLED + from ba._messages import PlayerProfilesChangedMessage + if isinstance(msg, PlayerReadyMessage): + self._on_player_ready(msg.chooser) + return None + + if isinstance(msg, PlayerProfilesChangedMessage): + # if we have a current activity with a lobby, ask it to + # reload profiles + with _ba.Context(self): + self.lobby.reload_profiles() + return None + + return UNHANDLED + + def set_activity(self, activity: ba.Activity) -> None: + """Assign a new current ba.Activity for the session. + + Note that this will not change the current context to the new + Activity's. Code must be run in the new activity's methods + (on_transition_in, etc) to get it. (so you can't do + session.set_activity(foo) and then ba.newnode() to add a node to foo) + """ + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + from ba import _error + from ba._gameutils import sharedobj + from ba._enums import TimeType + + # sanity test - make sure this doesn't get called recursively + if self._in_set_activity: + raise Exception( + "Session.set_activity() cannot be called recursively.") + + if activity.session is not _ba.getsession(): + raise Exception("provided activity's session is not current") + + # quietly ignore this if the whole session is going down + if self._ending: + return + + if activity is self._activity_retained: + _error.print_error("activity set to already-current activity") + return + + if self._next_activity is not None: + raise Exception("Activity switch already in progress (to " + + str(self._next_activity) + ")") + + self._in_set_activity = True + + prev_activity = self._activity_retained + + if prev_activity is not None: + with _ba.Context(prev_activity): + gprev = sharedobj('globals') + else: + gprev = None + + with _ba.Context(activity): + + # Now that it's going to be front and center, + # set some global values based on what the activity wants. + glb = sharedobj('globals') + glb.use_fixed_vr_overlay = activity.use_fixed_vr_overlay + glb.allow_kick_idle_players = activity.allow_kick_idle_players + if activity.inherits_slow_motion and gprev is not None: + glb.slow_motion = gprev.slow_motion + else: + glb.slow_motion = activity.slow_motion + if activity.inherits_music and gprev is not None: + glb.music_continuous = True # prevents restarting same music + glb.music = gprev.music + glb.music_count += 1 + if activity.inherits_camera_vr_offset and gprev is not None: + glb.vr_camera_offset = gprev.vr_camera_offset + if activity.inherits_vr_overlay_center and gprev is not None: + glb.vr_overlay_center = gprev.vr_overlay_center + glb.vr_overlay_center_enabled = gprev.vr_overlay_center_enabled + + # if they want to inherit tint from the previous activity.. + if activity.inherits_tint and gprev is not None: + glb.tint = gprev.tint + glb.vignette_outer = gprev.vignette_outer + glb.vignette_inner = gprev.vignette_inner + + # let the activity do its thing.. + activity.start_transition_in() + + self._next_activity = activity + + # if we have a current activity, tell it it's transitioning out; + # the next one will become current once this one dies. + if prev_activity is not None: + # pylint: disable=protected-access + prev_activity._transitioning_out = True + # pylint: enable=protected-access + + # activity will be None until the next one begins + with _ba.Context(prev_activity): + prev_activity.on_transition_out() + + # setting this to None should free up the old activity to die + # which will call begin_next_activity. + # we can still access our old activity through + # self._activity_weak() to keep it up to date on player + # joins/departures/etc until it dies + self._activity_retained = None + + # there's no existing activity; lets just go ahead with the begin call + else: + self.begin_next_activity() + + # tell the C layer that this new activity is now 'foregrounded' + # this means that its globals node controls global stuff and + # stuff like console operations, keyboard shortcuts, etc will run in it + # pylint: disable=protected-access + # noinspection PyProtectedMember + activity._activity_data.make_foreground() + # pylint: enable=protected-access + + # we want to call _destroy() for the previous activity once it should + # tear itself down, clear out any self-refs, etc. If the new activity + # has a transition-time, set it up to be called after that passes; + # otherwise call it immediately. After this call the activity should + # have no refs left to it and should die (which will trigger the next + # activity to run) + if prev_activity is not None: + if activity.transition_time > 0.0: + # FIXME: We should tweak the activity to not allow + # node-creation/etc when we call _destroy (or after). + with _ba.Context('ui'): + # pylint: disable=protected-access + # noinspection PyProtectedMember + _ba.timer(activity.transition_time, + prev_activity._destroy, + timetype=TimeType.REAL) + # Just run immediately. + else: + # noinspection PyProtectedMember + prev_activity._destroy() # pylint: disable=protected-access + self._in_set_activity = False + + def getactivity(self) -> Optional[ba.Activity]: + """Return the current foreground activity for this session.""" + return self._activity_weak() + + def get_custom_menu_entries(self) -> List[Dict[str, Any]]: + """Subclasses can override this to provide custom menu entries. + + The returned value should be a list of dicts, each containing + a 'label' and 'call' entry, with 'label' being the text for + the entry and 'call' being the callable to trigger if the entry + is pressed. + """ + return [] + + def _request_player(self, player: ba.Player) -> bool: + + # if we're ending, allow no new players + if self._ending: + return False + + # ask the user + try: + with _ba.Context(self): + result = self.on_player_request(player) + except Exception: + from ba import _error + _error.print_exception('error in on_player_request call for', self) + result = False + + # if the user said yes, add the player to the session list + if result: + self.players.append(player) + + # if we have a current activity with a lobby, + # ask it to bring up a chooser for this player. + # otherwise they'll have to wait around for the next activity. + with _ba.Context(self): + try: + self.lobby.add_chooser(player) + except Exception: + from ba import _error + _error.print_exception('exception in lobby.add_chooser()') + + return result + + def on_activity_end(self, activity: ba.Activity, results: Any) -> None: + """Called when the current ba.Activity has ended. + + The ba.Session should look at the results and start + another ba.Activity. + """ + + def begin_next_activity(self) -> None: + """Called once the previous activity has been totally torn down. + + This means we're ready to begin the next one + """ + if self._next_activity is not None: + + # we store both a weak and a strong ref to the new activity; + # the strong is to keep it alive and the weak is so we can access + # it even after we've released the strong-ref to allow it to die + self._activity_retained = self._next_activity + self._activity_weak = weakref.ref(self._next_activity) + self._next_activity = None + + # lets kick out any players sitting in the lobby since + # new activities such as score screens could cover them up; + # better to have them rejoin + self.lobby.remove_all_choosers_and_kick_players() + activity = self._activity_weak() + assert activity is not None + activity.begin(self) + + def _on_player_ready(self, chooser: ba.Chooser) -> None: + """Called when a ba.Player has checked themself ready.""" + from ba._lang import Lstr + lobby = chooser.lobby + activity = self._activity_weak() + + # in joining activities, we wait till all choosers are ready + # and then create all players at once + if activity is not None and activity.is_joining_activity: + if lobby.check_all_ready(): + choosers = lobby.get_choosers() + min_players = self.min_players + if len(choosers) >= min_players: + for lch in lobby.get_choosers(): + self._add_chosen_player(lch) + lobby.remove_all_choosers() + # get our next activity going.. + self._complete_end_activity(activity, {}) + else: + _ba.screenmessage(Lstr(resource='notEnoughPlayersText', + subs=[('${COUNT}', str(min_players)) + ]), + color=(1, 1, 0)) + _ba.playsound(_ba.getsound('error')) + else: + return + # otherwise just add players on the fly + else: + self._add_chosen_player(chooser) + lobby.remove_chooser(chooser.getplayer()) + + def _add_chosen_player(self, chooser: ba.Chooser) -> ba.Player: + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + from ba import _error + from ba._lang import Lstr + from ba._team import Team + from ba import _freeforallsession + player = chooser.getplayer() + if player not in self.players: + _error.print_error('player not found in session ' + 'player-list after chooser selection') + + activity = self._activity_weak() + assert activity is not None + + # we need to reset the player's input here, as it is currently + # referencing the chooser which could inadvertently keep it alive + player.reset_input() + + # pass it to the current activity if it has already begun + # (otherwise it'll get passed once begin is called) + pass_to_activity = (activity is not None and activity.has_begun() + and not activity.is_joining_activity) + + # if we're not allowing mid-game joins, don't pass; just announce + # the arrival + if pass_to_activity: + if not self._allow_mid_activity_joins: + pass_to_activity = False + with _ba.Context(self): + _ba.screenmessage(Lstr(resource='playerDelayedJoinText', + subs=[('${PLAYER}', + player.get_name(full=True)) + ]), + color=(0, 1, 0)) + + # if we're a non-team game, each player gets their own team + # (keeps mini-game coding simpler if we can always deal with teams) + if self._use_teams: + team = chooser.get_team() + else: + our_team_id = self._next_team_id + team = Team(team_id=our_team_id, + name=chooser.getplayer().get_name(full=True, + icon=False), + color=chooser.get_color()) + self.teams.append(team) + self._next_team_id += 1 + try: + with _ba.Context(self): + self.on_team_join(team) + except Exception: + _error.print_exception(f'exception in on_team_join for {self}') + + if pass_to_activity: + if team in activity.teams: + _error.print_error( + "Duplicate team ID in ba.Session._add_chosen_player") + activity.teams.append(team) + try: + with _ba.Context(activity): + activity.on_team_join(team) + except Exception: + _error.print_exception( + f'ERROR: exception in on_team_join for {activity}') + + player.set_data(team=team, + character=chooser.get_character_name(), + color=chooser.get_color(), + highlight=chooser.get_highlight()) + + self.stats.register_player(player) + if pass_to_activity: + if isinstance(self, _freeforallsession.FreeForAllSession): + if player.team.players: + _error.print_error("expected 0 players in FFA team") + + # Don't actually add the player to their team list if we're not + # in an activity. (players get (re)added to their team lists + # when the activity begins). + player.team.players.append(player) + if player in activity.players: + _error.print_exception( + f'Dup player in ba.Session._add_chosen_player: {player}') + else: + activity.players.append(player) + player.set_activity(activity) + pnode = activity.create_player_node(player) + player.set_node(pnode) + try: + with _ba.Context(activity): + activity.on_player_join(player) + except Exception: + _error.print_exception( + f'Error on on_player_join for {activity}') + return player diff --git a/assets/src/data/scripts/ba/_stats.py b/assets/src/data/scripts/ba/_stats.py new file mode 100644 index 00000000..c9259625 --- /dev/null +++ b/assets/src/data/scripts/ba/_stats.py @@ -0,0 +1,506 @@ +"""Functionality related to scores and statistics.""" + +from __future__ import annotations + +import random +import weakref +from typing import TYPE_CHECKING +from dataclasses import dataclass + +import _ba + +if TYPE_CHECKING: + import ba + from weakref import ReferenceType + from typing import Any, Dict, Optional, Sequence, Union + + +@dataclass +class PlayerScoredMessage: + # noinspection PyUnresolvedReferences + """Informs something that a ba.Player scored. + + Category: Message Classes + + Attributes: + + score + The score value. + """ + score: int + + +class PlayerRecord: + """Stats for an individual player in a ba.Stats object. + + Category: Gameplay Classes + + This does not necessarily correspond to a ba.Player that is + still present (stats may be retained for players that leave + mid-game) + """ + character: str + + def __init__(self, name: str, name_full: str, player: ba.Player, + stats: ba.Stats): + self.name = name + self.name_full = name_full + self.score = 0 + self.accumscore = 0 + self.kill_count = 0 + self.accum_kill_count = 0 + self.killed_count = 0 + self.accum_killed_count = 0 + self._multi_kill_timer: Optional[ba.Timer] = None + self._multikillcount = 0 + self._stats = weakref.ref(stats) + self._last_player: Optional[ba.Player] = None + self._player: Optional[ba.Player] = None + self.associate_with_player(player) + self._spaz: Optional[ReferenceType[ba.Actor]] = None + self._team: Optional[ReferenceType[ba.Team]] = None + self.streak = 0 + + @property + def team(self) -> ba.Team: + """The ba.Team the last associated player was last on. + + This can still return a valid result even if the player is gone. + Raises a ba.TeamNotFoundError if the team no longer exists. + """ + assert self._team is not None + team = self._team() + if team is None: + from ba._error import TeamNotFoundError + raise TeamNotFoundError() + return team + + @property + def player(self) -> ba.Player: + """Return the instance's associated ba.Player. + + Raises a ba.PlayerNotFoundError if the player no longer exists.""" + if not self._player: + from ba._error import PlayerNotFoundError + raise PlayerNotFoundError() + return self._player + + def get_name(self, full: bool = False) -> str: + """Return the player entry's name.""" + return self.name_full if full else self.name + + def get_icon(self) -> Dict[str, Any]: + """Get the icon for this instance's player.""" + player = self._last_player + assert player is not None + return player.get_icon() + + def get_spaz(self) -> Optional[ba.Actor]: + """Return the player entry's spaz.""" + if self._spaz is None: + return None + return self._spaz() + + def set_spaz(self, spaz: Optional[ba.Actor]) -> None: + """(internal)""" + self._spaz = weakref.ref(spaz) if spaz is not None else None + + def cancel_multi_kill_timer(self) -> None: + """Cancel any multi-kill timer for this player entry.""" + self._multi_kill_timer = None + + def getactivity(self) -> Optional[ba.Activity]: + """Return the ba.Activity this instance is currently associated with. + + Returns None if the activity no longer exists.""" + stats = self._stats() + if stats is not None: + return stats.getactivity() + return None + + def associate_with_player(self, player: ba.Player) -> None: + """Associate this entry with a ba.Player.""" + self._team = weakref.ref(player.team) + self.character = player.character + self._last_player = player + self._player = player + self._spaz = None + self.streak = 0 + + def _end_multi_kill(self) -> None: + self._multi_kill_timer = None + self._multikillcount = 0 + + def get_last_player(self) -> ba.Player: + """Return the last ba.Player we were associated with.""" + assert self._last_player is not None + return self._last_player + + def submit_kill(self, showpoints: bool = True) -> None: + """Submit a kill for this player entry.""" + # FIXME Clean this up. + # pylint: disable=too-many-statements + from ba._lang import Lstr + from ba._general import Call + from ba._enums import TimeFormat + self._multikillcount += 1 + stats = self._stats() + assert stats + if self._multikillcount == 1: + score = 0 + name = None + delay = 0 + color = (0.0, 0.0, 0.0, 1.0) + scale = 1.0 + sound = None + elif self._multikillcount == 2: + score = 20 + name = Lstr(resource='twoKillText') + color = (0.1, 1.0, 0.0, 1) + scale = 1.0 + delay = 0 + sound = stats.orchestrahitsound1 + elif self._multikillcount == 3: + score = 40 + name = Lstr(resource='threeKillText') + color = (1.0, 0.7, 0.0, 1) + scale = 1.1 + delay = 300 + sound = stats.orchestrahitsound2 + elif self._multikillcount == 4: + score = 60 + name = Lstr(resource='fourKillText') + color = (1.0, 1.0, 0.0, 1) + scale = 1.2 + delay = 600 + sound = stats.orchestrahitsound3 + elif self._multikillcount == 5: + score = 80 + name = Lstr(resource='fiveKillText') + color = (1.0, 0.5, 0.0, 1) + scale = 1.3 + delay = 900 + sound = stats.orchestrahitsound4 + else: + score = 100 + name = Lstr(resource='multiKillText', + subs=[('${COUNT}', str(self._multikillcount))]) + color = (1.0, 0.5, 0.0, 1) + scale = 1.3 + delay = 1000 + sound = stats.orchestrahitsound4 + + def _apply(name2: str, score2: int, showpoints2: bool, + color2: Sequence[float], scale2: float, + sound2: ba.Sound) -> None: + from bastd.actor.popuptext import PopupText + + # Only award this if they're still alive and we can get + # their pos. + try: + actor = self.get_spaz() + assert actor is not None + assert actor.node + our_pos = actor.node.position + except Exception: + return + + # Jitter position a bit since these often come in clusters. + our_pos = (our_pos[0] + (random.random() - 0.5) * 2.0, + our_pos[1] + (random.random() - 0.5) * 2.0, + our_pos[2] + (random.random() - 0.5) * 2.0) + activity = self.getactivity() + if activity is not None: + PopupText(Lstr( + value=(('+' + str(score2) + ' ') if showpoints2 else '') + + '${N}', + subs=[('${N}', name2)]), + color=color2, + scale=scale2, + position=our_pos).autoretain() + _ba.playsound(sound2) + + self.score += score2 + self.accumscore += score2 + + # Inform a running game of the score. + if score2 != 0 and activity is not None: + activity.handlemessage(PlayerScoredMessage(score=score2)) + + if name is not None: + _ba.timer(300 + delay, + Call(_apply, name, score, showpoints, color, scale, + sound), + timeformat=TimeFormat.MILLISECONDS) + + # Keep the tally rollin'... + # set a timer for a bit in the future. + self._multi_kill_timer = _ba.Timer(1.0, self._end_multi_kill) + + +class Stats: + """Manages scores and statistics for a ba.Session. + + category: Gameplay Classes + """ + + def __init__(self) -> None: + self._activity: Optional[ReferenceType[ba.Activity]] = None + self._player_records: Dict[str, PlayerRecord] = {} + self.orchestrahitsound1: Optional[ba.Sound] = None + self.orchestrahitsound2: Optional[ba.Sound] = None + self.orchestrahitsound3: Optional[ba.Sound] = None + self.orchestrahitsound4: Optional[ba.Sound] = None + + def set_activity(self, activity: ba.Activity) -> None: + """Set the current activity for this instance.""" + + self._activity = None if activity is None else weakref.ref(activity) + + # Load our media into this activity's context. + if activity is not None: + if activity.is_expired(): + from ba import _error + _error.print_error('unexpected finalized activity') + else: + with _ba.Context(activity): + self._load_activity_media() + + def getactivity(self) -> Optional[ba.Activity]: + """Get the activity associated with this instance. + + May return None. + """ + if self._activity is None: + return None + return self._activity() + + def _load_activity_media(self) -> None: + self.orchestrahitsound1 = _ba.getsound('orchestraHit') + self.orchestrahitsound2 = _ba.getsound('orchestraHit2') + self.orchestrahitsound3 = _ba.getsound('orchestraHit3') + self.orchestrahitsound4 = _ba.getsound('orchestraHit4') + + def reset(self) -> None: + """Reset the stats instance completely.""" + # Just to be safe, lets make sure no multi-kill timers are gonna go off + # for no-longer-on-the-list players. + for p_entry in list(self._player_records.values()): + p_entry.cancel_multi_kill_timer() + self._player_records = {} + + def reset_accum(self) -> None: + """Reset per-sound sub-scores.""" + for s_player in list(self._player_records.values()): + s_player.cancel_multi_kill_timer() + s_player.accumscore = 0 + s_player.accum_kill_count = 0 + s_player.accum_killed_count = 0 + s_player.streak = 0 + + def register_player(self, player: ba.Player) -> None: + """Register a player with this score-set.""" + name = player.get_name() + name_full = player.get_name(full=True) + try: + # If the player already exists, update his character and such as + # it may have changed. + self._player_records[name].associate_with_player(player) + except Exception: + # FIXME: Shouldn't use top level Exception catch for logic. + # Should only have this as a fallback and always log it. + self._player_records[name] = PlayerRecord(name, name_full, player, + self) + + def get_records(self) -> Dict[str, ba.PlayerRecord]: + """Get PlayerRecord corresponding to still-existing players.""" + records = {} + + # Go through our player records and return ones whose player id still + # corresponds to a player with that name. + for record_id, record in self._player_records.items(): + lastplayer = record.get_last_player() + if lastplayer and lastplayer.get_name() == record_id: + records[record_id] = record + return records + + def _get_spaz(self, player: ba.Player) -> Optional[ba.Actor]: + return self._player_records[player.get_name()].get_spaz() + + def player_got_new_spaz(self, player: ba.Player, spaz: ba.Actor) -> None: + """Call this when a player gets a new Spaz.""" + record = self._player_records[player.get_name()] + if record.get_spaz() is not None: + raise Exception("got 2 player_got_new_spaz() messages in a row" + " without a lost-spaz message") + record.set_spaz(spaz) + + def player_got_hit(self, player: ba.Player) -> None: + """Call this when a player got hit.""" + s_player = self._player_records[player.get_name()] + s_player.streak = 0 + + def player_scored(self, + player: ba.Player, + base_points: int = 1, + target: Sequence[float] = None, + kill: bool = False, + victim_player: ba.Player = None, + scale: float = 1.0, + color: Sequence[float] = None, + title: Union[str, ba.Lstr] = None, + screenmessage: bool = True, + display: bool = True, + importance: int = 1, + showpoints: bool = True, + big_message: bool = False) -> int: + """Register a score for the player. + + Return value is actual score with multipliers and such factored in. + """ + # FIXME: Tidy this up. + # pylint: disable=cyclic-import + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + from bastd.actor.popuptext import PopupText + from ba import _math + from ba._gameactivity import GameActivity + from ba._lang import Lstr + del victim_player # currently unused + name = player.get_name() + s_player = self._player_records[name] + + if kill: + s_player.submit_kill(showpoints=showpoints) + + display_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0) + + if color is not None: + display_color = color + elif importance != 1: + display_color = (1.0, 1.0, 0.4, 1.0) + points = base_points + + # If they want a big announcement, throw a zoom-text up there. + if display and big_message: + try: + assert self._activity is not None + activity = self._activity() + if isinstance(activity, GameActivity): + name_full = player.get_name(full=True, icon=False) + activity.show_zoom_message( + Lstr(resource='nameScoresText', + subs=[('${NAME}', name_full)]), + color=_math.normalized_color(player.team.color)) + except Exception: + from ba import _error + _error.print_exception('error showing big_message') + + # If we currently have a spaz, pop up a score over it. + if display and showpoints: + our_pos: Optional[Sequence[float]] + try: + spaz = s_player.get_spaz() + assert spaz is not None + assert spaz.node + our_pos = spaz.node.position + except Exception: + our_pos = None + if our_pos is not None: + if target is None: + target = our_pos + + # If display-pos is *way* lower than us, raise it up + # (so we can still see scores from dudes that fell off cliffs). + display_pos = (target[0], max(target[1], our_pos[1] - 2.0), + min(target[2], our_pos[2] + 2.0)) + activity = self.getactivity() + if activity is not None: + if title is not None: + sval = Lstr(value='+${A} ${B}', + subs=[('${A}', str(points)), + ('${B}', title)]) + else: + sval = Lstr(value='+${A}', + subs=[('${A}', str(points))]) + PopupText(sval, + color=display_color, + scale=1.2 * scale, + position=display_pos).autoretain() + + # Tally kills. + if kill: + s_player.accum_kill_count += 1 + s_player.kill_count += 1 + + # Report non-kill scorings. + try: + if screenmessage and not kill: + _ba.screenmessage(Lstr(resource='nameScoresText', + subs=[('${NAME}', name)]), + top=True, + color=player.color, + image=player.get_icon()) + except Exception: + from ba import _error + _error.print_exception('error announcing score') + + s_player.score += points + s_player.accumscore += points + + # Inform a running game of the score. + if points != 0: + activity = self._activity() if self._activity is not None else None + if activity is not None: + activity.handlemessage(PlayerScoredMessage(score=points)) + + return points + + def player_lost_spaz(self, + player: ba.Player, + killed: bool = False, + killer: ba.Player = None) -> None: + """Should be called when a player loses a spaz.""" + from ba._lang import Lstr + name = player.get_name() + prec = self._player_records[name] + prec.set_spaz(None) + prec.streak = 0 + if killed: + prec.accum_killed_count += 1 + prec.killed_count += 1 + try: + if killed and _ba.getactivity().announce_player_deaths: + if killer == player: + _ba.screenmessage(Lstr(resource='nameSuicideText', + subs=[('${NAME}', name)]), + top=True, + color=player.color, + image=player.get_icon()) + elif killer is not None: + if killer.team == player.team: + _ba.screenmessage(Lstr(resource='nameBetrayedText', + subs=[('${NAME}', + killer.get_name()), + ('${VICTIM}', name)]), + top=True, + color=killer.color, + image=killer.get_icon()) + else: + _ba.screenmessage(Lstr(resource='nameKilledText', + subs=[('${NAME}', + killer.get_name()), + ('${VICTIM}', name)]), + top=True, + color=killer.color, + image=killer.get_icon()) + else: + _ba.screenmessage(Lstr(resource='nameDiedText', + subs=[('${NAME}', name)]), + top=True, + color=player.color, + image=player.get_icon()) + except Exception: + from ba import _error + _error.print_exception('error announcing kill') diff --git a/assets/src/data/scripts/ba/_store.py b/assets/src/data/scripts/ba/_store.py new file mode 100644 index 00000000..5a701e36 --- /dev/null +++ b/assets/src/data/scripts/ba/_store.py @@ -0,0 +1,507 @@ +"""Store related functionality for classic mode.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Type, List, Dict, Tuple, Optional, Any + import ba + + +def get_store_item(item: str) -> Dict[str, Any]: + """(internal)""" + return get_store_items()[item] + + +def get_store_item_name_translated(item_name: str) -> ba.Lstr: + """Return a ba.Lstr for a store item name.""" + # pylint: disable=cyclic-import + from ba import _lang + from ba import _maps + item_info = get_store_item(item_name) + if item_name.startswith('characters.'): + return _lang.Lstr(translate=('characterNames', item_info['character'])) + if item_name in ['upgrades.pro', 'pro']: + return _lang.Lstr(resource='store.bombSquadProNameText', + subs=[('${APP_NAME}', + _lang.Lstr(resource='titleText'))]) + if item_name.startswith('maps.'): + map_type: Type[ba.Map] = item_info['map_type'] + return _maps.get_map_display_string(map_type.name) + if item_name.startswith('games.'): + gametype: Type[ba.GameActivity] = item_info['gametype'] + return gametype.get_display_string() + if item_name.startswith('icons.'): + return _lang.Lstr(resource='editProfileWindow.iconText') + raise Exception('unrecognized item: ' + item_name) + + +def get_store_item_display_size(item_name: str) -> Tuple[float, float]: + """(internal)""" + if item_name.startswith('characters.'): + return 340 * 0.6, 430 * 0.6 + if item_name in ['pro', 'upgrades.pro']: + return 650 * 0.9, 500 * 0.85 + if item_name.startswith('maps.'): + return 510 * 0.6, 450 * 0.6 + if item_name.startswith('icons.'): + return 265 * 0.6, 250 * 0.6 + return 450 * 0.6, 450 * 0.6 + + +def get_store_items() -> Dict[str, Dict]: + """Returns info about purchasable items. + + (internal) + """ + # pylint: disable=cyclic-import + from ba._enums import SpecialChar + from bastd import maps + if _ba.app.store_items is None: + from bastd.game import ninjafight + from bastd.game import meteorshower + from bastd.game import targetpractice + from bastd.game import easteregghunt + + # IMPORTANT - need to keep this synced with the master server. + # (doing so manually for now) + _ba.app.store_items = { + 'characters.kronk': { + 'character': 'Kronk' + }, + 'characters.zoe': { + 'character': 'Zoe' + }, + 'characters.jackmorgan': { + 'character': 'Jack Morgan' + }, + 'characters.mel': { + 'character': 'Mel' + }, + 'characters.snakeshadow': { + 'character': 'Snake Shadow' + }, + 'characters.bones': { + 'character': 'Bones' + }, + 'characters.bernard': { + 'character': 'Bernard', + 'highlight': (0.6, 0.5, 0.8) + }, + 'characters.pixie': { + 'character': 'Pixel' + }, + 'characters.wizard': { + 'character': 'Grumbledorf' + }, + 'characters.frosty': { + 'character': 'Frosty' + }, + 'characters.pascal': { + 'character': 'Pascal' + }, + 'characters.cyborg': { + 'character': 'B-9000' + }, + 'characters.agent': { + 'character': 'Agent Johnson' + }, + 'characters.taobaomascot': { + 'character': 'Taobao Mascot' + }, + 'characters.santa': { + 'character': 'Santa Claus' + }, + 'characters.bunny': { + 'character': 'Easter Bunny' + }, + 'pro': {}, + 'maps.lake_frigid': { + 'map_type': maps.LakeFrigid + }, + 'games.ninja_fight': { + 'gametype': ninjafight.NinjaFightGame, + 'previewTex': 'courtyardPreview' + }, + 'games.meteor_shower': { + 'gametype': meteorshower.MeteorShowerGame, + 'previewTex': 'rampagePreview' + }, + 'games.target_practice': { + 'gametype': targetpractice.TargetPracticeGame, + 'previewTex': 'doomShroomPreview' + }, + 'games.easter_egg_hunt': { + 'gametype': easteregghunt.EasterEggHuntGame, + 'previewTex': 'towerDPreview' + }, + 'icons.flag_us': { + 'icon': _ba.charstr(SpecialChar.FLAG_UNITED_STATES) + }, + 'icons.flag_mexico': { + 'icon': _ba.charstr(SpecialChar.FLAG_MEXICO) + }, + 'icons.flag_germany': { + 'icon': _ba.charstr(SpecialChar.FLAG_GERMANY) + }, + 'icons.flag_brazil': { + 'icon': _ba.charstr(SpecialChar.FLAG_BRAZIL) + }, + 'icons.flag_russia': { + 'icon': _ba.charstr(SpecialChar.FLAG_RUSSIA) + }, + 'icons.flag_china': { + 'icon': _ba.charstr(SpecialChar.FLAG_CHINA) + }, + 'icons.flag_uk': { + 'icon': _ba.charstr(SpecialChar.FLAG_UNITED_KINGDOM) + }, + 'icons.flag_canada': { + 'icon': _ba.charstr(SpecialChar.FLAG_CANADA) + }, + 'icons.flag_india': { + 'icon': _ba.charstr(SpecialChar.FLAG_INDIA) + }, + 'icons.flag_japan': { + 'icon': _ba.charstr(SpecialChar.FLAG_JAPAN) + }, + 'icons.flag_france': { + 'icon': _ba.charstr(SpecialChar.FLAG_FRANCE) + }, + 'icons.flag_indonesia': { + 'icon': _ba.charstr(SpecialChar.FLAG_INDONESIA) + }, + 'icons.flag_italy': { + 'icon': _ba.charstr(SpecialChar.FLAG_ITALY) + }, + 'icons.flag_south_korea': { + 'icon': _ba.charstr(SpecialChar.FLAG_SOUTH_KOREA) + }, + 'icons.flag_netherlands': { + 'icon': _ba.charstr(SpecialChar.FLAG_NETHERLANDS) + }, + 'icons.flag_uae': { + 'icon': _ba.charstr(SpecialChar.FLAG_UNITED_ARAB_EMIRATES) + }, + 'icons.flag_qatar': { + 'icon': _ba.charstr(SpecialChar.FLAG_QATAR) + }, + 'icons.flag_egypt': { + 'icon': _ba.charstr(SpecialChar.FLAG_EGYPT) + }, + 'icons.flag_kuwait': { + 'icon': _ba.charstr(SpecialChar.FLAG_KUWAIT) + }, + 'icons.flag_algeria': { + 'icon': _ba.charstr(SpecialChar.FLAG_ALGERIA) + }, + 'icons.flag_saudi_arabia': { + 'icon': _ba.charstr(SpecialChar.FLAG_SAUDI_ARABIA) + }, + 'icons.flag_malaysia': { + 'icon': _ba.charstr(SpecialChar.FLAG_MALAYSIA) + }, + 'icons.flag_czech_republic': { + 'icon': _ba.charstr(SpecialChar.FLAG_CZECH_REPUBLIC) + }, + 'icons.flag_australia': { + 'icon': _ba.charstr(SpecialChar.FLAG_AUSTRALIA) + }, + 'icons.flag_singapore': { + 'icon': _ba.charstr(SpecialChar.FLAG_SINGAPORE) + }, + 'icons.flag_iran': { + 'icon': _ba.charstr(SpecialChar.FLAG_IRAN) + }, + 'icons.flag_poland': { + 'icon': _ba.charstr(SpecialChar.FLAG_POLAND) + }, + 'icons.flag_argentina': { + 'icon': _ba.charstr(SpecialChar.FLAG_ARGENTINA) + }, + 'icons.flag_philippines': { + 'icon': _ba.charstr(SpecialChar.FLAG_PHILIPPINES) + }, + 'icons.flag_chile': { + 'icon': _ba.charstr(SpecialChar.FLAG_CHILE) + }, + 'icons.fedora': { + 'icon': _ba.charstr(SpecialChar.FEDORA) + }, + 'icons.hal': { + 'icon': _ba.charstr(SpecialChar.HAL) + }, + 'icons.crown': { + 'icon': _ba.charstr(SpecialChar.CROWN) + }, + 'icons.yinyang': { + 'icon': _ba.charstr(SpecialChar.YIN_YANG) + }, + 'icons.eyeball': { + 'icon': _ba.charstr(SpecialChar.EYE_BALL) + }, + 'icons.skull': { + 'icon': _ba.charstr(SpecialChar.SKULL) + }, + 'icons.heart': { + 'icon': _ba.charstr(SpecialChar.HEART) + }, + 'icons.dragon': { + 'icon': _ba.charstr(SpecialChar.DRAGON) + }, + 'icons.helmet': { + 'icon': _ba.charstr(SpecialChar.HELMET) + }, + 'icons.mushroom': { + 'icon': _ba.charstr(SpecialChar.MUSHROOM) + }, + 'icons.ninja_star': { + 'icon': _ba.charstr(SpecialChar.NINJA_STAR) + }, + 'icons.viking_helmet': { + 'icon': _ba.charstr(SpecialChar.VIKING_HELMET) + }, + 'icons.moon': { + 'icon': _ba.charstr(SpecialChar.MOON) + }, + 'icons.spider': { + 'icon': _ba.charstr(SpecialChar.SPIDER) + }, + 'icons.fireball': { + 'icon': _ba.charstr(SpecialChar.FIREBALL) + }, + 'icons.mikirog': { + 'icon': _ba.charstr(SpecialChar.MIKIROG) + }, + } + store_items = _ba.app.store_items + assert store_items is not None + return store_items + + +def get_store_layout() -> Dict[str, List[Dict[str, Any]]]: + """Return what's available in the store at a given time. + + Categorized by tab and by section.""" + if _ba.app.store_layout is None: + _ba.app.store_layout = { + 'characters': [{ + 'items': [] + }], + 'extras': [{ + 'items': ['pro'] + }], + 'maps': [{ + 'items': ['maps.lake_frigid'] + }], + 'minigames': [], + 'icons': [{ + 'items': [ + 'icons.mushroom', + 'icons.heart', + 'icons.eyeball', + 'icons.yinyang', + 'icons.hal', + 'icons.flag_us', + 'icons.flag_mexico', + 'icons.flag_germany', + 'icons.flag_brazil', + 'icons.flag_russia', + 'icons.flag_china', + 'icons.flag_uk', + 'icons.flag_canada', + 'icons.flag_india', + 'icons.flag_japan', + 'icons.flag_france', + 'icons.flag_indonesia', + 'icons.flag_italy', + 'icons.flag_south_korea', + 'icons.flag_netherlands', + 'icons.flag_uae', + 'icons.flag_qatar', + 'icons.flag_egypt', + 'icons.flag_kuwait', + 'icons.flag_algeria', + 'icons.flag_saudi_arabia', + 'icons.flag_malaysia', + 'icons.flag_czech_republic', + 'icons.flag_australia', + 'icons.flag_singapore', + 'icons.flag_iran', + 'icons.flag_poland', + 'icons.flag_argentina', + 'icons.flag_philippines', + 'icons.flag_chile', + 'icons.moon', + 'icons.fedora', + 'icons.spider', + 'icons.ninja_star', + 'icons.skull', + 'icons.dragon', + 'icons.viking_helmet', + 'icons.fireball', + 'icons.helmet', + 'icons.crown', + ] + }] + } + store_layout = _ba.app.store_layout + assert store_layout is not None + store_layout['characters'] = [{ + 'items': [ + 'characters.kronk', 'characters.zoe', 'characters.jackmorgan', + 'characters.mel', 'characters.snakeshadow', 'characters.bones', + 'characters.bernard', 'characters.agent', 'characters.frosty', + 'characters.pascal', 'characters.pixie' + ] + }] + store_layout['minigames'] = [{ + 'items': [ + 'games.ninja_fight', 'games.meteor_shower', 'games.target_practice' + ] + }] + if _ba.get_account_misc_read_val('xmas', False): + store_layout['characters'][0]['items'].append('characters.santa') + store_layout['characters'][0]['items'].append('characters.wizard') + store_layout['characters'][0]['items'].append('characters.cyborg') + if _ba.get_account_misc_read_val('easter', False): + store_layout['characters'].append({ + 'title': 'store.holidaySpecialText', + 'items': ['characters.bunny'] + }) + store_layout['minigames'].append({ + 'title': 'store.holidaySpecialText', + 'items': ['games.easter_egg_hunt'] + }) + return store_layout + + +def get_clean_price(price_string: str) -> str: + """(internal)""" + + # I'm not brave enough to try and do any numerical + # manipulation on formatted price strings, but lets do a + # few swap-outs to tidy things up a bit. + psubs = { + '$2.99': '$3.00', + '$4.99': '$5.00', + '$9.99': '$10.00', + '$19.99': '$20.00', + '$49.99': '$50.00' + } + return psubs.get(price_string, price_string) + + +def get_available_purchase_count(tab: str = None) -> int: + """(internal)""" + try: + if _ba.get_account_state() != 'signed_in': + return 0 + count = 0 + our_tickets = _ba.get_account_ticket_count() + store_data = get_store_layout() + if tab is not None: + tabs = [(tab, store_data[tab])] + else: + tabs = list(store_data.items()) + for tab_name, tabval in tabs: + if tab_name == 'icons': + continue # too many of these; don't show.. + count = _calc_count_for_tab(tabval, our_tickets, count) + return count + except Exception: + from ba import _error + _error.print_exception('error calcing available purchases') + return 0 + + +def _calc_count_for_tab(tabval: List[Dict[str, Any]], our_tickets: int, + count: int) -> int: + for section in tabval: + for item in section['items']: + ticket_cost = _ba.get_account_misc_read_val('price.' + item, None) + if ticket_cost is not None: + if (our_tickets >= ticket_cost + and not _ba.get_purchased(item)): + count += 1 + return count + + +def get_available_sale_time(tab: str) -> Optional[int]: + """(internal)""" + # pylint: disable=too-many-branches + # pylint: disable=too-many-nested-blocks + # pylint: disable=too-many-locals + try: + import datetime + from ba._account import have_pro + from ba._enums import TimeType, TimeFormat + app = _ba.app + sale_times: List[Optional[int]] = [] + + # Calc time for our pro sale (old special case). + if tab == 'extras': + config = app.config + if have_pro(): + return None + + # If we haven't calced/loaded start times yet. + if app.pro_sale_start_time is None: + + # If we've got a time-remaining in our config, start there. + if 'PSTR' in config: + app.pro_sale_start_time = int( + _ba.time(TimeType.REAL, TimeFormat.MILLISECONDS)) + app.pro_sale_start_val = config['PSTR'] + else: + + # We start the timer once we get the duration from + # the server. + start_duration = _ba.get_account_misc_read_val( + 'proSaleDurationMinutes', None) + if start_duration is not None: + app.pro_sale_start_time = int( + _ba.time(TimeType.REAL, TimeFormat.MILLISECONDS)) + app.pro_sale_start_val = (60000 * start_duration) + + # If we haven't heard from the server yet, no sale.. + else: + return None + + assert app.pro_sale_start_val is not None + val: Optional[int] = max( + 0, app.pro_sale_start_val - + (int(_ba.time(TimeType.REAL, TimeFormat.MILLISECONDS)) - + app.pro_sale_start_time)) + + # Keep the value in the config up to date. I suppose we should + # write the config occasionally but it should happen often enough + # for other reasons. + config['PSTR'] = val + if val == 0: + val = None + sale_times.append(val) + + # Now look for sales in this tab. + sales_raw = _ba.get_account_misc_read_val('sales', {}) + store_layout = get_store_layout() + for section in store_layout[tab]: + for item in section['items']: + if item in sales_raw: + if not _ba.get_purchased(item): + to_end = ((datetime.datetime.utcfromtimestamp( + sales_raw[item]['e']) - + datetime.datetime.utcnow()).total_seconds()) + if to_end > 0: + sale_times.append(int(to_end * 1000)) + + # Return the smallest time i guess? + return min(sale_times) if sale_times else None + + except Exception: + from ba import _error + _error.print_exception('error calcing sale time') + return None diff --git a/assets/src/data/scripts/ba/_team.py b/assets/src/data/scripts/ba/_team.py new file mode 100644 index 00000000..5dc436fb --- /dev/null +++ b/assets/src/data/scripts/ba/_team.py @@ -0,0 +1,112 @@ +"""Defines Team class.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Dict, List, Sequence, Any, Tuple, Union + import ba + + +class Team: + """A team of one or more ba.Players. + + Category: Gameplay Classes + + Note that a player *always* has a team; + in some cases, such as free-for-all ba.Sessions, + each team consists of just one ba.Player. + + Attributes: + + name + The team's name. + + color + The team's color. + + players + The list of ba.Players on the team. + + gamedata + A dict for use by the current ba.Activity + for storing data associated with this team. + This gets cleared for each new ba.Activity. + + sessiondata + A dict for use by the current ba.Session for + storing data associated with this team. + Unlike gamedata, this persists for the duration + of the session. + """ + + # Annotate our attr types at the class level so they're introspectable. + name: Union[ba.Lstr, str] + color: Tuple[float, ...] + players: List[ba.Player] + gamedata: Dict + sessiondata: Dict + + def __init__(self, + team_id: int = 0, + name: Union[ba.Lstr, str] = "", + color: Sequence[float] = (1.0, 1.0, 1.0)): + """Instantiate a ba.Team. + + In most cases, all teams are provided to you by the ba.Session, + ba.Session, so calling this shouldn't be necessary. + """ + + # TODO: Once we spin off team copies for each activity, we don't + # need to bother with trying to lock things down, since it won't + # matter at that point if the activity mucks with them. + + # Temporarily allow us to set our own attrs + # (keeps pylint happier than using __setattr__ explicitly for all). + object.__setattr__(self, '_locked', False) + self._team_id: int = team_id + self.name = name + self.color = tuple(color) + self.players = [] + self.gamedata = {} + self.sessiondata = {} + + # Now prevent further attr sets. + self._locked = True + + def get_id(self) -> int: + """Returns the numeric team ID.""" + return self._team_id + + def celebrate(self, duration: float = 10.0) -> None: + """Tells all players on the team to celebrate. + + duration is given in seconds. + """ + for player in self.players: + try: + if player.actor is not None and player.actor.node: + # Internal node-message is in milliseconds. + player.actor.node.handlemessage('celebrate', + int(duration * 1000)) + except Exception: + from ba import _error + _error.print_exception('Error on celebrate') + + def reset(self) -> None: + """(internal)""" + self.reset_gamedata() + object.__setattr__(self, 'players', []) + + def reset_gamedata(self) -> None: + """(internal)""" + object.__setattr__(self, 'gamedata', {}) + + def reset_sessiondata(self) -> None: + """(internal)""" + object.__setattr__(self, 'sessiondata', {}) + + def __setattr__(self, name: str, value: Any) -> None: + if self._locked: + raise Exception("can't set attrs on ba.Team objects") + object.__setattr__(self, name, value) diff --git a/assets/src/data/scripts/ba/_teambasesession.py b/assets/src/data/scripts/ba/_teambasesession.py new file mode 100644 index 00000000..1c3124c3 --- /dev/null +++ b/assets/src/data/scripts/ba/_teambasesession.py @@ -0,0 +1,308 @@ +"""Functionality related to teams sessions.""" +from __future__ import annotations + +import copy +import random +from typing import TYPE_CHECKING + +import _ba +from ba._session import Session + +if TYPE_CHECKING: + from typing import Optional, Any, Dict, List, Type, Sequence + import ba + +DEFAULT_TEAM_COLORS = ((0.1, 0.25, 1.0), (1.0, 0.25, 0.2)) +DEFAULT_TEAM_NAMES = ("Blue", "Red") + + +class TeamBaseSession(Session): + """Common base class for ba.TeamsSession and ba.FreeForAllSession. + + Category: Gameplay Classes + + Free-for-all-mode is essentially just teams-mode with each ba.Player having + their own ba.Team, so there is much overlap in functionality. + """ + + # These should be overridden. + _playlist_selection_var = 'UNSET Playlist Selection' + _playlist_randomize_var = 'UNSET Playlist Randomize' + _playlists_var = 'UNSET Playlists' + + def __init__(self) -> None: + """Set up playlists and launches a ba.Activity to accept joiners.""" + # pylint: disable=cyclic-import + from ba import _playlist as bsplaylist + from bastd.activity import multiteamjoinscreen + app = _ba.app + cfg = app.config + + if self._use_teams: + team_names = cfg.get('Custom Team Names', DEFAULT_TEAM_NAMES) + team_colors = cfg.get('Custom Team Colors', DEFAULT_TEAM_COLORS) + else: + team_names = None + team_colors = None + + print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.') + depsets: Sequence[ba.DepSet] = [] + super().__init__(depsets, + team_names=team_names, + team_colors=team_colors, + use_team_colors=self._use_teams, + min_players=1, + max_players=self.get_max_players()) + + self._series_length = app.teams_series_length + self._ffa_series_length = app.ffa_series_length + + show_tutorial = cfg.get('Show Tutorial', True) + + self._tutorial_activity_instance: Optional[ba.Activity] + if show_tutorial: + from bastd.tutorial import TutorialActivity + + # Get this loading. + self._tutorial_activity_instance = _ba.new_activity( + TutorialActivity) + else: + self._tutorial_activity_instance = None + + self._playlist_name = cfg.get(self._playlist_selection_var, + '__default__') + self._playlist_randomize = cfg.get(self._playlist_randomize_var, False) + + # Which game activity we're on. + self._game_number = 0 + + playlists = cfg.get(self._playlists_var, {}) + + if (self._playlist_name != '__default__' + and self._playlist_name in playlists): + # Make sure to copy this, as we muck with it in place once we've + # got it and we don't want that to affect our config. + playlist = copy.deepcopy(playlists[self._playlist_name]) + else: + if self._use_teams: + playlist = bsplaylist.get_default_teams_playlist() + else: + playlist = bsplaylist.get_default_free_for_all_playlist() + + # Resolve types and whatnot to get our final playlist. + playlist_resolved = bsplaylist.filter_playlist(playlist, + sessiontype=type(self), + add_resolved_type=True) + + if not playlist_resolved: + raise Exception("playlist contains no valid games") + + self._playlist = ShuffleList(playlist_resolved, + shuffle=self._playlist_randomize) + + # Get a game on deck ready to go. + self._current_game_spec: Optional[Dict[str, Any]] = None + self._next_game_spec: Dict[str, Any] = self._playlist.pull_next() + self._next_game: Type[ba.GameActivity] = ( + self._next_game_spec['resolved_type']) + + # Go ahead and instantiate the next game we'll + # use so it has lots of time to load. + self._instantiate_next_game() + + # Start in our custom join screen. + self.set_activity( + _ba.new_activity(multiteamjoinscreen.TeamJoiningActivity)) + + def get_ffa_series_length(self) -> int: + """Return free-for-all series length.""" + return self._ffa_series_length + + def get_series_length(self) -> int: + """Return teams series length.""" + return self._series_length + + def get_next_game_description(self) -> ba.Lstr: + """Returns a description of the next game on deck.""" + # pylint: disable=cyclic-import + from ba._gameactivity import GameActivity + gametype: Type[GameActivity] = self._next_game_spec['resolved_type'] + assert issubclass(gametype, GameActivity) + return gametype.get_config_display_string(self._next_game_spec) + + def get_game_number(self) -> int: + """Returns which game in the series is currently being played.""" + return self._game_number + + def on_team_join(self, team: ba.Team) -> None: + team.sessiondata['previous_score'] = team.sessiondata['score'] = 0 + + def get_max_players(self) -> int: + """Return max number of ba.Players allowed to join the game at once.""" + if self._use_teams: + return _ba.app.config.get('Team Game Max Players', 8) + return _ba.app.config.get('Free-for-All Max Players', 8) + + def _instantiate_next_game(self) -> None: + self._next_game_instance = _ba.new_activity( + self._next_game_spec['resolved_type'], + self._next_game_spec['settings']) + + def on_activity_end(self, activity: ba.Activity, results: Any) -> None: + # pylint: disable=cyclic-import + from ba import _error + from bastd.tutorial import TutorialActivity + from bastd.activity.multiteamendscreen import ( + TeamSeriesVictoryScoreScreenActivity) + from ba import _activitytypes + + # If we have a tutorial to show, + # that's the first thing we do no matter what. + if self._tutorial_activity_instance is not None: + self.set_activity(self._tutorial_activity_instance) + self._tutorial_activity_instance = None + + # If we're leaving the tutorial activity, + # pop a transition activity to transition + # us into a round gracefully (otherwise we'd + # snap from one terrain to another instantly). + elif isinstance(activity, TutorialActivity): + self.set_activity( + _ba.new_activity(_activitytypes.TransitionActivity)) + + # If we're in a between-round activity or a restart-activity, + # hop into a round. + elif isinstance( + activity, + (_activitytypes.JoiningActivity, _activitytypes.TransitionActivity, + _activitytypes.ScoreScreenActivity)): + + # If we're coming from a series-end activity, reset scores. + if isinstance(activity, TeamSeriesVictoryScoreScreenActivity): + self.stats.reset() + self._game_number = 0 + for team in self.teams: + team.sessiondata['score'] = 0 + # Otherwise just set accum (per-game) scores. + else: + self.stats.reset_accum() + + next_game = self._next_game_instance + + self._current_game_spec = self._next_game_spec + self._next_game_spec = self._playlist.pull_next() + self._game_number += 1 + + # Instantiate the next now so they have plenty of time to load. + self._instantiate_next_game() + + # (re)register all players and wire stats to our next activity + for player in self.players: + # ..but only ones who have been placed on a team + # (ie: no longer sitting in the lobby). + try: + has_team = (player.team is not None) + except _error.TeamNotFoundError: + has_team = False + if has_team: + self.stats.register_player(player) + self.stats.set_activity(next_game) + + # Now flip the current activity. + self.set_activity(next_game) + + # If we're leaving a round, go to the score screen. + else: + self._switch_to_score_screen(results) + + def _switch_to_score_screen(self, results: Any) -> None: + """Switch to a score screen after leaving a round.""" + from ba import _error + del results # Unused arg. + _error.print_error('this should be overridden') + + def announce_game_results(self, + activity: ba.GameActivity, + results: ba.TeamGameResults, + delay: float, + announce_winning_team: bool = True) -> None: + """Show basic game result at the end of a game. + + (before transitioning to a score screen). + This will include a zoom-text of 'BLUE WINS' + or whatnot, along with a possible audio + announcement of the same. + """ + # pylint: disable=cyclic-import + from ba import _math + from ba import _general + from ba._gameutils import cameraflash + from ba import _lang + from ba._freeforallsession import FreeForAllSession + _ba.timer(delay, + _general.Call(_ba.playsound, _ba.getsound("boxingBell"))) + if announce_winning_team: + winning_team = results.get_winning_team() + if winning_team is not None: + # Have all players celebrate. + for player in winning_team.players: + if player.actor is not None and player.actor.node: + # Note: celebrate message takes milliseconds + # for historical reasons. + player.actor.node.handlemessage('celebrate', 10000) + cameraflash() + + # Some languages say "FOO WINS" different for teams vs players. + if isinstance(self, FreeForAllSession): + wins_resource = 'winsPlayerText' + else: + wins_resource = 'winsTeamText' + wins_text = _lang.Lstr(resource=wins_resource, + subs=[('${NAME}', winning_team.name)]) + activity.show_zoom_message(wins_text, + scale=0.85, + color=_math.normalized_color( + winning_team.color)) + + +class ShuffleList: + """Smart shuffler for game playlists. + + (avoids repeats in maps or game types) + """ + + def __init__(self, items: List[Dict[str, Any]], shuffle: bool = True): + self.source_list = items + self.shuffle = shuffle + self.shuffle_list: List[Dict[str, Any]] = [] + self.last_gotten: Optional[Dict[str, Any]] = None + + def pull_next(self) -> Dict[str, Any]: + """Pull and return the next item on the shuffle-list.""" + + # Refill our list if its empty. + if not self.shuffle_list: + self.shuffle_list = list(self.source_list) + + # Ok now find an index we should pull. + index = 0 + + if self.shuffle: + for _i in range(4): + index = random.randrange(0, len(self.shuffle_list)) + test_obj = self.shuffle_list[index] + + # If the new one is the same map or game-type as the previous, + # lets try to keep looking. + if len(self.shuffle_list) > 1 and self.last_gotten is not None: + if (test_obj['settings']['map'] == + self.last_gotten['settings']['map']): + continue + if test_obj['type'] == self.last_gotten['type']: + continue + # Sufficiently different; lets go with it. + break + + obj = self.shuffle_list.pop(index) + self.last_gotten = obj + return obj diff --git a/assets/src/data/scripts/ba/_teamgame.py b/assets/src/data/scripts/ba/_teamgame.py new file mode 100644 index 00000000..494bf2cb --- /dev/null +++ b/assets/src/data/scripts/ba/_teamgame.py @@ -0,0 +1,149 @@ +"""Functionality related to team games.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +from ba._freeforallsession import FreeForAllSession +from ba._gameactivity import GameActivity +from ba._gameresults import TeamGameResults +from ba._teamssession import TeamsSession + +if TYPE_CHECKING: + from typing import Any, Dict, Type, Sequence + from bastd.actor.playerspaz import PlayerSpaz + import ba + + +class TeamGameActivity(GameActivity): + """Base class for teams and free-for-all mode games. + + Category: Gameplay Classes + + (Free-for-all is essentially just a special case where every + ba.Player has their own ba.Team) + """ + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + """ + Class method override; + returns True for ba.TeamsSessions and ba.FreeForAllSessions; + False otherwise. + """ + return (issubclass(sessiontype, TeamsSession) + or issubclass(sessiontype, FreeForAllSession)) + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings) + + # By default we don't show kill-points in free-for-all. + # (there's usually some activity-specific score and we don't + # wanna confuse things) + if isinstance(_ba.getsession(), FreeForAllSession): + self._show_kill_points = False + + def on_transition_in(self, music: str = None) -> None: + # pylint: disable=cyclic-import + from ba._coopsession import CoopSession + from bastd.actor.controlsguide import ControlsGuide + super().on_transition_in(music) + + # On the first game, show the controls UI momentarily. + # (unless we're being run in co-op mode, in which case we leave + # it up to them) + if not isinstance(self.session, CoopSession): + # FIXME: Need an elegant way to store on session. + if not self.session.have_shown_controls_help_overlay: + delay = 4.0 + lifespan = 10.0 + if self.slow_motion: + lifespan *= 0.3 + ControlsGuide(delay=delay, + lifespan=lifespan, + scale=0.8, + position=(380, 200), + bright=True).autoretain() + self.session.have_shown_controls_help_overlay = True + + def on_begin(self) -> None: + super().on_begin() + try: + # Award a few achievements. + if isinstance(self.session, FreeForAllSession): + if len(self.players) >= 2: + from ba import _achievement + _achievement.award_local_achievement('Free Loader') + elif isinstance(self.session, TeamsSession): + if len(self.players) >= 4: + from ba import _achievement + _achievement.award_local_achievement('Team Player') + except Exception: + from ba import _error + _error.print_exception() + + def spawn_player_spaz(self, + player: ba.Player, + position: Sequence[float] = None, + angle: float = None) -> PlayerSpaz: + """ + Method override; spawns and wires up a standard ba.PlayerSpaz for + a ba.Player. + + If position or angle is not supplied, a default will be chosen based + on the ba.Player and their ba.Team. + """ + if position is None: + # In teams-mode get our team-start-location. + if isinstance(self.session, TeamsSession): + position = (self.map.get_start_position(player.team.get_id())) + else: + # Otherwise do free-for-all spawn locations. + position = self.map.get_ffa_start_position(self.players) + + return super().spawn_player_spaz(player, position, angle) + + def end( # type: ignore + self, + results: Any = None, + announce_winning_team: bool = True, + announce_delay: float = 0.1, + force: bool = False) -> None: + """ + End the game and announce the single winning team + unless 'announce_winning_team' is False. + (for results without a single most-important winner). + """ + # pylint: disable=arguments-differ + from ba._coopsession import CoopSession + from ba._teambasesession import TeamBaseSession + from ba._general import Call + + # Announce win (but only for the first finish() call) + # (also don't announce in co-op sessions; we leave that up to them). + session = self.session + if not isinstance(session, CoopSession): + do_announce = not self.has_ended() + super().end(results, delay=2.0 + announce_delay, force=force) + # Need to do this *after* end end call so that results is valid. + assert isinstance(results, TeamGameResults) + if do_announce and isinstance(session, TeamBaseSession): + session.announce_game_results( + self, + results, + delay=announce_delay, + announce_winning_team=announce_winning_team) + + # For co-op we just pass this up the chain with a delay added + # (in most cases). Team games expect a delay for the announce + # portion in teams/ffa mode so this keeps it consistent. + else: + # don't want delay on restarts.. + if (isinstance(results, dict) and 'outcome' in results + and results['outcome'] == 'restart'): + delay = 0.0 + else: + delay = 2.0 + _ba.timer(0.1, Call(_ba.playsound, _ba.getsound("boxingBell"))) + super().end(results, delay=delay, force=force) diff --git a/assets/src/data/scripts/ba/_teamssession.py b/assets/src/data/scripts/ba/_teamssession.py new file mode 100644 index 00000000..729b6ede --- /dev/null +++ b/assets/src/data/scripts/ba/_teamssession.py @@ -0,0 +1,54 @@ +"""Functionality related to teams sessions.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +from ba import _teambasesession + +if TYPE_CHECKING: + import ba + + +class TeamsSession(_teambasesession.TeamBaseSession): + """ba.Session type for teams mode games. + + Category: Gameplay Classes + """ + _use_teams = True + _playlist_selection_var = 'Team Tournament Playlist Selection' + _playlist_randomize_var = 'Team Tournament Playlist Randomize' + _playlists_var = 'Team Tournament Playlists' + + def __init__(self) -> None: + _ba.increment_analytics_count('Teams session start') + super().__init__() + + def _switch_to_score_screen(self, results: ba.TeamGameResults) -> None: + # pylint: disable=cyclic-import + from bastd.activity import drawscreen + from bastd.activity import dualteamscorescreen + from bastd.activity import multiteamendscreen + winners = results.get_winners() + + # If everyone has the same score, call it a draw. + if len(winners) < 2: + self.set_activity( + _ba.new_activity(drawscreen.DrawScoreScreenActivity)) + else: + winner = winners[0].teams[0] + winner.sessiondata['score'] += 1 + + # If a team has won, show final victory screen. + if winner.sessiondata['score'] >= (self._series_length - + 1) / 2 + 1: + self.set_activity( + _ba.new_activity( + multiteamendscreen. + TeamSeriesVictoryScoreScreenActivity, + {'winner': winner})) + else: + self.set_activity( + _ba.new_activity( + dualteamscorescreen.TeamVictoryScoreScreenActivity, + {'winner': winner})) diff --git a/assets/src/data/scripts/ba/_tips.py b/assets/src/data/scripts/ba/_tips.py new file mode 100644 index 00000000..fdd71067 --- /dev/null +++ b/assets/src/data/scripts/ba/_tips.py @@ -0,0 +1,96 @@ +"""Functionality related to game tips. + +These can be shown at opportune times such as between rounds.""" + +import random +from typing import List + +import _ba + + +def get_next_tip() -> str: + """Returns the next tip to be displayed.""" + app = _ba.app + if not app.tips: + for tip in get_all_tips(): + app.tips.insert(random.randint(0, len(app.tips)), tip) + tip = app.tips.pop() + return tip + + +def get_all_tips() -> List[str]: + """Return the complete list of tips.""" + tips = [ + ('If you are short on controllers, install the \'${REMOTE_APP_NAME}\' ' + 'app\non your mobile devices to use them as controllers.'), + ('Create player profiles for yourself and your friends with\nyour ' + 'preferred names and appearances instead of using random ones.'), + ('You can \'aim\' your punches by spinning left or right.\nThis is ' + 'useful for knocking bad guys off edges or scoring in hockey.'), + ('If you pick up a curse, your only hope for survival is to\nfind a ' + 'health powerup in the next few seconds.'), + ('A perfectly timed running-jumping-spin-punch can kill in a single ' + 'hit\nand earn you lifelong respect from your friends.'), + 'Always remember to floss.', + 'Don\'t run all the time. Really. You will fall off cliffs.', + ('In Capture-the-Flag, your own flag must be at your base to score, ' + 'If the other\nteam is about to score, stealing their flag can be ' + 'a good way to stop them.'), + ('If you get a sticky-bomb stuck to you, jump around and spin in ' + 'circles. You might\nshake the bomb off, or if nothing else your ' + 'last moments will be entertaining.'), + ('You take damage when you whack your head on things,\nso try to not ' + 'whack your head on things.'), + 'If you kill an enemy in one hit you get double points for it.', + ('Despite their looks, all characters\' abilities are identical,\nso ' + 'just pick whichever one you most closely resemble.'), + 'You can throw bombs higher if you jump just before throwing.', + ('Throw strength is based on the direction you are holding.\nTo toss ' + 'something gently in front of you, don\'t hold any direction.'), + ('If someone picks you up, punch them and they\'ll let go.\nThis ' + 'works in real life too.'), + ('Don\'t get too cocky with that energy shield; you can still get ' + 'yourself thrown off a cliff.'), + ('Many things can be picked up and thrown, including other players. ' + 'Tossing\nyour enemies off cliffs can be an effective and ' + 'emotionally fulfilling strategy.'), + ('Ice bombs are not very powerful, but they freeze\nwhoever they ' + 'hit, leaving them vulnerable to shattering.'), + 'Don\'t spin for too long; you\'ll become dizzy and fall.', + ('Run back and forth before throwing a bomb\nto \'whiplash\' it ' + 'and throw it farther.'), + ('Punches do more damage the faster your fists are moving,\nso ' + 'try running, jumping, and spinning like crazy.'), + 'In hockey, you\'ll maintain more speed if you turn gradually.', + ('The head is the most vulnerable area, so a sticky-bomb\nto the ' + 'noggin usually means game-over.'), + ('Hold down any button to run. You\'ll get places faster\nbut ' + 'won\'t turn very well, so watch out for cliffs.'), + ('You can judge when a bomb is going to explode based on the\n' + 'color of sparks from its fuse: yellow..orange..red..BOOM.'), + ] + tips += [ + 'If your framerate is choppy, try turning down resolution\nor ' + 'visuals in the game\'s graphics settings.' + ] + app = _ba.app + if app.platform in ('android', 'ios') and not app.on_tv: + tips += [ + ('If your device gets too warm or you\'d like to conserve ' + 'battery power,\nturn down "Visuals" or "Resolution" ' + 'in Settings->Graphics'), + ] + if app.platform in ['mac', 'android']: + tips += [ + 'Tired of the soundtrack? Replace it with your own!' + '\nSee Settings->Audio->Soundtrack' + ] + + # Hot-plugging is currently only on some platforms. + # FIXME: Should add a platform entry for this so don't forget to update it. + if app.platform in ['mac', 'android', 'windows']: + tips += [ + 'Players can join and leave in the middle of most games,\n' + 'and you can also plug and unplug controllers on the fly.', + ] + return tips diff --git a/assets/src/data/scripts/ba/_tournament.py b/assets/src/data/scripts/ba/_tournament.py new file mode 100644 index 00000000..2baa399d --- /dev/null +++ b/assets/src/data/scripts/ba/_tournament.py @@ -0,0 +1,58 @@ +"""Functionality related to tournament play.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba + +if TYPE_CHECKING: + from typing import Dict, List, Any + + +def get_tournament_prize_strings(entry: Dict[str, Any]) -> List: + """Given a tournament entry, return strings for its prize levels.""" + # pylint: disable=too-many-locals + from ba._enums import SpecialChar + from ba._gameutils import get_trophy_string + range1 = entry.get('prizeRange1') + range2 = entry.get('prizeRange2') + range3 = entry.get('prizeRange3') + prize1 = entry.get('prize1') + prize2 = entry.get('prize2') + prize3 = entry.get('prize3') + trophy_type_1 = entry.get('prizeTrophy1') + trophy_type_2 = entry.get('prizeTrophy2') + trophy_type_3 = entry.get('prizeTrophy3') + out_vals = [] + for rng, prize, trophy_type in ((range1, prize1, trophy_type_1), + (range2, prize2, trophy_type_2), + (range3, prize3, trophy_type_3)): + prval = ('' if rng is None else ('#' + str(rng[0])) if + (rng[0] == rng[1]) else + ('#' + str(rng[0]) + '-' + str(rng[1]))) + pvval = '' + if trophy_type is not None: + pvval += get_trophy_string(trophy_type) + # trophy_chars = { + # '1': SpecialChar.TROPHY1, + # '2': SpecialChar.TROPHY2, + # '3': SpecialChar.TROPHY3, + # '0a': SpecialChar.TROPHY0A, + # '0b': SpecialChar.TROPHY0B, + # '4': SpecialChar.TROPHY4 + # } + # if trophy_type in trophy_chars: + # pvval += _bs.specialchar(trophy_chars[trophy_type]) + # else: + # from ba import err + # err.print_error( + # f"unrecognized trophy type: {trophy_type}", once=True) + # if we've got trophies but not for this entry, throw some space + # in to compensate so the ticket counts line up + if prize is not None: + pvval = _ba.charstr( + SpecialChar.TICKET_BACKING) + str(prize) + pvval + out_vals.append(prval) + out_vals.append(pvval) + return out_vals diff --git a/assets/src/data/scripts/ba/deprecated.py b/assets/src/data/scripts/ba/deprecated.py new file mode 100644 index 00000000..cb7bceeb --- /dev/null +++ b/assets/src/data/scripts/ba/deprecated.py @@ -0,0 +1,11 @@ +"""Deprecated functionality. + +Classes or functions can be relocated here when they are deprecated. +Any code using them should migrate to alternative methods, as +deprecated items will eventually be fully removed. +""" + +# pylint: disable=unused-import + +# The Lstr class should be used for all string resources. +from ba._lang import get_resource, translate diff --git a/assets/src/data/scripts/ba/internal.py b/assets/src/data/scripts/ba/internal.py new file mode 100644 index 00000000..968b7d47 --- /dev/null +++ b/assets/src/data/scripts/ba/internal.py @@ -0,0 +1,53 @@ +"""Exposed functionality not intended for full public use. + +Classes and functions contained here, while technically 'public', may change +or disappear without warning, so should be avoided (or used sparingly and +defensively) in mods. +""" + +# pylint: disable=unused-import + +from ba._maps import (get_unowned_maps, get_map_class, register_map, + preload_map_preview_media, get_map_display_string, + get_filtered_map_name) +from ba._appconfig import commit_app_config +from ba._input import (get_device_value, get_input_map_hash, + get_input_device_config) +from ba._general import getclass, json_prep, get_type_name +from ba._account import (on_account_state_changed, + handle_account_gained_tickets, have_pro_options, + have_pro, cache_tournament_info, + ensure_have_account_player_profile, + get_purchased_icons, get_cached_league_rank_data, + get_league_rank_points, cache_league_rank_data) +from ba._activitytypes import JoiningActivity, ScoreScreenActivity +from ba._achievement import (get_achievement, set_completed_achievements, + display_achievement_banner, + get_achievements_for_coop_level) +from ba._apputils import (is_browser_likely_available, get_remote_app_name, + should_submit_debug_info, show_ad) +from ba._benchmark import (run_gpu_benchmark, run_cpu_benchmark, + run_media_reload_benchmark, run_stress_test) +from ba._campaign import get_campaign +from ba._messages import PlayerProfilesChangedMessage +from ba._meta import get_game_types +from ba._modutils import show_user_scripts +from ba._teambasesession import DEFAULT_TEAM_COLORS, DEFAULT_TEAM_NAMES +from ba._music import (have_music_player, music_volume_changed, do_play_music, + get_soundtrack_entry_name, get_soundtrack_entry_type, + get_music_player, set_music_play_mode, + supports_soundtrack_entry_type, + get_valid_music_file_extensions, MacITunesMusicPlayer) +from ba._netutils import serverget, serverput, get_ip_address_type +from ba._powerup import get_default_powerup_distribution +from ba._profile import (get_player_profile_colors, get_player_profile_icon, + get_player_colors) +from ba._tips import get_next_tip +from ba._playlist import (get_default_free_for_all_playlist, + get_default_teams_playlist, filter_playlist) +from ba._store import (get_available_sale_time, get_available_purchase_count, + get_store_item_name_translated, + get_store_item_display_size, get_store_layout, + get_store_item, get_clean_price) +from ba._tournament import get_tournament_prize_strings +from ba._gameutils import get_trophy_string diff --git a/assets/src/data/scripts/ba/ui/__init__.py b/assets/src/data/scripts/ba/ui/__init__.py new file mode 100644 index 00000000..fc05bc39 --- /dev/null +++ b/assets/src/data/scripts/ba/ui/__init__.py @@ -0,0 +1,191 @@ +"""Provide top level UI related functionality.""" + +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING, cast, Type + +import _ba +from ba._enums import TimeType + +if TYPE_CHECKING: + from typing import Optional, List, Any + import ba + + +class OldWindow: + """Temp for transitioning windows over to UILocationWindows.""" + + def __init__(self, root_widget: ba.Widget): + self._root_widget = root_widget + + def get_root_widget(self) -> ba.Widget: + """Return the root widget.""" + return self._root_widget + + +class UILocation: + """Defines a specific 'place' in the UI the user can navigate to. + + Category: User Interface Classes + """ + + def __init__(self) -> None: + pass + + def save_state(self) -> None: + """Serialize this instance's state to a dict.""" + + def restore_state(self) -> None: + """Restore this instance's state from a dict.""" + + def push_location(self, location: str) -> None: + """Push a new location to the stack and transition to it.""" + + +class UILocationWindow(UILocation): + """A UILocation consisting of a single root window widget. + + Category: User Interface Classes + """ + + def __init__(self) -> None: + super().__init__() + self._root_widget: Optional[ba.Widget] = None + + def get_root_widget(self) -> ba.Widget: + """Return the root widget for this window.""" + assert self._root_widget is not None + return self._root_widget + + +class UIEntry: + """State for a UILocation on the stack.""" + + def __init__(self, name: str, controller: UIController): + self._name = name + self._state = None + self._args = None + self._instance: Optional[UILocation] = None + self._controller = weakref.ref(controller) + + def create(self) -> None: + """Create an instance of our UI.""" + cls = self._get_class() + self._instance = cls() + + def destroy(self) -> None: + """Transition out our UI if it exists.""" + if self._instance is None: + return + print('WOULD TRANSITION OUT', self._name) + + def _get_class(self) -> Type[UILocation]: + """Returns the UI class our name points to.""" + # pylint: disable=cyclic-import + + # TEMP HARD CODED - WILL REPLACE THIS WITH BA_META LOOKUPS. + if self._name == 'mainmenu': + from bastd.ui import mainmenu + return cast(Type[UILocation], mainmenu.MainMenuWindow) + raise Exception('unknown ui class ' + str(self._name)) + + +class UIController: + """Wrangles UILocations.""" + + def __init__(self) -> None: + + # FIXME: document why we have separate stacks for game and menu... + self._main_stack_game: List[UIEntry] = [] + self._main_stack_menu: List[UIEntry] = [] + + # This points at either the game or menu stack. + self._main_stack: Optional[List[UIEntry]] = None + + # There's only one of these since we don't need to preserve its state + # between sessions. + self._dialog_stack: List[UIEntry] = [] + + def show_main_menu(self, in_game: bool = True) -> None: + """Show the main menu, clearing other UIs from location stacks.""" + self._main_stack = [] + self._dialog_stack = [] + self._main_stack = (self._main_stack_game + if in_game else self._main_stack_menu) + self._main_stack.append(UIEntry('mainmenu', self)) + self._update_ui() + + def _update_ui(self) -> None: + """Instantiates the topmost ui in our stacks.""" + + # First tell any existing UIs to get outta here. + for stack in (self._dialog_stack, self._main_stack): + assert stack is not None + for entry in stack: + entry.destroy() + + # Now create the topmost one if there is one. + entrynew = (self._dialog_stack[-1] if self._dialog_stack else + self._main_stack[-1] if self._main_stack else None) + if entrynew is not None: + entrynew.create() + + +def uicleanupcheck(obj: Any, widget: ba.Widget) -> None: + """Add a check to ensure a widget-owning object gets cleaned up properly. + + Category: User Interface Functions + + This adds a check which will print an error message if the provided + object still exists ~5 seconds after the provided ba.Widget dies. + + This is a good sanity check for any sort of object that wraps or + controls a ba.Widget. For instance, a 'Window' class instance has + no reason to still exist once its root container ba.Widget has fully + transitioned out and been destroyed. Circular references or careless + strong referencing can lead to such objects never getting destroyed, + however, and this helps detect such cases to avoid memory leaks. + """ + if not isinstance(widget, _ba.Widget): + raise Exception('widget arg is not a ba.Widget') + + def foobar() -> None: + """Just testing.""" + print('FOO HERE (UICLEANUPCHECK)') + + widget.add_delete_callback(foobar) + _ba.app.uicleanupchecks.append({ + 'obj': weakref.ref(obj), + 'widget': widget, + 'widgetdeathtime': None + }) + + +def upkeep() -> None: + """Run UI cleanup checks, etc. should be called periodically.""" + app = _ba.app + remainingchecks = [] + now = _ba.time(TimeType.REAL) + for check in app.uicleanupchecks: + obj = check['obj']() + + # If the object has died, ignore and don't re-add. + if obj is None: + continue + + # If the widget hadn't died yet, note if it has. + if check['widgetdeathtime'] is None: + remainingchecks.append(check) + if not check['widget']: + check['widgetdeathtime'] = now + else: + # Widget was already dead; complain if its been too long. + if now - check['widgetdeathtime'] > 5.0: + print( + 'WARNING:', obj, + 'is still alive 5 second after its widget died;' + ' you probably have a memory leak.') + else: + remainingchecks.append(check) + app.uicleanupchecks = remainingchecks diff --git a/assets/src/data/scripts/bafoundation/__init__.py b/assets/src/data/scripts/bafoundation/__init__.py new file mode 100644 index 00000000..deeb38d0 --- /dev/null +++ b/assets/src/data/scripts/bafoundation/__init__.py @@ -0,0 +1,4 @@ +# Synced from bsmaster. +# EFRO_SYNC_HASH=47258835994253322418493299167560392753 +# +"""Functionality shared between Ballistica client and server components.""" diff --git a/assets/src/data/scripts/bafoundation/dataclassutils.py b/assets/src/data/scripts/bafoundation/dataclassutils.py new file mode 100644 index 00000000..67b3a419 --- /dev/null +++ b/assets/src/data/scripts/bafoundation/dataclassutils.py @@ -0,0 +1,34 @@ +# Synced from bsmaster. +# EFRO_SYNC_HASH=196941524992995247852512968857048418312 +# +"""Utilities for working with dataclasses.""" + +from __future__ import annotations + +# import dataclasses + +# def dataclass_from_dict(cls, data): +# print("Creating dataclass from dict", cls, data, type(cls)) +# try: +# print("FLDTYPES", [field.type for field in dataclasses.fields(cls)]) +# fieldtypes = { +# field.name: field.type +# for field in dataclasses.fields(cls) +# } +# print("GOT FIELDTYPES", fieldtypes) +# # print("GOT", cls.__name__, fieldtypes, data) +# args = { +# field: dataclass_from_dict(fieldtypes[field], data[field]) +# for field in data +# } +# print("CALCED ARGS", args) +# val = cls( +# **{ +# field: dataclass_from_dict(fieldtypes[field], data[field]) +# for field in data +# }) +# print("CREATED", val) +# return val +# except Exception as exc: +# print("GOT EXC", exc) +# return data # Not a dataclass field diff --git a/assets/src/data/scripts/bafoundation/entity/__init__.py b/assets/src/data/scripts/bafoundation/entity/__init__.py new file mode 100644 index 00000000..139ccc9c --- /dev/null +++ b/assets/src/data/scripts/bafoundation/entity/__init__.py @@ -0,0 +1,24 @@ +# Synced from bamaster. +# EFRO_SYNC_HASH=196413726588996288733581295344706442629 +# +"""Entity functionality. + +A system for defining complex data-containing types, supporting both static +and run-time type safety, serialization, efficient/sparse storage, per-field +value limits, etc. These are heavy-weight in comparison to things such as +dataclasses, but the increased features can make the overhead worth it for +certain use cases. +""" +# pylint: disable=unused-import + +from bafoundation.entity._entity import EntityMixin, Entity +from bafoundation.entity._field import (Field, CompoundField, ListField, + DictField, CompoundListField, + CompoundDictField) +from bafoundation.entity._value import ( + EnumValue, OptionalEnumValue, IntValue, OptionalIntValue, StringValue, + OptionalStringValue, BoolValue, OptionalBoolValue, FloatValue, + OptionalFloatValue, DateTimeValue, OptionalDateTimeValue, Float3Value, + CompoundValue) + +from bafoundation.entity._support import FieldInspector diff --git a/assets/src/data/scripts/bafoundation/entity/_base.py b/assets/src/data/scripts/bafoundation/entity/_base.py new file mode 100644 index 00000000..78f19255 --- /dev/null +++ b/assets/src/data/scripts/bafoundation/entity/_base.py @@ -0,0 +1,99 @@ +# Synced from bsmaster. +# EFRO_SYNC_HASH=8117567323116015157093251373970987221 +# +"""Base classes for the entity system.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + + +class DataHandler: + """Base class for anything that can wrangle entity data. + + This contains common functionality shared by Fields and Values. + """ + + def get_default_data(self) -> Any: + """Return the default internal data value for this object. + + This will be inserted when initing nonexistent entity data. + """ + raise RuntimeError(f'get_default_data() unimplemented for {self}') + + def filter_input(self, data: Any, error: bool) -> Any: + """Given arbitrary input data, return valid internal data. + + If error is True, exceptions should be thrown for any non-trivial + mismatch (more than just int vs float/etc.). Otherwise the invalid + data should be replaced with valid defaults and the problem noted + via the logging module. + The passed-in data can be modified in-place or returned as-is, or + completely new data can be returned. Compound types are responsible + for setting defaults and/or calling this recursively for their + children. Data that is not used by the field (such as orphaned values + in a dict field) can be left alone. + + Supported types for internal data are: + - anything that works with json (lists, dicts, bools, floats, ints, + strings, None) - no tuples! + - datetime.datetime objects + """ + del error # unused + return data + + def filter_output(self, data: Any) -> Any: + """Given valid internal data, return user-facing data. + + Note that entity data is expected to be filtered to correctness on + input, so if internal and extra entity data are the same type + Value types such as Vec3 may store data internally as simple float + tuples but return Vec3 objects to the user/etc. this is the mechanism + by which they do so. + """ + return data + + def prune_data(self, data: Any) -> bool: + """Prune internal data to strip out default values/etc. + + Should return a bool indicating whether root data itself can be pruned. + The object is responsible for pruning any sub-fields before returning. + """ + + +class BaseField(DataHandler): + """Base class for all field types.""" + + def __init__(self, d_key: str = None) -> None: + + # Key for this field's data in parent dict/list (when applicable; + # some fields such as the child field under a list field represent + # more than a single field entry so this is unused) + self.d_key = d_key + + def __get__(self, obj: Any, type_in: Any = None) -> Any: + if obj is None: + # when called on the type, we return the field + return self + return self.get_with_data(obj.d_data) + + def __set__(self, obj: Any, value: Any) -> None: + assert obj is not None + self.set_with_data(obj.d_data, value, error=True) + + def get_with_data(self, data: Any) -> Any: + """Get the field value given an explicit data source.""" + assert self.d_key is not None + return self.filter_output(data[self.d_key]) + + def set_with_data(self, data: Any, value: Any, error: bool) -> Any: + """Set the field value given an explicit data target. + + If error is True, exceptions should be thrown for invalid data; + otherwise the problem should be logged but corrected. + """ + assert self.d_key is not None + data[self.d_key] = self.filter_input(value, error=error) diff --git a/assets/src/data/scripts/bafoundation/entity/_entity.py b/assets/src/data/scripts/bafoundation/entity/_entity.py new file mode 100644 index 00000000..ed1cdfe3 --- /dev/null +++ b/assets/src/data/scripts/bafoundation/entity/_entity.py @@ -0,0 +1,198 @@ +# Synced from bamaster. +# EFRO_SYNC_HASH=11716656185614230313846373816308841148 +# +"""Functionality for the actual Entity types.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, TypeVar + +from bafoundation.entity._support import FieldInspector, BoundCompoundValue +from bafoundation.entity._value import CompoundValue +from bafoundation.jsonutils import ExtendedJSONEncoder, ExtendedJSONDecoder + +if TYPE_CHECKING: + from typing import Dict, Any, Type, Union + +T = TypeVar('T', bound='EntityMixin') + + +class EntityMixin: + """Mixin class to add data-storage to ComplexValue, forming an Entity. + + Distinct Entity types should inherit from this first and a CompoundValue + (sub)type second. This order ensures that constructor arguments for this + class are accessible on the new type. + """ + + def __init__(self, d_data: Dict[str, Any] = None, + error: bool = False) -> None: + super().__init__() + if not isinstance(self, CompoundValue): + raise RuntimeError('EntityMixin class must be combined' + ' with a CompoundValue class.') + + # Underlying data for this entity; fields simply operate on this. + self.d_data: Dict[str, Any] = {} + assert isinstance(self, EntityMixin) + self.set_data(d_data if d_data is not None else {}, error=error) + + def reset(self) -> None: + """Resets data to default.""" + self.set_data({}, error=True) + + def set_data(self, data: Dict, error: bool = False) -> None: + """Set the data for this entity and apply all value filters to it. + + Note that it is more efficient to pass data to an Entity's constructor + than it is to create a default Entity and then call this on it. + """ + self.d_data = data + assert isinstance(self, CompoundValue) + self.apply_fields_to_data(self.d_data, error=error) + + def copy_data(self, + target: Union[CompoundValue, BoundCompoundValue]) -> None: + """Copy data from a target Entity or compound-value. + + This first verifies that the target has a matching set of fields + and then copies its data into ourself. To copy data into a nested + compound field, the assignment operator can be used. + """ + import copy + from bafoundation.entity.util import have_matching_fields + tvalue: CompoundValue + if isinstance(target, CompoundValue): + tvalue = target + elif isinstance(target, BoundCompoundValue): + tvalue = target.d_value + else: + raise TypeError( + 'Target must be a CompoundValue or BoundCompoundValue') + target_data = getattr(target, 'd_data', None) + if target_data is None: + raise ValueError('Target is not bound to data.') + assert isinstance(self, CompoundValue) + if not have_matching_fields(self, tvalue): + raise ValueError( + f"Fields for target {type(tvalue)} do not match ours" + f" ({type(self)}); can't copy data.") + self.d_data = copy.deepcopy(target_data) + + def steal_data(self, target: EntityMixin) -> None: + """Steal data from another entity. + + This is more efficient than copy_data, as data is moved instead + of copied. However this leaves the target object in an invalid + state, and it must no longer be used after this call. + This can be convenient for entities to use to update themselves + with the result of a database transaction (which generally return + fresh entities). + """ + from bafoundation.entity.util import have_matching_fields + if not isinstance(target, EntityMixin): + raise TypeError('EntityMixin is required.') + assert isinstance(target, CompoundValue) + assert isinstance(self, CompoundValue) + if not have_matching_fields(self, target): + raise ValueError( + f"Fields for target {type(target)} do not match ours" + f" ({type(self)}); can't steal data.") + assert target.d_data is not None + self.d_data = target.d_data + target.d_data = None + + def get_pruned_data(self) -> Dict[str, Any]: + """Return a pruned version of this instance's data. + + This varies from d_data in that values may be stripped out if + they are equal to defaults (if the field allows such). + """ + import copy + data = copy.deepcopy(self.d_data) + assert isinstance(self, CompoundValue) + self.prune_fields_data(data) + return data + + def to_json_str(self, pretty: bool = False) -> str: + """Convert the entity to a json string. + + This uses bafoundation.jsontools.ExtendedJSONEncoder/Decoder + to support data types not natively storable in json. + """ + if pretty: + return json.dumps(self.d_data, + indent=2, + sort_keys=True, + cls=ExtendedJSONEncoder) + return json.dumps(self.d_data, + separators=(',', ':'), + cls=ExtendedJSONEncoder) + + @staticmethod + def json_loads(s: str) -> Any: + """Load a json string with our special extended decoder. + + Note that this simply returns loaded json data; no + Entities are involved. + """ + return json.loads(s, cls=ExtendedJSONDecoder) + + def load_from_json_str(self, s: str, error: bool = False) -> None: + """Set the entity's data in-place from a json string. + + The 'error' argument determines whether Exceptions will be raised + for invalid data values. Values will be reset/conformed to valid ones + if error is False. Note that Exceptions will always be raised + in the case of invalid formatted json. + """ + data = self.json_loads(s) + self.set_data(data, error=error) + + @classmethod + def from_json_str(cls: Type[T], s: str, error: bool = False) -> T: + """Instantiate a new instance with provided json string. + + The 'error' argument determines whether exceptions will be raised + on invalid data values. Values will be reset/conformed to valid ones + if error is False. Note that exceptions will always be raised + in the case of invalid formatted json. + """ + obj = cls(d_data=cls.json_loads(s), error=error) + return obj + + # Note: though d_fields actually returns a FieldInspector, + # in type-checking-land we currently just say it returns self. + # This allows the type-checker to at least validate subfield access, + # though the types will be incorrect (values instead of inspectors). + # This means that anything taking FieldInspectors needs to take 'Any' + # at the moment. Hopefully we can make this cleaner via a mypy + # plugin at some point. + if TYPE_CHECKING: + + @property + def d_fields(self: T) -> T: + """For accessing entity field objects (as opposed to values).""" + ... + else: + + @property + def d_fields(self): + """For accessing entity field objects (as opposed to values).""" + return FieldInspector(self, self, [], []) + + +class Entity(EntityMixin, CompoundValue): + """A data class consisting of Fields and their underlying data. + + Fields and Values simply define a data layout; Entities are concrete + objects using those layouts. + + Inherit from this class and add Fields to define a simple Entity type. + Alternately, combine an EntityMixin with any CompoundValue child class + to accomplish the same. The latter allows sharing CompoundValue + layouts between different concrete Entity types. For example, a + 'Weapon' CompoundValue could be embedded as part of a 'Character' + Entity but also exist as a distinct Entity in an armory database. + """ diff --git a/assets/src/data/scripts/bafoundation/entity/_field.py b/assets/src/data/scripts/bafoundation/entity/_field.py new file mode 100644 index 00000000..4d7d11df --- /dev/null +++ b/assets/src/data/scripts/bafoundation/entity/_field.py @@ -0,0 +1,451 @@ +# Synced from bamaster. +# EFRO_SYNC_HASH=1181984339043224435868827486253284940 +# +"""Field types for the entity system.""" + +from __future__ import annotations + +import copy +import logging +from typing import TYPE_CHECKING, Generic, TypeVar, overload + +from bafoundation.entity._support import (BaseField, BoundCompoundValue, + BoundListField, BoundDictField, + BoundCompoundListField, + BoundCompoundDictField) + +if TYPE_CHECKING: + from typing import Dict, Type, List, Any + from bafoundation.entity._value import TypedValue, CompoundValue + from bafoundation.entity._support import FieldInspector + +T = TypeVar('T') +TK = TypeVar('TK') +TC = TypeVar('TC', bound='CompoundValue') + + +class Field(BaseField, Generic[T]): + """Field consisting of a single value.""" + + def __init__(self, + d_key: str, + value: 'TypedValue[T]', + store_default: bool = False) -> None: + super().__init__(d_key) + self.d_value = value + self._store_default = store_default + + def __repr__(self) -> str: + return f'' + + def get_default_data(self) -> Any: + return self.d_value.get_default_data() + + def filter_input(self, data: Any, error: bool) -> Any: + return self.d_value.filter_input(data, error) + + def filter_output(self, data: Any) -> Any: + return self.d_value.filter_output(data) + + def prune_data(self, data: Any) -> bool: + return self.d_value.prune_data(data) + + if TYPE_CHECKING: + # Use default runtime get/set but let type-checker know our types. + # Note: we actually return a bound-field when accessed on + # a type instead of an instance, but we don't reflect that here yet + # (need to write a mypy plugin so sub-field access works first) + + def __get__(self, obj: Any, cls: Any = None) -> T: + ... + + def __set__(self, obj: Any, value: T) -> None: + ... + + +class CompoundField(BaseField, Generic[TC]): + """Field consisting of a single compound value.""" + + def __init__(self, d_key: str, value: TC, + store_default: bool = False) -> None: + super().__init__(d_key) + if __debug__ is True: + from bafoundation.entity._value import CompoundValue + assert isinstance(value, CompoundValue) + assert not hasattr(value, 'd_data') + self.d_value = value + self._store_default = store_default + + def get_default_data(self) -> dict: + return self.d_value.get_default_data() + + def filter_input(self, data: Any, error: bool) -> dict: + return self.d_value.filter_input(data, error) + + def prune_data(self, data: Any) -> bool: + return self.d_value.prune_data(data) + + # Note: + # Currently, to the type-checker we just return a simple instance + # of our CompoundValue so it can properly type-check access to its + # attrs. However at runtime we return a FieldInspector or + # BoundCompoundField which both use magic to provide the same attrs + # dynamically (but which the type-checker doesn't understand). + # Perhaps at some point we can write a mypy plugin to correct this. + if TYPE_CHECKING: + + def __get__(self, obj: Any, cls: Any = None) -> TC: + ... + + # Theoretically this type-checking may be too tight; + # we can support assigning a parent class to a child class if + # their fields match. Not sure if that'll ever come up though; + # gonna leave this for now as I prefer to have *some* checking. + # Also once we get BoundCompoundValues working with mypy we'll + # need to accept those too. + def __set__(self: CompoundField[TC], obj: Any, value: TC) -> None: + ... + + else: + + def __get__(self, obj, cls=None): + if obj is None: + # when called on the type, we return the field + return self + # (this is only ever called on entity root fields + # so no need to worry about custom d_key case) + assert self.d_key in obj.d_data + return BoundCompoundValue(self.d_value, obj.d_data[self.d_key]) + + def __set__(self, obj, value): + from bafoundation.entity._value import CompoundValue + + # Ok here's the deal: our type checking above allows any subtype + # of our CompoundValue in here, but we want to be more picky than + # that. Let's check fields for equality. This way we'll allow + # assigning something like a Carentity to a Car field + # (where the data is the same), but won't allow assigning a Car + # to a Vehicle field (as Car probably adds more fields). + value1: CompoundValue + if isinstance(value, BoundCompoundValue): + value1 = value.d_value + elif isinstance(value, CompoundValue): + value1 = value + else: + raise ValueError( + f"Can't assign from object type {type(value)}") + data = getattr(value, 'd_data', None) + if data is None: + raise ValueError(f"Can't assign from unbound object {value}") + if self.d_value.get_fields() != value1.get_fields(): + raise ValueError(f"Can't assign to {self.d_value} from" + f" incompatible type {value.d_value}; " + f"sub-fields do not match.") + + # If we're allowing this to go through, we can simply copy the + # data from the passed in value. The fields match so it should + # be in a valid state already. + obj.d_data[self.d_key] = copy.deepcopy(data) + + +class ListField(BaseField, Generic[T]): + """Field consisting of repeated values.""" + + def __init__(self, + d_key: str, + value: 'TypedValue[T]', + store_default: bool = False) -> None: + super().__init__(d_key) + self.d_value = value + self._store_default = store_default + + def get_default_data(self) -> list: + return [] + + def filter_input(self, data: Any, error: bool) -> Any: + if not isinstance(data, list): + if error: + raise TypeError('list value expected') + logging.error('Ignoring non-list data for %s: %s', self, data) + data = [] + for i, entry in enumerate(data): + data[i] = self.d_value.filter_input(entry, error=error) + return data + + def prune_data(self, data: Any) -> bool: + # We never prune individual values since that would fundamentally + # change the list, but we can prune completely if empty (and allowed). + return not data and not self._store_default + + # When accessed on a FieldInspector we return a sub-field FieldInspector. + # When accessed on an instance we return a BoundListField. + + @overload + def __get__(self, obj: None, cls: Any = None) -> FieldInspector: + ... + + @overload + def __get__(self, obj: Any, cls: Any = None) -> BoundListField[T]: + ... + + def __get__(self, obj: Any, cls: Any = None) -> Any: + if obj is None: + # When called on the type, we return the field. + return self + return BoundListField(self, obj.d_data[self.d_key]) + + if TYPE_CHECKING: + + def __set__(self, obj: Any, value: List[T]) -> None: + ... + + +class DictField(BaseField, Generic[TK, T]): + """A field of values in a dict with a specified index type.""" + + def __init__(self, + d_key: str, + keytype: Type[TK], + field: 'TypedValue[T]', + store_default: bool = False) -> None: + super().__init__(d_key) + self.d_value = field + self._store_default = store_default + self._keytype = keytype + + def get_default_data(self) -> dict: + return {} + + # noinspection DuplicatedCode + def filter_input(self, data: Any, error: bool) -> Any: + if not isinstance(data, dict): + if error: + raise TypeError('dict value expected') + logging.error('Ignoring non-dict data for %s: %s', self, data) + data = {} + data_out = {} + for key, val in data.items(): + if not isinstance(key, self._keytype): + if error: + raise TypeError('invalid key type') + logging.error('Ignoring invalid key type for %s: %s', self, + data) + continue + data_out[key] = self.d_value.filter_input(val, error=error) + return data_out + + def prune_data(self, data: Any) -> bool: + # We never prune individual values since that would fundamentally + # change the dict, but we can prune completely if empty (and allowed) + return not data and not self._store_default + + @overload + def __get__(self, obj: None, cls: Any = None) -> DictField[TK, T]: + ... + + @overload + def __get__(self, obj: Any, cls: Any = None) -> BoundDictField[TK, T]: + ... + + def __get__(self, obj: Any, cls: Any = None) -> Any: + if obj is None: + # When called on the type, we return the field. + return self + return BoundDictField(self._keytype, self, obj.d_data[self.d_key]) + + if TYPE_CHECKING: + + def __set__(self, obj: Any, value: Dict[TK, T]) -> None: + ... + + +class CompoundListField(BaseField, Generic[TC]): + """A field consisting of repeated instances of a compound-value. + + Element access returns the sub-field, allowing nested field access. + ie: mylist[10].fieldattr = 'foo' + """ + + def __init__(self, d_key: str, valuetype: TC, + store_default: bool = False) -> None: + super().__init__(d_key) + self.d_value = valuetype + + # This doesnt actually exist for us, but want the type-checker + # to think it does (see TYPE_CHECKING note below). + self.d_data: Any + self._store_default = store_default + + def filter_input(self, data: Any, error: bool) -> list: + if not isinstance(data, list): + if error: + raise TypeError('list value expected') + logging.error('Ignoring non-list data for %s: %s', self, data) + data = [] + assert isinstance(data, list) + + # Ok we've got a list; now run everything in it through validation. + for i, subdata in enumerate(data): + data[i] = self.d_value.filter_input(subdata, error=error) + return data + + def get_default_data(self) -> list: + return [] + + def prune_data(self, data: Any) -> bool: + # Run pruning on all individual entries' data through out child field. + # However we don't *completely* prune values from the list since that + # would change it. + for subdata in data: + self.d_value.prune_fields_data(subdata) + + # We can also optionally prune the whole list if empty and allowed. + return not data and not self._store_default + + @overload + def __get__(self, obj: None, cls: Any = None) -> CompoundListField[TC]: + ... + + @overload + def __get__(self, obj: Any, cls: Any = None) -> BoundCompoundListField[TC]: + ... + + def __get__(self, obj: Any, cls: Any = None) -> Any: + # On access we simply provide a version of ourself + # bound to our corresponding sub-data. + if obj is None: + # when called on the type, we return the field + return self + assert self.d_key in obj.d_data + return BoundCompoundListField(self, obj.d_data[self.d_key]) + + # Note: + # When setting the list, we tell the type-checker that we accept + # a raw list of CompoundValue objects, but at runtime we actually + # deal with BoundCompoundValue objects (see note in BoundCompoundListField) + if TYPE_CHECKING: + + def __set__(self, obj: Any, value: List[TC]) -> None: + ... + + else: + + def __set__(self, obj, value): + if not isinstance(value, list): + raise TypeError( + 'CompoundListField expected list value on set.') + + # Allow assigning only from a sequence of our existing children. + # (could look into expanding this to other children if we can + # be sure the underlying data will line up; for example two + # CompoundListFields with different child_field values should not + # be inter-assignable. + if (not all(isinstance(i, BoundCompoundValue) for i in value) + or not all(i.d_value is self.d_value for i in value)): + raise ValueError('CompoundListField assignment must be a ' + 'list containing only its existing children.') + obj.d_data[self.d_key] = [i.d_data for i in value] + + +class CompoundDictField(BaseField, Generic[TK, TC]): + """A field consisting of key-indexed instances of a compound-value. + + Element access returns the sub-field, allowing nested field access. + ie: mylist[10].fieldattr = 'foo' + """ + + def __init__(self, + d_key: str, + keytype: Type[TK], + valuetype: TC, + store_default: bool = False) -> None: + super().__init__(d_key) + self.d_value = valuetype + + # This doesnt actually exist for us, but want the type-checker + # to think it does (see TYPE_CHECKING note below). + self.d_data: Any + self.d_keytype = keytype + self._store_default = store_default + + # noinspection DuplicatedCode + def filter_input(self, data: Any, error: bool) -> dict: + if not isinstance(data, dict): + if error: + raise TypeError('dict value expected') + logging.error('Ignoring non-dict data for %s: %s', self, data) + data = {} + data_out = {} + for key, val in data.items(): + if not isinstance(key, self.d_keytype): + if error: + raise TypeError('invalid key type') + logging.error('Ignoring invalid key type for %s: %s', self, + data) + continue + data_out[key] = self.d_value.filter_input(val, error=error) + return data_out + + def get_default_data(self) -> dict: + return {} + + def prune_data(self, data: Any) -> bool: + # Run pruning on all individual entries' data through our child field. + # However we don't *completely* prune values from the list since that + # would change it. + for subdata in data.values(): + self.d_value.prune_fields_data(subdata) + + # We can also optionally prune the whole list if empty and allowed. + return not data and not self._store_default + + @overload + def __get__(self, obj: None, cls: Any = None) -> CompoundDictField[TK, TC]: + ... + + @overload + def __get__(self, obj: Any, + cls: Any = None) -> BoundCompoundDictField[TK, TC]: + ... + + def __get__(self, obj: Any, cls: Any = None) -> Any: + # On access we simply provide a version of ourself + # bound to our corresponding sub-data. + if obj is None: + # when called on the type, we return the field + return self + assert self.d_key in obj.d_data + return BoundCompoundDictField(self, obj.d_data[self.d_key]) + + # In the type-checker's eyes we take CompoundValues but at runtime + # we actually take BoundCompoundValues (see note in BoundCompoundDictField) + if TYPE_CHECKING: + + def __set__(self, obj: Any, value: Dict[TK, TC]) -> None: + ... + + else: + + def __set__(self, obj, value): + if not isinstance(value, dict): + raise TypeError( + 'CompoundDictField expected dict value on set.') + + # Allow assigning only from a sequence of our existing children. + # (could look into expanding this to other children if we can + # be sure the underlying data will line up; for example two + # CompoundListFields with different child_field values should not + # be inter-assignable. + print('val', value) + if (not all(isinstance(i, self.d_keytype) for i in value.keys()) + or not all( + isinstance(i, BoundCompoundValue) + for i in value.values()) + or not all(i.d_value is self.d_value + for i in value.values())): + raise ValueError('CompoundDictField assignment must be a ' + 'dict containing only its existing children.') + obj.d_data[self.d_key] = { + key: val.d_data + for key, val in value.items() + } diff --git a/assets/src/data/scripts/bafoundation/entity/_support.py b/assets/src/data/scripts/bafoundation/entity/_support.py new file mode 100644 index 00000000..a00fc8b7 --- /dev/null +++ b/assets/src/data/scripts/bafoundation/entity/_support.py @@ -0,0 +1,448 @@ +# Synced from bamaster. +# EFRO_SYNC_HASH=207162478257782519026483356805664558659 +# +"""Various support classes for accessing data and info on fields and values.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar, Generic, overload + +from bafoundation.entity._base import BaseField + +if TYPE_CHECKING: + from typing import (Optional, Tuple, Type, Any, Dict, List, Union) + from bafoundation.entity._value import CompoundValue + from bafoundation.entity._field import (ListField, DictField, + CompoundListField, + CompoundDictField) + +T = TypeVar('T') +TK = TypeVar('TK') +TC = TypeVar('TC', bound='CompoundValue') +TBL = TypeVar('TBL', bound='BoundCompoundListField') + + +class BoundCompoundValue: + """Wraps a CompoundValue object and its entity data. + + Allows access to its values through our own equivalent attributes. + """ + + def __init__(self, value: CompoundValue, + d_data: Union[List[Any], Dict[str, Any]]): + self.d_value: CompoundValue + self.d_data: Union[List[Any], Dict[str, Any]] + # need to use base setters to avoid triggering our own overrides + object.__setattr__(self, 'd_value', value) + object.__setattr__(self, 'd_data', d_data) + + def __eq__(self, other: Any) -> Any: + # allow comparing to compound and bound-compound objects + from bafoundation.entity.util import compound_eq + return compound_eq(self, other) + + def __getattr__(self, name: str, default: Any = None) -> Any: + # if this attribute corresponds to a field on our compound value's + # unbound type, ask it to give us a value using our data + field = getattr(type(object.__getattribute__(self, 'd_value')), name, + None) + if isinstance(field, BaseField): + return field.get_with_data(self.d_data) + raise AttributeError + + def __setattr__(self, name: str, value: Any) -> None: + # same deal as __getattr__ basically + field = getattr(type(object.__getattribute__(self, 'd_value')), name, + None) + if isinstance(field, BaseField): + field.set_with_data(self.d_data, value, error=True) + return + super().__setattr__(name, value) + + def reset(self) -> None: + """Reset this field's data to defaults.""" + value = object.__getattribute__(self, 'd_value') + data = object.__getattribute__(self, 'd_data') + assert isinstance(data, dict) + # Need to clear our dict in-place since we have no + # access to our parent which we'd need to assign an empty one. + data.clear() + # now fill in default data + value.apply_fields_to_data(data, error=True) + + def __repr__(self) -> str: + fstrs: List[str] = [] + for field in self.d_value.get_fields(): + try: + fstrs.append(str(field) + '=' + repr(getattr(self, field))) + except Exception: + fstrs.append('FAIL' + str(field) + ' ' + str(type(self))) + return type(self.d_value).__name__ + '(' + ', '.join(fstrs) + ')' + + +class FieldInspector: + """Used for inspecting fields.""" + + def __init__(self, root: Any, obj: Any, path: List[str], + dbpath: List[str]) -> None: + self._root = root + self._obj = obj + self._path = path + self._dbpath = dbpath + + def __repr__(self) -> str: + path = '.'.join(self._path) + typename = type(self._root).__name__ + if path == '': + return f'' + return f'' + + def __getattr__(self, name: str, default: Any = None) -> Any: + # pylint: disable=cyclic-import + from bafoundation.entity._field import CompoundField + + # If this attribute corresponds to a field on our obj's + # unbound type, return a new inspector for it. + if isinstance(self._obj, CompoundField): + target = self._obj.d_value + else: + target = self._obj + field = getattr(type(target), name, None) + if isinstance(field, BaseField): + newpath = list(self._path) + newpath.append(name) + newdbpath = list(self._dbpath) + assert field.d_key is not None + newdbpath.append(field.d_key) + return FieldInspector(self._root, field, newpath, newdbpath) + raise AttributeError + + def get_root(self) -> Any: + """Return the root object this inspector is targeting.""" + return self._root + + def get_path(self) -> List[str]: + """Return the python path components of this inspector.""" + return self._path + + def get_db_path(self) -> List[str]: + """Return the database path components of this inspector.""" + return self._dbpath + + +class BoundListField(Generic[T]): + """ListField bound to data; used for accessing field values.""" + + def __init__(self, field: ListField[T], d_data: List[Any]): + self.d_field = field + assert isinstance(d_data, list) + self.d_data = d_data + self._i = 0 + + def __eq__(self, other: Any) -> Any: + # just convert us into a regular list and run a compare with that + flattened = [ + self.d_field.d_value.filter_output(value) for value in self.d_data + ] + return flattened == other + + def __repr__(self) -> str: + return '[' + ', '.join( + repr(self.d_field.d_value.filter_output(i)) + for i in self.d_data) + ']' + + def __len__(self) -> int: + return len(self.d_data) + + def __iter__(self) -> Any: + self._i = 0 + return self + + def append(self, val: T) -> None: + """Append the provided value to the list.""" + self.d_data.append(self.d_field.d_value.filter_input(val, error=True)) + + def __next__(self) -> T: + if self._i < len(self.d_data): + self._i += 1 + val: T = self.d_field.d_value.filter_output(self.d_data[self._i - + 1]) + return val + raise StopIteration + + @overload + def __getitem__(self, key: int) -> T: + ... + + @overload + def __getitem__(self, key: slice) -> List[T]: + ... + + def __getitem__(self, key: Any) -> Any: + if isinstance(key, slice): + dofilter = self.d_field.d_value.filter_output + return [ + dofilter(self.d_data[i]) + for i in range(*key.indices(len(self))) + ] + assert isinstance(key, int) + return self.d_field.d_value.filter_output(self.d_data[key]) + + def __setitem__(self, key: int, value: T) -> None: + if not isinstance(key, int): + raise TypeError("Expected int index.") + self.d_data[key] = self.d_field.d_value.filter_input(value, error=True) + + +class BoundDictField(Generic[TK, T]): + """DictField bound to its data; used for accessing its values.""" + + def __init__(self, keytype: Type[TK], field: DictField[TK, T], + d_data: Dict[TK, T]): + self._keytype = keytype + self.d_field = field + assert isinstance(d_data, dict) + self.d_data = d_data + + def __eq__(self, other: Any) -> Any: + # just convert us into a regular dict and run a compare with that + flattened = { + key: self.d_field.d_value.filter_output(value) + for key, value in self.d_data.items() + } + return flattened == other + + def __repr__(self) -> str: + return '{' + ', '.join( + repr(key) + ': ' + repr(self.d_field.d_value.filter_output(val)) + for key, val in self.d_data.items()) + '}' + + def __len__(self) -> int: + return len(self.d_data) + + def __getitem__(self, key: TK) -> T: + if not isinstance(key, self._keytype): + raise TypeError( + f'Invalid key type {type(key)}; expected {self._keytype}') + assert isinstance(key, self._keytype) + typedval: T = self.d_field.d_value.filter_output(self.d_data[key]) + return typedval + + def get(self, key: TK, default: Optional[T] = None) -> Optional[T]: + """Get a value if present, or a default otherwise.""" + if not isinstance(key, self._keytype): + raise TypeError( + f'Invalid key type {type(key)}; expected {self._keytype}') + assert isinstance(key, self._keytype) + if key not in self.d_data: + return default + typedval: T = self.d_field.d_value.filter_output(self.d_data[key]) + return typedval + + def __setitem__(self, key: TK, value: T) -> None: + if not isinstance(key, self._keytype): + raise TypeError("Expected str index.") + self.d_data[key] = self.d_field.d_value.filter_input(value, error=True) + + def __contains__(self, key: TK) -> bool: + return key in self.d_data + + def __delitem__(self, key: TK) -> None: + del self.d_data[key] + + def keys(self) -> List[TK]: + """Return a list of our keys.""" + return list(self.d_data.keys()) + + def values(self) -> List[T]: + """Return a list of our values.""" + return [ + self.d_field.d_value.filter_output(value) + for value in self.d_data.values() + ] + + def items(self) -> List[Tuple[TK, T]]: + """Return a list of item/value pairs.""" + return [(key, self.d_field.d_value.filter_output(value)) + for key, value in self.d_data.items()] + + +class BoundCompoundListField(Generic[TC]): + """A CompoundListField bound to its entity sub-data.""" + + def __init__(self, field: CompoundListField[TC], d_data: List[Any]): + self.d_field = field + self.d_data = d_data + self._i = 0 + + def __eq__(self, other: Any) -> Any: + from bafoundation.entity.util import have_matching_fields + + # We can only be compared to other bound-compound-fields + if not isinstance(other, BoundCompoundListField): + return NotImplemented + + # If our compound values have differing fields, we're unequal. + if not have_matching_fields(self.d_field.d_value, + other.d_field.d_value): + return False + + # Ok our data schemas match; now just compare our data.. + return self.d_data == other.d_data + + def __len__(self) -> int: + return len(self.d_data) + + def __repr__(self) -> str: + return '[' + ', '.join( + repr(BoundCompoundValue(self.d_field.d_value, i)) + for i in self.d_data) + ']' + + # Note: to the type checker our gets/sets simply deal with CompoundValue + # objects so the type-checker can cleanly handle their sub-fields. + # However at runtime we deal in BoundCompoundValue objects which use magic + # to tie the CompoundValue object to its data but which the type checker + # can't understand. + if TYPE_CHECKING: + + @overload + def __getitem__(self, key: int) -> TC: + ... + + @overload + def __getitem__(self, key: slice) -> List[TC]: + ... + + def __getitem__(self, key: Any) -> Any: + ... + + def __next__(self) -> TC: + ... + + def append(self) -> TC: + """Append and return a new field entry to the array.""" + ... + else: + + def __getitem__(self, key: Any) -> Any: + if isinstance(key, slice): + return [ + BoundCompoundValue(self.d_field.d_value, self.d_data[i]) + for i in range(*key.indices(len(self))) + ] + assert isinstance(key, int) + return BoundCompoundValue(self.d_field.d_value, self.d_data[key]) + + def __next__(self): + if self._i < len(self.d_data): + self._i += 1 + return BoundCompoundValue(self.d_field.d_value, + self.d_data[self._i - 1]) + raise StopIteration + + def append(self) -> Any: + """Append and return a new field entry to the array.""" + # push the entity default into data and then let it fill in + # any children/etc. + self.d_data.append( + self.d_field.d_value.filter_input( + self.d_field.d_value.get_default_data(), error=True)) + return BoundCompoundValue(self.d_field.d_value, self.d_data[-1]) + + def __iter__(self: TBL) -> TBL: + self._i = 0 + return self + + +class BoundCompoundDictField(Generic[TK, TC]): + """A CompoundDictField bound to its entity sub-data.""" + + def __init__(self, field: CompoundDictField[TK, TC], + d_data: Dict[Any, Any]): + self.d_field = field + self.d_data = d_data + + def __eq__(self, other: Any) -> Any: + from bafoundation.entity.util import have_matching_fields + + # We can only be compared to other bound-compound-fields + if not isinstance(other, BoundCompoundDictField): + return NotImplemented + + # If our compound values have differing fields, we're unequal. + if not have_matching_fields(self.d_field.d_value, + other.d_field.d_value): + return False + + # Ok our data schemas match; now just compare our data.. + return self.d_data == other.d_data + + def __repr__(self) -> str: + return '{' + ', '.join( + repr(key) + ': ' + + repr(BoundCompoundValue(self.d_field.d_value, value)) + for key, value in self.d_data.items()) + '}' + + # In the typechecker's eyes, gets/sets on us simply deal in + # CompoundValue object. This allows type-checking to work nicely + # for its sub-fields. + # However in real-life we return BoundCompoundValues which use magic + # to tie the CompoundValue to its data (but which the typechecker + # would not be able to make sense of) + if TYPE_CHECKING: + + def __getitem__(self, key: TK) -> TC: + pass + + def values(self) -> List[TC]: + """Return a list of our values.""" + + def items(self) -> List[Tuple[TK, TC]]: + """Return key/value pairs for all dict entries.""" + + def add(self, key: TK) -> TC: + """Add an entry into the dict, returning it. + + Any existing value is replaced.""" + + else: + + def __getitem__(self, key): + return BoundCompoundValue(self.d_field.d_value, self.d_data[key]) + + def values(self): + """Return a list of our values.""" + return list( + BoundCompoundValue(self.d_field.d_value, i) + for i in self.d_data.values()) + + def items(self): + """Return key/value pairs for all dict entries.""" + return [(key, BoundCompoundValue(self.d_field.d_value, value)) + for key, value in self.d_data.items()] + + def add(self, key: TK) -> TC: + """Add an entry into the dict, returning it. + + Any existing value is replaced.""" + if not isinstance(key, self.d_field.d_keytype): + raise TypeError(f'expected key type {self.d_field.d_keytype};' + f' got {type(key)}') + # push the entity default into data and then let it fill in + # any children/etc. + self.d_data[key] = (self.d_field.d_value.filter_input( + self.d_field.d_value.get_default_data(), error=True)) + return BoundCompoundValue(self.d_field.d_value, self.d_data[key]) + + def __len__(self) -> int: + return len(self.d_data) + + def __contains__(self, key: TK) -> bool: + return key in self.d_data + + def __delitem__(self, key: TK) -> None: + del self.d_data[key] + + def keys(self) -> List[TK]: + """Return a list of our keys.""" + return list(self.d_data.keys()) diff --git a/assets/src/data/scripts/bafoundation/entity/_value.py b/assets/src/data/scripts/bafoundation/entity/_value.py new file mode 100644 index 00000000..bbb152ec --- /dev/null +++ b/assets/src/data/scripts/bafoundation/entity/_value.py @@ -0,0 +1,531 @@ +# Synced from bsmaster. +# EFRO_SYNC_HASH=158385720566816709798128360485086830759 +# +"""Value types for the entity system.""" + +from __future__ import annotations + +import datetime +import inspect +import logging +from collections import abc +from enum import Enum +from typing import TYPE_CHECKING, TypeVar, Tuple, Optional, Generic + +from bafoundation.entity._base import DataHandler, BaseField +from bafoundation.entity.util import compound_eq + +if TYPE_CHECKING: + from typing import Optional, Set, List, Dict, Any, Type + +T = TypeVar('T') +TE = TypeVar('TE', bound=Enum) + +_sanity_tested_types: Set[Type] = set() +_type_field_cache: Dict[Type, Dict[str, BaseField]] = {} + + +class TypedValue(DataHandler, Generic[T]): + """Base class for all value types dealing with a single data type.""" + + +class SimpleValue(TypedValue[T]): + """Standard base class for simple single-value types. + + This class provides enough functionality to handle most simple + types such as int/float/etc without too many subclass overrides. + """ + + def __init__(self, + default: T, + store_default: bool, + target_type: Type = None, + convert_source_types: Tuple[Type, ...] = (), + allow_none: bool = False) -> None: + """Init the value field. + + If store_default is False, the field value will not be included + in final entity data if it is a default value. Be sure to set + this to True for any fields that will be used for server-side + queries so they are included in indexing. + target_type and convert_source_types are used in the default + filter_input implementation; if passed in data's type is present + in convert_source_types, a target_type will be instantiated + using it. (allows for simple conversions to bool, int, etc) + Data will also be allowed through untouched if it matches target_type. + (types needing further introspection should override filter_input). + Lastly, the value of allow_none is also used in filter_input for + whether values of None should be allowed. + """ + super().__init__() + + self._store_default = store_default + self._target_type = target_type + self._convert_source_types = convert_source_types + self._allow_none = allow_none + + # We store _default_data in our internal data format so need + # to run user-facing value through our input filter. + # Make sure we do this last since filter_input depends on above vals. + self._default_data: T = self.filter_input(default, error=True) + + def __repr__(self) -> str: + if self._target_type is not None: + return f'' + return f'' + + def get_default_data(self) -> Any: + return self._default_data + + def prune_data(self, data: Any) -> bool: + return not self._store_default and data == self._default_data + + def filter_input(self, data: Any, error: bool) -> Any: + + # Let data pass through untouched if its already our target type + if self._target_type is not None: + if isinstance(data, self._target_type): + return data + + # ...and also if its None and we're into that sort of thing. + if self._allow_none and data is None: + return data + + # If its one of our convertible types, convert. + if (self._convert_source_types + and isinstance(data, self._convert_source_types)): + assert self._target_type is not None + return self._target_type(data) + if error: + errmsg = (f'value of type {self._target_type} or None expected' + if self._allow_none else + f'value of type {self._target_type} expected') + errmsg += f'; got {type(data)}' + raise TypeError(errmsg) + errmsg = f'Ignoring incompatible data for {self};' + errmsg += (f' expected {self._target_type} or None;' + if self._allow_none else f'expected {self._target_type};') + errmsg += f' got {type(data)}' + logging.error(errmsg) + return self.get_default_data() + + +class StringValue(SimpleValue[str]): + """Value consisting of a single string.""" + + def __init__(self, default: str = "", store_default: bool = False) -> None: + super().__init__(default, store_default, str) + + +class OptionalStringValue(SimpleValue[Optional[str]]): + """Value consisting of a single string or None.""" + + def __init__(self, + default: Optional[str] = None, + store_default: bool = False) -> None: + super().__init__(default, store_default, str, allow_none=True) + + +class BoolValue(SimpleValue[bool]): + """Value consisting of a single bool.""" + + def __init__(self, default: bool = False, + store_default: bool = False) -> None: + super().__init__(default, store_default, bool, (int, float)) + + +class OptionalBoolValue(SimpleValue[Optional[bool]]): + """Value consisting of a single bool or None.""" + + def __init__(self, + default: Optional[bool] = None, + store_default: bool = False) -> None: + super().__init__(default, + store_default, + bool, (int, float), + allow_none=True) + + +def verify_time_input(data: Any, error: bool, allow_none: bool) -> Any: + """Checks input data for time values.""" + pytz_utc: Any + + # We don't *require* pytz since it must be installed through pip + # but it is used by firestore client for its date values + # (in which case it should be installed as a dependency anyway). + try: + import pytz + pytz_utc = pytz.utc + except ModuleNotFoundError: + pytz_utc = None + + # Filter unallowed None values. + if not allow_none and data is None: + if error: + raise ValueError("datetime value cannot be None") + logging.error("ignoring datetime value of None") + data = (None if allow_none else datetime.datetime.now( + datetime.timezone.utc)) + + # Parent filter_input does what we need, but let's just make + # sure we *only* accept datetime values that know they're UTC. + elif (isinstance(data, datetime.datetime) + and data.tzinfo is not datetime.timezone.utc + and (pytz_utc is None or data.tzinfo is not pytz_utc)): + if error: + raise ValueError( + "datetime values must have timezone set as timezone.utc") + logging.error( + "ignoring datetime value without timezone.utc set: %s %s", + type(datetime.timezone.utc), type(data.tzinfo)) + data = (None if allow_none else datetime.datetime.now( + datetime.timezone.utc)) + return data + + +class DateTimeValue(SimpleValue[datetime.datetime]): + """Value consisting of a datetime.datetime object. + + The default value for this is always the current time in UTC. + """ + + def __init__(self, store_default: bool = False) -> None: + # Pass dummy datetime value as default just to satisfy constructor; + # we override get_default_data though so this doesn't get used. + dummy_default = datetime.datetime.now(datetime.timezone.utc) + super().__init__(dummy_default, store_default, datetime.datetime) + + def get_default_data(self) -> Any: + # For this class we don't use a static default value; + # default is always now. + return datetime.datetime.now(datetime.timezone.utc) + + def filter_input(self, data: Any, error: bool) -> Any: + data = verify_time_input(data, error, allow_none=False) + return super().filter_input(data, error) + + +class OptionalDateTimeValue(SimpleValue[Optional[datetime.datetime]]): + """Value consisting of a datetime.datetime object or None.""" + + def __init__(self, store_default: bool = False) -> None: + super().__init__(None, + store_default, + datetime.datetime, + allow_none=True) + + def filter_input(self, data: Any, error: bool) -> Any: + data = verify_time_input(data, error, allow_none=True) + return super().filter_input(data, error) + + +class IntValue(SimpleValue[int]): + """Value consisting of a single int.""" + + def __init__(self, default: int = 0, store_default: bool = False) -> None: + super().__init__(default, store_default, int, (bool, float)) + + +class OptionalIntValue(SimpleValue[Optional[int]]): + """Value consisting of a single int or None""" + + def __init__(self, default: int = None, + store_default: bool = False) -> None: + super().__init__(default, + store_default, + int, (bool, float), + allow_none=True) + + +class FloatValue(SimpleValue[float]): + """Value consisting of a single float.""" + + def __init__(self, default: float = 0.0, + store_default: bool = False) -> None: + super().__init__(default, store_default, float, (bool, int)) + + +class OptionalFloatValue(SimpleValue[Optional[float]]): + """Value consisting of a single float or None.""" + + def __init__(self, default: float = None, + store_default: bool = False) -> None: + super().__init__(default, + store_default, + float, (bool, int), + allow_none=True) + + +class Float3Value(SimpleValue[Tuple[float, float, float]]): + """Value consisting of 3 floats.""" + + def __init__(self, + default: Tuple[float, float, float] = (0.0, 0.0, 0.0), + store_default: bool = False) -> None: + super().__init__(default, store_default) + + def __repr__(self) -> str: + return f'' + + def filter_input(self, data: Any, error: bool) -> Any: + if (not isinstance(data, abc.Sequence) or len(data) != 3 + or any(not isinstance(i, (int, float)) for i in data)): + if error: + raise TypeError("Sequence of 3 float values expected.") + logging.error('Ignoring non-3-float-sequence data for %s: %s', + self, data) + data = self.get_default_data() + + # Actually store as list. + return [float(data[0]), float(data[1]), float(data[2])] + + def filter_output(self, data: Any) -> Any: + """Override.""" + assert len(data) == 3 + return tuple(data) + + +class BaseEnumValue(TypedValue[T]): + """Value class for storing Python Enums. + + Internally enums are stored as their corresponding int/str/etc. values. + """ + + def __init__(self, + enumtype: Type[T], + default: Optional[T] = None, + store_default: bool = False, + allow_none: bool = False) -> None: + super().__init__() + assert issubclass(enumtype, Enum) + + vals: List[T] = list(enumtype) + + # Bit of sanity checking: make sure this enum has at least + # one value and that its underlying values are all of simple + # json-friendly types. + if not vals: + raise TypeError(f'enum {enumtype} has no values') + for val in vals: + assert isinstance(val, Enum) + if not isinstance(val.value, (int, bool, float, str)): + raise TypeError(f'enum value {val} has an invalid' + f' value type {type(val.value)}') + self._enumtype: Type[Enum] = enumtype + self._store_default: bool = store_default + self._allow_none: bool = allow_none + + # We store default data is internal format so need to run + # user-provided value through input filter. + # Make sure to set this last since it could depend on other + # stuff we set here. + if default is None and not self._allow_none: + # Special case: we allow passing None as default even if + # we don't support None as a value; in that case we sub + # in the first enum value. + default = vals[0] + self._default_data: Enum = self.filter_input(default, error=True) + + def get_default_data(self) -> Any: + return self._default_data + + def prune_data(self, data: Any) -> bool: + return not self._store_default and data == self._default_data + + def filter_input(self, data: Any, error: bool) -> Any: + + # Allow passing in enum objects directly of course. + if isinstance(data, self._enumtype): + data = data.value + elif self._allow_none and data is None: + pass + else: + # At this point we assume its an enum value + try: + self._enumtype(data) + except ValueError: + if error: + raise ValueError( + f"Invalid value for {self._enumtype}: {data}") + logging.error('Ignoring invalid value for %s: %s', + self._enumtype, data) + data = self._default_data + return data + + def filter_output(self, data: Any) -> Any: + if self._allow_none and data is None: + return None + return self._enumtype(data) + + +class EnumValue(BaseEnumValue[TE]): + """Value class for storing Python Enums. + + Internally enums are stored as their corresponding int/str/etc. values. + """ + + def __init__(self, + enumtype: Type[TE], + default: TE = None, + store_default: bool = False) -> None: + super().__init__(enumtype, default, store_default, allow_none=False) + + +class OptionalEnumValue(BaseEnumValue[Optional[TE]]): + """Value class for storing Python Enums (or None). + + Internally enums are stored as their corresponding int/str/etc. values. + """ + + def __init__(self, + enumtype: Type[TE], + default: TE = None, + store_default: bool = False) -> None: + super().__init__(enumtype, default, store_default, allow_none=True) + + +class CompoundValue(DataHandler): + """A value containing one or more named child fields of its own. + + Custom classes can be defined that inherit from this and include + any number of Field instances within themself. + """ + + def __init__(self, store_default: bool = False) -> None: + super().__init__() + self._store_default = store_default + + # Run sanity checks on this type if we haven't. + self.run_type_sanity_checks() + + def __eq__(self, other: Any) -> Any: + # Allow comparing to compound and bound-compound objects. + return compound_eq(self, other) + + def get_default_data(self) -> dict: + return {} + + # NOTE: once we've got bound-compound-fields working in mypy + # we should get rid of this here. + # For now it needs to be here though since bound-compound fields + # come across as these in type-land. + def reset(self) -> None: + """Resets data to default.""" + raise ValueError('Unbound CompoundValue cannot be reset.') + + def filter_input(self, data: Any, error: bool) -> dict: + if not isinstance(data, dict): + if error: + raise TypeError('dict value expected') + logging.error('Ignoring non-dict data for %s: %s', self, data) + data = {} + assert isinstance(data, dict) + self.apply_fields_to_data(data, error=error) + return data + + def prune_data(self, data: Any) -> bool: + # Let all of our sub-fields prune themselves.. + self.prune_fields_data(data) + + # Now we can optionally prune ourself completely if there's + # nothing left in our data dict... + return not data and not self._store_default + + def prune_fields_data(self, d_data: Dict[str, Any]) -> None: + """Given a CompoundValue and data, prune any unnecessary data. + will include those set to default values with store_default False. + """ + + # Allow all fields to take a pruning pass. + assert isinstance(d_data, dict) + for field in self.get_fields().values(): + assert isinstance(field.d_key, str) + + # This is supposed to be valid data so there should be *something* + # there for all fields. + if field.d_key not in d_data: + raise RuntimeError(f'expected to find {field.d_key} in data' + f' for {self}; got data {d_data}') + + # Now ask the field if this data is necessary. If not, prune it. + if field.prune_data(d_data[field.d_key]): + del d_data[field.d_key] + + def apply_fields_to_data(self, d_data: Dict[str, Any], + error: bool) -> None: + """Apply all of our fields to target data. + + If error is True, exceptions will be raised for invalid data; + otherwise it will be overwritten (with logging notices emitted). + """ + assert isinstance(d_data, dict) + for field in self.get_fields().values(): + assert isinstance(field.d_key, str) + + # First off, make sure *something* is there for this field. + if field.d_key not in d_data: + d_data[field.d_key] = field.get_default_data() + + # Now let the field tweak the data as needed so its valid. + d_data[field.d_key] = field.filter_input(d_data[field.d_key], + error=error) + + def __repr__(self) -> str: + if not hasattr(self, 'd_data'): + return f'' + fstrs: List[str] = [] + assert isinstance(self, CompoundValue) + for field in self.get_fields(): + fstrs.append(str(field) + '=' + repr(getattr(self, field))) + return type(self).__name__ + '(' + ', '.join(fstrs) + ')' + + @classmethod + def get_fields(cls) -> Dict[str, BaseField]: + """Return all field instances for this type.""" + assert issubclass(cls, CompoundValue) + + # If we haven't yet, calculate and cache a complete list of fields + # for this exact type. + if cls not in _type_field_cache: + fields: Dict[str, BaseField] = {} + for icls in inspect.getmro(cls): + for name, field in icls.__dict__.items(): + if isinstance(field, BaseField): + fields[name] = field + _type_field_cache[cls] = fields + retval: Dict[str, BaseField] = _type_field_cache[cls] + assert isinstance(retval, dict) + return retval + + @classmethod + def run_type_sanity_checks(cls) -> None: + """Given a type, run one-time sanity checks on it. + + These tests ensure child fields are using valid + non-repeating names/etc. + """ + if cls not in _sanity_tested_types: + _sanity_tested_types.add(cls) + + # Make sure all embedded fields have a key set and there are no + # duplicates. + field_keys: Set[str] = set() + for field in cls.get_fields().values(): + assert isinstance(field.d_key, str) + if field.d_key is None: + raise RuntimeError(f'Child field {field} under {cls}' + 'has d_key None') + if field.d_key == '': + raise RuntimeError(f'Child field {field} under {cls}' + 'has empty d_key') + + # Allow alphanumeric and underscore only. + if not field.d_key.replace('_', '').isalnum(): + raise RuntimeError( + f'Child field "{field.d_key}" under {cls}' + f' contains invalid characters; only alphanumeric' + f' and underscore allowed.') + if field.d_key in field_keys: + raise RuntimeError('Multiple child fields with key' + f' "{field.d_key}" found in {cls}') + field_keys.add(field.d_key) diff --git a/assets/src/data/scripts/bafoundation/entity/util.py b/assets/src/data/scripts/bafoundation/entity/util.py new file mode 100644 index 00000000..9b4f3650 --- /dev/null +++ b/assets/src/data/scripts/bafoundation/entity/util.py @@ -0,0 +1,130 @@ +# Synced from bamaster. +# EFRO_SYNC_HASH=151238242547824871848833808259117588767 +# +"""Misc utility functionality related to the entity system.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Union, Tuple, List + from bafoundation.entity._value import CompoundValue + from bafoundation.entity._support import BoundCompoundValue + + +def diff_compound_values(obj1: Union[BoundCompoundValue, CompoundValue], + obj2: Union[BoundCompoundValue, CompoundValue] + ) -> str: + """Generate a string showing differences between two compound values. + + Both must be associated with data and have the same set of fields. + """ + + # Ensure fields match and both are attached to data... + value1, data1 = get_compound_value_and_data(obj1) + if data1 is None: + raise ValueError(f'Invalid unbound compound value: {obj1}') + value2, data2 = get_compound_value_and_data(obj2) + if data2 is None: + raise ValueError(f'Invalid unbound compound value: {obj2}') + if not have_matching_fields(value1, value2): + raise ValueError( + f"Can't diff objs with non-matching fields: {value1} and {value2}") + + # Ok; let 'er rip... + diff = _diff(obj1, obj2, 2) + return ' ' if diff == '' else diff + + +class CompoundValueDiff: + """Wraps diff_compound_values() in an object for efficiency. + + It is preferable to pass this to logging calls instead of the + final diff string since the diff will never be generated if + the associated logging level is not being emitted. + """ + + def __init__(self, obj1: Union[BoundCompoundValue, CompoundValue], + obj2: Union[BoundCompoundValue, CompoundValue]): + self._obj1 = obj1 + self._obj2 = obj2 + + def __repr__(self) -> str: + return diff_compound_values(self._obj1, self._obj2) + + +def _diff(obj1: Union[BoundCompoundValue, CompoundValue], + obj2: Union[BoundCompoundValue, CompoundValue], indent: int) -> str: + from bafoundation.entity._support import BoundCompoundValue + bits: List[str] = [] + indentstr = ' ' * indent + vobj1, _data1 = get_compound_value_and_data(obj1) + fields = sorted(vobj1.get_fields().keys()) + for field in fields: + val1 = getattr(obj1, field) + val2 = getattr(obj2, field) + # for nested compounds, dive in and do nice piecewise compares + if isinstance(val1, BoundCompoundValue): + assert isinstance(val2, BoundCompoundValue) + diff = _diff(val1, val2, indent + 2) + if diff != '': + bits.append(f'{indentstr}{field}:') + bits.append(diff) + # for all else just do a single line + # (perhaps we could improve on this for other complex types) + else: + if val1 != val2: + bits.append(f'{indentstr}{field}: {val1} -> {val2}') + return '\n'.join(bits) + + +def have_matching_fields(val1: CompoundValue, val2: CompoundValue) -> bool: + """Return whether two compound-values have matching sets of fields. + + Note this just refers to the field configuration; not data. + """ + # quick-out: matching types will always have identical fields + if type(val1) is type(val2): + return True + # otherwise do a full comparision + return val1.get_fields() == val2.get_fields() + + +def get_compound_value_and_data(obj: Union[BoundCompoundValue, CompoundValue] + ) -> Tuple[CompoundValue, Any]: + """Return value and data for bound or unbound compound values.""" + # pylint: disable=cyclic-import + from bafoundation.entity._support import BoundCompoundValue + from bafoundation.entity._value import CompoundValue + if isinstance(obj, BoundCompoundValue): + value = obj.d_value + data = obj.d_data + elif isinstance(obj, CompoundValue): + value = obj + data = getattr(obj, 'd_data', None) # may not exist + else: + raise TypeError( + f'Expected a BoundCompoundValue or CompoundValue; got {type(obj)}') + return value, data + + +def compound_eq(obj1: Union[BoundCompoundValue, CompoundValue], + obj2: Union[BoundCompoundValue, CompoundValue]) -> Any: + """Compare two compound value/bound-value objects for equality.""" + + # Criteria for comparison: both need to be a compound value + # and both must have data (which implies they are either a entity + # or bound to a subfield in a entity). + value1, data1 = get_compound_value_and_data(obj1) + if data1 is None: + return NotImplemented + value2, data2 = get_compound_value_and_data(obj2) + if data2 is None: + return NotImplemented + + # Ok we can compare them. To consider them equal we look for + # matching sets of fields and matching data. Note that there + # could be unbound data causing inequality despite their field + # values all matching; not sure if that's what we want. + return have_matching_fields(value1, value2) and data1 == data2 diff --git a/assets/src/data/scripts/bafoundation/err.py b/assets/src/data/scripts/bafoundation/err.py new file mode 100644 index 00000000..610f6634 --- /dev/null +++ b/assets/src/data/scripts/bafoundation/err.py @@ -0,0 +1,18 @@ +# Synced from bamaster. +# EFRO_SYNC_HASH=324606719817436157254454259763962378663 +# +"""Error related functionality shared between all ba components.""" + +# Hmmmm - need to give this exception structure some thought... + + +class CommunicationError(Exception): + """A communication-related error occurred.""" + + +class RemoteError(Exception): + """An error occurred on the other end of some connection.""" + + def __str__(self) -> str: + s = ''.join(str(arg) for arg in self.args) # pylint: disable=E1133 + return f'Remote Exception Follows:\n{s}' diff --git a/assets/src/data/scripts/bafoundation/executils.py b/assets/src/data/scripts/bafoundation/executils.py new file mode 100644 index 00000000..817c6d0d --- /dev/null +++ b/assets/src/data/scripts/bafoundation/executils.py @@ -0,0 +1,48 @@ +# Synced from bamaster. +# EFRO_SYNC_HASH=43697789967751346220367938882574464737 +# +"""Exec related functionality shared between all ba components.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar, Generic, Callable, cast + +if TYPE_CHECKING: + from typing import Any + +T = TypeVar('T', bound=Callable) + + +class _CallbackCall(Generic[T]): + """Descriptor for exposing a call with a type defined by a TypeVar.""" + + def __get__(self, obj: Any, type_in: Any = None) -> T: + return cast(T, None) + + +class CallbackSet(Generic[T]): + """Wrangles callbacks for a particular event.""" + + # In the type-checker's eyes, our 'run' attr is a CallbackCall which + # returns a callable with the type we were created with. This lets us + # type-check our run calls. (Is there another way to expose a function + # with a signature defined by a generic?..) + # At runtime, run() simply passes its args verbatim to its registered + # callbacks; no types are checked. + if TYPE_CHECKING: + run: _CallbackCall[T] = _CallbackCall() + else: + + def run(self, *args, **keywds): + """Run all callbacks.""" + print("HELLO FROM RUN", *args, **keywds) + + def __init__(self) -> None: + print("CallbackSet()") + + def __del__(self) -> None: + print("~CallbackSet()") + + def add(self, call: T) -> None: + """Add a callback to be run.""" + print("Would add call", call) diff --git a/assets/src/data/scripts/bafoundation/jsonutils.py b/assets/src/data/scripts/bafoundation/jsonutils.py new file mode 100644 index 00000000..97855435 --- /dev/null +++ b/assets/src/data/scripts/bafoundation/jsonutils.py @@ -0,0 +1,74 @@ +# Synced from bsmaster. +# EFRO_SYNC_HASH=303140082733449378022422119719823943963 +# +"""Custom json compressor/decompressor with support for more data times/etc.""" + +from __future__ import annotations + +import datetime +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + +# Special attr we included for our extended type information +# (extended-json-type) +TYPE_TAG = '_xjtp' + +_pytz_utc: Any + +# We don't *require* pytz since it must be installed through pip +# but it is used by firestore client for its utc tzinfos. +# (in which case it should be installed as a dependency anyway) +try: + import pytz + _pytz_utc = pytz.utc # pylint: disable=invalid-name +except ModuleNotFoundError: + _pytz_utc = None # pylint: disable=invalid-name + + +class ExtendedJSONEncoder(json.JSONEncoder): + """Custom json encoder supporting additional types.""" + + def default(self, obj: Any) -> Any: # pylint: disable=E0202, W0221 + if isinstance(obj, datetime.datetime): + + # We only support timezone-aware utc times. + if (obj.tzinfo is not datetime.timezone.utc + and (_pytz_utc is None or obj.tzinfo is not _pytz_utc)): + raise ValueError( + 'datetime values must have timezone set as timezone.utc') + return { + TYPE_TAG: + "dt", + "v": [ + obj.year, obj.month, obj.day, obj.hour, obj.minute, + obj.second, obj.microsecond + ], + } + return super().default(obj) + + +class ExtendedJSONDecoder(json.JSONDecoder): + """Custom json decoder supporting extended types.""" + + def __init__(self, *args: Any, **kwargs: Any): + json.JSONDecoder.__init__( # type: ignore + self, + object_hook=self.object_hook, + *args, + **kwargs) + + def object_hook(self, obj: Any) -> Any: # pylint: disable=E0202 + """Custom hook.""" + if TYPE_TAG not in obj: + return obj + objtype = obj[TYPE_TAG] + if objtype == 'dt': + vals = obj.get('v', []) + if len(vals) != 7: + raise ValueError("malformed datetime value") + return datetime.datetime( # type: ignore + *vals, tzinfo=datetime.timezone.utc) + return obj diff --git a/assets/src/data/scripts/bafoundation/util.py b/assets/src/data/scripts/bafoundation/util.py new file mode 100644 index 00000000..f2226197 --- /dev/null +++ b/assets/src/data/scripts/bafoundation/util.py @@ -0,0 +1,232 @@ +# Synced from bsmaster. +# EFRO_SYNC_HASH=15008988795367952822112128932296326511 +# +"""Small handy bits of functionality.""" + +from __future__ import annotations + +import datetime +import time +from typing import TYPE_CHECKING, cast, TypeVar, Generic + +if TYPE_CHECKING: + import asyncio + from typing import Any, Dict, Callable, Optional + +TVAL = TypeVar('TVAL') +TARG = TypeVar('TARG') +TRET = TypeVar('TRET') + + +def utc_now() -> datetime.datetime: + """Get offset-aware current utc time.""" + return datetime.datetime.now(datetime.timezone.utc) + + +class DispatchMethodWrapper(Generic[TARG, TRET]): + """Type-aware standin for the dispatch func returned by dispatchmethod.""" + + def __call__(self, arg: TARG) -> TRET: + pass + + @staticmethod + def register(func: Callable[[Any, Any], TRET]) -> Callable: + """Register a new dispatch handler for this dispatch-method.""" + + registry: Dict[Any, Callable] + + +# noinspection PyTypeHints, PyProtectedMember +def dispatchmethod(func: Callable[[Any, TARG], TRET] + ) -> DispatchMethodWrapper[TARG, TRET]: + """A variation of functools.singledispatch for methods.""" + from functools import singledispatch, update_wrapper + origwrapper: Any = singledispatch(func) + + # Pull this out so hopefully origwrapper can die, + # otherwise we reference origwrapper in our wrapper. + dispatch = origwrapper.dispatch + + # All we do here is recreate the end of functools.singledispatch + # where it returns a wrapper except instead of the wrapper using the + # first arg to the function ours uses the second (to skip 'self'). + # This was made with Python 3.7; we should probably check up on + # this in later versions in case anything has changed. + # (or hopefully they'll add this functionality to their version) + def wrapper(*args: Any, **kw: Any) -> Any: + if not args or len(args) < 2: + raise TypeError(f'{funcname} requires at least ' + '2 positional arguments') + + return dispatch(args[1].__class__)(*args, **kw) + + funcname = getattr(func, '__name__', 'dispatchmethod method') + wrapper.register = origwrapper.register # type: ignore + wrapper.dispatch = dispatch # type: ignore + wrapper.registry = origwrapper.registry # type: ignore + # pylint: disable=protected-access + wrapper._clear_cache = origwrapper._clear_cache # type: ignore + update_wrapper(wrapper, func) + # pylint: enable=protected-access + return cast(DispatchMethodWrapper, wrapper) + + +class DirtyBit: + """Manages whether a thing is dirty and regulates attempts to clean it. + + To use, simply set the 'dirty' value on this object to True when some + action is needed, and then check the 'should_update' value to regulate + when attempts to clean it should be made. Set 'dirty' back to False after + a successful update. + If 'use_lock' is True, an asyncio Lock will be created and incorporated + into update attempts to prevent simultaneous updates (should_update will + only return True when the lock is unlocked). Note that It is up to the user + to lock/unlock the lock during the actual update attempt. + If a value is passed for 'auto_dirty_seconds', the dirtybit will flip + itself back to dirty after being clean for the given amount of time. + 'min_update_interval' can be used to enforce a minimum update + interval even when updates are successful (retry_interval only applies + when updates fail) + """ + + def __init__(self, + dirty: bool = False, + retry_interval: float = 5.0, + use_lock: bool = False, + auto_dirty_seconds: float = None, + min_update_interval: Optional[float] = None): + curtime = time.time() + self._retry_interval = retry_interval + self._auto_dirty_seconds = auto_dirty_seconds + self._min_update_interval = min_update_interval + self._dirty = dirty + self._next_update_time: Optional[float] = (curtime if dirty else None) + self._last_update_time: Optional[float] = None + self._next_auto_dirty_time: Optional[float] = ( + (curtime + self._auto_dirty_seconds) if + (not dirty and self._auto_dirty_seconds is not None) else None) + self._use_lock = use_lock + self.lock: asyncio.Lock + if self._use_lock: + import asyncio + self.lock = asyncio.Lock() + + @property + def dirty(self) -> bool: + """Whether the target is currently dirty. + + This should be set to False once an update is successful. + """ + return self._dirty + + @dirty.setter + def dirty(self, value: bool) -> None: + + # If we're freshly clean, set our next auto-dirty time (if we have + # one). + if self._dirty and not value and self._auto_dirty_seconds is not None: + self._next_auto_dirty_time = time.time() + self._auto_dirty_seconds + + # If we're freshly dirty, schedule an immediate update. + if not self._dirty and value: + self._next_update_time = time.time() + + # If they want to enforce a minimum update interval, + # push out the next update time if it hasn't been long enough. + if (self._min_update_interval is not None + and self._last_update_time is not None): + self._next_update_time = max( + self._next_update_time, + self._last_update_time + self._min_update_interval) + + self._dirty = value + + @property + def should_update(self) -> bool: + """Whether an attempt should be made to clean the target now. + + Always returns False if the target is not dirty. + Takes into account the amount of time passed since the target + was marked dirty or since should_update last returned True. + """ + curtime = time.time() + + # Auto-dirty ourself if we're into that. + if (self._next_auto_dirty_time is not None + and curtime > self._next_auto_dirty_time): + self.dirty = True + self._next_auto_dirty_time = None + if not self._dirty: + return False + if self._use_lock and self.lock.locked(): + return False + assert self._next_update_time is not None + if curtime > self._next_update_time: + self._next_update_time = curtime + self._retry_interval + self._last_update_time = curtime + return True + return False + + +def valuedispatch(call: Callable[[TVAL], TRET]) -> ValueDispatcher[TVAL, TRET]: + """Decorator for functions to allow dispatching based on a value. + + The 'register' method of a value-dispatch function can be used + to assign new functions to handle particular values. + Unhandled values wind up in the original dispatch function.""" + return ValueDispatcher(call) + + +class ValueDispatcher(Generic[TVAL, TRET]): + """Used by the valuedispatch decorator""" + + def __init__(self, call: Callable[[TVAL], TRET]) -> None: + self._base_call = call + self._handlers: Dict[TVAL, Callable[[], TRET]] = {} + + def __call__(self, value: TVAL) -> TRET: + handler = self._handlers.get(value) + if handler is not None: + return handler() + return self._base_call(value) + + def _add_handler(self, value: TVAL, call: Callable[[], TRET]) -> None: + if value in self._handlers: + raise RuntimeError(f'Duplicate handlers added for {value}') + self._handlers[value] = call + + def register(self, value: TVAL) -> Callable[[Callable[[], TRET]], None]: + """Add a handler to the dispatcher.""" + from functools import partial + return partial(self._add_handler, value) + + +def valuedispatch1arg(call: Callable[[TVAL, TARG], TRET] + ) -> ValueDispatcher1Arg[TVAL, TARG, TRET]: + """Like valuedispatch but for functions taking an extra argument.""" + return ValueDispatcher1Arg(call) + + +class ValueDispatcher1Arg(Generic[TVAL, TARG, TRET]): + """Used by the valuedispatch1arg decorator""" + + def __init__(self, call: Callable[[TVAL, TARG], TRET]) -> None: + self._base_call = call + self._handlers: Dict[TVAL, Callable[[TARG], TRET]] = {} + + def __call__(self, value: TVAL, arg: TARG) -> TRET: + handler = self._handlers.get(value) + if handler is not None: + return handler(arg) + return self._base_call(value, arg) + + def _add_handler(self, value: TVAL, call: Callable[[TARG], TRET]) -> None: + if value in self._handlers: + raise RuntimeError(f'Duplicate handlers added for {value}') + self._handlers[value] = call + + def register(self, + value: TVAL) -> Callable[[Callable[[TARG], TRET]], None]: + """Add a handler to the dispatcher.""" + from functools import partial + return partial(self._add_handler, value) diff --git a/assets/src/data/scripts/bastd/__init__.py b/assets/src/data/scripts/bastd/__init__.py new file mode 100644 index 00000000..242fefe6 --- /dev/null +++ b/assets/src/data/scripts/bastd/__init__.py @@ -0,0 +1,3 @@ +"""BallisticaCore standard library: games, UI, etc.""" + +# bs_meta require api 6 diff --git a/assets/src/data/scripts/bastd/activity/__init__.py b/assets/src/data/scripts/bastd/activity/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assets/src/data/scripts/bastd/activity/coopjoinscreen.py b/assets/src/data/scripts/bastd/activity/coopjoinscreen.py new file mode 100644 index 00000000..032146d9 --- /dev/null +++ b/assets/src/data/scripts/bastd/activity/coopjoinscreen.py @@ -0,0 +1,183 @@ +"""Functionality related to the co-op join screen.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba +from ba.internal import JoiningActivity + +if TYPE_CHECKING: + from typing import Any, Dict, List, Optional, Sequence, Union + + +class CoopJoiningActivity(JoiningActivity): + """Join-screen for co-op mode.""" + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings) + session = ba.getsession() + + # Let's show a list of scores-to-beat for 1 player at least. + assert session.campaign is not None + level_name_full = (session.campaign.name + ":" + + session.campaign_state['level']) + config_str = ( + "1p" + session.campaign.get_level(session.campaign_state['level']). + get_score_version_string().replace(' ', '_')) + _ba.get_scores_to_beat(level_name_full, config_str, + ba.WeakCall(self._on_got_scores_to_beat)) + + def on_transition_in(self) -> None: + from bastd.actor.controlsguide import ControlsGuide + from bastd.actor.text import Text + super().on_transition_in() + assert self.session.campaign + Text(self.session.campaign.get_level( + self.session.campaign_state['level']).displayname, + scale=1.3, + h_attach='center', + h_align='center', + v_attach='top', + transition='fade_in', + transition_delay=4.0, + color=(1, 1, 1, 0.6), + position=(0, -95)).autoretain() + ControlsGuide(delay=1.0).autoretain() + + def _on_got_scores_to_beat(self, + scores: Optional[List[Dict[str, Any]]]) -> None: + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + from bastd.actor.text import Text + from ba.internal import get_achievements_for_coop_level + + # Sort by originating date so that the most recent is first. + if scores is not None: + scores.sort(reverse=True, key=lambda score: score['time']) + + # We only show achievements and challenges for CoopGameActivities. + session = self.session + assert isinstance(session, ba.CoopSession) + gameinstance = session.get_current_game_instance() + if isinstance(gameinstance, ba.CoopGameActivity): + score_type = gameinstance.get_score_type() + if scores is not None: + achievement_challenges = [ + a for a in scores if a['type'] == 'achievement_challenge' + ] + score_challenges = [ + a for a in scores if a['type'] == 'score_challenge' + ] + else: + achievement_challenges = score_challenges = [] + + delay = 1.0 + vpos = -140.0 + spacing = 25 + delay_inc = 0.1 + + def _add_t(text: Union[str, ba.Lstr], + h_offs: float = 0.0, + scale: float = 1.0, + color: Sequence[float] = (1.0, 1.0, 1.0, 0.46)) -> None: + Text(text, + scale=scale * 0.76, + h_align='left', + h_attach='left', + v_attach='top', + transition='fade_in', + transition_delay=delay, + color=color, + position=(60 + h_offs, vpos)).autoretain() + + if score_challenges: + _add_t(ba.Lstr(value='${A}:', + subs=[('${A}', + ba.Lstr(resource='scoreChallengesText')) + ]), + scale=1.1) + delay += delay_inc + vpos -= spacing + for chal in score_challenges: + _add_t(str(chal['value'] if score_type == 'points' else ba. + timestring(int(chal['value']) * 10, + timeformat=ba.TimeFormat.MILLISECONDS + ).evaluate()) + ' (1 player)', + h_offs=30, + color=(0.9, 0.7, 1.0, 0.8)) + delay += delay_inc + vpos -= 0.6 * spacing + _add_t(chal['player'], + h_offs=40, + color=(0.8, 1, 0.8, 0.6), + scale=0.8) + delay += delay_inc + vpos -= 1.2 * spacing + vpos -= 0.5 * spacing + + if achievement_challenges: + _add_t(ba.Lstr( + value='${A}:', + subs=[('${A}', + ba.Lstr(resource='achievementChallengesText'))]), + scale=1.1) + delay += delay_inc + vpos -= spacing + for chal in achievement_challenges: + _add_t(str(chal['value']), + h_offs=30, + color=(0.9, 0.7, 1.0, 0.8)) + delay += delay_inc + vpos -= 0.6 * spacing + _add_t(chal['player'], + h_offs=40, + color=(0.8, 1, 0.8, 0.6), + scale=0.8) + delay += delay_inc + vpos -= 1.2 * spacing + vpos -= 0.5 * spacing + + # Now list our remaining achievements for this level. + assert self.session.campaign is not None + levelname = (self.session.campaign.name + ":" + + self.session.campaign_state['level']) + ts_h_offs = 60 + + if not ba.app.kiosk_mode: + achievements = [ + a for a in get_achievements_for_coop_level(levelname) + if not a.complete + ] + have_achievements = bool(achievements) + achievements = [a for a in achievements if not a.complete] + vrmode = ba.app.vr_mode + if have_achievements: + Text(ba.Lstr(resource='achievementsRemainingText'), + host_only=True, + position=(ts_h_offs - 10, vpos), + transition='fade_in', + scale=1.1 * 0.76, + h_attach="left", + v_attach="top", + color=(1, 1, 1.2, 1) if vrmode else (0.8, 0.8, 1, 1), + shadow=1.0, + flatness=1.0 if vrmode else 0.6, + transition_delay=delay).autoretain() + hval = ts_h_offs + 50 + vpos -= 35 + for ach in achievements: + delay += 0.05 + ach.create_display(hval, vpos, delay, style='in_game') + vpos -= 55 + if not achievements: + Text(ba.Lstr(resource='noAchievementsRemainingText'), + host_only=True, + position=(ts_h_offs + 15, vpos + 10), + transition='fade_in', + scale=0.7, + h_attach="left", + v_attach="top", + color=(1, 1, 1, 0.5), + transition_delay=delay + 0.5).autoretain() diff --git a/assets/src/data/scripts/bastd/activity/coopscorescreen.py b/assets/src/data/scripts/bastd/activity/coopscorescreen.py new file mode 100644 index 00000000..72dcbb71 --- /dev/null +++ b/assets/src/data/scripts/bastd/activity/coopscorescreen.py @@ -0,0 +1,1454 @@ +"""Provides a score screen for coop games.""" +# pylint: disable=too-many-lines + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import _ba +import ba +from ba.internal import get_achievements_for_coop_level + +if TYPE_CHECKING: + from typing import Optional, Tuple, List, Dict, Any, Sequence + from bastd.ui.store.button import StoreButton + from bastd.ui.league.rankbutton import LeagueRankButton + + +class CoopScoreScreen(ba.Activity): + """Score screen showing the results of a cooperative game.""" + + def __init__(self, settings: Dict[str, Any]): + # pylint: disable=too-many-statements + super().__init__(settings=settings) + + # Keep prev activity alive while we fade in + self.transition_time = 0.5 + self.inherits_tint = True + self.inherits_camera_vr_offset = True + self.inherits_music = True + self.use_fixed_vr_overlay = True + + self._tournament_time_remaining = None + self._tournament_time_remaining_text = None + + self._do_new_rating: bool = self.session.tournament_id is not None + + self._score_display_sound = ba.getsound("scoreHit01") + self._score_display_sound_small = ba.getsound("scoreHit02") + self.drum_roll_sound = ba.getsound('drumRoll') + self.cymbal_sound = ba.getsound('cymbal') + + # These get used in UI bits so need to load them in the UI context. + with ba.Context('ui'): + self._replay_icon_texture = ba.gettexture('replayIcon') + self._menu_icon_texture = ba.gettexture('menuIcon') + self._next_level_icon_texture = ba.gettexture('nextLevelIcon') + + self._have_achievements = bool( + get_achievements_for_coop_level(settings['campaign'].get_name() + + ":" + settings['level'])) + + self._account_type = (_ba.get_account_type() if + _ba.get_account_state() == 'signed_in' else None) + + self._game_service_icon_color: Optional[Sequence[float]] + self._game_service_achievements_texture: Optional[ba.Texture] + self._game_service_leaderboards_texture: Optional[ba.Texture] + + with ba.Context('ui'): + if self._account_type == 'Game Center': + self._game_service_icon_color = (1.0, 1.0, 1.0) + icon = ba.gettexture('gameCenterIcon') + self._game_service_achievements_texture = icon + self._game_service_leaderboards_texture = icon + self._account_has_achievements = True + elif self._account_type == 'Game Circle': + icon = ba.gettexture('gameCircleIcon') + self._game_service_icon_color = (1, 1, 1) + self._game_service_achievements_texture = icon + self._game_service_leaderboards_texture = icon + self._account_has_achievements = True + elif self._account_type == 'Google Play': + self._game_service_icon_color = (0.8, 1.0, 0.6) + self._game_service_achievements_texture = ( + ba.gettexture('googlePlayAchievementsIcon')) + self._game_service_leaderboards_texture = ( + ba.gettexture('googlePlayLeaderboardsIcon')) + self._account_has_achievements = True + else: + self._game_service_icon_color = None + self._game_service_achievements_texture = None + self._game_service_leaderboards_texture = None + self._account_has_achievements = False + + self._cashregistersound = ba.getsound('cashRegister') + self._gun_cocking_sound = ba.getsound('gunCocking') + self._dingsound = ba.getsound('ding') + self._score_link: Optional[str] = None + self._root_ui: Optional[ba.Widget] = None + self._background: Optional[ba.Actor] = None + self._old_best_rank = 0.0 + self._game_name_str: Optional[str] = None + self._game_config_str: Optional[str] = None + + # Ui bits. + self._corner_button_offs: Optional[Tuple[float, float]] = None + self._league_rank_button: Optional[LeagueRankButton] = None + self._store_button_instance: Optional[StoreButton] = None + self._restart_button: Optional[ba.Widget] = None + self._update_corner_button_positions_timer: Optional[ba.Timer] = None + self._next_level_error: Optional[ba.Actor] = None + + # Score/gameplay bits. + self._was_complete = None + self._is_complete = None + self._newly_complete = None + self._is_more_levels = None + self._next_level_name = None + self._show_friend_scores = None + self._show_info: Optional[Dict[str, Any]] = None + self._name_str: Optional[str] = None + self._friends_loading_status: Optional[ba.Actor] = None + self._score_loading_status: Optional[ba.Actor] = None + self._tournament_time_remaining_text_timer: Optional[ba.Timer] = None + + self._player_info = settings['player_info'] + self._score = settings['score'] + self._fail_message = settings['fail_message'] + self._begin_time = ba.time() + if 'score_order' in settings: + if not settings['score_order'] in ['increasing', 'decreasing']: + raise Exception("Invalid score order: " + + settings['score_order']) + self._score_order = settings['score_order'] + else: + self._score_order = 'increasing' + + if 'score_type' in settings: + if not settings['score_type'] in ['points', 'time']: + raise Exception("Invalid score type: " + + settings['score_type']) + self._score_type = settings['score_type'] + else: + self._score_type = 'points' + + self._campaign = settings['campaign'] + self._level_name = settings['level'] + + self._game_name_str = self._campaign.get_name( + ) + ":" + self._level_name + self._game_config_str = str(len( + self._player_info)) + "p" + self._campaign.get_level( + self._level_name).get_score_version_string().replace(' ', '_') + + # If game-center/etc scores are available we show our friends' + # scores. Otherwise we show our local high scores. + self._show_friend_scores = _ba.game_service_has_leaderboard( + self._game_name_str, self._game_config_str) + + try: + self._old_best_rank = self._campaign.get_level( + self._level_name).get_rating() + except Exception: + self._old_best_rank = 0.0 + + self._victory = (settings['outcome'] == 'victory') + + def __del__(self) -> None: + super().__del__() + + # If our UI is still up, kill it. + if self._root_ui: + with ba.Context('ui'): + ba.containerwidget(edit=self._root_ui, transition='out_left') + + def on_transition_in(self) -> None: + from bastd.actor import background # FIXME NO BSSTD + ba.set_analytics_screen('Coop Score Screen') + super().on_transition_in() + self._background = background.Background(fade_time=0.45, + start_faded=False, + show_logo=True) + + def _ui_menu(self) -> None: + from bastd.ui import specialoffer + if specialoffer.show_offer(): + return + ba.containerwidget(edit=self._root_ui, transition='out_left') + with ba.Context(self): + ba.timer(0.1, ba.Call(ba.WeakCall(self.session.end))) + + def _ui_restart(self) -> None: + from bastd.ui.tournamententry import TournamentEntryWindow + from bastd.ui import specialoffer + if specialoffer.show_offer(): + return + + # If we're in a tournament and it looks like there's no time left, + # disallow. + if self.session.tournament_id is not None: + if self._tournament_time_remaining is None: + ba.screenmessage( + ba.Lstr(resource='tournamentCheckingStateText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + if self._tournament_time_remaining <= 0: + ba.screenmessage(ba.Lstr(resource='tournamentEndedText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + # If there are currently fewer players than our session min, + # don't allow. + if len(self.players) < self.session.min_players: + ba.screenmessage(ba.Lstr(resource='notEnoughPlayersRemainingText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + self._campaign.set_selected_level(self._level_name) + + # If this is a tournament, go back to the tournament-entry UI + # otherwise just hop back in. + tournament_id = self.session.tournament_id + if tournament_id is not None: + assert self._restart_button is not None + TournamentEntryWindow( + tournament_id=tournament_id, + tournament_activity=self, + position=self._restart_button.get_screen_space_center()) + else: + ba.containerwidget(edit=self._root_ui, transition='out_left') + self.can_show_ad_on_death = True + with ba.Context(self): + self.end({'outcome': 'restart'}) + + def _ui_next(self) -> None: + from bastd.ui.specialoffer import show_offer + if show_offer(): + return + + # If we didn't just complete this level but are choosing to play the + # next one, set it as current (this won't happen otherwise). + if (self._is_complete and self._is_more_levels + and not self._newly_complete): + self._campaign.set_selected_level(self._next_level_name) + ba.containerwidget(edit=self._root_ui, transition='out_left') + with ba.Context(self): + self.end({'outcome': 'next_level'}) + + def _ui_gc(self) -> None: + _ba.show_online_score_ui('leaderboard', + game=self._game_name_str, + game_version=self._game_config_str) + + def _ui_show_achievements(self) -> None: + _ba.show_online_score_ui('achievements') + + def _ui_worlds_best(self) -> None: + if self._score_link is None: + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource='scoreListUnavailableText'), + color=(1, 0.5, 0)) + else: + ba.open_url(self._score_link) + + def _ui_error(self) -> None: + from bastd.actor.text import Text + with ba.Context(self): + self._next_level_error = Text( + ba.Lstr(resource='completeThisLevelToProceedText'), + flash=True, + maxwidth=360, + scale=0.54, + h_align='center', + color=(0.5, 0.7, 0.5, 1), + position=(300, -235)) + ba.playsound(ba.getsound('error')) + ba.timer( + 2.0, + ba.WeakCall(self._next_level_error.handlemessage, + ba.DieMessage())) + + def _should_show_worlds_best_button(self) -> bool: + # link is too complicated to display with no browser + return ba.is_browser_likely_available() + + def request_ui(self) -> None: + """Set up a callback to show our UI at the next opportune time.""" + # We don't want to just show our UI in case the user already has the + # main menu up, so instead we add a callback for when the menu + # closes; if we're still alive, we'll come up then. + # If there's no main menu this gets called immediately. + ba.app.add_main_menu_close_callback(ba.WeakCall(self.show_ui)) + + def show_ui(self) -> None: + """Show the UI for restarting, playing the next Level, etc.""" + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + from bastd.ui.store.button import StoreButton + from bastd.ui.league.rankbutton import LeagueRankButton + + delay = 0.7 if (self._score is not None) else 0.0 + + # If there's no players left in the game, lets not show the UI + # (that would allow restarting the game with zero players, etc). + + # Hmmm shouldn't need this try/except here i don't think. + try: + players = self.players + except Exception as exc: + print(('EXC bs_coop_game show_ui cant get ' + 'self.players; shouldn\'t happen:'), exc) + players = [] + if not players: + return + + rootc = self._root_ui = ba.containerwidget(size=(0, 0), + transition='in_right') + + h_offs = 7.0 + v_offs = -280.0 + + # We wanna prevent controllers users from popping up browsers + # or game-center widgets in cases where they can't easily get back + # to the game (like on mac). + can_select_extra_buttons = ba.app.platform == 'android' + + _ba.set_ui_input_device(None) # Menu is up for grabs. + + if self._show_friend_scores: + ba.buttonwidget(parent=rootc, + color=(0.45, 0.4, 0.5), + position=(h_offs - 520, v_offs + 480), + size=(300, 60), + label=ba.Lstr(resource='topFriendsText'), + on_activate_call=ba.WeakCall(self._ui_gc), + transition_delay=delay + 0.5, + icon=self._game_service_leaderboards_texture, + icon_color=self._game_service_icon_color, + autoselect=True, + selectable=can_select_extra_buttons) + + if self._have_achievements and self._account_has_achievements: + ba.buttonwidget(parent=rootc, + color=(0.45, 0.4, 0.5), + position=(h_offs - 520, v_offs + 450 - 235 + 40), + size=(300, 60), + label=ba.Lstr(resource='achievementsText'), + on_activate_call=ba.WeakCall( + self._ui_show_achievements), + transition_delay=delay + 1.5, + icon=self._game_service_achievements_texture, + icon_color=self._game_service_icon_color, + autoselect=True, + selectable=can_select_extra_buttons) + + if self._should_show_worlds_best_button(): + ba.buttonwidget( + parent=rootc, + color=(0.45, 0.4, 0.5), + position=(160, v_offs + 480), + size=(350, 62), + label=ba.Lstr(resource='tournamentStandingsText') + if self.session.tournament_id is not None else ba.Lstr( + resource='worldsBestScoresText') + if self._score_type == 'points' else ba.Lstr( + resource='worldsBestTimesText'), + autoselect=True, + on_activate_call=ba.WeakCall(self._ui_worlds_best), + transition_delay=delay + 1.9, + selectable=can_select_extra_buttons) + else: + pass + + show_next_button = self._is_more_levels and not ba.app.kiosk_mode + + if not show_next_button: + h_offs += 70 + + menu_button = ba.buttonwidget(parent=rootc, + autoselect=True, + position=(h_offs - 130 - 60, v_offs), + size=(110, 85), + label='', + on_activate_call=ba.WeakCall( + self._ui_menu)) + ba.imagewidget(parent=rootc, + draw_controller=menu_button, + position=(h_offs - 130 - 60 + 22, v_offs + 14), + size=(60, 60), + texture=self._menu_icon_texture, + opacity=0.8) + self._restart_button = restart_button = ba.buttonwidget( + parent=rootc, + autoselect=True, + position=(h_offs - 60, v_offs), + size=(110, 85), + label='', + on_activate_call=ba.WeakCall(self._ui_restart)) + ba.imagewidget(parent=rootc, + draw_controller=restart_button, + position=(h_offs - 60 + 19, v_offs + 7), + size=(70, 70), + texture=self._replay_icon_texture, + opacity=0.8) + + next_button: Optional[ba.Widget] = None + + # Our 'next' button is disabled if we haven't unlocked the next + # level yet and invisible if there is none. + if show_next_button: + if self._is_complete: + call = ba.WeakCall(self._ui_next) + button_sound = True + image_opacity = 0.8 + color = None + else: + call = ba.WeakCall(self._ui_error) + button_sound = False + image_opacity = 0.2 + color = (0.3, 0.3, 0.3) + next_button = ba.buttonwidget(parent=rootc, + autoselect=True, + position=(h_offs + 130 - 60, v_offs), + size=(110, 85), + label='', + on_activate_call=call, + color=color, + enable_sound=button_sound) + ba.imagewidget(parent=rootc, + draw_controller=next_button, + position=(h_offs + 130 - 60 + 12, v_offs + 5), + size=(80, 80), + texture=self._next_level_icon_texture, + opacity=image_opacity) + + x_offs_extra = 0 if show_next_button else -100 + self._corner_button_offs = (h_offs + 300.0 + 100.0 + x_offs_extra, + v_offs + 560.0) + + if ba.app.kiosk_mode: + self._league_rank_button = None + self._store_button_instance = None + else: + self._league_rank_button = LeagueRankButton( + parent=rootc, + position=(h_offs + 300 + 100 + x_offs_extra, v_offs + 560), + size=(100, 60), + scale=0.9, + color=(0.4, 0.4, 0.9), + textcolor=(0.9, 0.9, 2.0), + transition_delay=0.0, + smooth_update_delay=5.0) + self._store_button_instance = StoreButton( + parent=rootc, + position=(h_offs + 400 + 100 + x_offs_extra, v_offs + 560), + show_tickets=True, + sale_scale=0.85, + size=(100, 60), + scale=0.9, + button_type='square', + color=(0.35, 0.25, 0.45), + textcolor=(0.9, 0.7, 1.0), + transition_delay=0.0) + + ba.containerwidget(edit=rootc, + selected_child=next_button if + (self._newly_complete and self._victory + and show_next_button) else restart_button, + on_cancel_call=menu_button.activate) + + self._update_corner_button_positions() + self._update_corner_button_positions_timer = ba.Timer( + 1.0, + ba.WeakCall(self._update_corner_button_positions), + repeat=True, + timetype=ba.TimeType.REAL) + + def _update_corner_button_positions(self) -> None: + offs = -55 if _ba.is_party_icon_visible() else 0 + assert self._corner_button_offs is not None + pos_x = self._corner_button_offs[0] + offs + pos_y = self._corner_button_offs[1] + if self._league_rank_button is not None: + self._league_rank_button.set_position((pos_x, pos_y)) + if self._store_button_instance is not None: + self._store_button_instance.set_position((pos_x + 100, pos_y)) + + def on_begin(self) -> None: + # FIXME: clean this up + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + from bastd.actor.text import Text + from bastd.actor.zoomtext import ZoomText + super().on_begin() + + # Calc whether the level is complete and other stuff. + levels = self._campaign.get_levels() + level = self._campaign.get_level(self._level_name) + self._was_complete = level.get_complete() + self._is_complete = (self._was_complete or self._victory) + self._newly_complete = (self._is_complete and not self._was_complete) + self._is_more_levels = ((level.get_index() < len(levels) - 1) + and self._campaign.is_sequential()) + + # Any time we complete a level, set the next one as unlocked. + if self._is_complete and self._is_more_levels: + _ba.add_transaction({ + 'type': 'COMPLETE_LEVEL', + 'campaign': self._campaign.get_name(), + 'level': self._level_name + }) + self._next_level_name = levels[level.get_index() + 1].get_name() + + # If this is the first time we completed it, set the next one + # as current. + if self._newly_complete: + cfg = ba.app.config + cfg['Selected Coop Game'] = (self._campaign.get_name() + ":" + + self._next_level_name) + cfg.commit() + self._campaign.set_selected_level(self._next_level_name) + + ba.timer(1.0, ba.WeakCall(self.request_ui)) + + if (self._is_complete and self._victory and self._is_more_levels + and not ba.app.kiosk_mode): + Text(ba.Lstr(value='${A}:\n', + subs=[('${A}', ba.Lstr(resource='levelUnlockedText')) + ]) if self._newly_complete else + ba.Lstr(value='${A}:\n', + subs=[('${A}', ba.Lstr(resource='nextLevelText'))]), + transition='in_right', + transition_delay=5.2, + flash=self._newly_complete, + scale=0.54, + h_align='center', + maxwidth=270, + color=(0.5, 0.7, 0.5, 1), + position=(270, -235)).autoretain() + Text(ba.Lstr(translate=('coopLevelNames', self._next_level_name)), + transition='in_right', + transition_delay=5.2, + flash=self._newly_complete, + scale=0.7, + h_align='center', + maxwidth=205, + color=(0.5, 0.7, 0.5, 1), + position=(270, -255)).autoretain() + if self._newly_complete: + ba.timer(5.2, ba.Call(ba.playsound, self._cashregistersound)) + ba.timer(5.2, ba.Call(ba.playsound, self._dingsound)) + + offs_x = -195 + if len(self._player_info) > 1: + pstr = ba.Lstr(value='- ${A} -', + subs=[('${A}', + ba.Lstr(resource='multiPlayerCountText', + subs=[('${COUNT}', + str(len(self._player_info))) + ]))]) + else: + pstr = ba.Lstr(value='- ${A} -', + subs=[('${A}', + ba.Lstr(resource='singlePlayerCountText'))]) + ZoomText(self._campaign.get_level( + self._level_name).get_display_string(), + maxwidth=800, + flash=False, + trail=False, + color=(0.5, 1, 0.5, 1), + h_align='center', + scale=0.4, + position=(0, 292), + jitter=1.0).autoretain() + Text(pstr, + maxwidth=300, + transition='fade_in', + scale=0.7, + h_align='center', + v_align='center', + color=(0.5, 0.7, 0.5, 1), + position=(0, 230)).autoretain() + + adisp = _ba.get_account_display_string() + txt = Text(ba.Lstr(resource='waitingForHostText', + subs=[('${HOST}', adisp)]), + maxwidth=300, + transition='fade_in', + transition_delay=8.0, + scale=0.85, + h_align='center', + v_align='center', + color=(1, 1, 0, 1), + position=(0, -230)).autoretain() + assert txt.node + txt.node.client_only = True + + if self._score is not None: + ba.timer(0.35, + ba.Call(ba.playsound, self._score_display_sound_small)) + + # Vestigial remain.. this stuff should just be instance vars. + self._show_info = {} + + if self._score is not None: + ba.timer(0.8, ba.WeakCall(self._show_score_val, offs_x)) + else: + ba.pushcall(ba.WeakCall(self._show_fail)) + + self._name_str = name_str = ', '.join( + [p['name'] for p in self._player_info]) + + if self._show_friend_scores: + self._friends_loading_status = Text(ba.Lstr( + value='${A}...', + subs=[('${A}', ba.Lstr(resource='loadingText'))]), + position=(-405, 150 + 30), + color=(1, 1, 1, 0.4), + transition='fade_in', + scale=0.7, + transition_delay=2.0) + self._score_loading_status = Text(ba.Lstr( + value='${A}...', subs=[('${A}', ba.Lstr(resource='loadingText'))]), + position=(280, 150 + 30), + color=(1, 1, 1, 0.4), + transition='fade_in', + scale=0.7, + transition_delay=2.0) + + if self._score is not None: + ba.timer(0.4, ba.WeakCall(self._play_drumroll)) + + # Add us to high scores, filter, and store. + our_high_scores_all = self._campaign.get_level( + self._level_name).get_high_scores() + try: + our_high_scores = our_high_scores_all[str(len(self._player_info)) + + " Player"] + except Exception: + our_high_scores = our_high_scores_all[str(len(self._player_info)) + + " Player"] = [] + + if self._score is not None: + our_score: Optional[list] = [ + self._score, { + 'players': self._player_info + } + ] + our_high_scores.append(our_score) + else: + our_score = None + + try: + our_high_scores.sort(reverse=self._score_order == 'increasing') + except Exception: + ba.print_exception('Error sorting scores') + print('our_high_scores:', our_high_scores) + + del our_high_scores[10:] + + if self._score is not None: + sver = (self._campaign.get_level( + self._level_name).get_score_version_string()) + _ba.add_transaction({ + 'type': 'SET_LEVEL_LOCAL_HIGH_SCORES', + 'campaign': self._campaign.get_name(), + 'level': self._level_name, + 'scoreVersion': sver, + 'scores': our_high_scores_all + }) + if _ba.get_account_state() != 'signed_in': + # we expect this only in kiosk mode; complain otherwise.. + if not ba.app.kiosk_mode: + print('got not-signed-in at score-submit; unexpected') + if self._show_friend_scores: + ba.pushcall(ba.WeakCall(self._got_friend_score_results, None)) + ba.pushcall(ba.WeakCall(self._got_score_results, None)) + else: + assert self._game_name_str is not None + assert self._game_config_str is not None + _ba.submit_score(self._game_name_str, + self._game_config_str, + name_str, + self._score, + ba.WeakCall(self._got_score_results), + ba.WeakCall(self._got_friend_score_results) + if self._show_friend_scores else None, + order=self._score_order, + tournament_id=self.session.tournament_id, + score_type=self._score_type, + campaign=self._campaign.get_name() + if self._campaign is not None else None, + level=self._level_name) + + # Apply the transactions we've been adding locally. + _ba.run_transactions() + + # If we're not doing the world's-best button, just show a title + # instead. + ts_height = 300 + ts_h_offs = 210 + v_offs = 40 + txt = Text(ba.Lstr(resource='tournamentStandingsText') + if self.session.tournament_id is not None else ba.Lstr( + resource='worldsBestScoresText') + if self._score_type == 'points' else ba.Lstr( + resource='worldsBestTimesText'), + maxwidth=210, + position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20), + transition='in_left', + v_align='center', + scale=1.2, + transition_delay=2.2).autoretain() + + # If we've got a button on the server, only show this on clients. + if self._should_show_worlds_best_button(): + assert txt.node + txt.node.client_only = True + + # If we have no friend scores, display local best scores. + if self._show_friend_scores: + + # Host has a button, so we need client-only text. + ts_height = 300 + ts_h_offs = -480 + v_offs = 40 + txt = Text(ba.Lstr(resource='topFriendsText'), + maxwidth=210, + position=(ts_h_offs - 10, + ts_height / 2 + 25 + v_offs + 20), + transition='in_right', + v_align='center', + scale=1.2, + transition_delay=1.8).autoretain() + assert txt.node + txt.node.client_only = True + else: + + ts_height = 300 + ts_h_offs = -480 + v_offs = 40 + Text(ba.Lstr(resource='yourBestScoresText') + if self._score_type == 'points' else ba.Lstr( + resource='yourBestTimesText'), + maxwidth=210, + position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20), + transition='in_right', + v_align='center', + scale=1.2, + transition_delay=1.8).autoretain() + + display_scores = list(our_high_scores) + display_count = 5 + + while len(display_scores) < display_count: + display_scores.append((0, None)) + + showed_ours = False + h_offs_extra = 85 if self._score_type == 'points' else 130 + v_offs_extra = 20 + v_offs_names = 0 + scale = 1.0 + p_count = len(self._player_info) + h_offs_extra -= 75 + if p_count > 1: + h_offs_extra -= 20 + if p_count == 2: + scale = 0.9 + elif p_count == 3: + scale = 0.65 + elif p_count == 4: + scale = 0.5 + times: List[Tuple[float, float]] = [] + for i in range(display_count): + times.insert(random.randrange(0, + len(times) + 1), + (1.9 + i * 0.05, 2.3 + i * 0.05)) + for i in range(display_count): + try: + name_str = ', '.join( + [p['name'] for p in display_scores[i][1]['players']]) + except Exception: + name_str = '-' + if display_scores[i] == our_score and not showed_ours: + flash = True + color0 = (0.6, 0.4, 0.1, 1.0) + color1 = (0.6, 0.6, 0.6, 1.0) + tdelay1 = 3.7 + tdelay2 = 3.7 + showed_ours = True + else: + flash = False + color0 = (0.6, 0.4, 0.1, 1.0) + color1 = (0.6, 0.6, 0.6, 1.0) + tdelay1 = times[i][0] + tdelay2 = times[i][1] + Text(str(display_scores[i][0]) if self._score_type == 'points' + else ba.timestring(display_scores[i][0] * 10, + timeformat=ba.TimeFormat.MILLISECONDS), + position=(ts_h_offs + 20 + h_offs_extra, + v_offs_extra + ts_height / 2 + -ts_height * + (i + 1) / 10 + v_offs + 11.0), + h_align='right', + v_align='center', + color=color0, + flash=flash, + transition='in_right', + transition_delay=tdelay1).autoretain() + + Text(ba.Lstr(value=name_str), + position=(ts_h_offs + 35 + h_offs_extra, + v_offs_extra + ts_height / 2 + -ts_height * + (i + 1) / 10 + v_offs_names + v_offs + 11.0), + maxwidth=80.0 + 100.0 * len(self._player_info), + v_align='center', + color=color1, + flash=flash, + scale=scale, + transition='in_right', + transition_delay=tdelay2).autoretain() + + # Show achievements for this level. + ts_height = -150 + ts_h_offs = -480 + v_offs = 40 + + # Only make this if we don't have the button + # (never want clients to see it so no need for client-only + # version, etc). + if self._have_achievements: + if not self._account_has_achievements: + Text(ba.Lstr(resource='achievementsText'), + position=(ts_h_offs - 10, + ts_height / 2 + 25 + v_offs + 3), + maxwidth=210, + host_only=True, + transition='in_right', + v_align='center', + scale=1.2, + transition_delay=2.8).autoretain() + + assert self._game_name_str is not None + achievements = get_achievements_for_coop_level(self._game_name_str) + hval = -455 + vval = -100 + tdelay = 0.0 + for ach in achievements: + ach.create_display(hval, vval + v_offs, 3.0 + tdelay) + vval -= 55 + tdelay += 0.250 + + ba.timer(5.0, ba.WeakCall(self._show_tips)) + + def _play_drumroll(self) -> None: + ba.Actor( + ba.newnode('sound', + attrs={ + 'sound': self.drum_roll_sound, + 'positional': False, + 'loop': False + })).autoretain() + + def _got_friend_score_results(self, results: Optional[List[Any]]) -> None: + + # FIXME: tidy this up + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # delay a bit if results come in too fast + from bastd.actor.text import Text + base_delay = max(0, 1.9 - (ba.time() - self._begin_time)) + ts_height = 300 + ts_h_offs = -550 + v_offs = 30 + + # Report in case of error. + if results is None: + self._friends_loading_status = Text( + ba.Lstr(resource='friendScoresUnavailableText'), + maxwidth=330, + position=(-475, 150 + v_offs), + color=(1, 1, 1, 0.4), + transition='fade_in', + transition_delay=base_delay + 0.8, + scale=0.7) + return + + self._friends_loading_status = None + + # Ok, it looks like we aren't able to reliably get a just-submitted + # result returned in the score list, so we need to look for our score + # in this list and replace it if ours is better or add ours otherwise. + if self._score is not None: + our_score_entry = [self._score, 'Me', True] + for score in results: + if score[2]: + if self._score_order == 'increasing': + our_score_entry[0] = max(score[0], self._score) + else: + our_score_entry[0] = min(score[0], self._score) + results.remove(score) + break + results.append(our_score_entry) + results.sort(reverse=self._score_order == 'increasing') + + # If we're not submitting our own score, we still want to change the + # name of our own score to 'Me'. + else: + for score in results: + if score[2]: + score[1] = 'Me' + break + h_offs_extra = 80 if self._score_type == 'points' else 130 + v_offs_extra = 20 + v_offs_names = 0 + scale = 1.0 + + # Make sure there's at least 5. + while len(results) < 5: + results.append([0, '-', False]) + results = results[:5] + times: List[Tuple[float, float]] = [] + for i in range(len(results)): + times.insert(random.randrange(0, + len(times) + 1), + (base_delay + i * 0.05, base_delay + 0.3 + i * 0.05)) + for i, tval in enumerate(results): + score = int(tval[0]) + name_str = tval[1] + is_me = tval[2] + if is_me and score == self._score: + flash = True + color0 = (0.6, 0.4, 0.1, 1.0) + color1 = (0.6, 0.6, 0.6, 1.0) + tdelay1 = base_delay + 1.0 + tdelay2 = base_delay + 1.0 + else: + flash = False + if is_me: + color0 = (0.6, 0.4, 0.1, 1.0) + color1 = (0.9, 1.0, 0.9, 1.0) + else: + color0 = (0.6, 0.4, 0.1, 1.0) + color1 = (0.6, 0.6, 0.6, 1.0) + tdelay1 = times[i][0] + tdelay2 = times[i][1] + if name_str != '-': + Text(str(score) if self._score_type == 'points' else + ba.timestring(score * 10, + timeformat=ba.TimeFormat.MILLISECONDS), + position=(ts_h_offs + 20 + h_offs_extra, + v_offs_extra + ts_height / 2 + -ts_height * + (i + 1) / 10 + v_offs + 11.0), + h_align='right', + v_align='center', + color=color0, + flash=flash, + transition='in_right', + transition_delay=tdelay1).autoretain() + else: + if is_me: + print('Error: got empty name_str on score result:', tval) + + Text(ba.Lstr(value=name_str), + position=(ts_h_offs + 35 + h_offs_extra, + v_offs_extra + ts_height / 2 + -ts_height * + (i + 1) / 10 + v_offs_names + v_offs + 11.0), + color=color1, + maxwidth=160.0, + v_align='center', + flash=flash, + scale=scale, + transition='in_right', + transition_delay=tdelay2).autoretain() + + def _got_score_results(self, results: Optional[Dict[str, Any]]) -> None: + + # FIXME: tidy this up + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + + # We need to manually run this in the context of our activity + # and only if we aren't shutting down. + # (really should make the submit_score call handle that stuff itself) + from bastd.actor.text import Text + if self.is_expired(): + return + with ba.Context(self): + # Delay a bit if results come in too fast. + base_delay = max(0, 2.7 - (ba.time() - self._begin_time)) + v_offs = 20 + if results is None: + self._score_loading_status = Text( + ba.Lstr(resource='worldScoresUnavailableText'), + position=(230, 150 + v_offs), + color=(1, 1, 1, 0.4), + transition='fade_in', + transition_delay=base_delay + 0.3, + scale=0.7) + else: + self._score_link = results['link'] + assert self._score_link is not None + if not self._score_link.startswith('http://'): + self._score_link = (_ba.get_master_server_address() + "/" + + self._score_link) + self._score_loading_status = None + if 'tournamentSecondsRemaining' in results: + self._tournament_time_remaining = ( + results['tournamentSecondsRemaining']) + self._tournament_time_remaining_text_timer = ba.Timer( + 1.0, + ba.WeakCall( + self._update_tournament_time_remaining_text), + repeat=True, + timetype=ba.TimeType.BASE) + + assert self._show_info is not None + self._show_info['results'] = results + if results is not None: + if results['tops'] != '': + self._show_info['tops'] = results['tops'] + else: + self._show_info['tops'] = [] + offs_x = -195 + available = (self._show_info['results'] is not None) + if self._score is not None: + ba.timer((1.5 + base_delay), + ba.WeakCall(self._show_world_rank, offs_x), + timetype=ba.TimeType.BASE) + ts_h_offs = 200 + ts_height = 300 + + # Show world tops. + if available: + + # Show the number of games represented by this + # list (except for in tournaments). + if self.session.tournament_id is None: + Text(ba.Lstr(resource='lastGamesText', + subs=[ + ('${COUNT}', + str(self._show_info['results']['total'])) + ]), + position=(ts_h_offs - 35 + 95, + ts_height / 2 + 6 + v_offs), + color=(0.4, 0.4, 0.4, 1.0), + scale=0.7, + transition='in_right', + transition_delay=base_delay + 0.3).autoretain() + else: + v_offs += 20 + + h_offs_extra = 0 + v_offs_names = 0 + scale = 1.0 + p_count = len(self._player_info) + if p_count > 1: + h_offs_extra -= 40 + if self._score_type != 'points': + h_offs_extra += 60 + if p_count == 2: + scale = 0.9 + elif p_count == 3: + scale = 0.65 + elif p_count == 4: + scale = 0.5 + + # make sure there's at least 10.. + while len(self._show_info['tops']) < 10: + self._show_info['tops'].append([0, '-']) + + times: List[Tuple[float, float]] = [] + for i in range(len(self._show_info['tops'])): + times.insert( + random.randrange(0, + len(times) + 1), + (base_delay + i * 0.05, base_delay + 0.4 + i * 0.05)) + for i, tval in enumerate(self._show_info['tops']): + score = int(tval[0]) + name_str = tval[1] + if self._name_str == name_str and self._score == score: + flash = True + color0 = (0.6, 0.4, 0.1, 1.0) + color1 = (0.6, 0.6, 0.6, 1.0) + tdelay1 = base_delay + 1.0 + tdelay2 = base_delay + 1.0 + else: + flash = False + if self._name_str == name_str: + color0 = (0.6, 0.4, 0.1, 1.0) + color1 = (0.9, 1.0, 0.9, 1.0) + else: + color0 = (0.6, 0.4, 0.1, 1.0) + color1 = (0.6, 0.6, 0.6, 1.0) + tdelay1 = times[i][0] + tdelay2 = times[i][1] + + if name_str != '-': + Text(str(score) if self._score_type == 'points' else + ba.timestring( + score * 10, + timeformat=ba.TimeFormat.MILLISECONDS), + position=(ts_h_offs + 20 + h_offs_extra, + ts_height / 2 + -ts_height * + (i + 1) / 10 + v_offs + 11.0), + h_align='right', + v_align='center', + color=color0, + flash=flash, + transition='in_left', + transition_delay=tdelay1).autoretain() + Text(ba.Lstr(value=name_str), + position=(ts_h_offs + 35 + h_offs_extra, + ts_height / 2 + -ts_height * (i + 1) / 10 + + v_offs_names + v_offs + 11.0), + maxwidth=80.0 + 100.0 * len(self._player_info), + v_align='center', + color=color1, + flash=flash, + scale=scale, + transition='in_left', + transition_delay=tdelay2).autoretain() + + def _show_tips(self) -> None: + from bastd.actor.tipstext import TipsText + TipsText(offs_y=30).autoretain() + + def _update_tournament_time_remaining_text(self) -> None: + if self._tournament_time_remaining is None: + return + self._tournament_time_remaining = max( + 0, self._tournament_time_remaining - 1) + if self._tournament_time_remaining_text is not None: + val = ba.timestring(self._tournament_time_remaining, centi=False) + self._tournament_time_remaining_text.node.text = val + + def _show_world_rank(self, offs_x: float) -> None: + # FIXME: Tidy this up. + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + from ba.internal import get_tournament_prize_strings + from bastd.actor.text import Text + from bastd.actor.zoomtext import ZoomText + assert self._show_info is not None + available = (self._show_info['results'] is not None) + if available: + error = (self._show_info['results']['error'] + if 'error' in self._show_info['results'] else None) + rank = self._show_info['results']['rank'] + total = self._show_info['results']['total'] + rating = 10.0 if total == 1 else round( + 10.0 * (1.0 - (float(rank - 1) / (total - 1))), 1) + player_rank = self._show_info['results']['playerRank'] + best_player_rank = self._show_info['results']['bestPlayerRank'] + else: + error = False + rating = None + player_rank = None + best_player_rank = None + + # If we've got tournament-seconds-remaining, show it. + if self._tournament_time_remaining is not None: + Text(ba.Lstr(resource='coopSelectWindow.timeRemainingText'), + position=(-360, -70 - 100), + color=(1, 1, 1, 0.7), + h_align='center', + v_align='center', + transition='fade_in', + scale=0.8, + maxwidth=300, + transition_delay=2.0).autoretain() + self._tournament_time_remaining_text = Text('', + position=(-360, + -110 - 100), + color=(1, 1, 1, 0.7), + h_align='center', + v_align='center', + transition='fade_in', + scale=1.6, + maxwidth=150, + transition_delay=2.0) + + # If we're a tournament, show prizes. + try: + tournament_id = self.session.tournament_id + if tournament_id is not None: + if tournament_id in ba.app.tournament_info: + tourney_info = ba.app.tournament_info[tournament_id] + # pylint: disable=unbalanced-tuple-unpacking + pr1, pv1, pr2, pv2, pr3, pv3 = ( + get_tournament_prize_strings(tourney_info)) + # pylint: enable=unbalanced-tuple-unpacking + Text(ba.Lstr(resource='coopSelectWindow.prizesText'), + position=(-360, -70 + 77), + color=(1, 1, 1, 0.7), + h_align='center', + v_align='center', + transition='fade_in', + scale=1.0, + maxwidth=300, + transition_delay=2.0).autoretain() + vval = -107 + 70 + for rng, val in ((pr1, pv1), (pr2, pv2), (pr3, pv3)): + Text(rng, + position=(-410 + 10, vval), + color=(1, 1, 1, 0.7), + h_align='right', + v_align='center', + transition='fade_in', + scale=0.6, + maxwidth=300, + transition_delay=2.0).autoretain() + Text(val, + position=(-390 + 10, vval), + color=(0.7, 0.7, 0.7, 1.0), + h_align='left', + v_align='center', + transition='fade_in', + scale=0.8, + maxwidth=300, + transition_delay=2.0).autoretain() + vval -= 35 + except Exception: + ba.print_exception("error showing prize ranges") + + if self._do_new_rating: + if error: + ZoomText(ba.Lstr(resource='failText'), + flash=True, + trail=True, + scale=1.0 if available else 0.333, + tilt_translate=0.11, + h_align='center', + position=(190 + offs_x, -60), + maxwidth=200, + jitter=1.0).autoretain() + Text(ba.Lstr(translate=('serverResponses', error)), + position=(0, -140), + color=(1, 1, 1, 0.7), + h_align='center', + v_align='center', + transition='fade_in', + scale=0.9, + maxwidth=400, + transition_delay=1.0).autoretain() + else: + ZoomText((('#' + str(player_rank)) if player_rank is not None + else ba.Lstr(resource='unavailableText')), + flash=True, + trail=True, + scale=1.0 if available else 0.333, + tilt_translate=0.11, + h_align='center', + position=(190 + offs_x, -60), + maxwidth=200, + jitter=1.0).autoretain() + + Text(ba.Lstr(value='${A}:', + subs=[('${A}', ba.Lstr(resource='rankText'))]), + position=(0, 36), + maxwidth=300, + transition='fade_in', + h_align='center', + v_align='center', + transition_delay=0).autoretain() + if best_player_rank is not None: + Text(ba.Lstr(resource='currentStandingText', + fallback_resource='bestRankText', + subs=[('${RANK}', str(best_player_rank))]), + position=(0, -155), + color=(1, 1, 1, 0.7), + h_align='center', + transition='fade_in', + scale=0.7, + transition_delay=1.0).autoretain() + else: + ZoomText((str(rating) if available else ba.Lstr( + resource='unavailableText')), + flash=True, + trail=True, + scale=0.6 if available else 0.333, + tilt_translate=0.11, + h_align='center', + position=(190 + offs_x, -94), + maxwidth=200, + jitter=1.0).autoretain() + + if available: + if rating >= 9.5: + stars = 3 + elif rating >= 7.5: + stars = 2 + elif rating > 0.0: + stars = 1 + else: + stars = 0 + star_tex = ba.gettexture('star') + star_x = 135 + offs_x + for _i in range(stars): + img = ba.Actor( + ba.newnode('image', + attrs={ + 'texture': star_tex, + 'position': (star_x, -16), + 'scale': (62, 62), + 'opacity': 1.0, + 'color': (2.2, 1.2, 0.3), + 'absolute_scale': True + })).autoretain() + + assert img.node + ba.animate(img.node, 'opacity', {0.15: 0, 0.4: 1}) + star_x += 60 + for _i in range(3 - stars): + img = ba.Actor( + ba.newnode('image', + attrs={ + 'texture': star_tex, + 'position': (star_x, -16), + 'scale': (62, 62), + 'opacity': 1.0, + 'color': (0.3, 0.3, 0.3), + 'absolute_scale': True + })).autoretain() + assert img.node + ba.animate(img.node, 'opacity', {0.15: 0, 0.4: 1}) + star_x += 60 + + def dostar(count: int, xval: float, offs_y: float, + score: str) -> None: + Text(score + ' =', + position=(xval, -64 + offs_y), + color=(0.6, 0.6, 0.6, 0.6), + h_align='center', + v_align='center', + transition='fade_in', + scale=0.4, + transition_delay=1.0).autoretain() + stx = xval + 20 + for _i2 in range(count): + img2 = ba.Actor( + ba.newnode('image', + attrs={ + 'texture': star_tex, + 'position': (stx, -64 + offs_y), + 'scale': (12, 12), + 'opacity': 0.7, + 'color': (2.2, 1.2, 0.3), + 'absolute_scale': True + })).autoretain() + assert img2.node + ba.animate(img2.node, 'opacity', {1.0: 0.0, 1.5: 0.5}) + stx += 13.0 + + dostar(1, -44 - 30, -112, '0.0') + dostar(2, 10 - 30, -112, '7.5') + dostar(3, 77 - 30, -112, '9.5') + try: + best_rank = self._campaign.get_level( + self._level_name).get_rating() + except Exception: + best_rank = 0.0 + + if available: + Text(ba.Lstr( + resource='outOfText', + subs=[('${RANK}', + str(int(self._show_info['results']['rank']))), + ('${ALL}', str(self._show_info['results']['total'])) + ]), + position=(0, -155 if self._newly_complete else -145), + color=(1, 1, 1, 0.7), + h_align='center', + transition='fade_in', + scale=0.55, + transition_delay=1.0).autoretain() + + new_best = (best_rank > self._old_best_rank and best_rank > 0.0) + was_string = ('' if self._old_best_rank is None else ba.Lstr( + value=' ${A}', + subs=[('${A}', ba.Lstr(resource='scoreWasText')), + ('${COUNT}', str(self._old_best_rank))])) + if not self._newly_complete: + Text(ba.Lstr(value='${A}${B}', + subs=[('${A}', + ba.Lstr(resource='newPersonalBestText')), + ('${B}', was_string)]) if new_best else + ba.Lstr(resource='bestRatingText', + subs=[('${RATING}', str(best_rank))]), + position=(0, -165), + color=(1, 1, 1, 0.7), + flash=new_best, + h_align='center', + transition='in_right' if new_best else 'fade_in', + scale=0.5, + transition_delay=1.0).autoretain() + + Text(ba.Lstr(value='${A}:', + subs=[('${A}', ba.Lstr(resource='ratingText'))]), + position=(0, 36), + maxwidth=300, + transition='fade_in', + h_align='center', + v_align='center', + transition_delay=0).autoretain() + + ba.timer(0.35, ba.Call(ba.playsound, self._score_display_sound)) + if not error: + ba.timer(0.35, ba.Call(ba.playsound, self.cymbal_sound)) + + def _show_fail(self) -> None: + from bastd.actor.text import Text + from bastd.actor.zoomtext import ZoomText + ZoomText(ba.Lstr(resource='failText'), + maxwidth=300, + flash=False, + trail=True, + h_align='center', + tilt_translate=0.11, + position=(0, 40), + jitter=1.0).autoretain() + if self._fail_message is not None: + Text(self._fail_message, + h_align='center', + position=(0, -130), + maxwidth=300, + color=(1, 1, 1, 0.5), + transition='fade_in', + transition_delay=1.0).autoretain() + ba.timer(0.35, ba.Call(ba.playsound, self._score_display_sound)) + + def _show_score_val(self, offs_x: float) -> None: + from bastd.actor.text import Text + from bastd.actor.zoomtext import ZoomText + ZoomText((str(self._score) if self._score_type == 'points' else + ba.timestring(self._score * 10, + timeformat=ba.TimeFormat.MILLISECONDS)), + maxwidth=300, + flash=True, + trail=True, + scale=1.0 if self._score_type == 'points' else 0.6, + h_align='center', + tilt_translate=0.11, + position=(190 + offs_x, 115), + jitter=1.0).autoretain() + Text(ba.Lstr( + value='${A}:', subs=[('${A}', ba.Lstr( + resource='finalScoreText'))]) if self._score_type == 'points' + else ba.Lstr(value='${A}:', + subs=[('${A}', ba.Lstr(resource='finalTimeText'))]), + maxwidth=300, + position=(0, 200), + transition='fade_in', + h_align='center', + v_align='center', + transition_delay=0).autoretain() + ba.timer(0.35, ba.Call(ba.playsound, self._score_display_sound)) diff --git a/assets/src/data/scripts/bastd/activity/drawscreen.py b/assets/src/data/scripts/bastd/activity/drawscreen.py new file mode 100644 index 00000000..8615e7b6 --- /dev/null +++ b/assets/src/data/scripts/bastd/activity/drawscreen.py @@ -0,0 +1,42 @@ +"""Functionality related to the draw screen.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.activity.teamsscorescreen import TeamsScoreScreenActivity + +if TYPE_CHECKING: + from typing import Any, Dict + + +class DrawScoreScreenActivity(TeamsScoreScreenActivity): + """Score screen shown after a draw.""" + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings=settings) + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME FIXME: unify args + # pylint: disable=arguments-differ + super().on_transition_in(music=None) + + # noinspection PyMethodOverriding + def on_begin(self) -> None: # type: ignore + # FIXME FIXME: unify args + # pylint: disable=arguments-differ + from bastd.actor.zoomtext import ZoomText + ba.set_analytics_screen('Draw Score Screen') + super().on_begin() + ZoomText(ba.Lstr(resource='drawText'), + position=(0, 0), + maxwidth=400, + shiftposition=(-220, 0), + shiftdelay=2.0, + flash=False, + trail=False, + jitter=1.0).autoretain() + ba.timer(0.35, ba.Call(ba.playsound, self._score_display_sound)) + self.show_player_scores(results=self.settings.get('results', None)) diff --git a/assets/src/data/scripts/bastd/activity/dualteamscorescreen.py b/assets/src/data/scripts/bastd/activity/dualteamscorescreen.py new file mode 100644 index 00000000..b84667f2 --- /dev/null +++ b/assets/src/data/scripts/bastd/activity/dualteamscorescreen.py @@ -0,0 +1,126 @@ +"""Functionality related to the end screen in dual-team mode.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.activity.teamsscorescreen import TeamsScoreScreenActivity +from bastd.actor.zoomtext import ZoomText + +if TYPE_CHECKING: + from typing import Any, Dict + + +class TeamVictoryScoreScreenActivity(TeamsScoreScreenActivity): + """Scorescreen between rounds of a dual-team session.""" + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings=settings) + + # noinspection PyMethodOverriding + def on_begin(self) -> None: # type: ignore + # FIXME: Unify args. + # pylint: disable=arguments-differ + from ba.deprecated import get_resource + ba.set_analytics_screen('Teams Score Screen') + super().on_begin() + + height = 130 + active_team_count = len(self.teams) + vval = (height * active_team_count) / 2 - height / 2 + i = 0 + shift_time = 2.5 + + # Usually we say 'Best of 7', but if the language prefers we can say + # 'First to 4'. + session = self.session + assert isinstance(session, ba.TeamBaseSession) + if get_resource('bestOfUseFirstToInstead'): + best_txt = ba.Lstr(resource='firstToSeriesText', + subs=[('${COUNT}', + str(session.get_series_length() / 2 + 1)) + ]) + else: + best_txt = ba.Lstr(resource='bestOfSeriesText', + subs=[('${COUNT}', + str(session.get_series_length()))]) + + ZoomText(best_txt, + position=(0, 175), + shiftposition=(-250, 175), + shiftdelay=2.5, + flash=False, + trail=False, + h_align='center', + scale=0.25, + color=(0.5, 0.5, 0.5, 1.0), + jitter=3.0).autoretain() + for team in self.teams: + ba.timer( + i * 0.15 + 0.15, + ba.WeakCall(self._show_team_name, vval - i * height, team, + i * 0.2, shift_time - (i * 0.150 + 0.150))) + ba.timer(i * 0.150 + 0.5, + ba.Call(ba.playsound, self._score_display_sound_small)) + scored = (team is self.settings['winner']) + delay = 0.2 + if scored: + delay = 1.2 + ba.timer( + i * 0.150 + 0.2, + ba.WeakCall(self._show_team_old_score, vval - i * height, + team, shift_time - (i * 0.15 + 0.2))) + ba.timer(i * 0.15 + 1.5, + ba.Call(ba.playsound, self._score_display_sound)) + + ba.timer( + i * 0.150 + delay, + ba.WeakCall(self._show_team_score, vval - i * height, team, + scored, i * 0.2 + 0.1, + shift_time - (i * 0.15 + delay))) + i += 1 + self.show_player_scores() + + def _show_team_name(self, pos_v: float, team: ba.Team, kill_delay: float, + shiftdelay: float) -> None: + del kill_delay # unused arg + ZoomText(ba.Lstr(value='${A}:', subs=[('${A}', team.name)]), + position=(100, pos_v), + shiftposition=(-150, pos_v), + shiftdelay=shiftdelay, + flash=False, + trail=False, + h_align='right', + maxwidth=300, + color=team.color, + jitter=1.0).autoretain() + + def _show_team_old_score(self, pos_v: float, team: ba.Team, + shiftdelay: float) -> None: + ZoomText(str(team.sessiondata['score'] - 1), + position=(150, pos_v), + maxwidth=100, + color=(0.6, 0.6, 0.7), + shiftposition=(-100, pos_v), + shiftdelay=shiftdelay, + flash=False, + trail=False, + lifespan=1.0, + h_align='left', + jitter=1.0).autoretain() + + def _show_team_score(self, pos_v: float, team: ba.Team, scored: bool, + kill_delay: float, shiftdelay: float) -> None: + del kill_delay # unused arg + ZoomText(str(team.sessiondata['score']), + position=(150, pos_v), + maxwidth=100, + color=(1.0, 0.9, 0.5) if scored else (0.6, 0.6, 0.7), + shiftposition=(-100, pos_v), + shiftdelay=shiftdelay, + flash=scored, + trail=scored, + h_align='left', + jitter=1.0, + trailcolor=(1, 0.8, 0.0, 0)).autoretain() diff --git a/assets/src/data/scripts/bastd/activity/freeforallendscreen.py b/assets/src/data/scripts/bastd/activity/freeforallendscreen.py new file mode 100644 index 00000000..81c511ea --- /dev/null +++ b/assets/src/data/scripts/bastd/activity/freeforallendscreen.py @@ -0,0 +1,258 @@ +"""Functionality related to the final screen in free-for-all games.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.activity.teamsscorescreen import TeamsScoreScreenActivity + +if TYPE_CHECKING: + from typing import Any, Dict, Optional, Set + + +class FreeForAllVictoryScoreScreenActivity(TeamsScoreScreenActivity): + """Score screen shown at the end of a free-for-all series.""" + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings=settings) + # keeps prev activity alive while we fade in + self.transition_time = 0.5 + self._cymbal_sound = ba.getsound('cymbal') + + # noinspection PyMethodOverriding + def on_begin(self) -> None: # type: ignore + # FIXME FIXME unify args + # pylint: disable=arguments-differ + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + from bastd.actor.text import Text + from bastd.actor import image + ba.set_analytics_screen('FreeForAll Score Screen') + super().on_begin() + + y_base = 100.0 + ts_h_offs = -305.0 + tdelay = 1.0 + scale = 1.2 + spacing = 37.0 + + # we include name and previous score in the sort to reduce the amount + # of random jumping around the list we do in cases of ties + player_order_prev = list(self.players) + player_order_prev.sort( + reverse=True, + key=lambda p: + (p.team.sessiondata['previous_score'], p.get_name(full=True))) + player_order = list(self.players) + player_order.sort(reverse=True, + key=lambda p: + (p.team.sessiondata['score'], p.team.sessiondata[ + 'score'], p.get_name(full=True))) + + v_offs = -74.0 + spacing * len(player_order_prev) * 0.5 + delay1 = 1.3 + 0.1 + delay2 = 2.9 + 0.1 + delay3 = 2.9 + 0.1 + order_change = player_order != player_order_prev + + if order_change: + delay3 += 1.5 + + ba.timer(0.3, ba.Call(ba.playsound, self._score_display_sound)) + self.show_player_scores(delay=0.001, + results=self.settings['results'], + scale=1.2, + x_offset=-110.0) + + sound_times: Set[float] = set() + + def _scoretxt(text: str, + x_offs: float, + y_offs: float, + highlight: bool, + delay: float, + extrascale: float, + flash: bool = False) -> Text: + return Text(text, + position=(ts_h_offs + x_offs * scale, + y_base + (y_offs + v_offs + 2.0) * scale), + scale=scale * extrascale, + color=((1.0, 0.7, 0.3, 1.0) if highlight else + (0.7, 0.7, 0.7, 0.7)), + h_align='right', + transition='in_left', + transition_delay=tdelay + delay, + flash=flash).autoretain() + + v_offs -= spacing + slide_amt = 0.0 + transtime = 0.250 + transtime2 = 0.250 + + session = self.session + assert isinstance(session, ba.FreeForAllSession) + title = Text(ba.Lstr(resource='firstToSeriesText', + subs=[('${COUNT}', + str(session.get_ffa_series_length()))]), + scale=1.05 * scale, + position=(ts_h_offs - 0.0 * scale, + y_base + (v_offs + 50.0) * scale), + h_align='center', + color=(0.5, 0.5, 0.5, 0.5), + transition='in_left', + transition_delay=tdelay).autoretain() + + v_offs -= 25 + v_offs_start = v_offs + + ba.timer( + tdelay + delay3, + ba.WeakCall( + self._safe_animate, title.position_combine, 'input0', { + 0.0: ts_h_offs - 0.0 * scale, + transtime2: ts_h_offs - (0.0 + slide_amt) * scale + })) + + for i, player in enumerate(player_order_prev): + v_offs_2 = v_offs_start - spacing * (player_order.index(player)) + ba.timer(tdelay + 0.3, + ba.Call(ba.playsound, self._score_display_sound_small)) + if order_change: + ba.timer(tdelay + delay2 + 0.1, + ba.Call(ba.playsound, self._cymbal_sound)) + img = image.Image(player.get_icon(), + position=(ts_h_offs - 72.0 * scale, + y_base + (v_offs + 15.0) * scale), + scale=(30.0 * scale, 30.0 * scale), + transition='in_left', + transition_delay=tdelay).autoretain() + ba.timer( + tdelay + delay2, + ba.WeakCall( + self._safe_animate, img.position_combine, 'input1', { + 0: y_base + (v_offs + 15.0) * scale, + transtime: y_base + (v_offs_2 + 15.0) * scale + })) + ba.timer( + tdelay + delay3, + ba.WeakCall( + self._safe_animate, img.position_combine, 'input0', { + 0: ts_h_offs - 72.0 * scale, + transtime2: ts_h_offs - (72.0 + slide_amt) * scale + })) + txt = Text(ba.Lstr(value=player.get_name(full=True)), + maxwidth=130.0, + scale=0.75 * scale, + position=(ts_h_offs - 50.0 * scale, + y_base + (v_offs + 15.0) * scale), + h_align='left', + v_align='center', + color=ba.safecolor(player.team.color + (1, )), + transition='in_left', + transition_delay=tdelay).autoretain() + ba.timer( + tdelay + delay2, + ba.WeakCall( + self._safe_animate, txt.position_combine, 'input1', { + 0: y_base + (v_offs + 15.0) * scale, + transtime: y_base + (v_offs_2 + 15.0) * scale + })) + ba.timer( + tdelay + delay3, + ba.WeakCall( + self._safe_animate, txt.position_combine, 'input0', { + 0: ts_h_offs - 50.0 * scale, + transtime2: ts_h_offs - (50.0 + slide_amt) * scale + })) + + txt_num = Text('#' + str(i + 1), + scale=0.55 * scale, + position=(ts_h_offs - 95.0 * scale, + y_base + (v_offs + 8.0) * scale), + h_align='right', + color=(0.6, 0.6, 0.6, 0.6), + transition='in_left', + transition_delay=tdelay).autoretain() + ba.timer( + tdelay + delay3, + ba.WeakCall( + self._safe_animate, txt_num.position_combine, 'input0', { + 0: ts_h_offs - 95.0 * scale, + transtime2: ts_h_offs - (95.0 + slide_amt) * scale + })) + + s_txt = _scoretxt(str(player.team.sessiondata['previous_score']), + 80, 0, False, 0, 1.0) + ba.timer( + tdelay + delay2, + ba.WeakCall( + self._safe_animate, s_txt.position_combine, 'input1', { + 0: y_base + (v_offs + 2.0) * scale, + transtime: y_base + (v_offs_2 + 2.0) * scale + })) + ba.timer( + tdelay + delay3, + ba.WeakCall( + self._safe_animate, s_txt.position_combine, 'input0', { + 0: ts_h_offs + 80.0 * scale, + transtime2: ts_h_offs + (80.0 - slide_amt) * scale + })) + + score_change = (player.team.sessiondata['score'] - + player.team.sessiondata['previous_score']) + if score_change > 0: + xval = 113 + yval = 3.0 + s_txt_2 = _scoretxt('+' + str(score_change), + xval, + yval, + True, + 0, + 0.7, + flash=True) + ba.timer( + tdelay + delay2, + ba.WeakCall( + self._safe_animate, s_txt_2.position_combine, 'input1', + { + 0: y_base + (v_offs + yval + 2.0) * scale, + transtime: y_base + (v_offs_2 + yval + 2.0) * scale + })) + ba.timer( + tdelay + delay3, + ba.WeakCall( + self._safe_animate, s_txt_2.position_combine, 'input0', + { + 0: ts_h_offs + xval * scale, + transtime2: ts_h_offs + (xval - slide_amt) * scale + })) + + def _safesetattr(node: Optional[ba.Node], attr: str, + value: Any) -> None: + if node: + setattr(node, attr, value) + + ba.timer( + tdelay + delay1, + ba.Call(_safesetattr, s_txt.node, 'color', (1, 1, 1, 1))) + for j in range(score_change): + ba.timer( + 0.001 * (tdelay + delay1 + 150 * j), + ba.Call( + _safesetattr, s_txt.node, 'text', + str(player.team.sessiondata['previous_score'] + j + + 1))) + tfin = tdelay + delay1 + 150 * j + if tfin not in sound_times: + sound_times.add(tfin) + ba.timer( + tfin, + ba.Call(ba.playsound, + self._score_display_sound_small)) + v_offs -= spacing + + def _safe_animate(self, node: Optional[ba.Node], attr: str, + keys: Dict[float, float]) -> None: + if node: + ba.animate(node, attr, keys) diff --git a/assets/src/data/scripts/bastd/activity/multiteamendscreen.py b/assets/src/data/scripts/bastd/activity/multiteamendscreen.py new file mode 100644 index 00000000..1e22c21e --- /dev/null +++ b/assets/src/data/scripts/bastd/activity/multiteamendscreen.py @@ -0,0 +1,374 @@ +"""Functionality related to the final screen in multi-teams sessions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.activity.teamsscorescreen import TeamsScoreScreenActivity + +if TYPE_CHECKING: + from typing import Any, Dict, List, Tuple, Optional + + +class TeamSeriesVictoryScoreScreenActivity(TeamsScoreScreenActivity): + """Final score screen for a team series.""" + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings=settings) + self._min_view_time = 15.0 + self._is_ffa = isinstance(self.session, ba.FreeForAllSession) + self._allow_server_restart = True + self._tips_text = None + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify args. + # pylint: disable=arguments-differ + # we don't yet want music and stuff.. + super().on_transition_in(music=None, show_tips=False) + + # noinspection PyMethodOverriding + def on_begin(self) -> None: # type: ignore + # FIXME FIXME: args differ + # pylint: disable=arguments-differ + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + from bastd.actor.text import Text + from bastd.actor.image import Image + from ba.deprecated import get_resource + ba.set_analytics_screen('FreeForAll Series Victory Screen' if self. + _is_ffa else 'Teams Series Victory Screen') + if ba.app.interface_type == 'large': + sval = ba.Lstr(resource='pressAnyKeyButtonPlayAgainText') + else: + sval = ba.Lstr(resource='pressAnyButtonPlayAgainText') + super().on_begin(show_up_next=False, custom_continue_message=sval) + winning_team = self.settings['winner'] + + # Pause a moment before playing victory music. + ba.timer(0.6, ba.WeakCall(self._play_victory_music)) + ba.timer(4.4, ba.WeakCall(self._show_winner, self.settings['winner'])) + ba.timer(4.6, ba.Call(ba.playsound, self._score_display_sound)) + + # Score / Name / Player-record. + player_entries: List[Tuple[int, str, ba.PlayerRecord]] = [] + + # Note: for ffa, exclude players who haven't entered the game yet. + if self._is_ffa: + for _pkey, prec in self.stats.get_records().items(): + if prec.player.in_game: + player_entries.append( + (prec.player.team.sessiondata['score'], + prec.get_name(full=True), prec)) + player_entries.sort(reverse=True) + else: + for _pkey, prec in self.stats.get_records().items(): + player_entries.append((prec.score, prec.name_full, prec)) + player_entries.sort(reverse=True) + + ts_height = 300.0 + ts_h_offs = -390.0 + tval = 6.4 + t_incr = 0.12 + + always_use_first_to = get_resource('bestOfUseFirstToInstead') + + session = self.session + if self._is_ffa: + assert isinstance(session, ba.FreeForAllSession) + txt = ba.Lstr( + value='${A}:', + subs=[('${A}', + ba.Lstr(resource='firstToFinalText', + subs=[('${COUNT}', + str(session.get_ffa_series_length()))])) + ]) + else: + assert isinstance(session, ba.TeamBaseSession) + + # Some languages may prefer to always show 'first to X' instead of + # 'best of X'. + # FIXME: This will affect all clients connected to us even if + # they're not using this language. Should try to come up + # with a wording that works everywhere. + if always_use_first_to: + txt = ba.Lstr( + value='${A}:', + subs=[ + ('${A}', + ba.Lstr(resource='firstToFinalText', + subs=[ + ('${COUNT}', + str(session.get_series_length() / 2 + 1)) + ])) + ]) + else: + txt = ba.Lstr( + value='${A}:', + subs=[('${A}', + ba.Lstr(resource='bestOfFinalText', + subs=[('${COUNT}', + str(session.get_series_length()))])) + ]) + + Text(txt, + v_align='center', + maxwidth=300, + color=(0.5, 0.5, 0.5, 1.0), + position=(0, 220), + scale=1.2, + transition='inTopSlow', + h_align='center', + transition_delay=t_incr * 4).autoretain() + + win_score = (session.get_series_length() - 1) / 2 + 1 + lose_score = 0 + for team in self.teams: + if team.sessiondata['score'] != win_score: + lose_score = team.sessiondata['score'] + + if not self._is_ffa: + Text(ba.Lstr(resource='gamesToText', + subs=[('${WINCOUNT}', str(win_score)), + ('${LOSECOUNT}', str(lose_score))]), + color=(0.5, 0.5, 0.5, 1.0), + maxwidth=160, + v_align='center', + position=(0, -215), + scale=1.8, + transition='in_left', + h_align='center', + transition_delay=4.8 + t_incr * 4).autoretain() + + if self._is_ffa: + v_extra = 120 + else: + v_extra = 0 + + mvp: Optional[ba.PlayerRecord] = None + mvp_name: Optional[str] = None + + # Show game MVP. + if not self._is_ffa: + mvp, mvp_name = None, None + for entry in player_entries: + if entry[2].team == winning_team: + mvp = entry[2] + mvp_name = entry[1] + break + if mvp is not None: + Text(ba.Lstr(resource='mostValuablePlayerText'), + color=(0.5, 0.5, 0.5, 1.0), + v_align='center', + maxwidth=300, + position=(180, ts_height / 2 + 15), + transition='in_left', + h_align='left', + transition_delay=tval).autoretain() + tval += 4 * t_incr + + Image(mvp.get_icon(), + position=(230, ts_height / 2 - 55 + 14 - 5), + scale=(70, 70), + transition='in_left', + transition_delay=tval).autoretain() + Text(ba.Lstr(value=mvp_name), + position=(280, ts_height / 2 - 55 + 15 - 5), + h_align='left', + v_align='center', + maxwidth=170, + scale=1.3, + color=ba.safecolor(mvp.team.color + (1, )), + transition='in_left', + transition_delay=tval).autoretain() + tval += 4 * t_incr + + # Most violent. + most_kills = 0 + for entry in player_entries: + if entry[2].kill_count >= most_kills: + mvp = entry[2] + mvp_name = entry[1] + most_kills = entry[2].kill_count + if mvp is not None: + Text(ba.Lstr(resource='mostViolentPlayerText'), + color=(0.5, 0.5, 0.5, 1.0), + v_align='center', + maxwidth=300, + position=(180, ts_height / 2 - 150 + v_extra + 15), + transition='in_left', + h_align='left', + transition_delay=tval).autoretain() + Text(ba.Lstr(value='(${A})', + subs=[('${A}', + ba.Lstr(resource='killsTallyText', + subs=[('${COUNT}', str(most_kills))])) + ]), + position=(260, ts_height / 2 - 150 - 15 + v_extra), + color=(0.3, 0.3, 0.3, 1.0), + scale=0.6, + h_align='left', + transition='in_left', + transition_delay=tval).autoretain() + tval += 4 * t_incr + + Image(mvp.get_icon(), + position=(233, ts_height / 2 - 150 - 30 - 46 + 25 + v_extra), + scale=(50, 50), + transition='in_left', + transition_delay=tval).autoretain() + Text(ba.Lstr(value=mvp_name), + position=(270, ts_height / 2 - 150 - 30 - 36 + v_extra + 15), + h_align='left', + v_align='center', + maxwidth=180, + color=ba.safecolor(mvp.team.color + (1, )), + transition='in_left', + transition_delay=tval).autoretain() + tval += 4 * t_incr + + # Most killed. + most_killed = 0 + mkp, mkp_name = None, None + for entry in player_entries: + if entry[2].killed_count >= most_killed: + mkp = entry[2] + mkp_name = entry[1] + most_killed = entry[2].killed_count + if mkp is not None: + Text(ba.Lstr(resource='mostViolatedPlayerText'), + color=(0.5, 0.5, 0.5, 1.0), + v_align='center', + maxwidth=300, + position=(180, ts_height / 2 - 300 + v_extra + 15), + transition='in_left', + h_align='left', + transition_delay=tval).autoretain() + Text(ba.Lstr(value='(${A})', + subs=[('${A}', + ba.Lstr(resource='deathsTallyText', + subs=[('${COUNT}', str(most_killed))])) + ]), + position=(260, ts_height / 2 - 300 - 15 + v_extra), + h_align='left', + scale=0.6, + color=(0.3, 0.3, 0.3, 1.0), + transition='in_left', + transition_delay=tval).autoretain() + tval += 4 * t_incr + Image(mkp.get_icon(), + position=(233, ts_height / 2 - 300 - 30 - 46 + 25 + v_extra), + scale=(50, 50), + transition='in_left', + transition_delay=tval).autoretain() + Text(ba.Lstr(value=mkp_name), + position=(270, ts_height / 2 - 300 - 30 - 36 + v_extra + 15), + h_align='left', + v_align='center', + color=ba.safecolor(mkp.team.color + (1, )), + maxwidth=180, + transition='in_left', + transition_delay=tval).autoretain() + tval += 4 * t_incr + + # Now show individual scores. + tdelay = tval + Text(ba.Lstr(resource='finalScoresText'), + color=(0.5, 0.5, 0.5, 1.0), + position=(ts_h_offs, ts_height / 2), + transition='in_right', + transition_delay=tdelay).autoretain() + tdelay += 4 * t_incr + + v_offs = 0.0 + tdelay += len(player_entries) * 8 * t_incr + for _score, name, prec in player_entries: + tdelay -= 4 * t_incr + v_offs -= 40 + Text(str(prec.team.sessiondata['score']) + if self._is_ffa else str(prec.score), + color=(0.5, 0.5, 0.5, 1.0), + position=(ts_h_offs + 230, ts_height / 2 + v_offs), + h_align='right', + transition='in_right', + transition_delay=tdelay).autoretain() + tdelay -= 4 * t_incr + + Image(prec.get_icon(), + position=(ts_h_offs - 72, ts_height / 2 + v_offs + 15), + scale=(30, 30), + transition='in_left', + transition_delay=tdelay).autoretain() + Text(ba.Lstr(value=name), + position=(ts_h_offs - 50, ts_height / 2 + v_offs + 15), + h_align='left', + v_align='center', + maxwidth=180, + color=ba.safecolor(prec.team.color + (1, )), + transition='in_right', + transition_delay=tdelay).autoretain() + + ba.timer(15.0, ba.WeakCall(self._show_tips)) + + def _show_tips(self) -> None: + from bastd.actor.tipstext import TipsText + self._tips_text = TipsText(offs_y=70) + + def _play_victory_music(self) -> None: + + # Make sure we don't stomp on the next activity's music choice. + if not self.is_transitioning_out(): + ba.setmusic('Victory') + + def _show_winner(self, team: ba.Team) -> None: + from bastd.actor.image import Image + from bastd.actor.zoomtext import ZoomText + if not self._is_ffa: + offs_v = 0.0 + ZoomText(team.name, + position=(0, 97), + color=team.color, + scale=1.15, + jitter=1.0, + maxwidth=250).autoretain() + else: + offs_v = -80.0 + if len(team.players) == 1: + i = Image(team.players[0].get_icon(), + position=(0, 143), + scale=(100, 100)).autoretain() + assert i.node + ba.animate(i.node, 'opacity', {0.0: 0.0, 0.25: 1.0}) + ZoomText(ba.Lstr( + value=team.players[0].get_name(full=True, icon=False)), + position=(0, 97 + offs_v), + color=team.color, + scale=1.15, + jitter=1.0, + maxwidth=250).autoretain() + + s_extra = 1.0 if self._is_ffa else 1.0 + + # Some languages say "FOO WINS" differently for teams vs players. + if isinstance(self.session, ba.FreeForAllSession): + wins_resource = 'seriesWinLine1PlayerText' + else: + wins_resource = 'seriesWinLine1TeamText' + wins_text = ba.Lstr(resource=wins_resource) + + # Temp - if these come up as the english default, fall-back to the + # unified old form which is more likely to be translated. + ZoomText(wins_text, + position=(0, -10 + offs_v), + color=team.color, + scale=0.65 * s_extra, + jitter=1.0, + maxwidth=250).autoretain() + ZoomText(ba.Lstr(resource='seriesWinLine2Text'), + position=(0, -110 + offs_v), + scale=1.0 * s_extra, + color=team.color, + jitter=1.0, + maxwidth=250).autoretain() diff --git a/assets/src/data/scripts/bastd/activity/multiteamjoinscreen.py b/assets/src/data/scripts/bastd/activity/multiteamjoinscreen.py new file mode 100644 index 00000000..a7565924 --- /dev/null +++ b/assets/src/data/scripts/bastd/activity/multiteamjoinscreen.py @@ -0,0 +1,79 @@ +"""Functionality related to the join screen for multi-team sessions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from ba.internal import JoiningActivity +from bastd.actor import text as textactor + +if TYPE_CHECKING: + from typing import Any, Dict, Optional + + +class TeamJoiningActivity(JoiningActivity): + """Join screen for teams sessions.""" + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings) + self._next_up_text: Optional[textactor.Text] = None + + def on_transition_in(self) -> None: + from bastd.actor.controlsguide import ControlsGuide + from ba import TeamsSession + super().on_transition_in() + ControlsGuide(delay=1.0).autoretain() + + session = self.session + assert isinstance(session, ba.TeamBaseSession) + + # Show info about the next up game. + self._next_up_text = textactor.Text(ba.Lstr( + value='${1} ${2}', + subs=[('${1}', ba.Lstr(resource='upFirstText')), + ('${2}', session.get_next_game_description())]), + h_attach='center', + scale=0.7, + v_attach='top', + h_align='center', + position=(0, -70), + flash=False, + color=(0.5, 0.5, 0.5, 1.0), + transition='fade_in', + transition_delay=5.0) + + # In teams mode, show our two team names. + # FIXME: Lobby should handle this. + if isinstance(ba.getsession(), TeamsSession): + team_names = [team.name for team in ba.getsession().teams] + team_colors = [ + tuple(team.color) + (0.5, ) for team in ba.getsession().teams + ] + if len(team_names) == 2: + for i in range(2): + textactor.Text(team_names[i], + scale=0.7, + h_attach='center', + v_attach='top', + h_align='center', + position=(-200 + 350 * i, -100), + color=team_colors[i], + transition='fade_in').autoretain() + + textactor.Text(ba.Lstr(resource='mustInviteFriendsText', + subs=[ + ('${GATHER}', + ba.Lstr(resource='gatherWindow.titleText')) + ]), + h_attach='center', + scale=0.8, + host_only=True, + v_attach='center', + h_align='center', + position=(0, 0), + flash=False, + color=(0, 1, 0, 1.0), + transition='fade_in', + transition_delay=2.0, + transition_out_delay=7.0).autoretain() diff --git a/assets/src/data/scripts/bastd/activity/teamsscorescreen.py b/assets/src/data/scripts/bastd/activity/teamsscorescreen.py new file mode 100644 index 00000000..65f1f933 --- /dev/null +++ b/assets/src/data/scripts/bastd/activity/teamsscorescreen.py @@ -0,0 +1,214 @@ +"""Functionality related to teams mode score screen.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from ba.internal import ScoreScreenActivity + +if TYPE_CHECKING: + from typing import Any, Dict, Optional, Union + from ba import PlayerRecord + + +class TeamsScoreScreenActivity(ScoreScreenActivity): + """Base class for score screens.""" + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings=settings) + self._score_display_sound = ba.getsound("scoreHit01") + self._score_display_sound_small = ba.getsound("scoreHit02") + + def on_begin( # type: ignore + self, + show_up_next: bool = True, + custom_continue_message: ba.Lstr = None) -> None: + # FIXME FIXME unify args + # pylint: disable=arguments-differ + from bastd.actor.text import Text + super().on_begin(custom_continue_message=custom_continue_message) + session = self.session + if show_up_next and isinstance(session, ba.TeamBaseSession): + txt = ba.Lstr(value='${A} ${B}', + subs=[ + ('${A}', + ba.Lstr(resource='upNextText', + subs=[ + ('${COUNT}', + str(session.get_game_number() + 1)) + ])), + ('${B}', session.get_next_game_description()) + ]) + Text(txt, + maxwidth=900, + h_attach='center', + v_attach='bottom', + h_align='center', + v_align='center', + position=(0, 53), + flash=False, + color=(0.3, 0.3, 0.35, 1.0), + transition='fade_in', + transition_delay=2.0).autoretain() + + def show_player_scores(self, + delay: float = 2.5, + results: Any = None, + scale: float = 1.0, + x_offset: float = 0.0, + y_offset: float = 0.0) -> None: + """Show scores for individual players.""" + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + from bastd.actor.text import Text + from bastd.actor.image import Image + from ba import FreeForAllSession + + ts_v_offset = 150.0 + y_offset + ts_h_offs = 80.0 + x_offset + tdelay = delay + spacing = 40 + + is_free_for_all = isinstance(self.session, FreeForAllSession) + + def _get_prec_score(p_rec: PlayerRecord) -> int: + if is_free_for_all and results is not None: + assert isinstance(results, ba.TeamGameResults) + val = results.get_team_score(p_rec.team) + assert val is not None + return val + return p_rec.accumscore + + def _get_prec_score_str(p_rec: PlayerRecord) -> Union[str, ba.Lstr]: + if is_free_for_all and results is not None: + assert isinstance(results, ba.TeamGameResults) + val = results.get_team_score_str(p_rec.team) + assert val is not None + return val + return str(p_rec.accumscore) + + # get_records() can return players that are no longer in + # the game.. if we're using results we have to filter those out + # (since they're not in results and that's where we pull their + # scores from) + if results is not None: + assert isinstance(results, ba.TeamGameResults) + player_records = [] + assert self.stats + valid_players = list(self.stats.get_records().items()) + + def _get_player_score_set_entry(player: ba.Player + ) -> Optional[PlayerRecord]: + for p_rec in valid_players: + # PyCharm incorrectly thinks valid_players is a List[str] + # noinspection PyUnresolvedReferences + if p_rec[1].player is player: + # noinspection PyTypeChecker + return p_rec[1] + return None + + # Results is already sorted; just convert it into a list of + # score-set-entries. + for winner in results.get_winners(): + for team in winner.teams: + if len(team.players) == 1: + player_entry = _get_player_score_set_entry( + team.players[0]) + if player_entry is not None: + player_records.append(player_entry) + else: + raise Exception('FIXME; CODE PATH NEEDS FIXING') + # player_records = [[ + # _get_prec_score(p), name, p + # ] for name, p in list(self.stats.get_records().items())] + # player_records.sort( + # reverse=(results is None + # or not results.get_lower_is_better())) + # # just want living player entries + # player_records = [p[2] for p in player_records if p[2]] + + v_offs = -140.0 + spacing * len(player_records) * 0.5 + + def _txt(x_offs: float, + y_offs: float, + text: ba.Lstr, + h_align: str = 'right', + extrascale: float = 1.0, + maxwidth: Optional[float] = 120.0) -> None: + Text(text, + color=(0.5, 0.5, 0.6, 0.5), + position=(ts_h_offs + x_offs * scale, + ts_v_offset + (v_offs + y_offs + 4.0) * scale), + h_align=h_align, + v_align='center', + scale=0.8 * scale * extrascale, + maxwidth=maxwidth, + transition='in_left', + transition_delay=tdelay).autoretain() + + session = self.session + assert isinstance(session, ba.TeamBaseSession) + tval = ba.Lstr(resource='gameLeadersText', + subs=[('${COUNT}', str(session.get_game_number()))]) + _txt(180, 43, tval, h_align='center', extrascale=1.4, maxwidth=None) + _txt(-15, 4, ba.Lstr(resource='playerText'), h_align='left') + _txt(180, 4, ba.Lstr(resource='killsText')) + _txt(280, 4, ba.Lstr(resource='deathsText'), maxwidth=100) + + score_name = 'Score' if results is None else results.get_score_name() + translated = ba.Lstr(translate=('scoreNames', score_name)) + + _txt(390, 0, translated) + + topkillcount = 0 + topkilledcount = 99999 + top_score = 0 if not player_records else _get_prec_score( + player_records[0]) + + for prec in player_records: + topkillcount = max(topkillcount, prec.accum_kill_count) + topkilledcount = min(topkilledcount, prec.accum_killed_count) + + def _scoretxt(text: Union[str, ba.Lstr], + x_offs: float, + highlight: bool, + delay2: float, + maxwidth: float = 70.0) -> None: + Text(text, + position=(ts_h_offs + x_offs * scale, + ts_v_offset + (v_offs + 15) * scale), + scale=scale, + color=(1.0, 0.9, 0.5, 1.0) if highlight else + (0.5, 0.5, 0.6, 0.5), + h_align='right', + v_align='center', + maxwidth=maxwidth, + transition='in_left', + transition_delay=tdelay + delay2).autoretain() + + for playerrec in player_records: + tdelay += 0.05 + v_offs -= spacing + Image(playerrec.get_icon(), + position=(ts_h_offs - 12 * scale, + ts_v_offset + (v_offs + 15.0) * scale), + scale=(30.0 * scale, 30.0 * scale), + transition='in_left', + transition_delay=tdelay).autoretain() + Text(ba.Lstr(value=playerrec.get_name(full=True)), + maxwidth=160, + scale=0.75 * scale, + position=(ts_h_offs + 10.0 * scale, + ts_v_offset + (v_offs + 15) * scale), + h_align='left', + v_align='center', + color=ba.safecolor(playerrec.team.color + (1, )), + transition='in_left', + transition_delay=tdelay).autoretain() + _scoretxt(str(playerrec.accum_kill_count), 180, + playerrec.accum_kill_count == topkillcount, 100) + _scoretxt(str(playerrec.accum_killed_count), 280, + playerrec.accum_killed_count == topkilledcount, 100) + _scoretxt(_get_prec_score_str(playerrec), 390, + _get_prec_score(playerrec) == top_score, 200) diff --git a/assets/src/data/scripts/bastd/actor/__init__.py b/assets/src/data/scripts/bastd/actor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assets/src/data/scripts/bastd/actor/background.py b/assets/src/data/scripts/bastd/actor/background.py new file mode 100644 index 00000000..f7dbce5c --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/background.py @@ -0,0 +1,138 @@ +"""Defines Actor(s).""" + +from __future__ import annotations + +import random +import weakref +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any + + +class Background(ba.Actor): + """Simple Fading Background Actor.""" + + def __init__(self, + fade_time: float = 0.5, + start_faded: bool = False, + show_logo: bool = False): + super().__init__() + self._dying = False + self.fade_time = fade_time + # We're special in that we create our node in the session + # scene instead of the activity scene. + # This way we can overlap multiple activities for fades + # and whatnot. + session = ba.getsession() + self._session = weakref.ref(session) + with ba.Context(session): + self.node = ba.newnode('image', + delegate=self, + attrs={ + 'fill_screen': True, + 'texture': ba.gettexture('bg'), + 'tilt_translate': -0.3, + 'has_alpha_channel': False, + 'color': (1, 1, 1) + }) + if not start_faded: + ba.animate(self.node, + 'opacity', { + 0.0: 0.0, + self.fade_time: 1.0 + }, + loop=False) + if show_logo: + logo_texture = ba.gettexture('logo') + logo_model = ba.getmodel('logo') + logo_model_transparent = ba.getmodel('logoTransparent') + self.logo = ba.newnode( + 'image', + owner=self.node, + attrs={ + 'texture': logo_texture, + 'model_opaque': logo_model, + 'model_transparent': logo_model_transparent, + 'scale': (0.7, 0.7), + 'vr_depth': -250, + 'color': (0.15, 0.15, 0.15), + 'position': (0, 0), + 'tilt_translate': -0.05, + 'absolute_scale': False + }) + self.node.connectattr('opacity', self.logo, 'opacity') + # add jitter/pulse for a stop-motion-y look unless we're in VR + # in which case stillness is better + if not ba.app.vr_mode: + self.cmb = ba.newnode('combine', + owner=self.node, + attrs={'size': 2}) + for attr in ['input0', 'input1']: + ba.animate(self.cmb, + attr, { + 0.0: 0.693, + 0.05: 0.7, + 0.5: 0.693 + }, + loop=True) + self.cmb.connectattr('output', self.logo, 'scale') + cmb = ba.newnode('combine', + owner=self.node, + attrs={'size': 2}) + cmb.connectattr('output', self.logo, 'position') + # Gen some random keys for that stop-motion-y look. + keys = {} + timeval = 0.0 + for _i in range(10): + keys[timeval] = (random.random() - 0.5) * 0.0015 + timeval += random.random() * 0.1 + ba.animate(cmb, "input0", keys, loop=True) + keys = {} + timeval = 0.0 + for _i in range(10): + keys[timeval] = (random.random() - 0.5) * 0.0015 + 0.05 + timeval += random.random() * 0.1 + ba.animate(cmb, "input1", keys, loop=True) + + def __del__(self) -> None: + # Normal actors don't get sent DieMessages when their + # activity is shutting down, but we still need to do so + # since our node lives in the session and it wouldn't die + # otherwise. + self._die() + super().__del__() + + def _die(self, immediate: bool = False) -> None: + session = self._session() + if session is None and self.node: + # If session is gone, our node should be too, + # since it was part of the session's scene. + # Let's make sure that's the case. + # (since otherwise we have no way to kill it) + ba.print_error("got None session on Background _die" + " (and node still exists!)") + elif session is not None: + with ba.Context(session): + if not self._dying and self.node: + self._dying = True + if immediate: + self.node.delete() + else: + ba.animate(self.node, + "opacity", { + 0.0: 1.0, + self.fade_time: 0.0 + }, + loop=False) + ba.timer(self.fade_time + 0.1, self.node.delete) + + def handlemessage(self, msg: Any) -> Any: + if __debug__ is True: + self._handlemessage_sanity_check() + if isinstance(msg, ba.DieMessage): + self._die(msg.immediate) + else: + super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/actor/bomb.py b/assets/src/data/scripts/bastd/actor/bomb.py new file mode 100644 index 00000000..1aa62a2f --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/bomb.py @@ -0,0 +1,1050 @@ +"""Various classes for bombs, mines, tnt, etc.""" + +# FIXME +# pylint: disable=too-many-lines + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Sequence, Optional, Callable, List, Tuple + + +class BombFactory: + """Wraps up media and other resources used by ba.Bombs. + + category: Gameplay Classes + + A single instance of this is shared between all bombs + and can be retrieved via bastd.actor.bomb.get_factory(). + + Attributes: + + bomb_model + The ba.Model of a standard or ice bomb. + + sticky_bomb_model + The ba.Model of a sticky-bomb. + + impact_bomb_model + The ba.Model of an impact-bomb. + + land_mine_model + The ba.Model of a land-mine. + + tnt_model + The ba.Model of a tnt box. + + regular_tex + The ba.Texture for regular bombs. + + ice_tex + The ba.Texture for ice bombs. + + sticky_tex + The ba.Texture for sticky bombs. + + impact_tex + The ba.Texture for impact bombs. + + impact_lit_tex + The ba.Texture for impact bombs with lights lit. + + land_mine_tex + The ba.Texture for land-mines. + + land_mine_lit_tex + The ba.Texture for land-mines with the light lit. + + tnt_tex + The ba.Texture for tnt boxes. + + hiss_sound + The ba.Sound for the hiss sound an ice bomb makes. + + debris_fall_sound + The ba.Sound for random falling debris after an explosion. + + wood_debris_fall_sound + A ba.Sound for random wood debris falling after an explosion. + + explode_sounds + A tuple of ba.Sounds for explosions. + + freeze_sound + A ba.Sound of an ice bomb freezing something. + + fuse_sound + A ba.Sound of a burning fuse. + + activate_sound + A ba.Sound for an activating impact bomb. + + warn_sound + A ba.Sound for an impact bomb about to explode due to time-out. + + bomb_material + A ba.Material applied to all bombs. + + normal_sound_material + A ba.Material that generates standard bomb noises on impacts, etc. + + sticky_material + A ba.Material that makes 'splat' sounds and makes collisions softer. + + land_mine_no_explode_material + A ba.Material that keeps land-mines from blowing up. + Applied to land-mines when they are created to allow land-mines to + touch without exploding. + + land_mine_blast_material + A ba.Material applied to activated land-mines that causes them to + explode on impact. + + impact_blast_material + A ba.Material applied to activated impact-bombs that causes them to + explode on impact. + + blast_material + A ba.Material applied to bomb blast geometry which triggers impact + events with what it touches. + + dink_sounds + A tuple of ba.Sounds for when bombs hit the ground. + + sticky_impact_sound + The ba.Sound for a squish made by a sticky bomb hitting something. + + roll_sound + ba.Sound for a rolling bomb. + """ + + def random_explode_sound(self) -> ba.Sound: + """Return a random explosion ba.Sound from the factory.""" + return self.explode_sounds[random.randrange(len(self.explode_sounds))] + + def __init__(self) -> None: + """Instantiate a BombFactory. + + You shouldn't need to do this; call bastd.actor.bomb.get_factory() + to get a shared instance. + """ + + self.bomb_model = ba.getmodel('bomb') + self.sticky_bomb_model = ba.getmodel('bombSticky') + self.impact_bomb_model = ba.getmodel('impactBomb') + self.land_mine_model = ba.getmodel('landMine') + self.tnt_model = ba.getmodel('tnt') + + self.regular_tex = ba.gettexture('bombColor') + self.ice_tex = ba.gettexture('bombColorIce') + self.sticky_tex = ba.gettexture('bombStickyColor') + self.impact_tex = ba.gettexture('impactBombColor') + self.impact_lit_tex = ba.gettexture('impactBombColorLit') + self.land_mine_tex = ba.gettexture('landMine') + self.land_mine_lit_tex = ba.gettexture('landMineLit') + self.tnt_tex = ba.gettexture('tnt') + + self.hiss_sound = ba.getsound('hiss') + self.debris_fall_sound = ba.getsound('debrisFall') + self.wood_debris_fall_sound = ba.getsound('woodDebrisFall') + + self.explode_sounds = (ba.getsound('explosion01'), + ba.getsound('explosion02'), + ba.getsound('explosion03'), + ba.getsound('explosion04'), + ba.getsound('explosion05')) + + self.freeze_sound = ba.getsound('freeze') + self.fuse_sound = ba.getsound('fuse01') + self.activate_sound = ba.getsound('activateBeep') + self.warn_sound = ba.getsound('warnBeep') + + # set up our material so new bombs don't collide with objects + # that they are initially overlapping + self.bomb_material = ba.Material() + self.normal_sound_material = ba.Material() + self.sticky_material = ba.Material() + + self.bomb_material.add_actions( + conditions=((('we_are_younger_than', 100), 'or', + ('they_are_younger_than', 100)), + 'and', ('they_have_material', + ba.sharedobj('object_material'))), + actions=('modify_node_collision', 'collide', False)) + + # we want pickup materials to always hit us even if we're currently not + # colliding with their node (generally due to the above rule) + self.bomb_material.add_actions( + conditions=('they_have_material', ba.sharedobj('pickup_material')), + actions=('modify_part_collision', 'use_node_collide', False)) + + self.bomb_material.add_actions(actions=('modify_part_collision', + 'friction', 0.3)) + + self.land_mine_no_explode_material = ba.Material() + self.land_mine_blast_material = ba.Material() + self.land_mine_blast_material.add_actions( + conditions=(('we_are_older_than', + 200), 'and', ('they_are_older_than', + 200), 'and', ('eval_colliding', ), + 'and', (('they_dont_have_material', + self.land_mine_no_explode_material), 'and', + (('they_have_material', + ba.sharedobj('object_material')), 'or', + ('they_have_material', + ba.sharedobj('player_material'))))), + actions=('message', 'our_node', 'at_connect', ImpactMessage())) + + self.impact_blast_material = ba.Material() + self.impact_blast_material.add_actions( + conditions=(('we_are_older_than', + 200), 'and', ('they_are_older_than', + 200), 'and', ('eval_colliding', ), + 'and', (('they_have_material', + ba.sharedobj('footing_material')), 'or', + ('they_have_material', + ba.sharedobj('object_material')))), + actions=('message', 'our_node', 'at_connect', ImpactMessage())) + + self.blast_material = ba.Material() + self.blast_material.add_actions( + conditions=(('they_have_material', + ba.sharedobj('object_material'))), + actions=(('modify_part_collision', 'collide', True), + ('modify_part_collision', 'physical', + False), ('message', 'our_node', 'at_connect', + ExplodeHitMessage()))) + + self.dink_sounds = (ba.getsound('bombDrop01'), + ba.getsound('bombDrop02')) + self.sticky_impact_sound = ba.getsound('stickyImpact') + self.roll_sound = ba.getsound('bombRoll01') + + # collision sounds + self.normal_sound_material.add_actions( + conditions=('they_have_material', + ba.sharedobj('footing_material')), + actions=(('impact_sound', self.dink_sounds, 2, 0.8), + ('roll_sound', self.roll_sound, 3, 6))) + + self.sticky_material.add_actions(actions=(('modify_part_collision', + 'stiffness', 0.1), + ('modify_part_collision', + 'damping', 1.0))) + + self.sticky_material.add_actions( + conditions=(('they_have_material', + ba.sharedobj('player_material')), + 'or', ('they_have_material', + ba.sharedobj('footing_material'))), + actions=('message', 'our_node', 'at_connect', SplatMessage())) + + +# noinspection PyTypeHints +def get_factory() -> BombFactory: + """Get/create a shared bastd.actor.bomb.BombFactory object.""" + activity = ba.getactivity() + + # FIXME: Need to figure out an elegant way to store + # shared actor data with an activity. + factory: BombFactory + try: + factory = activity.shared_bomb_factory # type: ignore + except Exception: + factory = activity.shared_bomb_factory = BombFactory() # type: ignore + assert isinstance(factory, BombFactory) + return factory + + +class SplatMessage: + """Tells an object to make a splat noise.""" + + +class ExplodeMessage: + """Tells an object to explode.""" + + +class ImpactMessage: + """Tell an object it touched something.""" + + +class ArmMessage: + """Tell an object to become armed.""" + + +class WarnMessage: + """Tell an object to issue a warning sound.""" + + +class ExplodeHitMessage: + """Tell an object it was hit by an explosion.""" + + def __init__(self) -> None: + pass + + +class Blast(ba.Actor): + """An explosion, as generated by a bomb or some other object. + + category: Gameplay Classes + """ + + def __init__(self, + position: Sequence[float] = (0.0, 1.0, 0.0), + velocity: Sequence[float] = (0.0, 0.0, 0.0), + blast_radius: float = 2.0, + blast_type: str = "normal", + source_player: ba.Player = None, + hit_type: str = 'explosion', + hit_subtype: str = 'normal'): + """Instantiate with given values.""" + + # bah; get off my lawn! + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + + super().__init__() + + factory = get_factory() + + self.blast_type = blast_type + self.source_player = source_player + self.hit_type = hit_type + self.hit_subtype = hit_subtype + self.radius = blast_radius + + # set our position a bit lower so we throw more things upward + rmats = (factory.blast_material, ba.sharedobj('attack_material')) + self.node = ba.newnode('region', + delegate=self, + attrs={ + 'position': (position[0], position[1] - 0.1, + position[2]), + 'scale': + (self.radius, self.radius, self.radius), + 'type': 'sphere', + 'materials': rmats + }) + + ba.timer(0.05, self.node.delete) + + # throw in an explosion and flash + evel = (velocity[0], max(-1.0, velocity[1]), velocity[2]) + explosion = ba.newnode("explosion", + attrs={ + 'position': position, + 'velocity': evel, + 'radius': self.radius, + 'big': (self.blast_type == 'tnt') + }) + if self.blast_type == "ice": + explosion.color = (0, 0.05, 0.4) + + ba.timer(1.0, explosion.delete) + + if self.blast_type != 'ice': + ba.emitfx(position=position, + velocity=velocity, + count=int(1.0 + random.random() * 4), + emit_type='tendrils', + tendril_type='thin_smoke') + ba.emitfx(position=position, + velocity=velocity, + count=int(4.0 + random.random() * 4), + emit_type='tendrils', + tendril_type='ice' if self.blast_type == 'ice' else 'smoke') + ba.emitfx(position=position, + emit_type='distortion', + spread=1.0 if self.blast_type == 'tnt' else 2.0) + + # and emit some shrapnel.. + if self.blast_type == 'ice': + + def emit() -> None: + ba.emitfx(position=position, + velocity=velocity, + count=30, + spread=2.0, + scale=0.4, + chunk_type='ice', + emit_type='stickers') + + # looks better if we delay a bit + ba.timer(0.05, emit) + + elif self.blast_type == 'sticky': + + def emit() -> None: + ba.emitfx(position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + spread=0.7, + chunk_type='slime') + ba.emitfx(position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.5, + spread=0.7, + chunk_type='slime') + ba.emitfx(position=position, + velocity=velocity, + count=15, + scale=0.6, + chunk_type='slime', + emit_type='stickers') + ba.emitfx(position=position, + velocity=velocity, + count=20, + scale=0.7, + chunk_type='spark', + emit_type='stickers') + ba.emitfx(position=position, + velocity=velocity, + count=int(6.0 + random.random() * 12), + scale=0.8, + spread=1.5, + chunk_type='spark') + + # looks better if we delay a bit + ba.timer(0.05, emit) + + elif self.blast_type == 'impact': # regular bomb shrapnel + + def emit() -> None: + ba.emitfx(position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.8, + chunk_type='metal') + ba.emitfx(position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.4, + chunk_type='metal') + ba.emitfx(position=position, + velocity=velocity, + count=20, + scale=0.7, + chunk_type='spark', + emit_type='stickers') + ba.emitfx(position=position, + velocity=velocity, + count=int(8.0 + random.random() * 15), + scale=0.8, + spread=1.5, + chunk_type='spark') + + # looks better if we delay a bit + ba.timer(0.05, emit) + + else: # regular or land mine bomb shrapnel + + def emit() -> None: + if self.blast_type != 'tnt': + ba.emitfx(position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + chunk_type='rock') + ba.emitfx(position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.5, + chunk_type='rock') + ba.emitfx(position=position, + velocity=velocity, + count=30, + scale=1.0 if self.blast_type == 'tnt' else 0.7, + chunk_type='spark', + emit_type='stickers') + ba.emitfx(position=position, + velocity=velocity, + count=int(18.0 + random.random() * 20), + scale=1.0 if self.blast_type == 'tnt' else 0.8, + spread=1.5, + chunk_type='spark') + + # tnt throws splintery chunks + if self.blast_type == 'tnt': + + def emit_splinters() -> None: + ba.emitfx(position=position, + velocity=velocity, + count=int(20.0 + random.random() * 25), + scale=0.8, + spread=1.0, + chunk_type='splinter') + + ba.timer(0.01, emit_splinters) + + # every now and then do a sparky one + if self.blast_type == 'tnt' or random.random() < 0.1: + + def emit_extra_sparks() -> None: + ba.emitfx(position=position, + velocity=velocity, + count=int(10.0 + random.random() * 20), + scale=0.8, + spread=1.5, + chunk_type='spark') + + ba.timer(0.02, emit_extra_sparks) + + # looks better if we delay a bit + ba.timer(0.05, emit) + + lcolor = ((0.6, 0.6, 1.0) if self.blast_type == 'ice' else + (1, 0.3, 0.1)) + light = ba.newnode('light', + attrs={ + 'position': position, + 'volume_intensity_scale': 10.0, + 'color': lcolor + }) + + scl = random.uniform(0.6, 0.9) + scorch_radius = light_radius = self.radius + if self.blast_type == 'tnt': + light_radius *= 1.4 + scorch_radius *= 1.15 + scl *= 3.0 + + iscale = 1.6 + ba.animate( + light, "intensity", { + 0: 2.0 * iscale, + scl * 0.02: 0.1 * iscale, + scl * 0.025: 0.2 * iscale, + scl * 0.05: 17.0 * iscale, + scl * 0.06: 5.0 * iscale, + scl * 0.08: 4.0 * iscale, + scl * 0.2: 0.6 * iscale, + scl * 2.0: 0.00 * iscale, + scl * 3.0: 0.0 + }) + ba.animate( + light, "radius", { + 0: light_radius * 0.2, + scl * 0.05: light_radius * 0.55, + scl * 0.1: light_radius * 0.3, + scl * 0.3: light_radius * 0.15, + scl * 1.0: light_radius * 0.05 + }) + ba.timer(scl * 3.0, light.delete) + + # make a scorch that fades over time + scorch = ba.newnode('scorch', + attrs={ + 'position': position, + 'size': scorch_radius * 0.5, + 'big': (self.blast_type == 'tnt') + }) + if self.blast_type == 'ice': + scorch.color = (1, 1, 1.5) + + ba.animate(scorch, "presence", {3.000: 1, 13.000: 0}) + ba.timer(13.0, scorch.delete) + + if self.blast_type == 'ice': + ba.playsound(factory.hiss_sound, position=light.position) + + lpos = light.position + ba.playsound(factory.random_explode_sound(), position=lpos) + ba.playsound(factory.debris_fall_sound, position=lpos) + + ba.camerashake(intensity=5.0 if self.blast_type == 'tnt' else 1.0) + + # tnt is more epic.. + if self.blast_type == 'tnt': + ba.playsound(factory.random_explode_sound(), position=lpos) + + def _extra_boom() -> None: + ba.playsound(factory.random_explode_sound(), position=lpos) + + ba.timer(0.25, _extra_boom) + + def _extra_debris_sound() -> None: + ba.playsound(factory.debris_fall_sound, position=lpos) + ba.playsound(factory.wood_debris_fall_sound, position=lpos) + + ba.timer(0.4, _extra_debris_sound) + + def handlemessage(self, msg: Any) -> Any: + self._handlemessage_sanity_check() + + if isinstance(msg, ba.DieMessage): + if self.node: + self.node.delete() + + elif isinstance(msg, ExplodeHitMessage): + node = ba.get_collision_info("opposing_node") + if node: + assert self.node + nodepos = self.node.position + + # new + mag = 2000.0 + if self.blast_type == 'ice': + mag *= 0.5 + elif self.blast_type == 'land_mine': + mag *= 2.5 + elif self.blast_type == 'tnt': + mag *= 2.0 + + node.handlemessage( + ba.HitMessage(pos=nodepos, + velocity=(0, 0, 0), + magnitude=mag, + hit_type=self.hit_type, + hit_subtype=self.hit_subtype, + radius=self.radius, + source_player=self.source_player)) + if self.blast_type == "ice": + ba.playsound(get_factory().freeze_sound, + 10, + position=nodepos) + node.handlemessage(ba.FreezeMessage()) + + else: + super().handlemessage(msg) + + +class Bomb(ba.Actor): + """A standard bomb and its variants such as land-mines and tnt-boxes. + + category: Gameplay Classes + """ + + # Ew; should try to clean this up later + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + + def __init__(self, + position: Sequence[float] = (0.0, 1.0, 0.0), + velocity: Sequence[float] = (0.0, 0.0, 0.0), + bomb_type: str = 'normal', + blast_radius: float = 2.0, + source_player: ba.Player = None, + owner: ba.Node = None): + """Create a new Bomb. + + bomb_type can be 'ice','impact','land_mine','normal','sticky', or + 'tnt'. Note that for impact or land_mine bombs you have to call arm() + before they will go off. + """ + super().__init__() + + factory = get_factory() + + if bomb_type not in ('ice', 'impact', 'land_mine', 'normal', 'sticky', + 'tnt'): + raise Exception("invalid bomb type: " + bomb_type) + self.bomb_type = bomb_type + + self._exploded = False + + self.texture_sequence: Optional[ba.Node] = None + + if self.bomb_type == 'sticky': + self._last_sticky_sound_time = 0.0 + + self.blast_radius = blast_radius + if self.bomb_type == 'ice': + self.blast_radius *= 1.2 + elif self.bomb_type == 'impact': + self.blast_radius *= 0.7 + elif self.bomb_type == 'land_mine': + self.blast_radius *= 0.7 + elif self.bomb_type == 'tnt': + self.blast_radius *= 1.45 + + self._explode_callbacks: List[Callable[[Bomb, Blast], Any]] = [] + + # the player this came from + self.source_player = source_player + + # by default our hit type/subtype is our own, but we pick up types of + # whoever sets us off so we know what caused a chain reaction + self.hit_type = 'explosion' + self.hit_subtype = self.bomb_type + + # if no owner was provided, use an unconnected node ref + # (nevermind; trying to use None in these type cases instead) + # if owner is None: + # owner = ba.Node(None) + + # the node this came from + self.owner = owner + + # adding footing-materials to things can screw up jumping and flying + # since players carrying those things + # and thus touching footing objects will think they're on solid + # ground.. perhaps we don't wanna add this even in the tnt case?.. + materials: Tuple[ba.Material, ...] + if self.bomb_type == 'tnt': + materials = (factory.bomb_material, + ba.sharedobj('footing_material'), + ba.sharedobj('object_material')) + else: + materials = (factory.bomb_material, + ba.sharedobj('object_material')) + + if self.bomb_type == 'impact': + materials = materials + (factory.impact_blast_material, ) + elif self.bomb_type == 'land_mine': + materials = materials + (factory.land_mine_no_explode_material, ) + + if self.bomb_type == 'sticky': + materials = materials + (factory.sticky_material, ) + else: + materials = materials + (factory.normal_sound_material, ) + + if self.bomb_type == 'land_mine': + fuse_time = None + self.node = ba.newnode('prop', + delegate=self, + attrs={ + 'position': position, + 'velocity': velocity, + 'model': factory.land_mine_model, + 'light_model': factory.land_mine_model, + 'body': 'landMine', + 'shadow_size': 0.44, + 'color_texture': factory.land_mine_tex, + 'reflection': 'powerup', + 'reflection_scale': [1.0], + 'materials': materials + }) + + elif self.bomb_type == 'tnt': + fuse_time = None + self.node = ba.newnode('prop', + delegate=self, + attrs={ + 'position': position, + 'velocity': velocity, + 'model': factory.tnt_model, + 'light_model': factory.tnt_model, + 'body': 'crate', + 'shadow_size': 0.5, + 'color_texture': factory.tnt_tex, + 'reflection': 'soft', + 'reflection_scale': [0.23], + 'materials': materials + }) + + elif self.bomb_type == 'impact': + fuse_time = 20.0 + self.node = ba.newnode('prop', + delegate=self, + attrs={ + 'position': position, + 'velocity': velocity, + 'body': 'sphere', + 'model': factory.impact_bomb_model, + 'shadow_size': 0.3, + 'color_texture': factory.impact_tex, + 'reflection': 'powerup', + 'reflection_scale': [1.5], + 'materials': materials + }) + self.arm_timer = ba.Timer( + 0.2, ba.WeakCall(self.handlemessage, ArmMessage())) + self.warn_timer = ba.Timer( + 0.001 * (fuse_time - 1700), + ba.WeakCall(self.handlemessage, WarnMessage())) + + else: + fuse_time = 3.0 + if self.bomb_type == 'sticky': + sticky = True + model = factory.sticky_bomb_model + rtype = 'sharper' + rscale = 1.8 + else: + sticky = False + model = factory.bomb_model + rtype = 'sharper' + rscale = 1.8 + if self.bomb_type == 'ice': + tex = factory.ice_tex + elif self.bomb_type == 'sticky': + tex = factory.sticky_tex + else: + tex = factory.regular_tex + self.node = ba.newnode('bomb', + delegate=self, + attrs={ + 'position': position, + 'velocity': velocity, + 'model': model, + 'shadow_size': 0.3, + 'color_texture': tex, + 'sticky': sticky, + 'owner': owner, + 'reflection': rtype, + 'reflection_scale': [rscale], + 'materials': materials + }) + + sound = ba.newnode('sound', + owner=self.node, + attrs={ + 'sound': factory.fuse_sound, + 'volume': 0.25 + }) + self.node.connectattr('position', sound, 'position') + ba.animate(self.node, 'fuse_length', {0.0: 1.0, fuse_time: 0.0}) + + # Light the fuse!!! + if self.bomb_type not in ('land_mine', 'tnt'): + assert fuse_time is not None + ba.timer(fuse_time, + ba.WeakCall(self.handlemessage, ExplodeMessage())) + + ba.animate(self.node, "model_scale", {0: 0, 0.2: 1.3, 0.26: 1}) + + def get_source_player(self) -> Optional[ba.Player]: + """Returns a ba.Player representing the source of this bomb. + + Be prepared for values of None or invalid Player refs.""" + return self.source_player + + def on_expire(self) -> None: + super().on_expire() + # release callbacks/refs so we don't wind up with dependency loops.. + self._explode_callbacks = [] + + def _handle_die(self) -> None: + if self.node: + self.node.delete() + + def _handle_oob(self) -> None: + self.handlemessage(ba.DieMessage()) + + def _handle_impact(self) -> None: + node = ba.get_collision_info("opposing_node") + # if we're an impact bomb and we came from this node, don't explode... + # alternately if we're hitting another impact-bomb from the same + # source, don't explode... + try: + node_delegate = node.getdelegate() + except Exception: + node_delegate = None + if node: + if (self.bomb_type == 'impact' + and (node is self.owner or + (isinstance(node_delegate, Bomb) + and node_delegate.bomb_type == 'impact' + and node_delegate.owner is self.owner))): + return + self.handlemessage(ExplodeMessage()) + + def _handle_dropped(self) -> None: + if self.bomb_type == 'land_mine': + self.arm_timer = ba.Timer( + 1.25, ba.WeakCall(self.handlemessage, ArmMessage())) + + # once we've thrown a sticky bomb we can stick to it.. + elif self.bomb_type == 'sticky': + + def _safesetattr(node: Optional[ba.Node], attr: str, + value: Any) -> None: + if node: + setattr(node, attr, value) + + ba.timer(0.25, + lambda: _safesetattr(self.node, 'stick_to_owner', True)) + + def _handle_splat(self) -> None: + node = ba.get_collision_info("opposing_node") + if (node is not self.owner + and ba.time() - self._last_sticky_sound_time > 1.0): + self._last_sticky_sound_time = ba.time() + assert self.node + ba.playsound(get_factory().sticky_impact_sound, + 2.0, + position=self.node.position) + + def add_explode_callback(self, call: Callable[[Bomb, Blast], Any]) -> None: + """Add a call to be run when the bomb has exploded. + + The bomb and the new blast object are passed as arguments. + """ + self._explode_callbacks.append(call) + + def explode(self) -> None: + """Blows up the bomb if it has not yet done so.""" + if self._exploded: + return + self._exploded = True + activity = self.getactivity() + if activity is not None and self.node: + blast = Blast(position=self.node.position, + velocity=self.node.velocity, + blast_radius=self.blast_radius, + blast_type=self.bomb_type, + source_player=self.source_player, + hit_type=self.hit_type, + hit_subtype=self.hit_subtype).autoretain() + for callback in self._explode_callbacks: + callback(self, blast) + + # we blew up so we need to go away + # FIXME; was there a reason we need this delay? + ba.timer(0.001, ba.WeakCall(self.handlemessage, ba.DieMessage())) + + def _handle_warn(self) -> None: + if self.texture_sequence and self.node: + self.texture_sequence.rate = 30 + ba.playsound(get_factory().warn_sound, + 0.5, + position=self.node.position) + + def _add_material(self, material: ba.Material) -> None: + if not self.node: + return + materials = self.node.materials + if material not in materials: + assert isinstance(materials, tuple) + self.node.materials = materials + (material, ) + + def arm(self) -> None: + """Arm the bomb (for land-mines and impact-bombs). + + These types of bombs will not explode until they have been armed. + """ + if not self.node: + return + factory = get_factory() + intex: Sequence[ba.Texture] + if self.bomb_type == 'land_mine': + intex = (factory.land_mine_lit_tex, factory.land_mine_tex) + self.texture_sequence = ba.newnode('texture_sequence', + owner=self.node, + attrs={ + 'rate': 30, + 'input_textures': intex + }) + ba.timer(0.5, self.texture_sequence.delete) + # We now make it explodable. + ba.timer( + 0.25, + ba.WeakCall(self._add_material, + factory.land_mine_blast_material)) + elif self.bomb_type == 'impact': + intex = (factory.impact_lit_tex, factory.impact_tex, + factory.impact_tex) + self.texture_sequence = ba.newnode('texture_sequence', + owner=self.node, + attrs={ + 'rate': 100, + 'input_textures': intex + }) + ba.timer( + 0.25, + ba.WeakCall(self._add_material, + factory.land_mine_blast_material)) + else: + raise Exception('arm() should only be called ' + 'on land-mines or impact bombs') + self.texture_sequence.connectattr('output_texture', self.node, + 'color_texture') + ba.playsound(factory.activate_sound, 0.5, position=self.node.position) + + def _handle_hit(self, msg: ba.HitMessage) -> None: + ispunch = (msg.srcnode and msg.srcnode.getnodetype() == 'spaz') + + # Normal bombs are triggered by non-punch impacts; + # impact-bombs by all impacts. + if (not self._exploded and not ispunch + or self.bomb_type in ['impact', 'land_mine']): + # Also lets change the owner of the bomb to whoever is setting + # us off. (this way points for big chain reactions go to the + # person causing them). + if msg.source_player not in [None]: + self.source_player = msg.source_player + + # Also inherit the hit type (if a landmine sets off by a bomb, + # the credit should go to the mine) + # the exception is TNT. TNT always gets credit. + if self.bomb_type != 'tnt': + self.hit_type = msg.hit_type + self.hit_subtype = msg.hit_subtype + + ba.timer(100 + int(random.random() * 100), + ba.WeakCall(self.handlemessage, ExplodeMessage()), + timeformat=ba.TimeFormat.MILLISECONDS) + assert self.node + self.node.handlemessage("impulse", msg.pos[0], msg.pos[1], msg.pos[2], + msg.velocity[0], msg.velocity[1], + msg.velocity[2], msg.magnitude, + msg.velocity_magnitude, msg.radius, 0, + msg.velocity[0], msg.velocity[1], + msg.velocity[2]) + + if msg.srcnode: + pass + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, ExplodeMessage): + self.explode() + elif isinstance(msg, ImpactMessage): + self._handle_impact() + elif isinstance(msg, ba.PickedUpMessage): + # change our source to whoever just picked us up *only* if its None + # this way we can get points for killing bots with their own bombs + # hmm would there be a downside to this?... + if self.source_player is not None: + self.source_player = msg.node.source_player + elif isinstance(msg, SplatMessage): + self._handle_splat() + elif isinstance(msg, ba.DroppedMessage): + self._handle_dropped() + elif isinstance(msg, ba.HitMessage): + self._handle_hit(msg) + elif isinstance(msg, ba.DieMessage): + self._handle_die() + elif isinstance(msg, ba.OutOfBoundsMessage): + self._handle_oob() + elif isinstance(msg, ArmMessage): + self.arm() + elif isinstance(msg, WarnMessage): + self._handle_warn() + else: + super().handlemessage(msg) + + +class TNTSpawner: + """Regenerates TNT at a given point in space every now and then. + + category: Gameplay Classes + """ + + def __init__(self, position: Sequence[float], respawn_time: float = 30.0): + """Instantiate with given position and respawn_time (in seconds).""" + self._position = position + self._tnt: Optional[Bomb] = None + self._update() + # (go with slightly more than 1 second to avoid timer stacking) + self._update_timer = ba.Timer(1.1, + ba.WeakCall(self._update), + repeat=True) + self._respawn_time = random.uniform(0.8, 1.2) * respawn_time + self._wait_time = 0.0 + + def _update(self) -> None: + tnt_alive = self._tnt is not None and self._tnt.node + if not tnt_alive: + # respawn if its been long enough.. otherwise just increment our + # how-long-since-we-died value + if self._tnt is None or self._wait_time >= self._respawn_time: + self._tnt = Bomb(position=self._position, bomb_type='tnt') + self._wait_time = 0.0 + else: + self._wait_time += 1.1 diff --git a/assets/src/data/scripts/bastd/actor/controlsguide.py b/assets/src/data/scripts/bastd/actor/controlsguide.py new file mode 100644 index 00000000..a319906b --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/controlsguide.py @@ -0,0 +1,464 @@ +"""Defines Actors related to controls guides.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Tuple, Optional, Sequence, Union + + +class ControlsGuide(ba.Actor): + """A screen overlay of game controls. + + category: Gameplay Classes + + Shows button mappings based on what controllers are connected. + Handy to show at the start of a series or whenever there might + be newbies watching. + """ + + def __init__(self, + position: Tuple[float, float] = (390.0, 120.0), + scale: float = 1.0, + delay: float = 0.0, + lifespan: float = None, + bright: bool = False): + """Instantiate an overlay. + + delay: is the time in seconds before the overlay fades in. + + lifespan: if not None, the overlay will fade back out and die after + that long (in milliseconds). + + bright: if True, brighter colors will be used; handy when showing + over gameplay but may be too bright for join-screens, etc. + """ + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + super().__init__() + show_title = True + scale *= 0.75 + image_size = 90.0 * scale + offs = 74.0 * scale + offs5 = 43.0 * scale + ouya = False + maxw = 50 + self._lifespan = lifespan + self._dead = False + self._bright = bright + self._cancel_timer: Optional[ba.Timer] = None + self._fade_in_timer: Optional[ba.Timer] = None + self._update_timer: Optional[ba.Timer] = None + self._title_text: Optional[ba.Node] + clr: Sequence[float] + if show_title: + self._title_text_pos_top = (position[0], + position[1] + 139.0 * scale) + self._title_text_pos_bottom = (position[0], + position[1] + 139.0 * scale) + clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7) + tval = ba.Lstr(value='${A}:', + subs=[('${A}', ba.Lstr(resource='controlsText'))]) + self._title_text = ba.newnode('text', + attrs={ + 'text': tval, + 'host_only': True, + 'scale': 1.1 * scale, + 'shadow': 0.5, + 'flatness': 1.0, + 'maxwidth': 480, + 'v_align': 'center', + 'h_align': 'center', + 'color': clr + }) + else: + self._title_text = None + pos = (position[0], position[1] - offs) + clr = (0.4, 1, 0.4) + self._jump_image = ba.newnode( + 'image', + attrs={ + 'texture': ba.gettexture('buttonJump'), + 'absolute_scale': True, + 'host_only': True, + 'vr_depth': 10, + 'position': pos, + 'scale': (image_size, image_size), + 'color': clr + }) + self._jump_text = ba.newnode('text', + attrs={ + 'v_align': 'top', + 'h_align': 'center', + 'scale': 1.5 * scale, + 'flatness': 1.0, + 'host_only': True, + 'shadow': 1.0, + 'maxwidth': maxw, + 'position': (pos[0], pos[1] - offs5), + 'color': clr + }) + pos = (position[0] - offs * 1.1, position[1]) + clr = (0.2, 0.6, 1) if ouya else (1, 0.7, 0.3) + self._punch_image = ba.newnode( + 'image', + attrs={ + 'texture': ba.gettexture('buttonPunch'), + 'absolute_scale': True, + 'host_only': True, + 'vr_depth': 10, + 'position': pos, + 'scale': (image_size, image_size), + 'color': clr + }) + self._punch_text = ba.newnode('text', + attrs={ + 'v_align': 'top', + 'h_align': 'center', + 'scale': 1.5 * scale, + 'flatness': 1.0, + 'host_only': True, + 'shadow': 1.0, + 'maxwidth': maxw, + 'position': (pos[0], pos[1] - offs5), + 'color': clr + }) + pos = (position[0] + offs * 1.1, position[1]) + clr = (1, 0.3, 0.3) + self._bomb_image = ba.newnode( + 'image', + attrs={ + 'texture': ba.gettexture('buttonBomb'), + 'absolute_scale': True, + 'host_only': True, + 'vr_depth': 10, + 'position': pos, + 'scale': (image_size, image_size), + 'color': clr + }) + self._bomb_text = ba.newnode('text', + attrs={ + 'h_align': 'center', + 'v_align': 'top', + 'scale': 1.5 * scale, + 'flatness': 1.0, + 'host_only': True, + 'shadow': 1.0, + 'maxwidth': maxw, + 'position': (pos[0], pos[1] - offs5), + 'color': clr + }) + pos = (position[0], position[1] + offs) + clr = (1, 0.8, 0.3) if ouya else (0.8, 0.5, 1) + self._pickup_image = ba.newnode( + 'image', + attrs={ + 'texture': ba.gettexture('buttonPickUp'), + 'absolute_scale': True, + 'host_only': True, + 'vr_depth': 10, + 'position': pos, + 'scale': (image_size, image_size), + 'color': clr + }) + self._pick_up_text = ba.newnode('text', + attrs={ + 'v_align': 'top', + 'h_align': 'center', + 'scale': 1.5 * scale, + 'flatness': 1.0, + 'host_only': True, + 'shadow': 1.0, + 'maxwidth': maxw, + 'position': + (pos[0], pos[1] - offs5), + 'color': clr + }) + clr = (0.9, 0.9, 2.0, 1.0) if bright else (0.8, 0.8, 2.0, 1.0) + self._run_text_pos_top = (position[0], position[1] - 135.0 * scale) + self._run_text_pos_bottom = (position[0], position[1] - 172.0 * scale) + sval = (1.0 * scale if ba.app.vr_mode else 0.8 * scale) + self._run_text = ba.newnode( + 'text', + attrs={ + 'scale': sval, + 'host_only': True, + 'shadow': 1.0 if ba.app.vr_mode else 0.5, + 'flatness': 1.0, + 'maxwidth': 380, + 'v_align': 'top', + 'h_align': 'center', + 'color': clr + }) + clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7) + self._extra_text = ba.newnode('text', + attrs={ + 'scale': 0.8 * scale, + 'host_only': True, + 'shadow': 0.5, + 'flatness': 1.0, + 'maxwidth': 380, + 'v_align': 'top', + 'h_align': 'center', + 'color': clr + }) + self._nodes = [ + self._bomb_image, self._bomb_text, self._punch_image, + self._punch_text, self._jump_image, self._jump_text, + self._pickup_image, self._pick_up_text, self._run_text, + self._extra_text + ] + if show_title: + assert self._title_text + self._nodes.append(self._title_text) + + # Start everything invisible. + for node in self._nodes: + node.opacity = 0.0 + + # Don't do anything until our delay has passed. + ba.timer(delay, ba.WeakCall(self._start_updating)) + + def _start_updating(self) -> None: + + # Ok, our delay has passed. Now lets periodically see if we can fade + # in (if a touch-screen is present we only want to show up if gamepads + # are connected, etc). + # Also set up a timer so if we haven't faded in by the end of our + # duration, abort. + if self._lifespan is not None: + self._cancel_timer = ba.Timer( + self._lifespan, + ba.WeakCall(self.handlemessage, ba.DieMessage(immediate=True))) + self._fade_in_timer = ba.Timer(1.0, + ba.WeakCall(self._check_fade_in), + repeat=True) + self._check_fade_in() # Do one check immediately. + + def _check_fade_in(self) -> None: + from ba.internal import get_device_value + + # If we have a touchscreen, we only fade in if we have a player with + # an input device that is *not* the touchscreen. + touchscreen = _ba.get_input_device('TouchScreen', '#1', doraise=False) + + if touchscreen is not None: + # We look at the session's players; not the activity's. + # We want to get ones who are still in the process of + # selecting a character, etc. + input_devices = [ + p.get_input_device() for p in ba.getsession().players + ] + input_devices = [ + i for i in input_devices if i and i is not touchscreen + ] + fade_in = False + if input_devices: + # Only count this one if it has non-empty button names + # (filters out wiimotes, the remote-app, etc). + for device in input_devices: + for name in ('buttonPunch', 'buttonJump', 'buttonBomb', + 'buttonPickUp'): + if device.get_button_name( + get_device_value(device, name)) != '': + fade_in = True + break + if fade_in: + break # No need to keep looking. + else: + # No touch-screen; fade in immediately. + fade_in = True + if fade_in: + self._cancel_timer = None # Didn't need this. + self._fade_in_timer = None # Done with this. + self._fade_in() + + def _fade_in(self) -> None: + for node in self._nodes: + ba.animate(node, 'opacity', {0: 0.0, 2.0: 1.0}) + + # If we were given a lifespan, transition out after it. + if self._lifespan is not None: + ba.timer(self._lifespan, + ba.WeakCall(self.handlemessage, ba.DieMessage())) + self._update() + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + repeat=True) + + def _update(self) -> None: + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + from ba.internal import get_device_value, get_remote_app_name + if self._dead: + return + punch_button_names = set() + jump_button_names = set() + pickup_button_names = set() + bomb_button_names = set() + + # We look at the session's players; not the activity's - we want to + # get ones who are still in the process of selecting a character, etc. + input_devices = [p.get_input_device() for p in ba.getsession().players] + input_devices = [i for i in input_devices if i] + + # If there's no players with input devices yet, try to default to + # showing keyboard controls. + if not input_devices: + kbd = _ba.get_input_device('Keyboard', '#1', doraise=False) + if kbd is not None: + input_devices.append(kbd) + + # We word things specially if we have nothing but keyboards. + all_keyboards = (input_devices + and all(i.name == 'Keyboard' for i in input_devices)) + only_remote = (len(input_devices) == 1 + and all(i.name == 'Amazon Fire TV Remote' + for i in input_devices)) + + right_button_names = set() + left_button_names = set() + up_button_names = set() + down_button_names = set() + + # For each player in the game with an input device, + # get the name of the button for each of these 4 actions. + # If any of them are uniform across all devices, display the name. + for device in input_devices: + # We only care about movement buttons in the case of keyboards. + if all_keyboards: + right_button_names.add( + device.get_button_name( + get_device_value(device, 'buttonRight'))) + left_button_names.add( + device.get_button_name( + get_device_value(device, 'buttonLeft'))) + down_button_names.add( + device.get_button_name( + get_device_value(device, 'buttonDown'))) + up_button_names.add( + device.get_button_name(get_device_value( + device, 'buttonUp'))) + + # Ignore empty values; things like the remote app or + # wiimotes can return these. + bname = device.get_button_name( + get_device_value(device, 'buttonPunch')) + if bname != '': + punch_button_names.add(bname) + bname = device.get_button_name( + get_device_value(device, 'buttonJump')) + if bname != '': + jump_button_names.add(bname) + bname = device.get_button_name( + get_device_value(device, 'buttonBomb')) + if bname != '': + bomb_button_names.add(bname) + bname = device.get_button_name( + get_device_value(device, 'buttonPickUp')) + if bname != '': + pickup_button_names.add(bname) + + # If we have no values yet, we may want to throw out some sane + # defaults. + if all(not lst for lst in (punch_button_names, jump_button_names, + bomb_button_names, pickup_button_names)): + # Otherwise on android show standard buttons. + if ba.app.platform == 'android': + punch_button_names.add('X') + jump_button_names.add('A') + bomb_button_names.add('B') + pickup_button_names.add('Y') + + run_text = ba.Lstr( + value='${R}: ${B}', + subs=[('${R}', ba.Lstr(resource='runText')), + ('${B}', + ba.Lstr(resource='holdAnyKeyText' + if all_keyboards else 'holdAnyButtonText'))]) + + # If we're all keyboards, lets show move keys too. + if (all_keyboards and len(up_button_names) == 1 + and len(down_button_names) == 1 and len(left_button_names) == 1 + and len(right_button_names) == 1): + up_text = list(up_button_names)[0] + down_text = list(down_button_names)[0] + left_text = list(left_button_names)[0] + right_text = list(right_button_names)[0] + run_text = ba.Lstr(value='${M}: ${U}, ${L}, ${D}, ${R}\n${RUN}', + subs=[('${M}', ba.Lstr(resource='moveText')), + ('${U}', up_text), ('${L}', left_text), + ('${D}', down_text), ('${R}', right_text), + ('${RUN}', run_text)]) + + self._run_text.text = run_text + w_text: Union[ba.Lstr, str] + if only_remote and self._lifespan is None: + w_text = ba.Lstr(resource='fireTVRemoteWarningText', + subs=[('${REMOTE_APP_NAME}', + get_remote_app_name())]) + else: + w_text = '' + self._extra_text.text = w_text + if len(punch_button_names) == 1: + self._punch_text.text = list(punch_button_names)[0] + else: + self._punch_text.text = '' + + if len(jump_button_names) == 1: + tval = list(jump_button_names)[0] + else: + tval = '' + self._jump_text.text = tval + if tval == '': + self._run_text.position = self._run_text_pos_top + self._extra_text.position = (self._run_text_pos_top[0], + self._run_text_pos_top[1] - 50) + else: + self._run_text.position = self._run_text_pos_bottom + self._extra_text.position = (self._run_text_pos_bottom[0], + self._run_text_pos_bottom[1] - 50) + if len(bomb_button_names) == 1: + self._bomb_text.text = list(bomb_button_names)[0] + else: + self._bomb_text.text = '' + + # Also move our title up/down depending on if this is shown. + if len(pickup_button_names) == 1: + self._pick_up_text.text = list(pickup_button_names)[0] + if self._title_text is not None: + self._title_text.position = self._title_text_pos_top + else: + self._pick_up_text.text = '' + if self._title_text is not None: + self._title_text.position = self._title_text_pos_bottom + + def _die(self) -> None: + for node in self._nodes: + node.delete() + self._nodes = [] + self._update_timer = None + self._dead = True + + def exists(self) -> bool: + return not self._dead + + def handlemessage(self, msg: Any) -> Any: + if __debug__ is True: + self._handlemessage_sanity_check() + if isinstance(msg, ba.DieMessage): + if msg.immediate: + self._die() + else: + # If they don't need immediate, + # fade out our nodes and die later. + for node in self._nodes: + ba.animate(node, 'opacity', {0: node.opacity, 3.0: 0.0}) + ba.timer(3.1, ba.WeakCall(self._die)) + return None + return super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/actor/flag.py b/assets/src/data/scripts/bastd/actor/flag.py new file mode 100644 index 00000000..17a1d040 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/flag.py @@ -0,0 +1,350 @@ +"""Implements a flag used for marking bases, capture-the-flag games, etc.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Sequence, Optional + + +class FlagFactory: + """Wraps up media and other resources used by ba.Flags. + + category: Gameplay Classes + + A single instance of this is shared between all flags + and can be retrieved via bastd.actor.flag.get_factory(). + + Attributes: + + flagmaterial + The ba.Material applied to all ba.Flags. + + impact_sound + The ba.Sound used when a ba.Flag hits the ground. + + skid_sound + The ba.Sound used when a ba.Flag skids along the ground. + + no_hit_material + A ba.Material that prevents contact with most objects; + applied to 'non-touchable' flags. + + flag_texture + The ba.Texture for flags. + """ + + def __init__(self) -> None: + """Instantiate a FlagFactory. + + You shouldn't need to do this; call bastd.actor.flag.get_factory() to + get a shared instance. + """ + + self.flagmaterial = ba.Material() + self.flagmaterial.add_actions( + conditions=(('we_are_younger_than', 100), + 'and', ('they_have_material', + ba.sharedobj('object_material'))), + actions=('modify_node_collision', 'collide', False)) + + self.flagmaterial.add_actions( + conditions=('they_have_material', + ba.sharedobj('footing_material')), + actions=(('message', 'our_node', 'at_connect', 'footing', 1), + ('message', 'our_node', 'at_disconnect', 'footing', -1))) + + self.impact_sound = ba.getsound('metalHit') + self.skid_sound = ba.getsound('metalSkid') + self.flagmaterial.add_actions( + conditions=('they_have_material', + ba.sharedobj('footing_material')), + actions=(('impact_sound', self.impact_sound, 2, 5), + ('skid_sound', self.skid_sound, 2, 5))) + + self.no_hit_material = ba.Material() + self.no_hit_material.add_actions( + conditions=(('they_have_material', + ba.sharedobj('pickup_material')), + 'or', ('they_have_material', + ba.sharedobj('attack_material'))), + actions=('modify_part_collision', 'collide', False)) + + # We also don't want anything moving it. + self.no_hit_material.add_actions( + conditions=(('they_have_material', + ba.sharedobj('object_material')), 'or', + ('they_dont_have_material', + ba.sharedobj('footing_material'))), + actions=(('modify_part_collision', 'collide', False), + ('modify_part_collision', 'physical', False))) + + self.flag_texture = ba.gettexture('flagColor') + + +# noinspection PyTypeHints +def get_factory() -> FlagFactory: + """Get/create a shared bastd.actor.flag.FlagFactory object.""" + activity = ba.getactivity() + factory: FlagFactory + try: + # FIXME: Find elegant way to handle shared data like this. + factory = activity.shared_flag_factory # type: ignore + except Exception: + factory = activity.shared_flag_factory = FlagFactory() # type: ignore + assert isinstance(factory, FlagFactory) + return factory + + +class FlagPickedUpMessage: + """A message saying a ba.Flag has been picked up. + + category: Message Classes + + Attributes: + + flag + The ba.Flag that has been picked up. + + node + The ba.Node doing the picking up. + """ + + def __init__(self, flag: Flag, node: ba.Node): + """Instantiate with given values.""" + self.flag = flag + self.node = node + + +class FlagDeathMessage: + """A message saying a ba.Flag has died. + + category: Message Classes + + Attributes: + + flag + The ba.Flag that died. + """ + + def __init__(self, flag: Flag): + """Instantiate with given values.""" + self.flag = flag + + +class FlagDroppedMessage: + """A message saying a ba.Flag has been dropped. + + category: Message Classes + + Attributes: + + flag + The ba.Flag that was dropped. + + node + The ba.Node that was holding it. + """ + + def __init__(self, flag: Flag, node: ba.Node): + """Instantiate with given values.""" + self.flag = flag + self.node = node + + +class Flag(ba.Actor): + """A flag; used in games such as capture-the-flag or king-of-the-hill. + + category: Gameplay Classes + + Can be stationary or carry-able by players. + """ + + def __init__(self, + position: Sequence[float] = (0.0, 1.0, 0.0), + color: Sequence[float] = (1.0, 1.0, 1.0), + materials: Sequence[ba.Material] = None, + touchable: bool = True, + dropped_timeout: int = None): + """Instantiate a flag. + + If 'touchable' is False, the flag will only touch terrain; + useful for things like king-of-the-hill where players should + not be moving the flag around. + + 'materials can be a list of extra ba.Materials to apply to the flag. + + If 'dropped_timeout' is provided (in seconds), the flag will die + after remaining untouched for that long once it has been moved + from its initial position. + """ + + super().__init__() + + self._initial_position: Optional[Sequence[float]] = None + self._has_moved = False + factory = get_factory() + + if materials is None: + materials = [] + elif not isinstance(materials, list): + # In case they passed a tuple or whatnot. + materials = list(materials) + if not touchable: + materials = [factory.no_hit_material] + materials + + finalmaterials = ( + [ba.sharedobj('object_material'), factory.flagmaterial] + + materials) + self.node = ba.newnode("flag", + attrs={ + 'position': + (position[0], position[1] + 0.75, + position[2]), + 'color_texture': factory.flag_texture, + 'color': color, + 'materials': finalmaterials + }, + delegate=self) + + if dropped_timeout is not None: + dropped_timeout = int(dropped_timeout) + self._dropped_timeout = dropped_timeout + self._counter: Optional[ba.Node] + if self._dropped_timeout is not None: + self._count = self._dropped_timeout + self._tick_timer = ba.Timer(1.0, + call=ba.WeakCall(self._tick), + repeat=True) + self._counter = ba.newnode('text', + owner=self.node, + attrs={ + 'in_world': True, + 'color': (1, 1, 1, 0.7), + 'scale': 0.015, + 'shadow': 0.5, + 'flatness': 1.0, + 'h_align': 'center' + }) + else: + self._counter = None + + self._held_count = 0 + self._score_text: Optional[ba.Node] = None + self._score_text_hide_timer: Optional[ba.Timer] = None + + def _tick(self) -> None: + if self.node: + + # Grab our initial position after one tick (in case we fall). + if self._initial_position is None: + self._initial_position = self.node.position + + # Keep track of when we first move; we don't count down + # until then. + if not self._has_moved: + nodepos = self.node.position + if (max( + abs(nodepos[i] - self._initial_position[i]) + for i in list(range(3))) > 1.0): + self._has_moved = True + + if self._held_count > 0 or not self._has_moved: + assert self._dropped_timeout is not None + assert self._counter + self._count = self._dropped_timeout + self._counter.text = '' + else: + self._count -= 1 + if self._count <= 10: + nodepos = self.node.position + assert self._counter + self._counter.position = (nodepos[0], nodepos[1] + 1.3, + nodepos[2]) + self._counter.text = str(self._count) + if self._count < 1: + self.handlemessage(ba.DieMessage()) + else: + assert self._counter + self._counter.text = '' + + def _hide_score_text(self) -> None: + assert self._score_text is not None + assert isinstance(self._score_text.scale, float) + ba.animate(self._score_text, 'scale', { + 0: self._score_text.scale, + 0.2: 0 + }) + + def set_score_text(self, text: str) -> None: + """Show a message over the flag; handy for scores.""" + if not self.node: + return + if not self._score_text: + start_scale = 0.0 + math = ba.newnode('math', + owner=self.node, + attrs={ + 'input1': (0, 1.4, 0), + 'operation': 'add' + }) + self.node.connectattr('position', math, 'input2') + self._score_text = ba.newnode('text', + owner=self.node, + attrs={ + 'text': text, + 'in_world': True, + 'scale': 0.02, + 'shadow': 0.5, + 'flatness': 1.0, + 'h_align': 'center' + }) + math.connectattr('output', self._score_text, 'position') + else: + assert isinstance(self._score_text.scale, float) + start_scale = self._score_text.scale + self._score_text.text = text + self._score_text.color = ba.safecolor(self.node.color) + ba.animate(self._score_text, 'scale', {0: start_scale, 0.2: 0.02}) + self._score_text_hide_timer = ba.Timer( + 1.0, ba.WeakCall(self._hide_score_text)) + + def handlemessage(self, msg: Any) -> Any: + self._handlemessage_sanity_check() + if isinstance(msg, ba.DieMessage): + if self.node: + self.node.delete() + if not msg.immediate: + self.activity.handlemessage(FlagDeathMessage(self)) + elif isinstance(msg, ba.HitMessage): + assert self.node + assert msg.force_direction is not None + self.node.handlemessage( + "impulse", msg.pos[0], msg.pos[1], msg.pos[2], msg.velocity[0], + msg.velocity[1], msg.velocity[2], msg.magnitude, + msg.velocity_magnitude, msg.radius, 0, msg.force_direction[0], + msg.force_direction[1], msg.force_direction[2]) + elif isinstance(msg, ba.OutOfBoundsMessage): + # We just kill ourselves when out-of-bounds.. would we ever not + # want this?.. + self.handlemessage(ba.DieMessage(how='fall')) + elif isinstance(msg, ba.PickedUpMessage): + self._held_count += 1 + if self._held_count == 1 and self._counter is not None: + self._counter.text = '' + activity = self.getactivity() + if activity is not None: + activity.handlemessage(FlagPickedUpMessage(self, msg.node)) + elif isinstance(msg, ba.DroppedMessage): + self._held_count -= 1 + if self._held_count < 0: + print('Flag held count < 0') + self._held_count = 0 + activity = self.getactivity() + if activity is not None: + activity.handlemessage(FlagDroppedMessage(self, msg.node)) + else: + super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/actor/image.py b/assets/src/data/scripts/bastd/actor/image.py new file mode 100644 index 00000000..734c236f --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/image.py @@ -0,0 +1,151 @@ +"""Defines Actor(s).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Tuple, Sequence, Union, Dict, Optional + + +class Image(ba.Actor): + """Just a wrapped up image node with a few tricks up its sleeve.""" + + def __init__(self, + texture: Union[ba.Texture, Dict[str, Any]], + position: Tuple[float, float] = (0, 0), + transition: str = None, + transition_delay: float = 0.0, + attach: str = 'center', + color: Sequence[float] = (1.0, 1.0, 1.0, 1.0), + scale: Tuple[float, float] = (100.0, 100.0), + transition_out_delay: float = None, + model_opaque: ba.Model = None, + model_transparent: ba.Model = None, + vr_depth: float = 0.0, + host_only: bool = False, + front: bool = False): + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + super().__init__() + # if they provided a dict as texture, assume its an icon.. + # otherwise its just a texture value itself + mask_texture: Optional[ba.Texture] + if isinstance(texture, dict): + tint_color = texture['tint_color'] + tint2_color = texture['tint2_color'] + tint_texture = texture['tint_texture'] + texture = texture['texture'] + mask_texture = ba.gettexture('characterIconMask') + else: + tint_color = (1, 1, 1) + tint2_color = None + tint_texture = None + mask_texture = None + + self.node = ba.newnode('image', + attrs={ + 'texture': texture, + 'tint_color': tint_color, + 'tint_texture': tint_texture, + 'position': position, + 'vr_depth': vr_depth, + 'scale': scale, + 'mask_texture': mask_texture, + 'color': color, + 'absolute_scale': True, + 'host_only': host_only, + 'front': front, + 'attach': attach + }, + delegate=self) + + if model_opaque is not None: + self.node.model_opaque = model_opaque + if model_transparent is not None: + self.node.model_transparent = model_transparent + if tint2_color is not None: + self.node.tint2_color = tint2_color + if transition == 'fade_in': + keys = {transition_delay: 0, transition_delay + 0.5: color[3]} + if transition_out_delay is not None: + keys[transition_delay + transition_out_delay] = color[3] + keys[transition_delay + transition_out_delay + 0.5] = 0 + ba.animate(self.node, 'opacity', keys) + cmb = self.position_combine = ba.newnode('combine', + owner=self.node, + attrs={'size': 2}) + if transition == 'in_right': + keys = { + transition_delay: position[0] + 1200, + transition_delay + 0.2: position[0] + } + o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0} + ba.animate(cmb, 'input0', keys) + cmb.input1 = position[1] + ba.animate(self.node, 'opacity', o_keys) + elif transition == 'in_left': + keys = { + transition_delay: position[0] - 1200, + transition_delay + 0.2: position[0] + } + o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0} + if transition_out_delay is not None: + keys[transition_delay + transition_out_delay] = position[0] + keys[transition_delay + transition_out_delay + + 200] = -position[0] - 1200 + o_keys[transition_delay + transition_out_delay + 0.15] = 1.0 + o_keys[transition_delay + transition_out_delay + 0.2] = 0.0 + ba.animate(cmb, 'input0', keys) + cmb.input1 = position[1] + ba.animate(self.node, 'opacity', o_keys) + elif transition == 'in_bottom_slow': + keys = { + transition_delay: -400, + transition_delay + 3.5: position[1] + } + o_keys = {transition_delay: 0.0, transition_delay + 2.0: 1.0} + cmb.input0 = position[0] + ba.animate(cmb, 'input1', keys) + ba.animate(self.node, 'opacity', o_keys) + elif transition == 'in_bottom': + keys = { + transition_delay: -400, + transition_delay + 0.2: position[1] + } + o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0} + if transition_out_delay is not None: + keys[transition_delay + transition_out_delay] = position[1] + keys[transition_delay + transition_out_delay + 0.2] = -400 + o_keys[transition_delay + transition_out_delay + 0.15] = 1.0 + o_keys[transition_delay + transition_out_delay + 0.2] = 0.0 + cmb.input0 = position[0] + ba.animate(cmb, 'input1', keys) + ba.animate(self.node, 'opacity', o_keys) + elif transition == 'inTopSlow': + keys = {transition_delay: 400, transition_delay + 3.5: position[1]} + o_keys = {transition_delay: 0.0, transition_delay + 1.0: 1.0} + cmb.input0 = position[0] + ba.animate(cmb, 'input1', keys) + ba.animate(self.node, 'opacity', o_keys) + else: + cmb.input0 = position[0] + cmb.input1 = position[1] + cmb.connectattr('output', self.node, 'position') + + # if we're transitioning out, die at the end of it + if transition_out_delay is not None: + ba.timer(transition_delay + transition_out_delay + 1.0, + ba.WeakCall(self.handlemessage, ba.DieMessage())) + + def handlemessage(self, msg: Any) -> Any: + if __debug__ is True: + self._handlemessage_sanity_check() + if isinstance(msg, ba.DieMessage): + if self.node: + self.node.delete() + return None + return super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/actor/onscreencountdown.py b/assets/src/data/scripts/bastd/actor/onscreencountdown.py new file mode 100644 index 00000000..b7303ccf --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/onscreencountdown.py @@ -0,0 +1,99 @@ +"""Defines Actor Type(s).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + + +class OnScreenCountdown(ba.Actor): + """A Handy On-Screen Timer. + + category: Gameplay Classes + + Useful for time-based games that count down to zero. + """ + + def __init__(self, duration: int, endcall: Callable[[], Any] = None): + """Duration is provided in seconds.""" + super().__init__() + self._timeremaining = duration + self._ended = False + self._endcall = endcall + self.node = ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'center', + 'color': (1, 1, 0.5, 1), + 'flatness': 0.5, + 'shadow': 0.5, + 'position': (0, -70), + 'scale': 1.4, + 'text': '' + }) + self.inputnode = ba.newnode('timedisplay', + attrs={ + 'time2': duration * 1000, + 'timemax': duration * 1000, + 'timemin': 0 + }) + self.inputnode.connectattr('output', self.node, 'text') + self._countdownsounds = { + 10: ba.getsound('announceTen'), + 9: ba.getsound('announceNine'), + 8: ba.getsound('announceEight'), + 7: ba.getsound('announceSeven'), + 6: ba.getsound('announceSix'), + 5: ba.getsound('announceFive'), + 4: ba.getsound('announceFour'), + 3: ba.getsound('announceThree'), + 2: ba.getsound('announceTwo'), + 1: ba.getsound('announceOne') + } + self._timer: Optional[ba.Timer] = None + + def start(self) -> None: + """Start the timer.""" + globalsnode = ba.sharedobj('globals') + globalsnode.connectattr('time', self.inputnode, 'time1') + self.inputnode.time2 = (globalsnode.time + + (self._timeremaining + 1) * 1000) + self._timer = ba.Timer(1.0, self._update, repeat=True) + + def on_expire(self) -> None: + super().on_expire() + # release callbacks/refs + self._endcall = None + + def _update(self, forcevalue: int = None) -> None: + if forcevalue is not None: + tval = forcevalue + else: + self._timeremaining = max(0, self._timeremaining - 1) + tval = self._timeremaining + + # if there's a countdown sound for this time that we + # haven't played yet, play it + if tval == 10: + assert self.node + assert isinstance(self.node.scale, float) + self.node.scale *= 1.2 + cmb = ba.newnode('combine', owner=self.node, attrs={'size': 4}) + cmb.connectattr('output', self.node, 'color') + ba.animate(cmb, "input0", {0: 1.0, 0.15: 1.0}, loop=True) + ba.animate(cmb, "input1", {0: 1.0, 0.15: 0.5}, loop=True) + ba.animate(cmb, "input2", {0: 0.1, 0.15: 0.0}, loop=True) + cmb.input3 = 1.0 + if tval <= 10 and not self._ended: + ba.playsound(ba.getsound('tick')) + if tval in self._countdownsounds: + ba.playsound(self._countdownsounds[tval]) + if tval <= 0 and not self._ended: + self._ended = True + if self._endcall is not None: + self._endcall() diff --git a/assets/src/data/scripts/bastd/actor/onscreentimer.py b/assets/src/data/scripts/bastd/actor/onscreentimer.py new file mode 100644 index 00000000..73c830e3 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/onscreentimer.py @@ -0,0 +1,106 @@ +"""Defines Actor(s).""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Optional, Union, Any + + +class OnScreenTimer(ba.Actor): + """A handy on-screen timer. + + category: Gameplay Classes + + Useful for time-based games where time increases. + """ + + def __init__(self) -> None: + super().__init__() + self._starttime: Optional[int] = None + self.node = ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'center', + 'color': (1, 1, 0.5, 1), + 'flatness': 0.5, + 'shadow': 0.5, + 'position': (0, -70), + 'scale': 1.4, + 'text': '' + }) + self.inputnode = ba.newnode('timedisplay', + attrs={ + 'timemin': 0, + 'showsubseconds': True + }) + self.inputnode.connectattr('output', self.node, 'text') + + def start(self) -> None: + """Start the timer.""" + tval = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(tval, int) + self._starttime = tval + self.inputnode.time1 = self._starttime + ba.sharedobj('globals').connectattr('time', self.inputnode, 'time2') + + def hasstarted(self) -> bool: + """Return whether this timer has started yet.""" + return self._starttime is not None + + def stop(self, + endtime: Union[int, float] = None, + timeformat: ba.TimeFormat = ba.TimeFormat.SECONDS) -> None: + """End the timer. + + If 'endtime' is not None, it is used when calculating + the final display time; otherwise the current time is used. + + 'timeformat' applies to endtime and can be SECONDS or MILLISECONDS + """ + if endtime is None: + endtime = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + timeformat = ba.TimeFormat.MILLISECONDS + + if self._starttime is None: + print('Warning: OnScreenTimer.stop() called without start() first') + else: + endtime_ms: int + if timeformat is ba.TimeFormat.SECONDS: + endtime_ms = int(endtime * 1000) + elif timeformat is ba.TimeFormat.MILLISECONDS: + assert isinstance(endtime, int) + endtime_ms = endtime + else: + raise Exception(f'invalid timeformat: {timeformat}') + + self.inputnode.timemax = endtime_ms - self._starttime + + def getstarttime(self, timeformat: ba.TimeFormat = ba.TimeFormat.SECONDS + ) -> Union[int, float]: + """Return the sim-time when start() was called. + + Time will be returned in seconds if timeformat is SECONDS or + milliseconds if it is MILLISECONDS. + """ + val_ms: Any + if self._starttime is None: + print('WARNING: getstarttime() called on un-started timer') + val_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + else: + val_ms = self._starttime + assert isinstance(val_ms, int) + if timeformat is ba.TimeFormat.SECONDS: + return 0.001 * val_ms + if timeformat is ba.TimeFormat.MILLISECONDS: + return val_ms + raise Exception(f'invalid timeformat: {timeformat}') + + def handlemessage(self, msg: Any) -> Any: + # if we're asked to die, just kill our node/timer + if isinstance(msg, ba.DieMessage): + if self.node: + self.node.delete() diff --git a/assets/src/data/scripts/bastd/actor/playerspaz.py b/assets/src/data/scripts/bastd/actor/playerspaz.py new file mode 100644 index 00000000..5e670764 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/playerspaz.py @@ -0,0 +1,288 @@ +"""Functionality related to player-controlled Spazzes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.actor import spaz as basespaz + +if TYPE_CHECKING: + from typing import Any, Optional, Sequence, Tuple + + +class PlayerSpazDeathMessage: + """A message saying a ba.PlayerSpaz has died. + + category: Message Classes + + Attributes: + + spaz + The ba.PlayerSpaz that died. + + killed + If True, the spaz was killed; + If False, they left the game or the round ended. + + killerplayer + The ba.Player that did the killing, or None. + + how + The particular type of death. + """ + + def __init__(self, spaz: PlayerSpaz, was_killed: bool, + killerplayer: Optional[ba.Player], how: str): + """Instantiate a message with the given values.""" + self.spaz = spaz + self.killed = was_killed + self.killerplayer = killerplayer + self.how = how + + +class PlayerSpazHurtMessage: + """A message saying a ba.PlayerSpaz was hurt. + + category: Message Classes + + Attributes: + + spaz + The ba.PlayerSpaz that was hurt + """ + + def __init__(self, spaz: PlayerSpaz): + """Instantiate with the given ba.Spaz value.""" + self.spaz = spaz + + +class PlayerSpaz(basespaz.Spaz): + """A ba.Spaz subclass meant to be controlled by a ba.Player. + + category: Gameplay Classes + + When a PlayerSpaz dies, it delivers a ba.PlayerSpazDeathMessage + to the current ba.Activity. (unless the death was the result of the + player leaving the game, in which case no message is sent) + + When a PlayerSpaz is hurt, it delivers a ba.PlayerSpazHurtMessage + to the current ba.Activity. + """ + + def __init__(self, + color: Sequence[float] = (1.0, 1.0, 1.0), + highlight: Sequence[float] = (0.5, 0.5, 0.5), + character: str = "Spaz", + player: ba.Player = None, + powerups_expire: bool = True): + """Create a spaz for the provided ba.Player. + + Note: this does not wire up any controls; + you must call connect_controls_to_player() to do so. + """ + + basespaz.Spaz.__init__(self, + color=color, + highlight=highlight, + character=character, + source_player=player, + start_invincible=True, + powerups_expire=powerups_expire) + self.last_player_attacked_by: Optional[ba.Player] = None + self.last_attacked_time = 0.0 + self.last_attacked_type: Optional[Tuple[str, str]] = None + self.held_count = 0 + self.last_player_held_by: Optional[ba.Player] = None + self._player = player + + # Grab the node for this player and wire it to follow our spaz + # (so players' controllers know where to draw their guides, etc). + if player: + assert self.node + assert player.node + self.node.connectattr('torso_position', player.node, 'position') + + @property + def player(self) -> ba.Player: + """The ba.Player associated with this Spaz. + + If the player no longer exists, raises an Exception. + """ + player = self._player + if not player: + raise Exception("player no longer exists") + return player + + def getplayer(self) -> Optional[ba.Player]: + """Get the ba.Player associated with this Spaz. + + Note that this may return None or an invalidated ba.Player, + so always test it with 'if playerval' before using it to + cover both cases. + """ + return self._player + + def connect_controls_to_player(self, + enable_jump: bool = True, + enable_punch: bool = True, + enable_pickup: bool = True, + enable_bomb: bool = True, + enable_run: bool = True, + enable_fly: bool = True) -> None: + """Wire this spaz up to the provided ba.Player. + + Full control of the character is given by default + but can be selectively limited by passing False + to specific arguments. + """ + player = self.getplayer() + assert player + + # Reset any currently connected player and/or the player we're + # wiring up. + if self._connected_to_player: + if player != self._connected_to_player: + player.reset_input() + self.disconnect_controls_from_player() + else: + player.reset_input() + + player.assign_input_call('upDown', self.on_move_up_down) + player.assign_input_call('leftRight', self.on_move_left_right) + player.assign_input_call('holdPositionPress', + self._on_hold_position_press) + player.assign_input_call('holdPositionRelease', + self._on_hold_position_release) + if enable_jump: + player.assign_input_call('jumpPress', self.on_jump_press) + player.assign_input_call('jumpRelease', self.on_jump_release) + if enable_pickup: + player.assign_input_call('pickUpPress', self.on_pickup_press) + player.assign_input_call('pickUpRelease', self.on_pickup_release) + if enable_punch: + player.assign_input_call('punchPress', self.on_punch_press) + player.assign_input_call('punchRelease', self.on_punch_release) + if enable_bomb: + player.assign_input_call('bombPress', self.on_bomb_press) + player.assign_input_call('bombRelease', self.on_bomb_release) + if enable_run: + player.assign_input_call('run', self.on_run) + if enable_fly: + player.assign_input_call('flyPress', self.on_fly_press) + player.assign_input_call('flyRelease', self.on_fly_release) + + self._connected_to_player = player + + def disconnect_controls_from_player(self) -> None: + """ + Completely sever any previously connected + ba.Player from control of this spaz. + """ + if self._connected_to_player: + self._connected_to_player.reset_input() + self._connected_to_player = None + + # Send releases for anything in case its held. + self.on_move_up_down(0) + self.on_move_left_right(0) + self._on_hold_position_release() + self.on_jump_release() + self.on_pickup_release() + self.on_punch_release() + self.on_bomb_release() + self.on_run(0.0) + self.on_fly_release() + else: + print('WARNING: disconnect_controls_from_player() called for' + ' non-connected player') + + def handlemessage(self, msg: Any) -> Any: + # FIXME: Tidy this up. + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-nested-blocks + self._handlemessage_sanity_check() + + # Keep track of if we're being held and by who most recently. + if isinstance(msg, ba.PickedUpMessage): + super().handlemessage(msg) # Augment standard behavior. + self.held_count += 1 + picked_up_by = msg.node.source_player + if picked_up_by: + self.last_player_held_by = picked_up_by + elif isinstance(msg, ba.DroppedMessage): + super().handlemessage(msg) # Augment standard behavior. + self.held_count -= 1 + if self.held_count < 0: + print("ERROR: spaz held_count < 0") + + # Let's count someone dropping us as an attack. + try: + picked_up_by = msg.node.source_player + except Exception: + picked_up_by = None + if picked_up_by: + self.last_player_attacked_by = picked_up_by + self.last_attacked_time = ba.time() + self.last_attacked_type = ('picked_up', 'default') + elif isinstance(msg, ba.DieMessage): + + # Report player deaths to the game. + if not self._dead: + + # Immediate-mode or left-game deaths don't count as 'kills'. + killed = (not msg.immediate and msg.how != 'leftGame') + + activity = self._activity() + + if not killed: + killerplayer = None + else: + # If this player was being held at the time of death, + # the holder is the killer. + if self.held_count > 0 and self.last_player_held_by: + killerplayer = self.last_player_held_by + else: + # Otherwise, if they were attacked by someone in the + # last few seconds, that person is the killer. + # Otherwise it was a suicide. + # FIXME: Currently disabling suicides in Co-Op since + # all bot kills would register as suicides; need to + # change this from last_player_attacked_by to + # something like last_actor_attacked_by to fix that. + if (self.last_player_attacked_by + and ba.time() - self.last_attacked_time < 4.0): + killerplayer = self.last_player_attacked_by + else: + # ok, call it a suicide unless we're in co-op + if (activity is not None and not isinstance( + activity.session, ba.CoopSession)): + killerplayer = self.getplayer() + else: + killerplayer = None + + # Convert dead-refs to None. + if not killerplayer: + killerplayer = None + + # Only report if both the player and the activity still exist. + if killed and activity is not None and self.getplayer(): + activity.handlemessage( + PlayerSpazDeathMessage(self, killed, killerplayer, + msg.how)) + + super().handlemessage(msg) # Augment standard behavior. + + # Keep track of the player who last hit us for point rewarding. + elif isinstance(msg, ba.HitMessage): + if msg.source_player: + self.last_player_attacked_by = msg.source_player + self.last_attacked_time = ba.time() + self.last_attacked_type = (msg.hit_type, msg.hit_subtype) + super().handlemessage(msg) # Augment standard behavior. + activity = self._activity() + if activity is not None: + activity.handlemessage(PlayerSpazHurtMessage(self)) + else: + super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/actor/popuptext.py b/assets/src/data/scripts/bastd/actor/popuptext.py new file mode 100644 index 00000000..85fe49a9 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/popuptext.py @@ -0,0 +1,111 @@ +"""Defines Actor(s).""" + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Union, Sequence + + +class PopupText(ba.Actor): + """Text that pops up above a position to denote something special. + + category: Gameplay Classes + """ + + def __init__(self, + text: Union[str, ba.Lstr], + position: Sequence[float] = (0.0, 0.0, 0.0), + color: Sequence[float] = (1.0, 1.0, 1.0, 1.0), + random_offset: float = 0.5, + offset: Sequence[float] = (0.0, 0.0, 0.0), + scale: float = 1.0): + """Instantiate with given values. + + random_offset is the amount of random offset from the provided position + that will be applied. This can help multiple achievements from + overlapping too much. + """ + super().__init__() + if len(color) == 3: + color = (color[0], color[1], color[2], 1.0) + pos = (position[0] + offset[0] + random_offset * + (0.5 - random.random()), position[1] + offset[0] + + random_offset * (0.5 - random.random()), position[2] + + offset[0] + random_offset * (0.5 - random.random())) + + self.node = ba.newnode('text', + attrs={ + 'text': text, + 'in_world': True, + 'shadow': 1.0, + 'flatness': 1.0, + 'h_align': 'center' + }, + delegate=self) + + lifespan = 1.5 + + # scale up + ba.animate( + self.node, 'scale', { + 0: 0.0, + lifespan * 0.11: 0.020 * 0.7 * scale, + lifespan * 0.16: 0.013 * 0.7 * scale, + lifespan * 0.25: 0.014 * 0.7 * scale + }) + + # translate upward + self._tcombine = ba.newnode('combine', + owner=self.node, + attrs={ + 'input0': pos[0], + 'input2': pos[2], + 'size': 3 + }) + ba.animate(self._tcombine, 'input1', { + 0: pos[1] + 1.5, + lifespan: pos[1] + 2.0 + }) + self._tcombine.connectattr('output', self.node, 'position') + + # fade our opacity in/out + self._combine = ba.newnode('combine', + owner=self.node, + attrs={ + 'input0': color[0], + 'input1': color[1], + 'input2': color[2], + 'size': 4 + }) + for i in range(4): + ba.animate( + self._combine, 'input' + str(i), { + 0.13 * lifespan: color[i], + 0.18 * lifespan: 4.0 * color[i], + 0.22 * lifespan: color[i] + }) + ba.animate(self._combine, 'input3', { + 0: 0, + 0.1 * lifespan: color[3], + 0.7 * lifespan: color[3], + lifespan: 0 + }) + self._combine.connectattr('output', self.node, 'color') + + # kill ourself + self._die_timer = ba.Timer( + lifespan, ba.WeakCall(self.handlemessage, ba.DieMessage())) + + def handlemessage(self, msg: Any) -> Any: + if __debug__ is True: + self._handlemessage_sanity_check() + if isinstance(msg, ba.DieMessage): + if self.node: + self.node.delete() + else: + super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/actor/powerupbox.py b/assets/src/data/scripts/bastd/actor/powerupbox.py new file mode 100644 index 00000000..c95c8949 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/powerupbox.py @@ -0,0 +1,309 @@ +"""Defines Actor(s).""" + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import List, Any, Optional, Sequence + +DEFAULT_POWERUP_INTERVAL = 8.0 + + +class _TouchedMessage: + pass + + +class PowerupBoxFactory: + """A collection of media and other resources used by ba.Powerups. + + category: Gameplay Classes + + A single instance of this is shared between all powerups + and can be retrieved via ba.Powerup.get_factory(). + + Attributes: + + model + The ba.Model of the powerup box. + + model_simple + A simpler ba.Model of the powerup box, for use in shadows, etc. + + tex_bomb + Triple-bomb powerup ba.Texture. + + tex_punch + Punch powerup ba.Texture. + + tex_ice_bombs + Ice bomb powerup ba.Texture. + + tex_sticky_bombs + Sticky bomb powerup ba.Texture. + + tex_shield + Shield powerup ba.Texture. + + tex_impact_bombs + Impact-bomb powerup ba.Texture. + + tex_health + Health powerup ba.Texture. + + tex_land_mines + Land-mine powerup ba.Texture. + + tex_curse + Curse powerup ba.Texture. + + health_powerup_sound + ba.Sound played when a health powerup is accepted. + + powerup_sound + ba.Sound played when a powerup is accepted. + + powerdown_sound + ba.Sound that can be used when powerups wear off. + + powerup_material + ba.Material applied to powerup boxes. + + powerup_accept_material + Powerups will send a ba.PowerupMessage to anything they touch + that has this ba.Material applied. + """ + + def __init__(self) -> None: + """Instantiate a PowerupBoxFactory. + + You shouldn't need to do this; call ba.Powerup.get_factory() + to get a shared instance. + """ + from ba.internal import get_default_powerup_distribution + self._lastpoweruptype: Optional[str] = None + self.model = ba.getmodel("powerup") + self.model_simple = ba.getmodel("powerupSimple") + self.tex_bomb = ba.gettexture("powerupBomb") + self.tex_punch = ba.gettexture("powerupPunch") + self.tex_ice_bombs = ba.gettexture("powerupIceBombs") + self.tex_sticky_bombs = ba.gettexture("powerupStickyBombs") + self.tex_shield = ba.gettexture("powerupShield") + self.tex_impact_bombs = ba.gettexture("powerupImpactBombs") + self.tex_health = ba.gettexture("powerupHealth") + self.tex_land_mines = ba.gettexture("powerupLandMines") + self.tex_curse = ba.gettexture("powerupCurse") + self.health_powerup_sound = ba.getsound("healthPowerup") + self.powerup_sound = ba.getsound("powerup01") + self.powerdown_sound = ba.getsound("powerdown01") + self.drop_sound = ba.getsound("boxDrop") + + # Material for powerups. + self.powerup_material = ba.Material() + + # Material for anyone wanting to accept powerups. + self.powerup_accept_material = ba.Material() + + # Pass a powerup-touched message to applicable stuff. + self.powerup_material.add_actions( + conditions=("they_have_material", self.powerup_accept_material), + actions=(("modify_part_collision", "collide", + True), ("modify_part_collision", "physical", False), + ("message", "our_node", "at_connect", _TouchedMessage()))) + + # We don't wanna be picked up. + self.powerup_material.add_actions( + conditions=("they_have_material", ba.sharedobj('pickup_material')), + actions=("modify_part_collision", "collide", False)) + + self.powerup_material.add_actions( + conditions=("they_have_material", + ba.sharedobj('footing_material')), + actions=("impact_sound", self.drop_sound, 0.5, 0.1)) + + self._powerupdist: List[str] = [] + for powerup, freq in get_default_powerup_distribution(): + for _i in range(int(freq)): + self._powerupdist.append(powerup) + + def get_random_powerup_type(self, + forcetype: str = None, + excludetypes: List[str] = None) -> str: + """Returns a random powerup type (string). + + See ba.Powerup.poweruptype for available type values. + + There are certain non-random aspects to this; a 'curse' powerup, + for instance, is always followed by a 'health' powerup (to keep things + interesting). Passing 'forcetype' forces a given returned type while + still properly interacting with the non-random aspects of the system + (ie: forcing a 'curse' powerup will result + in the next powerup being health). + """ + if excludetypes is None: + excludetypes = [] + if forcetype: + ptype = forcetype + else: + # If the last one was a curse, make this one a health to + # provide some hope. + if self._lastpoweruptype == 'curse': + ptype = 'health' + else: + while True: + ptype = self._powerupdist[random.randint( + 0, + len(self._powerupdist) - 1)] + if ptype not in excludetypes: + break + self._lastpoweruptype = ptype + return ptype + + +def get_factory() -> PowerupBoxFactory: + """Return a shared ba.PowerupBoxFactory object, creating if necessary.""" + activity = ba.getactivity() + if activity is None: + raise Exception("no current activity") + try: + # FIXME: et better way to store stuff with activity + # pylint: disable=protected-access + # noinspection PyProtectedMember + return activity._shared_powerup_factory # type: ignore + except Exception: + factory = activity._shared_powerup_factory = ( # type: ignore + PowerupBoxFactory()) + return factory + + +class PowerupBox(ba.Actor): + """A box that grants a powerup. + + category: Gameplay Classes + + This will deliver a ba.PowerupMessage to anything that touches it + which has the ba.PowerupBoxFactory.powerup_accept_material applied. + + Attributes: + + poweruptype + The string powerup type. This can be 'triple_bombs', 'punch', + 'ice_bombs', 'impact_bombs', 'land_mines', 'sticky_bombs', 'shield', + 'health', or 'curse'. + + node + The 'prop' ba.Node representing this box. + """ + + def __init__(self, + position: Sequence[float] = (0.0, 1.0, 0.0), + poweruptype: str = 'triple_bombs', + expire: bool = True): + """Create a powerup-box of the requested type at the given position. + + see ba.Powerup.poweruptype for valid type strings. + """ + + super().__init__() + + factory = get_factory() + self.poweruptype = poweruptype + self._powersgiven = False + + if poweruptype == 'triple_bombs': + tex = factory.tex_bomb + elif poweruptype == 'punch': + tex = factory.tex_punch + elif poweruptype == 'ice_bombs': + tex = factory.tex_ice_bombs + elif poweruptype == 'impact_bombs': + tex = factory.tex_impact_bombs + elif poweruptype == 'land_mines': + tex = factory.tex_land_mines + elif poweruptype == 'sticky_bombs': + tex = factory.tex_sticky_bombs + elif poweruptype == 'shield': + tex = factory.tex_shield + elif poweruptype == 'health': + tex = factory.tex_health + elif poweruptype == 'curse': + tex = factory.tex_curse + else: + raise Exception("invalid poweruptype: " + str(poweruptype)) + + if len(position) != 3: + raise Exception("expected 3 floats for position") + + self.node = ba.newnode( + 'prop', + delegate=self, + attrs={ + 'body': 'box', + 'position': position, + 'model': factory.model, + 'light_model': factory.model_simple, + 'shadow_size': 0.5, + 'color_texture': tex, + 'reflection': 'powerup', + 'reflection_scale': [1.0], + 'materials': (factory.powerup_material, + ba.sharedobj('object_material')) + }) # yapf: disable + + # Animate in. + curve = ba.animate(self.node, "model_scale", {0: 0, 0.14: 1.6, 0.2: 1}) + ba.timer(0.2, curve.delete) + + if expire: + ba.timer(DEFAULT_POWERUP_INTERVAL - 2.5, + ba.WeakCall(self._start_flashing)) + ba.timer(DEFAULT_POWERUP_INTERVAL - 1.0, + ba.WeakCall(self.handlemessage, ba.DieMessage())) + + def _start_flashing(self) -> None: + if self.node: + self.node.flashing = True + + def handlemessage(self, msg: Any) -> Any: + # pylint: disable=too-many-branches + self._handlemessage_sanity_check() + + if isinstance(msg, ba.PowerupAcceptMessage): + factory = get_factory() + assert self.node + if self.poweruptype == 'health': + ba.playsound(factory.health_powerup_sound, + 3, + position=self.node.position) + ba.playsound(factory.powerup_sound, 3, position=self.node.position) + self._powersgiven = True + self.handlemessage(ba.DieMessage()) + + elif isinstance(msg, _TouchedMessage): + if not self._powersgiven: + node = ba.get_collision_info("opposing_node") + if node: + node.handlemessage( + ba.PowerupMessage(self.poweruptype, + source_node=self.node)) + + elif isinstance(msg, ba.DieMessage): + if self.node: + if msg.immediate: + self.node.delete() + else: + ba.animate(self.node, "model_scale", {0: 1, 0.1: 0}) + ba.timer(0.1, self.node.delete) + + elif isinstance(msg, ba.OutOfBoundsMessage): + self.handlemessage(ba.DieMessage()) + + elif isinstance(msg, ba.HitMessage): + # Don't die on punches (that's annoying). + if msg.hit_type != 'punch': + self.handlemessage(ba.DieMessage()) + else: + super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/actor/respawnicon.py b/assets/src/data/scripts/bastd/actor/respawnicon.py new file mode 100644 index 00000000..fc67a843 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/respawnicon.py @@ -0,0 +1,152 @@ +"""Implements respawn icon actor.""" + +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Optional + + +class RespawnIcon: + """An icon with a countdown that appears alongside the screen. + + category: Gameplay Classes + + This is used to indicate that a ba.Player is waiting to respawn. + """ + + def __init__(self, player: ba.Player, respawn_time: float): + """ + Instantiate with a given ba.Player and respawn_time (in seconds) + """ + # FIXME; tidy up + # pylint: disable=too-many-locals + activity = ba.getactivity() + self._visible = True + if isinstance(ba.getsession(), ba.TeamsSession): + on_right = player.team.get_id() % 2 == 1 + # store a list of icons in the team + try: + respawn_icons = ( + player.team.gamedata['_spaz_respawn_icons_right']) + except Exception: + respawn_icons = ( + player.team.gamedata['_spaz_respawn_icons_right']) = {} + offs_extra = -20 + else: + on_right = False + # Store a list of icons in the activity. + # FIXME: Need an elegant way to store our + # shared stuff with the activity. + try: + respawn_icons = activity.spaz_respawn_icons_right + except Exception: + respawn_icons = activity.spaz_respawn_icons_right = {} + if isinstance(activity.session, ba.FreeForAllSession): + offs_extra = -150 + else: + offs_extra = -20 + + try: + mask_tex = (player.team.gamedata['_spaz_respawn_icons_mask_tex']) + except Exception: + mask_tex = player.team.gamedata['_spaz_respawn_icons_mask_tex'] = ( + ba.gettexture('characterIconMask')) + + # now find the first unused slot and use that + index = 0 + while (index in respawn_icons and respawn_icons[index]() is not None + and respawn_icons[index]().visible): + index += 1 + respawn_icons[index] = weakref.ref(self) + + offs = offs_extra + index * -53 + icon = player.get_icon() + texture = icon['texture'] + h_offs = -10 + ipos = (-40 - h_offs if on_right else 40 + h_offs, -180 + offs) + self._image: Optional[ba.Actor] = ba.Actor( + ba.newnode('image', + attrs={ + 'texture': texture, + 'tint_texture': icon['tint_texture'], + 'tint_color': icon['tint_color'], + 'tint2_color': icon['tint2_color'], + 'mask_texture': mask_tex, + 'position': ipos, + 'scale': (32, 32), + 'opacity': 1.0, + 'absolute_scale': True, + 'attach': 'topRight' if on_right else 'topLeft' + })) + + assert self._image.node + ba.animate(self._image.node, 'opacity', {0.0: 0, 0.2: 0.7}) + + npos = (-40 - h_offs if on_right else 40 + h_offs, -205 + 49 + offs) + self._name: Optional[ba.Actor] = ba.Actor( + ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'right' if on_right else 'left', + 'text': ba.Lstr(value=player.get_name()), + 'maxwidth': 100, + 'h_align': 'center', + 'v_align': 'center', + 'shadow': 1.0, + 'flatness': 1.0, + 'color': ba.safecolor(icon['tint_color']), + 'scale': 0.5, + 'position': npos + })) + + assert self._name.node + ba.animate(self._name.node, 'scale', {0: 0, 0.1: 0.5}) + + tpos = (-60 - h_offs if on_right else 60 + h_offs, -192 + offs) + self._text: Optional[ba.Actor] = ba.Actor( + ba.newnode('text', + attrs={ + 'position': tpos, + 'h_attach': 'right' if on_right else 'left', + 'h_align': 'right' if on_right else 'left', + 'scale': 0.9, + 'shadow': 0.5, + 'flatness': 0.5, + 'v_attach': 'top', + 'color': ba.safecolor(icon['tint_color']), + 'text': '' + })) + + assert self._text.node + ba.animate(self._text.node, 'scale', {0: 0, 0.1: 0.9}) + + self._respawn_time = ba.time() + respawn_time + self._update() + self._timer: Optional[ba.Timer] = ba.Timer(1.0, + ba.WeakCall(self._update), + repeat=True) + + @property + def visible(self) -> bool: + """Is this icon still visible?""" + return self._visible + + def _update(self) -> None: + remaining = int( + round(self._respawn_time - + ba.time(timeformat=ba.TimeFormat.MILLISECONDS)) / 1000.0) + if remaining > 0: + assert self._text is not None + if self._text.node: + self._text.node.text = str(remaining) + else: + self._clear() + + def _clear(self) -> None: + self._visible = False + self._image = self._text = self._timer = self._name = None diff --git a/assets/src/data/scripts/bastd/actor/scoreboard.py b/assets/src/data/scripts/bastd/actor/scoreboard.py new file mode 100644 index 00000000..7c8a03e3 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/scoreboard.py @@ -0,0 +1,383 @@ +"""Defines ScoreBoard Actor and related functionality.""" + +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Optional, Sequence, Dict, Union + +# This could use some tidying up when I get a chance.. +# pylint: disable=too-many-statements + + +class _Entry: + + def __init__(self, scoreboard: Scoreboard, team: ba.Team, do_cover: bool, + scale: float, label: Optional[ba.Lstr], flash_length: float): + self._scoreboard = weakref.ref(scoreboard) + self._do_cover = do_cover + self._scale = scale + self._flash_length = flash_length + self._width = 140.0 * self._scale + self._height = 32.0 * self._scale + self._bar_width = 2.0 * self._scale + self._bar_height = 32.0 * self._scale + self._bar_tex = self._backing_tex = ba.gettexture('bar') + self._cover_tex = ba.gettexture('uiAtlas') + self._model = ba.getmodel('meterTransparent') + self._pos: Optional[Sequence[float]] = None + self._flash_timer: Optional[ba.Timer] = None + self._flash_counter: Optional[int] = None + self._flash_colors: Optional[bool] = None + self._score: Optional[int] = None + + safe_team_color = ba.safecolor(team.color, target_intensity=1.0) + + # FIXME: Should not do things conditionally for vr-mode, as there may + # be non-vr clients connected. + vrmode = ba.app.vr_mode + + if self._do_cover: + if vrmode: + self._backing_color = [0.1 + c * 0.1 for c in safe_team_color] + else: + self._backing_color = [ + 0.05 + c * 0.17 for c in safe_team_color + ] + else: + self._backing_color = [0.05 + c * 0.1 for c in safe_team_color] + + opacity = (0.8 if vrmode else 0.8) if self._do_cover else 0.5 + self._backing = ba.Actor( + ba.newnode('image', + attrs={ + 'scale': (self._width, self._height), + 'opacity': opacity, + 'color': self._backing_color, + 'vr_depth': -3, + 'attach': 'topLeft', + 'texture': self._backing_tex + })) + + self._barcolor = safe_team_color + self._bar = ba.Actor( + ba.newnode('image', + attrs={ + 'opacity': 0.7, + 'color': self._barcolor, + 'attach': 'topLeft', + 'texture': self._bar_tex + })) + + self._bar_scale = ba.newnode('combine', + owner=self._bar.node, + attrs={ + 'size': 2, + 'input0': self._bar_width, + 'input1': self._bar_height + }) + assert self._bar.node + self._bar_scale.connectattr('output', self._bar.node, 'scale') + self._bar_position = ba.newnode('combine', + owner=self._bar.node, + attrs={ + 'size': 2, + 'input0': 0, + 'input1': 0 + }) + self._bar_position.connectattr('output', self._bar.node, 'position') + self._cover_color = safe_team_color + if self._do_cover: + self._cover = ba.Actor( + ba.newnode('image', + attrs={ + 'scale': + (self._width * 1.15, self._height * 1.6), + 'opacity': 1.0, + 'color': self._cover_color, + 'vr_depth': 2, + 'attach': 'topLeft', + 'texture': self._cover_tex, + 'model_transparent': self._model + })) + + clr = safe_team_color + maxwidth = 130.0 * (1.0 - scoreboard.score_split) + flatness = ((1.0 if vrmode else 0.5) if self._do_cover else 1.0) + self._score_text = ba.Actor( + ba.newnode('text', + attrs={ + 'h_attach': 'left', + 'v_attach': 'top', + 'h_align': 'right', + 'v_align': 'center', + 'maxwidth': maxwidth, + 'vr_depth': 2, + 'scale': self._scale * 0.9, + 'text': '', + 'shadow': 1.0 if vrmode else 0.5, + 'flatness': flatness, + 'color': clr + })) + + clr = safe_team_color + + team_name_label: Union[str, ba.Lstr] + if label is not None: + team_name_label = label + else: + team_name_label = team.name + + # we do our own clipping here; should probably try to tap into some + # existing functionality + if isinstance(team_name_label, ba.Lstr): + + # hmmm; if the team-name is a non-translatable value lets go + # ahead and clip it otherwise we leave it as-is so + # translation can occur.. + if team_name_label.is_flat_value(): + val = team_name_label.evaluate() + if len(val) > 10: + team_name_label = ba.Lstr(value=val[:10] + '...') + else: + if len(team_name_label) > 10: + team_name_label = team_name_label[:10] + '...' + team_name_label = ba.Lstr(value=team_name_label) + + flatness = ((1.0 if vrmode else 0.5) if self._do_cover else 1.0) + self._name_text = ba.Actor( + ba.newnode('text', + attrs={ + 'h_attach': 'left', + 'v_attach': 'top', + 'h_align': 'left', + 'v_align': 'center', + 'vr_depth': 2, + 'scale': self._scale * 0.9, + 'shadow': 1.0 if vrmode else 0.5, + 'flatness': flatness, + 'maxwidth': 130 * scoreboard.score_split, + 'text': team_name_label, + 'color': clr + (1.0, ) + })) + + def flash(self, countdown: bool, extra_flash: bool) -> None: + """Flash momentarily.""" + self._flash_timer = ba.Timer(0.1, + ba.WeakCall(self._do_flash), + repeat=True) + if countdown: + self._flash_counter = 10 + else: + self._flash_counter = int(20.0 * self._flash_length) + if extra_flash: + self._flash_counter *= 4 + self._set_flash_colors(True) + + def set_position(self, position: Sequence[float]) -> None: + """Set the entry's position.""" + # abort if we've been killed + if not self._backing.node: + return + self._pos = tuple(position) + self._backing.node.position = (position[0] + self._width / 2, + position[1] - self._height / 2) + if self._do_cover: + assert self._cover.node + self._cover.node.position = (position[0] + self._width / 2, + position[1] - self._height / 2) + self._bar_position.input0 = self._pos[0] + self._bar_width / 2 + self._bar_position.input1 = self._pos[1] - self._bar_height / 2 + assert self._score_text.node + self._score_text.node.position = (self._pos[0] + self._width - + 7.0 * self._scale, + self._pos[1] - self._bar_height + + 16.0 * self._scale) + assert self._name_text.node + self._name_text.node.position = (self._pos[0] + 7.0 * self._scale, + self._pos[1] - self._bar_height + + 16.0 * self._scale) + + def _set_flash_colors(self, flash: bool) -> None: + self._flash_colors = flash + + def _safesetattr(node: Optional[ba.Node], attr: str, val: Any) -> None: + if node: + setattr(node, attr, val) + + if flash: + scale = 2.0 + _safesetattr( + self._backing.node, "color", + (self._backing_color[0] * scale, self._backing_color[1] * + scale, self._backing_color[2] * scale)) + _safesetattr(self._bar.node, "color", + (self._barcolor[0] * scale, self._barcolor[1] * scale, + self._barcolor[2] * scale)) + if self._do_cover: + _safesetattr( + self._cover.node, "color", + (self._cover_color[0] * scale, self._cover_color[1] * + scale, self._cover_color[2] * scale)) + else: + _safesetattr(self._backing.node, "color", self._backing_color) + _safesetattr(self._bar.node, "color", self._barcolor) + if self._do_cover: + _safesetattr(self._cover.node, "color", self._cover_color) + + def _do_flash(self) -> None: + assert self._flash_counter is not None + if self._flash_counter <= 0: + self._set_flash_colors(False) + else: + self._flash_counter -= 1 + self._set_flash_colors(not self._flash_colors) + + def set_value(self, + score: int, + max_score: int = None, + countdown: bool = False, + flash: bool = True, + show_value: bool = True) -> None: + """Set the value for the scoreboard entry.""" + + # if we have no score yet, just set it.. otherwise compare + # and see if we should flash + if self._score is None: + self._score = score + else: + if score > self._score or (countdown and score < self._score): + extra_flash = (max_score is not None and score >= max_score + and not countdown) or (countdown and score == 0) + if flash: + self.flash(countdown, extra_flash) + self._score = score + + if max_score is None: + self._bar_width = 0.0 + else: + if countdown: + self._bar_width = max( + 2.0 * self._scale, + self._width * (1.0 - (float(score) / max_score))) + else: + self._bar_width = max( + 2.0 * self._scale, + self._width * (min(1.0, + float(score) / max_score))) + + cur_width = self._bar_scale.input0 + ba.animate(self._bar_scale, 'input0', { + 0.0: cur_width, + 0.25: self._bar_width + }) + self._bar_scale.input1 = self._bar_height + cur_x = self._bar_position.input0 + assert self._pos is not None + ba.animate(self._bar_position, 'input0', { + 0.0: cur_x, + 0.25: self._pos[0] + self._bar_width / 2 + }) + self._bar_position.input1 = self._pos[1] - self._bar_height / 2 + assert self._score_text.node + if show_value: + self._score_text.node.text = str(score) + else: + self._score_text.node.text = '' + + +class _EntryProxy: + """Encapsulates adding/removing of a scoreboard Entry.""" + + def __init__(self, scoreboard: Scoreboard, team: ba.Team): + self._scoreboard = weakref.ref(scoreboard) + # have to store ID here instead of a weak-ref since the team will be + # dead when we die and need to remove it + self._team_id = team.get_id() + + def __del__(self) -> None: + scoreboard = self._scoreboard() + # remove our team from the scoreboard if its still around + if scoreboard is not None: + scoreboard.remove_team(self._team_id) + + +class Scoreboard: + """A display for player or team scores during a game. + + category: Gameplay Classes + """ + + def __init__(self, label: ba.Lstr = None, score_split: float = 0.7): + """Instantiate a score-board. + + Label can be something like 'points' and will + show up on boards if provided. + """ + self._flat_tex = ba.gettexture("null") + self._entries: Dict[int, _Entry] = {} + self._label = label + self.score_split = score_split + + # for free-for-all we go simpler since we have one per player + self._pos: Sequence[float] + if isinstance(ba.getsession(), ba.FreeForAllSession): + self._do_cover = False + self._spacing = 35.0 + self._pos = (17.0, -65.0) + self._scale = 0.8 + self._flash_length = 0.5 + else: + self._do_cover = True + self._spacing = 50.0 + self._pos = (20.0, -70.0) + self._scale = 1.0 + self._flash_length = 1.0 + + def set_team_value(self, + team: ba.Team, + score: int, + max_score: int = None, + countdown: bool = False, + flash: bool = True, + show_value: bool = True) -> None: + """Update the score-board display for the given ba.Team.""" + if not team.get_id() in self._entries: + self._add_team(team) + # create a proxy in the team which will kill + # our entry when it dies (for convenience) + if '_scoreboard_entry' in team.gamedata: + raise Exception("existing _EntryProxy found") + team.gamedata['_scoreboard_entry'] = _EntryProxy(self, team) + # now set the entry.. + self._entries[team.get_id()].set_value(score=score, + max_score=max_score, + countdown=countdown, + flash=flash, + show_value=show_value) + + def _add_team(self, team: ba.Team) -> None: + if team.get_id() in self._entries: + raise Exception('Duplicate team add') + self._entries[team.get_id()] = _Entry(self, + team, + do_cover=self._do_cover, + scale=self._scale, + label=self._label, + flash_length=self._flash_length) + self._update_teams() + + def remove_team(self, team_id: int) -> None: + """Remove the team with the given id from the scoreboard.""" + del self._entries[team_id] + self._update_teams() + + def _update_teams(self) -> None: + pos = list(self._pos) + for entry in list(self._entries.values()): + entry.set_position(pos) + pos[1] -= self._spacing * self._scale diff --git a/assets/src/data/scripts/bastd/actor/spawner.py b/assets/src/data/scripts/bastd/actor/spawner.py new file mode 100644 index 00000000..410e099e --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/spawner.py @@ -0,0 +1,105 @@ +"""Defines some lovely Actor(s).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Sequence, Callable + + +# FIXME: Should make this an Actor. +class Spawner: + """Utility for delayed spawning of objects. + + category: Gameplay Classes + + Creates a light flash and sends a ba.Spawner.SpawnMessage + to the current activity after a delay. + """ + + class SpawnMessage: + """Spawn message sent by a ba.Spawner after its delay has passed. + + category: Message Classes + + Attributes: + + spawner + The ba.Spawner we came from. + + data + The data object passed by the user. + + pt + The spawn position. + """ + + def __init__(self, spawner: Spawner, data: Any, pt: Sequence[float]): + """Instantiate with the given values.""" + self.spawner = spawner + self.data = data + self.pt = pt # pylint: disable=invalid-name + + def __init__(self, + data: Any = None, + pt: Sequence[float] = (0, 0, 0), + spawn_time: float = 1.0, + send_spawn_message: bool = True, + spawn_callback: Callable[[], Any] = None): + """Instantiate a Spawner. + + Requires some custom data, a position, + and a spawn-time in seconds. + """ + self._spawn_callback = spawn_callback + self._send_spawn_message = send_spawn_message + self._spawner_sound = ba.getsound('swip2') + self._data = data + self._pt = pt + # create a light where the spawn will happen + self._light = ba.newnode('light', + attrs={ + 'position': tuple(pt), + 'radius': 0.1, + 'color': (1.0, 0.1, 0.1), + 'lights_volumes': False + }) + scl = float(spawn_time) / 3.75 + min_val = 0.4 + max_val = 0.7 + ba.playsound(self._spawner_sound, position=self._light.position) + ba.animate( + self._light, 'intensity', { + 0.0: 0.0, + 0.25 * scl: max_val, + 0.500 * scl: min_val, + 0.750 * scl: max_val, + 1.000 * scl: min_val, + 1.250 * scl: 1.1 * max_val, + 1.500 * scl: min_val, + 1.750 * scl: 1.2 * max_val, + 2.000 * scl: min_val, + 2.250 * scl: 1.3 * max_val, + 2.500 * scl: min_val, + 2.750 * scl: 1.4 * max_val, + 3.000 * scl: min_val, + 3.250 * scl: 1.5 * max_val, + 3.500 * scl: min_val, + 3.750 * scl: 2.0, + 4.000 * scl: 0.0 + }) + ba.timer(spawn_time, self._spawn) + + def _spawn(self) -> None: + ba.timer(1.0, self._light.delete) + if self._spawn_callback is not None: + self._spawn_callback() + if self._send_spawn_message: + # only run if our activity still exists + activity = ba.getactivity() + if activity is not None: + activity.handlemessage( + self.SpawnMessage(self, self._data, self._pt)) diff --git a/assets/src/data/scripts/bastd/actor/spaz.py b/assets/src/data/scripts/bastd/actor/spaz.py new file mode 100644 index 00000000..1ea969d1 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/spaz.py @@ -0,0 +1,1407 @@ +"""Defines the spaz actor.""" +# pylint: disable=too-many-lines + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor import bomb as stdbomb +from bastd.actor import powerupbox + +if TYPE_CHECKING: + from typing import (Any, Sequence, Optional, Dict, List, Union, Callable, + Tuple, Set) + from bastd.actor.spazfactory import SpazFactory + +POWERUP_WEAR_OFF_TIME = 20000 +BASE_PUNCH_COOLDOWN = 400 + + +class PickupMessage: + """We wanna pick something up.""" + + +class PunchHitMessage: + """Message saying an object was hit.""" + + +class CurseExplodeMessage: + """We are cursed and should blow up now.""" + + +class BombDiedMessage: + """A bomb has died and thus can be recycled.""" + + +def get_factory() -> SpazFactory: + """Return the shared ba.SpazFactory object, creating it if necessary.""" + # pylint: disable=cyclic-import + activity = ba.getactivity() + factory = getattr(activity, 'shared_spaz_factory', None) + if factory is None: + from bastd.actor.spazfactory import SpazFactory + # noinspection PyTypeHints + factory = activity.shared_spaz_factory = SpazFactory() # type: ignore + assert isinstance(factory, SpazFactory) + return factory + + +class Spaz(ba.Actor): + """ + Base class for various Spazzes. + + category: Gameplay Classes + + A Spaz is the standard little humanoid character in the game. + It can be controlled by a player or by AI, and can have + various different appearances. The name 'Spaz' is not to be + confused with the 'Spaz' character in the game, which is just + one of the skins available for instances of this class. + + Attributes: + + node + The 'spaz' ba.Node. + """ + + # pylint: disable=too-many-public-methods + # pylint: disable=too-many-locals + + points_mult = 1 + curse_time: Optional[float] = 5.0 + default_bomb_count = 1 + default_bomb_type = 'normal' + default_boxing_gloves = False + default_shields = False + + def __init__(self, + color: Sequence[float] = (1.0, 1.0, 1.0), + highlight: Sequence[float] = (0.5, 0.5, 0.5), + character: str = "Spaz", + source_player: ba.Player = None, + start_invincible: bool = True, + can_accept_powerups: bool = True, + powerups_expire: bool = False, + demo_mode: bool = False): + """Create a spaz with the requested color, character, etc.""" + # pylint: disable=too-many-statements + + super().__init__() + activity = self.activity + + factory = get_factory() + + # we need to behave slightly different in the tutorial + self._demo_mode = demo_mode + + self.play_big_death_sound = False + + # scales how much impacts affect us (most damage calcs) + self.impact_scale = 1.0 + + self.source_player = source_player + self._dead = False + if self._demo_mode: # preserve old behavior + self._punch_power_scale = 1.2 + else: + self._punch_power_scale = factory.punch_power_scale + self.fly = ba.sharedobj('globals').happy_thoughts_mode + assert isinstance(activity, ba.GameActivity) + self._hockey = activity.map.is_hockey + self._punched_nodes: Set[ba.Node] = set() + self._cursed = False + self._connected_to_player: Optional[ba.Player] = None + materials = [ + factory.spaz_material, + ba.sharedobj('object_material'), + ba.sharedobj('player_material') + ] + roller_materials = [ + factory.roller_material, + ba.sharedobj('player_material') + ] + extras_material = [] + + if can_accept_powerups: + pam = powerupbox.get_factory().powerup_accept_material + materials.append(pam) + roller_materials.append(pam) + extras_material.append(pam) + + media = factory.get_media(character) + punchmats = (factory.punch_material, ba.sharedobj('attack_material')) + pickupmats = (factory.pickup_material, ba.sharedobj('pickup_material')) + self.node = ba.newnode( + type="spaz", + delegate=self, + attrs={ + 'color': color, + 'behavior_version': 0 if demo_mode else 1, + 'demo_mode': demo_mode, + 'highlight': highlight, + 'jump_sounds': media['jump_sounds'], + 'attack_sounds': media['attack_sounds'], + 'impact_sounds': media['impact_sounds'], + 'death_sounds': media['death_sounds'], + 'pickup_sounds': media['pickup_sounds'], + 'fall_sounds': media['fall_sounds'], + 'color_texture': media['color_texture'], + 'color_mask_texture': media['color_mask_texture'], + 'head_model': media['head_model'], + 'torso_model': media['torso_model'], + 'pelvis_model': media['pelvis_model'], + 'upper_arm_model': media['upper_arm_model'], + 'forearm_model': media['forearm_model'], + 'hand_model': media['hand_model'], + 'upper_leg_model': media['upper_leg_model'], + 'lower_leg_model': media['lower_leg_model'], + 'toes_model': media['toes_model'], + 'style': factory.get_style(character), + 'fly': self.fly, + 'hockey': self._hockey, + 'materials': materials, + 'roller_materials': roller_materials, + 'extras_material': extras_material, + 'punch_materials': punchmats, + 'pickup_materials': pickupmats, + 'invincible': start_invincible, + 'source_player': source_player + }) + self.shield: Optional[ba.Node] = None + + if start_invincible: + + def _safesetattr(node: Optional[ba.Node], attr: str, + val: Any) -> None: + if node: + setattr(node, attr, val) + + ba.timer(1.0, ba.Call(_safesetattr, self.node, 'invincible', + False)) + self.hitpoints = 1000 + self.hitpoints_max = 1000 + self.shield_hitpoints: Optional[int] = None + self.shield_hitpoints_max = 650 + self.shield_decay_rate = 0 + self.shield_decay_timer: Optional[ba.Timer] = None + self._boxing_gloves_wear_off_timer: Optional[ba.Timer] = None + self._boxing_gloves_wear_off_flash_timer: Optional[ba.Timer] = None + self._bomb_wear_off_timer: Optional[ba.Timer] = None + self._bomb_wear_off_flash_timer: Optional[ba.Timer] = None + self._multi_bomb_wear_off_timer: Optional[ba.Timer] = None + self.bomb_count = self.default_bomb_count + self._max_bomb_count = self.default_bomb_count + self.bomb_type_default = self.default_bomb_type + self.bomb_type = self.bomb_type_default + self.land_mine_count = 0 + self.blast_radius = 2.0 + self.powerups_expire = powerups_expire + if self._demo_mode: # preserve old behavior + self._punch_cooldown = BASE_PUNCH_COOLDOWN + else: + self._punch_cooldown = factory.punch_cooldown + self._jump_cooldown = 250 + self._pickup_cooldown = 0 + self._bomb_cooldown = 0 + self._has_boxing_gloves = False + if self.default_boxing_gloves: + self.equip_boxing_gloves() + self.last_punch_time_ms = -9999 + self.last_pickup_time_ms = -9999 + self.last_run_time_ms = -9999 + self._last_run_value = 0.0 + self.last_bomb_time_ms = -9999 + self._turbo_filter_times: Dict[str, int] = {} + self._turbo_filter_time_bucket = 0 + self._turbo_filter_counts: Dict[str, int] = {} + self.frozen = False + self.shattered = False + self._last_hit_time: Optional[int] = None + self._num_times_hit = 0 + self._bomb_held = False + if self.default_shields: + self.equip_shields() + self._dropped_bomb_callbacks: List[ + Callable[[Spaz, ba.Actor], Any]] = [] + + self._score_text: Optional[ba.Node] = None + self._score_text_hide_timer: Optional[ba.Timer] = None + self._last_stand_pos: Optional[Sequence[float]] = None + + # deprecated stuff.. need to make these into lists + self.punch_callback: Optional[Callable[[Spaz], Any]] = None + self.pick_up_powerup_callback: Optional[Callable[[Spaz], Any]] = None + + def on_expire(self) -> None: + super().on_expire() + + # release callbacks/refs so we don't wind up with dependency loops.. + self._dropped_bomb_callbacks = [] + self.punch_callback = None + self.pick_up_powerup_callback = None + + def add_dropped_bomb_callback(self, call: Callable[[Spaz, ba.Actor], Any] + ) -> None: + """ + Add a call to be run whenever this Spaz drops a bomb. + The spaz and the newly-dropped bomb are passed as arguments. + """ + self._dropped_bomb_callbacks.append(call) + + def is_alive(self) -> bool: + """ + Method override; returns whether ol' spaz is still kickin'. + """ + return not self._dead + + def _hide_score_text(self) -> None: + if self._score_text: + assert isinstance(self._score_text.scale, float) + ba.animate(self._score_text, 'scale', { + 0.0: self._score_text.scale, + 0.2: 0.0 + }) + + def _turbo_filter_add_press(self, source: str) -> None: + """ + Can pass all button presses through here; if we see an obscene number + of them in a short time let's shame/pushish this guy for using turbo + """ + t_ms = ba.time(timetype=ba.TimeType.BASE, + timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(t_ms, int) + t_bucket = int(t_ms / 1000) + if t_bucket == self._turbo_filter_time_bucket: + # add only once per timestep (filter out buttons triggering + # multiple actions) + if t_ms != self._turbo_filter_times.get(source, 0): + self._turbo_filter_counts[source] = ( + self._turbo_filter_counts.get(source, 0) + 1) + self._turbo_filter_times[source] = t_ms + # (uncomment to debug; prints what this count is at) + # ba.screenmessage( str(source) + " " + # + str(self._turbo_filter_counts[source])) + if self._turbo_filter_counts[source] == 15: + # knock 'em out. That'll learn 'em. + assert self.node + self.node.handlemessage("knockout", 500.0) + + # also issue periodic notices about who is turbo-ing + now = ba.time(ba.TimeType.REAL) + if now > ba.app.last_spaz_turbo_warn_time + 30.0: + ba.app.last_spaz_turbo_warn_time = now + ba.screenmessage(ba.Lstr( + translate=('statements', + ('Warning to ${NAME}: ' + 'turbo / button-spamming knocks' + ' you out.')), + subs=[('${NAME}', self.node.name)]), + color=(1, 0.5, 0)) + ba.playsound(ba.getsound('error')) + else: + self._turbo_filter_times = {} + self._turbo_filter_time_bucket = t_bucket + self._turbo_filter_counts = {source: 1} + + def set_score_text(self, + text: Union[str, ba.Lstr], + color: Sequence[float] = (1.0, 1.0, 0.4), + flash: bool = False) -> None: + """ + Utility func to show a message momentarily over our spaz that follows + him around; Handy for score updates and things. + """ + color_fin = ba.safecolor(color)[:3] + if not self.node: + return + if not self._score_text: + start_scale = 0.0 + mnode = ba.newnode('math', + owner=self.node, + attrs={ + 'input1': (0, 1.4, 0), + 'operation': 'add' + }) + self.node.connectattr('torso_position', mnode, 'input2') + self._score_text = ba.newnode('text', + owner=self.node, + attrs={ + 'text': text, + 'in_world': True, + 'shadow': 1.0, + 'flatness': 1.0, + 'color': color_fin, + 'scale': 0.02, + 'h_align': 'center' + }) + mnode.connectattr('output', self._score_text, 'position') + else: + self._score_text.color = color_fin + assert isinstance(self._score_text.scale, float) + start_scale = self._score_text.scale + self._score_text.text = text + if flash: + combine = ba.newnode("combine", + owner=self._score_text, + attrs={'size': 3}) + scl = 1.8 + offs = 0.5 + tval = 0.300 + for i in range(3): + cl1 = offs + scl * color_fin[i] + cl2 = color_fin[i] + ba.animate(combine, 'input' + str(i), { + 0.5 * tval: cl2, + 0.75 * tval: cl1, + 1.0 * tval: cl2 + }) + combine.connectattr('output', self._score_text, 'color') + + ba.animate(self._score_text, 'scale', {0.0: start_scale, 0.2: 0.02}) + self._score_text_hide_timer = ba.Timer( + 1.0, ba.WeakCall(self._hide_score_text)) + + def on_jump_press(self) -> None: + """ + Called to 'press jump' on this spaz; + used by player or AI connections. + """ + if not self.node: + return + self.node.jump_pressed = True + self._turbo_filter_add_press('jump') + + def on_jump_release(self) -> None: + """ + Called to 'release jump' on this spaz; + used by player or AI connections. + """ + if not self.node: + return + self.node.jump_pressed = False + + def on_pickup_press(self) -> None: + """ + Called to 'press pick-up' on this spaz; + used by player or AI connections. + """ + if not self.node: + return + t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(t_ms, int) + if t_ms - self.last_pickup_time_ms >= self._pickup_cooldown: + self.node.pickup_pressed = True + self.last_pickup_time_ms = t_ms + self._turbo_filter_add_press('pickup') + + def on_pickup_release(self) -> None: + """ + Called to 'release pick-up' on this spaz; + used by player or AI connections. + """ + if not self.node: + return + self.node.pickup_pressed = False + + def _on_hold_position_press(self) -> None: + """ + Called to 'press hold-position' on this spaz; + used for player or AI connections. + """ + if not self.node: + return + self.node.hold_position_pressed = True + self._turbo_filter_add_press('holdposition') + + def _on_hold_position_release(self) -> None: + """ + Called to 'release hold-position' on this spaz; + used for player or AI connections. + """ + if not self.node: + return + self.node.hold_position_pressed = False + + def on_punch_press(self) -> None: + """ + Called to 'press punch' on this spaz; + used for player or AI connections. + """ + assert self.node + if not self.node or self.frozen or self.node.knockout > 0.0: + return + t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(t_ms, int) + if t_ms - self.last_punch_time_ms >= self._punch_cooldown: + if self.punch_callback is not None: + self.punch_callback(self) + self._punched_nodes = set() # reset this.. + self.last_punch_time_ms = t_ms + self.node.punch_pressed = True + if not self.node.hold_node: + ba.timer( + 0.1, + ba.WeakCall(self._safe_play_sound, + get_factory().swish_sound, 0.8)) + self._turbo_filter_add_press('punch') + + def _safe_play_sound(self, sound: ba.Sound, volume: float) -> None: + """Plays a sound at our position if we exist.""" + if self.node: + ba.playsound(sound, volume, self.node.position) + + def on_punch_release(self) -> None: + """ + Called to 'release punch' on this spaz; + used for player or AI connections. + """ + if not self.node: + return + self.node.punch_pressed = False + + def on_bomb_press(self) -> None: + """ + Called to 'press bomb' on this spaz; + used for player or AI connections. + """ + if not self.node: + return + + if self._dead or self.frozen: + return + if self.node.knockout > 0.0: + return + t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(t_ms, int) + if t_ms - self.last_bomb_time_ms >= self._bomb_cooldown: + self.last_bomb_time_ms = t_ms + self.node.bomb_pressed = True + if not self.node.hold_node: + self.drop_bomb() + self._turbo_filter_add_press('bomb') + + def on_bomb_release(self) -> None: + """ + Called to 'release bomb' on this spaz; + used for player or AI connections. + """ + if not self.node: + return + self.node.bomb_pressed = False + + def on_run(self, value: float) -> None: + """ + Called to 'press run' on this spaz; + used for player or AI connections. + """ + if not self.node: + return + + t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(t_ms, int) + self.last_run_time_ms = t_ms + self.node.run = value + + # filtering these events would be tough since its an analog + # value, but lets still pass full 0-to-1 presses along to + # the turbo filter to punish players if it looks like they're turbo-ing + if self._last_run_value < 0.01 and value > 0.99: + self._turbo_filter_add_press('run') + + self._last_run_value = value + + def on_fly_press(self) -> None: + """ + Called to 'press fly' on this spaz; + used for player or AI connections. + """ + if not self.node: + return + # not adding a cooldown time here for now; slightly worried + # input events get clustered up during net-games and we'd wind up + # killing a lot and making it hard to fly.. should look into this. + self.node.fly_pressed = True + self._turbo_filter_add_press('fly') + + def on_fly_release(self) -> None: + """ + Called to 'release fly' on this spaz; + used for player or AI connections. + """ + if not self.node: + return + self.node.fly_pressed = False + + def on_move(self, x: float, y: float) -> None: + """ + Called to set the joystick amount for this spaz; + used for player or AI connections. + """ + if not self.node: + return + self.node.handlemessage("move", x, y) + + def on_move_up_down(self, value: float) -> None: + """ + Called to set the up/down joystick amount on this spaz; + used for player or AI connections. + value will be between -32768 to 32767 + WARNING: deprecated; use on_move instead. + """ + if not self.node: + return + self.node.move_up_down = value + + def on_move_left_right(self, value: float) -> None: + """ + Called to set the left/right joystick amount on this spaz; + used for player or AI connections. + value will be between -32768 to 32767 + WARNING: deprecated; use on_move instead. + """ + if not self.node: + return + self.node.move_left_right = value + + def on_punched(self, damage: int) -> None: + """Called when this spaz gets punched.""" + + def get_death_points(self, how: str) -> Tuple[int, int]: + """Get the points awarded for killing this spaz.""" + del how # unused arg + num_hits = float(max(1, self._num_times_hit)) + # base points is simply 10 for 1-hit-kills and 5 otherwise + importance = 2 if num_hits < 2 else 1 + return (10 if num_hits < 2 else 5) * self.points_mult, importance + + def curse(self) -> None: + """ + Give this poor spaz a curse; + he will explode in 5 seconds. + """ + if not self._cursed: + factory = get_factory() + self._cursed = True + # add the curse material.. + for attr in ['materials', 'roller_materials']: + materials = getattr(self.node, attr) + if factory.curse_material not in materials: + setattr(self.node, attr, + materials + (factory.curse_material, )) + + # None specifies no time limit + assert self.node + if self.curse_time is None: + self.node.curse_death_time = -1 + else: + # note: curse-death-time takes milliseconds + tval = ba.time() + assert isinstance(tval, int) + self.node.curse_death_time = int(1000.0 * + (tval + self.curse_time)) + ba.timer(5.0, ba.WeakCall(self.curse_explode)) + + def equip_boxing_gloves(self) -> None: + """ + Give this spaz some boxing gloves. + """ + assert self.node + self.node.boxing_gloves = True + if self._demo_mode: # preserve old behavior + self._punch_power_scale = 1.7 + self._punch_cooldown = 300 + else: + factory = get_factory() + self._punch_power_scale = factory.punch_power_scale_gloves + self._punch_cooldown = factory.punch_cooldown_gloves + + def equip_shields(self, decay: bool = False) -> None: + """ + Give this spaz a nice energy shield. + """ + + if not self.node: + ba.print_error('Can\'t equip shields; no node.') + return + + factory = get_factory() + if self.shield is None: + self.shield = ba.newnode('shield', + owner=self.node, + attrs={ + 'color': (0.3, 0.2, 2.0), + 'radius': 1.3 + }) + self.node.connectattr('position_center', self.shield, 'position') + self.shield_hitpoints = self.shield_hitpoints_max = 650 + self.shield_decay_rate = factory.shield_decay_rate if decay else 0 + self.shield.hurt = 0 + ba.playsound(factory.shield_up_sound, 1.0, position=self.node.position) + + if self.shield_decay_rate > 0: + self.shield_decay_timer = ba.Timer(0.5, + ba.WeakCall(self.shield_decay), + repeat=True) + # so user can see the decay + self.shield.always_show_health_bar = True + + def shield_decay(self) -> None: + """Called repeatedly to decay shield HP over time.""" + if self.shield: + assert self.shield_hitpoints is not None + self.shield_hitpoints = (max( + 0, self.shield_hitpoints - self.shield_decay_rate)) + assert self.shield_hitpoints is not None + self.shield.hurt = ( + 1.0 - float(self.shield_hitpoints) / self.shield_hitpoints_max) + if self.shield_hitpoints <= 0: + self.shield.delete() + self.shield = None + self.shield_decay_timer = None + assert self.node + ba.playsound(get_factory().shield_down_sound, + 1.0, + position=self.node.position) + else: + self.shield_decay_timer = None + + def handlemessage(self, msg: Any) -> Any: + # pylint: disable=too-many-return-statements + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + if __debug__ is True: + self._handlemessage_sanity_check() + assert self.node + + if isinstance(msg, ba.PickedUpMessage): + self.node.handlemessage("hurt_sound") + self.node.handlemessage("picked_up") + # this counts as a hit + self._num_times_hit += 1 + + elif isinstance(msg, ba.ShouldShatterMessage): + # eww; seems we have to do this in a timer or it wont work right + # (since we're getting called from within update() perhaps?..) + # NOTE: should test to see if that's still the case + ba.timer(0.001, ba.WeakCall(self.shatter)) + + elif isinstance(msg, ba.ImpactDamageMessage): + # eww; seems we have to do this in a timer or it wont work right + # (since we're getting called from within update() perhaps?..) + ba.timer(0.001, ba.WeakCall(self._hit_self, msg.intensity)) + + elif isinstance(msg, ba.PowerupMessage): + if self._dead: + return True + if self.pick_up_powerup_callback is not None: + self.pick_up_powerup_callback(self) + if msg.poweruptype == 'triple_bombs': + tex = powerupbox.get_factory().tex_bomb + self._flash_billboard(tex) + self.set_bomb_count(3) + if self.powerups_expire: + self.node.mini_billboard_1_texture = tex + t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(t_ms, int) + self.node.mini_billboard_1_start_time = t_ms + self.node.mini_billboard_1_end_time = ( + t_ms + POWERUP_WEAR_OFF_TIME) + self._multi_bomb_wear_off_timer = (ba.Timer( + (POWERUP_WEAR_OFF_TIME - 2000), + ba.WeakCall(self._multi_bomb_wear_off_flash), + timeformat=ba.TimeFormat.MILLISECONDS)) + self._multi_bomb_wear_off_timer = (ba.Timer( + POWERUP_WEAR_OFF_TIME, + ba.WeakCall(self._multi_bomb_wear_off), + timeformat=ba.TimeFormat.MILLISECONDS)) + elif msg.poweruptype == 'land_mines': + self.set_land_mine_count(min(self.land_mine_count + 3, 3)) + elif msg.poweruptype == 'impact_bombs': + self.bomb_type = 'impact' + tex = self._get_bomb_type_tex() + self._flash_billboard(tex) + if self.powerups_expire: + self.node.mini_billboard_2_texture = tex + t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(t_ms, int) + self.node.mini_billboard_2_start_time = t_ms + self.node.mini_billboard_2_end_time = ( + t_ms + POWERUP_WEAR_OFF_TIME) + self._bomb_wear_off_flash_timer = (ba.Timer( + POWERUP_WEAR_OFF_TIME - 2000, + ba.WeakCall(self._bomb_wear_off_flash), + timeformat=ba.TimeFormat.MILLISECONDS)) + self._bomb_wear_off_timer = (ba.Timer( + POWERUP_WEAR_OFF_TIME, + ba.WeakCall(self._bomb_wear_off), + timeformat=ba.TimeFormat.MILLISECONDS)) + elif msg.poweruptype == 'sticky_bombs': + self.bomb_type = 'sticky' + tex = self._get_bomb_type_tex() + self._flash_billboard(tex) + if self.powerups_expire: + self.node.mini_billboard_2_texture = tex + t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(t_ms, int) + self.node.mini_billboard_2_start_time = t_ms + self.node.mini_billboard_2_end_time = ( + t_ms + POWERUP_WEAR_OFF_TIME) + self._bomb_wear_off_flash_timer = (ba.Timer( + POWERUP_WEAR_OFF_TIME - 2000, + ba.WeakCall(self._bomb_wear_off_flash), + timeformat=ba.TimeFormat.MILLISECONDS)) + self._bomb_wear_off_timer = (ba.Timer( + POWERUP_WEAR_OFF_TIME, + ba.WeakCall(self._bomb_wear_off), + timeformat=ba.TimeFormat.MILLISECONDS)) + elif msg.poweruptype == 'punch': + self._has_boxing_gloves = True + tex = powerupbox.get_factory().tex_punch + self._flash_billboard(tex) + self.equip_boxing_gloves() + if self.powerups_expire: + self.node.boxing_gloves_flashing = False + self.node.mini_billboard_3_texture = tex + t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(t_ms, int) + self.node.mini_billboard_3_start_time = t_ms + self.node.mini_billboard_3_end_time = ( + t_ms + POWERUP_WEAR_OFF_TIME) + self._boxing_gloves_wear_off_flash_timer = (ba.Timer( + POWERUP_WEAR_OFF_TIME - 2000, + ba.WeakCall(self._gloves_wear_off_flash), + timeformat=ba.TimeFormat.MILLISECONDS)) + self._boxing_gloves_wear_off_timer = (ba.Timer( + POWERUP_WEAR_OFF_TIME, + ba.WeakCall(self._gloves_wear_off), + timeformat=ba.TimeFormat.MILLISECONDS)) + elif msg.poweruptype == 'shield': + factory = get_factory() + # let's allow powerup-equipped shields to lose hp over time + self.equip_shields(decay=factory.shield_decay_rate > 0) + elif msg.poweruptype == 'curse': + self.curse() + elif msg.poweruptype == 'ice_bombs': + self.bomb_type = 'ice' + tex = self._get_bomb_type_tex() + self._flash_billboard(tex) + if self.powerups_expire: + self.node.mini_billboard_2_texture = tex + t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(t_ms, int) + self.node.mini_billboard_2_start_time = t_ms + self.node.mini_billboard_2_end_time = ( + t_ms + POWERUP_WEAR_OFF_TIME) + self._bomb_wear_off_flash_timer = (ba.Timer( + POWERUP_WEAR_OFF_TIME - 2000, + ba.WeakCall(self._bomb_wear_off_flash), + timeformat=ba.TimeFormat.MILLISECONDS)) + self._bomb_wear_off_timer = (ba.Timer( + POWERUP_WEAR_OFF_TIME, + ba.WeakCall(self._bomb_wear_off), + timeformat=ba.TimeFormat.MILLISECONDS)) + elif msg.poweruptype == 'health': + if self._cursed: + self._cursed = False + # remove cursed material + factory = get_factory() + for attr in ['materials', 'roller_materials']: + materials = getattr(self.node, attr) + if factory.curse_material in materials: + setattr( + self.node, attr, + tuple(m for m in materials + if m != factory.curse_material)) + self.node.curse_death_time = 0 + self.hitpoints = self.hitpoints_max + self._flash_billboard(powerupbox.get_factory().tex_health) + self.node.hurt = 0 + self._last_hit_time = None + self._num_times_hit = 0 + + self.node.handlemessage("flash") + if msg.source_node: + msg.source_node.handlemessage(ba.PowerupAcceptMessage()) + return True + + elif isinstance(msg, ba.FreezeMessage): + if not self.node: + return None + if self.node.invincible: + ba.playsound(get_factory().block_sound, + 1.0, + position=self.node.position) + return None + if self.shield: + return None + if not self.frozen: + self.frozen = True + self.node.frozen = True + ba.timer(5.0, ba.WeakCall(self.handlemessage, + ba.ThawMessage())) + # instantly shatter if we're already dead + # (otherwise its hard to tell we're dead) + if self.hitpoints <= 0: + self.shatter() + + elif isinstance(msg, ba.ThawMessage): + if self.frozen and not self.shattered and self.node: + self.frozen = False + self.node.frozen = False + + elif isinstance(msg, ba.HitMessage): + if not self.node: + return None + if self.node.invincible: + ba.playsound(get_factory().block_sound, + 1.0, + position=self.node.position) + return True + + # if we were recently hit, don't count this as another + # (so punch flurries and bomb pileups essentially count as 1 hit) + local_time = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(local_time, int) + if (self._last_hit_time is None + or local_time - self._last_hit_time > 1000): + self._num_times_hit += 1 + self._last_hit_time = local_time + + mag = msg.magnitude * self.impact_scale + velocity_mag = msg.velocity_magnitude * self.impact_scale + damage_scale = 0.22 + + # if they've got a shield, deliver it to that instead.. + if self.shield: + if msg.flat_damage: + damage = msg.flat_damage * self.impact_scale + else: + # hit our spaz with an impulse but tell it to only return + # theoretical damage; not apply the impulse.. + assert msg.force_direction is not None + self.node.handlemessage( + "impulse", msg.pos[0], msg.pos[1], msg.pos[2], + msg.velocity[0], msg.velocity[1], msg.velocity[2], mag, + velocity_mag, msg.radius, 1, msg.force_direction[0], + msg.force_direction[1], msg.force_direction[2]) + damage = damage_scale * self.node.damage + + assert self.shield_hitpoints is not None + self.shield_hitpoints -= int(damage) + self.shield.hurt = ( + 1.0 - + float(self.shield_hitpoints) / self.shield_hitpoints_max) + + # Its a cleaner event if a hit just kills the shield + # without damaging the player. + # However, massive damage events should still be able to + # damage the player. This hopefully gives us a happy medium. + max_spillover = get_factory().max_shield_spillover_damage + if self.shield_hitpoints <= 0: + + # FIXME: Transition out perhaps? + self.shield.delete() + self.shield = None + ba.playsound(get_factory().shield_down_sound, + 1.0, + position=self.node.position) + + # Emit some cool looking sparks when the shield dies. + npos = self.node.position + ba.emitfx(position=(npos[0], npos[1] + 0.9, npos[2]), + velocity=self.node.velocity, + count=random.randrange(20, 30), + scale=1.0, + spread=0.6, + chunk_type='spark') + + else: + ba.playsound(get_factory().shield_hit_sound, + 0.5, + position=self.node.position) + + # Emit some cool looking sparks on shield hit. + assert msg.force_direction is not None + ba.emitfx(position=msg.pos, + velocity=(msg.force_direction[0] * 1.0, + msg.force_direction[1] * 1.0, + msg.force_direction[2] * 1.0), + count=min(30, 5 + int(damage * 0.005)), + scale=0.5, + spread=0.3, + chunk_type='spark') + + # If they passed our spillover threshold, + # pass damage along to spaz. + if self.shield_hitpoints <= -max_spillover: + leftover_damage = -max_spillover - self.shield_hitpoints + shield_leftover_ratio = leftover_damage / damage + + # Scale down the magnitudes applied to spaz accordingly. + mag *= shield_leftover_ratio + velocity_mag *= shield_leftover_ratio + else: + return True # Good job shield! + else: + shield_leftover_ratio = 1.0 + + if msg.flat_damage: + damage = int(msg.flat_damage * self.impact_scale * + shield_leftover_ratio) + else: + # Hit it with an impulse and get the resulting damage. + assert msg.force_direction is not None + self.node.handlemessage( + "impulse", msg.pos[0], msg.pos[1], msg.pos[2], + msg.velocity[0], msg.velocity[1], msg.velocity[2], mag, + velocity_mag, msg.radius, 0, msg.force_direction[0], + msg.force_direction[1], msg.force_direction[2]) + + damage = int(damage_scale * self.node.damage) + self.node.handlemessage("hurt_sound") + + # Play punch impact sound based on damage if it was a punch. + if msg.hit_type == 'punch': + self.on_punched(damage) + + # If damage was significant, lets show it. + if damage > 350: + assert msg.force_direction is not None + ba.show_damage_count('-' + str(int(damage / 10)) + "%", + msg.pos, msg.force_direction) + + # Let's always add in a super-punch sound with boxing + # gloves just to differentiate them. + if msg.hit_subtype == 'super_punch': + ba.playsound(get_factory().punch_sound_stronger, + 1.0, + position=self.node.position) + if damage > 500: + sounds = get_factory().punch_sound_strong + sound = sounds[random.randrange(len(sounds))] + else: + sound = get_factory().punch_sound + ba.playsound(sound, 1.0, position=self.node.position) + + # Throw up some chunks. + assert msg.force_direction is not None + ba.emitfx(position=msg.pos, + velocity=(msg.force_direction[0] * 0.5, + msg.force_direction[1] * 0.5, + msg.force_direction[2] * 0.5), + count=min(10, 1 + int(damage * 0.0025)), + scale=0.3, + spread=0.03) + + ba.emitfx(position=msg.pos, + chunk_type='sweat', + velocity=(msg.force_direction[0] * 1.3, + msg.force_direction[1] * 1.3 + 5.0, + msg.force_direction[2] * 1.3), + count=min(30, 1 + int(damage * 0.04)), + scale=0.9, + spread=0.28) + + # Momentary flash. + hurtiness = damage * 0.003 + punchpos = (msg.pos[0] + msg.force_direction[0] * 0.02, + msg.pos[1] + msg.force_direction[1] * 0.02, + msg.pos[2] + msg.force_direction[2] * 0.02) + flash_color = (1.0, 0.8, 0.4) + light = ba.newnode( + "light", + attrs={ + 'position': punchpos, + 'radius': 0.12 + hurtiness * 0.12, + 'intensity': 0.3 * (1.0 + 1.0 * hurtiness), + 'height_attenuated': False, + 'color': flash_color + }) + ba.timer(0.06, light.delete) + + flash = ba.newnode("flash", + attrs={ + 'position': punchpos, + 'size': 0.17 + 0.17 * hurtiness, + 'color': flash_color + }) + ba.timer(0.06, flash.delete) + + if msg.hit_type == 'impact': + assert msg.force_direction is not None + ba.emitfx(position=msg.pos, + velocity=(msg.force_direction[0] * 2.0, + msg.force_direction[1] * 2.0, + msg.force_direction[2] * 2.0), + count=min(10, 1 + int(damage * 0.01)), + scale=0.4, + spread=0.1) + if self.hitpoints > 0: + + # It's kinda crappy to die from impacts, so lets reduce + # impact damage by a reasonable amount if it'll keep us alive + if msg.hit_type == 'impact' and damage > self.hitpoints: + # drop damage to whatever puts us at 10 hit points, + # or 200 less than it used to be whichever is greater + # (so it *can* still kill us if its high enough) + newdamage = max(damage - 200, self.hitpoints - 10) + damage = newdamage + self.node.handlemessage("flash") + # if we're holding something, drop it + if damage > 0.0 and self.node.hold_node: + # self.node.hold_node = ba.Node(None) + self.node.hold_node = None + self.hitpoints -= damage + self.node.hurt = 1.0 - float( + self.hitpoints) / self.hitpoints_max + # if we're cursed, *any* damage blows us up + if self._cursed and damage > 0: + ba.timer( + 0.05, ba.WeakCall(self.curse_explode, + msg.source_player)) + # if we're frozen, shatter.. otherwise die if we hit zero + if self.frozen and (damage > 200 or self.hitpoints <= 0): + self.shatter() + elif self.hitpoints <= 0: + self.node.handlemessage(ba.DieMessage(how='impact')) + + # if we're dead, take a look at the smoothed damage val + # (which gives us a smoothed average of recent damage) and shatter + # us if its grown high enough + if self.hitpoints <= 0: + damage_avg = self.node.damage_smoothed * damage_scale + if damage_avg > 1000: + self.shatter() + + elif isinstance(msg, BombDiedMessage): + self.bomb_count += 1 + + elif isinstance(msg, ba.DieMessage): + wasdead = self._dead + self._dead = True + self.hitpoints = 0 + if msg.immediate: + self.node.delete() + elif self.node: + self.node.hurt = 1.0 + if self.play_big_death_sound and not wasdead: + ba.playsound(get_factory().single_player_death_sound) + self.node.dead = True + ba.timer(2.0, self.node.delete) + + elif isinstance(msg, ba.OutOfBoundsMessage): + # by default we just die here + self.handlemessage(ba.DieMessage(how='fall')) + elif isinstance(msg, ba.StandMessage): + self._last_stand_pos = (msg.position[0], msg.position[1], + msg.position[2]) + self.node.handlemessage("stand", msg.position[0], msg.position[1], + msg.position[2], msg.angle) + elif isinstance(msg, CurseExplodeMessage): + self.curse_explode() + elif isinstance(msg, PunchHitMessage): + node = ba.get_collision_info("opposing_node") + + # only allow one hit per node per punch + if node and (node not in self._punched_nodes): + + punch_momentum_angular = (self.node.punch_momentum_angular * + self._punch_power_scale) + punch_power = self.node.punch_power * self._punch_power_scale + + # ok here's the deal: we pass along our base velocity for use + # in the impulse damage calculations since that is a more + # predictable value than our fist velocity, which is rather + # erratic. ...however we want to actually apply force in the + # direction our fist is moving so it looks better.. so we still + # pass that along as a direction ..perhaps a time-averaged + # fist-velocity would work too?.. should try that. + + # if its something besides another spaz, just do a muffled + # punch sound + if node.getnodetype() != 'spaz': + sounds = get_factory().impact_sounds_medium + sound = sounds[random.randrange(len(sounds))] + ba.playsound(sound, 1.0, position=self.node.position) + + ppos = self.node.punch_position + punchdir = self.node.punch_velocity + vel = self.node.punch_momentum_linear + + self._punched_nodes.add(node) + node.handlemessage( + ba.HitMessage( + pos=ppos, + velocity=vel, + magnitude=punch_power * punch_momentum_angular * 110.0, + velocity_magnitude=punch_power * 40, + radius=0, + srcnode=self.node, + source_player=self.source_player, + force_direction=punchdir, + hit_type='punch', + hit_subtype=('super_punch' if self._has_boxing_gloves + else 'default'))) + + # Also apply opposite to ourself for the first punch only. + # This is given as a constant force so that it is more + # noticeable for slower punches where it matters. For fast + # awesome looking punches its ok if we punch 'through' + # the target. + mag = -400.0 + if self._hockey: + mag *= 0.5 + if len(self._punched_nodes) == 1: + self.node.handlemessage("kick_back", ppos[0], ppos[1], + ppos[2], punchdir[0], punchdir[1], + punchdir[2], mag) + elif isinstance(msg, PickupMessage): + opposing_node, opposing_body = ba.get_collision_info( + 'opposing_node', 'opposing_body') + + if opposing_node is None or not opposing_node: + return True + + # Don't allow picking up of invincible dudes. + try: + if opposing_node.invincible: + return True + except Exception: + pass + + # If we're grabbing the pelvis of a non-shattered spaz, we wanna + # grab the torso instead. + if (opposing_node.getnodetype() == 'spaz' + and not opposing_node.shattered and opposing_body == 4): + opposing_body = 1 + + # Special case - if we're holding a flag, don't replace it + # (hmm - should make this customizable or more low level). + held = self.node.hold_node + if held and held.getnodetype() == 'flag': + return True + + # hold_body needs to be set before hold_node. + self.node.hold_body = opposing_body + self.node.hold_node = opposing_node + else: + return super().handlemessage(msg) + return None + + def drop_bomb(self) -> Optional[stdbomb.Bomb]: + """ + Tell the spaz to drop one of his bombs, and returns + the resulting bomb object. + If the spaz has no bombs or is otherwise unable to + drop a bomb, returns None. + """ + + if (self.land_mine_count <= 0 and self.bomb_count <= 0) or self.frozen: + return None + assert self.node + pos = self.node.position_forward + vel = self.node.velocity + + if self.land_mine_count > 0: + dropping_bomb = False + self.set_land_mine_count(self.land_mine_count - 1) + bomb_type = 'land_mine' + else: + dropping_bomb = True + bomb_type = self.bomb_type + + bomb = stdbomb.Bomb(position=(pos[0], pos[1] - 0.0, pos[2]), + velocity=(vel[0], vel[1], vel[2]), + bomb_type=bomb_type, + blast_radius=self.blast_radius, + source_player=self.source_player, + owner=self.node).autoretain() + + assert bomb.node + if dropping_bomb: + self.bomb_count -= 1 + bomb.node.add_death_action( + ba.WeakCall(self.handlemessage, BombDiedMessage())) + self._pick_up(bomb.node) + + for clb in self._dropped_bomb_callbacks: + clb(self, bomb) + + return bomb + + def _pick_up(self, node: ba.Node) -> None: + if self.node: + self.node.hold_body = 0 # needs to be set before hold_node + self.node.hold_node = node + + def set_land_mine_count(self, count: int) -> None: + """Set the number of land-mines this spaz is carrying.""" + self.land_mine_count = count + if self.node: + if self.land_mine_count != 0: + self.node.counter_text = 'x' + str(self.land_mine_count) + self.node.counter_texture = ( + powerupbox.get_factory().tex_land_mines) + else: + self.node.counter_text = '' + + def curse_explode(self, source_player: ba.Player = None) -> None: + """Explode the poor spaz spectacularly.""" + if self._cursed and self.node: + self.shatter(extreme=True) + self.handlemessage(ba.DieMessage()) + activity = self._activity() + if activity: + stdbomb.Blast( + position=self.node.position, + velocity=self.node.velocity, + blast_radius=3.0, + blast_type='normal', + source_player=(source_player if source_player else + self.source_player)).autoretain() + self._cursed = False + + def shatter(self, extreme: bool = False) -> None: + """Break the poor spaz into little bits.""" + if self.shattered: + return + self.shattered = True + assert self.node + if self.frozen: + # momentary flash of light + light = ba.newnode('light', + attrs={ + 'position': self.node.position, + 'radius': 0.5, + 'height_attenuated': False, + 'color': (0.8, 0.8, 1.0) + }) + + ba.animate(light, 'intensity', { + 0.0: 3.0, + 0.04: 0.5, + 0.08: 0.07, + 0.3: 0 + }) + ba.timer(0.3, light.delete) + # emit ice chunks.. + ba.emitfx(position=self.node.position, + velocity=self.node.velocity, + count=int(random.random() * 10.0 + 10.0), + scale=0.6, + spread=0.2, + chunk_type='ice') + ba.emitfx(position=self.node.position, + velocity=self.node.velocity, + count=int(random.random() * 10.0 + 10.0), + scale=0.3, + spread=0.2, + chunk_type='ice') + ba.playsound(get_factory().shatter_sound, + 1.0, + position=self.node.position) + else: + ba.playsound(get_factory().splatter_sound, + 1.0, + position=self.node.position) + self.handlemessage(ba.DieMessage()) + self.node.shattered = 2 if extreme else 1 + + def _hit_self(self, intensity: float) -> None: + if not self.node: + return + pos = self.node.position + self.handlemessage( + ba.HitMessage(flat_damage=50.0 * intensity, + pos=pos, + force_direction=self.node.velocity, + hit_type='impact')) + self.node.handlemessage("knockout", max(0.0, 50.0 * intensity)) + sounds: Sequence[ba.Sound] + if intensity > 5.0: + sounds = get_factory().impact_sounds_harder + elif intensity > 3.0: + sounds = get_factory().impact_sounds_hard + else: + sounds = get_factory().impact_sounds_medium + sound = sounds[random.randrange(len(sounds))] + ba.playsound(sound, position=pos, volume=5.0) + + def _get_bomb_type_tex(self) -> ba.Texture: + bomb_factory = powerupbox.get_factory() + if self.bomb_type == 'sticky': + return bomb_factory.tex_sticky_bombs + if self.bomb_type == 'ice': + return bomb_factory.tex_ice_bombs + if self.bomb_type == 'impact': + return bomb_factory.tex_impact_bombs + raise Exception() + + def _flash_billboard(self, tex: ba.Texture) -> None: + assert self.node + self.node.billboard_texture = tex + self.node.billboard_cross_out = False + ba.animate(self.node, "billboard_opacity", { + 0.0: 0.0, + 0.1: 1.0, + 0.4: 1.0, + 0.5: 0.0 + }) + + def set_bomb_count(self, count: int) -> None: + """Sets the number of bombs this Spaz has.""" + # we cant just set bomb_count cuz some bombs may be laid currently + # so we have to do a relative diff based on max + diff = count - self._max_bomb_count + self._max_bomb_count += diff + self.bomb_count += diff + + def _gloves_wear_off_flash(self) -> None: + if self.node: + self.node.boxing_gloves_flashing = True + self.node.billboard_texture = powerupbox.get_factory().tex_punch + self.node.billboard_opacity = 1.0 + self.node.billboard_cross_out = True + + def _gloves_wear_off(self) -> None: + if self._demo_mode: # preserve old behavior + self._punch_power_scale = 1.2 + self._punch_cooldown = BASE_PUNCH_COOLDOWN + else: + factory = get_factory() + self._punch_power_scale = factory.punch_power_scale + self._punch_cooldown = factory.punch_cooldown + self._has_boxing_gloves = False + if self.node: + ba.playsound(powerupbox.get_factory().powerdown_sound, + position=self.node.position) + self.node.boxing_gloves = False + self.node.billboard_opacity = 0.0 + + def _multi_bomb_wear_off_flash(self) -> None: + if self.node: + self.node.billboard_texture = powerupbox.get_factory().tex_bomb + self.node.billboard_opacity = 1.0 + self.node.billboard_cross_out = True + + def _multi_bomb_wear_off(self) -> None: + self.set_bomb_count(self.default_bomb_count) + if self.node: + ba.playsound(powerupbox.get_factory().powerdown_sound, + position=self.node.position) + self.node.billboard_opacity = 0.0 + + def _bomb_wear_off_flash(self) -> None: + if self.node: + self.node.billboard_texture = self._get_bomb_type_tex() + self.node.billboard_opacity = 1.0 + self.node.billboard_cross_out = True + + def _bomb_wear_off(self) -> None: + self.bomb_type = self.bomb_type_default + if self.node: + ba.playsound(powerupbox.get_factory().powerdown_sound, + position=self.node.position) + self.node.billboard_opacity = 0.0 diff --git a/assets/src/data/scripts/bastd/actor/spazappearance.py b/assets/src/data/scripts/bastd/actor/spazappearance.py new file mode 100644 index 00000000..e4b529cd --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/spazappearance.py @@ -0,0 +1,947 @@ +"""Appearance functionality for spazzes.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import List, Optional, Tuple + + +def get_appearances(include_locked: bool = False) -> List[str]: + """Get the list of available spaz appearances.""" + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + disallowed = [] + if not include_locked: + # hmm yeah this'll be tough to hack... + if not _ba.get_purchased('characters.santa'): + disallowed.append('Santa Claus') + if not _ba.get_purchased('characters.frosty'): + disallowed.append('Frosty') + if not _ba.get_purchased('characters.bones'): + disallowed.append('Bones') + if not _ba.get_purchased('characters.bernard'): + disallowed.append('Bernard') + if not _ba.get_purchased('characters.pixie'): + disallowed.append('Pixel') + if not _ba.get_purchased('characters.pascal'): + disallowed.append('Pascal') + if not _ba.get_purchased('characters.actionhero'): + disallowed.append('Todd McBurton') + if not _ba.get_purchased('characters.taobaomascot'): + disallowed.append('Taobao Mascot') + if not _ba.get_purchased('characters.agent'): + disallowed.append('Agent Johnson') + if not _ba.get_purchased('characters.jumpsuit'): + disallowed.append('Lee') + if not _ba.get_purchased('characters.assassin'): + disallowed.append('Zola') + if not _ba.get_purchased('characters.wizard'): + disallowed.append('Grumbledorf') + if not _ba.get_purchased('characters.cowboy'): + disallowed.append('Butch') + if not _ba.get_purchased('characters.witch'): + disallowed.append('Witch') + if not _ba.get_purchased('characters.warrior'): + disallowed.append('Warrior') + if not _ba.get_purchased('characters.superhero'): + disallowed.append('Middle-Man') + if not _ba.get_purchased('characters.alien'): + disallowed.append('Alien') + if not _ba.get_purchased('characters.oldlady'): + disallowed.append('OldLady') + if not _ba.get_purchased('characters.gladiator'): + disallowed.append('Gladiator') + if not _ba.get_purchased('characters.wrestler'): + disallowed.append('Wrestler') + if not _ba.get_purchased('characters.operasinger'): + disallowed.append('Gretel') + if not _ba.get_purchased('characters.robot'): + disallowed.append('Robot') + if not _ba.get_purchased('characters.cyborg'): + disallowed.append('B-9000') + if not _ba.get_purchased('characters.bunny'): + disallowed.append('Easter Bunny') + if not _ba.get_purchased('characters.kronk'): + disallowed.append('Kronk') + if not _ba.get_purchased('characters.zoe'): + disallowed.append('Zoe') + if not _ba.get_purchased('characters.jackmorgan'): + disallowed.append('Jack Morgan') + if not _ba.get_purchased('characters.mel'): + disallowed.append('Mel') + if not _ba.get_purchased('characters.snakeshadow'): + disallowed.append('Snake Shadow') + return [ + s for s in list(ba.app.spaz_appearances.keys()) if s not in disallowed + ] + + +class Appearance: + """Create and fill out one of these suckers to define a spaz appearance""" + + def __init__(self, name: str): + self.name = name + if self.name in ba.app.spaz_appearances: + raise Exception("spaz appearance name \"" + self.name + + "\" already exists.") + ba.app.spaz_appearances[self.name] = self + self.color_texture = "" + self.color_mask_texture = "" + self.icon_texture = "" + self.icon_mask_texture = "" + self.head_model = "" + self.torso_model = "" + self.pelvis_model = "" + self.upper_arm_model = "" + self.forearm_model = "" + self.hand_model = "" + self.upper_leg_model = "" + self.lower_leg_model = "" + self.toes_model = "" + self.jump_sounds: List[str] = [] + self.attack_sounds: List[str] = [] + self.impact_sounds: List[str] = [] + self.death_sounds: List[str] = [] + self.pickup_sounds: List[str] = [] + self.fall_sounds: List[str] = [] + self.style = 'spaz' + self.default_color: Optional[Tuple[float, float, float]] = None + self.default_highlight: Optional[Tuple[float, float, float]] = None + + +def register_appearances() -> None: + """Register our builtin spaz appearances.""" + + # this is quite ugly but will be going away so not worth cleaning up + # pylint: disable=invalid-name + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + + # Spaz ####################################### + t = Appearance("Spaz") + t.color_texture = "neoSpazColor" + t.color_mask_texture = "neoSpazColorMask" + t.icon_texture = "neoSpazIcon" + t.icon_mask_texture = "neoSpazIconColorMask" + t.head_model = "neoSpazHead" + t.torso_model = "neoSpazTorso" + t.pelvis_model = "neoSpazPelvis" + t.upper_arm_model = "neoSpazUpperArm" + t.forearm_model = "neoSpazForeArm" + t.hand_model = "neoSpazHand" + t.upper_leg_model = "neoSpazUpperLeg" + t.lower_leg_model = "neoSpazLowerLeg" + t.toes_model = "neoSpazToes" + t.jump_sounds = ["spazJump01", "spazJump02", "spazJump03", "spazJump04"] + t.attack_sounds = [ + "spazAttack01", "spazAttack02", "spazAttack03", "spazAttack04" + ] + t.impact_sounds = [ + "spazImpact01", "spazImpact02", "spazImpact03", "spazImpact04" + ] + t.death_sounds = ["spazDeath01"] + t.pickup_sounds = ["spazPickup01"] + t.fall_sounds = ["spazFall01"] + t.style = 'spaz' + + # Zoe ##################################### + t = Appearance("Zoe") + t.color_texture = "zoeColor" + t.color_mask_texture = "zoeColorMask" + t.default_color = (0.6, 0.6, 0.6) + t.default_highlight = (0, 1, 0) + t.icon_texture = "zoeIcon" + t.icon_mask_texture = "zoeIconColorMask" + t.head_model = "zoeHead" + t.torso_model = "zoeTorso" + t.pelvis_model = "zoePelvis" + t.upper_arm_model = "zoeUpperArm" + t.forearm_model = "zoeForeArm" + t.hand_model = "zoeHand" + t.upper_leg_model = "zoeUpperLeg" + t.lower_leg_model = "zoeLowerLeg" + t.toes_model = "zoeToes" + t.jump_sounds = ["zoeJump01", "zoeJump02", "zoeJump03"] + t.attack_sounds = [ + "zoeAttack01", "zoeAttack02", "zoeAttack03", "zoeAttack04" + ] + t.impact_sounds = [ + "zoeImpact01", "zoeImpact02", "zoeImpact03", "zoeImpact04" + ] + t.death_sounds = ["zoeDeath01"] + t.pickup_sounds = ["zoePickup01"] + t.fall_sounds = ["zoeFall01"] + t.style = 'female' + + # Ninja ########################################## + t = Appearance("Snake Shadow") + t.color_texture = "ninjaColor" + t.color_mask_texture = "ninjaColorMask" + t.default_color = (1, 1, 1) + t.default_highlight = (0.55, 0.8, 0.55) + t.icon_texture = "ninjaIcon" + t.icon_mask_texture = "ninjaIconColorMask" + t.head_model = "ninjaHead" + t.torso_model = "ninjaTorso" + t.pelvis_model = "ninjaPelvis" + t.upper_arm_model = "ninjaUpperArm" + t.forearm_model = "ninjaForeArm" + t.hand_model = "ninjaHand" + t.upper_leg_model = "ninjaUpperLeg" + t.lower_leg_model = "ninjaLowerLeg" + t.toes_model = "ninjaToes" + ninja_attacks = ['ninjaAttack' + str(i + 1) + '' for i in range(7)] + ninja_hits = ['ninjaHit' + str(i + 1) + '' for i in range(8)] + ninja_jumps = ['ninjaAttack' + str(i + 1) + '' for i in range(7)] + t.jump_sounds = ninja_jumps + t.attack_sounds = ninja_attacks + t.impact_sounds = ninja_hits + t.death_sounds = ["ninjaDeath1"] + t.pickup_sounds = ninja_attacks + t.fall_sounds = ["ninjaFall1"] + t.style = 'ninja' + + # Barbarian ##################################### + t = Appearance("Kronk") + t.color_texture = "kronk" + t.color_mask_texture = "kronkColorMask" + t.default_color = (0.4, 0.5, 0.4) + t.default_highlight = (1, 0.5, 0.3) + t.icon_texture = "kronkIcon" + t.icon_mask_texture = "kronkIconColorMask" + t.head_model = "kronkHead" + t.torso_model = "kronkTorso" + t.pelvis_model = "kronkPelvis" + t.upper_arm_model = "kronkUpperArm" + t.forearm_model = "kronkForeArm" + t.hand_model = "kronkHand" + t.upper_leg_model = "kronkUpperLeg" + t.lower_leg_model = "kronkLowerLeg" + t.toes_model = "kronkToes" + kronk_sounds = [ + "kronk1", "kronk2", "kronk3", "kronk4", "kronk5", "kronk6", "kronk7", + "kronk8", "kronk9", "kronk10" + ] + t.jump_sounds = kronk_sounds + t.attack_sounds = kronk_sounds + t.impact_sounds = kronk_sounds + t.death_sounds = ["kronkDeath"] + t.pickup_sounds = kronk_sounds + t.fall_sounds = ["kronkFall"] + t.style = 'kronk' + + # Chef ########################################### + t = Appearance("Mel") + t.color_texture = "melColor" + t.color_mask_texture = "melColorMask" + t.default_color = (1, 1, 1) + t.default_highlight = (0.1, 0.6, 0.1) + t.icon_texture = "melIcon" + t.icon_mask_texture = "melIconColorMask" + t.head_model = "melHead" + t.torso_model = "melTorso" + t.pelvis_model = "kronkPelvis" + t.upper_arm_model = "melUpperArm" + t.forearm_model = "melForeArm" + t.hand_model = "melHand" + t.upper_leg_model = "melUpperLeg" + t.lower_leg_model = "melLowerLeg" + t.toes_model = "melToes" + mel_sounds = [ + "mel01", "mel02", "mel03", "mel04", "mel05", "mel06", "mel07", "mel08", + "mel09", "mel10" + ] + t.attack_sounds = mel_sounds + t.jump_sounds = mel_sounds + t.impact_sounds = mel_sounds + t.death_sounds = ["melDeath01"] + t.pickup_sounds = mel_sounds + t.fall_sounds = ["melFall01"] + t.style = 'mel' + + # Pirate ####################################### + t = Appearance("Jack Morgan") + t.color_texture = "jackColor" + t.color_mask_texture = "jackColorMask" + t.default_color = (1, 0.2, 0.1) + t.default_highlight = (1, 1, 0) + t.icon_texture = "jackIcon" + t.icon_mask_texture = "jackIconColorMask" + t.head_model = "jackHead" + t.torso_model = "jackTorso" + t.pelvis_model = "kronkPelvis" + t.upper_arm_model = "jackUpperArm" + t.forearm_model = "jackForeArm" + t.hand_model = "jackHand" + t.upper_leg_model = "jackUpperLeg" + t.lower_leg_model = "jackLowerLeg" + t.toes_model = "jackToes" + hit_sounds = [ + "jackHit01", "jackHit02", "jackHit03", "jackHit04", "jackHit05", + "jackHit06", "jackHit07" + ] + sounds = ["jack01", "jack02", "jack03", "jack04", "jack05", "jack06"] + t.attack_sounds = sounds + t.jump_sounds = sounds + t.impact_sounds = hit_sounds + t.death_sounds = ["jackDeath01"] + t.pickup_sounds = sounds + t.fall_sounds = ["jackFall01"] + t.style = 'pirate' + + # Santa ###################################### + t = Appearance("Santa Claus") + t.color_texture = "santaColor" + t.color_mask_texture = "santaColorMask" + t.default_color = (1, 0, 0) + t.default_highlight = (1, 1, 1) + t.icon_texture = "santaIcon" + t.icon_mask_texture = "santaIconColorMask" + t.head_model = "santaHead" + t.torso_model = "santaTorso" + t.pelvis_model = "kronkPelvis" + t.upper_arm_model = "santaUpperArm" + t.forearm_model = "santaForeArm" + t.hand_model = "santaHand" + t.upper_leg_model = "santaUpperLeg" + t.lower_leg_model = "santaLowerLeg" + t.toes_model = "santaToes" + hit_sounds = ['santaHit01', 'santaHit02', 'santaHit03', 'santaHit04'] + sounds = ['santa01', 'santa02', 'santa03', 'santa04', 'santa05'] + t.attack_sounds = sounds + t.jump_sounds = sounds + t.impact_sounds = hit_sounds + t.death_sounds = ["santaDeath"] + t.pickup_sounds = sounds + t.fall_sounds = ["santaFall"] + t.style = 'santa' + + # Snowman ################################### + t = Appearance("Frosty") + t.color_texture = "frostyColor" + t.color_mask_texture = "frostyColorMask" + t.default_color = (0.5, 0.5, 1) + t.default_highlight = (1, 0.5, 0) + t.icon_texture = "frostyIcon" + t.icon_mask_texture = "frostyIconColorMask" + t.head_model = "frostyHead" + t.torso_model = "frostyTorso" + t.pelvis_model = "frostyPelvis" + t.upper_arm_model = "frostyUpperArm" + t.forearm_model = "frostyForeArm" + t.hand_model = "frostyHand" + t.upper_leg_model = "frostyUpperLeg" + t.lower_leg_model = "frostyLowerLeg" + t.toes_model = "frostyToes" + frosty_sounds = [ + 'frosty01', 'frosty02', 'frosty03', 'frosty04', 'frosty05' + ] + frosty_hit_sounds = ['frostyHit01', 'frostyHit02', 'frostyHit03'] + t.attack_sounds = frosty_sounds + t.jump_sounds = frosty_sounds + t.impact_sounds = frosty_hit_sounds + t.death_sounds = ["frostyDeath"] + t.pickup_sounds = frosty_sounds + t.fall_sounds = ["frostyFall"] + t.style = 'frosty' + + # Skeleton ################################ + t = Appearance("Bones") + t.color_texture = "bonesColor" + t.color_mask_texture = "bonesColorMask" + t.default_color = (0.6, 0.9, 1) + t.default_highlight = (0.6, 0.9, 1) + t.icon_texture = "bonesIcon" + t.icon_mask_texture = "bonesIconColorMask" + t.head_model = "bonesHead" + t.torso_model = "bonesTorso" + t.pelvis_model = "bonesPelvis" + t.upper_arm_model = "bonesUpperArm" + t.forearm_model = "bonesForeArm" + t.hand_model = "bonesHand" + t.upper_leg_model = "bonesUpperLeg" + t.lower_leg_model = "bonesLowerLeg" + t.toes_model = "bonesToes" + bones_sounds = ['bones1', 'bones2', 'bones3'] + bones_hit_sounds = ['bones1', 'bones2', 'bones3'] + t.attack_sounds = bones_sounds + t.jump_sounds = bones_sounds + t.impact_sounds = bones_hit_sounds + t.death_sounds = ["bonesDeath"] + t.pickup_sounds = bones_sounds + t.fall_sounds = ["bonesFall"] + t.style = 'bones' + + # Bear ################################### + t = Appearance("Bernard") + t.color_texture = "bearColor" + t.color_mask_texture = "bearColorMask" + t.default_color = (0.7, 0.5, 0.0) + t.icon_texture = "bearIcon" + t.icon_mask_texture = "bearIconColorMask" + t.head_model = "bearHead" + t.torso_model = "bearTorso" + t.pelvis_model = "bearPelvis" + t.upper_arm_model = "bearUpperArm" + t.forearm_model = "bearForeArm" + t.hand_model = "bearHand" + t.upper_leg_model = "bearUpperLeg" + t.lower_leg_model = "bearLowerLeg" + t.toes_model = "bearToes" + bear_sounds = ['bear1', 'bear2', 'bear3', 'bear4'] + bear_hit_sounds = ['bearHit1', 'bearHit2'] + t.attack_sounds = bear_sounds + t.jump_sounds = bear_sounds + t.impact_sounds = bear_hit_sounds + t.death_sounds = ["bearDeath"] + t.pickup_sounds = bear_sounds + t.fall_sounds = ["bearFall"] + t.style = 'bear' + + # Penguin ################################### + t = Appearance("Pascal") + t.color_texture = "penguinColor" + t.color_mask_texture = "penguinColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "penguinIcon" + t.icon_mask_texture = "penguinIconColorMask" + t.head_model = "penguinHead" + t.torso_model = "penguinTorso" + t.pelvis_model = "penguinPelvis" + t.upper_arm_model = "penguinUpperArm" + t.forearm_model = "penguinForeArm" + t.hand_model = "penguinHand" + t.upper_leg_model = "penguinUpperLeg" + t.lower_leg_model = "penguinLowerLeg" + t.toes_model = "penguinToes" + penguin_sounds = ['penguin1', 'penguin2', 'penguin3', 'penguin4'] + penguin_hit_sounds = ['penguinHit1', 'penguinHit2'] + t.attack_sounds = penguin_sounds + t.jump_sounds = penguin_sounds + t.impact_sounds = penguin_hit_sounds + t.death_sounds = ["penguinDeath"] + t.pickup_sounds = penguin_sounds + t.fall_sounds = ["penguinFall"] + t.style = 'penguin' + + # Ali ################################### + t = Appearance("Taobao Mascot") + t.color_texture = "aliColor" + t.color_mask_texture = "aliColorMask" + t.default_color = (1, 0.5, 0) + t.default_highlight = (1, 1, 1) + t.icon_texture = "aliIcon" + t.icon_mask_texture = "aliIconColorMask" + t.head_model = "aliHead" + t.torso_model = "aliTorso" + t.pelvis_model = "aliPelvis" + t.upper_arm_model = "aliUpperArm" + t.forearm_model = "aliForeArm" + t.hand_model = "aliHand" + t.upper_leg_model = "aliUpperLeg" + t.lower_leg_model = "aliLowerLeg" + t.toes_model = "aliToes" + ali_sounds = ['ali1', 'ali2', 'ali3', 'ali4'] + ali_hit_sounds = ['aliHit1', 'aliHit2'] + t.attack_sounds = ali_sounds + t.jump_sounds = ali_sounds + t.impact_sounds = ali_hit_sounds + t.death_sounds = ["aliDeath"] + t.pickup_sounds = ali_sounds + t.fall_sounds = ["aliFall"] + t.style = 'ali' + + # cyborg ################################### + t = Appearance("B-9000") + t.color_texture = "cyborgColor" + t.color_mask_texture = "cyborgColorMask" + t.default_color = (0.5, 0.5, 0.5) + t.default_highlight = (1, 0, 0) + t.icon_texture = "cyborgIcon" + t.icon_mask_texture = "cyborgIconColorMask" + t.head_model = "cyborgHead" + t.torso_model = "cyborgTorso" + t.pelvis_model = "cyborgPelvis" + t.upper_arm_model = "cyborgUpperArm" + t.forearm_model = "cyborgForeArm" + t.hand_model = "cyborgHand" + t.upper_leg_model = "cyborgUpperLeg" + t.lower_leg_model = "cyborgLowerLeg" + t.toes_model = "cyborgToes" + cyborg_sounds = ['cyborg1', 'cyborg2', 'cyborg3', 'cyborg4'] + cyborg_hit_sounds = ['cyborgHit1', 'cyborgHit2'] + t.attack_sounds = cyborg_sounds + t.jump_sounds = cyborg_sounds + t.impact_sounds = cyborg_hit_sounds + t.death_sounds = ["cyborgDeath"] + t.pickup_sounds = cyborg_sounds + t.fall_sounds = ["cyborgFall"] + t.style = 'cyborg' + + # Agent ################################### + t = Appearance("Agent Johnson") + t.color_texture = "agentColor" + t.color_mask_texture = "agentColorMask" + t.default_color = (0.3, 0.3, 0.33) + t.default_highlight = (1, 0.5, 0.3) + t.icon_texture = "agentIcon" + t.icon_mask_texture = "agentIconColorMask" + t.head_model = "agentHead" + t.torso_model = "agentTorso" + t.pelvis_model = "agentPelvis" + t.upper_arm_model = "agentUpperArm" + t.forearm_model = "agentForeArm" + t.hand_model = "agentHand" + t.upper_leg_model = "agentUpperLeg" + t.lower_leg_model = "agentLowerLeg" + t.toes_model = "agentToes" + agent_sounds = ['agent1', 'agent2', 'agent3', 'agent4'] + agent_hit_sounds = ['agentHit1', 'agentHit2'] + t.attack_sounds = agent_sounds + t.jump_sounds = agent_sounds + t.impact_sounds = agent_hit_sounds + t.death_sounds = ["agentDeath"] + t.pickup_sounds = agent_sounds + t.fall_sounds = ["agentFall"] + t.style = 'agent' + + # Jumpsuit ################################### + t = Appearance("Lee") + t.color_texture = "jumpsuitColor" + t.color_mask_texture = "jumpsuitColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "jumpsuitIcon" + t.icon_mask_texture = "jumpsuitIconColorMask" + t.head_model = "jumpsuitHead" + t.torso_model = "jumpsuitTorso" + t.pelvis_model = "jumpsuitPelvis" + t.upper_arm_model = "jumpsuitUpperArm" + t.forearm_model = "jumpsuitForeArm" + t.hand_model = "jumpsuitHand" + t.upper_leg_model = "jumpsuitUpperLeg" + t.lower_leg_model = "jumpsuitLowerLeg" + t.toes_model = "jumpsuitToes" + jumpsuit_sounds = ['jumpsuit1', 'jumpsuit2', 'jumpsuit3', 'jumpsuit4'] + jumpsuit_hit_sounds = ['jumpsuitHit1', 'jumpsuitHit2'] + t.attack_sounds = jumpsuit_sounds + t.jump_sounds = jumpsuit_sounds + t.impact_sounds = jumpsuit_hit_sounds + t.death_sounds = ["jumpsuitDeath"] + t.pickup_sounds = jumpsuit_sounds + t.fall_sounds = ["jumpsuitFall"] + t.style = 'spaz' + + # ActionHero ################################### + t = Appearance("Todd McBurton") + t.color_texture = "actionHeroColor" + t.color_mask_texture = "actionHeroColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "actionHeroIcon" + t.icon_mask_texture = "actionHeroIconColorMask" + t.head_model = "actionHeroHead" + t.torso_model = "actionHeroTorso" + t.pelvis_model = "actionHeroPelvis" + t.upper_arm_model = "actionHeroUpperArm" + t.forearm_model = "actionHeroForeArm" + t.hand_model = "actionHeroHand" + t.upper_leg_model = "actionHeroUpperLeg" + t.lower_leg_model = "actionHeroLowerLeg" + t.toes_model = "actionHeroToes" + action_hero_sounds = [ + 'actionHero1', 'actionHero2', 'actionHero3', 'actionHero4' + ] + action_hero_hit_sounds = ['actionHeroHit1', 'actionHeroHit2'] + t.attack_sounds = action_hero_sounds + t.jump_sounds = action_hero_sounds + t.impact_sounds = action_hero_hit_sounds + t.death_sounds = ["actionHeroDeath"] + t.pickup_sounds = action_hero_sounds + t.fall_sounds = ["actionHeroFall"] + t.style = 'spaz' + + # Assassin ################################### + t = Appearance("Zola") + t.color_texture = "assassinColor" + t.color_mask_texture = "assassinColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "assassinIcon" + t.icon_mask_texture = "assassinIconColorMask" + t.head_model = "assassinHead" + t.torso_model = "assassinTorso" + t.pelvis_model = "assassinPelvis" + t.upper_arm_model = "assassinUpperArm" + t.forearm_model = "assassinForeArm" + t.hand_model = "assassinHand" + t.upper_leg_model = "assassinUpperLeg" + t.lower_leg_model = "assassinLowerLeg" + t.toes_model = "assassinToes" + assassin_sounds = ['assassin1', 'assassin2', 'assassin3', 'assassin4'] + assassin_hit_sounds = ['assassinHit1', 'assassinHit2'] + t.attack_sounds = assassin_sounds + t.jump_sounds = assassin_sounds + t.impact_sounds = assassin_hit_sounds + t.death_sounds = ["assassinDeath"] + t.pickup_sounds = assassin_sounds + t.fall_sounds = ["assassinFall"] + t.style = 'spaz' + + # Wizard ################################### + t = Appearance("Grumbledorf") + t.color_texture = "wizardColor" + t.color_mask_texture = "wizardColorMask" + t.default_color = (0.2, 0.4, 1.0) + t.default_highlight = (0.06, 0.15, 0.4) + t.icon_texture = "wizardIcon" + t.icon_mask_texture = "wizardIconColorMask" + t.head_model = "wizardHead" + t.torso_model = "wizardTorso" + t.pelvis_model = "wizardPelvis" + t.upper_arm_model = "wizardUpperArm" + t.forearm_model = "wizardForeArm" + t.hand_model = "wizardHand" + t.upper_leg_model = "wizardUpperLeg" + t.lower_leg_model = "wizardLowerLeg" + t.toes_model = "wizardToes" + wizard_sounds = ['wizard1', 'wizard2', 'wizard3', 'wizard4'] + wizard_hit_sounds = ['wizardHit1', 'wizardHit2'] + t.attack_sounds = wizard_sounds + t.jump_sounds = wizard_sounds + t.impact_sounds = wizard_hit_sounds + t.death_sounds = ["wizardDeath"] + t.pickup_sounds = wizard_sounds + t.fall_sounds = ["wizardFall"] + t.style = 'spaz' + + # Cowboy ################################### + t = Appearance("Butch") + t.color_texture = "cowboyColor" + t.color_mask_texture = "cowboyColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "cowboyIcon" + t.icon_mask_texture = "cowboyIconColorMask" + t.head_model = "cowboyHead" + t.torso_model = "cowboyTorso" + t.pelvis_model = "cowboyPelvis" + t.upper_arm_model = "cowboyUpperArm" + t.forearm_model = "cowboyForeArm" + t.hand_model = "cowboyHand" + t.upper_leg_model = "cowboyUpperLeg" + t.lower_leg_model = "cowboyLowerLeg" + t.toes_model = "cowboyToes" + cowboy_sounds = ['cowboy1', 'cowboy2', 'cowboy3', 'cowboy4'] + cowboy_hit_sounds = ['cowboyHit1', 'cowboyHit2'] + t.attack_sounds = cowboy_sounds + t.jump_sounds = cowboy_sounds + t.impact_sounds = cowboy_hit_sounds + t.death_sounds = ["cowboyDeath"] + t.pickup_sounds = cowboy_sounds + t.fall_sounds = ["cowboyFall"] + t.style = 'spaz' + + # Witch ################################### + t = Appearance("Witch") + t.color_texture = "witchColor" + t.color_mask_texture = "witchColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "witchIcon" + t.icon_mask_texture = "witchIconColorMask" + t.head_model = "witchHead" + t.torso_model = "witchTorso" + t.pelvis_model = "witchPelvis" + t.upper_arm_model = "witchUpperArm" + t.forearm_model = "witchForeArm" + t.hand_model = "witchHand" + t.upper_leg_model = "witchUpperLeg" + t.lower_leg_model = "witchLowerLeg" + t.toes_model = "witchToes" + witch_sounds = ['witch1', 'witch2', 'witch3', 'witch4'] + witch_hit_sounds = ['witchHit1', 'witchHit2'] + t.attack_sounds = witch_sounds + t.jump_sounds = witch_sounds + t.impact_sounds = witch_hit_sounds + t.death_sounds = ["witchDeath"] + t.pickup_sounds = witch_sounds + t.fall_sounds = ["witchFall"] + t.style = 'spaz' + + # Warrior ################################### + t = Appearance("Warrior") + t.color_texture = "warriorColor" + t.color_mask_texture = "warriorColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "warriorIcon" + t.icon_mask_texture = "warriorIconColorMask" + t.head_model = "warriorHead" + t.torso_model = "warriorTorso" + t.pelvis_model = "warriorPelvis" + t.upper_arm_model = "warriorUpperArm" + t.forearm_model = "warriorForeArm" + t.hand_model = "warriorHand" + t.upper_leg_model = "warriorUpperLeg" + t.lower_leg_model = "warriorLowerLeg" + t.toes_model = "warriorToes" + warrior_sounds = ['warrior1', 'warrior2', 'warrior3', 'warrior4'] + warrior_hit_sounds = ['warriorHit1', 'warriorHit2'] + t.attack_sounds = warrior_sounds + t.jump_sounds = warrior_sounds + t.impact_sounds = warrior_hit_sounds + t.death_sounds = ["warriorDeath"] + t.pickup_sounds = warrior_sounds + t.fall_sounds = ["warriorFall"] + t.style = 'spaz' + + # Superhero ################################### + t = Appearance("Middle-Man") + t.color_texture = "superheroColor" + t.color_mask_texture = "superheroColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "superheroIcon" + t.icon_mask_texture = "superheroIconColorMask" + t.head_model = "superheroHead" + t.torso_model = "superheroTorso" + t.pelvis_model = "superheroPelvis" + t.upper_arm_model = "superheroUpperArm" + t.forearm_model = "superheroForeArm" + t.hand_model = "superheroHand" + t.upper_leg_model = "superheroUpperLeg" + t.lower_leg_model = "superheroLowerLeg" + t.toes_model = "superheroToes" + superhero_sounds = ['superhero1', 'superhero2', 'superhero3', 'superhero4'] + superhero_hit_sounds = ['superheroHit1', 'superheroHit2'] + t.attack_sounds = superhero_sounds + t.jump_sounds = superhero_sounds + t.impact_sounds = superhero_hit_sounds + t.death_sounds = ["superheroDeath"] + t.pickup_sounds = superhero_sounds + t.fall_sounds = ["superheroFall"] + t.style = 'spaz' + + # Alien ################################### + t = Appearance("Alien") + t.color_texture = "alienColor" + t.color_mask_texture = "alienColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "alienIcon" + t.icon_mask_texture = "alienIconColorMask" + t.head_model = "alienHead" + t.torso_model = "alienTorso" + t.pelvis_model = "alienPelvis" + t.upper_arm_model = "alienUpperArm" + t.forearm_model = "alienForeArm" + t.hand_model = "alienHand" + t.upper_leg_model = "alienUpperLeg" + t.lower_leg_model = "alienLowerLeg" + t.toes_model = "alienToes" + alien_sounds = ['alien1', 'alien2', 'alien3', 'alien4'] + alien_hit_sounds = ['alienHit1', 'alienHit2'] + t.attack_sounds = alien_sounds + t.jump_sounds = alien_sounds + t.impact_sounds = alien_hit_sounds + t.death_sounds = ["alienDeath"] + t.pickup_sounds = alien_sounds + t.fall_sounds = ["alienFall"] + t.style = 'spaz' + + # OldLady ################################### + t = Appearance("OldLady") + t.color_texture = "oldLadyColor" + t.color_mask_texture = "oldLadyColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "oldLadyIcon" + t.icon_mask_texture = "oldLadyIconColorMask" + t.head_model = "oldLadyHead" + t.torso_model = "oldLadyTorso" + t.pelvis_model = "oldLadyPelvis" + t.upper_arm_model = "oldLadyUpperArm" + t.forearm_model = "oldLadyForeArm" + t.hand_model = "oldLadyHand" + t.upper_leg_model = "oldLadyUpperLeg" + t.lower_leg_model = "oldLadyLowerLeg" + t.toes_model = "oldLadyToes" + old_lady_sounds = ['oldLady1', 'oldLady2', 'oldLady3', 'oldLady4'] + old_lady_hit_sounds = ['oldLadyHit1', 'oldLadyHit2'] + t.attack_sounds = old_lady_sounds + t.jump_sounds = old_lady_sounds + t.impact_sounds = old_lady_hit_sounds + t.death_sounds = ["oldLadyDeath"] + t.pickup_sounds = old_lady_sounds + t.fall_sounds = ["oldLadyFall"] + t.style = 'spaz' + + # Gladiator ################################### + t = Appearance("Gladiator") + t.color_texture = "gladiatorColor" + t.color_mask_texture = "gladiatorColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "gladiatorIcon" + t.icon_mask_texture = "gladiatorIconColorMask" + t.head_model = "gladiatorHead" + t.torso_model = "gladiatorTorso" + t.pelvis_model = "gladiatorPelvis" + t.upper_arm_model = "gladiatorUpperArm" + t.forearm_model = "gladiatorForeArm" + t.hand_model = "gladiatorHand" + t.upper_leg_model = "gladiatorUpperLeg" + t.lower_leg_model = "gladiatorLowerLeg" + t.toes_model = "gladiatorToes" + gladiator_sounds = ['gladiator1', 'gladiator2', 'gladiator3', 'gladiator4'] + gladiator_hit_sounds = ['gladiatorHit1', 'gladiatorHit2'] + t.attack_sounds = gladiator_sounds + t.jump_sounds = gladiator_sounds + t.impact_sounds = gladiator_hit_sounds + t.death_sounds = ["gladiatorDeath"] + t.pickup_sounds = gladiator_sounds + t.fall_sounds = ["gladiatorFall"] + t.style = 'spaz' + + # Wrestler ################################### + t = Appearance("Wrestler") + t.color_texture = "wrestlerColor" + t.color_mask_texture = "wrestlerColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "wrestlerIcon" + t.icon_mask_texture = "wrestlerIconColorMask" + t.head_model = "wrestlerHead" + t.torso_model = "wrestlerTorso" + t.pelvis_model = "wrestlerPelvis" + t.upper_arm_model = "wrestlerUpperArm" + t.forearm_model = "wrestlerForeArm" + t.hand_model = "wrestlerHand" + t.upper_leg_model = "wrestlerUpperLeg" + t.lower_leg_model = "wrestlerLowerLeg" + t.toes_model = "wrestlerToes" + wrestler_sounds = ['wrestler1', 'wrestler2', 'wrestler3', 'wrestler4'] + wrestler_hit_sounds = ['wrestlerHit1', 'wrestlerHit2'] + t.attack_sounds = wrestler_sounds + t.jump_sounds = wrestler_sounds + t.impact_sounds = wrestler_hit_sounds + t.death_sounds = ["wrestlerDeath"] + t.pickup_sounds = wrestler_sounds + t.fall_sounds = ["wrestlerFall"] + t.style = 'spaz' + + # OperaSinger ################################### + t = Appearance("Gretel") + t.color_texture = "operaSingerColor" + t.color_mask_texture = "operaSingerColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "operaSingerIcon" + t.icon_mask_texture = "operaSingerIconColorMask" + t.head_model = "operaSingerHead" + t.torso_model = "operaSingerTorso" + t.pelvis_model = "operaSingerPelvis" + t.upper_arm_model = "operaSingerUpperArm" + t.forearm_model = "operaSingerForeArm" + t.hand_model = "operaSingerHand" + t.upper_leg_model = "operaSingerUpperLeg" + t.lower_leg_model = "operaSingerLowerLeg" + t.toes_model = "operaSingerToes" + opera_singer_sounds = [ + 'operaSinger1', 'operaSinger2', 'operaSinger3', 'operaSinger4' + ] + opera_singer_hit_sounds = ['operaSingerHit1', 'operaSingerHit2'] + t.attack_sounds = opera_singer_sounds + t.jump_sounds = opera_singer_sounds + t.impact_sounds = opera_singer_hit_sounds + t.death_sounds = ["operaSingerDeath"] + t.pickup_sounds = opera_singer_sounds + t.fall_sounds = ["operaSingerFall"] + t.style = 'spaz' + + # Pixie ################################### + t = Appearance("Pixel") + t.color_texture = "pixieColor" + t.color_mask_texture = "pixieColorMask" + t.default_color = (0, 1, 0.7) + t.default_highlight = (0.65, 0.35, 0.75) + t.icon_texture = "pixieIcon" + t.icon_mask_texture = "pixieIconColorMask" + t.head_model = "pixieHead" + t.torso_model = "pixieTorso" + t.pelvis_model = "pixiePelvis" + t.upper_arm_model = "pixieUpperArm" + t.forearm_model = "pixieForeArm" + t.hand_model = "pixieHand" + t.upper_leg_model = "pixieUpperLeg" + t.lower_leg_model = "pixieLowerLeg" + t.toes_model = "pixieToes" + pixie_sounds = ['pixie1', 'pixie2', 'pixie3', 'pixie4'] + pixie_hit_sounds = ['pixieHit1', 'pixieHit2'] + t.attack_sounds = pixie_sounds + t.jump_sounds = pixie_sounds + t.impact_sounds = pixie_hit_sounds + t.death_sounds = ["pixieDeath"] + t.pickup_sounds = pixie_sounds + t.fall_sounds = ["pixieFall"] + t.style = 'pixie' + + # Robot ################################### + t = Appearance("Robot") + t.color_texture = "robotColor" + t.color_mask_texture = "robotColorMask" + t.default_color = (0.3, 0.5, 0.8) + t.default_highlight = (1, 0, 0) + t.icon_texture = "robotIcon" + t.icon_mask_texture = "robotIconColorMask" + t.head_model = "robotHead" + t.torso_model = "robotTorso" + t.pelvis_model = "robotPelvis" + t.upper_arm_model = "robotUpperArm" + t.forearm_model = "robotForeArm" + t.hand_model = "robotHand" + t.upper_leg_model = "robotUpperLeg" + t.lower_leg_model = "robotLowerLeg" + t.toes_model = "robotToes" + robot_sounds = ['robot1', 'robot2', 'robot3', 'robot4'] + robot_hit_sounds = ['robotHit1', 'robotHit2'] + t.attack_sounds = robot_sounds + t.jump_sounds = robot_sounds + t.impact_sounds = robot_hit_sounds + t.death_sounds = ["robotDeath"] + t.pickup_sounds = robot_sounds + t.fall_sounds = ["robotFall"] + t.style = 'spaz' + + # Bunny ################################### + t = Appearance("Easter Bunny") + t.color_texture = "bunnyColor" + t.color_mask_texture = "bunnyColorMask" + t.default_color = (1, 1, 1) + t.default_highlight = (1, 0.5, 0.5) + t.icon_texture = "bunnyIcon" + t.icon_mask_texture = "bunnyIconColorMask" + t.head_model = "bunnyHead" + t.torso_model = "bunnyTorso" + t.pelvis_model = "bunnyPelvis" + t.upper_arm_model = "bunnyUpperArm" + t.forearm_model = "bunnyForeArm" + t.hand_model = "bunnyHand" + t.upper_leg_model = "bunnyUpperLeg" + t.lower_leg_model = "bunnyLowerLeg" + t.toes_model = "bunnyToes" + bunny_sounds = ['bunny1', 'bunny2', 'bunny3', 'bunny4'] + bunny_hit_sounds = ['bunnyHit1', 'bunnyHit2'] + t.attack_sounds = bunny_sounds + t.jump_sounds = ['bunnyJump'] + t.impact_sounds = bunny_hit_sounds + t.death_sounds = ["bunnyDeath"] + t.pickup_sounds = bunny_sounds + t.fall_sounds = ["bunnyFall"] + t.style = 'bunny' diff --git a/assets/src/data/scripts/bastd/actor/spazbot.py b/assets/src/data/scripts/bastd/actor/spazbot.py new file mode 100644 index 00000000..606f8db3 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/spazbot.py @@ -0,0 +1,1033 @@ +"""Bot versions of Spaz.""" +# pylint: disable=too-many-lines + +from __future__ import annotations + +import random +import weakref +from typing import TYPE_CHECKING + +import ba +from bastd.actor import spaz as basespaz + +if TYPE_CHECKING: + from typing import Any, Optional, List, Tuple, Sequence, Type, Callable + from bastd.actor.flag import Flag + +LITE_BOT_COLOR = (1.2, 0.9, 0.2) +LITE_BOT_HIGHLIGHT = (1.0, 0.5, 0.6) +DEFAULT_BOT_COLOR = (0.6, 0.6, 0.6) +DEFAULT_BOT_HIGHLIGHT = (0.1, 0.3, 0.1) +PRO_BOT_COLOR = (1.0, 0.2, 0.1) +PRO_BOT_HIGHLIGHT = (0.6, 0.1, 0.05) + + +class SpazBotPunchedMessage: + """A message saying a ba.SpazBot got punched. + + category: Message Classes + + Attributes: + + badguy + The ba.SpazBot that got punched. + + damage + How much damage was done to the ba.SpazBot. + """ + + def __init__(self, badguy: SpazBot, damage: int): + """Instantiate a message with the given values.""" + self.badguy = badguy + self.damage = damage + + +class SpazBotDeathMessage: + """A message saying a ba.SpazBot has died. + + category: Message Classes + + Attributes: + + badguy + The ba.SpazBot that was killed. + + killerplayer + The ba.Player that killed it (or None). + + how + The particular type of death. + """ + + def __init__(self, badguy: SpazBot, killerplayer: Optional[ba.Player], + how: str): + """Instantiate with given values.""" + self.badguy = badguy + self.killerplayer = killerplayer + self.how = how + + +class SpazBot(basespaz.Spaz): + """A really dumb AI version of ba.Spaz. + + category: Bot Classes + + Add these to a ba.BotSet to use them. + + Note: currently the AI has no real ability to + navigate obstacles and so should only be used + on wide-open maps. + + When a SpazBot is killed, it delivers a ba.SpazBotDeathMessage + to the current activity. + + When a SpazBot is punched, it delivers a ba.SpazBotPunchedMessage + to the current activity. + """ + + character = 'Spaz' + punchiness = 0.5 + throwiness = 0.7 + static = False + bouncy = False + run = False + charge_dist_min = 0.0 # when we can start a new charge + charge_dist_max = 2.0 # when we can start a new charge + run_dist_min = 0.0 # how close we can be to continue running + charge_speed_min = 0.4 + charge_speed_max = 1.0 + throw_dist_min = 5.0 + throw_dist_max = 9.0 + throw_rate = 1.0 + default_bomb_type = 'normal' + default_bomb_count = 3 + start_cursed = False + color = DEFAULT_BOT_COLOR + highlight = DEFAULT_BOT_HIGHLIGHT + + def __init__(self) -> None: + """Instantiate a spaz-bot.""" + basespaz.Spaz.__init__(self, + color=self.color, + highlight=self.highlight, + character=self.character, + source_player=None, + start_invincible=False, + can_accept_powerups=False) + + # If you need to add custom behavior to a bot, set this to a callable + # which takes one arg (the bot) and returns False if the bot's normal + # update should be run and True if not. + self.update_callback: Optional[Callable[[SpazBot], Any]] = None + activity = self.activity + assert isinstance(activity, ba.GameActivity) + self._map = weakref.ref(activity.map) + self.last_player_attacked_by: Optional[ba.Player] = None + self.last_attacked_time = 0.0 + self.last_attacked_type: Optional[Tuple[str, str]] = None + self.target_point_default: Optional[ba.Vec3] = None + self.held_count = 0 + self.last_player_held_by: Optional[ba.Player] = None + self.target_flag: Optional[Flag] = None + self._charge_speed = 0.5 * (self.charge_speed_min + + self.charge_speed_max) + self._lead_amount = 0.5 + self._mode = 'wait' + self._charge_closing_in = False + self._last_charge_dist = 0.0 + self._running = False + self._last_jump_time = 0.0 + + self._throw_release_time: Optional[float] = None + self._have_dropped_throw_bomb: Optional[bool] = None + self._player_pts: Optional[List[Tuple[ba.Vec3, ba.Vec3]]] = None + + # These cooldowns didn't exist when these bots were calibrated, + # so take them out of the equation. + self._jump_cooldown = 0 + self._pickup_cooldown = 0 + self._fly_cooldown = 0 + self._bomb_cooldown = 0 + + if self.start_cursed: + self.curse() + + @property + def map(self) -> ba.Map: + """The map this bot was created on.""" + mval = self._map() + assert mval is not None + return mval + + def _get_target_player_pt(self + ) -> Tuple[Optional[ba.Vec3], Optional[ba.Vec3]]: + """Returns the position and velocity of our target. + + Both values will be None in the case of no target. + """ + assert self.node + botpt = ba.Vec3(self.node.position) + closest_dist = None + closest_vel = None + closest = None + assert self._player_pts + for plpt, plvel in self._player_pts: + dist = (plpt - botpt).length() + # Ignore player-points that are significantly below the bot + # (keeps bots from following players off cliffs). + if (closest_dist is None + or dist < closest_dist) and (plpt[1] > botpt[1] - 5.0): + closest_dist = dist + closest_vel = plvel + closest = plpt + if closest_dist is not None: + assert closest_vel is not None + assert closest is not None + return (ba.Vec3(closest[0], closest[1], closest[2]), + ba.Vec3(closest_vel[0], closest_vel[1], closest_vel[2])) + return None, None + + def set_player_points(self, pts: List[Tuple[ba.Vec3, ba.Vec3]]) -> None: + """Provide the spaz-bot with the locations of its enemies.""" + self._player_pts = pts + + def update_ai(self) -> None: + """Should be called periodically to update the spaz' AI.""" + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + if self.update_callback is not None: + if self.update_callback(self): + return # true means bot has been handled + + assert self.node + pos = self.node.position + our_pos = ba.Vec3(pos[0], 0, pos[2]) + can_attack = True + + target_pt_raw: Optional[ba.Vec3] + target_vel: Optional[ba.Vec3] + + # If we're a flag-bearer, we're pretty simple-minded - just walk + # towards the flag and try to pick it up. + if self.target_flag: + if self.node.hold_node: + try: + holding_flag = ( + self.node.hold_node.getnodetype() == 'flag') + except Exception: + holding_flag = False + else: + holding_flag = False + # If we're holding the flag, just walk left. + if holding_flag: + # just walk left + self.node.move_left_right = -1.0 + self.node.move_up_down = 0.0 + # Otherwise try to go pick it up. + else: + assert self.target_flag.node + target_pt_raw = ba.Vec3(*self.target_flag.node.position) + diff = (target_pt_raw - our_pos) + diff = ba.Vec3(diff[0], 0, diff[2]) # don't care about y + dist = diff.length() + to_target = diff.normalized() + + # If we're holding some non-flag item, drop it. + if self.node.hold_node: + self.node.pickup_pressed = True + self.node.pickup_pressed = False + return + + # If we're a runner, run only when not super-near the flag. + if self.run and dist > 3.0: + self._running = True + self.node.run = 1.0 + else: + self._running = False + self.node.run = 0.0 + + self.node.move_left_right = to_target.x + self.node.move_up_down = -to_target.z + if dist < 1.25: + self.node.pickup_pressed = True + self.node.pickup_pressed = False + return + # Not a flag-bearer. If we're holding anything but a bomb, drop it. + if self.node.hold_node: + try: + holding_bomb = (self.node.hold_node.getnodetype() in [ + 'bomb', 'prop' + ]) + except Exception: + holding_bomb = False + if not holding_bomb: + self.node.pickup_pressed = True + self.node.pickup_pressed = False + return + + target_pt_raw, target_vel = self._get_target_player_pt() + + if target_pt_raw is None: + # use default target if we've got one + if self.target_point_default is not None: + target_pt_raw = self.target_point_default + target_vel = ba.Vec3(0, 0, 0) + can_attack = False + # with no target, we stop moving and drop whatever we're holding + else: + self.node.move_left_right = 0 + self.node.move_up_down = 0 + if self.node.hold_node: + self.node.pickup_pressed = True + self.node.pickup_pressed = False + return + + # we don't want height to come into play + target_pt_raw[1] = 0.0 + assert target_vel is not None + target_vel[1] = 0.0 + + dist_raw = (target_pt_raw - our_pos).length() + # use a point out in front of them as real target + # (more out in front the farther from us they are) + target_pt = (target_pt_raw + + target_vel * dist_raw * 0.3 * self._lead_amount) + + diff = (target_pt - our_pos) + dist = diff.length() + to_target = diff.normalized() + + if self._mode == 'throw': + # we can only throw if alive and well.. + if not self._dead and not self.node.knockout: + + assert self._throw_release_time is not None + time_till_throw = self._throw_release_time - ba.time() + + if not self.node.hold_node: + # if we haven't thrown yet, whip out the bomb + if not self._have_dropped_throw_bomb: + self.drop_bomb() + self._have_dropped_throw_bomb = True + # otherwise our lack of held node means we successfully + # released our bomb.. lets retreat now + else: + self._mode = 'flee' + + # oh crap we're holding a bomb.. better throw it. + elif time_till_throw <= 0.0: + # jump and throw.. + def _safe_pickup(node: ba.Node) -> None: + if node and self.node: + self.node.pickup_pressed = True + self.node.pickup_pressed = False + + if dist > 5.0: + self.node.jump_pressed = True + self.node.jump_pressed = False + # throws: + ba.timer(0.1, ba.Call(_safe_pickup, self.node)) + else: + # throws: + ba.timer(0.1, ba.Call(_safe_pickup, self.node)) + + if self.static: + if time_till_throw < 0.3: + speed = 1.0 + elif time_till_throw < 0.7 and dist > 3.0: + speed = -1.0 # whiplash for long throws + else: + speed = 0.02 + else: + if time_till_throw < 0.7: + # right before throw charge full speed towards target + speed = 1.0 + else: + # earlier we can hold or move backward for a whiplash + speed = 0.0125 + self.node.move_left_right = to_target.x * speed + self.node.move_up_down = to_target.z * -1.0 * speed + + elif self._mode == 'charge': + if random.random() < 0.3: + self._charge_speed = random.uniform(self.charge_speed_min, + self.charge_speed_max) + # if we're a runner we run during charges *except when near + # an edge (otherwise we tend to fly off easily) + if self.run and dist_raw > self.run_dist_min: + self._lead_amount = 0.3 + self._running = True + self.node.run = 1.0 + else: + self._lead_amount = 0.01 + self._running = False + self.node.run = 0.0 + + self.node.move_left_right = to_target.x * self._charge_speed + self.node.move_up_down = to_target.z * -1.0 * self._charge_speed + + elif self._mode == 'wait': + # every now and then, aim towards our target.. + # other than that, just stand there + if ba.time(timeformat=ba.TimeFormat.MILLISECONDS) % 1234 < 100: + self.node.move_left_right = to_target.x * (400.0 / 33000) + self.node.move_up_down = to_target.z * (-400.0 / 33000) + else: + self.node.move_left_right = 0 + self.node.move_up_down = 0 + + elif self._mode == 'flee': + # even if we're a runner, only run till we get away from our + # target (if we keep running we tend to run off edges) + if self.run and dist < 3.0: + self._running = True + self.node.run = 1.0 + else: + self._running = False + self.node.run = 0.0 + self.node.move_left_right = to_target.x * -1.0 + self.node.move_up_down = to_target.z + + # we might wanna switch states unless we're doing a throw + # (in which case that's our sole concern) + if self._mode != 'throw': + + # if we're currently charging, keep track of how far we are + # from our target.. when this value increases it means our charge + # is over (ran by them or something) + if self._mode == 'charge': + if (self._charge_closing_in + and self._last_charge_dist < dist < 3.0): + self._charge_closing_in = False + self._last_charge_dist = dist + + # if we have a clean shot, throw! + if (self.throw_dist_min <= dist < self.throw_dist_max + and random.random() < self.throwiness and can_attack): + self._mode = 'throw' + self._lead_amount = ((0.4 + random.random() * 0.6) + if dist_raw > 4.0 else + (0.1 + random.random() * 0.4)) + self._have_dropped_throw_bomb = False + self._throw_release_time = (ba.time() + + (1.0 / self.throw_rate) * + (0.8 + 1.3 * random.random())) + + # if we're static, always charge (which for us means barely move) + elif self.static: + self._mode = 'wait' + + # if we're too close to charge (and aren't in the middle of an + # existing charge) run away + elif dist < self.charge_dist_min and not self._charge_closing_in: + # ..unless we're near an edge, in which case we got no choice + # but to charge.. + if self.map.is_point_near_edge(our_pos, self._running): + if self._mode != 'charge': + self._mode = 'charge' + self._lead_amount = 0.2 + self._charge_closing_in = True + self._last_charge_dist = dist + else: + self._mode = 'flee' + + # we're within charging distance, backed against an edge, + # or farther than our max throw distance.. chaaarge! + elif (dist < self.charge_dist_max or dist > self.throw_dist_max + or self.map.is_point_near_edge(our_pos, self._running)): + if self._mode != 'charge': + self._mode = 'charge' + self._lead_amount = 0.01 + self._charge_closing_in = True + self._last_charge_dist = dist + + # we're too close to throw but too far to charge - either run + # away or just chill if we're near an edge + elif dist < self.throw_dist_min: + # charge if either we're within charge range or + # cant retreat to throw + self._mode = 'flee' + + # Do some awesome jumps if we're running. + # FIXME: pylint: disable=too-many-boolean-expressions + if ((self._running and 1.2 < dist < 2.2 + and ba.time() - self._last_jump_time > 1.0) + or (self.bouncy and ba.time() - self._last_jump_time > 0.4 + and random.random() < 0.5)): + self._last_jump_time = ba.time() + self.node.jump_pressed = True + self.node.jump_pressed = False + + # Throw punches when real close. + if dist < (1.6 if self._running else 1.2) and can_attack: + if random.random() < self.punchiness: + self.on_punch_press() + self.on_punch_release() + + def on_punched(self, damage: int) -> None: + """ + Method override; sends ba.SpazBotPunchedMessage + to the current activity. + """ + ba.getactivity().handlemessage(SpazBotPunchedMessage(self, damage)) + + def on_expire(self) -> None: + basespaz.Spaz.on_expire(self) + # we're being torn down; release + # our callback(s) so there's no chance of them + # keeping activities or other things alive.. + self.update_callback = None + + def handlemessage(self, msg: Any) -> Any: + # pylint: disable=too-many-branches + self._handlemessage_sanity_check() + + # keep track of if we're being held and by who most recently + if isinstance(msg, ba.PickedUpMessage): + super().handlemessage(msg) # augment standard behavior + self.held_count += 1 + picked_up_by = msg.node.source_player + if picked_up_by: + self.last_player_held_by = picked_up_by + + elif isinstance(msg, ba.DroppedMessage): + super().handlemessage(msg) # augment standard behavior + self.held_count -= 1 + if self.held_count < 0: + print("ERROR: spaz held_count < 0") + # let's count someone dropping us as an attack.. + try: + if msg.node: + picked_up_by = msg.node.source_player + else: + picked_up_by = None + except Exception as exc: + print('EXC on SpazBot DroppedMessage:', exc) + picked_up_by = None + + if picked_up_by: + self.last_player_attacked_by = picked_up_by + self.last_attacked_time = ba.time() + self.last_attacked_type = ('picked_up', 'default') + + elif isinstance(msg, ba.DieMessage): + + # report normal deaths for scoring purposes + if not self._dead and not msg.immediate: + + killerplayer: Optional[ba.Player] + # if this guy was being held at the time of death, the + # holder is the killer + if self.held_count > 0 and self.last_player_held_by: + killerplayer = self.last_player_held_by + else: + # otherwise if they were attacked by someone in the + # last few seconds that person's the killer.. + # otherwise it was a suicide + if (self.last_player_attacked_by + and ba.time() - self.last_attacked_time < 4.0): + killerplayer = self.last_player_attacked_by + else: + killerplayer = None + activity = self._activity() + + # (convert dead refs to None) + if not killerplayer: + killerplayer = None + if activity is not None: + activity.handlemessage( + SpazBotDeathMessage(self, killerplayer, msg.how)) + super().handlemessage(msg) # augment standard behavior + + # keep track of the player who last hit us for point rewarding + elif isinstance(msg, ba.HitMessage): + if msg.source_player: + self.last_player_attacked_by = msg.source_player + self.last_attacked_time = ba.time() + self.last_attacked_type = (msg.hit_type, msg.hit_subtype) + super().handlemessage(msg) + else: + super().handlemessage(msg) + + +class BomberBot(SpazBot): + """A bot that throws regular bombs and occasionally punches. + + category: Bot Classes + """ + character = 'Spaz' + punchiness = 0.3 + + +class BomberBotLite(BomberBot): + """A less aggressive yellow version of ba.BomberBot. + + category: Bot Classes + """ + color = LITE_BOT_COLOR + highlight = LITE_BOT_HIGHLIGHT + punchiness = 0.2 + throw_rate = 0.7 + throwiness = 0.1 + charge_speed_min = 0.6 + charge_speed_max = 0.6 + + +class BomberBotStaticLite(BomberBotLite): + """A less aggressive generally immobile weak version of ba.BomberBot. + + category: Bot Classes + """ + static = True + throw_dist_min = 0.0 + + +class BomberBotStatic(BomberBot): + """A version of ba.BomberBot who generally stays in one place. + + category: Bot Classes + """ + static = True + throw_dist_min = 0.0 + + +class BomberBotPro(BomberBot): + """A more powerful version of ba.BomberBot. + + category: Bot Classes + """ + points_mult = 2 + color = PRO_BOT_COLOR + highlight = PRO_BOT_HIGHLIGHT + default_bomb_count = 3 + default_boxing_gloves = True + punchiness = 0.7 + throw_rate = 1.3 + run = True + run_dist_min = 6.0 + + +class BomberBotProShielded(BomberBotPro): + """A more powerful version of ba.BomberBot who starts with shields. + + category: Bot Classes + """ + points_mult = 3 + default_shields = True + + +class BomberBotProStatic(BomberBotPro): + """A more powerful ba.BomberBot who generally stays in one place. + + category: Bot Classes + """ + static = True + throw_dist_min = 0.0 + + +class BomberBotProStaticShielded(BomberBotProShielded): + """A powerful ba.BomberBot with shields who is generally immobile. + + category: Bot Classes + """ + static = True + throw_dist_min = 0.0 + + +class BrawlerBot(SpazBot): + """A bot who walks and punches things. + + category: Bot Classes + """ + character = 'Kronk' + punchiness = 0.9 + charge_dist_max = 9999.0 + charge_speed_min = 1.0 + charge_speed_max = 1.0 + throw_dist_min = 9999 + throw_dist_max = 9999 + + +class BrawlerBotLite(BrawlerBot): + """A weaker version of ba.BrawlerBot. + + category: Bot Classes + """ + color = LITE_BOT_COLOR + highlight = LITE_BOT_HIGHLIGHT + punchiness = 0.3 + charge_speed_min = 0.6 + charge_speed_max = 0.6 + + +class BrawlerBotPro(BrawlerBot): + """A stronger version of ba.BrawlerBot. + + category: Bot Classes + """ + color = PRO_BOT_COLOR + highlight = PRO_BOT_HIGHLIGHT + run = True + run_dist_min = 4.0 + default_boxing_gloves = True + punchiness = 0.95 + points_mult = 2 + + +class BrawlerBotProShielded(BrawlerBotPro): + """A stronger version of ba.BrawlerBot who starts with shields. + + category: Bot Classes + """ + default_shields = True + points_mult = 3 + + +class ChargerBot(SpazBot): + """A speedy melee attack bot. + + category: Bot Classes + """ + + character = 'Snake Shadow' + punchiness = 1.0 + run = True + charge_dist_min = 10.0 + charge_dist_max = 9999.0 + charge_speed_min = 1.0 + charge_speed_max = 1.0 + throw_dist_min = 9999 + throw_dist_max = 9999 + points_mult = 2 + + +class BouncyBot(SpazBot): + """A speedy attacking melee bot that jumps constantly. + + category: Bot Classes + """ + + color = (1, 1, 1) + highlight = (1.0, 0.5, 0.5) + character = 'Easter Bunny' + punchiness = 1.0 + run = True + bouncy = True + default_boxing_gloves = True + charge_dist_min = 10.0 + charge_dist_max = 9999.0 + charge_speed_min = 1.0 + charge_speed_max = 1.0 + throw_dist_min = 9999 + throw_dist_max = 9999 + points_mult = 2 + + +class ChargerBotPro(ChargerBot): + """A stronger ba.ChargerBot. + + category: Bot Classes + """ + color = PRO_BOT_COLOR + highlight = PRO_BOT_HIGHLIGHT + default_shields = True + default_boxing_gloves = True + points_mult = 3 + + +class ChargerBotProShielded(ChargerBotPro): + """A stronger ba.ChargerBot who starts with shields. + + category: Bot Classes + """ + default_shields = True + points_mult = 4 + + +class TriggerBot(SpazBot): + """A slow moving bot with trigger bombs. + + category: Bot Classes + """ + character = 'Zoe' + punchiness = 0.75 + throwiness = 0.7 + charge_dist_max = 1.0 + charge_speed_min = 0.3 + charge_speed_max = 0.5 + throw_dist_min = 3.5 + throw_dist_max = 5.5 + default_bomb_type = 'impact' + points_mult = 2 + + +class TriggerBotStatic(TriggerBot): + """A ba.TriggerBot who generally stays in one place. + + category: Bot Classes + """ + static = True + throw_dist_min = 0.0 + + +class TriggerBotPro(TriggerBot): + """A stronger version of ba.TriggerBot. + + category: Bot Classes + """ + color = PRO_BOT_COLOR + highlight = PRO_BOT_HIGHLIGHT + default_bomb_count = 3 + default_boxing_gloves = True + charge_speed_min = 1.0 + charge_speed_max = 1.0 + punchiness = 0.9 + throw_rate = 1.3 + run = True + run_dist_min = 6.0 + points_mult = 3 + + +class TriggerBotProShielded(TriggerBotPro): + """A stronger version of ba.TriggerBot who starts with shields. + + category: Bot Classes + """ + default_shields = True + points_mult = 4 + + +class StickyBot(SpazBot): + """A crazy bot who runs and throws sticky bombs. + + category: Bot Classes + """ + character = 'Mel' + punchiness = 0.9 + throwiness = 1.0 + run = True + charge_dist_min = 4.0 + charge_dist_max = 10.0 + charge_speed_min = 1.0 + charge_speed_max = 1.0 + throw_dist_min = 0.0 + throw_dist_max = 4.0 + throw_rate = 2.0 + default_bomb_type = 'sticky' + default_bomb_count = 3 + points_mult = 3 + + +class StickyBotStatic(StickyBot): + """A crazy bot who throws sticky-bombs but generally stays in one place. + + category: Bot Classes + """ + static = True + + +class ExplodeyBot(SpazBot): + """A bot who runs and explodes in 5 seconds. + + category: Bot Classes + """ + character = 'Jack Morgan' + run = True + charge_dist_min = 0.0 + charge_dist_max = 9999 + charge_speed_min = 1.0 + charge_speed_max = 1.0 + throw_dist_min = 9999 + throw_dist_max = 9999 + start_cursed = True + points_mult = 4 + + +class ExplodeyBotNoTimeLimit(ExplodeyBot): + """A bot who runs but does not explode on his own. + + category: Bot Classes + """ + curse_time = None + + +class ExplodeyBotShielded(ExplodeyBot): + """A ba.ExplodeyBot who starts with shields. + + category: Bot Classes + """ + default_shields = True + points_mult = 5 + + +class BotSet: + """A container/controller for one or more ba.SpazBots. + + category: Bot Classes + """ + + def __init__(self) -> None: + """Create a bot-set.""" + # we spread our bots out over a few lists so we can update + # them in a staggered fashion + self._bot_list_count = 5 + self._bot_add_list = 0 + self._bot_update_list = 0 + self._bot_lists: List[List[SpazBot]] = [ + [] for _ in range(self._bot_list_count) + ] + self._spawn_sound = ba.getsound('spawn') + self._spawning_count = 0 + self._bot_update_timer: Optional[ba.Timer] = None + self.start_moving() + + def __del__(self) -> None: + self.clear() + + def spawn_bot(self, + bot_type: Type[SpazBot], + pos: Sequence[float], + spawn_time: float = 3.0, + on_spawn_call: Callable[[SpazBot], Any] = None) -> None: + """Spawn a bot from this set.""" + from bastd.actor import spawner + spawner.Spawner(pt=pos, + spawn_time=spawn_time, + send_spawn_message=False, + spawn_callback=ba.Call(self._spawn_bot, bot_type, pos, + on_spawn_call)) + self._spawning_count += 1 + + def _spawn_bot(self, bot_type: Type[SpazBot], pos: Sequence[float], + on_spawn_call: Callable[[SpazBot], Any]) -> None: + spaz = bot_type() + ba.playsound(self._spawn_sound, position=pos) + assert spaz.node + spaz.node.handlemessage("flash") + spaz.node.is_area_of_interest = False + spaz.handlemessage(ba.StandMessage(pos, random.uniform(0, 360))) + self.add_bot(spaz) + self._spawning_count -= 1 + if on_spawn_call is not None: + on_spawn_call(spaz) + + def have_living_bots(self) -> bool: + """Return whether any bots in the set are alive or spawning.""" + return (self._spawning_count > 0 + or any(any(b.is_alive() for b in l) for l in self._bot_lists)) + + def get_living_bots(self) -> List[SpazBot]: + """Get the living bots in the set.""" + bots: List[SpazBot] = [] + for botlist in self._bot_lists: + for bot in botlist: + if bot.is_alive(): + bots.append(bot) + return bots + + def _update(self) -> None: + + # Update one of our bot lists each time through. + # First off, remove dead bots from the list. Note that we check + # exists() here via the bool operator instead of dead; we want to + # keep them around even if they're just a corpse. + try: + bot_list = self._bot_lists[self._bot_update_list] = ([ + b for b in self._bot_lists[self._bot_update_list] if b + ]) + except Exception: + bot_list = [] + ba.print_exception("error updating bot list: " + + str(self._bot_lists[self._bot_update_list])) + self._bot_update_list = (self._bot_update_list + + 1) % self._bot_list_count + + # update our list of player points for the bots to use + player_pts = [] + for player in ba.getactivity().players: + try: + if player.is_alive(): + assert player.actor is not None and player.actor.node + player_pts.append((ba.Vec3(player.actor.node.position), + ba.Vec3(player.actor.node.velocity))) + except Exception: + ba.print_exception('error on bot-set _update') + + for bot in bot_list: + bot.set_player_points(player_pts) + bot.update_ai() + + def clear(self) -> None: + """Immediately clear out any bots in the set.""" + # don't do this if the activity is shutting down or dead + activity = ba.getactivity(doraise=False) + if activity is None or activity.is_expired(): + return + + for i in range(len(self._bot_lists)): + for bot in self._bot_lists[i]: + bot.handlemessage(ba.DieMessage(immediate=True)) + self._bot_lists[i] = [] + + def celebrate(self, duration: float) -> None: + """Tell all living bots in the set to celebrate momentarily. + + Duration is given in seconds. + """ + for botlist in self._bot_lists: + for bot in botlist: + if bot.node: + bot.node.handlemessage('celebrate', int(duration * 1000)) + + def start_moving(self) -> None: + """Start processing bot AI updates so they start doing their thing.""" + self._bot_update_timer = ba.Timer(0.05, + ba.WeakCall(self._update), + repeat=True) + + def stop_moving(self) -> None: + """Tell all bots to stop moving and stops updating their AI. + + Useful when players have won and you want the + enemy bots to just stand and look bewildered. + """ + self._bot_update_timer = None + for botlist in self._bot_lists: + for bot in botlist: + if bot.node: + bot.node.move_left_right = 0 + bot.node.move_up_down = 0 + + def final_celebrate(self) -> None: + """Tell all bots in the set to stop what they were doing and celebrate. + + Use this when the bots have won a game. + """ + self._bot_update_timer = None + # at this point stop doing anything but jumping and celebrating + for botlist in self._bot_lists: + for bot in botlist: + if bot.node: + bot.node.move_left_right = 0 + bot.node.move_up_down = 0 + ba.timer( + 0.5 * random.random(), + ba.Call(bot.node.handlemessage, 'celebrate', 10.0)) + jump_duration = random.randrange(400, 500) + j = random.randrange(0, 200) + for _i in range(10): + bot.node.jump_pressed = True + bot.node.jump_pressed = False + j += jump_duration + ba.timer(random.uniform(0.0, 1.0), + ba.Call(bot.node.handlemessage, 'attack_sound')) + ba.timer(random.uniform(1.0, 2.0), + ba.Call(bot.node.handlemessage, 'attack_sound')) + ba.timer(random.uniform(2.0, 3.0), + ba.Call(bot.node.handlemessage, 'attack_sound')) + + def add_bot(self, bot: SpazBot) -> None: + """Add a ba.SpazBot instance to the set.""" + self._bot_lists[self._bot_add_list].append(bot) + self._bot_add_list = (self._bot_add_list + 1) % self._bot_list_count diff --git a/assets/src/data/scripts/bastd/actor/spazfactory.py b/assets/src/data/scripts/bastd/actor/spazfactory.py new file mode 100644 index 00000000..3fd3fc3e --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/spazfactory.py @@ -0,0 +1,224 @@ +"""Provides a factory object from creating Spazzes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.actor import spaz as basespaz + +if TYPE_CHECKING: + from typing import Any, Dict + + +class SpazFactory: + """Wraps up media and other resources used by ba.Spaz instances. + + Category: Gameplay Classes + + Generally one of these is created per ba.Activity and shared + between all spaz instances. Use ba.Spaz.get_factory() to return + the shared factory for the current activity. + + Attributes: + + impact_sounds_medium + A tuple of ba.Sounds for when a ba.Spaz hits something kinda hard. + + impact_sounds_hard + A tuple of ba.Sounds for when a ba.Spaz hits something really hard. + + impact_sounds_harder + A tuple of ba.Sounds for when a ba.Spaz hits something really + really hard. + + single_player_death_sound + The sound that plays for an 'important' spaz death such as in + co-op games. + + punch_sound + A standard punch ba.Sound. + + punch_sound_strong + A tuple of stronger sounding punch ba.Sounds. + + punch_sound_stronger + A really really strong sounding punch ba.Sound. + + swish_sound + A punch swish ba.Sound. + + block_sound + A ba.Sound for when an attack is blocked by invincibility. + + shatter_sound + A ba.Sound for when a frozen ba.Spaz shatters. + + splatter_sound + A ba.Sound for when a ba.Spaz blows up via curse. + + spaz_material + A ba.Material applied to all of parts of a ba.Spaz. + + roller_material + A ba.Material applied to the invisible roller ball body that + a ba.Spaz uses for locomotion. + + punch_material + A ba.Material applied to the 'fist' of a ba.Spaz. + + pickup_material + A ba.Material applied to the 'grabber' body of a ba.Spaz. + + curse_material + A ba.Material applied to a cursed ba.Spaz that triggers an explosion. + """ + + def _preload(self, character: str) -> None: + """Preload media needed for a given character.""" + self.get_media(character) + + def __init__(self) -> None: + """Instantiate a factory object.""" + self.impact_sounds_medium = (ba.getsound('impactMedium'), + ba.getsound('impactMedium2')) + self.impact_sounds_hard = (ba.getsound('impactHard'), + ba.getsound('impactHard2'), + ba.getsound('impactHard3')) + self.impact_sounds_harder = (ba.getsound('bigImpact'), + ba.getsound('bigImpact2')) + self.single_player_death_sound = ba.getsound('playerDeath') + self.punch_sound = ba.getsound('punch01') + self.punch_sound_strong = (ba.getsound('punchStrong01'), + ba.getsound('punchStrong02')) + self.punch_sound_stronger = ba.getsound('superPunch') + self.swish_sound = ba.getsound('punchSwish') + self.block_sound = ba.getsound('block') + self.shatter_sound = ba.getsound('shatter') + self.splatter_sound = ba.getsound('splatter') + self.spaz_material = ba.Material() + self.roller_material = ba.Material() + self.punch_material = ba.Material() + self.pickup_material = ba.Material() + self.curse_material = ba.Material() + + footing_material = ba.sharedobj('footing_material') + object_material = ba.sharedobj('object_material') + player_material = ba.sharedobj('player_material') + region_material = ba.sharedobj('region_material') + + # send footing messages to spazzes so they know when they're on solid + # ground. + # eww this should really just be built into the spaz node + self.roller_material.add_actions( + conditions=('they_have_material', footing_material), + actions=(('message', 'our_node', 'at_connect', 'footing', 1), + ('message', 'our_node', 'at_disconnect', 'footing', -1))) + + self.spaz_material.add_actions( + conditions=('they_have_material', footing_material), + actions=(('message', 'our_node', 'at_connect', 'footing', 1), + ('message', 'our_node', 'at_disconnect', 'footing', -1))) + # punches + self.punch_material.add_actions( + conditions=('they_are_different_node_than_us', ), + actions=(('modify_part_collision', 'collide', + True), ('modify_part_collision', 'physical', False), + ('message', 'our_node', 'at_connect', + basespaz.PunchHitMessage()))) + # pickups + self.pickup_material.add_actions( + conditions=(('they_are_different_node_than_us', ), 'and', + ('they_have_material', object_material)), + actions=(('modify_part_collision', 'collide', + True), ('modify_part_collision', 'physical', False), + ('message', 'our_node', 'at_connect', + basespaz.PickupMessage()))) + # curse + self.curse_material.add_actions( + conditions=(('they_are_different_node_than_us', ), 'and', + ('they_have_material', player_material)), + actions=('message', 'our_node', 'at_connect', + basespaz.CurseExplodeMessage())) + + self.foot_impact_sounds = (ba.getsound('footImpact01'), + ba.getsound('footImpact02'), + ba.getsound('footImpact03')) + + self.foot_skid_sound = ba.getsound('skid01') + self.foot_roll_sound = ba.getsound('scamper01') + + self.roller_material.add_actions( + conditions=('they_have_material', footing_material), + actions=(('impact_sound', self.foot_impact_sounds, 1, + 0.2), ('skid_sound', self.foot_skid_sound, 20, 0.3), + ('roll_sound', self.foot_roll_sound, 20, 3.0))) + + self.skid_sound = ba.getsound('gravelSkid') + + self.spaz_material.add_actions( + conditions=('they_have_material', footing_material), + actions=(('impact_sound', self.foot_impact_sounds, 20, + 6), ('skid_sound', self.skid_sound, 2.0, 1), + ('roll_sound', self.skid_sound, 2.0, 1))) + + self.shield_up_sound = ba.getsound('shieldUp') + self.shield_down_sound = ba.getsound('shieldDown') + self.shield_hit_sound = ba.getsound('shieldHit') + + # we don't want to collide with stuff we're initially overlapping + # (unless its marked with a special region material) + self.spaz_material.add_actions( + conditions=((('we_are_younger_than', 51), 'and', + ('they_are_different_node_than_us', )), 'and', + ('they_dont_have_material', region_material)), + actions=('modify_node_collision', 'collide', False)) + + self.spaz_media: Dict[str, Any] = {} + + # lets load some basic rules (allows them to be tweaked from the + # master server) + self.shield_decay_rate = _ba.get_account_misc_read_val('rsdr', 10.0) + self.punch_cooldown = _ba.get_account_misc_read_val('rpc', 400) + self.punch_cooldown_gloves = (_ba.get_account_misc_read_val( + 'rpcg', 300)) + self.punch_power_scale = _ba.get_account_misc_read_val('rpp', 1.2) + self.punch_power_scale_gloves = (_ba.get_account_misc_read_val( + 'rppg', 1.4)) + self.max_shield_spillover_damage = (_ba.get_account_misc_read_val( + 'rsms', 500)) + + def get_style(self, character: str) -> str: + """Return the named style for this character. + + (this influences subtle aspects of their appearance, etc) + """ + return ba.app.spaz_appearances[character].style + + def get_media(self, character: str) -> Dict[str, Any]: + """Return the set of media used by this variant of spaz.""" + char = ba.app.spaz_appearances[character] + if character not in self.spaz_media: + media = self.spaz_media[character] = { + 'jump_sounds': [ba.getsound(s) for s in char.jump_sounds], + 'attack_sounds': [ba.getsound(s) for s in char.attack_sounds], + 'impact_sounds': [ba.getsound(s) for s in char.impact_sounds], + 'death_sounds': [ba.getsound(s) for s in char.death_sounds], + 'pickup_sounds': [ba.getsound(s) for s in char.pickup_sounds], + 'fall_sounds': [ba.getsound(s) for s in char.fall_sounds], + 'color_texture': ba.gettexture(char.color_texture), + 'color_mask_texture': ba.gettexture(char.color_mask_texture), + 'head_model': ba.getmodel(char.head_model), + 'torso_model': ba.getmodel(char.torso_model), + 'pelvis_model': ba.getmodel(char.pelvis_model), + 'upper_arm_model': ba.getmodel(char.upper_arm_model), + 'forearm_model': ba.getmodel(char.forearm_model), + 'hand_model': ba.getmodel(char.hand_model), + 'upper_leg_model': ba.getmodel(char.upper_leg_model), + 'lower_leg_model': ba.getmodel(char.lower_leg_model), + 'toes_model': ba.getmodel(char.toes_model) + } + else: + media = self.spaz_media[character] + return media diff --git a/assets/src/data/scripts/bastd/actor/text.py b/assets/src/data/scripts/bastd/actor/text.py new file mode 100644 index 00000000..6c627c35 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/text.py @@ -0,0 +1,180 @@ +"""Defines Actor(s).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Union, Tuple, Sequence + + +class Text(ba.Actor): + """ Text with some tricks """ + + def __init__(self, + text: Union[str, ba.Lstr], + position: Tuple[float, float] = (0.0, 0.0), + h_align: str = 'left', + v_align: str = 'none', + color: Sequence[float] = (1.0, 1.0, 1.0, 1.0), + transition: str = None, + transition_delay: float = 0.0, + flash: bool = False, + v_attach: str = 'center', + h_attach: str = 'center', + scale: float = 1.0, + transition_out_delay: float = None, + maxwidth: float = None, + shadow: float = 0.5, + flatness: float = 0.0, + vr_depth: float = 0.0, + host_only: bool = False, + front: bool = False): + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + super().__init__() + self.node = ba.newnode( + 'text', + delegate=self, + attrs={ + 'text': text, + 'color': color, + 'position': position, + 'h_align': h_align, + 'vr_depth': vr_depth, + 'v_align': v_align, + 'h_attach': h_attach, + 'v_attach': v_attach, + 'shadow': shadow, + 'flatness': flatness, + 'maxwidth': 0.0 if maxwidth is None else maxwidth, + 'host_only': host_only, + 'front': front, + 'scale': scale + }) + + if transition == 'fade_in': + if flash: + raise Exception("fixme: flash and fade-in" + " currently cant both be on") + cmb = ba.newnode('combine', + owner=self.node, + attrs={ + 'input0': color[0], + 'input1': color[1], + 'input2': color[2], + 'size': 4 + }) + keys = {transition_delay: 0.0, transition_delay + 0.5: color[3]} + if transition_out_delay is not None: + keys[transition_delay + transition_out_delay] = color[3] + keys[transition_delay + transition_out_delay + 0.5] = 0.0 + ba.animate(cmb, "input3", keys) + cmb.connectattr('output', self.node, 'color') + + if flash: + mult = 2.0 + tm1 = 0.15 + tm2 = 0.3 + cmb = ba.newnode('combine', owner=self.node, attrs={'size': 4}) + ba.animate(cmb, + "input0", { + 0.0: color[0] * mult, + tm1: color[0], + tm2: color[0] * mult + }, + loop=True) + ba.animate(cmb, + "input1", { + 0.0: color[1] * mult, + tm1: color[1], + tm2: color[1] * mult + }, + loop=True) + ba.animate(cmb, + "input2", { + 0.0: color[2] * mult, + tm1: color[2], + tm2: color[2] * mult + }, + loop=True) + cmb.input3 = color[3] + cmb.connectattr('output', self.node, 'color') + + cmb = self.position_combine = ba.newnode('combine', + owner=self.node, + attrs={'size': 2}) + if transition == 'in_right': + keys = { + transition_delay: position[0] + 1.3, + transition_delay + 0.2: position[0] + } + o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0} + ba.animate(cmb, 'input0', keys) + cmb.input1 = position[1] + ba.animate(self.node, 'opacity', o_keys) + elif transition == 'in_left': + keys = { + transition_delay: position[0] - 1.3, + transition_delay + 0.2: position[0] + } + o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0} + if transition_out_delay is not None: + keys[transition_delay + transition_out_delay] = position[0] + keys[transition_delay + transition_out_delay + + 0.2] = position[0] - 1300.0 + o_keys[transition_delay + transition_out_delay + 0.15] = 1.0 + o_keys[transition_delay + transition_out_delay + 0.2] = 0.0 + ba.animate(cmb, 'input0', keys) + cmb.input1 = position[1] + ba.animate(self.node, 'opacity', o_keys) + elif transition == 'in_bottom_slow': + keys = { + transition_delay: -100.0, + transition_delay + 1.0: position[1] + } + o_keys = {transition_delay: 0.0, transition_delay + 0.2: 1.0} + cmb.input0 = position[0] + ba.animate(cmb, 'input1', keys) + ba.animate(self.node, 'opacity', o_keys) + elif transition == 'in_bottom': + keys = { + transition_delay: -100.0, + transition_delay + 0.2: position[1] + } + o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0} + if transition_out_delay is not None: + keys[transition_delay + transition_out_delay] = position[1] + keys[transition_delay + transition_out_delay + 0.2] = -100.0 + o_keys[transition_delay + transition_out_delay + 0.15] = 1.0 + o_keys[transition_delay + transition_out_delay + 0.2] = 0.0 + cmb.input0 = position[0] + ba.animate(cmb, 'input1', keys) + ba.animate(self.node, 'opacity', o_keys) + elif transition == 'inTopSlow': + keys = {transition_delay: 0.4, transition_delay + 3.5: position[1]} + o_keys = {transition_delay: 0.0, transition_delay + 1.0: 1.0} + cmb.input0 = position[0] + ba.animate(cmb, 'input1', keys) + ba.animate(self.node, 'opacity', o_keys) + else: + cmb.input0 = position[0] + cmb.input1 = position[1] + cmb.connectattr('output', self.node, 'position') + + # if we're transitioning out, die at the end of it + if transition_out_delay is not None: + ba.timer(transition_delay + transition_out_delay + 1.0, + ba.WeakCall(self.handlemessage, ba.DieMessage())) + + def handlemessage(self, msg: Any) -> Any: + if __debug__ is True: + self._handlemessage_sanity_check() + if isinstance(msg, ba.DieMessage): + if self.node: + self.node.delete() + return None + return super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/actor/tipstext.py b/assets/src/data/scripts/bastd/actor/tipstext.py new file mode 100644 index 00000000..ed645028 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/tipstext.py @@ -0,0 +1,93 @@ +"""Provides tip related Actor(s).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any + + +class TipsText(ba.Actor): + """A bit of text showing various helpful game tips.""" + + def __init__(self, offs_y: float = 100.0): + super().__init__() + self._tip_scale = 0.8 + self._tip_title_scale = 1.1 + self._offs_y = offs_y + self.node = ba.newnode('text', + delegate=self, + attrs={ + 'text': '', + 'scale': self._tip_scale, + 'h_align': 'left', + 'maxwidth': 800, + 'vr_depth': -20, + 'v_align': 'center', + 'v_attach': 'bottom' + }) + tval = ba.Lstr(value='${A}:', + subs=[('${A}', ba.Lstr(resource='tipText'))]) + self.title_node = ba.newnode('text', + delegate=self, + attrs={ + 'text': tval, + 'scale': self._tip_title_scale, + 'maxwidth': 122, + 'h_align': 'right', + 'vr_depth': -20, + 'v_align': 'center', + 'v_attach': 'bottom' + }) + self._message_duration = 10000 + self._message_spacing = 3000 + self._change_timer = ba.Timer( + 0.001 * (self._message_duration + self._message_spacing), + ba.WeakCall(self.change_phrase), + repeat=True) + self._combine = ba.newnode("combine", + owner=self.node, + attrs={ + 'input0': 1.0, + 'input1': 0.8, + 'input2': 1.0, + 'size': 4 + }) + self._combine.connectattr('output', self.node, 'color') + self._combine.connectattr('output', self.title_node, 'color') + self.change_phrase() + + def change_phrase(self) -> None: + """Switch the visible tip phrase.""" + from ba.internal import get_remote_app_name, get_next_tip + next_tip = ba.Lstr(translate=('tips', get_next_tip()), + subs=[('${REMOTE_APP_NAME}', get_remote_app_name()) + ]) + spc = self._message_spacing + assert self.node + self.node.position = (-200, self._offs_y) + self.title_node.position = (-220, self._offs_y + 3) + keys = { + spc: 0, + spc + 1000: 1.0, + spc + self._message_duration - 1000: 1.0, + spc + self._message_duration: 0.0 + } + ba.animate(self._combine, + "input3", {k: v * 0.5 + for k, v in list(keys.items())}, + timeformat=ba.TimeFormat.MILLISECONDS) + self.node.text = next_tip + + def handlemessage(self, msg: Any) -> Any: + if __debug__ is True: + self._handlemessage_sanity_check() + if isinstance(msg, ba.DieMessage): + if self.node: + self.node.delete() + self.title_node.delete() + return None + return super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/actor/zoomtext.py b/assets/src/data/scripts/bastd/actor/zoomtext.py new file mode 100644 index 00000000..4a052a18 --- /dev/null +++ b/assets/src/data/scripts/bastd/actor/zoomtext.py @@ -0,0 +1,196 @@ +"""Defined Actor(s).""" + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Union, Tuple, Sequence + + +class ZoomText(ba.Actor): + """Big Zooming Text. + + Category: Gameplay Classes + + Used for things such as the 'BOB WINS' victory messages. + """ + + def __init__(self, + text: Union[str, ba.Lstr], + position: Tuple[float, float] = (0.0, 0.0), + shiftposition: Tuple[float, float] = None, + shiftdelay: float = None, + lifespan: float = None, + flash: bool = True, + trail: bool = True, + h_align: str = "center", + color: Sequence[float] = (0.9, 0.4, 0.0), + jitter: float = 0.0, + trailcolor: Sequence[float] = (1.0, 0.35, 0.1, 0.0), + scale: float = 1.0, + project_scale: float = 1.0, + tilt_translate: float = 0.0, + maxwidth: float = None): + # pylint: disable=too-many-locals + super().__init__() + self._dying = False + positionadjusted = (position[0], position[1] - 100) + if shiftdelay is None: + shiftdelay = 2.500 + if shiftdelay < 0.0: + ba.print_error('got shiftdelay < 0') + shiftdelay = 0.0 + self._project_scale = project_scale + self.node = ba.newnode( + 'text', + delegate=self, + attrs={ + 'position': positionadjusted, + 'big': True, + 'text': text, + 'trail': trail, + 'vr_depth': 0, + 'shadow': 0.0 if trail else 0.3, + 'scale': scale, + 'maxwidth': maxwidth if maxwidth is not None else 0.0, + 'tilt_translate': tilt_translate, + 'h_align': h_align, + 'v_align': 'center' + }) + + # we never jitter in vr mode.. + if ba.app.vr_mode: + jitter = 0.0 + + # if they want jitter, animate its position slightly... + if jitter > 0.0: + self._jitter(positionadjusted, jitter * scale) + + # if they want shifting, move to the shift position and + # then resume jittering + if shiftposition is not None: + positionadjusted2 = (shiftposition[0], shiftposition[1] - 100) + ba.timer( + shiftdelay, + ba.WeakCall(self._shift, positionadjusted, positionadjusted2)) + if jitter > 0.0: + ba.timer( + shiftdelay + 0.25, + ba.WeakCall(self._jitter, positionadjusted2, + jitter * scale)) + color_combine = ba.newnode('combine', + owner=self.node, + attrs={ + 'input2': color[2], + 'input3': 1.0, + 'size': 4 + }) + if trail: + trailcolor_n = ba.newnode('combine', + owner=self.node, + attrs={ + 'size': 3, + 'input0': trailcolor[0], + 'input1': trailcolor[1], + 'input2': trailcolor[2] + }) + trailcolor_n.connectattr('output', self.node, 'trailcolor') + basemult = 0.85 + ba.animate( + self.node, 'trail_project_scale', { + 0: 0 * project_scale, + basemult * 0.201: 0.6 * project_scale, + basemult * 0.347: 0.8 * project_scale, + basemult * 0.478: 0.9 * project_scale, + basemult * 0.595: 0.93 * project_scale, + basemult * 0.748: 0.95 * project_scale, + basemult * 0.941: 0.95 * project_scale + }) + if flash: + mult = 2.0 + tm1 = 0.15 + tm2 = 0.3 + ba.animate(color_combine, + 'input0', { + 0: color[0] * mult, + tm1: color[0], + tm2: color[0] * mult + }, + loop=True) + ba.animate(color_combine, + 'input1', { + 0: color[1] * mult, + tm1: color[1], + tm2: color[1] * mult + }, + loop=True) + ba.animate(color_combine, + 'input2', { + 0: color[2] * mult, + tm1: color[2], + tm2: color[2] * mult + }, + loop=True) + else: + color_combine.input0 = color[0] + color_combine.input1 = color[1] + color_combine.connectattr('output', self.node, 'color') + ba.animate(self.node, 'project_scale', { + 0: 0, + 0.27: 1.05 * project_scale, + 0.3: 1 * project_scale + }) + + # if they give us a lifespan, kill ourself down the line + if lifespan is not None: + ba.timer(lifespan, ba.WeakCall(self.handlemessage, + ba.DieMessage())) + + def handlemessage(self, msg: Any) -> Any: + if __debug__ is True: + self._handlemessage_sanity_check() + if isinstance(msg, ba.DieMessage): + if not self._dying and self.node: + self._dying = True + if msg.immediate: + self.node.delete() + else: + ba.animate( + self.node, 'project_scale', { + 0.0: 1 * self._project_scale, + 0.6: 1.2 * self._project_scale + }) + ba.animate(self.node, 'opacity', {0.0: 1, 0.3: 0}) + ba.animate(self.node, 'trail_opacity', {0.0: 1, 0.6: 0}) + ba.timer(0.7, self.node.delete) + return None + return super().handlemessage(msg) + + def _jitter(self, position: Tuple[float, float], + jitter_amount: float) -> None: + if not self.node: + return + cmb = ba.newnode('combine', owner=self.node, attrs={'size': 2}) + for index, attr in enumerate(['input0', 'input1']): + keys = {} + timeval = 0.0 + # gen some random keys for that stop-motion-y look + for _i in range(10): + keys[timeval] = (position[index] + + (random.random() - 0.5) * jitter_amount * 1.6) + timeval += random.random() * 0.1 + ba.animate(cmb, attr, keys, loop=True) + cmb.connectattr('output', self.node, 'position') + + def _shift(self, position1: Tuple[float, float], + position2: Tuple[float, float]) -> None: + if not self.node: + return + cmb = ba.newnode('combine', owner=self.node, attrs={'size': 2}) + ba.animate(cmb, 'input0', {0.0: position1[0], 0.25: position2[0]}) + ba.animate(cmb, 'input1', {0.0: position1[1], 0.25: position2[1]}) + cmb.connectattr('output', self.node, 'position') diff --git a/assets/src/data/scripts/bastd/appdelegate.py b/assets/src/data/scripts/bastd/appdelegate.py new file mode 100644 index 00000000..d50c97cd --- /dev/null +++ b/assets/src/data/scripts/bastd/appdelegate.py @@ -0,0 +1,28 @@ +"""Provide our delegate for high level app functionality.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Type, Any, Dict, Callable, Optional + + +class AppDelegate(ba.AppDelegate): + """Defines handlers for high level app functionality.""" + + def create_default_game_config_ui( + self, gameclass: Type[ba.GameActivity], + sessionclass: Type[ba.Session], config: Optional[Dict[str, Any]], + completion_call: Callable[[Optional[Dict[str, Any]]], Any] + ) -> None: + """(internal)""" + + # Replace the main window once we come up successfully. + from bastd.ui.playlist.editgame import PlaylistEditGameWindow + prev_window = ba.app.main_menu_window + ba.app.main_menu_window = (PlaylistEditGameWindow( + gameclass, sessionclass, config, + completion_call=completion_call).get_root_widget()) + ba.containerwidget(edit=prev_window, transition='out_left') diff --git a/assets/src/data/scripts/bastd/game/__init__.py b/assets/src/data/scripts/bastd/game/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assets/src/data/scripts/bastd/game/assault.py b/assets/src/data/scripts/bastd/game/assault.py new file mode 100644 index 00000000..78458073 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/assault.py @@ -0,0 +1,232 @@ +"""Defines assault minigame.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor import playerspaz + +if TYPE_CHECKING: + from typing import Any, Type, List, Dict, Tuple, Sequence, Union + + +# bs_meta export game +class AssaultGame(ba.TeamGameActivity): + """Game where you score by touching the other team's flag.""" + + @classmethod + def get_name(cls) -> str: + return 'Assault' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Reach the enemy flag to score.' + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + return issubclass(sessiontype, ba.TeamsSession) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ba.getmaps('team_flag') + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + return [('Score to Win', {'min_value': 1, 'default': 3}), + ('Time Limit', { + 'choices': [('None', 0), ('1 Minute', 60), + ('2 Minutes', 120), ('5 Minutes', 300), + ('10 Minutes', 600), ('20 Minutes', 1200)], + 'default': 0}), + ('Respawn Times', { + 'choices': [('Shorter', 0.25), ('Short', 0.5), + ('Normal', 1.0), ('Long', 2.0), + ('Longer', 4.0)], + 'default': 1.0}), + ('Epic Mode', {'default': False})] # yapf: disable + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + super().__init__(settings) + self._scoreboard = Scoreboard() + if self.settings['Epic Mode']: + self.slow_motion = True + self._last_score_time = 0.0 + self._score_sound = ba.getsound("score") + self._base_region_materials: Dict[int, ba.Material] = {} + + def get_instance_description(self) -> Union[str, Sequence]: + if self.settings['Score to Win'] == 1: + return 'Touch the enemy flag.' + return ('Touch the enemy flag ${ARG1} times.', + self.settings['Score to Win']) + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + if self.settings['Score to Win'] == 1: + return 'touch 1 flag' + return 'touch ${ARG1} flags', self.settings['Score to Win'] + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Need to unify these parameters. + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in( + self, + music='Epic' if self.settings['Epic Mode'] else 'ForwardMarch') + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['score'] = 0 + self._update_scoreboard() + + def on_begin(self) -> None: + from bastd.actor.flag import Flag + ba.TeamGameActivity.on_begin(self) + self.setup_standard_time_limit(self.settings['Time Limit']) + self.setup_standard_powerup_drops() + for team in self.teams: + mat = self._base_region_materials[team.get_id()] = ba.Material() + mat.add_actions(conditions=('they_have_material', + ba.sharedobj('player_material')), + actions=(('modify_part_collision', 'collide', + True), ('modify_part_collision', + 'physical', False), + ('call', 'at_connect', + ba.Call(self._handle_base_collide, + team)))) + + # Create a score region and flag for each team. + for team in self.teams: + team.gamedata['base_pos'] = self.map.get_flag_position( + team.get_id()) + + ba.newnode('light', + attrs={ + 'position': team.gamedata['base_pos'], + 'intensity': 0.6, + 'height_attenuated': False, + 'volume_intensity_scale': 0.1, + 'radius': 0.1, + 'color': team.color + }) + + self.project_flag_stand(team.gamedata['base_pos']) + team.gamedata['flag'] = Flag(touchable=False, + position=team.gamedata['base_pos'], + color=team.color) + basepos = team.gamedata['base_pos'] + ba.newnode( + 'region', + owner=team.gamedata['flag'].node, + attrs={ + 'position': (basepos[0], basepos[1] + 0.75, basepos[2]), + 'scale': (0.5, 0.5, 0.5), + 'type': 'sphere', + 'materials': [self._base_region_materials[team.get_id()]] + }) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + super().handlemessage(msg) # augment standard + self.respawn_player(msg.spaz.player) + else: + super().handlemessage(msg) + + def _flash_base(self, team: ba.Team, length: float = 2.0) -> None: + light = ba.newnode('light', + attrs={ + 'position': team.gamedata['base_pos'], + 'height_attenuated': False, + 'radius': 0.3, + 'color': team.color + }) + ba.animate(light, "intensity", {0: 0, 0.25: 2.0, 0.5: 0}, loop=True) + ba.timer(length, light.delete) + + def _handle_base_collide(self, team: ba.Team) -> None: + cval = ba.get_collision_info('opposing_node') + try: + player = cval.getdelegate().getplayer() + except Exception: + return + if not player or not player.is_alive(): + return + + # If its another team's player, they scored. + player_team = player.get_team() + if player_team is not team: + + # Prevent multiple simultaneous scores. + if ba.time() != self._last_score_time: + self._last_score_time = ba.time() + self.stats.player_scored(player, 50, big_message=True) + ba.playsound(self._score_sound) + self._flash_base(team) + + # Move all players on the scoring team back to their start + # and add flashes of light so its noticeable. + for player in player_team.players: + if player.is_alive(): + pos = player.actor.node.position + light = ba.newnode('light', + attrs={ + 'position': pos, + 'color': player_team.color, + 'height_attenuated': False, + 'radius': 0.4 + }) + ba.timer(0.5, light.delete) + ba.animate(light, 'intensity', { + 0: 0, + 0.1: 1.0, + 0.5: 0 + }) + + new_pos = (self.map.get_start_position( + player_team.get_id())) + light = ba.newnode('light', + attrs={ + 'position': new_pos, + 'color': player_team.color, + 'radius': 0.4, + 'height_attenuated': False + }) + ba.timer(0.5, light.delete) + ba.animate(light, 'intensity', { + 0: 0, + 0.1: 1.0, + 0.5: 0 + }) + player.actor.handlemessage( + ba.StandMessage(new_pos, random.uniform(0, 360))) + + # Have teammates celebrate. + for player in player_team.players: + try: + # Note: celebrate message is milliseconds + # for historical reasons. + player.actor.node.handlemessage('celebrate', 2000) + except Exception: + pass + + player_team.gamedata['score'] += 1 + self._update_scoreboard() + if (player_team.gamedata['score'] >= + self.settings['Score to Win']): + self.end_game() + + def end_game(self) -> None: + results = ba.TeamGameResults() + for team in self.teams: + results.set_team_score(team, team.gamedata['score']) + self.end(results=results) + + def _update_scoreboard(self) -> None: + for team in self.teams: + self._scoreboard.set_team_value(team, team.gamedata['score'], + self.settings['Score to Win']) diff --git a/assets/src/data/scripts/bastd/game/capturetheflag.py b/assets/src/data/scripts/bastd/game/capturetheflag.py new file mode 100644 index 00000000..0b88de28 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/capturetheflag.py @@ -0,0 +1,485 @@ +"""Defines a capture-the-flag game.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.actor import flag as stdflag +from bastd.actor import playerspaz + +if TYPE_CHECKING: + from typing import Any, Type, List, Dict, Tuple, Sequence, Union, Optional + + +class CTFFlag(stdflag.Flag): + """Special flag type for ctf games.""" + + def __init__(self, team: ba.Team): + super().__init__(materials=[team.gamedata['flagmaterial']], + position=team.gamedata['base_pos'], + color=team.color) + self._team = team + self.held_count = 0 + self.counter = ba.newnode('text', + owner=self.node, + attrs={ + 'in_world': True, + 'scale': 0.02, + 'h_align': 'center' + }) + self.reset_return_times() + self.last_player_to_hold = None + self.time_out_respawn_time: Optional[int] = None + self.touch_return_time: Optional[float] = None + + def reset_return_times(self) -> None: + """Clear flag related times in the activity.""" + self.time_out_respawn_time = int( + self.activity.settings['Flag Idle Return Time']) + self.touch_return_time = float( + self.activity.settings['Flag Touch Return Time']) + + def get_team(self) -> ba.Team: + """return the flag's team.""" + return self._team + + +# bs_meta export game +class CaptureTheFlagGame(ba.TeamGameActivity): + """Game of stealing other team's flag and returning it to your base.""" + + @classmethod + def get_name(cls) -> str: + return 'Capture the Flag' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Return the enemy flag to score.' + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + return issubclass(sessiontype, ba.TeamsSession) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ba.getmaps('team_flag') + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + return [ + ('Score to Win', {'min_value': 1, 'default': 3}), + ('Flag Touch Return Time', { + 'min_value': 0, 'default': 0, 'increment': 1}), + ('Flag Idle Return Time', { + 'min_value': 5, 'default': 30, 'increment': 5}), + ('Time Limit', { + 'choices': [('None', 0), ('1 Minute', 60), + ('2 Minutes', 120), ('5 Minutes', 300), + ('10 Minutes', 600), ('20 Minutes', 1200)], + 'default': 0}), + ('Respawn Times', { + 'choices': [('Shorter', 0.25), ('Short', 0.5), ('Normal', 1.0), + ('Long', 2.0), ('Longer', 4.0)], + 'default': 1.0}), + ('Epic Mode', {'default': False})] # yapf: disable + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + super().__init__(settings) + self._scoreboard = Scoreboard() + if self.settings['Epic Mode']: + self.slow_motion = True + self._alarmsound = ba.getsound('alarm') + self._ticking_sound = ba.getsound('ticking') + self._last_score_time = 0 + self._score_sound = ba.getsound('score') + self._swipsound = ba.getsound('swip') + self._all_bases_material = ba.Material() + self._last_home_flag_notice_print_time = 0.0 + + def get_instance_description(self) -> Union[str, Sequence]: + if self.settings['Score to Win'] == 1: + return 'Steal the enemy flag.' + return ('Steal the enemy flag ${ARG1} times.', + self.settings['Score to Win']) + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + if self.settings['Score to Win'] == 1: + return 'return 1 flag' + return 'return ${ARG1} flags', self.settings['Score to Win'] + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME unify these args + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in( + self, + music='Epic' if self.settings['Epic Mode'] else 'FlagCatcher') + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['score'] = 0 + team.gamedata['flag_return_touches'] = 0 + team.gamedata['home_flag_at_base'] = True + team.gamedata['touch_return_timer'] = None + team.gamedata['enemy_flag_at_base'] = False + team.gamedata['base_pos'] = (self.map.get_flag_position(team.get_id())) + + self.project_flag_stand(team.gamedata['base_pos']) + + ba.newnode('light', + attrs={ + 'position': team.gamedata['base_pos'], + 'intensity': 0.6, + 'height_attenuated': False, + 'volume_intensity_scale': 0.1, + 'radius': 0.1, + 'color': team.color + }) + + base_region_mat = team.gamedata['base_region_material'] = ba.Material() + pos = team.gamedata['base_pos'] + team.gamedata['base_region'] = ba.newnode( + "region", + attrs={ + 'position': (pos[0], pos[1] + 0.75, pos[2]), + 'scale': (0.5, 0.5, 0.5), + 'type': 'sphere', + 'materials': [base_region_mat, self._all_bases_material] + }) + + # create some materials for this team + spaz_mat_no_flag_physical = team.gamedata[ + 'spaz_material_no_flag_physical'] = ba.Material() + spaz_mat_no_flag_collide = team.gamedata[ + 'spaz_material_no_flag_collide'] = ba.Material() + flagmat = team.gamedata['flagmaterial'] = ba.Material() + + # Some parts of our spazzes don't collide physically with our + # flags but generate callbacks. + spaz_mat_no_flag_physical.add_actions( + conditions=('they_have_material', flagmat), + actions=(('modify_part_collision', 'physical', + False), ('call', 'at_connect', + lambda: self._handle_hit_own_flag(team, 1)), + ('call', 'at_disconnect', + lambda: self._handle_hit_own_flag(team, 0)))) + + # Other parts of our spazzes don't collide with our flags at all. + spaz_mat_no_flag_collide.add_actions(conditions=('they_have_material', + flagmat), + actions=('modify_part_collision', + 'collide', False)) + + # We wanna know when *any* flag enters/leaves our base. + base_region_mat.add_actions( + conditions=('they_have_material', + stdflag.get_factory().flagmaterial), + actions=(('modify_part_collision', 'collide', + True), ('modify_part_collision', 'physical', False), + ('call', 'at_connect', + lambda: self._handle_flag_entered_base(team)), + ('call', 'at_disconnect', + lambda: self._handle_flag_left_base(team)))) + + self._spawn_flag_for_team(team) + self._update_scoreboard() + + def on_begin(self) -> None: + ba.TeamGameActivity.on_begin(self) + self.setup_standard_time_limit(self.settings['Time Limit']) + self.setup_standard_powerup_drops() + ba.timer(1.0, call=self._tick, repeat=True) + + def _spawn_flag_for_team(self, team: ba.Team) -> None: + flag = team.gamedata['flag'] = CTFFlag(team) + team.gamedata['flag_return_touches'] = 0 + self._flash_base(team, length=1.0) + assert flag.node + ba.playsound(self._swipsound, position=flag.node.position) + + def _handle_flag_entered_base(self, team: ba.Team) -> None: + flag = ba.get_collision_info("opposing_node").getdelegate() + + if flag.get_team() is team: + team.gamedata['home_flag_at_base'] = True + + # If the enemy flag is already here, score! + if team.gamedata['enemy_flag_at_base']: + self._score(team) + else: + team.gamedata['enemy_flag_at_base'] = True + if team.gamedata['home_flag_at_base']: + # Award points to whoever was carrying the enemy flag. + player = flag.last_player_to_hold + if player and player.get_team() is team: + assert self.stats + self.stats.player_scored(player, 50, big_message=True) + + # Update score and reset flags. + self._score(team) + + # If the home-team flag isn't here, print a message to that effect. + else: + # Don't want slo-mo affecting this + curtime = ba.time(ba.TimeType.BASE) + if curtime - self._last_home_flag_notice_print_time > 5.0: + self._last_home_flag_notice_print_time = curtime + bpos = team.gamedata['base_pos'] + tval = ba.Lstr(resource='ownFlagAtYourBaseWarning') + tnode = ba.newnode( + 'text', + attrs={ + 'text': tval, + 'in_world': True, + 'scale': 0.013, + 'color': (1, 1, 0, 1), + 'h_align': 'center', + 'position': (bpos[0], bpos[1] + 3.2, bpos[2]) + }) + ba.timer(5.1, tnode.delete) + ba.animate(tnode, 'scale', { + 0.0: 0, + 0.2: 0.013, + 4.8: 0.013, + 5.0: 0 + }) + + def _tick(self) -> None: + # If either flag is away from base and not being held, tick down its + # respawn timer. + for team in self.teams: + flag = team.gamedata['flag'] + + if (not team.gamedata['home_flag_at_base'] + and flag.held_count == 0): + time_out_counting_down = True + flag.time_out_respawn_time -= 1 + if flag.time_out_respawn_time <= 0: + flag.handlemessage(ba.DieMessage()) + else: + time_out_counting_down = False + + if flag.node and flag.counter: + pos = flag.node.position + flag.counter.position = (pos[0], pos[1] + 1.3, pos[2]) + + # If there's no self-touches on this flag, set its text + # to show its auto-return counter. (if there's self-touches + # its showing that time). + if team.gamedata['flag_return_touches'] == 0: + flag.counter.text = (str(flag.time_out_respawn_time) if + (time_out_counting_down + and flag.time_out_respawn_time <= 10) + else '') + flag.counter.color = (1, 1, 1, 0.5) + flag.counter.scale = 0.014 + + def _score(self, team: ba.Team) -> None: + team.gamedata['score'] += 1 + ba.playsound(self._score_sound) + self._flash_base(team) + self._update_scoreboard() + + # Have teammates celebrate + for player in team.players: + if player.actor is not None and player.actor.node: + # Note: celebrate message is milliseconds + # for historical reasons. + player.actor.node.handlemessage('celebrate', 2000) + + # Reset all flags/state. + for reset_team in self.teams: + if not reset_team.gamedata['home_flag_at_base']: + reset_team.gamedata['flag'].handlemessage(ba.DieMessage()) + reset_team.gamedata['enemy_flag_at_base'] = False + if team.gamedata['score'] >= self.settings['Score to Win']: + self.end_game() + + def end_game(self) -> None: + results = ba.TeamGameResults() + for team in self.teams: + results.set_team_score(team, team.gamedata['score']) + self.end(results=results, announce_delay=0.8) + + def _handle_flag_left_base(self, team: ba.Team) -> None: + cur_time = ba.time() + op_node = ba.get_collision_info("opposing_node") + try: + flag = op_node.getdelegate() + except Exception: + return # Can happen when we kill a flag. + + if flag.get_team() is team: + + # Check times here to prevent too much flashing. + if ('last_flag_leave_time' not in team.gamedata + or cur_time - team.gamedata['last_flag_leave_time'] > 3.0): + ba.playsound(self._alarmsound, + position=team.gamedata['base_pos']) + self._flash_base(team) + team.gamedata['last_flag_leave_time'] = cur_time + team.gamedata['home_flag_at_base'] = False + else: + team.gamedata['enemy_flag_at_base'] = False + + def _touch_return_update(self, team: ba.Team) -> None: + + # Count down only while its away from base and not being held. + if (team.gamedata['home_flag_at_base'] + or team.gamedata['flag'].held_count > 0): + team.gamedata['touch_return_timer_ticking'] = None + return # No need to return when its at home. + if team.gamedata['touch_return_timer_ticking'] is None: + team.gamedata['touch_return_timer_ticking'] = ba.Actor( + ba.newnode('sound', + attrs={ + 'sound': self._ticking_sound, + 'positional': False, + 'loop': True + })) + flag = team.gamedata['flag'] + flag.touch_return_time -= 0.1 + if flag.counter: + flag.counter.text = "%.1f" % flag.touch_return_time + flag.counter.color = (1, 1, 0, 1) + flag.counter.scale = 0.02 + + if flag.touch_return_time <= 0.0: + self._award_players_touching_own_flag(team) + flag.handlemessage(ba.DieMessage()) + + def _award_players_touching_own_flag(self, team: ba.Team) -> None: + for player in team.players: + if player.gamedata['touching_own_flag'] > 0: + return_score = 10 + 5 * int( + self.settings['Flag Touch Return Time']) + self.stats.player_scored(player, + return_score, + screenmessage=False) + + def _handle_hit_own_flag(self, team: ba.Team, val: int) -> None: + """ + keep track of when each player is touching their + own flag so we can award points when returned + """ + # I wear the cone of shame. + # pylint: disable=too-many-branches + srcnode = ba.get_collision_info('source_node') + try: + player = srcnode.getdelegate().getplayer() + except Exception: + player = None + if player: + if val: + player.gamedata['touching_own_flag'] += 1 + else: + player.gamedata['touching_own_flag'] -= 1 + + # If return-time is zero, just kill it immediately.. otherwise keep + # track of touches and count down. + if float(self.settings['Flag Touch Return Time']) <= 0.0: + if (not team.gamedata['home_flag_at_base'] + and team.gamedata['flag'].held_count == 0): + + # Use a node message to kill the flag instead of just killing + # our team's. (avoids redundantly killing new flags if + # multiple body parts generate callbacks in one step). + node = ba.get_collision_info("opposing_node") + if node: + self._award_players_touching_own_flag(team) + node.handlemessage(ba.DieMessage()) + + # Takes a non-zero amount of time to return. + else: + if val: + team.gamedata['flag_return_touches'] += 1 + if team.gamedata['flag_return_touches'] == 1: + team.gamedata['touch_return_timer'] = ba.Timer( + 0.1, + call=ba.Call(self._touch_return_update, team), + repeat=True) + team.gamedata['touch_return_timer_ticking'] = None + else: + team.gamedata['flag_return_touches'] -= 1 + if team.gamedata['flag_return_touches'] == 0: + team.gamedata['touch_return_timer'] = None + team.gamedata['touch_return_timer_ticking'] = None + if team.gamedata['flag_return_touches'] < 0: + ba.print_error( + "CTF: flag_return_touches < 0; this shouldn't happen.") + + def _flash_base(self, team: ba.Team, length: float = 2.0) -> None: + light = ba.newnode('light', + attrs={ + 'position': team.gamedata['base_pos'], + 'height_attenuated': False, + 'radius': 0.3, + 'color': team.color + }) + ba.animate(light, 'intensity', {0.0: 0, 0.25: 2.0, 0.5: 0}, loop=True) + ba.timer(length, light.delete) + + def spawn_player_spaz(self, *args: Any, **keywds: Any) -> Any: + """Intercept new spazzes and add our team material for them.""" + # (chill pylint; we're passing our exact args to parent call) + # pylint: disable=arguments-differ + spaz = ba.TeamGameActivity.spawn_player_spaz(self, *args, **keywds) + player = spaz.player + player.gamedata['touching_own_flag'] = 0 + + # Ignore false alarm for gamedata member. + no_physical_mats = [ + player.team.gamedata['spaz_material_no_flag_physical'] + ] + no_collide_mats = [ + player.team.gamedata['spaz_material_no_flag_collide'] + ] + # pylint: enable=arguments-differ + + # Our normal parts should still collide; just not physically + # (so we can calc restores). + assert spaz.node + spaz.node.materials = list(spaz.node.materials) + no_physical_mats + spaz.node.roller_materials = list( + spaz.node.roller_materials) + no_physical_mats + + # Pickups and punches shouldn't hit at all though. + spaz.node.punch_materials = list( + spaz.node.punch_materials) + no_collide_mats + spaz.node.pickup_materials = list( + spaz.node.pickup_materials) + no_collide_mats + spaz.node.extras_material = list( + spaz.node.extras_material) + no_collide_mats + return spaz + + def _update_scoreboard(self) -> None: + for team in self.teams: + self._scoreboard.set_team_value(team, team.gamedata['score'], + self.settings['Score to Win']) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + # Augment standard behavior. + super().handlemessage(msg) + self.respawn_player(msg.spaz.player) + elif isinstance(msg, stdflag.FlagDeathMessage): + assert isinstance(msg.flag, CTFFlag) + ba.timer(0.1, + ba.Call(self._spawn_flag_for_team, msg.flag.get_team())) + elif isinstance(msg, stdflag.FlagPickedUpMessage): + # Store the last player to hold the flag for scoring purposes. + assert isinstance(msg.flag, CTFFlag) + msg.flag.last_player_to_hold = msg.node.getdelegate().getplayer() + msg.flag.held_count += 1 + msg.flag.reset_return_times() + elif isinstance(msg, stdflag.FlagDroppedMessage): + # Store the last player to hold the flag for scoring purposes. + assert isinstance(msg.flag, CTFFlag) + msg.flag.held_count -= 1 + else: + super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/game/chosenone.py b/assets/src/data/scripts/bastd/game/chosenone.py new file mode 100644 index 00000000..835707f7 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/chosenone.py @@ -0,0 +1,319 @@ +"""Provides the chosen-one mini-game.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.actor import flag +from bastd.actor import playerspaz +from bastd.actor import spaz + +if TYPE_CHECKING: + from typing import (Any, Type, List, Dict, Tuple, Optional, Sequence, + Union) + + +# bs_meta export game +class ChosenOneGame(ba.TeamGameActivity): + """ + Game involving trying to remain the one 'chosen one' + for a set length of time while everyone else tries to + kill you and become the chosen one themselves. + """ + + @classmethod + def get_name(cls) -> str: + return 'Chosen One' + + @classmethod + def get_score_info(cls) -> Dict[str, Any]: + return {'score_name': 'Time Held'} + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return ('Be the chosen one for a length of time to win.\n' + 'Kill the chosen one to become it.') + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ba.getmaps('keep_away') + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + return [("Chosen One Time", { + 'min_value': 10, + 'default': 30, + 'increment': 10 + }), ("Chosen One Gets Gloves", { + 'default': True + }), ("Chosen One Gets Shield", { + 'default': False + }), + ("Time Limit", { + 'choices': [('None', 0), ('1 Minute', 60), + ('2 Minutes', 120), ('5 Minutes', 300), + ('10 Minutes', 600), ('20 Minutes', 1200)], + 'default': 0 + }), + ("Respawn Times", { + 'choices': [('Shorter', 0.25), ('Short', 0.5), + ('Normal', 1.0), ('Long', 2.0), + ('Longer', 4.0)], + 'default': 1.0 + }), ("Epic Mode", { + 'default': False + })] + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + super().__init__(settings) + if self.settings['Epic Mode']: + self.slow_motion = True + self._scoreboard = Scoreboard() + self._chosen_one_player: Optional[ba.Player] = None + self._swipsound = ba.getsound("swip") + self._countdownsounds: Dict[int, ba.Sound] = { + 10: ba.getsound('announceTen'), + 9: ba.getsound('announceNine'), + 8: ba.getsound('announceEight'), + 7: ba.getsound('announceSeven'), + 6: ba.getsound('announceSix'), + 5: ba.getsound('announceFive'), + 4: ba.getsound('announceFour'), + 3: ba.getsound('announceThree'), + 2: ba.getsound('announceTwo'), + 1: ba.getsound('announceOne') + } + self._flag_spawn_pos: Optional[Sequence[float]] = None + self._reset_region_material: Optional[ba.Material] = None + self._flag: Optional[flag.Flag] = None + self._reset_region: Optional[ba.Node] = None + + def get_instance_description(self) -> Union[str, Sequence]: + return 'There can be only one.' + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: unify these args. + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in( + self, music='Epic' if self.settings['Epic Mode'] else 'Chosen One') + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['time_remaining'] = self.settings["Chosen One Time"] + self._update_scoreboard() + + def on_player_leave(self, player: ba.Player) -> None: + ba.TeamGameActivity.on_player_leave(self, player) + if self._get_chosen_one_player() is player: + self._set_chosen_one_player(None) + + def on_begin(self) -> None: + ba.TeamGameActivity.on_begin(self) + self.setup_standard_time_limit(self.settings['Time Limit']) + self.setup_standard_powerup_drops() + self._flag_spawn_pos = self.map.get_flag_position(None) + self.project_flag_stand(self._flag_spawn_pos) + self._set_chosen_one_player(None) + + pos = self._flag_spawn_pos + ba.timer(1.0, call=self._tick, repeat=True) + + mat = self._reset_region_material = ba.Material() + mat.add_actions(conditions=('they_have_material', + ba.sharedobj('player_material')), + actions=(('modify_part_collision', 'collide', True), + ('modify_part_collision', 'physical', False), + ('call', 'at_connect', + ba.WeakCall(self._handle_reset_collide)))) + + self._reset_region = ba.newnode('region', + attrs={ + 'position': (pos[0], pos[1] + 0.75, + pos[2]), + 'scale': (0.5, 0.5, 0.5), + 'type': 'sphere', + 'materials': [mat] + }) + + def _get_chosen_one_player(self) -> Optional[ba.Player]: + if self._chosen_one_player: + return self._chosen_one_player + return None + + def _handle_reset_collide(self) -> None: + # If we have a chosen one, ignore these. + if self._get_chosen_one_player() is not None: + return + try: + player = (ba.get_collision_info( + "opposing_node").getdelegate().getplayer()) + except Exception: + return + if player is not None and player.is_alive(): + self._set_chosen_one_player(player) + + def _flash_flag_spawn(self) -> None: + light = ba.newnode('light', + attrs={ + 'position': self._flag_spawn_pos, + 'color': (1, 1, 1), + 'radius': 0.3, + 'height_attenuated': False + }) + ba.animate(light, "intensity", {0: 0, 0.25: 0.5, 0.5: 0}, loop=True) + ba.timer(1.0, light.delete) + + def _tick(self) -> None: + + # Give the chosen one points. + player = self._get_chosen_one_player() + if player is not None: + + # This shouldn't happen, but just in case. + if not player.is_alive(): + ba.print_error('got dead player as chosen one in _tick') + self._set_chosen_one_player(None) + else: + scoring_team = player.team + assert self.stats + self.stats.player_scored(player, + 3, + screenmessage=False, + display=False) + + scoring_team.gamedata['time_remaining'] = max( + 0, scoring_team.gamedata['time_remaining'] - 1) + + # show the count over their head + try: + if scoring_team.gamedata['time_remaining'] > 0: + if isinstance(player.actor, spaz.Spaz): + player.actor.set_score_text( + str(scoring_team.gamedata['time_remaining'])) + except Exception: + pass + + self._update_scoreboard() + + # announce numbers we have sounds for + try: + ba.playsound(self._countdownsounds[ + scoring_team.gamedata['time_remaining']]) + except Exception: + pass + + # Winner! + if scoring_team.gamedata['time_remaining'] <= 0: + self.end_game() + + else: + # (player is None) + # This shouldn't happen, but just in case. + # (Chosen-one player ceasing to exist should + # trigger on_player_leave which resets chosen-one) + if self._chosen_one_player is not None: + ba.print_error('got nonexistent player as chosen one in _tick') + self._set_chosen_one_player(None) + + def end_game(self) -> None: + results = ba.TeamGameResults() + for team in self.teams: + results.set_team_score( + team, self.settings['Chosen One Time'] - + team.gamedata['time_remaining']) + self.end(results=results, announce_delay=0) + + def _set_chosen_one_player(self, player: Optional[ba.Player]) -> None: + try: + for p_other in self.players: + p_other.gamedata['chosen_light'] = None + ba.playsound(self._swipsound) + if not player: + assert self._flag_spawn_pos is not None + self._flag = flag.Flag(color=(1, 0.9, 0.2), + position=self._flag_spawn_pos, + touchable=False) + self._chosen_one_player = None + + # Create a light to highlight the flag; + # this will go away when the flag dies. + ba.newnode('light', + owner=self._flag.node, + attrs={ + 'position': self._flag_spawn_pos, + 'intensity': 0.6, + 'height_attenuated': False, + 'volume_intensity_scale': 0.1, + 'radius': 0.1, + 'color': (1.2, 1.2, 0.4) + }) + + # Also an extra momentary flash. + self._flash_flag_spawn() + else: + if player.actor is not None: + self._flag = None + self._chosen_one_player = player + + if player.actor.node: + if self.settings['Chosen One Gets Shield']: + player.actor.handlemessage( + ba.PowerupMessage('shield')) + if self.settings['Chosen One Gets Gloves']: + player.actor.handlemessage( + ba.PowerupMessage('punch')) + + # Use a color that's partway between their team color + # and white. + color = [ + 0.3 + c * 0.7 + for c in ba.normalized_color(player.team.color) + ] + light = player.gamedata['chosen_light'] = ba.Actor( + ba.newnode('light', + attrs={ + "intensity": 0.6, + "height_attenuated": False, + "volume_intensity_scale": 0.1, + "radius": 0.13, + "color": color + })) + + assert light.node + ba.animate(light.node, + 'intensity', { + 0: 1.0, + 0.2: 0.4, + 0.4: 1.0 + }, + loop=True) + player.actor.node.connectattr('position', light.node, + 'position') + except Exception: + ba.print_exception('EXC in _set_chosen_one_player') + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + # Augment standard behavior. + super().handlemessage(msg) + player = msg.spaz.player + if player is self._get_chosen_one_player(): + killerplayer = msg.killerplayer + self._set_chosen_one_player(None if ( + killerplayer is None or killerplayer is player + or not killerplayer.is_alive()) else killerplayer) + self.respawn_player(player) + else: + super().handlemessage(msg) + + def _update_scoreboard(self) -> None: + for team in self.teams: + self._scoreboard.set_team_value(team, + team.gamedata['time_remaining'], + self.settings['Chosen One Time'], + countdown=True) diff --git a/assets/src/data/scripts/bastd/game/conquest.py b/assets/src/data/scripts/bastd/game/conquest.py new file mode 100644 index 00000000..c038e239 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/conquest.py @@ -0,0 +1,284 @@ +"""Provides the Conquest game.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor.flag import Flag +from bastd.actor.playerspaz import PlayerSpazDeathMessage + +if TYPE_CHECKING: + from typing import (Any, Optional, Type, List, Tuple, Dict, Sequence, + Union) + + +class ConquestFlag(Flag): + """A custom flag for use with Conquest games.""" + + def __init__(self, *args: Any, **keywds: Any): + super().__init__(*args, **keywds) + self._team: Optional[ba.Team] = None + self.light: Optional[ba.Node] = None + + def set_team(self, team: ba.Team) -> None: + """Set the team that owns this flag.""" + self._team = None if team is None else team + + @property + def team(self) -> ba.Team: + """The team that owns this flag.""" + assert self._team is not None + return self._team + + +# bs_meta export game +class ConquestGame(ba.TeamGameActivity): + """A game where teams try to claim all flags on the map.""" + + @classmethod + def get_name(cls) -> str: + return 'Conquest' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Secure all flags on the map to win.' + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + return issubclass(sessiontype, ba.TeamsSession) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ba.getmaps("conquest") + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + return [ + ("Time Limit", { + 'choices': [('None', 0), ('1 Minute', 60), + ('2 Minutes', 120), + ('5 Minutes', 300), + ('10 Minutes', 600), + ('20 Minutes', 1200)], + 'default': 0 + }), + ('Respawn Times', { + 'choices': [('Shorter', 0.25), + ('Short', 0.5), + ('Normal', 1.0), + ('Long', 2.0), + ('Longer', 4.0)], + 'default': 1.0 + }), + ('Epic Mode', {'default': False})] # yapf: disable + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + super().__init__(settings) + if self.settings['Epic Mode']: + self.slow_motion = True + self._scoreboard = Scoreboard() + self._score_sound = ba.getsound('score') + self._swipsound = ba.getsound('swip') + self._extraflagmat = ba.Material() + self._flags: List[ConquestFlag] = [] + + # We want flags to tell us they've been hit but not react physically. + self._extraflagmat.add_actions( + conditions=('they_have_material', ba.sharedobj('player_material')), + actions=(('modify_part_collision', 'collide', True), + ('call', 'at_connect', self._handle_flag_player_collide))) + + def get_instance_description(self) -> Union[str, Sequence]: + return 'Secure all ${ARG1} flags.', len(self.map.flag_points) + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + return 'secure all ${ARG1} flags', len(self.map.flag_points) + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME unify these args + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in( + self, music='Epic' if self.settings['Epic Mode'] else 'GrandRomp') + + def on_team_join(self, team: ba.Team) -> None: + if self.has_begun(): + self._update_scores() + team.gamedata['flags_held'] = 0 + + def on_player_join(self, player: ba.Player) -> None: + player.gamedata['respawn_timer'] = None + + # Only spawn if this player's team has a flag currently. + if player.team.gamedata['flags_held'] > 0: + self.spawn_player(player) + + def on_begin(self) -> None: + ba.TeamGameActivity.on_begin(self) + self.setup_standard_time_limit(self.settings['Time Limit']) + self.setup_standard_powerup_drops() + + # Set up flags with marker lights. + for i in range(len(self.map.flag_points)): + point = self.map.flag_points[i] + flag = ConquestFlag(position=point, + touchable=False, + materials=[self._extraflagmat]) + self._flags.append(flag) + # FIXME: Move next few lines to the flag class. + self.project_flag_stand(point) + flag.light = ba.newnode('light', + owner=flag.node, + attrs={ + 'position': point, + 'intensity': 0.25, + 'height_attenuated': False, + 'radius': 0.3, + 'color': (1, 1, 1) + }) + + # Give teams a flag to start with. + for i in range(len(self.teams)): + self._flags[i].set_team(self.teams[i]) + light = self._flags[i].light + assert light + node = self._flags[i].node + assert node + light.color = self.teams[i].color + node.color = self.teams[i].color + + self._update_scores() + + # Initial joiners didn't spawn due to no flags being owned yet; + # spawn them now. + for player in self.players: + self.spawn_player(player) + + def _update_scores(self) -> None: + for team in self.teams: + team.gamedata['flags_held'] = 0 + for flag in self._flags: + try: + flag.team.gamedata['flags_held'] += 1 + except Exception: + pass + for team in self.teams: + + # If a team finds themselves with no flags, cancel all + # outstanding spawn-timers. + if team.gamedata['flags_held'] == 0: + for player in team.players: + player.gamedata['respawn_timer'] = None + player.gamedata['respawn_icon'] = None + if team.gamedata['flags_held'] == len(self._flags): + self.end_game() + self._scoreboard.set_team_value(team, team.gamedata['flags_held'], + len(self._flags)) + + def end_game(self) -> None: + results = ba.TeamGameResults() + for team in self.teams: + results.set_team_score(team, team.gamedata['flags_held']) + self.end(results=results) + + def _flash_flag(self, flag: ConquestFlag, length: float = 1.0) -> None: + assert flag.node + assert flag.light + light = ba.newnode('light', + attrs={ + 'position': flag.node.position, + 'height_attenuated': False, + 'color': flag.light.color + }) + ba.animate(light, "intensity", {0: 0, 0.25: 1, 0.5: 0}, loop=True) + ba.timer(length, light.delete) + + def _handle_flag_player_collide(self) -> None: + flagnode, playernode = ba.get_collision_info("source_node", + "opposing_node") + try: + player = playernode.getdelegate().getplayer() + flag = flagnode.getdelegate() + except Exception: + return # Player may have left and his body hit the flag. + + if flag.get_team() is not player.get_team(): + flag.set_team(player.get_team()) + flag.light.color = player.get_team().color + flag.node.color = player.get_team().color + self.stats.player_scored(player, 10, screenmessage=False) + ba.playsound(self._swipsound) + self._flash_flag(flag) + self._update_scores() + + # Respawn any players on this team that were in limbo due to the + # lack of a flag for their team. + for otherplayer in self.players: + if (otherplayer.team is flag.team + and otherplayer.actor is not None + and not otherplayer.is_alive() + and otherplayer.gamedata['respawn_timer'] is None): + self.spawn_player(otherplayer) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, PlayerSpazDeathMessage): + # Augment standard behavior. + super().handlemessage(msg) + + # Respawn only if this team has a flag. + player = msg.spaz.player + if player.team.gamedata['flags_held'] > 0: + self.respawn_player(player) + else: + player.gamedata['respawn_timer'] = None + + else: + super().handlemessage(msg) + + def spawn_player(self, player: ba.Player) -> ba.Actor: + # We spawn players at different places based on what flags are held. + return self.spawn_player_spaz(player, + self._get_player_spawn_position(player)) + + def _get_player_spawn_position(self, player: ba.Player) -> Sequence[float]: + + # Iterate until we find a spawn owned by this team. + spawn_count = len(self.map.spawn_by_flag_points) + + # Get all spawns owned by this team. + spawns = [ + i for i in range(spawn_count) if self._flags[i].team is player.team + ] + + closest_spawn = 0 + closest_distance = 9999.0 + + # Now find the spawn that's closest to a spawn not owned by us; + # we'll use that one. + for spawn in spawns: + spt = self.map.spawn_by_flag_points[spawn] + our_pt = ba.Vec3(spt[0], spt[1], spt[2]) + for otherspawn in [ + i for i in range(spawn_count) + if self._flags[i].team is not player.team + ]: + spt = self.map.spawn_by_flag_points[otherspawn] + their_pt = ba.Vec3(spt[0], spt[1], spt[2]) + dist = (their_pt - our_pt).length() + if dist < closest_distance: + closest_distance = dist + closest_spawn = spawn + + pos = self.map.spawn_by_flag_points[closest_spawn] + x_range = (-0.5, 0.5) if pos[3] == 0.0 else (-pos[3], pos[3]) + z_range = (-0.5, 0.5) if pos[5] == 0.0 else (-pos[5], pos[5]) + pos = (pos[0] + random.uniform(*x_range), pos[1], + pos[2] + random.uniform(*z_range)) + return pos diff --git a/assets/src/data/scripts/bastd/game/deathmatch.py b/assets/src/data/scripts/bastd/game/deathmatch.py new file mode 100644 index 00000000..9de103d2 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/deathmatch.py @@ -0,0 +1,189 @@ +"""DeathMatch game and support classes.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.actor import playerspaz +from bastd.actor import spaz as stdspaz + +if TYPE_CHECKING: + from typing import Any, Type, List, Dict, Tuple, Union, Sequence + + +# bs_meta export game +class DeathMatchGame(ba.TeamGameActivity): + """A game type based on acquiring kills.""" + + @classmethod + def get_name(cls) -> str: + return 'Death Match' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Kill a set number of enemies to win.' + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + return (issubclass(sessiontype, ba.TeamsSession) + or issubclass(sessiontype, ba.FreeForAllSession)) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ba.getmaps("melee") + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + settings: List[Tuple[str, Dict[str, Any]]] = [ + ("Kills to Win Per Player", { + 'min_value': 1, + 'default': 5, + 'increment': 1 + }), + ("Time Limit", { + 'choices': + [('None', 0), + ('1 Minute', 60), ('2 Minutes', 120), + ('5 Minutes', 300), ('10 Minutes', 600), + ('20 Minutes', 1200)], + 'default': 0 + }), + ("Respawn Times", { + 'choices': + [('Shorter', 0.25), ('Short', 0.5), + ('Normal', 1.0), ('Long', 2.0), + ('Longer', 4.0)], + 'default': 1.0 + }), + ("Epic Mode", { + 'default': False + }) + ] # yapf: disable + + # In teams mode, a suicide gives a point to the other team, but in + # free-for-all it subtracts from your own score. By default we clamp + # this at zero to benefit new players, but pro players might like to + # be able to go negative. (to avoid a strategy of just + # suiciding until you get a good drop) + if issubclass(sessiontype, ba.FreeForAllSession): + settings.append(("Allow Negative Scores", {'default': False})) + + return settings + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + super().__init__(settings) + if self.settings['Epic Mode']: + self.slow_motion = True + + # Print messages when players die since it matters here. + self.announce_player_deaths = True + + self._scoreboard = Scoreboard() + self._score_to_win = None + self._dingsound = ba.getsound('dingSmall') + + def get_instance_description(self) -> Union[str, Sequence]: + return 'Crush ${ARG1} of your enemies.', self._score_to_win + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + return 'kill ${ARG1} enemies', self._score_to_win + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME need to unify these function signatures + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in( + self, music='Epic' if self.settings['Epic Mode'] else 'ToTheDeath') + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['score'] = 0 + if self.has_begun(): + self._update_scoreboard() + + def on_begin(self) -> None: + ba.TeamGameActivity.on_begin(self) + self.setup_standard_time_limit(self.settings['Time Limit']) + self.setup_standard_powerup_drops() + if self.teams: + self._score_to_win = ( + self.settings['Kills to Win Per Player'] * + max(1, max(len(t.players) for t in self.teams))) + else: + self._score_to_win = self.settings['Kills to Win Per Player'] + self._update_scoreboard() + + def handlemessage(self, msg: Any) -> Any: + # pylint: disable=too-many-branches + + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + + # Augment standard behavior. + super().handlemessage(msg) + + player = msg.spaz.player + self.respawn_player(player) + + killer = msg.killerplayer + if killer is None: + return + + # Handle team-kills. + if killer.team is player.team: + + # In free-for-all, killing yourself loses you a point. + if isinstance(self.session, ba.FreeForAllSession): + new_score = player.team.gamedata['score'] - 1 + if not self.settings['Allow Negative Scores']: + new_score = max(0, new_score) + player.team.gamedata['score'] = new_score + + # In teams-mode it gives a point to the other team. + else: + ba.playsound(self._dingsound) + for team in self.teams: + if team is not killer.team: + team.gamedata['score'] += 1 + + # Killing someone on another team nets a kill. + else: + killer.team.gamedata['score'] += 1 + ba.playsound(self._dingsound) + + # In FFA show scores since its hard to find on the scoreboard. + try: + if isinstance(killer.actor, stdspaz.Spaz): + killer.actor.set_score_text( + str(killer.team.gamedata['score']) + '/' + + str(self._score_to_win), + color=killer.team.color, + flash=True) + except Exception: + pass + + self._update_scoreboard() + + # If someone has won, set a timer to end shortly. + # (allows the dust to clear and draws to occur if deaths are + # close enough) + if any(team.gamedata['score'] >= self._score_to_win + for team in self.teams): + ba.timer(0.5, self.end_game) + + else: + super().handlemessage(msg) + + def _update_scoreboard(self) -> None: + for team in self.teams: + self._scoreboard.set_team_value(team, team.gamedata['score'], + self._score_to_win) + + def end_game(self) -> None: + results = ba.TeamGameResults() + for team in self.teams: + results.set_team_score(team, team.gamedata['score']) + self.end(results=results) diff --git a/assets/src/data/scripts/bastd/game/easteregghunt.py b/assets/src/data/scripts/bastd/game/easteregghunt.py new file mode 100644 index 00000000..aa5cdd25 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/easteregghunt.py @@ -0,0 +1,279 @@ +"""Provides an easter egg hunt game.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor import bomb +from bastd.actor import playerspaz +from bastd.actor import spazbot +from bastd.actor.onscreencountdown import OnScreenCountdown + +if TYPE_CHECKING: + from typing import Any, Type, Dict, List, Tuple, Optional + + +# bs_meta export game +class EasterEggHuntGame(ba.TeamGameActivity): + """A game where score is based on collecting eggs""" + + @classmethod + def get_name(cls) -> str: + return 'Easter Egg Hunt' + + @classmethod + def get_score_info(cls) -> Dict[str, Any]: + return {'score_name': 'Score', 'score_type': 'points'} + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Gather eggs!' + + # We're currently hard-coded for one map. + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ['Tower D'] + + # We support teams, free-for-all, and co-op sessions. + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + return (issubclass(sessiontype, ba.CoopSession) + or issubclass(sessiontype, ba.TeamsSession) + or issubclass(sessiontype, ba.FreeForAllSession)) + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + return [("Pro Mode", {'default': False})] + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + super().__init__(settings) + self._last_player_death_time = None + self._scoreboard = Scoreboard() + self.egg_model = ba.getmodel('egg') + self.egg_tex_1 = ba.gettexture('eggTex1') + self.egg_tex_2 = ba.gettexture('eggTex2') + self.egg_tex_3 = ba.gettexture('eggTex3') + self._collect_sound = ba.getsound('powerup01') + self._pro_mode = settings.get('Pro Mode', False) + self._max_eggs = 1.0 + self.egg_material = ba.Material() + self.egg_material.add_actions( + conditions=("they_have_material", ba.sharedobj('player_material')), + actions=(("call", "at_connect", self._on_egg_player_collide), )) + self._eggs: List[Egg] = [] + self._update_timer: Optional[ba.Timer] = None + self._countdown: Optional[OnScreenCountdown] = None + self._bots: Optional[spazbot.BotSet] = None + + # Called when our game is transitioning in but not ready to start. + # ..we can go ahead and set our music and whatnot. + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify these arguments. + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in(self, music='ForwardMarch') + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['score'] = 0 + if self.has_begun(): + self._update_scoreboard() + + # Called when our game actually starts. + def on_begin(self) -> None: + from bastd.maps import TowerD + + # There's a player-wall on the tower-d level to prevent + # players from getting up on the stairs.. we wanna kill that. + gamemap = self.map + assert isinstance(gamemap, TowerD) + gamemap.player_wall.delete() + ba.TeamGameActivity.on_begin(self) + self._update_scoreboard() + self._update_timer = ba.Timer(0.25, self._update, repeat=True) + self._countdown = OnScreenCountdown(60, endcall=self.end_game) + ba.timer(4.0, self._countdown.start) + self._bots = spazbot.BotSet() + + # Spawn evil bunny in co-op only. + if isinstance(self.session, ba.CoopSession) and self._pro_mode: + self._spawn_evil_bunny() + + # Overriding the default character spawning. + def spawn_player(self, player: ba.Player) -> ba.Actor: + spaz = self.spawn_player_spaz(player) + spaz.connect_controls_to_player() + return spaz + + def _spawn_evil_bunny(self) -> None: + assert self._bots is not None + self._bots.spawn_bot(spazbot.BouncyBot, + pos=(6, 4, -7.8), + spawn_time=10.0) + + def _on_egg_player_collide(self) -> None: + if not self.has_ended(): + egg_node, playernode = ba.get_collision_info( + 'source_node', 'opposing_node') + if egg_node is not None and playernode is not None: + egg = egg_node.getdelegate() + spaz = playernode.getdelegate() + player = (spaz.getplayer() + if hasattr(spaz, 'getplayer') else None) + if player and egg: + player.get_team().gamedata['score'] += 1 + + # Displays a +1 (and adds to individual player score in + # teams mode). + self.stats.player_scored(player, 1, screenmessage=False) + if self._max_eggs < 5: + self._max_eggs += 1.0 + elif self._max_eggs < 10: + self._max_eggs += 0.5 + elif self._max_eggs < 30: + self._max_eggs += 0.3 + self._update_scoreboard() + ba.playsound(self._collect_sound, + 0.5, + position=egg.node.position) + + # Create a flash. + light = ba.newnode('light', + attrs={ + 'position': egg_node.position, + 'height_attenuated': False, + 'radius': 0.1, + 'color': (1, 1, 0) + }) + ba.animate(light, + 'intensity', { + 0: 0, + 0.1: 1.0, + 0.2: 0 + }, + loop=False) + ba.timer(0.200, light.delete) + egg.handlemessage(ba.DieMessage()) + + def _update(self) -> None: + # Misc. periodic updating. + xpos = random.uniform(-7.1, 6.0) + ypos = random.uniform(3.5, 3.5) + zpos = random.uniform(-8.2, 3.7) + + # Prune dead eggs from our list. + self._eggs = [e for e in self._eggs if e] + + # Spawn more eggs if we've got space. + if len(self._eggs) < int(self._max_eggs): + + # Occasionally spawn a land-mine in addition. + if self._pro_mode and random.random() < 0.25: + mine = bomb.Bomb(position=(xpos, ypos, zpos), + bomb_type='land_mine').autoretain() + mine.arm() + else: + self._eggs.append(Egg(position=(xpos, ypos, zpos))) + + # Various high-level game events come through this method. + def handlemessage(self, msg: Any) -> Any: + + # Respawn dead players. + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + from bastd.actor import respawnicon + + # Augment standard behavior. + super().handlemessage(msg) + player = msg.spaz.getplayer() + if not player: + return + self.stats.player_lost_spaz(player) + + # Respawn them shortly. + assert self.initial_player_info is not None + respawn_time = 2.0 + len(self.initial_player_info) * 1.0 + player.gamedata['respawn_timer'] = ba.Timer( + respawn_time, ba.Call(self.spawn_player_if_exists, player)) + player.gamedata['respawn_icon'] = respawnicon.RespawnIcon( + player, respawn_time) + + # Whenever our evil bunny dies, respawn him and spew some eggs. + elif isinstance(msg, spazbot.SpazBotDeathMessage): + self._spawn_evil_bunny() + assert msg.badguy.node + pos = msg.badguy.node.position + for _i in range(6): + spread = 0.4 + self._eggs.append( + Egg(position=(pos[0] + random.uniform(-spread, spread), + pos[1] + random.uniform(-spread, spread), + pos[2] + random.uniform(-spread, spread)))) + else: + + # Default handler. + super().handlemessage(msg) + + def _update_scoreboard(self) -> None: + for team in self.teams: + self._scoreboard.set_team_value(team, team.gamedata['score']) + + def end_game(self) -> None: + results = ba.TeamGameResults() + for team in self.teams: + results.set_team_score(team, team.gamedata['score']) + self.end(results) + + +class Egg(ba.Actor): + """A lovely egg that can be picked up for points.""" + + def __init__(self, position: Tuple[float, float, float] = (0.0, 1.0, 0.0)): + super().__init__() + activity = self.activity + assert isinstance(activity, EasterEggHuntGame) + + # Spawn just above the provided point. + self._spawn_pos = (position[0], position[1] + 1.0, position[2]) + ctex = (activity.egg_tex_1, activity.egg_tex_2, + activity.egg_tex_3)[random.randrange(3)] + mats = [ba.sharedobj('object_material'), activity.egg_material] + self.node = ba.newnode("prop", + delegate=self, + attrs={ + 'model': activity.egg_model, + 'color_texture': ctex, + 'body': 'capsule', + 'reflection': 'soft', + 'model_scale': 0.5, + 'bodyScale': 0.6, + 'density': 4.0, + 'reflection_scale': [0.15], + 'shadow_size': 0.6, + 'position': self._spawn_pos, + 'materials': mats + }) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, ba.DieMessage): + if self.node: + self.node.delete() + elif isinstance(msg, ba.OutOfBoundsMessage): + self.handlemessage(ba.DieMessage()) + elif isinstance(msg, ba.HitMessage): + if self.node: + assert msg.force_direction is not None + self.node.handlemessage( + "impulse", msg.pos[0], msg.pos[1], msg.pos[2], + msg.velocity[0], msg.velocity[1], msg.velocity[2], + 1.0 * msg.magnitude, 1.0 * msg.velocity_magnitude, + msg.radius, 0, msg.force_direction[0], + msg.force_direction[1], msg.force_direction[2]) + else: + super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/game/elimination.py b/assets/src/data/scripts/bastd/game/elimination.py new file mode 100644 index 00000000..3fcaee7f --- /dev/null +++ b/assets/src/data/scripts/bastd/game/elimination.py @@ -0,0 +1,551 @@ +"""Elimination mini-game.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.actor import playerspaz +from bastd.actor import spaz + +if TYPE_CHECKING: + from typing import (Any, Tuple, Dict, Type, List, Sequence, Optional, + Union) + + +class Icon(ba.Actor): + """Creates in in-game icon on screen.""" + + def __init__(self, + player: ba.Player, + position: Tuple[float, float], + scale: float, + show_lives: bool = True, + show_death: bool = True, + name_scale: float = 1.0, + name_maxwidth: float = 115.0, + flatness: float = 1.0, + shadow: float = 1.0): + super().__init__() + + self._player = player + self._show_lives = show_lives + self._show_death = show_death + self._name_scale = name_scale + self._outline_tex = ba.gettexture('characterIconMask') + + icon = player.get_icon() + self.node = ba.newnode('image', + owner=self, + attrs={ + 'texture': icon['texture'], + 'tint_texture': icon['tint_texture'], + 'tint_color': icon['tint_color'], + 'vr_depth': 400, + 'tint2_color': icon['tint2_color'], + 'mask_texture': self._outline_tex, + 'opacity': 1.0, + 'absolute_scale': True, + 'attach': 'bottomCenter' + }) + self._name_text = ba.newnode( + 'text', + owner=self.node, + attrs={ + 'text': ba.Lstr(value=player.get_name()), + 'color': ba.safecolor(player.team.color), + 'h_align': 'center', + 'v_align': 'center', + 'vr_depth': 410, + 'maxwidth': name_maxwidth, + 'shadow': shadow, + 'flatness': flatness, + 'h_attach': 'center', + 'v_attach': 'bottom' + }) + if self._show_lives: + self._lives_text = ba.newnode('text', + owner=self.node, + attrs={ + 'text': 'x0', + 'color': (1, 1, 0.5), + 'h_align': 'left', + 'vr_depth': 430, + 'shadow': 1.0, + 'flatness': 1.0, + 'h_attach': 'center', + 'v_attach': 'bottom' + }) + self.set_position_and_scale(position, scale) + + def set_position_and_scale(self, position: Tuple[float, float], + scale: float) -> None: + """(Re)position the icon.""" + assert self.node + self.node.position = position + self.node.scale = [70.0 * scale] + self._name_text.position = (position[0], position[1] + scale * 52.0) + self._name_text.scale = 1.0 * scale * self._name_scale + if self._show_lives: + self._lives_text.position = (position[0] + scale * 10.0, + position[1] - scale * 43.0) + self._lives_text.scale = 1.0 * scale + + def update_for_lives(self) -> None: + """Update for the target player's current lives.""" + if self._player: + lives = self._player.gamedata['lives'] + else: + lives = 0 + if self._show_lives: + if lives > 0: + self._lives_text.text = 'x' + str(lives - 1) + else: + self._lives_text.text = '' + if lives == 0: + self._name_text.opacity = 0.2 + assert self.node + self.node.color = (0.7, 0.3, 0.3) + self.node.opacity = 0.2 + + def handle_player_spawned(self) -> None: + """Our player spawned; hooray!""" + if not self.node: + return + self.node.opacity = 1.0 + self.update_for_lives() + + def handle_player_died(self) -> None: + """Well poo; our player died.""" + if not self.node: + return + if self._show_death: + ba.animate( + self.node, 'opacity', { + 0.00: 1.0, + 0.05: 0.0, + 0.10: 1.0, + 0.15: 0.0, + 0.20: 1.0, + 0.25: 0.0, + 0.30: 1.0, + 0.35: 0.0, + 0.40: 1.0, + 0.45: 0.0, + 0.50: 1.0, + 0.55: 0.2 + }) + lives = self._player.gamedata['lives'] + if lives == 0: + ba.timer(0.6, self.update_for_lives) + + +# bs_meta export game +class EliminationGame(ba.TeamGameActivity): + """Game type where last player(s) left alive win.""" + + @classmethod + def get_name(cls) -> str: + return 'Elimination' + + @classmethod + def get_score_info(cls) -> Dict[str, Any]: + return { + 'score_name': 'Survived', + 'score_type': 'seconds', + 'none_is_winner': True + } + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Last remaining alive wins.' + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + return (issubclass(sessiontype, ba.TeamsSession) + or issubclass(sessiontype, ba.FreeForAllSession)) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ba.getmaps("melee") + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + settings: List[Tuple[str, Dict[str, Any]]] = [ + ("Lives Per Player", { + 'default': 1, 'min_value': 1, + 'max_value': 10, 'increment': 1 + }), + ("Time Limit", { + 'choices': [('None', 0), ('1 Minute', 60), + ('2 Minutes', 120), ('5 Minutes', 300), + ('10 Minutes', 600), ('20 Minutes', 1200)], + 'default': 0 + }), + ("Respawn Times", { + 'choices': [('Shorter', 0.25), ('Short', 0.5), ('Normal', 1.0), + ('Long', 2.0), ('Longer', 4.0)], + 'default': 1.0 + }), + ("Epic Mode", {'default': False})] # yapf: disable + + if issubclass(sessiontype, ba.TeamsSession): + settings.append(("Solo Mode", {'default': False})) + settings.append(("Balance Total Lives", {'default': False})) + + return settings + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + super().__init__(settings) + if self.settings['Epic Mode']: + self.slow_motion = True + + # Show messages when players die since it's meaningful here. + self.announce_player_deaths = True + + self._solo_mode = settings.get('Solo Mode', False) + self._scoreboard = Scoreboard() + self._start_time: Optional[float] = None + self._vs_text: Optional[ba.Actor] = None + self._round_end_timer: Optional[ba.Timer] = None + + def get_instance_description(self) -> Union[str, Sequence]: + return 'Last team standing wins.' if isinstance( + self.session, ba.TeamsSession) else 'Last one standing wins.' + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + return 'last team standing wins' if isinstance( + self.session, ba.TeamsSession) else 'last one standing wins' + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: need to give on_transition_in() consistent args everywhere. + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in( + self, music='Epic' if self.settings['Epic Mode'] else 'Survival') + self._start_time = ba.time() + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['survival_seconds'] = None + team.gamedata['spawn_order'] = [] + + def on_player_join(self, player: ba.Player) -> None: + + # No longer allowing mid-game joiners here; too easy to exploit. + if self.has_begun(): + player.gamedata['lives'] = 0 + player.gamedata['icons'] = [] + + # Make sure our team has survival seconds set if they're all dead + # (otherwise blocked new ffa players would be considered 'still + # alive' in score tallying). + if self._get_total_team_lives( + player.team + ) == 0 and player.team.gamedata['survival_seconds'] is None: + player.team.gamedata['survival_seconds'] = 0 + ba.screenmessage(ba.Lstr(resource='playerDelayedJoinText', + subs=[('${PLAYER}', + player.get_name(full=True))]), + color=(0, 1, 0)) + return + + player.gamedata['lives'] = self.settings['Lives Per Player'] + + if self._solo_mode: + player.gamedata['icons'] = [] + player.team.gamedata['spawn_order'].append(player) + self._update_solo_mode() + else: + # Create our icon and spawn. + player.gamedata['icons'] = [ + Icon(player, position=(0, 50), scale=0.8) + ] + if player.gamedata['lives'] > 0: + self.spawn_player(player) + + # Don't waste time doing this until begin. + if self.has_begun(): + self._update_icons() + + def _update_solo_mode(self) -> None: + # For both teams, find the first player on the spawn order list with + # lives remaining and spawn them if they're not alive. + for team in self.teams: + # Prune dead players from the spawn order. + team.gamedata['spawn_order'] = [ + p for p in team.gamedata['spawn_order'] if p + ] + for player in team.gamedata['spawn_order']: + if player.gamedata['lives'] > 0: + if not player.is_alive(): + self.spawn_player(player) + break + + def _update_icons(self) -> None: + # pylint: disable=too-many-branches + + # In free-for-all mode, everyone is just lined up along the bottom. + if isinstance(self.session, ba.FreeForAllSession): + count = len(self.teams) + x_offs = 85 + xval = x_offs * (count - 1) * -0.5 + for team in self.teams: + if len(team.players) == 1: + player = team.players[0] + for icon in player.gamedata['icons']: + icon.set_position_and_scale((xval, 30), 0.7) + icon.update_for_lives() + xval += x_offs + + # In teams mode we split up teams. + else: + if self._solo_mode: + # First off, clear out all icons. + for player in self.players: + player.gamedata['icons'] = [] + + # Now for each team, cycle through our available players + # adding icons. + for team in self.teams: + if team.get_id() == 0: + xval = -60 + x_offs = -78 + else: + xval = 60 + x_offs = 78 + is_first = True + test_lives = 1 + while True: + players_with_lives = [ + p for p in team.gamedata['spawn_order'] + if p and p.gamedata['lives'] >= test_lives + ] + if not players_with_lives: + break + for player in players_with_lives: + player.gamedata['icons'].append( + Icon(player, + position=(xval, (40 if is_first else 25)), + scale=1.0 if is_first else 0.5, + name_maxwidth=130 if is_first else 75, + name_scale=0.8 if is_first else 1.0, + flatness=0.0 if is_first else 1.0, + shadow=0.5 if is_first else 1.0, + show_death=is_first, + show_lives=False)) + xval += x_offs * (0.8 if is_first else 0.56) + is_first = False + test_lives += 1 + # Non-solo mode. + else: + for team in self.teams: + if team.get_id() == 0: + xval = -50 + x_offs = -85 + else: + xval = 50 + x_offs = 85 + for player in team.players: + for icon in player.gamedata['icons']: + icon.set_position_and_scale((xval, 30), 0.7) + icon.update_for_lives() + xval += x_offs + + # noinspection PyUnusedLocal + def _get_spawn_point(self, player: ba.Player) -> Optional[ba.Vec3]: + # pylint: disable=unused-argument + # In solo-mode, if there's an existing live player on the map, spawn at + # whichever spot is farthest from them (keeps the action spread out). + if self._solo_mode: + living_player = None + living_player_pos = None + for team in self.teams: + for tplayer in team.players: + if tplayer.is_alive(): + assert tplayer.actor is not None and tplayer.actor.node + ppos = tplayer.actor.node.position + living_player = tplayer + living_player_pos = ppos + break + if living_player: + assert living_player_pos is not None + player_pos = ba.Vec3(living_player_pos) + points: List[Tuple[float, ba.Vec3]] = [] + for team in self.teams: + start_pos = ba.Vec3( + self.map.get_start_position(team.get_id())) + points.append( + ((start_pos - player_pos).length(), start_pos)) + points.sort() + return points[-1][1] + return None + + def spawn_player(self, player: ba.Player) -> ba.Actor: + actor = self.spawn_player_spaz(player, self._get_spawn_point(player)) + if not self._solo_mode: + ba.timer(0.3, ba.Call(self._print_lives, player)) + + # If we have any icons, update their state. + for icon in player.gamedata['icons']: + icon.handle_player_spawned() + return actor + + def _print_lives(self, player: ba.Player) -> None: + from bastd.actor import popuptext + if not player or not player.is_alive(): + return + try: + assert player.actor is not None and player.actor.node + pos = player.actor.node.position + except Exception as exc: + print('EXC getting player pos in bs_elim', exc) + return + popuptext.PopupText('x' + str(player.gamedata['lives'] - 1), + color=(1, 1, 0, 1), + offset=(0, -0.8, 0), + random_offset=0.0, + scale=1.8, + position=pos).autoretain() + + def on_player_leave(self, player: ba.Player) -> None: + ba.TeamGameActivity.on_player_leave(self, player) + player.gamedata['icons'] = None + + # Remove us from spawn-order. + if self._solo_mode: + if player in player.team.gamedata['spawn_order']: + player.team.gamedata['spawn_order'].remove(player) + + # Update icons in a moment since our team will be gone from the + # list then. + ba.timer(0, self._update_icons) + + def on_begin(self) -> None: + ba.TeamGameActivity.on_begin(self) + self.setup_standard_time_limit(self.settings['Time Limit']) + self.setup_standard_powerup_drops() + if self._solo_mode: + self._vs_text = ba.Actor( + ba.newnode("text", + attrs={ + 'position': (0, 105), + 'h_attach': "center", + 'h_align': 'center', + 'maxwidth': 200, + 'shadow': 0.5, + 'vr_depth': 390, + 'scale': 0.6, + 'v_attach': "bottom", + 'color': (0.8, 0.8, 0.3, 1.0), + 'text': ba.Lstr(resource='vsText') + })) + + # If balance-team-lives is on, add lives to the smaller team until + # total lives match. + if (isinstance(self.session, ba.TeamsSession) + and self.settings['Balance Total Lives'] + and self.teams[0].players and self.teams[1].players): + if self._get_total_team_lives( + self.teams[0]) < self._get_total_team_lives(self.teams[1]): + lesser_team = self.teams[0] + greater_team = self.teams[1] + else: + lesser_team = self.teams[1] + greater_team = self.teams[0] + add_index = 0 + while self._get_total_team_lives( + lesser_team) < self._get_total_team_lives(greater_team): + lesser_team.players[add_index].gamedata['lives'] += 1 + add_index = (add_index + 1) % len(lesser_team.players) + + self._update_icons() + + # We could check game-over conditions at explicit trigger points, + # but lets just do the simple thing and poll it. + ba.timer(1.0, self._update, repeat=True) + + def _get_total_team_lives(self, team: ba.Team) -> int: + return sum(player.gamedata['lives'] for player in team.players) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + + # Augment standard behavior. + super().handlemessage(msg) + player = msg.spaz.player + + player.gamedata['lives'] -= 1 + if player.gamedata['lives'] < 0: + ba.print_error( + "Got lives < 0 in Elim; this shouldn't happen. solo:" + + str(self._solo_mode)) + player.gamedata['lives'] = 0 + + # If we have any icons, update their state. + for icon in player.gamedata['icons']: + icon.handle_player_died() + + # Play big death sound on our last death + # or for every one in solo mode. + if self._solo_mode or player.gamedata['lives'] == 0: + ba.playsound(spaz.get_factory().single_player_death_sound) + + # If we hit zero lives, we're dead (and our team might be too). + if player.gamedata['lives'] == 0: + # If the whole team is now dead, mark their survival time. + if self._get_total_team_lives(player.team) == 0: + assert self._start_time is not None + player.team.gamedata['survival_seconds'] = int( + ba.time() - self._start_time) + else: + # Otherwise, in regular mode, respawn. + if not self._solo_mode: + self.respawn_player(player) + + # In solo, put ourself at the back of the spawn order. + if self._solo_mode: + player.team.gamedata['spawn_order'].remove(player) + player.team.gamedata['spawn_order'].append(player) + + def _update(self) -> None: + if self._solo_mode: + # For both teams, find the first player on the spawn order + # list with lives remaining and spawn them if they're not alive. + for team in self.teams: + # Prune dead players from the spawn order. + team.gamedata['spawn_order'] = [ + p for p in team.gamedata['spawn_order'] if p + ] + for player in team.gamedata['spawn_order']: + if player.gamedata['lives'] > 0: + if not player.is_alive(): + self.spawn_player(player) + self._update_icons() + break + + # If we're down to 1 or fewer living teams, start a timer to end + # the game (allows the dust to settle and draws to occur if deaths + # are close enough). + if len(self._get_living_teams()) < 2: + self._round_end_timer = ba.Timer(0.5, self.end_game) + + def _get_living_teams(self) -> List[ba.Team]: + return [ + team for team in self.teams + if len(team.players) > 0 and any(player.gamedata['lives'] > 0 + for player in team.players) + ] + + def end_game(self) -> None: + if self.has_ended(): + return + results = ba.TeamGameResults() + self._vs_text = None # Kill our 'vs' if its there. + for team in self.teams: + results.set_team_score(team, team.gamedata['survival_seconds']) + self.end(results=results) diff --git a/assets/src/data/scripts/bastd/game/football.py b/assets/src/data/scripts/bastd/game/football.py new file mode 100644 index 00000000..31c8ea6f --- /dev/null +++ b/assets/src/data/scripts/bastd/game/football.py @@ -0,0 +1,884 @@ +"""Implements football games (both co-op and teams varieties).""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor import bomb as stdbomb +from bastd.actor import flag as stdflag +from bastd.actor import playerspaz +from bastd.actor import spazbot +from bastd.actor.scoreboard import Scoreboard + +if TYPE_CHECKING: + from typing import (Any, List, Tuple, Type, Dict, Sequence, Optional, + Union) + from bastd.actor.spaz import Spaz + + +class FootballFlag(stdflag.Flag): + """Custom flag class for football games.""" + + def __init__(self, position: Sequence[float]): + super().__init__(position=position, + dropped_timeout=20, + color=(1.0, 1.0, 0.3)) + assert self.node + self.last_holding_player: Optional[ba.Player] = None + self.node.is_area_of_interest = True + self.respawn_timer: Optional[ba.Timer] = None + self.scored = False + self.held_count = 0 + self.light = ba.newnode('light', + owner=self.node, + attrs={ + 'intensity': 0.25, + 'height_attenuated': False, + 'radius': 0.2, + 'color': (0.9, 0.7, 0.0) + }) + self.node.connectattr('position', self.light, 'position') + + +# bs_meta export game +class FootballTeamGame(ba.TeamGameActivity): + """Football game for teams mode.""" + + @classmethod + def get_name(cls) -> str: + return 'Football' + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + # We only support two-team play. + return issubclass(sessiontype, ba.TeamsSession) + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Get the flag to the enemy end zone.' + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ba.getmaps('football') + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + return [ + ("Score to Win", { + 'min_value': 7, + 'default': 21, + 'increment': 7 + }), + ("Time Limit", { + 'choices': [('None', 0), ('1 Minute', 60), ('2 Minutes', 120), + ('5 Minutes', 300), ('10 Minutes', 600), + ('20 Minutes', 1200)], + 'default': 0 + }), + ("Respawn Times", { + 'choices': [('Shorter', 0.25), ('Short', 0.5), ('Normal', 1.0), + ('Long', 2.0), ('Longer', 4.0)], + 'default': 1.0 + }) + ] # yapf: disable + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings) + self._scoreboard: Optional[Scoreboard] = Scoreboard() + + # Load some media we need. + self._cheer_sound = ba.getsound("cheer") + self._chant_sound = ba.getsound("crowdChant") + self._score_sound = ba.getsound("score") + self._swipsound = ba.getsound("swip") + self._whistle_sound = ba.getsound("refWhistle") + + self.score_region_material = ba.Material() + self.score_region_material.add_actions( + conditions=("they_have_material", + stdflag.get_factory().flagmaterial), + actions=(("modify_part_collision", "collide", + True), ("modify_part_collision", "physical", False), + ("call", "at_connect", self._handle_score))) + self._flag_spawn_pos: Optional[Sequence[float]] = None + self._score_regions: List[ba.Actor] = [] + self._flag: Optional[FootballFlag] = None + self._flag_respawn_timer: Optional[ba.Timer] = None + self._flag_respawn_light: Optional[ba.Actor] = None + + def get_instance_description(self) -> Union[str, Sequence]: + touchdowns = self.settings['Score to Win'] / 7 + if touchdowns > 1: + return 'Score ${ARG1} touchdowns.', touchdowns + return 'Score a touchdown.' + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + touchdowns = self.settings['Score to Win'] / 7 + if touchdowns > 1: + return 'score ${ARG1} touchdowns', touchdowns + return 'score a touchdown' + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify these args. + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in(self, music='Football') + + def on_begin(self) -> None: + ba.TeamGameActivity.on_begin(self) + self.setup_standard_time_limit(self.settings['Time Limit']) + self.setup_standard_powerup_drops() + self._flag_spawn_pos = (self.map.get_flag_position(None)) + self._spawn_flag() + defs = self.map.defs + self._score_regions.append( + ba.Actor( + ba.newnode('region', + attrs={ + 'position': defs.boxes['goal1'][0:3], + 'scale': defs.boxes['goal1'][6:9], + 'type': 'box', + 'materials': (self.score_region_material, ) + }))) + self._score_regions.append( + ba.Actor( + ba.newnode('region', + attrs={ + 'position': defs.boxes['goal2'][0:3], + 'scale': defs.boxes['goal2'][6:9], + 'type': 'box', + 'materials': (self.score_region_material, ) + }))) + self._update_scoreboard() + ba.playsound(self._chant_sound) + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['score'] = 0 + self._update_scoreboard() + + def _kill_flag(self) -> None: + self._flag = None + + def _handle_score(self) -> None: + """A point has been scored.""" + + # Our flag might stick around for a second or two + # make sure it doesn't score again. + assert self._flag is not None + if self._flag.scored: + return + region = ba.get_collision_info("source_node") + i = None + for i in range(len(self._score_regions)): + if region == self._score_regions[i].node: + break + for team in self.teams: + if team.get_id() == i: + team.gamedata['score'] += 7 + + # Tell all players to celebrate. + for player in team.players: + if player.actor is not None and player.actor.node: + try: + # Note: celebrate message is milliseconds + # (for historical reasons). + player.actor.node.handlemessage('celebrate', 2000) + except Exception: + ba.print_exception('Error on celebrate') + + # If someone on this team was last to touch it, + # give them points. + assert self._flag is not None + if (self._flag.last_holding_player + and team == self._flag.last_holding_player.team): + self.stats.player_scored(self._flag.last_holding_player, + 50, + big_message=True) + # end game if we won + if team.gamedata['score'] >= self.settings['Score to Win']: + self.end_game() + ba.playsound(self._score_sound) + ba.playsound(self._cheer_sound) + assert self._flag + self._flag.scored = True + + # Kill the flag (it'll respawn shortly). + ba.timer(1.0, self._kill_flag) + light = ba.newnode('light', + attrs={ + 'position': ba.get_collision_info('position'), + 'height_attenuated': False, + 'color': (1, 0, 0) + }) + ba.animate(light, 'intensity', {0.0: 0, 0.5: 1, 1.0: 0}, loop=True) + ba.timer(1.0, light.delete) + ba.cameraflash(duration=10.0) + self._update_scoreboard() + + def end_game(self) -> None: + results = ba.TeamGameResults() + for team in self.teams: + results.set_team_score(team, team.gamedata['score']) + self.end(results=results, announce_delay=0.8) + + def _update_scoreboard(self) -> None: + win_score = self.settings['Score to Win'] + assert self._scoreboard is not None + for team in self.teams: + self._scoreboard.set_team_value(team, team.gamedata['score'], + win_score) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, stdflag.FlagPickedUpMessage): + assert isinstance(msg.flag, FootballFlag) + try: + player = msg.node.getdelegate().getplayer() + if player: + msg.flag.last_holding_player = player + msg.flag.held_count += 1 + except Exception: + ba.print_exception("exception in Football FlagPickedUpMessage;" + " this shouldn't happen") + + elif isinstance(msg, stdflag.FlagDroppedMessage): + assert isinstance(msg.flag, FootballFlag) + msg.flag.held_count -= 1 + + # Respawn dead players if they're still in the game. + elif isinstance(msg, playerspaz.PlayerSpazDeathMessage): + # Augment standard behavior. + super().handlemessage(msg) + self.respawn_player(msg.spaz.player) + + # Respawn dead flags. + elif isinstance(msg, stdflag.FlagDeathMessage): + if not self.has_ended(): + self._flag_respawn_timer = ba.Timer(3.0, self._spawn_flag) + self._flag_respawn_light = ba.Actor( + ba.newnode('light', + attrs={ + 'position': self._flag_spawn_pos, + 'height_attenuated': False, + 'radius': 0.15, + 'color': (1.0, 1.0, 0.3) + })) + assert self._flag_respawn_light.node + ba.animate(self._flag_respawn_light.node, + "intensity", { + 0.0: 0, + 0.25: 0.15, + 0.5: 0 + }, + loop=True) + ba.timer(3.0, self._flag_respawn_light.node.delete) + + else: + # Augment standard behavior. + super().handlemessage(msg) + + def _flash_flag_spawn(self) -> None: + light = ba.newnode('light', + attrs={ + 'position': self._flag_spawn_pos, + 'height_attenuated': False, + 'color': (1, 1, 0) + }) + ba.animate(light, 'intensity', {0: 0, 0.25: 0.25, 0.5: 0}, loop=True) + ba.timer(1.0, light.delete) + + def _spawn_flag(self) -> None: + ba.playsound(self._swipsound) + ba.playsound(self._whistle_sound) + self._flash_flag_spawn() + assert self._flag_spawn_pos is not None + self._flag = FootballFlag(position=self._flag_spawn_pos) + + +class FootballCoopGame(ba.CoopGameActivity): + """ + Co-op variant of football + """ + + tips = ['Use the pick-up button to grab the flag < ${PICKUP} >'] + + @classmethod + def get_name(cls) -> str: + return 'Football' + + @classmethod + def get_score_info(cls) -> Dict[str, Any]: + return {'score_type': 'milliseconds', 'score_version': 'B'} + + # FIXME: Need to update co-op games to use get_score_info. + def get_score_type(self) -> str: + return 'time' + + def get_instance_description(self) -> Union[str, Sequence]: + touchdowns = self._score_to_win / 7 + if touchdowns > 1: + return 'Score ${ARG1} touchdowns.', touchdowns + return 'Score a touchdown.' + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + touchdowns = self._score_to_win / 7 + if touchdowns > 1: + return 'score ${ARG1} touchdowns', touchdowns + return 'score a touchdown' + + def __init__(self, settings: Dict[str, Any]): + settings['map'] = 'Football Stadium' + super().__init__(settings) + self._preset = self.settings.get('preset', 'rookie') + + # Load some media we need. + self._cheer_sound = ba.getsound("cheer") + self._boo_sound = ba.getsound("boo") + self._chant_sound = ba.getsound("crowdChant") + self._score_sound = ba.getsound("score") + self._swipsound = ba.getsound("swip") + self._whistle_sound = ba.getsound("refWhistle") + self._score_to_win = 21 + self._score_region_material = ba.Material() + self._score_region_material.add_actions( + conditions=("they_have_material", + stdflag.get_factory().flagmaterial), + actions=(("modify_part_collision", "collide", + True), ("modify_part_collision", "physical", False), + ("call", "at_connect", self._handle_score))) + self._powerup_center = (0, 2, 0) + self._powerup_spread = (10, 5.5) + self._player_has_dropped_bomb = False + self._player_has_punched = False + self._scoreboard: Optional[Scoreboard] = None + self._flag_spawn_pos: Optional[Sequence[float]] = None + self.score_regions: List[ba.Actor] = [] + self._exclude_powerups: List[str] = [] + self._have_tnt = False + self._bot_types_initial: Optional[List[Type[spazbot.SpazBot]]] = None + self._bot_types_7: Optional[List[Type[spazbot.SpazBot]]] = None + self._bot_types_14: Optional[List[Type[spazbot.SpazBot]]] = None + self._bot_team: Optional[ba.Team] = None + self._starttime_ms: Optional[int] = None + self._time_text: Optional[ba.Actor] = None + self._time_text_input: Optional[ba.Actor] = None + self._tntspawner: Optional[stdbomb.TNTSpawner] = None + self._bots = spazbot.BotSet() + self._bot_spawn_timer: Optional[ba.Timer] = None + self._powerup_drop_timer: Optional[ba.Timer] = None + self.scoring_team: Optional[ba.Team] = None + self._final_time_ms: Optional[int] = None + self._time_text_timer: Optional[ba.Timer] = None + self._flag_respawn_light: Optional[ba.Actor] = None + self._flag: Optional[FootballFlag] = None + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify these args. + # pylint: disable=arguments-differ + ba.CoopGameActivity.on_transition_in(self, music='Football') + self._scoreboard = Scoreboard() + self._flag_spawn_pos = self.map.get_flag_position(None) + self._spawn_flag() + + # Set up the two score regions. + defs = self.map.defs + self.score_regions.append( + ba.Actor( + ba.newnode('region', + attrs={ + 'position': defs.boxes['goal1'][0:3], + 'scale': defs.boxes['goal1'][6:9], + 'type': 'box', + 'materials': [self._score_region_material] + }))) + self.score_regions.append( + ba.Actor( + ba.newnode('region', + attrs={ + 'position': defs.boxes['goal2'][0:3], + 'scale': defs.boxes['goal2'][6:9], + 'type': 'box', + 'materials': [self._score_region_material] + }))) + ba.playsound(self._chant_sound) + + def on_begin(self) -> None: + # FIXME: Split this up a bit. + # pylint: disable=too-many-statements + from bastd.actor import controlsguide + ba.CoopGameActivity.on_begin(self) + + # Show controls help in kiosk mode. + if ba.app.kiosk_mode: + controlsguide.ControlsGuide(delay=3.0, lifespan=10.0, + bright=True).autoretain() + assert self.initial_player_info is not None + abot: Type[spazbot.SpazBot] + bbot: Type[spazbot.SpazBot] + cbot: Type[spazbot.SpazBot] + if self._preset in ['rookie', 'rookie_easy']: + self._exclude_powerups = ['curse'] + self._have_tnt = False + abot = (spazbot.BrawlerBotLite + if self._preset == 'rookie_easy' else spazbot.BrawlerBot) + self._bot_types_initial = [abot] * len(self.initial_player_info) + bbot = (spazbot.BomberBotLite + if self._preset == 'rookie_easy' else spazbot.BomberBot) + self._bot_types_7 = ( + [bbot] * (1 if len(self.initial_player_info) < 3 else 2)) + cbot = (spazbot.BomberBot + if self._preset == 'rookie_easy' else spazbot.TriggerBot) + self._bot_types_14 = ( + [cbot] * (1 if len(self.initial_player_info) < 3 else 2)) + elif self._preset == 'tournament': + self._exclude_powerups = [] + self._have_tnt = True + self._bot_types_initial = ( + [spazbot.BrawlerBot] * + (1 if len(self.initial_player_info) < 2 else 2)) + self._bot_types_7 = ( + [spazbot.TriggerBot] * + (1 if len(self.initial_player_info) < 3 else 2)) + self._bot_types_14 = ( + [spazbot.ChargerBot] * + (1 if len(self.initial_player_info) < 4 else 2)) + elif self._preset in ['pro', 'pro_easy', 'tournament_pro']: + self._exclude_powerups = ['curse'] + self._have_tnt = True + self._bot_types_initial = [spazbot.ChargerBot] * len( + self.initial_player_info) + abot = (spazbot.BrawlerBot + if self._preset == 'pro' else spazbot.BrawlerBotLite) + typed_bot_list: List[Type[spazbot.SpazBot]] = [] + self._bot_types_7 = ( + typed_bot_list + [abot] + [spazbot.BomberBot] * + (1 if len(self.initial_player_info) < 3 else 2)) + bbot = (spazbot.TriggerBotPro + if self._preset == 'pro' else spazbot.TriggerBot) + self._bot_types_14 = ( + [bbot] * (1 if len(self.initial_player_info) < 3 else 2)) + elif self._preset in ['uber', 'uber_easy']: + self._exclude_powerups = [] + self._have_tnt = True + abot = (spazbot.BrawlerBotPro + if self._preset == 'uber' else spazbot.BrawlerBot) + bbot = (spazbot.TriggerBotPro + if self._preset == 'uber' else spazbot.TriggerBot) + typed_bot_list_2: List[Type[spazbot.SpazBot]] = [] + self._bot_types_initial = (typed_bot_list_2 + [spazbot.StickyBot] + + [abot] * len(self.initial_player_info)) + self._bot_types_7 = ( + [bbot] * (1 if len(self.initial_player_info) < 3 else 2)) + self._bot_types_14 = ( + [spazbot.ExplodeyBot] * + (1 if len(self.initial_player_info) < 3 else 2)) + else: + raise Exception() + + self.setup_low_life_warning_sound() + + self._drop_powerups(standard_points=True) + ba.timer(4.0, self._start_powerup_drops) + + # Make a bogus team for our bots. + bad_team_name = self.get_team_display_string('Bad Guys') + self._bot_team = ba.Team(1, bad_team_name, (0.5, 0.4, 0.4)) + + for team in [self.teams[0], self._bot_team]: + team.gamedata['score'] = 0 + + self.update_scores() + + # Time display. + starttime_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(starttime_ms, int) + self._starttime_ms = starttime_ms + self._time_text = ba.Actor( + ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'center', + 'color': (1, 1, 0.5, 1), + 'flatness': 0.5, + 'shadow': 0.5, + 'position': (0, -50), + 'scale': 1.3, + 'text': '' + })) + self._time_text_input = ba.Actor( + ba.newnode('timedisplay', attrs={'showsubseconds': True})) + ba.sharedobj('globals').connectattr('time', self._time_text_input.node, + 'time2') + assert self._time_text_input.node + assert self._time_text.node + self._time_text_input.node.connectattr('output', self._time_text.node, + 'text') + + # Our TNT spawner (if applicable). + if self._have_tnt: + self._tntspawner = stdbomb.TNTSpawner(position=(0, 1, -1)) + + self._bots = spazbot.BotSet() + self._bot_spawn_timer = ba.Timer(1.0, self._update_bots, repeat=True) + + for bottype in self._bot_types_initial: + self._spawn_bot(bottype) + + def _on_got_scores_to_beat(self, scores: List[Dict[str, Any]]) -> None: + self._show_standard_scores_to_beat_ui(scores) + + def _on_bot_spawn(self, spaz: spazbot.SpazBot) -> None: + # We want to move to the left by default. + spaz.target_point_default = ba.Vec3(0, 0, 0) + + def _spawn_bot(self, + spaz_type: Type[spazbot.SpazBot], + immediate: bool = False) -> None: + assert self._bot_team is not None + pos = self.map.get_start_position(self._bot_team.get_id()) + self._bots.spawn_bot(spaz_type, + pos=pos, + spawn_time=0.001 if immediate else 3.0, + on_spawn_call=self._on_bot_spawn) + + def _update_bots(self) -> None: + bots = self._bots.get_living_bots() + for bot in bots: + bot.target_flag = None + + # If we're waiting on a continue, stop here so they don't keep scoring. + if self.is_waiting_for_continue(): + self._bots.stop_moving() + return + + # If we've got a flag and no player are holding it, find the closest + # bot to it, and make them the designated flag-bearer. + assert self._flag is not None + if self._flag.node: + for player in self.players: + try: + assert player.actor is not None and player.actor.node + if (player.actor.is_alive() and + player.actor.node.hold_node == self._flag.node): + return + except Exception: + ba.print_exception("exception checking hold node") + + flagpos = ba.Vec3(self._flag.node.position) + closest_bot = None + closest_dist = None + for bot in bots: + # If a bot is picked up, he should forget about the flag. + if bot.held_count > 0: + continue + assert bot.node + botpos = ba.Vec3(bot.node.position) + botdist = (botpos - flagpos).length() + if closest_bot is None or botdist < closest_dist: + closest_dist = botdist + closest_bot = bot + if closest_bot is not None: + closest_bot.target_flag = self._flag + + def _drop_powerup(self, index: int, poweruptype: str = None) -> None: + from bastd.actor import powerupbox + if poweruptype is None: + poweruptype = (powerupbox.get_factory().get_random_powerup_type( + excludetypes=self._exclude_powerups)) + powerupbox.PowerupBox(position=self.map.powerup_spawn_points[index], + poweruptype=poweruptype).autoretain() + + def _start_powerup_drops(self) -> None: + self._powerup_drop_timer = ba.Timer(3.0, + self._drop_powerups, + repeat=True) + + def _drop_powerups(self, + standard_points: bool = False, + poweruptype: str = None) -> None: + """Generic powerup drop.""" + from bastd.actor import powerupbox + if standard_points: + spawnpoints = self.map.powerup_spawn_points + for i, _point in enumerate(spawnpoints): + ba.timer(1.0 + i * 0.5, + ba.Call(self._drop_powerup, i, poweruptype)) + else: + point = (self._powerup_center[0] + random.uniform( + -1.0 * self._powerup_spread[0], 1.0 * self._powerup_spread[0]), + self._powerup_center[1], + self._powerup_center[2] + random.uniform( + -self._powerup_spread[1], self._powerup_spread[1])) + + # Drop one random one somewhere. + powerupbox.PowerupBox( + position=point, + poweruptype=powerupbox.get_factory().get_random_powerup_type( + excludetypes=self._exclude_powerups)).autoretain() + + def _kill_flag(self) -> None: + try: + assert self._flag is not None + self._flag.handlemessage(ba.DieMessage()) + except Exception: + ba.print_exception('error in _kill_flag') + + def _handle_score(self) -> None: + """ a point has been scored """ + # FIXME tidy this up + # pylint: disable=too-many-branches + # pylint: disable=too-many-nested-blocks + + # Our flag might stick around for a second or two; + # we don't want it to be able to score again. + assert self._flag is not None + if self._flag.scored: + return + + # See which score region it was. + region = ba.get_collision_info("source_node") + i = None + for i in range(len(self.score_regions)): + if region == self.score_regions[i].node: + break + + for team in [self.teams[0], self._bot_team]: + assert team is not None + if team.get_id() == i: + team.gamedata['score'] += 7 + + # Tell all players (or bots) to celebrate. + if i == 0: + for player in team.players: + try: + # Note: celebrate message is milliseconds + # (for historical reasons). + if player.actor is not None and player.actor.node: + player.actor.node.handlemessage( + 'celebrate', 2000) + except Exception: + ba.print_exception() + else: + self._bots.celebrate(2000) + + # If the good guys scored, add more enemies. + if i == 0: + if self.teams[0].gamedata['score'] == 7: + assert self._bot_types_7 is not None + for bottype in self._bot_types_7: + self._spawn_bot(bottype) + elif self.teams[0].gamedata['score'] == 14: + assert self._bot_types_14 is not None + for bottype in self._bot_types_14: + self._spawn_bot(bottype) + + ba.playsound(self._score_sound) + if i == 0: + ba.playsound(self._cheer_sound) + else: + ba.playsound(self._boo_sound) + + # Kill the flag (it'll respawn shortly). + self._flag.scored = True + + ba.timer(0.2, self._kill_flag) + + self.update_scores() + light = ba.newnode('light', + attrs={ + 'position': ba.get_collision_info('position'), + 'height_attenuated': False, + 'color': (1, 0, 0) + }) + ba.animate(light, 'intensity', {0: 0, 0.5: 1, 1.0: 0}, loop=True) + ba.timer(1.0, light.delete) + if i == 0: + ba.cameraflash(duration=10.0) + + def end_game(self) -> None: + ba.setmusic(None) + self._bots.final_celebrate() + ba.timer(0.001, ba.Call(self.do_end, 'defeat')) + + def on_continue(self) -> None: + # Subtract one touchdown from the bots and get them moving again. + assert self._bot_team is not None + self._bot_team.gamedata['score'] -= 7 + self._bots.start_moving() + self.update_scores() + + def update_scores(self) -> None: + """ update scoreboard and check for winners """ + # FIXME: tidy this up + # pylint: disable=too-many-nested-blocks + have_scoring_team = False + win_score = self._score_to_win + for team in [self.teams[0], self._bot_team]: + assert team is not None + assert self._scoreboard is not None + self._scoreboard.set_team_value(team, team.gamedata['score'], + win_score) + if team.gamedata['score'] >= win_score: + if not have_scoring_team: + self.scoring_team = team + if team is self._bot_team: + self.continue_or_end_game() + else: + ba.setmusic('Victory') + + # Completion achievements. + assert self._bot_team is not None + if self._preset in ['rookie', 'rookie_easy']: + self._award_achievement('Rookie Football Victory', + sound=False) + if self._bot_team.gamedata['score'] == 0: + self._award_achievement( + 'Rookie Football Shutout', sound=False) + elif self._preset in ['pro', 'pro_easy']: + self._award_achievement('Pro Football Victory', + sound=False) + if self._bot_team.gamedata['score'] == 0: + self._award_achievement('Pro Football Shutout', + sound=False) + elif self._preset in ['uber', 'uber_easy']: + self._award_achievement('Uber Football Victory', + sound=False) + if self._bot_team.gamedata['score'] == 0: + self._award_achievement( + 'Uber Football Shutout', sound=False) + if (not self._player_has_dropped_bomb + and not self._player_has_punched): + self._award_achievement('Got the Moves', + sound=False) + self._bots.stop_moving() + self.show_zoom_message(ba.Lstr(resource='victoryText'), + scale=1.0, + duration=4.0) + self.celebrate(10.0) + assert self._starttime_ms is not None + self._final_time_ms = int( + ba.time(timeformat=ba.TimeFormat.MILLISECONDS) - + self._starttime_ms) + self._time_text_timer = None + assert (self._time_text_input is not None + and self._time_text_input.node) + self._time_text_input.node.timemax = ( + self._final_time_ms) + + # FIXME: Does this still need to be deferred? + ba.pushcall(ba.Call(self.do_end, 'victory')) + + def do_end(self, outcome: str) -> None: + """End the game with the specified outcome.""" + if outcome == 'defeat': + self.fade_to_red() + assert self._final_time_ms is not None + scoreval = (None if outcome == 'defeat' else int(self._final_time_ms // + 10)) + self.end(delay=3.0, + results={ + 'outcome': outcome, + 'score': scoreval, + 'score_order': 'decreasing', + 'player_info': self.initial_player_info + }) + + def handlemessage(self, msg: Any) -> Any: + """ handle high-level game messages """ + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + from bastd.actor import respawnicon + + # Respawn dead players. + player = msg.spaz.player + self.stats.player_lost_spaz(player) + assert self.initial_player_info is not None + respawn_time = 2.0 + len(self.initial_player_info) * 1.0 + + # Respawn them shortly. + player.gamedata['respawn_timer'] = ba.Timer( + respawn_time, ba.Call(self.spawn_player_if_exists, player)) + player.gamedata['respawn_icon'] = respawnicon.RespawnIcon( + player, respawn_time) + + # Augment standard behavior. + super().handlemessage(msg) + + elif isinstance(msg, spazbot.SpazBotDeathMessage): + + # Every time a bad guy dies, spawn a new one. + ba.timer(3.0, ba.Call(self._spawn_bot, (type(msg.badguy)))) + + elif isinstance(msg, spazbot.SpazBotPunchedMessage): + if self._preset in ['rookie', 'rookie_easy']: + if msg.damage >= 500: + self._award_achievement('Super Punch') + elif self._preset in ['pro', 'pro_easy']: + if msg.damage >= 1000: + self._award_achievement('Super Mega Punch') + + # Respawn dead flags. + elif isinstance(msg, stdflag.FlagDeathMessage): + assert isinstance(msg.flag, FootballFlag) + msg.flag.respawn_timer = ba.Timer(3.0, self._spawn_flag) + self._flag_respawn_light = ba.Actor( + ba.newnode('light', + attrs={ + 'position': self._flag_spawn_pos, + 'height_attenuated': False, + 'radius': 0.15, + 'color': (1.0, 1.0, 0.3) + })) + assert self._flag_respawn_light.node + ba.animate(self._flag_respawn_light.node, + "intensity", { + 0: 0, + 0.25: 0.15, + 0.5: 0 + }, + loop=True) + ba.timer(3.0, self._flag_respawn_light.node.delete) + else: + super().handlemessage(msg) + + def _handle_player_dropped_bomb(self, player: Spaz, + bomb: ba.Actor) -> None: + del player, bomb # Unused. + self._player_has_dropped_bomb = True + + def _handle_player_punched(self, player: Spaz) -> None: + del player # Unused. + self._player_has_punched = True + + def spawn_player(self, player: ba.Player) -> ba.Actor: + spaz = self.spawn_player_spaz(player, + position=self.map.get_start_position( + player.team.get_id())) + if self._preset in ['rookie_easy', 'pro_easy', 'uber_easy']: + spaz.impact_scale = 0.25 + spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb) + spaz.punch_callback = self._handle_player_punched + return spaz + + def _flash_flag_spawn(self) -> None: + light = ba.newnode('light', + attrs={ + 'position': self._flag_spawn_pos, + 'height_attenuated': False, + 'color': (1, 1, 0) + }) + ba.animate(light, 'intensity', {0: 0, 0.25: 0.25, 0.5: 0}, loop=True) + ba.timer(1.0, light.delete) + + def _spawn_flag(self) -> None: + ba.playsound(self._swipsound) + ba.playsound(self._whistle_sound) + self._flash_flag_spawn() + assert self._flag_spawn_pos is not None + self._flag = FootballFlag(position=self._flag_spawn_pos) diff --git a/assets/src/data/scripts/bastd/game/hockey.py b/assets/src/data/scripts/bastd/game/hockey.py new file mode 100644 index 00000000..b034a4d6 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/hockey.py @@ -0,0 +1,350 @@ +"""Hockey game and support classes.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.actor import playerspaz + +if TYPE_CHECKING: + from typing import (Any, Sequence, Dict, Type, List, Tuple, Optional, + Union) + + +class PuckDeathMessage: + """Inform an object that a puck has died.""" + + def __init__(self, puck: Puck): + self.puck = puck + + +class Puck(ba.Actor): + """A lovely giant hockey puck.""" + + def __init__(self, position: Sequence[float] = (0.0, 1.0, 0.0)): + super().__init__() + activity = self.getactivity() + + # Spawn just above the provided point. + self._spawn_pos = (position[0], position[1] + 1.0, position[2]) + self.last_players_to_touch: Dict[int, ba.Player] = {} + self.scored = False + assert activity is not None + assert isinstance(activity, HockeyGame) + pmats = [ba.sharedobj('object_material'), activity.puck_material] + self.node = ba.newnode("prop", + delegate=self, + attrs={ + 'model': activity.puck_model, + 'color_texture': activity.puck_tex, + 'body': 'puck', + 'reflection': 'soft', + 'reflection_scale': [0.2], + 'shadow_size': 1.0, + 'is_area_of_interest': True, + 'position': self._spawn_pos, + 'materials': pmats + }) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, ba.DieMessage): + assert self.node + self.node.delete() + activity = self._activity() + if activity and not msg.immediate: + activity.handlemessage(PuckDeathMessage(self)) + + # If we go out of bounds, move back to where we started. + elif isinstance(msg, ba.OutOfBoundsMessage): + assert self.node + self.node.position = self._spawn_pos + + elif isinstance(msg, ba.HitMessage): + assert self.node + assert msg.force_direction is not None + self.node.handlemessage( + "impulse", msg.pos[0], msg.pos[1], msg.pos[2], msg.velocity[0], + msg.velocity[1], msg.velocity[2], 1.0 * msg.magnitude, + 1.0 * msg.velocity_magnitude, msg.radius, 0, + msg.force_direction[0], msg.force_direction[1], + msg.force_direction[2]) + + # If this hit came from a player, log them as the last to touch us. + if msg.source_player is not None: + activity = self._activity() + if activity: + if msg.source_player in activity.players: + self.last_players_to_touch[ + msg.source_player.team.get_id( + )] = msg.source_player + else: + super().handlemessage(msg) + + +# bs_meta export game +class HockeyGame(ba.TeamGameActivity): + """Ice hockey game.""" + + @classmethod + def get_name(cls) -> str: + return 'Hockey' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Score some goals.' + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + return issubclass(sessiontype, ba.TeamsSession) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ba.getmaps('hockey') + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + return [ + ("Score to Win", { + 'min_value': 1, 'default': 1, 'increment': 1 + }), + ("Time Limit", { + 'choices': [('None', 0), ('1 Minute', 60), + ('2 Minutes', 120), ('5 Minutes', 300), + ('10 Minutes', 600), ('20 Minutes', 1200)], + 'default': 0 + }), + ("Respawn Times", { + 'choices': [('Shorter', 0.25), ('Short', 0.5), ('Normal', 1.0), + ('Long', 2.0), ('Longer', 4.0)], + 'default': 1.0 + })] # yapf: disable + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + from bastd.actor import powerupbox + super().__init__(settings) + self._scoreboard = Scoreboard() + self._cheer_sound = ba.getsound("cheer") + self._chant_sound = ba.getsound("crowdChant") + self._foghorn_sound = ba.getsound("foghorn") + self._swipsound = ba.getsound("swip") + self._whistle_sound = ba.getsound("refWhistle") + self.puck_model = ba.getmodel("puck") + self.puck_tex = ba.gettexture("puckColor") + self._puck_sound = ba.getsound("metalHit") + self.puck_material = ba.Material() + self.puck_material.add_actions(actions=(("modify_part_collision", + "friction", 0.5))) + self.puck_material.add_actions( + conditions=("they_have_material", ba.sharedobj('pickup_material')), + actions=("modify_part_collision", "collide", False)) + self.puck_material.add_actions( + conditions=(("we_are_younger_than", 100), + 'and', ("they_have_material", + ba.sharedobj('object_material'))), + actions=("modify_node_collision", "collide", False)) + self.puck_material.add_actions( + conditions=("they_have_material", + ba.sharedobj('footing_material')), + actions=("impact_sound", self._puck_sound, 0.2, 5)) + + # Keep track of which player last touched the puck + self.puck_material.add_actions( + conditions=("they_have_material", ba.sharedobj('player_material')), + actions=(("call", "at_connect", + self._handle_puck_player_collide), )) + + # We want the puck to kill powerups; not get stopped by them + self.puck_material.add_actions( + conditions=("they_have_material", + powerupbox.get_factory().powerup_material), + actions=(("modify_part_collision", "physical", False), + ("message", "their_node", "at_connect", ba.DieMessage()))) + self._score_region_material = ba.Material() + self._score_region_material.add_actions( + conditions=("they_have_material", self.puck_material), + actions=(("modify_part_collision", "collide", + True), ("modify_part_collision", "physical", False), + ("call", "at_connect", self._handle_score))) + self._puck_spawn_pos: Optional[Sequence[float]] = None + self._score_regions: Optional[List[ba.Actor]] = None + self._puck: Optional[Puck] = None + + def get_instance_description(self) -> Union[str, Sequence]: + if self.settings['Score to Win'] == 1: + return 'Score a goal.' + return 'Score ${ARG1} goals.', self.settings['Score to Win'] + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + if self.settings['Score to Win'] == 1: + return 'score a goal' + return 'score ${ARG1} goals', self.settings['Score to Win'] + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify args. + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in(self, music='Hockey') + + def on_begin(self) -> None: + ba.TeamGameActivity.on_begin(self) + + self.setup_standard_time_limit(self.settings['Time Limit']) + self.setup_standard_powerup_drops() + self._puck_spawn_pos = self.map.get_flag_position(None) + self._spawn_puck() + + # set up the two score regions + defs = self.map.defs + self._score_regions = [] + self._score_regions.append( + ba.Actor( + ba.newnode("region", + attrs={ + 'position': defs.boxes["goal1"][0:3], + 'scale': defs.boxes["goal1"][6:9], + 'type': "box", + 'materials': [self._score_region_material] + }))) + self._score_regions.append( + ba.Actor( + ba.newnode("region", + attrs={ + 'position': defs.boxes["goal2"][0:3], + 'scale': defs.boxes["goal2"][6:9], + 'type': "box", + 'materials': [self._score_region_material] + }))) + self._update_scoreboard() + ba.playsound(self._chant_sound) + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['score'] = 0 + self._update_scoreboard() + + def _handle_puck_player_collide(self) -> None: + try: + pucknode, playernode = ba.get_collision_info( + 'source_node', 'opposing_node') + puck = pucknode.getdelegate() + player = playernode.getdelegate().getplayer() + except Exception: + player = puck = None + if player and puck: + puck.last_players_to_touch[player.get_team().get_id()] = player + + def _kill_puck(self) -> None: + self._puck = None + + def _handle_score(self) -> None: + """A point has been scored.""" + + assert self._puck is not None + assert self._score_regions is not None + + # Our puck might stick around for a second or two + # we don't want it to be able to score again. + if self._puck.scored: + return + + region = ba.get_collision_info("source_node") + index = 0 + for index in range(len(self._score_regions)): + if region == self._score_regions[index].node: + break + + for team in self.teams: + if team.get_id() == index: + scoring_team = team + team.gamedata['score'] += 1 + + # Tell all players to celebrate. + for player in team.players: + if player.actor is not None and player.actor.node: + # Note: celebrate message takes milliseconds + # (for historical reasons). + player.actor.node.handlemessage('celebrate', 2000) + + # If we've got the player from the scoring team that last + # touched us, give them points. + if (scoring_team.get_id() in self._puck.last_players_to_touch + and self._puck.last_players_to_touch[ + scoring_team.get_id()]): + self.stats.player_scored(self._puck.last_players_to_touch[ + scoring_team.get_id()], + 100, + big_message=True) + + # End game if we won. + if team.gamedata['score'] >= self.settings['Score to Win']: + self.end_game() + + ba.playsound(self._foghorn_sound) + ba.playsound(self._cheer_sound) + + self._puck.scored = True + + # Kill the puck (it'll respawn itself shortly). + ba.timer(1.0, self._kill_puck) + + light = ba.newnode('light', + attrs={ + 'position': ba.get_collision_info('position'), + 'height_attenuated': False, + 'color': (1, 0, 0) + }) + ba.animate(light, 'intensity', {0: 0, 0.5: 1, 1.0: 0}, loop=True) + ba.timer(1.0, light.delete) + + ba.cameraflash(duration=10.0) + self._update_scoreboard() + + def end_game(self) -> None: + results = ba.TeamGameResults() + for team in self.teams: + results.set_team_score(team, team.gamedata['score']) + self.end(results=results) + + def _update_scoreboard(self) -> None: + """ update scoreboard and check for winners """ + winscore = self.settings['Score to Win'] + for team in self.teams: + self._scoreboard.set_team_value(team, team.gamedata['score'], + winscore) + + def handlemessage(self, msg: Any) -> Any: + + # Respawn dead players if they're still in the game. + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + # Augment standard behavior... + super().handlemessage(msg) + self.respawn_player(msg.spaz.player) + + # Respawn dead pucks. + elif isinstance(msg, PuckDeathMessage): + if not self.has_ended(): + ba.timer(3.0, self._spawn_puck) + else: + super().handlemessage(msg) + + def _flash_puck_spawn(self) -> None: + light = ba.newnode('light', + attrs={ + 'position': self._puck_spawn_pos, + 'height_attenuated': False, + 'color': (1, 0, 0) + }) + ba.animate(light, 'intensity', {0.0: 0, 0.25: 1, 0.5: 0}, loop=True) + ba.timer(1.0, light.delete) + + def _spawn_puck(self) -> None: + ba.playsound(self._swipsound) + ba.playsound(self._whistle_sound) + self._flash_puck_spawn() + assert self._puck_spawn_pos is not None + self._puck = Puck(position=self._puck_spawn_pos) diff --git a/assets/src/data/scripts/bastd/game/keepaway.py b/assets/src/data/scripts/bastd/game/keepaway.py new file mode 100644 index 00000000..3df02148 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/keepaway.py @@ -0,0 +1,263 @@ +"""Defines a keep-away game type.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.actor import flag as stdflag +from bastd.actor import playerspaz + +if TYPE_CHECKING: + from typing import (Any, Type, List, Tuple, Dict, Optional, Sequence, + Union) + + +# bs_meta export game +class KeepAwayGame(ba.TeamGameActivity): + """Game where you try to keep the flag away from your enemies.""" + + FLAG_NEW = 0 + FLAG_UNCONTESTED = 1 + FLAG_CONTESTED = 2 + FLAG_HELD = 3 + + @classmethod + def get_name(cls) -> str: + return 'Keep Away' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Carry the flag for a set length of time.' + + @classmethod + def get_score_info(cls) -> Dict[str, Any]: + return {'score_name': 'Time Held'} + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + return (issubclass(sessiontype, ba.TeamsSession) + or issubclass(sessiontype, ba.FreeForAllSession)) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ba.getmaps('keep_away') + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + return [ + ("Hold Time", { + 'min_value': 10, + 'default': 30, + 'increment': 10 + }), + ("Time Limit", { + 'choices': [('None', 0), ('1 Minute', 60), ('2 Minutes', 120), + ('5 Minutes', 300), ('10 Minutes', 600), + ('20 Minutes', 1200)], + 'default': 0 + }), + ("Respawn Times", { + 'choices': [('Shorter', 0.25), ('Short', 0.5), ('Normal', 1.0), + ('Long', 2.0), ('Longer', 4.0)], + 'default': 1.0 + }) + ] # yapf: disable + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + super().__init__(settings) + self._scoreboard = Scoreboard() + self._swipsound = ba.getsound("swip") + self._tick_sound = ba.getsound('tick') + self._countdownsounds = { + 10: ba.getsound('announceTen'), + 9: ba.getsound('announceNine'), + 8: ba.getsound('announceEight'), + 7: ba.getsound('announceSeven'), + 6: ba.getsound('announceSix'), + 5: ba.getsound('announceFive'), + 4: ba.getsound('announceFour'), + 3: ba.getsound('announceThree'), + 2: ba.getsound('announceTwo'), + 1: ba.getsound('announceOne') + } + self._flag_spawn_pos: Optional[Sequence[float]] = None + self._update_timer: Optional[ba.Timer] = None + self._holding_players: List[ba.Player] = [] + self._flag_state: Optional[int] = None + self._flag_light: Optional[ba.Node] = None + self._scoring_team: Optional[ba.Team] = None + self._flag: Optional[stdflag.Flag] = None + + def get_instance_description(self) -> Union[str, Sequence]: + return ('Carry the flag for ${ARG1} seconds.', + self.settings['Hold Time']) + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + return ('carry the flag for ${ARG1} seconds', + self.settings['Hold Time']) + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify these args. + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in(self, music='Keep Away') + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['time_remaining'] = self.settings["Hold Time"] + self._update_scoreboard() + + def on_begin(self) -> None: + ba.TeamGameActivity.on_begin(self) + self.setup_standard_time_limit(self.settings['Time Limit']) + self.setup_standard_powerup_drops() + self._flag_spawn_pos = self.map.get_flag_position(None) + self._spawn_flag() + self._update_timer = ba.Timer(1.0, call=self._tick, repeat=True) + self._update_flag_state() + self.project_flag_stand(self._flag_spawn_pos) + + def _tick(self) -> None: + self._update_flag_state() + + # Award points to all living players holding the flag. + for player in self._holding_players: + if player: + assert self.stats + self.stats.player_scored(player, + 3, + screenmessage=False, + display=False) + + scoring_team = self._scoring_team + + if scoring_team is not None: + + if scoring_team.gamedata['time_remaining'] > 0: + ba.playsound(self._tick_sound) + + scoring_team.gamedata['time_remaining'] = max( + 0, scoring_team.gamedata['time_remaining'] - 1) + self._update_scoreboard() + if scoring_team.gamedata['time_remaining'] > 0: + assert self._flag is not None + self._flag.set_score_text( + str(scoring_team.gamedata['time_remaining'])) + + # Announce numbers we have sounds for. + try: + ba.playsound(self._countdownsounds[ + scoring_team.gamedata['time_remaining']]) + except Exception: + pass + + # Winner. + if scoring_team.gamedata['time_remaining'] <= 0: + self.end_game() + + def end_game(self) -> None: + results = ba.TeamGameResults() + for team in self.teams: + results.set_team_score( + team, + self.settings['Hold Time'] - team.gamedata['time_remaining']) + self.end(results=results, announce_delay=0) + + def _update_flag_state(self) -> None: + for team in self.teams: + team.gamedata['holding_flag'] = False + self._holding_players = [] + for player in self.players: + holding_flag = False + try: + assert player.actor is not None + if (player.actor.is_alive() and player.actor.node + and player.actor.node.hold_node): + holding_flag = ( + player.actor.node.hold_node.getnodetype() == 'flag') + except Exception: + ba.print_exception("exception checking hold flag") + if holding_flag: + self._holding_players.append(player) + player.team.gamedata['holding_flag'] = True + + holding_teams = set(t for t in self.teams + if t.gamedata['holding_flag']) + prev_state = self._flag_state + assert self._flag is not None + assert self._flag_light + assert self._flag.node + if len(holding_teams) > 1: + self._flag_state = self.FLAG_CONTESTED + self._scoring_team = None + self._flag_light.color = (0.6, 0.6, 0.1) + self._flag.node.color = (1.0, 1.0, 0.4) + elif len(holding_teams) == 1: + holding_team = list(holding_teams)[0] + self._flag_state = self.FLAG_HELD + self._scoring_team = holding_team + self._flag_light.color = ba.normalized_color(holding_team.color) + self._flag.node.color = holding_team.color + else: + self._flag_state = self.FLAG_UNCONTESTED + self._scoring_team = None + self._flag_light.color = (0.2, 0.2, 0.2) + self._flag.node.color = (1, 1, 1) + + if self._flag_state != prev_state: + ba.playsound(self._swipsound) + + def _spawn_flag(self) -> None: + ba.playsound(self._swipsound) + self._flash_flag_spawn() + assert self._flag_spawn_pos is not None + self._flag = stdflag.Flag(dropped_timeout=20, + position=self._flag_spawn_pos) + self._flag_state = self.FLAG_NEW + self._flag_light = ba.newnode('light', + owner=self._flag.node, + attrs={ + 'intensity': 0.2, + 'radius': 0.3, + 'color': (0.2, 0.2, 0.2) + }) + assert self._flag.node + self._flag.node.connectattr('position', self._flag_light, 'position') + self._update_flag_state() + + def _flash_flag_spawn(self) -> None: + light = ba.newnode('light', + attrs={ + 'position': self._flag_spawn_pos, + 'color': (1, 1, 1), + 'radius': 0.3, + 'height_attenuated': False + }) + ba.animate(light, 'intensity', {0.0: 0, 0.25: 0.5, 0.5: 0}, loop=True) + ba.timer(1.0, light.delete) + + def _update_scoreboard(self) -> None: + for team in self.teams: + self._scoreboard.set_team_value(team, + team.gamedata['time_remaining'], + self.settings['Hold Time'], + countdown=True) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + # Augment standard behavior. + super().handlemessage(msg) + self.respawn_player(msg.spaz.player) + elif isinstance(msg, stdflag.FlagDeathMessage): + self._spawn_flag() + elif isinstance( + msg, + (stdflag.FlagDroppedMessage, stdflag.FlagPickedUpMessage)): + self._update_flag_state() + else: + super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/game/kingofthehill.py b/assets/src/data/scripts/bastd/game/kingofthehill.py new file mode 100644 index 00000000..89d7b909 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/kingofthehill.py @@ -0,0 +1,267 @@ +"""Defines the King of the Hill game.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +import ba +from bastd.actor import flag as stdflag +from bastd.actor import playerspaz + +if TYPE_CHECKING: + from weakref import ReferenceType + from typing import (Any, Type, List, Dict, Tuple, Optional, Sequence, + Union) + + +# bs_meta export game +class KingOfTheHillGame(ba.TeamGameActivity): + """Game where a team wins by holding a 'hill' for a set amount of time.""" + + FLAG_NEW = 0 + FLAG_UNCONTESTED = 1 + FLAG_CONTESTED = 2 + FLAG_HELD = 3 + + @classmethod + def get_name(cls) -> str: + return 'King of the Hill' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Secure the flag for a set length of time.' + + @classmethod + def get_score_info(cls) -> Dict[str, Any]: + return {'score_name': 'Time Held'} + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + return issubclass(sessiontype, ba.TeamBaseSession) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ba.getmaps("king_of_the_hill") + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + return [("Hold Time", { + 'min_value': 10, + 'default': 30, + 'increment': 10 + }), + ("Time Limit", { + 'choices': [('None', 0), ('1 Minute', 60), + ('2 Minutes', 120), ('5 Minutes', 300), + ('10 Minutes', 600), ('20 Minutes', 1200)], + 'default': 0 + }), + ("Respawn Times", { + 'choices': [('Shorter', 0.25), ('Short', 0.5), + ('Normal', 1.0), ('Long', 2.0), + ('Longer', 4.0)], + 'default': 1.0 + })] + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + super().__init__(settings) + self._scoreboard = Scoreboard() + self._swipsound = ba.getsound("swip") + self._tick_sound = ba.getsound('tick') + self._countdownsounds = { + 10: ba.getsound('announceTen'), + 9: ba.getsound('announceNine'), + 8: ba.getsound('announceEight'), + 7: ba.getsound('announceSeven'), + 6: ba.getsound('announceSix'), + 5: ba.getsound('announceFive'), + 4: ba.getsound('announceFour'), + 3: ba.getsound('announceThree'), + 2: ba.getsound('announceTwo'), + 1: ba.getsound('announceOne') + } + self._flag_pos: Optional[Sequence[float]] = None + self._flag_state: Optional[int] = None + self._flag: Optional[stdflag.Flag] = None + self._flag_light: Optional[ba.Node] = None + self._scoring_team: Optional[ReferenceType[ba.Team]] = None + + self._flag_region_material = ba.Material() + self._flag_region_material.add_actions( + conditions=("they_have_material", ba.sharedobj('player_material')), + actions=(("modify_part_collision", "collide", + True), ("modify_part_collision", "physical", False), + ("call", "at_connect", + ba.Call(self._handle_player_flag_region_collide, 1)), + ("call", "at_disconnect", + ba.Call(self._handle_player_flag_region_collide, 0)))) + + def get_instance_description(self) -> Union[str, Sequence]: + return ('Secure the flag for ${ARG1} seconds.', + self.settings['Hold Time']) + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + return ('secure the flag for ${ARG1} seconds', + self.settings['Hold Time']) + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify these args. + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in(self, music='Scary') + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['time_remaining'] = self.settings["Hold Time"] + self._update_scoreboard() + + def on_player_join(self, player: ba.Player) -> None: + ba.TeamGameActivity.on_player_join(self, player) + player.gamedata['at_flag'] = 0 + + def on_begin(self) -> None: + ba.TeamGameActivity.on_begin(self) + self.setup_standard_time_limit(self.settings['Time Limit']) + self.setup_standard_powerup_drops() + self._flag_pos = self.map.get_flag_position(None) + ba.timer(1.0, self._tick, repeat=True) + self._flag_state = self.FLAG_NEW + self.project_flag_stand(self._flag_pos) + + self._flag = stdflag.Flag(position=self._flag_pos, + touchable=False, + color=(1, 1, 1)) + self._flag_light = ba.newnode('light', + attrs={ + 'position': self._flag_pos, + 'intensity': 0.2, + 'height_attenuated': False, + 'radius': 0.4, + 'color': (0.2, 0.2, 0.2) + }) + + # Flag region. + flagmats = [ + self._flag_region_material, + ba.sharedobj('region_material') + ] + ba.newnode('region', + attrs={ + 'position': self._flag_pos, + 'scale': (1.8, 1.8, 1.8), + 'type': 'sphere', + 'materials': flagmats + }) + self._update_flag_state() + + def _tick(self) -> None: + self._update_flag_state() + + # Give holding players points. + for player in self.players: + if player.gamedata['at_flag'] > 0: + self.stats.player_scored(player, + 3, + screenmessage=False, + display=False) + + if self._scoring_team is None: + scoring_team = None + else: + scoring_team = self._scoring_team() + if scoring_team: + + if scoring_team.gamedata['time_remaining'] > 0: + ba.playsound(self._tick_sound) + + scoring_team.gamedata['time_remaining'] = max( + 0, scoring_team.gamedata['time_remaining'] - 1) + self._update_scoreboard() + if scoring_team.gamedata['time_remaining'] > 0: + assert self._flag is not None + self._flag.set_score_text( + str(scoring_team.gamedata['time_remaining'])) + + # Announce numbers we have sounds for. + try: + ba.playsound(self._countdownsounds[ + scoring_team.gamedata['time_remaining']]) + except Exception: + pass + + # winner + if scoring_team.gamedata['time_remaining'] <= 0: + self.end_game() + + def end_game(self) -> None: + results = ba.TeamGameResults() + for team in self.teams: + results.set_team_score( + team, + self.settings['Hold Time'] - team.gamedata['time_remaining']) + self.end(results=results, announce_delay=0) + + def _update_flag_state(self) -> None: + holding_teams = set(player.team for player in self.players + if player.gamedata['at_flag']) + prev_state = self._flag_state + assert self._flag_light + assert self._flag is not None + assert self._flag.node + if len(holding_teams) > 1: + self._flag_state = self.FLAG_CONTESTED + self._scoring_team = None + self._flag_light.color = (0.6, 0.6, 0.1) + self._flag.node.color = (1.0, 1.0, 0.4) + elif len(holding_teams) == 1: + holding_team = list(holding_teams)[0] + self._flag_state = self.FLAG_HELD + self._scoring_team = weakref.ref(holding_team) + self._flag_light.color = ba.normalized_color(holding_team.color) + self._flag.node.color = holding_team.color + else: + self._flag_state = self.FLAG_UNCONTESTED + self._scoring_team = None + self._flag_light.color = (0.2, 0.2, 0.2) + self._flag.node.color = (1, 1, 1) + if self._flag_state != prev_state: + ba.playsound(self._swipsound) + + def _handle_player_flag_region_collide(self, colliding: bool) -> None: + playernode = ba.get_collision_info("opposing_node") + try: + player = playernode.getdelegate().getplayer() + except Exception: + return + + # Different parts of us can collide so a single value isn't enough + # also don't count it if we're dead (flying heads shouldn't be able to + # win the game :-) + if colliding and player.is_alive(): + player.gamedata['at_flag'] += 1 + else: + player.gamedata['at_flag'] = max(0, player.gamedata['at_flag'] - 1) + + self._update_flag_state() + + def _update_scoreboard(self) -> None: + for team in self.teams: + self._scoreboard.set_team_value(team, + team.gamedata['time_remaining'], + self.settings['Hold Time'], + countdown=True) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + super().handlemessage(msg) # Augment default. + + # No longer can count as at_flag once dead. + player = msg.spaz.player + player.gamedata['at_flag'] = 0 + self._update_flag_state() + self.respawn_player(player) diff --git a/assets/src/data/scripts/bastd/game/meteorshower.py b/assets/src/data/scripts/bastd/game/meteorshower.py new file mode 100644 index 00000000..88a65e7e --- /dev/null +++ b/assets/src/data/scripts/bastd/game/meteorshower.py @@ -0,0 +1,269 @@ +"""Defines a bomb-dodging mini-game.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor import bomb +from bastd.actor import playerspaz + +if TYPE_CHECKING: + from typing import Any, Tuple, Sequence, Optional, List, Dict, Type + from bastd.actor.onscreentimer import OnScreenTimer + + +# bs_meta export game +class MeteorShowerGame(ba.TeamGameActivity): + """Minigame involving dodging falling bombs.""" + + @classmethod + def get_name(cls) -> str: + return 'Meteor Shower' + + @classmethod + def get_score_info(cls) -> Dict[str, Any]: + return { + 'score_name': 'Survived', + 'score_type': 'milliseconds', + 'score_version': 'B' + } + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Dodge the falling bombs.' + + # we're currently hard-coded for one map.. + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ['Rampage'] + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + return [("Epic Mode", {'default': False})] + + # We support teams, free-for-all, and co-op sessions. + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + return (issubclass(sessiontype, ba.TeamsSession) + or issubclass(sessiontype, ba.FreeForAllSession) + or issubclass(sessiontype, ba.CoopSession)) + + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings) + + if self.settings['Epic Mode']: + self.slow_motion = True + + # Print messages when players die (since its meaningful in this game). + self.announce_player_deaths = True + + self._last_player_death_time: Optional[float] = None + self._meteor_time = 2.0 + self._timer: Optional[OnScreenTimer] = None + + # Called when our game is transitioning in but not ready to start; + # ..we can go ahead and set our music and whatnot. + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME unify these + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in( + self, music='Epic' if self.settings['Epic Mode'] else 'Survival') + + # Called when our game actually starts. + def on_begin(self) -> None: + from bastd.actor.onscreentimer import OnScreenTimer + + ba.TeamGameActivity.on_begin(self) + + # Drop a wave every few seconds.. and every so often drop the time + # between waves ..lets have things increase faster if we have fewer + # players. + delay = 5.0 if len(self.players) > 2 else 2.5 + if self.settings['Epic Mode']: + delay *= 0.25 + ba.timer(delay, self._decrement_meteor_time, repeat=True) + + # Kick off the first wave in a few seconds. + delay = 3.0 + if self.settings['Epic Mode']: + delay *= 0.25 + ba.timer(delay, self._set_meteor_timer) + + self._timer = OnScreenTimer() + self._timer.start() + + # Check for immediate end (if we've only got 1 player, etc). + ba.timer(5.0, self._check_end_game) + + def on_player_join(self, player: ba.Player) -> None: + # Don't allow joining after we start + # (would enable leave/rejoin tomfoolery). + if self.has_begun(): + ba.screenmessage(ba.Lstr(resource='playerDelayedJoinText', + subs=[('${PLAYER}', + player.get_name(full=True))]), + color=(0, 1, 0)) + # For score purposes, mark them as having died right as the + # game started. + assert self._timer is not None + player.gamedata['death_time'] = self._timer.getstarttime() + return + self.spawn_player(player) + + def on_player_leave(self, player: ba.Player) -> None: + # Augment default behavior. + ba.TeamGameActivity.on_player_leave(self, player) + + # A departing player may trigger game-over. + self._check_end_game() + + # overriding the default character spawning.. + def spawn_player(self, player: ba.Player) -> ba.Actor: + spaz = self.spawn_player_spaz(player) + + # Let's reconnect this player's controls to this + # spaz but *without* the ability to attack or pick stuff up. + spaz.connect_controls_to_player(enable_punch=False, + enable_bomb=False, + enable_pickup=False) + + # Also lets have them make some noise when they die. + spaz.play_big_death_sound = True + return spaz + + # Various high-level game events come through this method. + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + + # Augment standard behavior. + super().handlemessage(msg) + + death_time = ba.time() + + # Record the player's moment of death. + msg.spaz.player.gamedata['death_time'] = death_time + + # In co-op mode, end the game the instant everyone dies + # (more accurate looking). + # In teams/ffa, allow a one-second fudge-factor so we can + # get more draws if players die basically at the same time. + if isinstance(self.session, ba.CoopSession): + # Teams will still show up if we check now.. check in + # the next cycle. + ba.pushcall(self._check_end_game) + + # Also record this for a final setting of the clock. + self._last_player_death_time = death_time + else: + ba.timer(1.0, self._check_end_game) + + else: + # Default handler: + super().handlemessage(msg) + + def _check_end_game(self) -> None: + living_team_count = 0 + for team in self.teams: + for player in team.players: + if player.is_alive(): + living_team_count += 1 + break + + # In co-op, we go till everyone is dead.. otherwise we go + # until one team remains. + if isinstance(self.session, ba.CoopSession): + if living_team_count <= 0: + self.end_game() + else: + if living_team_count <= 1: + self.end_game() + + def _set_meteor_timer(self) -> None: + ba.timer((1.0 + 0.2 * random.random()) * self._meteor_time, + self._drop_bomb_cluster) + + def _drop_bomb_cluster(self) -> None: + + # Random note: code like this is a handy way to plot out extents + # and debug things. + loc_test = False + if loc_test: + ba.newnode('locator', attrs={'position': (8, 6, -5.5)}) + ba.newnode('locator', attrs={'position': (8, 6, -2.3)}) + ba.newnode('locator', attrs={'position': (-7.3, 6, -5.5)}) + ba.newnode('locator', attrs={'position': (-7.3, 6, -2.3)}) + + # Drop several bombs in series. + delay = 0.0 + for _i in range(random.randrange(1, 3)): + # Drop them somewhere within our bounds with velocity pointing + # toward the opposite side. + pos = (-7.3 + 15.3 * random.random(), 11, + -5.5 + 2.1 * random.random()) + dropdir = (-1.0 if pos[0] > 0 else 1.0) + vel = ((-5.0 + random.random() * 30.0) * dropdir, -4.0, 0) + ba.timer(delay, ba.Call(self._drop_bomb, pos, vel)) + delay += 0.1 + self._set_meteor_timer() + + def _drop_bomb(self, position: Sequence[float], + velocity: Sequence[float]) -> None: + bomb.Bomb(position=position, velocity=velocity).autoretain() + + def _decrement_meteor_time(self) -> None: + self._meteor_time = max(0.01, self._meteor_time * 0.9) + + def end_game(self) -> None: + cur_time = ba.time() + assert self._timer is not None + + # Mark 'death-time' as now for any still-living players + # and award players points for how long they lasted. + # (these per-player scores are only meaningful in team-games) + for team in self.teams: + for player in team.players: + + # Throw an extra fudge factor in so teams that + # didn't die come out ahead of teams that did. + if 'death_time' not in player.gamedata: + player.gamedata['death_time'] = cur_time + 0.001 + + # Award a per-player score depending on how many seconds + # they lasted (per-player scores only affect teams mode; + # everywhere else just looks at the per-team score). + score = int(player.gamedata['death_time'] - + self._timer.getstarttime()) + if 'death_time' not in player.gamedata: + score += 50 # a bit extra for survivors + self.stats.player_scored(player, score, screenmessage=False) + + # Stop updating our time text, and set the final time to match + # exactly when our last guy died. + self._timer.stop(endtime=self._last_player_death_time) + + # Ok now calc game results: set a score for each team and then tell + # the game to end. + results = ba.TeamGameResults() + + # Remember that 'free-for-all' mode is simply a special form + # of 'teams' mode where each player gets their own team, so we can + # just always deal in teams and have all cases covered. + for team in self.teams: + + # Set the team score to the max time survived by any player on + # that team. + longest_life = 0 + for player in team.players: + longest_life = max(longest_life, + (player.gamedata['death_time'] - + self._timer.getstarttime())) + results.set_team_score(team, longest_life) + + self.end(results=results) diff --git a/assets/src/data/scripts/bastd/game/ninjafight.py b/assets/src/data/scripts/bastd/game/ninjafight.py new file mode 100644 index 00000000..f0ce38c5 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/ninjafight.py @@ -0,0 +1,195 @@ +"""Provides Ninja Fight mini-game.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor import onscreentimer +from bastd.actor import playerspaz +from bastd.actor import spazbot + +if TYPE_CHECKING: + from typing import Any, Type, Dict, List, Optional + + +# bs_meta export game +class NinjaFightGame(ba.TeamGameActivity): + """ + A co-op game where you try to defeat a group + of Ninjas as fast as possible + """ + + @classmethod + def get_name(cls) -> str: + return 'Ninja Fight' + + @classmethod + def get_score_info(cls) -> Dict[str, Any]: + return { + 'score_type': 'milliseconds', + 'lower_is_better': True, + 'score_name': 'Time' + } + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'How fast can you defeat the ninjas?' + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + # For now we're hard-coding spawn positions and whatnot + # so we need to be sure to specify that we only support + # a specific map. + return ['Courtyard'] + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + # We currently support Co-Op only. + return issubclass(sessiontype, ba.CoopSession) + + # In the constructor we should load any media we need/etc. + # ...but not actually create anything yet. + def __init__(self, settings: Dict[str, Any]): + super().__init__(settings) + self._winsound = ba.getsound("score") + self._won = False + self._timer: Optional[onscreentimer.OnScreenTimer] = None + self._bots = spazbot.BotSet() + + # Called when our game is transitioning in but not ready to begin; + # we can go ahead and start creating stuff, playing music, etc. + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify args. + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in(self, music='ToTheDeath') + + # Called when our game actually begins. + def on_begin(self) -> None: + ba.TeamGameActivity.on_begin(self) + is_pro = self.settings.get('preset') == 'pro' + + # In pro mode there's no powerups. + if not is_pro: + self.setup_standard_powerup_drops() + + # Make our on-screen timer and start it roughly when our bots appear. + self._timer = onscreentimer.OnScreenTimer() + ba.timer(4.0, self._timer.start) + + # Spawn some baddies. + ba.timer( + 1.0, + ba.Call(self._bots.spawn_bot, + spazbot.ChargerBot, + pos=(3, 3, -2), + spawn_time=3.0)) + ba.timer( + 2.0, + ba.Call(self._bots.spawn_bot, + spazbot.ChargerBot, + pos=(-3, 3, -2), + spawn_time=3.0)) + ba.timer( + 3.0, + ba.Call(self._bots.spawn_bot, + spazbot.ChargerBot, + pos=(5, 3, -2), + spawn_time=3.0)) + ba.timer( + 4.0, + ba.Call(self._bots.spawn_bot, + spazbot.ChargerBot, + pos=(-5, 3, -2), + spawn_time=3.0)) + + # Add some extras for multiplayer or pro mode. + assert self.initial_player_info is not None + if len(self.initial_player_info) > 2 or is_pro: + ba.timer( + 5.0, + ba.Call(self._bots.spawn_bot, + spazbot.ChargerBot, + pos=(0, 3, -5), + spawn_time=3.0)) + if len(self.initial_player_info) > 3 or is_pro: + ba.timer( + 6.0, + ba.Call(self._bots.spawn_bot, + spazbot.ChargerBot, + pos=(0, 3, 1), + spawn_time=3.0)) + + # Called for each spawning player. + def spawn_player(self, player: ba.Player) -> ba.Actor: + + # Let's spawn close to the center. + spawn_center = (0, 3, -2) + pos = (spawn_center[0] + random.uniform(-1.5, 1.5), spawn_center[1], + spawn_center[2] + random.uniform(-1.5, 1.5)) + return self.spawn_player_spaz(player, position=pos) + + def _check_if_won(self) -> None: + # Simply end the game if there's no living bots. + # FIXME: Should also make sure all bots have been spawned; + # if spawning is spread out enough that we're able to kill + # all living bots before the next spawns, it would incorrectly + # count as a win. + if not self._bots.have_living_bots(): + self._won = True + self.end_game() + + # Called for miscellaneous messages. + def handlemessage(self, msg: Any) -> Any: + + # A player has died. + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + super().handlemessage(msg) # do standard stuff + self.respawn_player(msg.spaz.player) # kick off a respawn + + # A spaz-bot has died. + elif isinstance(msg, spazbot.SpazBotDeathMessage): + # Unfortunately the bot-set will always tell us there are living + # bots if we ask here (the currently-dying bot isn't officially + # marked dead yet) ..so lets push a call into the event loop to + # check once this guy has finished dying. + ba.pushcall(self._check_if_won) + else: + # Let the base class handle anything we don't. + super().handlemessage(msg) + + # When this is called, we should fill out results and end the game + # *regardless* of whether is has been won. (this may be called due + # to a tournament ending or other external reason). + def end_game(self) -> None: + + # Stop our on-screen timer so players can see what they got. + assert self._timer is not None + self._timer.stop() + + results = ba.TeamGameResults() + + # If we won, set our score to the elapsed time + # (there should just be 1 team here since this is co-op). + # ..if we didn't win, leave scores as default (None) which means + # we lost. + if self._won: + curtime = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(curtime, int) + starttime = self._timer.getstarttime( + timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(starttime, int) + elapsed_time_ms = curtime - starttime + ba.cameraflash() + ba.playsound(self._winsound) + for team in self.teams: + team.celebrate() # Woooo! par-tay! + results.set_team_score(team, elapsed_time_ms) + + # Ends the activity. + self.end(results) diff --git a/assets/src/data/scripts/bastd/game/onslaught.py b/assets/src/data/scripts/bastd/game/onslaught.py new file mode 100644 index 00000000..a917ed51 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/onslaught.py @@ -0,0 +1,1268 @@ +"""Provides Onslaught Co-op game.""" + +# Yes this is a long one.. +# pylint: disable=too-many-lines + +from __future__ import annotations + +import math +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor import bomb as stdbomb +from bastd.actor import playerspaz, spazbot + +if TYPE_CHECKING: + from typing import Any, Type, Dict, Optional, List, Tuple, Union, Sequence + from bastd.actor.scoreboard import Scoreboard + + +class OnslaughtGame(ba.CoopGameActivity): + """Co-op game where players try to survive attacking waves of enemies.""" + + tips: List[Union[str, Dict[str, Any]]] = [ + 'Hold any button to run.' + ' (Trigger buttons work well if you have them)', + 'Try tricking enemies into killing eachother or running off cliffs.', + 'Try \'Cooking off\' bombs for a second or two before throwing them.', + 'It\'s easier to win with a friend or two helping.', + 'If you stay in one place, you\'re toast. Run and dodge to survive..', + 'Practice using your momentum to throw bombs more accurately.', + 'Your punches do much more damage if you are running or spinning.' + ] + + @classmethod + def get_name(cls) -> str: + return 'Onslaught' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return "Defeat all enemies." + + def __init__(self, settings: Dict[str, Any]): + + self._preset = settings.get('preset', 'training') + if self._preset in [ + 'training', + 'training_easy', + 'pro', + 'pro_easy', + 'endless', + 'endless_tournament', + ]: + settings['map'] = 'Doom Shroom' + else: + settings['map'] = 'Courtyard' + + super().__init__(settings) + + # Show messages when players die since it matters here. + self.announce_player_deaths = True + + self._new_wave_sound = ba.getsound('scoreHit01') + self._winsound = ba.getsound("score") + self._cashregistersound = ba.getsound('cashRegister') + self._a_player_has_been_hurt = False + self._player_has_dropped_bomb = False + + # FIXME: should use standard map defs. + if settings['map'] == 'Doom Shroom': + self._spawn_center = (0, 3, -5) + self._tntspawnpos = (0.0, 3.0, -5.0) + self._powerup_center = (0, 5, -3.6) + self._powerup_spread = (6.0, 4.0) + elif settings['map'] == 'Courtyard': + self._spawn_center = (0, 3, -2) + self._tntspawnpos = (0.0, 3.0, 2.1) + self._powerup_center = (0, 5, -1.6) + self._powerup_spread = (4.6, 2.7) + else: + raise Exception("Unsupported map: " + str(settings['map'])) + self._scoreboard: Optional[Scoreboard] = None + self._game_over = False + self._wave = 0 + self._can_end_wave = True + self._score = 0 + self._time_bonus = 0 + self._spawn_info_text: Optional[ba.Actor] = None + self._dingsound = ba.getsound('dingSmall') + self._dingsoundhigh = ba.getsound('dingSmallHigh') + self._have_tnt = False + self._excludepowerups: Optional[List[str]] = None + self._waves: Optional[List[Dict[str, Any]]] = None + self._tntspawner: Optional[stdbomb.TNTSpawner] = None + self._bots: Optional[spazbot.BotSet] = None + self._powerup_drop_timer: Optional[ba.Timer] = None + self._time_bonus_timer: Optional[ba.Timer] = None + self._time_bonus_text: Optional[ba.Actor] = None + self._flawless_bonus: Optional[int] = None + self._wave_text: Optional[ba.Actor] = None + self._wave_update_timer: Optional[ba.Timer] = None + self._throw_off_kills = 0 + self._land_mine_kills = 0 + self._tnt_kills = 0 + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify these args. + # pylint: disable=arguments-differ + from bastd.actor.scoreboard import Scoreboard + ba.CoopGameActivity.on_transition_in(self) + + # Show special landmine tip on rookie preset. + if self._preset in ['rookie', 'rookie_easy']: + # Show once per session only (then we revert to regular tips). + if not hasattr(ba.getsession(), + '_g_showed_onslaught_land_mine_tip'): + # pylint: disable=protected-access + ba.getsession( # type: ignore + )._g_showed_onslaught_land_mine_tip = True + self.tips = [{ + 'tip': "Land-mines are a good way" + " to stop speedy enemies.", + 'icon': ba.gettexture('powerupLandMines'), + 'sound': ba.getsound('ding') + }] + + # Show special tnt tip on pro preset. + if self._preset in ['pro', 'pro_easy']: + # Show once per session only (then we revert to regular tips). + if not hasattr(ba.getsession(), '_g_showed_onslaught_tnt_tip'): + # pylint: disable=protected-access + ba.getsession( # type: ignore + )._g_showed_onslaught_tnt_tip = True + self.tips = [{ + 'tip': "Take out a group of enemies by\n" + "setting off a bomb near a TNT box.", + 'icon': ba.gettexture('tnt'), + 'sound': ba.getsound('ding') + }] + + # Show special curse tip on uber preset. + if self._preset in ['uber', 'uber_easy']: + # Show once per session only (then we revert to regular tips). + if not hasattr(ba.getsession(), '_g_showed_onslaught_curse_tip'): + # pylint: disable=protected-access + ba.getsession( # type: ignore + )._g_showed_onslaught_curse_tip = True + self.tips = [{ + 'tip': "Curse boxes turn you into a ticking time bomb.\n" + "The only cure is to quickly grab a health-pack.", + 'icon': ba.gettexture('powerupCurse'), + 'sound': ba.getsound('ding') + }] + + self._spawn_info_text = ba.Actor( + ba.newnode("text", + attrs={ + 'position': (15, -130), + 'h_attach': "left", + 'v_attach': "top", + 'scale': 0.55, + 'color': (0.3, 0.8, 0.3, 1.0), + 'text': '' + })) + ba.setmusic('Onslaught') + + self._scoreboard = Scoreboard(label=ba.Lstr(resource='scoreText'), + score_split=0.5) + + def on_begin(self) -> None: + from bastd.actor.controlsguide import ControlsGuide + super().on_begin() + player_count = len(self.players) + hard = self._preset not in [ + 'training_easy', 'rookie_easy', 'pro_easy', 'uber_easy' + ] + if self._preset in ['training', 'training_easy']: + ControlsGuide(delay=3.0, lifespan=10.0, bright=True).autoretain() + + self._have_tnt = False + self._excludepowerups = ['curse', 'land_mines'] + self._waves = [ + {'base_angle': 195, + 'entries': [ + {'type': spazbot.BomberBotLite, 'spacing': 5}, + ] * player_count}, + {'base_angle': 130, + 'entries': [ + {'type': spazbot.BrawlerBotLite, 'spacing': 5}, + ] * player_count}, + {'base_angle': 195, + 'entries': [ + {'type': spazbot.BomberBotLite, 'spacing': 10}, + ] * (player_count + 1)}, + {'base_angle': 130, + 'entries': [ + {'type': spazbot.BrawlerBotLite, 'spacing': 10}, + ] * (player_count + 1)}, + {'base_angle': 130, + 'entries': [ + {'type': spazbot.BrawlerBotLite, 'spacing': 5} + if player_count > 1 else None, + {'type': spazbot.BrawlerBotLite, 'spacing': 5}, + {'type': None, 'spacing': 30}, + {'type': spazbot.BomberBotLite, 'spacing': 5} + if player_count > 3 else None, + {'type': spazbot.BomberBotLite, 'spacing': 5}, + {'type': None, 'spacing': 30}, + {'type': spazbot.BrawlerBotLite, 'spacing': 5}, + {'type': spazbot.BrawlerBotLite, 'spacing': 5} + if player_count > 2 else None, + ]}, + {'base_angle': 195, + 'entries': [ + {'type': spazbot.TriggerBot, 'spacing': 90}, + {'type': spazbot.TriggerBot, 'spacing': 90} + if player_count > 1 else None, + ]}, + ] # yapf: disable + + elif self._preset in ['rookie', 'rookie_easy']: + self._have_tnt = False + self._excludepowerups = ['curse'] + self._waves = [ + {'entries': [ + {'type': spazbot.ChargerBot, 'point': 'left_upper_more'} + if player_count > 2 else None, + {'type': spazbot.ChargerBot, 'point': 'left_upper'}, + ]}, + {'entries': [ + {'type': spazbot.BomberBotStaticLite, + 'point': 'turret_top_right'}, + {'type': spazbot.BrawlerBotLite, 'point': 'right_upper'}, + {'type': spazbot.BrawlerBotLite, 'point': 'right_lower'} + if player_count > 1 else None, + {'type': spazbot.BomberBotStaticLite, + 'point': 'turret_bottom_right'} + if player_count > 2 else None, + ]}, + {'entries': [ + {'type': spazbot.BomberBotStaticLite, + 'point': 'turret_bottom_left'}, + {'type': spazbot.TriggerBot, 'point': 'Left'}, + {'type': spazbot.TriggerBot, 'point': 'left_lower'} + if player_count > 1 else None, + {'type': spazbot.TriggerBot, 'point': 'left_upper'} + if player_count > 2 else None, + ]}, + {'entries': [ + {'type': spazbot.BrawlerBotLite, 'point': 'top_right'}, + {'type': spazbot.BrawlerBot, 'point': 'top_half_right'} + if player_count > 1 else None, + {'type': spazbot.BrawlerBotLite, 'point': 'top_left'}, + {'type': spazbot.BrawlerBotLite, 'point': 'top_half_left'} + if player_count > 2 else None, + {'type': spazbot.BrawlerBot, 'point': 'top'}, + {'type': spazbot.BomberBotStaticLite, + 'point': 'turret_top_middle'}, + ]}, + {'entries': [ + {'type': spazbot.TriggerBotStatic, + 'point': 'turret_bottom_left'}, + {'type': spazbot.TriggerBotStatic, + 'point': 'turret_bottom_right'}, + {'type': spazbot.TriggerBot, 'point': 'bottom'}, + {'type': spazbot.TriggerBot, 'point': 'bottom_half_right'} + if player_count > 1 else None, + {'type': spazbot.TriggerBot, 'point': 'bottom_half_left'} + if player_count > 2 else None, + ]}, + {'entries': [ + {'type': spazbot.BomberBotStaticLite, + 'point': 'turret_top_left'}, + {'type': spazbot.BomberBotStaticLite, + 'point': 'turret_top_right'}, + {'type': spazbot.ChargerBot, 'point': 'bottom'}, + {'type': spazbot.ChargerBot, 'point': 'bottom_half_left'} + if player_count > 1 else None, + {'type': spazbot.ChargerBot, 'point': 'bottom_half_right'} + if player_count > 2 else None, + ]}, + ] # yapf: disable + + elif self._preset in ['pro', 'pro_easy']: + self._excludepowerups = ['curse'] + self._have_tnt = True + self._waves = [ + {'base_angle': -50, + 'entries': [ + {'type': spazbot.BrawlerBot, 'spacing': 12} + if player_count > 3 else None, + {'type': spazbot.BrawlerBot, 'spacing': 12}, + {'type': spazbot.BomberBot, 'spacing': 6}, + {'type': spazbot.BomberBot, 'spacing': 6} + if self._preset == 'pro' else None, + {'type': spazbot.BomberBot, 'spacing': 6} + if player_count > 1 else None, + {'type': spazbot.BrawlerBot, 'spacing': 12}, + {'type': spazbot.BrawlerBot, 'spacing': 12} + if player_count > 2 else None, + ]}, + {'base_angle': 180, + 'entries': [ + {'type': spazbot.BrawlerBot, 'spacing': 6} + if player_count > 3 else None, + {'type': spazbot.BrawlerBot, 'spacing': 6} + if self._preset == 'pro' else None, + {'type': spazbot.BrawlerBot, 'spacing': 6}, + {'type': spazbot.ChargerBot, 'spacing': 45}, + {'type': spazbot.ChargerBot, 'spacing': 45} + if player_count > 1 else None, + {'type': spazbot.BrawlerBot, 'spacing': 6}, + {'type': spazbot.BrawlerBot, 'spacing': 6} + if self._preset == 'pro' else None, + {'type': spazbot.BrawlerBot, 'spacing': 6} + if player_count > 2 else None, + ]}, + {'base_angle': 0, + 'entries': [ + {'type': spazbot.ChargerBot, 'spacing': 30}, + {'type': spazbot.TriggerBot, 'spacing': 30}, + {'type': spazbot.TriggerBot, 'spacing': 30}, + {'type': spazbot.TriggerBot, 'spacing': 30} + if self._preset == 'pro' else None, + {'type': spazbot.TriggerBot, 'spacing': 30} + if player_count > 1 else None, + {'type': spazbot.TriggerBot, 'spacing': 30} + if player_count > 3 else None, + {'type': spazbot.ChargerBot, 'spacing': 30}, + ]}, + {'base_angle': 90, + 'entries': [ + {'type': spazbot.StickyBot, 'spacing': 50}, + {'type': spazbot.StickyBot, 'spacing': 50} + if self._preset == 'pro' else None, + {'type': spazbot.StickyBot, 'spacing': 50}, + {'type': spazbot.StickyBot, 'spacing': 50} + if player_count > 1 else None, + {'type': spazbot.StickyBot, 'spacing': 50} + if player_count > 3 else None, + ]}, + {'base_angle': 0, + 'entries': [ + {'type': spazbot.TriggerBot, 'spacing': 72}, + {'type': spazbot.TriggerBot, 'spacing': 72}, + {'type': spazbot.TriggerBot, 'spacing': 72} + if self._preset == 'pro' else None, + {'type': spazbot.TriggerBot, 'spacing': 72}, + {'type': spazbot.TriggerBot, 'spacing': 72}, + {'type': spazbot.TriggerBot, 'spacing': 36} + if player_count > 2 else None, + ]}, + {'base_angle': 30, + 'entries': [ + {'type': spazbot.ChargerBotProShielded, 'spacing': 50}, + {'type': spazbot.ChargerBotProShielded, 'spacing': 50}, + {'type': spazbot.ChargerBotProShielded, 'spacing': 50} + if self._preset == 'pro' else None, + {'type': spazbot.ChargerBotProShielded, 'spacing': 50} + if player_count > 1 else None, + {'type': spazbot.ChargerBotProShielded, 'spacing': 50} + if player_count > 2 else None, + ]} + ] # yapf: disable + + elif self._preset in ['uber', 'uber_easy']: + + # Show controls help in kiosk mode. + if ba.app.kiosk_mode: + ControlsGuide(delay=3.0, lifespan=10.0, + bright=True).autoretain() + + self._have_tnt = True + self._excludepowerups = [] + self._waves = [ + {'entries': [ + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_top_middle_left'} + if hard else None, + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_top_middle_right'}, + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_top_left'} + if player_count > 2 else None, + {'type': spazbot.ExplodeyBot, 'point': 'top_right'}, + {'type': 'delay', 'duration': 4.0}, + {'type': spazbot.ExplodeyBot, 'point': 'top_left'}, + ]}, + {'entries': [ + {'type': spazbot.ChargerBot, 'point': 'Left'}, + {'type': spazbot.ChargerBot, 'point': 'Right'}, + {'type': spazbot.ChargerBot, 'point': 'right_upper_more'} + if player_count > 2 else None, + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_top_left'}, + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_top_right'}, + ]}, + {'entries': [ + {'type': spazbot.TriggerBotPro, 'point': 'top_right'}, + {'type': spazbot.TriggerBotPro, + 'point': 'right_upper_more'} + if player_count > 1 else None, + {'type': spazbot.TriggerBotPro, 'point': 'right_upper'}, + {'type': spazbot.TriggerBotPro, 'point': 'right_lower'} + if hard else None, + {'type': spazbot.TriggerBotPro, + 'point': 'right_lower_more'} + if player_count > 2 else None, + {'type': spazbot.TriggerBotPro, 'point': 'bottom_right'}, + ]}, + {'entries': [ + {'type': spazbot.ChargerBotProShielded, + 'point': 'bottom_right'}, + {'type': spazbot.ChargerBotProShielded, 'point': 'Bottom'} + if player_count > 2 else None, + {'type': spazbot.ChargerBotProShielded, + 'point': 'bottom_left'}, + {'type': spazbot.ChargerBotProShielded, 'point': 'Top'} + if hard else None, + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_top_middle'}, + ]}, + {'entries': [ + {'type': spazbot.ExplodeyBot, 'point': 'left_upper'}, + {'type': 'delay', 'duration': 1.0}, + {'type': spazbot.BrawlerBotProShielded, + 'point': 'left_lower'}, + {'type': spazbot.BrawlerBotProShielded, + 'point': 'left_lower_more'}, + {'type': 'delay', 'duration': 4.0}, + {'type': spazbot.ExplodeyBot, 'point': 'right_upper'}, + {'type': 'delay', 'duration': 1.0}, + {'type': spazbot.BrawlerBotProShielded, + 'point': 'right_lower'}, + {'type': spazbot.BrawlerBotProShielded, + 'point': 'right_upper_more'}, + {'type': 'delay', 'duration': 4.0}, + {'type': spazbot.ExplodeyBot, 'point': 'Left'}, + {'type': 'delay', 'duration': 5.0}, + {'type': spazbot.ExplodeyBot, 'point': 'Right'}, + ]}, + {'entries': [ + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_top_left'}, + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_top_right'}, + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_bottom_left'}, + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_bottom_right'}, + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_top_middle_left'} if hard else None, + {'type': spazbot.BomberBotProStatic, + 'point': 'turret_top_middle_right'} if hard else None, + ] + }] # yapf: disable + + # We generate these on the fly in endless. + elif self._preset in ['endless', 'endless_tournament']: + self._have_tnt = True + self._excludepowerups = [] + + else: + raise Exception("Invalid preset: " + str(self._preset)) + + # FIXME: Should migrate to use setup_standard_powerup_drops(). + + # Spit out a few powerups and start dropping more shortly. + self._drop_powerups( + standard_points=True, + poweruptype='curse' if self._preset in ['uber', 'uber_easy'] else + ('land_mines' + if self._preset in ['rookie', 'rookie_easy'] else None)) + ba.timer(4.0, self._start_powerup_drops) + + # Our TNT spawner (if applicable). + if self._have_tnt: + self._tntspawner = stdbomb.TNTSpawner(position=self._tntspawnpos) + + self.setup_low_life_warning_sound() + self._update_scores() + self._bots = spazbot.BotSet() + ba.timer(4.0, self._start_updating_waves) + + def _on_got_scores_to_beat(self, scores: List[Dict[str, Any]]) -> None: + self._show_standard_scores_to_beat_ui(scores) + + def _get_distribution(self, target_points: int, min_dudes: int, + max_dudes: int, group_count: int, + max_level: int) -> List[List[Tuple[int, int]]]: + """ calculate a distribution of bad guys given some params """ + # FIXME; This method wears the cone of shame + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + # pylint: disable=too-many-nested-blocks + max_iterations = 10 + max_dudes * 2 + + def _get_totals(grps: List[Any]) -> Tuple[int, int]: + totalpts = 0 + totaldudes = 0 + for grp in grps: + for grpentry in grp: + dudes = grpentry[1] + totalpts += grpentry[0] * dudes + totaldudes += dudes + return totalpts, totaldudes + + groups: List[List[Tuple[int, int]]] = [] + for _g in range(group_count): + groups.append([]) + types = [1] + if max_level > 1: + types.append(2) + if max_level > 2: + types.append(3) + if max_level > 3: + types.append(4) + for iteration in range(max_iterations): + + # See how much we're off our target by. + total_points, total_dudes = _get_totals(groups) + diff = target_points - total_points + dudes_diff = max_dudes - total_dudes + + # Add an entry if one will fit. + value = types[random.randrange(len(types))] + group = groups[random.randrange(len(groups))] + if not group: + max_count = random.randint(1, 6) + else: + max_count = 2 * random.randint(1, 3) + max_count = min(max_count, dudes_diff) + count = min(max_count, diff // value) + if count > 0: + group.append((value, count)) + total_points += value * count + total_dudes += count + diff = target_points - total_points + + total_points, total_dudes = _get_totals(groups) + full = (total_points >= target_points) + + if full: + # Every so often, delete a random entry just to + # shake up our distribution. + if random.random() < 0.2 and iteration != max_iterations - 1: + entry_count = 0 + for group in groups: + for _ in group: + entry_count += 1 + if entry_count > 1: + del_entry = random.randrange(entry_count) + entry_count = 0 + for group in groups: + for entry in group: + if entry_count == del_entry: + group.remove(entry) + break + entry_count += 1 + + # If we don't have enough dudes, kill the group with + # the biggest point value. + elif (total_dudes < min_dudes + and iteration != max_iterations - 1): + biggest_value = 9999 + biggest_entry = None + biggest_entry_group = None + for group in groups: + for entry in group: + if (entry[0] > biggest_value + or biggest_entry is None): + biggest_value = entry[0] + biggest_entry = entry + biggest_entry_group = group + if biggest_entry is not None: + assert biggest_entry_group is not None + biggest_entry_group.remove(biggest_entry) + + # If we've got too many dudes, kill the group with the + # smallest point value. + elif (total_dudes > max_dudes + and iteration != max_iterations - 1): + smallest_value = 9999 + smallest_entry = None + smallest_entry_group = None + for group in groups: + for entry in group: + if (entry[0] < smallest_value + or smallest_entry is None): + smallest_value = entry[0] + smallest_entry = entry + smallest_entry_group = group + assert smallest_entry is not None + assert smallest_entry_group is not None + smallest_entry_group.remove(smallest_entry) + + # Close enough.. we're done. + else: + if diff == 0: + break + + return groups + + def spawn_player(self, player: ba.Player) -> ba.Actor: + + # We keep track of who got hurt each wave for score purposes. + player.gamedata['has_been_hurt'] = False + pos = (self._spawn_center[0] + random.uniform(-1.5, 1.5), + self._spawn_center[1], + self._spawn_center[2] + random.uniform(-1.5, 1.5)) + spaz = self.spawn_player_spaz(player, position=pos) + if self._preset in [ + 'training_easy', 'rookie_easy', 'pro_easy', 'uber_easy' + ]: + spaz.impact_scale = 0.25 + spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb) + return spaz + + def _handle_player_dropped_bomb(self, player: ba.Actor, + bomb: ba.Actor) -> None: + del player, bomb # Unused. + self._player_has_dropped_bomb = True + + def _drop_powerup(self, index: int, poweruptype: str = None) -> None: + from bastd.actor import powerupbox + poweruptype = (powerupbox.get_factory().get_random_powerup_type( + forcetype=poweruptype, excludetypes=self._excludepowerups)) + powerupbox.PowerupBox(position=self.map.powerup_spawn_points[index], + poweruptype=poweruptype).autoretain() + + def _start_powerup_drops(self) -> None: + self._powerup_drop_timer = ba.Timer(3.0, + ba.WeakCall(self._drop_powerups), + repeat=True) + + def _drop_powerups(self, + standard_points: bool = False, + poweruptype: str = None) -> None: + """Generic powerup drop.""" + from bastd.actor import powerupbox + if standard_points: + points = self.map.powerup_spawn_points + for i in range(len(points)): + ba.timer( + 1.0 + i * 0.5, + ba.WeakCall(self._drop_powerup, i, + poweruptype if i == 0 else None)) + else: + point = (self._powerup_center[0] + random.uniform( + -1.0 * self._powerup_spread[0], 1.0 * self._powerup_spread[0]), + self._powerup_center[1], + self._powerup_center[2] + random.uniform( + -self._powerup_spread[1], self._powerup_spread[1])) + + # Drop one random one somewhere. + powerupbox.PowerupBox( + position=point, + poweruptype=powerupbox.get_factory().get_random_powerup_type( + excludetypes=self._excludepowerups)).autoretain() + + def do_end(self, outcome: str, delay: float = 0.0) -> None: + """End the game with the specified outcome.""" + if outcome == 'defeat': + self.fade_to_red() + score: Optional[int] + if self._wave >= 2: + score = self._score + fail_message = None + else: + score = None + fail_message = ba.Lstr(resource='reachWave2Text') + self.end( + { + 'outcome': outcome, + 'score': score, + 'fail_message': fail_message, + 'player_info': self.initial_player_info + }, + delay=delay) + + def _update_waves(self) -> None: + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + + # If we have no living bots, go to the next wave. + assert self._bots is not None + if (self._can_end_wave and not self._bots.have_living_bots() + and not self._game_over): + self._can_end_wave = False + self._time_bonus_timer = None + self._time_bonus_text = None + if self._preset in ['endless', 'endless_tournament']: + won = False + else: + assert self._waves is not None + won = (self._wave == len(self._waves)) + + # Reward time bonus. + base_delay = 4.0 if won else 0.0 + + if self._time_bonus > 0: + ba.timer(0, lambda: ba.playsound(self._cashregistersound)) + ba.timer(base_delay, + ba.WeakCall(self._award_time_bonus, self._time_bonus)) + base_delay += 1.0 + + # Reward flawless bonus. + if self._wave > 0: + have_flawless = False + for player in self.players: + if (player.is_alive() + and not player.gamedata['has_been_hurt']): + have_flawless = True + ba.timer( + base_delay, + ba.WeakCall(self._award_flawless_bonus, player)) + player.gamedata['has_been_hurt'] = False # reset + if have_flawless: + base_delay += 1.0 + + if won: + self.show_zoom_message(ba.Lstr(resource='victoryText'), + scale=1.0, + duration=4.0) + self.celebrate(20.0) + + # Rookie onslaught completion. + if self._preset in ['training', 'training_easy']: + self._award_achievement('Onslaught Training Victory', + sound=False) + if not self._player_has_dropped_bomb: + self._award_achievement('Boxer', sound=False) + elif self._preset in ['rookie', 'rookie_easy']: + self._award_achievement('Rookie Onslaught Victory', + sound=False) + if not self._a_player_has_been_hurt: + self._award_achievement('Flawless Victory', + sound=False) + elif self._preset in ['pro', 'pro_easy']: + self._award_achievement('Pro Onslaught Victory', + sound=False) + if not self._player_has_dropped_bomb: + self._award_achievement('Pro Boxer', sound=False) + elif self._preset in ['uber', 'uber_easy']: + self._award_achievement('Uber Onslaught Victory', + sound=False) + + ba.timer(base_delay, ba.WeakCall(self._award_completion_bonus)) + base_delay += 0.85 + ba.playsound(self._winsound) + ba.cameraflash() + ba.setmusic('Victory') + self._game_over = True + + # Can't just pass delay to do_end because our extra bonuses + # haven't been added yet (once we call do_end the score + # gets locked in). + ba.timer(base_delay, ba.WeakCall(self.do_end, 'victory')) + return + + self._wave += 1 + + # Short celebration after waves. + if self._wave > 1: + self.celebrate(0.5) + ba.timer(base_delay, ba.WeakCall(self._start_next_wave)) + + def _award_completion_bonus(self) -> None: + ba.playsound(self._cashregistersound) + for player in self.players: + try: + if player.is_alive(): + assert self.initial_player_info is not None + self.stats.player_scored( + player, + int(100 / len(self.initial_player_info)), + scale=1.4, + color=(0.6, 0.6, 1.0, 1.0), + title=ba.Lstr(resource='completionBonusText'), + screenmessage=False) + except Exception: + ba.print_exception() + + def _award_time_bonus(self, bonus: int) -> None: + from bastd.actor import popuptext + ba.playsound(self._cashregistersound) + popuptext.PopupText(ba.Lstr(value='+${A} ${B}', + subs=[('${A}', str(bonus)), + ('${B}', + ba.Lstr(resource='timeBonusText')) + ]), + color=(1, 1, 0.5, 1), + scale=1.0, + position=(0, 3, -1)).autoretain() + self._score += self._time_bonus + self._update_scores() + + def _award_flawless_bonus(self, player: ba.Player) -> None: + ba.playsound(self._cashregistersound) + try: + if player.is_alive(): + assert self._flawless_bonus is not None + self.stats.player_scored( + player, + self._flawless_bonus, + scale=1.2, + color=(0.6, 1.0, 0.6, 1.0), + title=ba.Lstr(resource='flawlessWaveText'), + screenmessage=False) + except Exception: + ba.print_exception() + + def _start_time_bonus_timer(self) -> None: + self._time_bonus_timer = ba.Timer(1.0, + ba.WeakCall(self._update_time_bonus), + repeat=True) + + def _update_player_spawn_info(self) -> None: + + # If we have no living players lets just blank this. + assert self._spawn_info_text is not None + assert self._spawn_info_text.node + if not any(player.is_alive() for player in self.teams[0].players): + self._spawn_info_text.node.text = '' + else: + text: Union[str, ba.Lstr] = '' + for player in self.players: + assert self._waves is not None + if (not player.is_alive() and + (self._preset in ['endless', 'endless_tournament'] or + (player.gamedata['respawn_wave'] <= len(self._waves)))): + rtxt = ba.Lstr(resource='onslaughtRespawnText', + subs=[('${PLAYER}', player.get_name()), + ('${WAVE}', + str(player.gamedata['respawn_wave'])) + ]) + text = ba.Lstr(value='${A}${B}\n', + subs=[ + ('${A}', text), + ('${B}', rtxt), + ]) + self._spawn_info_text.node.text = text + + def _start_next_wave(self) -> None: + + # FIXME; tidy up + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + + # This could happen if we beat a wave as we die. + # We don't wanna respawn players and whatnot if this happens. + if self._game_over: + return + + # respawn applicable players + if self._wave > 1 and not self.is_waiting_for_continue(): + for player in self.players: + if (not player.is_alive() + and player.gamedata['respawn_wave'] == self._wave): + self.spawn_player(player) + self._update_player_spawn_info() + self.show_zoom_message(ba.Lstr(value='${A} ${B}', + subs=[('${A}', + ba.Lstr(resource='waveText')), + ('${B}', str(self._wave))]), + scale=1.0, + duration=1.0, + trail=True) + ba.timer(0.4, ba.Call(ba.playsound, self._new_wave_sound)) + tval = 0.0 + dtime = 0.2 + if self._wave == 1: + spawn_time = 3.973 + tval += 0.5 + else: + spawn_time = 2.648 + + # Populate waves: + + # Generate random waves in endless mode. + wave: Dict[str, Any] + if self._preset in ['endless', 'endless_tournament']: + level = self._wave + bot_types2 = [ + spazbot.BomberBot, spazbot.BrawlerBot, spazbot.TriggerBot, + spazbot.ChargerBot, spazbot.BomberBotPro, + spazbot.BrawlerBotPro, spazbot.TriggerBotPro, + spazbot.BomberBotProShielded, spazbot.ExplodeyBot, + spazbot.ChargerBotProShielded, spazbot.StickyBot, + spazbot.BrawlerBotProShielded, spazbot.TriggerBotProShielded + ] + if level > 5: + bot_types2 += [ + spazbot.ExplodeyBot, + spazbot.TriggerBotProShielded, + spazbot.BrawlerBotProShielded, + spazbot.ChargerBotProShielded, + ] + if level > 7: + bot_types2 += [ + spazbot.ExplodeyBot, + spazbot.TriggerBotProShielded, + spazbot.BrawlerBotProShielded, + spazbot.ChargerBotProShielded, + ] + if level > 10: + bot_types2 += [ + spazbot.TriggerBotProShielded, + spazbot.TriggerBotProShielded, + spazbot.TriggerBotProShielded, + spazbot.TriggerBotProShielded + ] + if level > 13: + bot_types2 += [ + spazbot.TriggerBotProShielded, + spazbot.TriggerBotProShielded, + spazbot.TriggerBotProShielded, + spazbot.TriggerBotProShielded + ] + + bot_levels = [[b for b in bot_types2 if b.points_mult == 1], + [b for b in bot_types2 if b.points_mult == 2], + [b for b in bot_types2 if b.points_mult == 3], + [b for b in bot_types2 if b.points_mult == 4]] + + # Make sure all lists have something in them + if not all(bot_levels): + raise Exception() + + target_points = level * 3 - 2 + min_dudes = min(1 + level // 3, 10) + max_dudes = min(10, level + 1) + max_level = 4 if level > 6 else (3 if level > 3 else + (2 if level > 2 else 1)) + group_count = 3 + distribution = self._get_distribution(target_points, min_dudes, + max_dudes, group_count, + max_level) + + all_entries: List[Dict[str, Any]] = [] + for group in distribution: + entries: List[Dict[str, Any]] = [] + for entry in group: + bot_level = bot_levels[entry[0] - 1] + bot_type = bot_level[random.randrange(len(bot_level))] + rval = random.random() + if rval < 0.5: + spacing = 10 + elif rval < 0.9: + spacing = 20 + else: + spacing = 40 + split = random.random() > 0.3 + for i in range(entry[1]): + if split and i % 2 == 0: + entries.insert(0, { + "type": bot_type, + "spacing": spacing + }) + else: + entries.append({ + "type": bot_type, + "spacing": spacing + }) + if entries: + all_entries += entries + all_entries.append({ + "type": None, + "spacing": 40 if random.random() < 0.5 else 80 + }) + + angle_rand = random.random() + if angle_rand > 0.75: + base_angle = 130.0 + elif angle_rand > 0.5: + base_angle = 210.0 + elif angle_rand > 0.25: + base_angle = 20.0 + else: + base_angle = -30.0 + base_angle += (0.5 - random.random()) * 20.0 + wave = {'base_angle': base_angle, 'entries': all_entries} + else: + assert self._waves is not None + wave = self._waves[self._wave - 1] + entries = [] + bot_angle = wave.get('base_angle', 0.0) + entries += wave['entries'] + this_time_bonus = 0 + this_flawless_bonus = 0 + for info in entries: + if info is None: + continue + bot_type_2 = info['type'] + if bot_type_2 == 'delay': + spawn_time += info['duration'] + continue + if bot_type_2 is not None: + this_time_bonus += bot_type_2.points_mult * 20 + this_flawless_bonus += bot_type_2.points_mult * 5 + # if its got a position, use that + point = info.get('point', None) + if point is not None: + spcall = ba.WeakCall(self.add_bot_at_point, point, bot_type_2, + spawn_time) + ba.timer(tval, spcall) + tval += dtime + else: + spacing = info.get('spacing', 5.0) + bot_angle += spacing * 0.5 + if bot_type_2 is not None: + tcall = ba.WeakCall(self.add_bot_at_angle, bot_angle, + bot_type_2, spawn_time) + ba.timer(tval, tcall) + tval += dtime + bot_angle += spacing * 0.5 + + # We can end the wave after all the spawning happens. + ba.timer(tval + spawn_time - dtime + 0.01, + ba.WeakCall(self._set_can_end_wave)) + + # Reset our time bonus. + self._time_bonus = this_time_bonus + self._flawless_bonus = this_flawless_bonus + tbtcolor = (1, 1, 0, 1) + tbttxt = ba.Lstr(value='${A}: ${B}', + subs=[ + ('${A}', ba.Lstr(resource='timeBonusText')), + ('${B}', str(self._time_bonus)), + ]) + self._time_bonus_text = ba.Actor( + ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'center', + 'vr_depth': -30, + 'color': tbtcolor, + 'shadow': 1.0, + 'flatness': 1.0, + 'position': (0, -60), + 'scale': 0.8, + 'text': tbttxt + })) + + ba.timer(5.0, ba.WeakCall(self._start_time_bonus_timer)) + wtcolor = (1, 1, 1, 1) + assert self._waves is not None + wttxt = ba.Lstr( + value='${A} ${B}', + subs=[ + ('${A}', ba.Lstr(resource='waveText')), + ('${B}', str(self._wave) + + ('' if self._preset in ['endless', 'endless_tournament'] else + ('/' + str(len(self._waves))))) + ]) + self._wave_text = ba.Actor( + ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'center', + 'vr_depth': -10, + 'color': wtcolor, + 'shadow': 1.0, + 'flatness': 1.0, + 'position': (0, -40), + 'scale': 1.3, + 'text': wttxt + })) + + def add_bot_at_point(self, + point: str, + spaz_type: Type[spazbot.SpazBot], + spawn_time: float = 1.0) -> None: + """Add a new bot at a specified named point.""" + if self._game_over: + return + pointpos = self.map.defs.points['bot_spawn_' + point] + assert self._bots is not None + self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time) + + def add_bot_at_angle(self, + angle: float, + spaz_type: Type[spazbot.SpazBot], + spawn_time: float = 1.0) -> None: + """Add a new bot at a specified angle (for circular maps).""" + if self._game_over: + return + angle_radians = angle / 57.2957795 + xval = math.sin(angle_radians) * 1.06 + zval = math.cos(angle_radians) * 1.06 + point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7) + assert self._bots is not None + self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time) + + def _update_time_bonus(self) -> None: + self._time_bonus = int(self._time_bonus * 0.93) + if self._time_bonus > 0 and self._time_bonus_text is not None: + assert self._time_bonus_text.node + self._time_bonus_text.node.text = ba.Lstr( + value='${A}: ${B}', + subs=[('${A}', ba.Lstr(resource='timeBonusText')), + ('${B}', str(self._time_bonus))]) + else: + self._time_bonus_text = None + + def _start_updating_waves(self) -> None: + self._wave_update_timer = ba.Timer(2.0, + ba.WeakCall(self._update_waves), + repeat=True) + + def _update_scores(self) -> None: + score = self._score + if self._preset == 'endless': + if score >= 500: + self._award_achievement('Onslaught Master') + if score >= 1000: + self._award_achievement('Onslaught Wizard') + if score >= 5000: + self._award_achievement('Onslaught God') + assert self._scoreboard is not None + self._scoreboard.set_team_value(self.teams[0], score, max_score=None) + + def handlemessage(self, msg: Any) -> Any: + + # FIXME; tidy this up + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + + if isinstance(msg, playerspaz.PlayerSpazHurtMessage): + player = msg.spaz.getplayer() + if not player: + return + player.gamedata['has_been_hurt'] = True + self._a_player_has_been_hurt = True + + elif isinstance(msg, ba.PlayerScoredMessage): + self._score += msg.score + self._update_scores() + + elif isinstance(msg, playerspaz.PlayerSpazDeathMessage): + super().handlemessage(msg) # Augment standard behavior. + player = msg.spaz.getplayer() + assert player is not None + self._a_player_has_been_hurt = True + + # Make note with the player when they can respawn: + if self._wave < 10: + player.gamedata['respawn_wave'] = max(2, self._wave + 1) + elif self._wave < 15: + player.gamedata['respawn_wave'] = max(2, self._wave + 2) + else: + player.gamedata['respawn_wave'] = max(2, self._wave + 3) + ba.timer(0.1, self._update_player_spawn_info) + ba.timer(0.1, self._checkroundover) + + elif isinstance(msg, spazbot.SpazBotDeathMessage): + pts, importance = msg.badguy.get_death_points(msg.how) + if msg.killerplayer is not None: + + # Toss-off-map achievement: + if self._preset in ['training', 'training_easy']: + if msg.badguy.last_attacked_type == ('picked_up', + 'default'): + self._throw_off_kills += 1 + if self._throw_off_kills >= 3: + self._award_achievement('Off You Go Then') + + # Land-mine achievement: + elif self._preset in ['rookie', 'rookie_easy']: + if msg.badguy.last_attacked_type == ('explosion', + 'land_mine'): + self._land_mine_kills += 1 + if self._land_mine_kills >= 3: + self._award_achievement('Mine Games') + + # TNT achievement: + elif self._preset in ['pro', 'pro_easy']: + if msg.badguy.last_attacked_type == ('explosion', 'tnt'): + self._tnt_kills += 1 + if self._tnt_kills >= 3: + ba.timer( + 0.5, + ba.WeakCall(self._award_achievement, + 'Boom Goes the Dynamite')) + + elif self._preset in ['uber', 'uber_easy']: + + # Uber mine achievement: + if msg.badguy.last_attacked_type == ('explosion', + 'land_mine'): + if not hasattr(self, '_land_mine_kills'): + self._land_mine_kills = 0 + self._land_mine_kills += 1 + if self._land_mine_kills >= 6: + self._award_achievement('Gold Miner') + + # Uber tnt achievement: + if msg.badguy.last_attacked_type == ('explosion', 'tnt'): + self._tnt_kills += 1 + if self._tnt_kills >= 6: + ba.timer( + 0.5, + ba.WeakCall(self._award_achievement, + 'TNT Terror')) + + target: Optional[Sequence[float]] + try: + assert msg.badguy.node + target = msg.badguy.node.position + except Exception: + ba.print_exception() + target = None + try: + killerplayer = msg.killerplayer + self.stats.player_scored(killerplayer, + pts, + target=target, + kill=True, + screenmessage=False, + importance=importance) + ba.playsound(self._dingsound + if importance == 1 else self._dingsoundhigh, + volume=0.6) + except Exception: + pass + + # Normally we pull scores from the score-set, but if there's + # no player lets be explicit. + else: + self._score += pts + self._update_scores() + else: + super().handlemessage(msg) + + def _set_can_end_wave(self) -> None: + self._can_end_wave = True + + def end_game(self) -> None: + # Tell our bots to celebrate just to rub it in. + assert self._bots is not None + self._bots.final_celebrate() + self._game_over = True + self.do_end('defeat', delay=2.0) + ba.setmusic(None) + + def on_continue(self) -> None: + for player in self.players: + if not player.is_alive(): + self.spawn_player(player) + + def _checkroundover(self) -> None: + """ + see if the round is over in response to an event (player died, etc) + """ + if self.has_ended(): + return + if not any(player.is_alive() for player in self.teams[0].players): + # Allow continuing after wave 1. + if self._wave > 1: + self.continue_or_end_game() + else: + self.end_game() diff --git a/assets/src/data/scripts/bastd/game/race.py b/assets/src/data/scripts/bastd/game/race.py new file mode 100644 index 00000000..f8521941 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/race.py @@ -0,0 +1,700 @@ +"""Defines Race mini-game.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor.bomb import Bomb +from bastd.actor.playerspaz import PlayerSpaz, PlayerSpazDeathMessage + +if TYPE_CHECKING: + from typing import (Any, Type, Tuple, List, Sequence, Optional, Dict, + Union) + from bastd.actor.onscreentimer import OnScreenTimer + + +class RaceRegion(ba.Actor): + """Region used to track progress during a race.""" + + def __init__(self, pt: Sequence[float], index: int): + super().__init__() + activity = self.activity + assert isinstance(activity, RaceGame) + self.pos = pt + self.index = index + self.node = ba.newnode( + "region", + delegate=self, + attrs={ + 'position': pt[:3], + 'scale': (pt[3] * 2.0, pt[4] * 2.0, pt[5] * 2.0), + 'type': "box", + 'materials': [activity.race_region_material] + }) + + +# bs_meta export game +class RaceGame(ba.TeamGameActivity): + """Game of racing around a track.""" + + @classmethod + def get_name(cls) -> str: + return 'Race' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Run real fast!' + + @classmethod + def get_score_info(cls) -> Dict[str, Any]: + return { + 'score_name': 'Time', + 'lower_is_better': True, + 'score_type': 'milliseconds' + } + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + return issubclass(sessiontype, ba.TeamBaseSession) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ba.getmaps("race") + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + settings: List[Tuple[str, Dict[str, Any]]] = [ + ("Laps", { + 'min_value': 1, + "default": 3, + "increment": 1 + }), + ("Time Limit", { + 'choices': [('None', 0), ('1 Minute', 60), + ('2 Minutes', 120), ('5 Minutes', 300), + ('10 Minutes', 600), ('20 Minutes', 1200)], + 'default': 0 + }), + ("Mine Spawning", { + 'choices': [('No Mines', 0), ('8 Seconds', 8000), + ('4 Seconds', 4000), ('2 Seconds', 2000)], + 'default': 4000 + }), + ("Bomb Spawning", { + 'choices': [('None', 0), ('8 Seconds', 8000), + ('4 Seconds', 4000), ('2 Seconds', 2000), + ('1 Second', 1000)], + 'default': 2000 + }), + ("Epic Mode", { + 'default': False + })] # yapf: disable + + if issubclass(sessiontype, ba.TeamsSession): + settings.append(("Entire Team Must Finish", {'default': False})) + return settings + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + self._race_started = False + super().__init__(settings) + self._scoreboard = Scoreboard() + if self.settings['Epic Mode']: + self.slow_motion = True + self._score_sound = ba.getsound("score") + self._swipsound = ba.getsound("swip") + self._last_team_time: Optional[float] = None + self._front_race_region = None + self._nub_tex = ba.gettexture('nub') + self._beep_1_sound = ba.getsound('raceBeep1') + self._beep_2_sound = ba.getsound('raceBeep2') + self.race_region_material: Optional[ba.Material] = None + self._regions: List[RaceRegion] = [] + self._team_finish_pts: Optional[int] = None + self._time_text: Optional[ba.Actor] = None + self._timer: Optional[OnScreenTimer] = None + self._race_mines: Optional[List[Dict[str, Any]]] = None + self._race_mine_timer: Optional[ba.Timer] = None + self._scoreboard_timer: Optional[ba.Timer] = None + self._player_order_update_timer: Optional[ba.Timer] = None + self._start_lights: Optional[List[ba.Node]] = None + self._bomb_spawn_timer: Optional[ba.Timer] = None + + def get_instance_description(self) -> Union[str, Sequence]: + if isinstance(self.session, ba.TeamsSession) and self.settings.get( + 'Entire Team Must Finish', False): + t_str = ' Your entire team has to finish.' + else: + t_str = '' + + if self.settings['Laps'] > 1: + return 'Run ${ARG1} laps.' + t_str, self.settings['Laps'] + return 'Run 1 lap.' + t_str + + def get_instance_scoreboard_description(self) -> Union[str, Sequence]: + if self.settings['Laps'] > 1: + return 'run ${ARG1} laps', self.settings['Laps'] + return 'run 1 lap' + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: unify these args + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in( + self, music='Epic Race' if self.settings['Epic Mode'] else 'Race') + + pts = self.map.get_def_points('race_point') + mat = self.race_region_material = ba.Material() + mat.add_actions(conditions=("they_have_material", + ba.sharedobj('player_material')), + actions=(("modify_part_collision", "collide", True), + ("modify_part_collision", "physical", + False), ("call", "at_connect", + self._handle_race_point_collide))) + for rpt in pts: + self._regions.append(RaceRegion(rpt, len(self._regions))) + + def _flash_player(self, player: ba.Player, scale: float) -> None: + assert player.actor is not None and player.actor.node + pos = player.actor.node.position + light = ba.newnode('light', + attrs={ + 'position': pos, + 'color': (1, 1, 0), + 'height_attenuated': False, + 'radius': 0.4 + }) + ba.timer(0.5, light.delete) + ba.animate(light, 'intensity', {0: 0, 0.1: 1.0 * scale, 0.5: 0}) + + def _handle_race_point_collide(self) -> None: + # FIXME: Tidy this up. + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-nested-blocks + region_node, playernode = ba.get_collision_info( + 'source_node', 'opposing_node') + try: + player = playernode.getdelegate().getplayer() + except Exception: + player = None + region = region_node.getdelegate() + if not player or not region: + return + + last_region = player.gamedata['last_region'] + this_region = region.index + + if last_region != this_region: + + # If a player tries to skip regions, smite them. + # Allow a one region leeway though (its plausible players can get + # blown over a region, etc). + if this_region > last_region + 2: + if player.is_alive(): + player.actor.handlemessage(ba.DieMessage()) + ba.screenmessage(ba.Lstr( + translate=('statements', 'Killing ${NAME} for' + ' skipping part of the track!'), + subs=[('${NAME}', player.get_name(full=True))]), + color=(1, 0, 0)) + else: + # If this player is in first, note that this is the + # front-most race-point. + if player.gamedata['rank'] == 0: + self._front_race_region = this_region + + player.gamedata['last_region'] = this_region + if last_region >= len(self._regions) - 2 and this_region == 0: + team = player.get_team() + player.gamedata['lap'] = min(self.settings['Laps'], + player.gamedata['lap'] + 1) + + # In teams mode with all-must-finish on, the team lap + # value is the min of all team players. + # Otherwise its the max. + if isinstance(self.session, + ba.TeamsSession) and self.settings.get( + 'Entire Team Must Finish'): + team.gamedata['lap'] = min( + [p.gamedata['lap'] for p in team.players]) + else: + team.gamedata['lap'] = max( + [p.gamedata['lap'] for p in team.players]) + + # A player is finishing. + if player.gamedata['lap'] == self.settings['Laps']: + + # In teams mode, hand out points based on the order + # players come in. + if isinstance(self.session, ba.TeamsSession): + assert self._team_finish_pts is not None + if self._team_finish_pts > 0: + self.stats.player_scored(player, + self._team_finish_pts, + screenmessage=False) + self._team_finish_pts -= 25 + + # Flash where the player is. + self._flash_player(player, 1.0) + player.gamedata['finished'] = True + player.actor.handlemessage( + ba.DieMessage(immediate=True)) + + # Makes sure noone behind them passes them in rank + # while finishing. + player.gamedata['distance'] = 9999.0 + + # If the whole team has finished the race. + if team.gamedata['lap'] == self.settings['Laps']: + ba.playsound(self._score_sound) + player.get_team().gamedata['finished'] = True + assert self._timer is not None + self._last_team_time = ( + player.get_team().gamedata['time']) = ( + ba.time() - self._timer.getstarttime()) + self._check_end_game() + + # Team has yet to finish. + else: + ba.playsound(self._swipsound) + + # They've just finished a lap but not the race. + else: + ba.playsound(self._swipsound) + self._flash_player(player, 0.3) + + # Print their lap number over their head. + try: + mathnode = ba.newnode('math', + owner=player.actor.node, + attrs={ + 'input1': (0, 1.9, 0), + 'operation': 'add' + }) + player.actor.node.connectattr( + 'torso_position', mathnode, 'input2') + tstr = ba.Lstr(resource='lapNumberText', + subs=[('${CURRENT}', + str(player.gamedata['lap'] + + 1)), + ('${TOTAL}', + str(self.settings['Laps']))]) + txtnode = ba.newnode('text', + owner=mathnode, + attrs={ + 'text': tstr, + 'in_world': True, + 'color': (1, 1, 0, 1), + 'scale': 0.015, + 'h_align': 'center' + }) + mathnode.connectattr('output', txtnode, 'position') + ba.animate(txtnode, 'scale', { + 0.0: 0, + 0.2: 0.019, + 2.0: 0.019, + 2.2: 0 + }) + ba.timer(2.3, mathnode.delete) + except Exception as exc: + print('Exception printing lap:', exc) + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['time'] = None + team.gamedata['lap'] = 0 + team.gamedata['finished'] = False + self._update_scoreboard() + + def on_player_join(self, player: ba.Player) -> None: + player.gamedata['last_region'] = 0 + player.gamedata['lap'] = 0 + player.gamedata['distance'] = 0.0 + player.gamedata['finished'] = False + player.gamedata['rank'] = None + ba.TeamGameActivity.on_player_join(self, player) + + def on_player_leave(self, player: ba.Player) -> None: + ba.TeamGameActivity.on_player_leave(self, player) + + # A player leaving disqualifies the team if 'Entire Team Must Finish' + # is on (otherwise in teams mode everyone could just leave except the + # leading player to win). + if (isinstance(self.session, ba.TeamsSession) + and self.settings.get('Entire Team Must Finish')): + ba.screenmessage(ba.Lstr( + translate=('statements', + '${TEAM} is disqualified because ${PLAYER} left'), + subs=[('${TEAM}', player.team.name), + ('${PLAYER}', player.get_name(full=True))]), + color=(1, 1, 0)) + player.team.gamedata['finished'] = True + player.team.gamedata['time'] = None + player.team.gamedata['lap'] = 0 + ba.playsound(ba.getsound("boo")) + for otherplayer in player.team.players: + otherplayer.gamedata['lap'] = 0 + otherplayer.gamedata['finished'] = True + try: + if otherplayer.actor is not None: + otherplayer.actor.handlemessage(ba.DieMessage()) + except Exception: + ba.print_exception("Error sending diemessages") + + # Defer so team/player lists will be updated. + ba.pushcall(self._check_end_game) + + def _update_scoreboard(self) -> None: + for team in self.teams: + distances = [ + player.gamedata['distance'] for player in team.players + ] + if not distances: + teams_dist = 0 + else: + if (isinstance(self.session, ba.TeamsSession) + and self.settings.get('Entire Team Must Finish')): + teams_dist = min(distances) + else: + teams_dist = max(distances) + self._scoreboard.set_team_value( + team, + teams_dist, + self.settings['Laps'], + flash=(teams_dist >= float(self.settings['Laps'])), + show_value=False) + + def on_begin(self) -> None: + from bastd.actor.onscreentimer import OnScreenTimer + ba.TeamGameActivity.on_begin(self) + self.setup_standard_time_limit(self.settings['Time Limit']) + self.setup_standard_powerup_drops() + self._team_finish_pts = 100 + + # Throw a timer up on-screen. + self._time_text = ba.Actor( + ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'center', + 'color': (1, 1, 0.5, 1), + 'flatness': 0.5, + 'shadow': 0.5, + 'position': (0, -50), + 'scale': 1.4, + 'text': '' + })) + self._timer = OnScreenTimer() + + if self.settings['Mine Spawning'] != 0: + self._race_mines = [{ + 'point': p, + 'mine': None + } for p in self.map.get_def_points('race_mine')] + if self._race_mines: + self._race_mine_timer = ba.Timer( + 0.001 * self.settings['Mine Spawning'], + self._update_race_mine, + repeat=True) + + self._scoreboard_timer = ba.Timer(0.25, + self._update_scoreboard, + repeat=True) + self._player_order_update_timer = ba.Timer(0.25, + self._update_player_order, + repeat=True) + + if self.slow_motion: + t_scale = 0.4 + light_y = 50 + else: + t_scale = 1.0 + light_y = 150 + lstart = 7.1 * t_scale + inc = 1.25 * t_scale + + ba.timer(lstart, self._do_light_1) + ba.timer(lstart + inc, self._do_light_2) + ba.timer(lstart + 2 * inc, self._do_light_3) + ba.timer(lstart + 3 * inc, self._start_race) + + self._start_lights = [] + for i in range(4): + lnub = ba.newnode('image', + attrs={ + 'texture': ba.gettexture('nub'), + 'opacity': 1.0, + 'absolute_scale': True, + 'position': (-75 + i * 50, light_y), + 'scale': (50, 50), + 'attach': 'center' + }) + ba.animate( + lnub, 'opacity', { + 4.0 * t_scale: 0, + 5.0 * t_scale: 1.0, + 12.0 * t_scale: 1.0, + 12.5 * t_scale: 0.0 + }) + ba.timer(13.0 * t_scale, lnub.delete) + self._start_lights.append(lnub) + + self._start_lights[0].color = (0.2, 0, 0) + self._start_lights[1].color = (0.2, 0, 0) + self._start_lights[2].color = (0.2, 0.05, 0) + self._start_lights[3].color = (0.0, 0.3, 0) + + def _do_light_1(self) -> None: + assert self._start_lights is not None + self._start_lights[0].color = (1.0, 0, 0) + ba.playsound(self._beep_1_sound) + + def _do_light_2(self) -> None: + assert self._start_lights is not None + self._start_lights[1].color = (1.0, 0, 0) + ba.playsound(self._beep_1_sound) + + def _do_light_3(self) -> None: + assert self._start_lights is not None + self._start_lights[2].color = (1.0, 0.3, 0) + ba.playsound(self._beep_1_sound) + + def _start_race(self) -> None: + assert self._start_lights is not None + self._start_lights[3].color = (0.0, 1.0, 0) + ba.playsound(self._beep_2_sound) + for player in self.players: + if player.actor is not None: + try: + assert isinstance(player.actor, PlayerSpaz) + player.actor.connect_controls_to_player() + except Exception as exc: + print('Exception in race player connects:', exc) + assert self._timer is not None + self._timer.start() + + if self.settings['Bomb Spawning'] != 0: + self._bomb_spawn_timer = ba.Timer(0.001 * + self.settings['Bomb Spawning'], + self._spawn_bomb, + repeat=True) + + self._race_started = True + + def _update_player_order(self) -> None: + # FIXME: tidy this up + + # Calc all player distances. + for player in self.players: + pos: Optional[ba.Vec3] + try: + assert player.actor is not None and player.actor.node + pos = ba.Vec3(player.actor.node.position) + except Exception: + pos = None + if pos is not None: + r_index = player.gamedata['last_region'] + rg1 = self._regions[r_index] + r1pt = ba.Vec3(rg1.pos[:3]) + rg2 = self._regions[0] if r_index == len( + self._regions) - 1 else self._regions[r_index + 1] + r2pt = ba.Vec3(rg2.pos[:3]) + r2dist = (pos - r2pt).length() + amt = 1.0 - (r2dist / (r2pt - r1pt).length()) + amt = player.gamedata['lap'] + (r_index + amt) * ( + 1.0 / len(self._regions)) + player.gamedata['distance'] = amt + + # Sort players by distance and update their ranks. + p_list = [[player.gamedata['distance'], player] + for player in self.players] + p_list.sort(reverse=True) + for i, plr in enumerate(p_list): + try: + plr[1].gamedata['rank'] = i + if plr[1].actor is not None: + node = plr[1].actor.distance_txt + if node: + node.text = str(i + 1) if plr[1].is_alive() else '' + except Exception: + ba.print_exception('error updating player orders') + + def _spawn_bomb(self) -> None: + if self._front_race_region is None: + return + region = (self._front_race_region + 3) % len(self._regions) + pos = self._regions[region].pos + + # Don't use the full region so we're less likely to spawn off a cliff. + region_scale = 0.8 + x_range = ((-0.5, 0.5) if pos[3] == 0 else + (-region_scale * pos[3], region_scale * pos[3])) + z_range = ((-0.5, 0.5) if pos[5] == 0 else + (-region_scale * pos[5], region_scale * pos[5])) + pos = (pos[0] + random.uniform(*x_range), pos[1] + 1.0, + pos[2] + random.uniform(*z_range)) + ba.timer(random.uniform(0.0, 2.0), + ba.WeakCall(self._spawn_bomb_at_pos, pos)) + + def _spawn_bomb_at_pos(self, pos: Sequence[float]) -> None: + if self.has_ended(): + return + Bomb(position=pos, bomb_type='normal').autoretain() + + def _make_mine(self, i: int) -> None: + assert self._race_mines is not None + rmine = self._race_mines[i] + rmine['mine'] = Bomb(position=rmine['point'][:3], + bomb_type='land_mine') + rmine['mine'].arm() + + def _flash_mine(self, i: int) -> None: + assert self._race_mines is not None + rmine = self._race_mines[i] + light = ba.newnode("light", + attrs={ + 'position': rmine['point'][:3], + 'color': (1, 0.2, 0.2), + 'radius': 0.1, + 'height_attenuated': False + }) + ba.animate(light, "intensity", {0.0: 0, 0.1: 1.0, 0.2: 0}, loop=True) + ba.timer(1.0, light.delete) + + def _update_race_mine(self) -> None: + assert self._race_mines is not None + m_index = -1 + rmine = None + for _i in range(3): + m_index = random.randrange(len(self._race_mines)) + rmine = self._race_mines[m_index] + if not rmine['mine']: + break + assert rmine is not None + if not rmine['mine']: + self._flash_mine(m_index) + ba.timer(0.95, ba.Call(self._make_mine, m_index)) + + def spawn_player(self, player: ba.Player) -> ba.Actor: + if player.team.gamedata['finished']: + # FIXME: This is not type-safe + # (this call is expected to return an Actor). + # noinspection PyTypeChecker + return None # type: ignore + pos = self._regions[player.gamedata['last_region']].pos + + # Don't use the full region so we're less likely to spawn off a cliff. + region_scale = 0.8 + x_range = ((-0.5, 0.5) if pos[3] == 0 else + (-region_scale * pos[3], region_scale * pos[3])) + z_range = ((-0.5, 0.5) if pos[5] == 0 else + (-region_scale * pos[5], region_scale * pos[5])) + pos = (pos[0] + random.uniform(*x_range), pos[1], + pos[2] + random.uniform(*z_range)) + spaz = self.spawn_player_spaz( + player, position=pos, angle=90 if not self._race_started else None) + assert spaz.node + + # Prevent controlling of characters before the start of the race. + if not self._race_started: + spaz.disconnect_controls_from_player() + + mathnode = ba.newnode('math', + owner=spaz.node, + attrs={ + 'input1': (0, 1.4, 0), + 'operation': 'add' + }) + spaz.node.connectattr('torso_position', mathnode, 'input2') + + distance_txt = ba.newnode('text', + owner=spaz.node, + attrs={ + 'text': '', + 'in_world': True, + 'color': (1, 1, 0.4), + 'scale': 0.02, + 'h_align': 'center' + }) + # FIXME store this in a type-safe way + # noinspection PyTypeHints + spaz.distance_txt = distance_txt # type: ignore + mathnode.connectattr('output', distance_txt, 'position') + return spaz + + def _check_end_game(self) -> None: + + # If there's no teams left racing, finish. + teams_still_in = len( + [t for t in self.teams if not t.gamedata['finished']]) + if teams_still_in == 0: + self.end_game() + return + + # Count the number of teams that have completed the race. + teams_completed = len([ + t for t in self.teams + if t.gamedata['finished'] and t.gamedata['time'] is not None + ]) + + if teams_completed > 0: + session = self.session + + # In teams mode its over as soon as any team finishes the race + + # FIXME: The get_ffa_point_awards code looks dangerous. + if isinstance(session, ba.TeamsSession): + self.end_game() + else: + # In ffa we keep the race going while there's still any points + # to be handed out. Find out how many points we have to award + # and how many teams have finished, and once that matches + # we're done. + assert isinstance(session, ba.FreeForAllSession) + points_to_award = len(session.get_ffa_point_awards()) + if teams_completed >= points_to_award - teams_completed: + self.end_game() + return + + def end_game(self) -> None: + + # Stop updating our time text, and set it to show the exact last + # finish time if we have one. (so users don't get upset if their + # final time differs from what they see onscreen by a tiny bit) + assert self._timer is not None + if self._timer.hasstarted(): + self._timer.stop( + endtime=None if self._last_team_time is None else ( + self._timer.getstarttime() + self._last_team_time)) + + results = ba.TeamGameResults() + + for team in self.teams: + results.set_team_score(team, team.gamedata['time']) + + # We don't announce a winner in ffa mode since its probably been a + # while since the first place guy crossed the finish line so it seems + # odd to be announcing that now. + self.end(results=results, + announce_winning_team=isinstance(self.session, + ba.TeamsSession)) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, PlayerSpazDeathMessage): + # Augment default behavior. + super().handlemessage(msg) + player = msg.spaz.getplayer() + if not player: + ba.print_error('got no player in PlayerSpazDeathMessage') + return + if not player.gamedata['finished']: + self.respawn_player(player, respawn_time=1000) + else: + super().handlemessage(msg) diff --git a/assets/src/data/scripts/bastd/game/runaround.py b/assets/src/data/scripts/bastd/game/runaround.py new file mode 100644 index 00000000..69d2019b --- /dev/null +++ b/assets/src/data/scripts/bastd/game/runaround.py @@ -0,0 +1,1169 @@ +"""Defines the runaround co-op game.""" + +# We wear the cone of shame. +# pylint: disable=too-many-lines + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor import playerspaz +from bastd.actor import spazbot +from bastd.actor.bomb import TNTSpawner +from bastd.actor.scoreboard import Scoreboard + +if TYPE_CHECKING: + from typing import Type, Any, List, Dict, Tuple, Sequence, Optional + + +class RunaroundGame(ba.CoopGameActivity): + """Game involving trying to bomb bots as they walk through the map.""" + + tips = [ + 'Jump just as you\'re throwing to get bombs up to the highest levels.', + 'No, you can\'t get up on the ledge. You have to throw bombs.', + 'Whip back and forth to get more distance on your throws..' + ] + + # How fast our various bot types walk. + _bot_speed_map = { + spazbot.BomberBot: 0.48, + spazbot.BomberBotPro: 0.48, + spazbot.BomberBotProShielded: 0.48, + spazbot.BrawlerBot: 0.57, + spazbot.BrawlerBotPro: 0.57, + spazbot.BrawlerBotProShielded: 0.57, + spazbot.TriggerBot: 0.73, + spazbot.TriggerBotPro: 0.78, + spazbot.TriggerBotProShielded: 0.78, + spazbot.ChargerBot: 1.0, + spazbot.ChargerBotProShielded: 1.0, + spazbot.ExplodeyBot: 1.0, + spazbot.StickyBot: 0.5 + } + + @classmethod + def get_name(cls) -> str: + return 'Runaround' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return "Prevent enemies from reaching the exit." + + def __init__(self, settings: Dict[str, Any]): + settings['map'] = 'Tower D' + super().__init__(settings) + self._preset = self.settings.get('preset', 'pro') + + self._player_death_sound = ba.getsound('playerDeath') + self._new_wave_sound = ba.getsound('scoreHit01') + self._winsound = ba.getsound("score") + self._cashregistersound = ba.getsound('cashRegister') + self._bad_guy_score_sound = ba.getsound("shieldDown") + self._heart_tex = ba.gettexture('heart') + self._heart_model_opaque = ba.getmodel('heartOpaque') + self._heart_model_transparent = ba.getmodel('heartTransparent') + + self._a_player_has_been_killed = False + self._spawn_center = self._map_type.defs.points['spawn1'][0:3] + self._tntspawnpos = self._map_type.defs.points['tnt_loc'][0:3] + self._powerup_center = self._map_type.defs.boxes['powerup_region'][0:3] + self._powerup_spread = ( + self._map_type.defs.boxes['powerup_region'][6] * 0.5, + self._map_type.defs.boxes['powerup_region'][8] * 0.5) + + self._score_region_material = ba.Material() + self._score_region_material.add_actions( + conditions=("they_have_material", ba.sharedobj('player_material')), + actions=(("modify_part_collision", "collide", + True), ("modify_part_collision", "physical", False), + ("call", "at_connect", self._handle_reached_end))) + + self._last_wave_end_time = ba.time() + self._player_has_picked_up_powerup = False + self._scoreboard: Optional[Scoreboard] = None + self._game_over = False + self._wave = 0 + self._can_end_wave = True + self._score = 0 + self._time_bonus = 0 + self._score_region: Optional[ba.Actor] = None + self._dingsound = ba.getsound('dingSmall') + self._dingsoundhigh = ba.getsound('dingSmallHigh') + self._exclude_powerups: Optional[List[str]] = None + self._have_tnt: Optional[bool] = None + self._waves: Optional[List[Dict[str, Any]]] = None + self._bots = spazbot.BotSet() + self._tntspawner: Optional[TNTSpawner] = None + self._lives_bg: Optional[ba.Actor] = None + self._start_lives = 10 + self._lives = self._start_lives + self._lives_text: Optional[ba.Actor] = None + self._flawless = True + self._time_bonus_timer: Optional[ba.Timer] = None + self._time_bonus_text: Optional[ba.Actor] = None + self._time_bonus_mult: Optional[float] = None + self._wave_text: Optional[ba.Actor] = None + self._flawless_bonus: Optional[int] = None + self._wave_update_timer: Optional[ba.Timer] = None + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify args here. + # pylint: disable=arguments-differ + ba.CoopGameActivity.on_transition_in(self, music='Marching') + self._scoreboard = Scoreboard(label=ba.Lstr(resource='scoreText'), + score_split=0.5) + self._score_region = ba.Actor( + ba.newnode( + 'region', + attrs={ + 'position': self.map.defs.boxes['score_region'][0:3], + 'scale': self.map.defs.boxes['score_region'][6:9], + 'type': 'box', + 'materials': [self._score_region_material] + })) + + def on_begin(self) -> None: + ba.CoopGameActivity.on_begin(self) + player_count = len(self.players) + hard = self._preset not in ['pro_easy', 'uber_easy'] + + if self._preset in ['pro', 'pro_easy', 'tournament']: + self._exclude_powerups = ['curse'] + self._have_tnt = True + self._waves = [ + {'entries': [ + {'type': spazbot.BomberBot, 'path': 3 if hard else 2}, + {'type': spazbot.BomberBot, 'path': 2}, + {'type': spazbot.BomberBot, 'path': 2} if hard else None, + {'type': spazbot.BomberBot, 'path': 2} if player_count > 1 + else None, + {'type': spazbot.BomberBot, 'path': 1} if hard else None, + {'type': spazbot.BomberBot, 'path': 1} if player_count > 2 + else None, + {'type': spazbot.BomberBot, 'path': 1} if player_count > 3 + else None, + ]}, + {'entries': [ + {'type': spazbot.BomberBot, 'path': 1} if hard else None, + {'type': spazbot.BomberBot, 'path': 2} if hard else None, + {'type': spazbot.BomberBot, 'path': 2}, + {'type': spazbot.BomberBot, 'path': 2}, + {'type': spazbot.BomberBot, 'path': 2} if player_count > 3 + else None, + {'type': spazbot.BrawlerBot, 'path': 3}, + {'type': spazbot.BrawlerBot, 'path': 3}, + {'type': spazbot.BrawlerBot, 'path': 3} if hard else None, + {'type': spazbot.BrawlerBot, 'path': 3} if player_count > 1 + else None, + {'type': spazbot.BrawlerBot, 'path': 3} if player_count > 2 + else None, + ]}, + {'entries': [ + {'type': spazbot.ChargerBot, 'path': 2} if hard else None, + {'type': spazbot.ChargerBot, 'path': 2} if player_count > 2 + else None, + {'type': spazbot.TriggerBot, 'path': 2}, + {'type': spazbot.TriggerBot, 'path': 2} if player_count > 1 + else None, + {'type': 'spacing', 'duration': 3.0}, + {'type': spazbot.BomberBot, 'path': 2} if hard else None, + {'type': spazbot.BomberBot, 'path': 2} if hard else None, + {'type': spazbot.BomberBot, 'path': 2}, + {'type': spazbot.BomberBot, 'path': 3} if hard else None, + {'type': spazbot.BomberBot, 'path': 3}, + {'type': spazbot.BomberBot, 'path': 3}, + {'type': spazbot.BomberBot, 'path': 3} if player_count > 3 + else None, + ]}, + {'entries': [ + {'type': spazbot.TriggerBot, 'path': 1} if hard else None, + {'type': 'spacing', 'duration': 1.0} if hard else None, + {'type': spazbot.TriggerBot, 'path': 2}, + {'type': 'spacing', 'duration': 1.0}, + {'type': spazbot.TriggerBot, 'path': 3}, + {'type': 'spacing', 'duration': 1.0}, + {'type': spazbot.TriggerBot, 'path': 1} if hard else None, + {'type': 'spacing', 'duration': 1.0} if hard else None, + {'type': spazbot.TriggerBot, 'path': 2}, + {'type': 'spacing', 'duration': 1.0}, + {'type': spazbot.TriggerBot, 'path': 3}, + {'type': 'spacing', 'duration': 1.0}, + {'type': spazbot.TriggerBot, 'path': 1} + if (player_count > 1 and hard) else None, + {'type': 'spacing', 'duration': 1.0}, + {'type': spazbot.TriggerBot, 'path': 2} if player_count > 2 + else None, + {'type': 'spacing', 'duration': 1.0}, + {'type': spazbot.TriggerBot, 'path': 3} if player_count > 3 + else None, + {'type': 'spacing', 'duration': 1.0}, + ]}, + {'entries': [ + {'type': spazbot.ChargerBotProShielded if hard + else spazbot.ChargerBot, 'path': 1}, + {'type': spazbot.BrawlerBot, 'path': 2} if hard else None, + {'type': spazbot.BrawlerBot, 'path': 2}, + {'type': spazbot.BrawlerBot, 'path': 2}, + {'type': spazbot.BrawlerBot, 'path': 3} if hard else None, + {'type': spazbot.BrawlerBot, 'path': 3}, + {'type': spazbot.BrawlerBot, 'path': 3}, + {'type': spazbot.BrawlerBot, 'path': 3} if player_count > 1 + else None, + {'type': spazbot.BrawlerBot, 'path': 3} if player_count > 2 + else None, + {'type': spazbot.BrawlerBot, 'path': 3} if player_count > 3 + else None, + ]}, + {'entries': [ + {'type': spazbot.BomberBotProShielded, 'path': 3}, + {'type': 'spacing', 'duration': 1.5}, + {'type': spazbot.BomberBotProShielded, 'path': 2}, + {'type': 'spacing', 'duration': 1.5}, + {'type': spazbot.BomberBotProShielded, 'path': 1} if hard + else None, + {'type': 'spacing', 'duration': 1.0} if hard else None, + {'type': spazbot.BomberBotProShielded, 'path': 3}, + {'type': 'spacing', 'duration': 1.5}, + {'type': spazbot.BomberBotProShielded, 'path': 2}, + {'type': 'spacing', 'duration': 1.5}, + {'type': spazbot.BomberBotProShielded, 'path': 1} if hard + else None, + {'type': 'spacing', 'duration': 1.5} if hard else None, + {'type': spazbot.BomberBotProShielded, 'path': 3} + if player_count > 1 else None, + {'type': 'spacing', 'duration': 1.5}, + {'type': spazbot.BomberBotProShielded, 'path': 2} + if player_count > 2 else None, + {'type': 'spacing', 'duration': 1.5}, + {'type': spazbot.BomberBotProShielded, 'path': 1} + if player_count > 3 else None, + ]}, + ] # yapf: disable + elif self._preset in ['uber_easy', 'uber', 'tournament_uber']: + self._exclude_powerups = [] + self._have_tnt = True + self._waves = [ + {'entries': [ + {'type': spazbot.TriggerBot, 'path': 1} if hard else None, + {'type': spazbot.TriggerBot, 'path': 2}, + {'type': spazbot.TriggerBot, 'path': 2}, + {'type': spazbot.TriggerBot, 'path': 3}, + {'type': spazbot.BrawlerBotPro if hard + else spazbot.BrawlerBot, 'point': 'bottom_left'}, + {'type': spazbot.BrawlerBotPro, 'point': 'bottom_right'} + if player_count > 2 else None, + ]}, + {'entries': [ + {'type': spazbot.ChargerBot, 'path': 2}, + {'type': spazbot.ChargerBot, 'path': 3}, + {'type': spazbot.ChargerBot, 'path': 1} if hard else None, + {'type': spazbot.ChargerBot, 'path': 2}, + {'type': spazbot.ChargerBot, 'path': 3}, + {'type': spazbot.ChargerBot, 'path': 1} if player_count > 2 + else None, + ]}, + {'entries': [ + {'type': spazbot.BomberBotProShielded, 'path': 1} if hard + else None, + {'type': spazbot.BomberBotProShielded, 'path': 2}, + {'type': spazbot.BomberBotProShielded, 'path': 2}, + {'type': spazbot.BomberBotProShielded, 'path': 3}, + {'type': spazbot.BomberBotProShielded, 'path': 3}, + {'type': spazbot.ChargerBot, 'point': 'bottom_right'}, + {'type': spazbot.ChargerBot, 'point': 'bottom_left'} + if player_count > 2 else None, + ]}, + {'entries': [ + {'type': spazbot.TriggerBotPro, 'path': 1} + if hard else None, + {'type': spazbot.TriggerBotPro, 'path': 1 if hard else 2}, + {'type': spazbot.TriggerBotPro, 'path': 1 if hard else 2}, + {'type': spazbot.TriggerBotPro, 'path': 1 if hard else 2}, + {'type': spazbot.TriggerBotPro, 'path': 1 if hard else 2}, + {'type': spazbot.TriggerBotPro, 'path': 1 if hard else 2}, + {'type': spazbot.TriggerBotPro, 'path': 1 if hard else 2} + if player_count > 1 else None, + {'type': spazbot.TriggerBotPro, 'path': 1 if hard else 2} + if player_count > 3 else None, + ]}, + {'entries': [ + {'type': spazbot.TriggerBotProShielded if hard + else spazbot.TriggerBotPro, 'point': 'bottom_left'}, + {'type': spazbot.TriggerBotProShielded, + 'point': 'bottom_right'} + if hard else None, + {'type': spazbot.TriggerBotProShielded, + 'point': 'bottom_right'} + if player_count > 2 else None, + {'type': spazbot.BomberBot, 'path': 3}, + {'type': spazbot.BomberBot, 'path': 3}, + {'type': 'spacing', 'duration': 5.0}, + {'type': spazbot.BrawlerBot, 'path': 2}, + {'type': spazbot.BrawlerBot, 'path': 2}, + {'type': 'spacing', 'duration': 5.0}, + {'type': spazbot.TriggerBot, 'path': 1} if hard else None, + {'type': spazbot.TriggerBot, 'path': 1} if hard else None, + ]}, + {'entries': [ + {'type': spazbot.BomberBotProShielded, 'path': 2}, + {'type': spazbot.BomberBotProShielded, 'path': 2} if hard + else None, + {'type': spazbot.StickyBot, 'point': 'bottom_right'}, + {'type': spazbot.BomberBotProShielded, 'path': 2}, + {'type': spazbot.BomberBotProShielded, 'path': 2}, + {'type': spazbot.StickyBot, 'point': 'bottom_right'} + if player_count > 2 else None, + {'type': spazbot.BomberBotProShielded, 'path': 2}, + {'type': spazbot.ExplodeyBot, 'point': 'bottom_left'}, + {'type': spazbot.BomberBotProShielded, 'path': 2}, + {'type': spazbot.BomberBotProShielded, 'path': 2} + if player_count > 1 else None, + {'type': 'spacing', 'duration': 5.0}, + {'type': spazbot.StickyBot, 'point': 'bottom_left'}, + {'type': 'spacing', 'duration': 2.0}, + {'type': spazbot.ExplodeyBot, 'point': 'bottom_right'}, + ]}, + ] # yapf: disable + elif self._preset in ['endless', 'endless_tournament']: + self._exclude_powerups = [] + self._have_tnt = True + + # Spit out a few powerups and start dropping more shortly. + self._drop_powerups(standard_points=True) + ba.timer(4.0, self._start_powerup_drops) + self.setup_low_life_warning_sound() + self._update_scores() + + # Our TNT spawner (if applicable). + if self._have_tnt: + self._tntspawner = TNTSpawner(position=self._tntspawnpos) + + # Make sure to stay out of the way of menu/party buttons in the corner. + interface_type = ba.app.interface_type + l_offs = (-80 if interface_type == 'small' else + -40 if interface_type == 'medium' else 0) + + self._lives_bg = ba.Actor( + ba.newnode('image', + attrs={ + 'texture': self._heart_tex, + 'model_opaque': self._heart_model_opaque, + 'model_transparent': self._heart_model_transparent, + 'attach': 'topRight', + 'scale': (90, 90), + 'position': (-110 + l_offs, -50), + 'color': (1, 0.2, 0.2) + })) + # FIXME; should not set things based on vr mode. + # (won't look right to non-vr connected clients, etc) + vrmode = ba.app.vr_mode + self._lives_text = ba.Actor( + ba.newnode( + 'text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'right', + 'h_align': 'center', + 'color': (1, 1, 1, 1) if vrmode else (0.8, 0.8, 0.8, 1.0), + 'flatness': 1.0 if vrmode else 0.5, + 'shadow': 1.0 if vrmode else 0.5, + 'vr_depth': 10, + 'position': (-113 + l_offs, -69), + 'scale': 1.3, + 'text': str(self._lives) + })) + + ba.timer(2.0, self._start_updating_waves) + + def _handle_reached_end(self) -> None: + oppnode = ba.get_collision_info("opposing_node") + spaz = oppnode.getdelegate() + + if not spaz.is_alive(): + return # Ignore bodies flying in. + + self._flawless = False + pos = spaz.node.position + ba.playsound(self._bad_guy_score_sound, position=pos) + light = ba.newnode('light', + attrs={ + 'position': pos, + 'radius': 0.5, + 'color': (1, 0, 0) + }) + ba.animate(light, 'intensity', {0.0: 0, 0.1: 1, 0.5: 0}, loop=False) + ba.timer(1.0, light.delete) + spaz.handlemessage(ba.DieMessage(immediate=True, how='goal')) + + if self._lives > 0: + self._lives -= 1 + if self._lives == 0: + self._bots.stop_moving() + self.continue_or_end_game() + assert self._lives_text is not None + assert self._lives_text.node + self._lives_text.node.text = str(self._lives) + delay = 0.0 + + def _safesetattr(node: ba.Node, attr: str, value: Any) -> None: + if node: + setattr(node, attr, value) + + for _i in range(4): + ba.timer( + delay, + ba.Call(_safesetattr, self._lives_text.node, 'color', + (1, 0, 0, 1.0))) + assert self._lives_bg is not None + assert self._lives_bg.node + ba.timer( + delay, + ba.Call(_safesetattr, self._lives_bg.node, 'opacity', 0.5)) + delay += 0.125 + ba.timer( + delay, + ba.Call(_safesetattr, self._lives_text.node, 'color', + (1.0, 1.0, 0.0, 1.0))) + ba.timer( + delay, + ba.Call(_safesetattr, self._lives_bg.node, 'opacity', 1.0)) + delay += 0.125 + ba.timer( + delay, + ba.Call(_safesetattr, self._lives_text.node, 'color', + (0.8, 0.8, 0.8, 1.0))) + + def on_continue(self) -> None: + self._lives = 3 + assert self._lives_text is not None + assert self._lives_text.node + self._lives_text.node.text = str(self._lives) + self._bots.start_moving() + + def spawn_player(self, player: ba.Player) -> ba.Actor: + pos = (self._spawn_center[0] + random.uniform(-1.5, 1.5), + self._spawn_center[1], + self._spawn_center[2] + random.uniform(-1.5, 1.5)) + spaz = self.spawn_player_spaz(player, position=pos) + if self._preset in ['pro_easy', 'uber_easy']: + spaz.impact_scale = 0.25 + + # Add the material that causes us to hit the player-wall. + spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup + return spaz + + # noinspection PyUnusedLocal + def _on_player_picked_up_powerup(self, player: ba.Actor) -> None: + # pylint: disable=unused-argument + self._player_has_picked_up_powerup = True + + def _drop_powerup(self, index: int, poweruptype: str = None) -> None: + from bastd.actor import powerupbox + if poweruptype is None: + poweruptype = (powerupbox.get_factory().get_random_powerup_type( + excludetypes=self._exclude_powerups)) + powerupbox.PowerupBox(position=self.map.powerup_spawn_points[index], + poweruptype=poweruptype).autoretain() + + def _start_powerup_drops(self) -> None: + ba.timer(3.0, self._drop_powerups, repeat=True) + + def _drop_powerups(self, + standard_points: bool = False, + force_first: str = None) -> None: + """ Generic powerup drop """ + from bastd.actor import powerupbox + + # If its been a minute since our last wave finished emerging, stop + # giving out land-mine powerups. (prevents players from waiting + # around for them on purpose and filling the map up) + if ba.time() - self._last_wave_end_time > 60.0: + extra_excludes = ['land_mines'] + else: + extra_excludes = [] + + if standard_points: + points = self.map.powerup_spawn_points + for i in range(len(points)): + ba.timer( + 1.0 + i * 0.5, + ba.Call(self._drop_powerup, i, + force_first if i == 0 else None)) + else: + pos = (self._powerup_center[0] + random.uniform( + -1.0 * self._powerup_spread[0], 1.0 * self._powerup_spread[0]), + self._powerup_center[1], + self._powerup_center[2] + random.uniform( + -self._powerup_spread[1], self._powerup_spread[1])) + + # drop one random one somewhere.. + assert self._exclude_powerups is not None + powerupbox.PowerupBox( + position=pos, + poweruptype=powerupbox.get_factory().get_random_powerup_type( + excludetypes=self._exclude_powerups + + extra_excludes)).autoretain() + + def end_game(self) -> None: + + # FIXME: If we don't start our bots moving again we get stuck. This + # is because the bot-set never prunes itself while movement is off + # and on_expire() never gets called for some bots because + # _prune_dead_objects() saw them as dead and pulled them off the + # weak-ref lists. this is an architectural issue; can hopefully fix + # this by having _actor_weak_refs not look at exists(). + self._bots.start_moving() + ba.pushcall(ba.Call(self.do_end, 'defeat')) + ba.setmusic(None) + ba.playsound(self._player_death_sound) + + def do_end(self, outcome: str) -> None: + """End the game now with the provided outcome.""" + + if outcome == 'defeat': + delay = 2.0 + self.fade_to_red() + else: + delay = 0 + + score: Optional[int] + if self._wave >= 2: + score = self._score + fail_message = None + else: + score = None + fail_message = 'Reach wave 2 to rank.' + + self.end(delay=delay, + results={ + 'outcome': outcome, + 'score': score, + 'fail_message': fail_message, + 'player_info': self.initial_player_info + }) + + def _on_got_scores_to_beat(self, scores: List[Dict[str, Any]]) -> None: + self._show_standard_scores_to_beat_ui(scores) + + def _update_waves(self) -> None: + # pylint: disable=too-many-branches + + # If we have no living bots, go to the next wave. + if (self._can_end_wave and not self._bots.have_living_bots() + and not self._game_over and self._lives > 0): + + self._can_end_wave = False + self._time_bonus_timer = None + self._time_bonus_text = None + + if self._preset in ['endless', 'endless_tournament']: + won = False + else: + assert self._waves is not None + won = (self._wave == len(self._waves)) + + # Reward time bonus. + base_delay = 4.0 if won else 0 + if self._time_bonus > 0: + ba.timer(0, ba.Call(ba.playsound, self._cashregistersound)) + ba.timer(base_delay, + ba.Call(self._award_time_bonus, self._time_bonus)) + base_delay += 1.0 + + # Reward flawless bonus. + if self._wave > 0 and self._flawless: + ba.timer(base_delay, self._award_flawless_bonus) + base_delay += 1.0 + + self._flawless = True # reset + + if won: + + # Completion achievements: + if self._preset in ['pro', 'pro_easy']: + self._award_achievement('Pro Runaround Victory', + sound=False) + if self._lives == self._start_lives: + self._award_achievement('The Wall', sound=False) + if not self._player_has_picked_up_powerup: + self._award_achievement('Precision Bombing', + sound=False) + elif self._preset in ['uber', 'uber_easy']: + self._award_achievement('Uber Runaround Victory', + sound=False) + if self._lives == self._start_lives: + self._award_achievement('The Great Wall', sound=False) + if not self._a_player_has_been_killed: + self._award_achievement('Stayin\' Alive', sound=False) + + # Give remaining players some points and have them celebrate. + self.show_zoom_message(ba.Lstr(resource='victoryText'), + scale=1.0, + duration=4.0) + + self.celebrate(10.0) + ba.timer(base_delay, self._award_lives_bonus) + base_delay += 1.0 + ba.timer(base_delay, self._award_completion_bonus) + base_delay += 0.85 + ba.playsound(self._winsound) + ba.cameraflash() + ba.setmusic('Victory') + self._game_over = True + ba.timer(base_delay, ba.Call(self.do_end, 'victory')) + return + + self._wave += 1 + + # Short celebration after waves. + if self._wave > 1: + self.celebrate(0.5) + + ba.timer(base_delay, self._start_next_wave) + + def _award_completion_bonus(self) -> None: + from bastd.actor import popuptext + bonus = 200 + ba.playsound(self._cashregistersound) + popuptext.PopupText(ba.Lstr( + value='+${A} ${B}', + subs=[('${A}', str(bonus)), + ('${B}', ba.Lstr(resource='completionBonusText'))]), + color=(0.7, 0.7, 1.0, 1), + scale=1.6, + position=(0, 1.5, -1)).autoretain() + self._score += bonus + self._update_scores() + + def _award_lives_bonus(self) -> None: + from bastd.actor import popuptext + bonus = self._lives * 30 + ba.playsound(self._cashregistersound) + popuptext.PopupText(ba.Lstr(value='+${A} ${B}', + subs=[('${A}', str(bonus)), + ('${B}', + ba.Lstr(resource='livesBonusText')) + ]), + color=(0.7, 1.0, 0.3, 1), + scale=1.3, + position=(0, 1, -1)).autoretain() + self._score += bonus + self._update_scores() + + def _award_time_bonus(self, bonus: int) -> None: + from bastd.actor import popuptext + ba.playsound(self._cashregistersound) + popuptext.PopupText(ba.Lstr(value='+${A} ${B}', + subs=[('${A}', str(bonus)), + ('${B}', + ba.Lstr(resource='timeBonusText')) + ]), + color=(1, 1, 0.5, 1), + scale=1.0, + position=(0, 3, -1)).autoretain() + + self._score += self._time_bonus + self._update_scores() + + def _award_flawless_bonus(self) -> None: + from bastd.actor import popuptext + ba.playsound(self._cashregistersound) + popuptext.PopupText(ba.Lstr(value='+${A} ${B}', + subs=[('${A}', str(self._flawless_bonus)), + ('${B}', + ba.Lstr(resource='perfectWaveText')) + ]), + color=(1, 1, 0.2, 1), + scale=1.2, + position=(0, 2, -1)).autoretain() + + assert self._flawless_bonus is not None + self._score += self._flawless_bonus + self._update_scores() + + def _start_time_bonus_timer(self) -> None: + self._time_bonus_timer = ba.Timer(1.0, + self._update_time_bonus, + repeat=True) + + def _start_next_wave(self) -> None: + # FIXME: Need to split this up. + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + self.show_zoom_message(ba.Lstr(value='${A} ${B}', + subs=[('${A}', + ba.Lstr(resource='waveText')), + ('${B}', str(self._wave))]), + scale=1.0, + duration=1.0, + trail=True) + ba.timer(0.4, ba.Call(ba.playsound, self._new_wave_sound)) + t_sec = 0.0 + base_delay = 0.5 + delay = 0.0 + bot_types: List[Dict[str, Any]] = [] + + if self._preset in ['endless', 'endless_tournament']: + level = self._wave + target_points = (level + 1) * 8.0 + group_count = random.randint(1, 3) + entries = [] + spaz_types: List[Tuple[Type[spazbot.SpazBot], float]] = [] + if level < 6: + spaz_types += [(spazbot.BomberBot, 5.0)] + if level < 10: + spaz_types += [(spazbot.BrawlerBot, 5.0)] + if level < 15: + spaz_types += [(spazbot.TriggerBot, 6.0)] + if level > 5: + spaz_types += [(spazbot.TriggerBotPro, 7.5) + ] * (1 + (level - 5) // 7) + if level > 2: + spaz_types += [(spazbot.BomberBotProShielded, 8.0) + ] * (1 + (level - 2) // 6) + if level > 6: + spaz_types += [(spazbot.TriggerBotProShielded, 12.0) + ] * (1 + (level - 6) // 5) + if level > 1: + spaz_types += ([(spazbot.ChargerBot, 10.0)] * + (1 + (level - 1) // 4)) + if level > 7: + spaz_types += [(spazbot.ChargerBotProShielded, 15.0) + ] * (1 + (level - 7) // 3) + + # Bot type, their effect on target points. + defender_types: List[Tuple[Type[spazbot.SpazBot], float]] = [ + (spazbot.BomberBot, 0.9), + (spazbot.BrawlerBot, 0.9), + (spazbot.TriggerBot, 0.85), + ] + if level > 2: + defender_types += [(spazbot.ChargerBot, 0.75)] + if level > 4: + defender_types += ([(spazbot.StickyBot, 0.7)] * + (1 + (level - 5) // 6)) + if level > 6: + defender_types += ([(spazbot.ExplodeyBot, 0.7)] * + (1 + (level - 5) // 5)) + if level > 8: + defender_types += ([(spazbot.BrawlerBotProShielded, 0.65)] * + (1 + (level - 5) // 4)) + if level > 10: + defender_types += ([(spazbot.TriggerBotProShielded, 0.6)] * + (1 + (level - 6) // 3)) + + for group in range(group_count): + this_target_point_s = target_points / group_count + + # Adding spacing makes things slightly harder. + rval = random.random() + if rval < 0.07: + spacing = 1.5 + this_target_point_s *= 0.85 + elif rval < 0.15: + spacing = 1.0 + this_target_point_s *= 0.9 + else: + spacing = 0.0 + + path = random.randint(1, 3) + + # Don't allow hard paths on early levels. + if level < 3: + if path == 1: + path = 3 + + # Easy path. + if path == 3: + pass + + # Harder path. + elif path == 2: + this_target_point_s *= 0.8 + + # Even harder path. + elif path == 1: + this_target_point_s *= 0.7 + + # Looping forward. + elif path == 4: + this_target_point_s *= 0.7 + + # Looping backward. + elif path == 5: + this_target_point_s *= 0.7 + + # Random. + elif path == 6: + this_target_point_s *= 0.7 + + def _add_defender( + defender_type: Tuple[Type[spazbot.SpazBot], float], + pnt: str) -> Tuple[float, Dict[str, Any]]: + # FIXME: should look into this warning + # pylint: disable=cell-var-from-loop + return this_target_point_s * defender_type[1], { + 'type': defender_type[0], + 'point': pnt + } + + # Add defenders. + defender_type1 = defender_types[random.randrange( + len(defender_types))] + defender_type2 = defender_types[random.randrange( + len(defender_types))] + defender1 = defender2 = None + if ((group == 0) or (group == 1 and level > 3) + or (group == 2 and level > 5)): + if random.random() < min(0.75, (level - 1) * 0.11): + this_target_point_s, defender1 = _add_defender( + defender_type1, 'bottom_left') + if random.random() < min(0.75, (level - 1) * 0.04): + this_target_point_s, defender2 = _add_defender( + defender_type2, 'bottom_right') + + spaz_type = spaz_types[random.randrange(len(spaz_types))] + member_count = max( + 1, int(round(this_target_point_s / spaz_type[1]))) + for i, _member in enumerate(range(member_count)): + if path == 4: + this_path = i % 3 # Looping forward. + elif path == 5: + this_path = 3 - (i % 3) # Looping backward. + elif path == 6: + this_path = random.randint(1, 3) # Random. + else: + this_path = path + entries.append({'type': spaz_type[0], 'path': this_path}) + if spacing != 0.0: + entries.append({ + 'type': 'spacing', + 'duration': spacing + }) + + if defender1 is not None: + entries.append(defender1) + if defender2 is not None: + entries.append(defender2) + + # Some spacing between groups. + rval = random.random() + if rval < 0.1: + spacing = 5.0 + elif rval < 0.5: + spacing = 1.0 + else: + spacing = 1.0 + entries.append({'type': 'spacing', 'duration': spacing}) + + wave = {'entries': entries} + + else: + assert self._waves is not None + wave = self._waves[self._wave - 1] + + bot_types += wave['entries'] + self._time_bonus_mult = 1.0 + this_flawless_bonus = 0 + non_runner_spawn_time = 1.0 + + for info in bot_types: + if info is None: + continue + bot_type = info['type'] + path = -1 + if bot_type is not None: + if bot_type == 'non_runner_delay': + non_runner_spawn_time += info['duration'] + continue + if bot_type == 'spacing': + t_sec += info['duration'] + continue + try: + path = info['path'] + except Exception: + path = random.randint(1, 3) + self._time_bonus_mult += bot_type.points_mult * 0.02 + this_flawless_bonus += bot_type.points_mult * 5 + + # If its got a position, use that. + try: + point = info['point'] + except Exception: + point = 'start' + + # Space our our slower bots. + delay = base_delay + delay /= self._get_bot_speed(bot_type) + t_sec += delay * 0.5 + tcall = ba.Call(self.add_bot_at_point, point, { + 'type': bot_type, + 'path': path + }, 0.1 if point == 'start' else non_runner_spawn_time) + ba.timer(t_sec, tcall) + t_sec += delay * 0.5 + + # We can end the wave after all the spawning happens. + ba.timer(t_sec - delay * 0.5 + non_runner_spawn_time + 0.01, + self._set_can_end_wave) + + # Reset our time bonus. + # In this game we use a constant time bonus so it erodes away in + # roughly the same time (since the time limit a wave can take is + # relatively constant) ..we then post-multiply a modifier to adjust + # points. + self._time_bonus = 150 + self._flawless_bonus = this_flawless_bonus + assert self._time_bonus_mult is not None + txtval = ba.Lstr( + value='${A}: ${B}', + subs=[('${A}', ba.Lstr(resource='timeBonusText')), + ('${B}', str(int(self._time_bonus * self._time_bonus_mult))) + ]) + self._time_bonus_text = ba.Actor( + ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'center', + 'color': (1, 1, 0.0, 1), + 'shadow': 1.0, + 'vr_depth': -30, + 'flatness': 1.0, + 'position': (0, -60), + 'scale': 0.8, + 'text': txtval + })) + + ba.timer(t_sec, self._start_time_bonus_timer) + + # Keep track of when this wave finishes emerging. We wanna stop + # dropping land-mines powerups at some point (otherwise a crafty + # player could fill the whole map with them) + self._last_wave_end_time = ba.time() + t_sec + assert self._waves is not None + txtval = ba.Lstr( + value='${A} ${B}', + subs=[ + ('${A}', ba.Lstr(resource='waveText')), + ('${B}', str(self._wave) + + ('' if self._preset in ['endless', 'endless_tournament'] else + ('/' + str(len(self._waves))))) + ]) + self._wave_text = ba.Actor( + ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'center', + 'vr_depth': -10, + 'color': (1, 1, 1, 1), + 'shadow': 1.0, + 'flatness': 1.0, + 'position': (0, -40), + 'scale': 1.3, + 'text': txtval + })) + + # noinspection PyTypeHints + def _on_bot_spawn(self, path: int, spaz: spazbot.SpazBot) -> None: + # Add our custom update callback and set some info for this bot. + spaz_type = type(spaz) + assert spaz is not None + spaz.update_callback = self._update_bot + + # FIXME: Do this in a type-safe way. + spaz.r_walk_row = path # type: ignore + spaz.r_walk_speed = self._get_bot_speed(spaz_type) # type: ignore + + def add_bot_at_point(self, + point: str, + spaz_info: Dict[str, Any], + spawn_time: float = 0.1) -> None: + """Add the given type bot with the given delay (in seconds).""" + + # Don't add if the game has ended. + if self._game_over: + return + pos = self.map.defs.points['bot_spawn_' + point][:3] + self._bots.spawn_bot(spaz_info['type'], + pos=pos, + spawn_time=spawn_time, + on_spawn_call=ba.Call(self._on_bot_spawn, + spaz_info['path'])) + + def _update_time_bonus(self) -> None: + self._time_bonus = int(self._time_bonus * 0.91) + if self._time_bonus > 0 and self._time_bonus_text is not None: + assert self._time_bonus_text.node + assert self._time_bonus_mult + self._time_bonus_text.node.text = ba.Lstr( + value='${A}: ${B}', + subs=[('${A}', ba.Lstr(resource='timeBonusText')), + ('${B}', + str(int(self._time_bonus * self._time_bonus_mult)))]) + else: + self._time_bonus_text = None + + def _start_updating_waves(self) -> None: + self._wave_update_timer = ba.Timer(2.0, + self._update_waves, + repeat=True) + + def _update_scores(self) -> None: + score = self._score + if self._preset == 'endless': + if score >= 500: + self._award_achievement('Runaround Master') + if score >= 1000: + self._award_achievement('Runaround Wizard') + if score >= 2000: + self._award_achievement('Runaround God') + + assert self._scoreboard is not None + self._scoreboard.set_team_value(self.teams[0], score, max_score=None) + + def _update_bot(self, bot: spazbot.SpazBot) -> bool: + # Yup; that's a lot of return statements right there. + # pylint: disable=too-many-return-statements + assert bot.node + + # FIXME: Do this in a type safe way. + r_walk_speed: float = bot.r_walk_speed # type: ignore + r_walk_row: int = bot.r_walk_row # type: ignore + + speed = r_walk_speed + pos = bot.node.position + boxes = self.map.defs.boxes + + # Bots in row 1 attempt the high road.. + if r_walk_row == 1: + if ba.is_point_in_box(pos, boxes['b4']): + bot.node.move_up_down = speed + bot.node.move_left_right = 0 + bot.node.run = 0.0 + return True + + # Row 1 and 2 bots attempt the middle road.. + if r_walk_row in [1, 2]: + if ba.is_point_in_box(pos, boxes['b1']): + bot.node.move_up_down = speed + bot.node.move_left_right = 0 + bot.node.run = 0.0 + return True + + # All bots settle for the third row. + if ba.is_point_in_box(pos, boxes['b7']): + bot.node.move_up_down = speed + bot.node.move_left_right = 0 + bot.node.run = 0.0 + return True + if ba.is_point_in_box(pos, boxes['b2']): + bot.node.move_up_down = -speed + bot.node.move_left_right = 0 + bot.node.run = 0.0 + return True + if ba.is_point_in_box(pos, boxes['b3']): + bot.node.move_up_down = -speed + bot.node.move_left_right = 0 + bot.node.run = 0.0 + return True + if ba.is_point_in_box(pos, boxes['b5']): + bot.node.move_up_down = -speed + bot.node.move_left_right = 0 + bot.node.run = 0.0 + return True + if ba.is_point_in_box(pos, boxes['b6']): + bot.node.move_up_down = speed + bot.node.move_left_right = 0 + bot.node.run = 0.0 + return True + if ((ba.is_point_in_box(pos, boxes['b8']) + and not ba.is_point_in_box(pos, boxes['b9'])) + or pos == (0.0, 0.0, 0.0)): + + # Default to walking right if we're still in the walking area. + bot.node.move_left_right = speed + bot.node.move_up_down = 0 + bot.node.run = 0.0 + return True + + # Revert to normal bot behavior otherwise.. + return False + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, ba.PlayerScoredMessage): + self._score += msg.score + self._update_scores() + + # Respawn dead players. + elif isinstance(msg, playerspaz.PlayerSpazDeathMessage): + from bastd.actor import respawnicon + self._a_player_has_been_killed = True + player = msg.spaz.getplayer() + if player is None: + ba.print_error('FIXME: getplayer() should no' + ' longer ever be returning None') + return + if not player: + return + self.stats.player_lost_spaz(player) + + # Respawn them shortly. + assert self.initial_player_info is not None + respawn_time = 2.0 + len(self.initial_player_info) * 1.0 + player.gamedata['respawn_timer'] = ba.Timer( + respawn_time, ba.Call(self.spawn_player_if_exists, player)) + player.gamedata['respawn_icon'] = respawnicon.RespawnIcon( + player, respawn_time) + + elif isinstance(msg, spazbot.SpazBotDeathMessage): + if msg.how == 'goal': + return + pts, importance = msg.badguy.get_death_points(msg.how) + if msg.killerplayer is not None: + target: Optional[Sequence[float]] + try: + assert msg.badguy is not None + assert msg.badguy.node + target = msg.badguy.node.position + except Exception: + ba.print_exception() + target = None + try: + if msg.killerplayer: + self.stats.player_scored(msg.killerplayer, + pts, + target=target, + kill=True, + screenmessage=False, + importance=importance) + ba.playsound(self._dingsound if importance == 1 else + self._dingsoundhigh, + volume=0.6) + except Exception as exc: + print('EXC in Runaround on SpazBotDeathMessage:', exc) + + # Normally we pull scores from the score-set, but if there's no + # player lets be explicit. + else: + self._score += pts + self._update_scores() + + else: + super().handlemessage(msg) + + def _get_bot_speed(self, bot_type: Type[spazbot.SpazBot]) -> float: + speed = self._bot_speed_map.get(bot_type) + if speed is None: + raise Exception('Invalid bot type to _get_bot_speed(): ' + + str(bot_type)) + return speed + + def _set_can_end_wave(self) -> None: + self._can_end_wave = True diff --git a/assets/src/data/scripts/bastd/game/targetpractice.py b/assets/src/data/scripts/bastd/game/targetpractice.py new file mode 100644 index 00000000..804d1a1d --- /dev/null +++ b/assets/src/data/scripts/bastd/game/targetpractice.py @@ -0,0 +1,368 @@ +"""Implements Target Practice game.""" + +# bs_meta require api 6 +# (see bombsquadgame.com/apichanges) + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor import playerspaz + +if TYPE_CHECKING: + from typing import Any, Type, List, Dict, Optional, Tuple, Sequence + from bastd.actor.onscreencountdown import OnScreenCountdown + from bastd.actor.bomb import Bomb, Blast + + +# bs_meta export game +class TargetPracticeGame(ba.TeamGameActivity): + """Game where players try to hit targets with bombs.""" + + @classmethod + def get_name(cls) -> str: + return 'Target Practice' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return 'Bomb as many targets as you can.' + + @classmethod + def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]: + return ['Doom Shroom'] + + @classmethod + def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool: + # We support any teams or versus sessions. + return (issubclass(sessiontype, ba.CoopSession) + or issubclass(sessiontype, ba.TeamBaseSession)) + + @classmethod + def get_settings(cls, sessiontype: Type[ba.Session] + ) -> List[Tuple[str, Dict[str, Any]]]: + return [("Target Count", { + 'min_value': 1, + 'default': 3 + }), ("Enable Impact Bombs", { + 'default': True + }), ("Enable Triple Bombs", { + 'default': True + })] + + def __init__(self, settings: Dict[str, Any]): + from bastd.actor.scoreboard import Scoreboard + super().__init__(settings) + self._scoreboard = Scoreboard() + self._targets: List[Target] = [] + self._update_timer: Optional[ba.Timer] = None + self._countdown: Optional[OnScreenCountdown] = None + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify these args. + # pylint: disable=arguments-differ + ba.TeamGameActivity.on_transition_in(self, music='ForwardMarch') + + def on_team_join(self, team: ba.Team) -> None: + team.gamedata['score'] = 0 + if self.has_begun(): + self.update_scoreboard() + + def on_begin(self) -> None: + from bastd.actor.onscreencountdown import OnScreenCountdown + ba.TeamGameActivity.on_begin(self) + self.update_scoreboard() + + # Number of targets is based on player count. + num_targets = self.settings['Target Count'] + for i in range(num_targets): + ba.timer(5.0 + i * 1.0, self._spawn_target) + + self._update_timer = ba.Timer(1.0, self._update, repeat=True) + self._countdown = OnScreenCountdown(60, endcall=self.end_game) + ba.timer(4.0, self._countdown.start) + + def spawn_player(self, player: ba.Player) -> ba.Actor: + spawn_center = (0, 3, -5) + pos = (spawn_center[0] + random.uniform(-1.5, 1.5), spawn_center[1], + spawn_center[2] + random.uniform(-1.5, 1.5)) + + # Reset their streak. + player.gamedata['streak'] = 0 + spaz = self.spawn_player_spaz(player, position=pos) + + # Give players permanent triple impact bombs and wire them up + # to tell us when they drop a bomb. + if self.settings['Enable Impact Bombs']: + spaz.bomb_type = 'impact' + if self.settings['Enable Triple Bombs']: + spaz.set_bomb_count(3) + spaz.add_dropped_bomb_callback(self._on_spaz_dropped_bomb) + return spaz + + def _spawn_target(self) -> None: + + # Generate a few random points; we'll use whichever one is farthest + # from our existing targets (don't want overlapping targets). + points = [] + + for _i in range(4): + # Calc a random point within a circle. + while True: + xpos = random.uniform(-1.0, 1.0) + ypos = random.uniform(-1.0, 1.0) + if xpos * xpos + ypos * ypos < 1.0: + break + points.append((8.0 * xpos, 2.2, -3.5 + 5.0 * ypos)) + + def get_min_dist_from_target(pnt: Sequence[float]) -> float: + return min((t.get_dist_from_point(pnt) for t in self._targets)) + + # If we have existing targets, use the point with the highest + # min-distance-from-targets. + if self._targets: + point = max(points, key=get_min_dist_from_target) + else: + point = points[0] + + self._targets.append(Target(position=point)) + + # noinspection PyUnusedLocal + def _on_spaz_dropped_bomb(self, spaz: ba.Actor, bomb: ba.Actor) -> None: + # pylint: disable=unused-argument + from bastd.actor.bomb import Bomb + + # Wire up this bomb to inform us when it blows up. + assert isinstance(bomb, Bomb) + bomb.add_explode_callback(self._on_bomb_exploded) + + def _on_bomb_exploded(self, bomb: Bomb, blast: Blast) -> None: + assert blast.node + pos = blast.node.position + + # Debugging: throw a locator down where we landed. + # ba.newnode('locator', attrs={'position':blast.node.position}) + + # Feed the explosion point to all our targets and get points in return. + # Note: we operate on a copy of self._targets since the list may change + # under us if we hit stuff (don't wanna get points for new targets). + player = bomb.get_source_player() + if not player: + return # could happen if they leave after throwing a bomb.. + + bullseye = any( + target.do_hit_at_position(pos, player) + for target in list(self._targets)) + if bullseye: + player.gamedata['streak'] += 1 + else: + player.gamedata['streak'] = 0 + + def _update(self) -> None: + """Misc. periodic updating.""" + # Clear out targets that have died. + self._targets = [t for t in self._targets if t] + + def handlemessage(self, msg: Any) -> Any: + # When players die, respawn them. + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + super().handlemessage(msg) # Do standard stuff. + player = msg.spaz.getplayer() + assert player is not None + self.respawn_player(player) # Kick off a respawn. + elif isinstance(msg, Target.TargetHitMessage): + # A target is telling us it was hit and will die soon.. + # ..so make another one. + self._spawn_target() + else: + super().handlemessage(msg) + + def update_scoreboard(self) -> None: + """Update the game scoreboard with current team values.""" + for team in self.teams: + self._scoreboard.set_team_value(team, team.gamedata['score']) + + def end_game(self) -> None: + results = ba.TeamGameResults() + for team in self.teams: + results.set_team_score(team, team.gamedata['score']) + self.end(results) + + +class Target(ba.Actor): + """A target practice target.""" + + class TargetHitMessage: + """Inform an object a target was hit.""" + + def __init__(self, position: Sequence[float]): + self._r1 = 0.45 + self._r2 = 1.1 + self._r3 = 2.0 + self._rfudge = 0.15 + super().__init__() + self._position = ba.Vec3(position) + self._hit = False + + # It can be handy to test with this on to make sure the projection + # isn't too far off from the actual object. + show_in_space = False + loc1 = ba.newnode('locator', + attrs={ + 'shape': 'circle', + 'position': position, + 'color': (0, 1, 0), + 'opacity': 0.5, + 'draw_beauty': show_in_space, + 'additive': True + }) + loc2 = ba.newnode('locator', + attrs={ + 'shape': 'circle_outline', + 'position': position, + 'color': (0, 1, 0), + 'opacity': 0.3, + 'draw_beauty': False, + 'additive': True + }) + loc3 = ba.newnode('locator', + attrs={ + 'shape': 'circle_outline', + 'position': position, + 'color': (0, 1, 0), + 'opacity': 0.1, + 'draw_beauty': False, + 'additive': True + }) + self._nodes = [loc1, loc2, loc3] + ba.animate_array(loc1, 'size', 1, {0: [0.0], 0.2: [self._r1 * 2.0]}) + ba.animate_array(loc2, 'size', 1, { + 0.05: [0.0], + 0.25: [self._r2 * 2.0] + }) + ba.animate_array(loc3, 'size', 1, {0.1: [0.0], 0.3: [self._r3 * 2.0]}) + ba.playsound(ba.getsound('laserReverse')) + + def exists(self) -> bool: + return bool(self._nodes) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, ba.DieMessage): + for node in self._nodes: + node.delete() + self._nodes = [] + else: + super().handlemessage(msg) + + def get_dist_from_point(self, pos: Sequence[float]) -> float: + """Given a point, returns distance squared from it.""" + return (ba.Vec3(pos) - self._position).length() + + def do_hit_at_position(self, pos: Sequence[float], + player: ba.Player) -> bool: + """Handle a bomb hit at the given position.""" + # pylint: disable=too-many-statements + from bastd.actor import popuptext + activity = self.activity + + # Ignore hits if the game is over or if we've already been hit + if activity.has_ended() or self._hit or not self._nodes: + return False + + diff = (ba.Vec3(pos) - self._position) + + # Disregard Y difference. Our target point probably isn't exactly + # on the ground anyway. + diff[1] = 0.0 + dist = diff.length() + + bullseye = False + if dist <= self._r3 + self._rfudge: + # Inform our activity that we were hit + self._hit = True + activity.handlemessage(self.TargetHitMessage()) + keys: Dict[float, Sequence[float]] = { + 0.0: (1.0, 0.0, 0.0), + 0.049: (1.0, 0.0, 0.0), + 0.05: (1.0, 1.0, 1.0), + 0.1: (0.0, 1.0, 0.0) + } + cdull = (0.3, 0.3, 0.3) + popupcolor: Sequence[float] + if dist <= self._r1 + self._rfudge: + bullseye = True + self._nodes[1].color = cdull + self._nodes[2].color = cdull + ba.animate_array(self._nodes[0], 'color', 3, keys, loop=True) + popupscale = 1.8 + popupcolor = (1, 1, 0, 1) + streak = player.gamedata['streak'] + points = 10 + min(20, streak * 2) + ba.playsound(ba.getsound('bellHigh')) + if streak > 0: + ba.playsound( + ba.getsound( + 'orchestraHit4' if streak > 3 else + 'orchestraHit3' if streak > 2 else + 'orchestraHit2' if streak > 1 else 'orchestraHit')) + elif dist <= self._r2 + self._rfudge: + self._nodes[0].color = cdull + self._nodes[2].color = cdull + ba.animate_array(self._nodes[1], 'color', 3, keys, loop=True) + popupscale = 1.25 + popupcolor = (1, 0.5, 0.2, 1) + points = 4 + ba.playsound(ba.getsound('bellMed')) + else: + self._nodes[0].color = cdull + self._nodes[1].color = cdull + ba.animate_array(self._nodes[2], 'color', 3, keys, loop=True) + popupscale = 1.0 + popupcolor = (0.8, 0.3, 0.3, 1) + points = 2 + ba.playsound(ba.getsound('bellLow')) + + # Award points/etc.. (technically should probably leave this up + # to the activity). + popupstr = "+" + str(points) + + # If there's more than 1 player in the game, include their + # names and colors so they know who got the hit. + if len(activity.players) > 1: + popupcolor = ba.safecolor(player.color, target_intensity=0.75) + popupstr += ' ' + player.get_name() + popuptext.PopupText(popupstr, + position=self._position, + color=popupcolor, + scale=popupscale).autoretain() + + # Give this player's team points and update the score-board. + player.team.gamedata['score'] += points + assert isinstance(activity, TargetPracticeGame) + activity.update_scoreboard() + + # Also give this individual player points + # (only applies in teams mode). + assert activity.stats is not None + activity.stats.player_scored(player, + points, + showpoints=False, + screenmessage=False) + + ba.animate_array(self._nodes[0], 'size', 1, { + 0.8: self._nodes[0].size, + 1.0: [0.0] + }) + ba.animate_array(self._nodes[1], 'size', 1, { + 0.85: self._nodes[1].size, + 1.05: [0.0] + }) + ba.animate_array(self._nodes[2], 'size', 1, { + 0.9: self._nodes[2].size, + 1.1: [0.0] + }) + ba.timer(1.1, ba.Call(self.handlemessage, ba.DieMessage())) + + return bullseye diff --git a/assets/src/data/scripts/bastd/game/thelaststand.py b/assets/src/data/scripts/bastd/game/thelaststand.py new file mode 100644 index 00000000..84b4c883 --- /dev/null +++ b/assets/src/data/scripts/bastd/game/thelaststand.py @@ -0,0 +1,299 @@ +"""Defines the last stand minigame.""" + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import ba +from bastd.actor import playerspaz +from bastd.actor import spazbot +from bastd.actor.bomb import TNTSpawner + +if TYPE_CHECKING: + from typing import Any, Dict, Type, List, Optional, Sequence + from bastd.actor.scoreboard import Scoreboard + + +class TheLastStandGame(ba.CoopGameActivity): + """Slow motion how-long-can-you-last game.""" + + tips = [ + 'This level never ends, but a high score here\n' + 'will earn you eternal respect throughout the world.' + ] + + @classmethod + def get_name(cls) -> str: + return 'The Last Stand' + + @classmethod + def get_description(cls, sessiontype: Type[ba.Session]) -> str: + return "Final glorious epic slow motion battle to the death." + + def __init__(self, settings: Dict[str, Any]): + settings['map'] = 'Rampage' + super().__init__(settings) + + # Show messages when players die since it matters here. + self.announce_player_deaths = True + + # And of course the most important part. + self.slow_motion = True + + self._new_wave_sound = ba.getsound('scoreHit01') + self._winsound = ba.getsound("score") + self._cashregistersound = ba.getsound('cashRegister') + self._spawn_center = (0, 5.5, -4.14) + self._tntspawnpos = (0, 5.5, -6) + self._powerup_center = (0, 7, -4.14) + self._powerup_spread = (7, 2) + self._preset = self.settings.get('preset', 'default') + self._excludepowerups: List[str] = [] + self._scoreboard: Optional[Scoreboard] = None + self._score = 0 + self._bots = spazbot.BotSet() + self._dingsound = ba.getsound('dingSmall') + self._dingsoundhigh = ba.getsound('dingSmallHigh') + self._tntspawner: Optional[TNTSpawner] = None + self._bot_update_interval: Optional[float] = None + self._bot_update_timer: Optional[ba.Timer] = None + self._powerup_drop_timer = None + + # For each bot type: [spawn-rate, increase, d_increase] + self._bot_spawn_types = { + spazbot.BomberBot: [1.00, 0.00, 0.000], + spazbot.BomberBotPro: [0.00, 0.05, 0.001], + spazbot.BomberBotProShielded: [0.00, 0.02, 0.002], + spazbot.BrawlerBot: [1.00, 0.00, 0.000], + spazbot.BrawlerBotPro: [0.00, 0.05, 0.001], + spazbot.BrawlerBotProShielded: [0.00, 0.02, 0.002], + spazbot.TriggerBot: [0.30, 0.00, 0.000], + spazbot.TriggerBotPro: [0.00, 0.05, 0.001], + spazbot.TriggerBotProShielded: [0.00, 0.02, 0.002], + spazbot.ChargerBot: [0.30, 0.05, 0.000], + spazbot.StickyBot: [0.10, 0.03, 0.001], + spazbot.ExplodeyBot: [0.05, 0.02, 0.002] + } # yapf: disable + + # noinspection PyMethodOverriding + def on_transition_in(self) -> None: # type: ignore + # FIXME: Unify args for this call. + # pylint: disable=arguments-differ + from bastd.actor.scoreboard import Scoreboard + ba.CoopGameActivity.on_transition_in(self, music='Epic') + ba.timer(1.3, ba.Call(ba.playsound, self._new_wave_sound)) + self._scoreboard = Scoreboard(label=ba.Lstr(resource='scoreText'), + score_split=0.5) + + def on_begin(self) -> None: + ba.CoopGameActivity.on_begin(self) + + # Spit out a few powerups and start dropping more shortly. + self._drop_powerups(standard_points=True) + ba.timer(2.0, ba.WeakCall(self._start_powerup_drops)) + ba.timer(0.001, ba.WeakCall(self._start_bot_updates)) + self.setup_low_life_warning_sound() + self._update_scores() + + # Our TNT spawner (if applicable). + self._tntspawner = TNTSpawner(position=self._tntspawnpos, + respawn_time=10.0) + + def spawn_player(self, player: ba.Player) -> ba.Actor: + pos = (self._spawn_center[0] + random.uniform(-1.5, 1.5), + self._spawn_center[1], + self._spawn_center[2] + random.uniform(-1.5, 1.5)) + return self.spawn_player_spaz(player, position=pos) + + def _start_bot_updates(self) -> None: + self._bot_update_interval = 3.3 - 0.3 * (len(self.players)) + self._update_bots() + self._update_bots() + if len(self.players) > 2: + self._update_bots() + if len(self.players) > 3: + self._update_bots() + self._bot_update_timer = ba.Timer(self._bot_update_interval, + ba.WeakCall(self._update_bots)) + + def _drop_powerup(self, index: int, poweruptype: str = None) -> None: + from bastd.actor import powerupbox + if poweruptype is None: + poweruptype = (powerupbox.get_factory().get_random_powerup_type( + excludetypes=self._excludepowerups)) + powerupbox.PowerupBox(position=self.map.powerup_spawn_points[index], + poweruptype=poweruptype).autoretain() + + def _start_powerup_drops(self) -> None: + self._powerup_drop_timer = ba.Timer(3.0, + ba.WeakCall(self._drop_powerups), + repeat=True) + + def _drop_powerups(self, + standard_points: bool = False, + force_first: bool = None) -> None: + """Generic powerup drop.""" + from bastd.actor import powerupbox + if standard_points: + pts = self.map.powerup_spawn_points + for i in range(len(pts)): + ba.timer( + 1.0 + i * 0.5, + ba.WeakCall(self._drop_powerup, i, + force_first if i == 0 else None)) + else: + drop_pt = (self._powerup_center[0] + random.uniform( + -1.0 * self._powerup_spread[0], 1.0 * self._powerup_spread[0]), + self._powerup_center[1], + self._powerup_center[2] + random.uniform( + -self._powerup_spread[1], self._powerup_spread[1])) + + # Drop one random one somewhere. + powerupbox.PowerupBox( + position=drop_pt, + poweruptype=powerupbox.get_factory().get_random_powerup_type( + excludetypes=self._excludepowerups)).autoretain() + + def do_end(self, outcome: str) -> None: + """End the game.""" + if outcome == 'defeat': + self.fade_to_red() + self.end(delay=2.0, + results={ + 'outcome': outcome, + 'score': self._score, + 'player_info': self.initial_player_info + }) + + def _update_bots(self) -> None: + assert self._bot_update_interval is not None + self._bot_update_interval = max(0.5, self._bot_update_interval * 0.98) + self._bot_update_timer = ba.Timer(self._bot_update_interval, + ba.WeakCall(self._update_bots)) + botspawnpts: List[Sequence[float]] = [[-5.0, 5.5, -4.14], + [0.0, 5.5, -4.14], + [5.0, 5.5, -4.14]] + dists = [0.0, 0.0, 0.0] + playerpts: List[Sequence[float]] = [] + for player in self.players: + try: + if player.is_alive(): + assert player.actor is not None and player.actor.node + playerpts.append(player.actor.node.position) + except Exception as exc: + print('ERROR in _update_bots', exc) + for i in range(3): + for playerpt in playerpts: + dists[i] += abs(playerpt[0] - botspawnpts[i][0]) + + # Little random variation. + dists[i] += random.random() * 5.0 + if dists[0] > dists[1] and dists[0] > dists[2]: + spawnpt = botspawnpts[0] + elif dists[1] > dists[2]: + spawnpt = botspawnpts[1] + else: + spawnpt = botspawnpts[2] + + spawnpt = (spawnpt[0] + 3.0 * (random.random() - 0.5), spawnpt[1], + 2.0 * (random.random() - 0.5) + spawnpt[2]) + + # Normalize our bot type total and find a random number within that. + total = 0.0 + for spawntype in self._bot_spawn_types.items(): + total += spawntype[1][0] + randval = random.random() * total + + # Now go back through and see where this value falls. + total = 0 + bottype: Optional[Type[spazbot.SpazBot]] = None + for spawntype in self._bot_spawn_types.items(): + total += spawntype[1][0] + if randval <= total: + bottype = spawntype[0] + break + spawn_time = 1.0 + assert bottype is not None + self._bots.spawn_bot(bottype, pos=spawnpt, spawn_time=spawn_time) + + # After every spawn we adjust our ratios slightly to get more + # difficult. + for spawntype in self._bot_spawn_types.items(): + spawntype[1][0] += spawntype[1][1] # incr spawn rate + spawntype[1][1] += spawntype[1][2] # incr spawn rate incr rate + + def _update_scores(self) -> None: + # Achievements in default preset only. + score = self._score + if self._preset == 'default': + if score >= 250: + self._award_achievement('Last Stand Master') + if score >= 500: + self._award_achievement('Last Stand Wizard') + if score >= 1000: + self._award_achievement('Last Stand God') + assert self._scoreboard is not None + self._scoreboard.set_team_value(self.teams[0], score, max_score=None) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, playerspaz.PlayerSpazDeathMessage): + player = msg.spaz.getplayer() + if player is None: + ba.print_error('FIXME: getplayer() should no longer ' + 'ever be returning None.') + return + if not player: + return + self.stats.player_lost_spaz(player) + ba.timer(0.1, self._checkroundover) + + elif isinstance(msg, ba.PlayerScoredMessage): + self._score += msg.score + self._update_scores() + + elif isinstance(msg, spazbot.SpazBotDeathMessage): + pts, importance = msg.badguy.get_death_points(msg.how) + target: Optional[Sequence[float]] + if msg.killerplayer: + try: + assert msg.badguy.node + target = msg.badguy.node.position + except Exception: + ba.print_exception() + target = None + try: + self.stats.player_scored(msg.killerplayer, + pts, + target=target, + kill=True, + screenmessage=False, + importance=importance) + ba.playsound(self._dingsound + if importance == 1 else self._dingsoundhigh, + volume=0.6) + except Exception as exc: + print('EXC on last-stand SpazBotDeathMessage', exc) + + # Normally we pull scores from the score-set, but if there's no + # player lets be explicit. + else: + self._score += pts + self._update_scores() + else: + super().handlemessage(msg) + + def _on_got_scores_to_beat(self, scores: List[Dict[str, Any]]) -> None: + # FIXME: Unify args. + self._show_standard_scores_to_beat_ui(scores) + + def end_game(self) -> None: + # Tell our bots to celebrate just to rub it in. + self._bots.final_celebrate() + ba.setmusic(None) + ba.pushcall(ba.WeakCall(self.do_end, 'defeat')) + + def _checkroundover(self) -> None: + """End the round if conditions are met.""" + if not any(player.is_alive() for player in self.teams[0].players): + self.end_game() diff --git a/assets/src/data/scripts/bastd/mainmenu.py b/assets/src/data/scripts/bastd/mainmenu.py new file mode 100644 index 00000000..bc547cde --- /dev/null +++ b/assets/src/data/scripts/bastd/mainmenu.py @@ -0,0 +1,901 @@ +"""Session and Activity for displaying the main menu bg.""" + +from __future__ import annotations + +import random +import time +import weakref +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.actor import spaz + +if TYPE_CHECKING: + from typing import Any, List, Optional + +# FIXME: Clean this up if I ever revisit it. +# pylint: disable=attribute-defined-outside-init +# pylint: disable=too-many-branches +# pylint: disable=too-many-statements +# pylint: disable=too-many-locals +# noinspection PyUnreachableCode +# noinspection PyAttributeOutsideInit + + +class MainMenuActivity(ba.Activity): + """Activity showing the rotating main menu bg stuff.""" + + _stdassets = ba.Dep(ba.AssetPackage, 'stdassets@1') + + def on_transition_in(self) -> None: + super().on_transition_in() + random.seed(123) + self._logo_node: Optional[ba.Node] = None + self._custom_logo_tex_name: Optional[str] = None + self._word_actors: List[ba.Actor] = [] + app = ba.app + + # FIXME: We shouldn't be doing things conditionally based on whether + # the host is VR mode or not (clients may differ in that regard). + # Any differences need to happen at the engine level so everyone + # sees things in their own optimal way. + vr_mode = ba.app.vr_mode + + if not ba.app.toolbar_test: + color = ((1.0, 1.0, 1.0, 1.0) if vr_mode else (0.5, 0.6, 0.5, 0.6)) + # FIXME: Need a node attr for vr-specific-scale. + scale = (0.9 if + (app.interface_type == 'small' or vr_mode) else 0.7) + self.my_name = ba.Actor( + ba.newnode('text', + attrs={ + 'v_attach': 'bottom', + 'h_align': 'center', + 'color': color, + 'flatness': 1.0, + 'shadow': 1.0 if vr_mode else 0.5, + 'scale': scale, + 'position': (0, 10), + 'vr_depth': -10, + 'text': '\xa9 2018 Eric Froemling' + })) + + # Throw up some text that only clients can see so they know that the + # host is navigating menus while they're just staring at an + # empty-ish screen. + tval = ba.Lstr(resource='hostIsNavigatingMenusText', + subs=[('${HOST}', _ba.get_account_display_string())]) + self._host_is_navigating_text = ba.Actor( + ba.newnode('text', + attrs={ + 'text': tval, + 'client_only': True, + 'position': (0, -200), + 'flatness': 1.0, + 'h_align': 'center' + })) + if not ba.app.main_menu_did_initial_transition and hasattr( + self, 'my_name'): + assert self.my_name.node + ba.animate(self.my_name.node, 'opacity', {2.3: 0, 3.0: 1.0}) + + # FIXME: We shouldn't be doing things conditionally based on whether + # the host is vr mode or not (clients may not be or vice versa). + # Any differences need to happen at the engine level so everyone sees + # things in their own optimal way. + vr_mode = app.vr_mode + interface_type = app.interface_type + + # In cases where we're doing lots of dev work lets always show the + # build number. + force_show_build_number = False + + if not ba.app.toolbar_test: + if app.debug_build or app.test_build or force_show_build_number: + if app.debug_build: + text = ba.Lstr(value='${V} (${B}) (${D})', + subs=[ + ('${V}', app.version), + ('${B}', str(app.build_number)), + ('${D}', ba.Lstr(resource='debugText')), + ]) + else: + text = ba.Lstr(value='${V} (${B})', + subs=[ + ('${V}', app.version), + ('${B}', str(app.build_number)), + ]) + else: + text = ba.Lstr(value='${V}', subs=[('${V}', app.version)]) + scale = 0.9 if (interface_type == 'small' or vr_mode) else 0.7 + color = (1, 1, 1, 1) if vr_mode else (0.5, 0.6, 0.5, 0.7) + self.version = ba.Actor( + ba.newnode( + 'text', + attrs={ + 'v_attach': 'bottom', + 'h_attach': 'right', + 'h_align': 'right', + 'flatness': 1.0, + 'vr_depth': -10, + 'shadow': 1.0 if vr_mode else 0.5, + 'color': color, + 'scale': scale, + 'position': (-260, 10) if vr_mode else (-10, 10), + 'text': text + })) + if not ba.app.main_menu_did_initial_transition: + assert self.version.node + ba.animate(self.version.node, 'opacity', {2.3: 0, 3.0: 1.0}) + + # Throw in beta info. + self.beta_info = self.beta_info_2 = None + if app.test_build and not app.kiosk_mode: + pos = (230, 125) if app.kiosk_mode else (230, 35) + self.beta_info = ba.Actor( + ba.newnode('text', + attrs={ + 'v_attach': 'center', + 'h_align': 'center', + 'color': (1, 1, 1, 1), + 'shadow': 0.5, + 'flatness': 0.5, + 'scale': 1, + 'vr_depth': -60, + 'position': pos, + 'text': ba.Lstr(resource='testBuildText') + })) + if not ba.app.main_menu_did_initial_transition: + assert self.beta_info.node + ba.animate(self.beta_info.node, 'opacity', {1.3: 0, 1.8: 1.0}) + + model = ba.getmodel('thePadLevel') + trees_model = ba.getmodel('trees') + bottom_model = ba.getmodel('thePadLevelBottom') + color_texture = ba.gettexture('thePadLevelColor') + trees_texture = ba.gettexture('treesColor') + bgtex = ba.gettexture('menuBG') + bgmodel = ba.getmodel('thePadBG') + + # Load these last since most platforms don't use them. + vr_bottom_fill_model = ba.getmodel('thePadVRFillBottom') + vr_top_fill_model = ba.getmodel('thePadVRFillTop') + + gnode = ba.sharedobj('globals') + gnode.camera_mode = 'rotate' + + tint = (1.14, 1.1, 1.0) + gnode.tint = tint + gnode.ambient_color = (1.06, 1.04, 1.03) + gnode.vignette_outer = (0.45, 0.55, 0.54) + gnode.vignette_inner = (0.99, 0.98, 0.98) + + self.bottom = ba.Actor( + ba.newnode('terrain', + attrs={ + 'model': bottom_model, + 'lighting': False, + 'reflection': 'soft', + 'reflection_scale': [0.45], + 'color_texture': color_texture + })) + self.vr_bottom_fill = ba.Actor( + ba.newnode('terrain', + attrs={ + 'model': vr_bottom_fill_model, + 'lighting': False, + 'vr_only': True, + 'color_texture': color_texture + })) + self.vr_top_fill = ba.Actor( + ba.newnode('terrain', + attrs={ + 'model': vr_top_fill_model, + 'vr_only': True, + 'lighting': False, + 'color_texture': bgtex + })) + self.terrain = ba.Actor( + ba.newnode('terrain', + attrs={ + 'model': model, + 'color_texture': color_texture, + 'reflection': 'soft', + 'reflection_scale': [0.3] + })) + self.trees = ba.Actor( + ba.newnode('terrain', + attrs={ + 'model': trees_model, + 'lighting': False, + 'reflection': 'char', + 'reflection_scale': [0.1], + 'color_texture': trees_texture + })) + self.bgterrain = ba.Actor( + ba.newnode('terrain', + attrs={ + 'model': bgmodel, + 'color': (0.92, 0.91, 0.9), + 'lighting': False, + 'background': True, + 'color_texture': bgtex + })) + self._ts = 0.86 + + self._language: Optional[str] = None + self._update_timer = ba.Timer(1.0, self._update, repeat=True) + self._update() + + # Hopefully this won't hitch but lets space these out anyway. + _ba.add_clean_frame_callback(ba.WeakCall(self._start_preloads)) + + random.seed() + + # On the main menu, also show our news. + class News: + """Wrangles news display.""" + + def __init__(self, activity: ba.Activity): + self._valid = True + self._message_duration = 10.0 + self._message_spacing = 2.0 + self._text: Optional[ba.Actor] = None + self._activity = weakref.ref(activity) + + # If we're signed in, fetch news immediately. + # Otherwise wait until we are signed in. + self._fetch_timer: Optional[ba.Timer] = ba.Timer( + 1.0, ba.WeakCall(self._try_fetching_news), repeat=True) + self._try_fetching_news() + + # We now want to wait until we're signed in before fetching news. + def _try_fetching_news(self) -> None: + if _ba.get_account_state() == 'signed_in': + self._fetch_news() + self._fetch_timer = None + + def _fetch_news(self) -> None: + ba.app.main_menu_last_news_fetch_time = time.time() + + # UPDATE - We now just pull news from MRVs. + news = _ba.get_account_misc_read_val('n', None) + if news is not None: + self._got_news(news) + + def _change_phrase(self) -> None: + from bastd.actor import text + + # If our news is way out of date, lets re-request it; + # otherwise, rotate our phrase. + assert app.main_menu_last_news_fetch_time is not None + if time.time() - app.main_menu_last_news_fetch_time > 600.0: + self._fetch_news() + self._text = None + else: + if self._text is not None: + if not self._phrases: + for phr in self._used_phrases: + self._phrases.insert(0, phr) + val = self._phrases.pop() + if val == '__ACH__': + vrmode = app.vr_mode + text.Text(ba.Lstr(resource='nextAchievementsText'), + color=((1, 1, 1, 1) if vrmode else + (0.95, 0.9, 1, 0.4)), + host_only=True, + maxwidth=200, + position=(-300, -35), + h_align='right', + transition='fade_in', + scale=0.9 if vrmode else 0.7, + flatness=1.0 if vrmode else 0.6, + shadow=1.0 if vrmode else 0.5, + h_attach="center", + v_attach="top", + transition_delay=1.0, + transition_out_delay=self. + _message_duration).autoretain() + achs = [ + a for a in app.achievements if not a.complete + ] + if achs: + ach = achs.pop( + random.randrange(min(4, len(achs)))) + ach.create_display( + -180, + -35, + 1.0, + outdelay=self._message_duration, + style='news') + if achs: + ach = achs.pop( + random.randrange(min(8, len(achs)))) + ach.create_display( + 180, + -35, + 1.25, + outdelay=self._message_duration, + style='news') + else: + spc = self._message_spacing + keys = { + spc: 0.0, + spc + 1.0: 1.0, + spc + self._message_duration - 1.0: 1.0, + spc + self._message_duration: 0.0 + } + assert self._text.node + ba.animate(self._text.node, "opacity", keys) + # {k: v + # for k, v in list(keys.items())}) + self._text.node.text = val + + def _got_news(self, news: str) -> None: + # Run this stuff in the context of our activity since we + # need to make nodes and stuff.. should fix the serverget + # call so it. + activity = self._activity() + if activity is None or activity.is_expired(): + return + with ba.Context(activity): + + self._phrases: List[str] = [] + + # Show upcoming achievements in non-vr versions + # (currently too hard to read in vr). + self._used_phrases = ( + ['__ACH__'] if not ba.app.vr_mode else + []) + [s for s in news.split('
\n') if s != ''] + self._phrase_change_timer = ba.Timer( + (self._message_duration + self._message_spacing), + ba.WeakCall(self._change_phrase), + repeat=True) + + scl = 1.2 if (ba.app.interface_type == 'small' + or ba.app.vr_mode) else 0.8 + + color2 = ((1, 1, 1, 1) if ba.app.vr_mode else + (0.7, 0.65, 0.75, 1.0)) + shadow = (1.0 if ba.app.vr_mode else 0.4) + self._text = ba.Actor( + ba.newnode('text', + attrs={ + 'v_attach': 'top', + 'h_attach': 'center', + 'h_align': 'center', + 'vr_depth': -20, + 'shadow': shadow, + 'flatness': 0.8, + 'v_align': 'top', + 'color': color2, + 'scale': scl, + 'maxwidth': 900.0 / scl, + 'position': (0, -10) + })) + self._change_phrase() + + if not app.kiosk_mode and not app.toolbar_test: + self._news = News(self) + + # Bring up the last place we were, or start at the main menu otherwise. + with ba.Context('ui'): + from bastd.ui import specialoffer + if True: # pylint: disable=using-constant-test + uicontroller = ba.app.uicontroller + assert uicontroller is not None + uicontroller.show_main_menu() + else: + + # main_window = ba.app.main_window + + # # when coming back from a kiosk-mode game, jump to + # # the kiosk start screen.. + # if ba.app.kiosk_mode: + # ba.app.main_menu_window = ( + # bs_ui.KioskWindow().get_root_widget()) + # # ..or in normal cases go back to the main menu + # else: + # if main_window == 'Gather': + # ba.app.main_menu_window = (bs_ui.GatherWindow( + # transition=None).get_root_widget()) + # elif main_window == 'Watch': + # ba.app.main_menu_window = (bs_ui.WatchWindow( + # transition=None).get_root_widget()) + # elif main_window == 'Team Game Select': + # ba.app.main_menu_window = + # (bs_ui.PlaylistBrowserWindow( + # sessiontype=ba.TeamsSession, + # transition=None).get_root_widget()) + # elif main_window == 'Free-for-All Game Select': + # ba.app.main_menu_window = + # (bs_ui.PlaylistBrowserWindow( + # sessiontype=ba.FreeForAllSession, + # transition=None).get_root_widget()) + # elif main_window == 'Coop Select': + # ba.app.main_menu_window = (bs_ui.CoopWindow( + # transition=None).get_root_widget()) + # else: + # ba.app.main_menu_window = ( + # bs_ui.MainMenuWindow( + # transition=None).get_root_widget()) + + # attempt to show any pending offers immediately. + # If that doesn't work, try again in a few seconds + # (we may not have heard back from the server) + # ..if that doesn't work they'll just have to wait + # until the next opportunity. + if not specialoffer.show_offer(): + + def try_again(): + if not specialoffer.show_offer(): + # try one last time.. + ba.timer(2.0, + specialoffer.show_offer, + timetype='real') + + ba.timer(2.0, try_again, timetype='real') + ba.app.main_menu_did_initial_transition = True + + def _update(self) -> None: + app = ba.app + + # Update logo in case it changes. + if self._logo_node: + custom_texture = self._get_custom_logo_tex_name() + if custom_texture != self._custom_logo_tex_name: + self._custom_logo_tex_name = custom_texture + self._logo_node.texture = ba.gettexture( + custom_texture if custom_texture is not None else 'logo') + self._logo_node.model_opaque = (None + if custom_texture is not None + else ba.getmodel('logo')) + self._logo_node.model_transparent = ( + None if custom_texture is not None else + ba.getmodel('logoTransparent')) + + # If language has changed, recreate our logo text/graphics. + lang = app.language + if lang != self._language: + self._language = lang + y = 20 + base_scale = 1.1 + self._word_actors = [] + base_delay = 1.0 + delay = base_delay + delay_inc = 0.02 + + # Come on faster after the first time. + if app.main_menu_did_initial_transition: + base_delay = 0.0 + delay = base_delay + delay_inc = 0.02 + + # We draw higher in kiosk mode (make sure to test this + # when making adjustments) for now we're hard-coded for + # a few languages.. should maybe look into generalizing this?.. + if app.language == 'Chinese': + base_x = -270.0 + x = base_x - 20.0 + spacing = 85.0 * base_scale + y_extra = 0.0 if app.kiosk_mode else 0.0 + self._make_logo(x - 110 + 50, + 113 + y + 1.2 * y_extra, + 0.34 * base_scale, + delay=base_delay + 0.1, + custom_texture='chTitleChar1', + jitter_scale=2.0, + vr_depth_offset=-30) + x += spacing + delay += delay_inc + self._make_logo(x - 10 + 50, + 110 + y + 1.2 * y_extra, + 0.31 * base_scale, + delay=base_delay + 0.15, + custom_texture='chTitleChar2', + jitter_scale=2.0, + vr_depth_offset=-30) + x += 2.0 * spacing + delay += delay_inc + self._make_logo(x + 180 - 140, + 110 + y + 1.2 * y_extra, + 0.3 * base_scale, + delay=base_delay + 0.25, + custom_texture='chTitleChar3', + jitter_scale=2.0, + vr_depth_offset=-30) + x += spacing + delay += delay_inc + self._make_logo(x + 241 - 120, + 110 + y + 1.2 * y_extra, + 0.31 * base_scale, + delay=base_delay + 0.3, + custom_texture='chTitleChar4', + jitter_scale=2.0, + vr_depth_offset=-30) + x += spacing + delay += delay_inc + self._make_logo(x + 300 - 90, + 105 + y + 1.2 * y_extra, + 0.34 * base_scale, + delay=base_delay + 0.35, + custom_texture='chTitleChar5', + jitter_scale=2.0, + vr_depth_offset=-30) + self._make_logo(base_x + 155, + 146 + y + 1.2 * y_extra, + 0.28 * base_scale, + delay=base_delay + 0.2, + rotate=-7) + else: + base_x = -170 + x = base_x - 20 + spacing = 55 * base_scale + y_extra = 0 if app.kiosk_mode else 0 + xv1 = x + delay1 = delay + for shadow in (True, False): + x = xv1 + delay = delay1 + self._make_word('B', + x - 50, + y - 23 + 0.8 * y_extra, + scale=1.3 * base_scale, + delay=delay, + vr_depth_offset=3, + shadow=shadow) + x += spacing + delay += delay_inc + self._make_word('m', + x, + y + y_extra, + delay=delay, + scale=base_scale, + shadow=shadow) + x += spacing * 1.25 + delay += delay_inc + self._make_word('b', + x, + y + y_extra - 10, + delay=delay, + scale=1.1 * base_scale, + vr_depth_offset=5, + shadow=shadow) + x += spacing * 0.85 + delay += delay_inc + self._make_word('S', + x, + y - 25 + 0.8 * y_extra, + scale=1.35 * base_scale, + delay=delay, + vr_depth_offset=14, + shadow=shadow) + x += spacing + delay += delay_inc + self._make_word('q', + x, + y + y_extra, + delay=delay, + scale=base_scale, + shadow=shadow) + x += spacing * 0.9 + delay += delay_inc + self._make_word('u', + x, + y + y_extra, + delay=delay, + scale=base_scale, + vr_depth_offset=7, + shadow=shadow) + x += spacing * 0.9 + delay += delay_inc + self._make_word('a', + x, + y + y_extra, + delay=delay, + scale=base_scale, + shadow=shadow) + x += spacing * 0.64 + delay += delay_inc + self._make_word('d', + x, + y + y_extra - 10, + delay=delay, + scale=1.1 * base_scale, + vr_depth_offset=6, + shadow=shadow) + self._make_logo(base_x - 28, + 125 + y + 1.2 * y_extra, + 0.32 * base_scale, + delay=base_delay) + + def _make_word(self, + word: str, + x: float, + y: float, + scale: float = 1.0, + delay: float = 0.0, + vr_depth_offset: float = 0.0, + shadow: bool = False) -> None: + if shadow: + word_obj = ba.Actor( + ba.newnode('text', + attrs={ + 'position': (x, y), + 'big': True, + 'color': (0.0, 0.0, 0.2, 0.08), + 'tilt_translate': 0.09, + 'opacity_scales_shadow': False, + 'shadow': 0.2, + 'vr_depth': -130, + 'v_align': 'center', + 'project_scale': 0.97 * scale, + 'scale': 1.0, + 'text': word + })) + self._word_actors.append(word_obj) + else: + word_obj = ba.Actor( + ba.newnode('text', + attrs={ + 'position': (x, y), + 'big': True, + 'color': (1.2, 1.15, 1.15, 1.0), + 'tilt_translate': 0.11, + 'shadow': 0.2, + 'vr_depth': -40 + vr_depth_offset, + 'v_align': 'center', + 'project_scale': scale, + 'scale': 1.0, + 'text': word + })) + self._word_actors.append(word_obj) + + # Add a bit of stop-motion-y jitter to the logo + # (unless we're in VR mode in which case its best to + # leave things still). + if not ba.app.vr_mode: + cmb: Optional[ba.Node] + cmb2: Optional[ba.Node] + if not shadow: + cmb = ba.newnode("combine", + owner=word_obj.node, + attrs={'size': 2}) + else: + cmb = None + if shadow: + cmb2 = ba.newnode("combine", + owner=word_obj.node, + attrs={'size': 2}) + else: + cmb2 = None + if not shadow: + assert cmb and word_obj.node + cmb.connectattr('output', word_obj.node, 'position') + if shadow: + assert cmb2 and word_obj.node + cmb2.connectattr('output', word_obj.node, 'position') + keys = {} + keys2 = {} + time_v = 0.0 + for _i in range(10): + val = x + (random.random() - 0.5) * 0.8 + val2 = x + (random.random() - 0.5) * 0.8 + keys[time_v * self._ts] = val + keys2[time_v * self._ts] = val2 + 5 + time_v += random.random() * 0.1 + if cmb is not None: + ba.animate(cmb, "input0", keys, loop=True) + if cmb2 is not None: + ba.animate(cmb2, "input0", keys2, loop=True) + keys = {} + keys2 = {} + time_v = 0 + for _i in range(10): + val = y + (random.random() - 0.5) * 0.8 + val2 = y + (random.random() - 0.5) * 0.8 + keys[time_v * self._ts] = val + keys2[time_v * self._ts] = val2 - 9 + time_v += random.random() * 0.1 + if cmb is not None: + ba.animate(cmb, "input1", keys, loop=True) + if cmb2 is not None: + ba.animate(cmb2, "input1", keys2, loop=True) + + if not shadow: + assert word_obj.node + ba.animate(word_obj.node, "project_scale", { + delay: 0.0, + delay + 0.1: scale * 1.1, + delay + 0.2: scale + }) + else: + assert word_obj.node + ba.animate(word_obj.node, "project_scale", { + delay: 0.0, + delay + 0.1: scale * 1.1, + delay + 0.2: scale + }) + + def _get_custom_logo_tex_name(self) -> Optional[str]: + if _ba.get_account_misc_read_val('easter', False): + return 'logoEaster' + return None + + # Pop the logo and menu in. + def _make_logo(self, + x: float, + y: float, + scale: float, + delay: float, + custom_texture: str = None, + jitter_scale: float = 1.0, + rotate: float = 0.0, + vr_depth_offset: float = 0.0) -> None: + + # Temp easter goodness. + if custom_texture is None: + custom_texture = self._get_custom_logo_tex_name() + self._custom_logo_tex_name = custom_texture + ltex = ba.gettexture( + custom_texture if custom_texture is not None else 'logo') + mopaque = (None if custom_texture is not None else ba.getmodel('logo')) + mtrans = (None if custom_texture is not None else + ba.getmodel('logoTransparent')) + logo = ba.Actor( + ba.newnode('image', + attrs={ + 'texture': ltex, + 'model_opaque': mopaque, + 'model_transparent': mtrans, + 'vr_depth': -10 + vr_depth_offset, + 'rotate': rotate, + 'attach': "center", + 'tilt_translate': 0.21, + 'absolute_scale': True + })) + self._logo_node = logo.node + self._word_actors.append(logo) + + # Add a bit of stop-motion-y jitter to the logo + # (unless we're in VR mode in which case its best to + # leave things still). + assert logo.node + if not ba.app.vr_mode: + cmb = ba.newnode("combine", owner=logo.node, attrs={'size': 2}) + cmb.connectattr('output', logo.node, 'position') + keys = {} + time_v = 0.0 + + # Gen some random keys for that stop-motion-y look + for _i in range(10): + keys[time_v] = x + (random.random() - 0.5) * 0.7 * jitter_scale + time_v += random.random() * 0.1 + ba.animate(cmb, "input0", keys, loop=True) + keys = {} + time_v = 0.0 + for _i in range(10): + keys[time_v * self._ts] = y + (random.random() - + 0.5) * 0.7 * jitter_scale + time_v += random.random() * 0.1 + ba.animate(cmb, "input1", keys, loop=True) + else: + logo.node.position = (x, y) + + cmb = ba.newnode("combine", owner=logo.node, attrs={"size": 2}) + + keys = { + delay: 0.0, + delay + 0.1: 700.0 * scale, + delay + 0.2: 600.0 * scale + } + ba.animate(cmb, "input0", keys) + ba.animate(cmb, "input1", keys) + cmb.connectattr("output", logo.node, "scale") + + def _start_preloads(self) -> None: + # FIXME: The func that calls us back doesn't save/restore state + # or check for a dead activity so we have to do that ourself. + if self.is_expired(): + return + with ba.Context(self): + _preload1() + + ba.timer(0.5, lambda: ba.setmusic('Menu')) + + +def _preload1() -> None: + """Pre-load some assets a second or two into the main menu. + + Helps avoid hitches later on. + """ + for mname in [ + 'plasticEyesTransparent', 'playerLineup1Transparent', + 'playerLineup2Transparent', 'playerLineup3Transparent', + 'playerLineup4Transparent', 'angryComputerTransparent', + 'scrollWidgetShort', 'windowBGBlotch' + ]: + ba.getmodel(mname) + for tname in ["playerLineup", "lock"]: + ba.gettexture(tname) + for tex in [ + 'iconRunaround', 'iconOnslaught', 'medalComplete', 'medalBronze', + 'medalSilver', 'medalGold', 'characterIconMask' + ]: + ba.gettexture(tex) + ba.gettexture("bg") + from bastd.actor import powerupbox + powerupbox.get_factory() + ba.timer(0.1, _preload2) + + +def _preload2() -> None: + # FIXME: Could integrate these loads with the classes that use them + # so they don't have to redundantly call the load + # (even if the actual result is cached). + for mname in ["powerup", "powerupSimple"]: + ba.getmodel(mname) + for tname in [ + "powerupBomb", "powerupSpeed", "powerupPunch", "powerupIceBombs", + "powerupStickyBombs", "powerupShield", "powerupImpactBombs", + "powerupHealth" + ]: + ba.gettexture(tname) + for sname in [ + "powerup01", "boxDrop", "boxingBell", "scoreHit01", "scoreHit02", + "dripity", "spawn", "gong" + ]: + ba.getsound(sname) + from bastd.actor import bomb + bomb.get_factory() + ba.timer(0.1, _preload3) + + +def _preload3() -> None: + for mname in ["bomb", "bombSticky", "impactBomb"]: + ba.getmodel(mname) + for tname in [ + "bombColor", "bombColorIce", "bombStickyColor", "impactBombColor", + "impactBombColorLit" + ]: + ba.gettexture(tname) + for sname in ["freeze", "fuse01", "activateBeep", "warnBeep"]: + ba.getsound(sname) + spaz.get_factory() + ba.timer(0.2, _preload4) + + +def _preload4() -> None: + for tname in ['bar', 'meter', 'null', 'flagColor', 'achievementOutline']: + ba.gettexture(tname) + for mname in ['frameInset', 'meterTransparent', 'achievementOutline']: + ba.getmodel(mname) + for sname in ['metalHit', 'metalSkid', 'refWhistle', 'achievement']: + ba.getsound(sname) + from bastd.actor.flag import get_factory + get_factory() + + +class MainMenuSession(ba.Session): + """Session that runs the main menu environment.""" + + def __init__(self) -> None: + + # Gather dependencies we'll need (just our activity). + self._activity_deps = ba.DepSet(ba.Dep(MainMenuActivity)) + + super().__init__([self._activity_deps]) + self._locked = False + self.set_activity(ba.new_activity(MainMenuActivity)) + + def on_activity_end(self, activity: ba.Activity, results: Any) -> None: + if self._locked: + _ba.unlock_all_input() + + # Any ending activity leads us into the main menu one. + self.set_activity(ba.new_activity(MainMenuActivity)) + + def on_player_request(self, player: ba.Player) -> bool: + # Reject all player requests. + return False diff --git a/assets/src/data/scripts/bastd/mapdata/__init__.py b/assets/src/data/scripts/bastd/mapdata/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assets/src/data/scripts/bastd/mapdata/big_g.py b/assets/src/data/scripts/bastd/mapdata/big_g.py new file mode 100644 index 00000000..cb23fee4 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/big_g.py @@ -0,0 +1,81 @@ +# This file was automatically generated from "big_g.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (-0.4011866709, 2.331310176, + -0.5426286416) + (0.0, 0.0, 0.0) + ( + 19.11746262, 10.19675564, 23.50119277) +points['ffa_spawn1'] = (3.140826121, 1.16512015, + 6.172121491) + (4.739204545, 1.0, 1.028864849) +points['ffa_spawn2'] = (5.416289073, 1.180022599, -0.1696495695) + ( + 2.945888237, 0.621599724, 0.4969830881) +points['ffa_spawn3'] = (-0.3692088357, 2.88984723, -6.909741615) + ( + 7.575371952, 0.621599724, 0.4969830881) +points['ffa_spawn4'] = (-2.391932409, 1.123690253, -3.417262271) + ( + 2.933065031, 0.621599724, 0.9796558695) +points['ffa_spawn5'] = (-7.46052038, 2.863807079, + 4.936420902) + (0.8707600789, 0.621599724, 2.233577195) +points['flag1'] = (7.557928387, 2.889342613, -7.208799596) +points['flag2'] = (7.696183956, 1.095466627, 6.103380446) +points['flag3'] = (-8.122819332, 2.844893069, 6.103380446) +points['flag4'] = (-8.018537918, 2.844893069, -6.202403896) +points['flag_default'] = (-7.563673017, 2.850652319, 0.08844978098) +boxes['map_bounds'] = (-0.1916036665, 8.764115729, 0.1971423239) + ( + 0.0, 0.0, 0.0) + (27.41996888, 18.47258973, 22.17335735) +points['powerup_spawn1'] = (7.830495287, 2.115087683, -0.05452287857) +points['powerup_spawn2'] = (-5.190293739, 1.476317443, -3.80237889) +points['powerup_spawn3'] = (-8.540957726, 3.762979519, -7.27710542) +points['powerup_spawn4'] = (7.374052727, 3.762979519, -3.091707631) +points['powerup_spawn5'] = (-8.691423338, 3.692026034, 6.627877455) +points['race_mine1'] = (-0.06161453294, 1.123140909, 4.966104324) +points['race_mine10'] = (-6.870248758, 2.851484105, 2.718992803) +points['race_mine2'] = (-0.06161453294, 1.123140909, 6.99632996) +points['race_mine3'] = (-0.7319278377, 1.123140909, -2.828583367) +points['race_mine4'] = (-3.286508423, 1.123140909, 0.8453899305) +points['race_mine5'] = (5.077545429, 2.850225463, -5.253575631) +points['race_mine6'] = (6.286453838, 2.850225463, -5.253575631) +points['race_mine7'] = (0.969120762, 2.851484105, -7.892038145) +points['race_mine8'] = (-2.976299166, 2.851484105, -6.241064664) +points['race_mine9'] = (-6.962812986, 2.851484105, -2.120262964) +points['race_point1'] = (2.280447713, 1.16512015, 6.015278429) + ( + 0.7066894139, 4.672784871, 1.322422256) +points['race_point10'] = (-4.196540687, 2.877461266, -7.106874334) + ( + 0.1057202515, 5.496127671, 1.028552836) +points['race_point11'] = (-7.634488499, 2.877461266, -3.61728743) + ( + 1.438144134, 5.157457566, 0.06318119808) +points['race_point12'] = (-7.541251512, 2.877461266, 3.290439202) + ( + 1.668578284, 5.52484043, 0.06318119808) +points['race_point2'] = (4.853459878, 1.16512015, + 6.035867283) + (0.3920628436, 4.577066678, 1.34568243) +points['race_point3'] = (6.905234402, 1.16512015, 1.143337503) + ( + 1.611663691, 3.515259775, 0.1135135003) +points['race_point4'] = (2.681673258, 1.16512015, 0.771967064) + ( + 0.6475414982, 3.602143342, 0.1135135003) +points['race_point5'] = (-0.3776550727, 1.225615225, 1.920343787) + ( + 0.1057202515, 4.245024435, 0.5914887576) +points['race_point6'] = (-4.365081958, 1.16512015, -0.3565529313) + ( + 1.627090525, 4.549428479, 0.1135135003) +points['race_point7'] = (0.4149308672, 1.16512015, -3.394316313) + ( + 0.1057202515, 4.945367833, 1.310190117) +points['race_point8'] = (4.27031635, 2.19747021, -3.335165617) + ( + 0.1057202515, 4.389664492, 1.20413595) +points['race_point9'] = (2.552998384, 2.877461266, -7.117366939) + ( + 0.1057202515, 5.512312989, 0.9986814472) +points['shadow_lower_bottom'] = (-0.2227795102, 0.2903873918, 2.680075641) +points['shadow_lower_top'] = (-0.2227795102, 0.8824975157, 2.680075641) +points['shadow_upper_bottom'] = (-0.2227795102, 6.305086402, 2.680075641) +points['shadow_upper_top'] = (-0.2227795102, 9.470923628, 2.680075641) +points['spawn1'] = (7.180043217, 2.85596295, -4.407134234) + (0.7629937742, + 1.0, 1.818908238) +points['spawn2'] = (5.880548999, 1.142163379, 6.171168951) + (1.817516622, 1.0, + 0.7724344394) +points['spawn_by_flag1'] = (7.180043217, 2.85596295, + -4.407134234) + (0.7629937742, 1.0, 1.818908238) +points['spawn_by_flag2'] = (5.880548999, 1.142163379, + 6.171168951) + (1.817516622, 1.0, 0.7724344394) +points['spawn_by_flag3'] = (-6.66642559, 3.554416948, + 5.820238985) + (1.097315815, 1.0, 1.285161684) +points['spawn_by_flag4'] = (-6.842951255, 3.554416948, + -6.17429905) + (0.8208434737, 1.0, 1.285161684) +points['tnt1'] = (-3.398312776, 2.067056737, -1.90142919) diff --git a/assets/src/data/scripts/bastd/mapdata/bridgit.py b/assets/src/data/scripts/bastd/mapdata/bridgit.py new file mode 100644 index 00000000..bc3b2935 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/bridgit.py @@ -0,0 +1,31 @@ +# This file was automatically generated from "bridgit.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (-0.2457963347, 3.828181068, + -1.528362695) + (0.0, 0.0, 0.0) + ( + 19.14849937, 7.312788846, 8.436232726) +points['ffa_spawn1'] = (-5.869295124, 3.715437928, + -1.617274877) + (0.9410329222, 1.0, 1.818908238) +points['ffa_spawn2'] = (5.160809653, 3.761793434, + -1.443012115) + (0.7729807005, 1.0, 1.818908238) +points['ffa_spawn3'] = (-0.4266381164, 3.761793434, + -1.555562653) + (4.034151421, 1.0, 0.2731725824) +points['flag1'] = (-7.354603923, 3.770769731, -1.617274877) +points['flag2'] = (6.885846926, 3.770685211, -1.443012115) +points['flag_default'] = (-0.2227795102, 3.802429326, -1.562586233) +boxes['map_bounds'] = (-0.1916036665, 7.481446847, -1.311948055) + ( + 0.0, 0.0, 0.0) + (27.41996888, 18.47258973, 19.52220249) +points['powerup_spawn1'] = (6.82849491, 4.658454461, 0.1938139802) +points['powerup_spawn2'] = (-7.253381358, 4.728692078, 0.252121017) +points['powerup_spawn3'] = (6.82849491, 4.658454461, -3.461765427) +points['powerup_spawn4'] = (-7.253381358, 4.728692078, -3.40345839) +points['shadow_lower_bottom'] = (-0.2227795102, 2.83188898, 2.680075641) +points['shadow_lower_top'] = (-0.2227795102, 3.498267184, 2.680075641) +points['shadow_upper_bottom'] = (-0.2227795102, 6.305086402, 2.680075641) +points['shadow_upper_top'] = (-0.2227795102, 9.470923628, 2.680075641) +points['spawn1'] = (-5.869295124, 3.715437928, + -1.617274877) + (0.9410329222, 1.0, 1.818908238) +points['spawn2'] = (5.160809653, 3.761793434, + -1.443012115) + (0.7729807005, 1.0, 1.818908238) diff --git a/assets/src/data/scripts/bastd/mapdata/courtyard.py b/assets/src/data/scripts/bastd/mapdata/courtyard.py new file mode 100644 index 00000000..6f6fd694 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/courtyard.py @@ -0,0 +1,67 @@ +# This file was automatically generated from "courtyard.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (0.3544110667, 3.958431362, + -2.175025358) + (0.0, 0.0, 0.0) + ( + 16.37702017, 7.755670126, 13.38680645) +points['bot_spawn_bottom'] = (-0.06281376545, 2.814769232, 1.95079953) +points['bot_spawn_bottom_half_left'] = (-2.05017213, 2.814769232, 1.95079953) +points['bot_spawn_bottom_half_right'] = (1.85515704, 2.814769232, 1.95079953) +points['bot_spawn_bottom_left'] = (-3.680966394, 2.814769232, 1.95079953) +points['bot_spawn_bottom_right'] = (3.586455826, 2.814769232, 1.95079953) +points['bot_spawn_left'] = (-6.447075231, 2.814769232, -2.317996277) +points['bot_spawn_left_lower'] = (-6.447075231, 2.814769232, -1.509957962) +points['bot_spawn_left_lower_more'] = (-6.447075231, 2.814769232, + -0.4832205112) +points['bot_spawn_left_upper'] = (-6.447075231, 2.814769232, -3.183562653) +points['bot_spawn_left_upper_more'] = (-6.447075231, 2.814769232, -4.010007449) +points['bot_spawn_right'] = (6.539735433, 2.814769232, -2.317996277) +points['bot_spawn_right_lower'] = (6.539735433, 2.814769232, -1.396042829) +points['bot_spawn_right_lower_more'] = (6.539735433, 2.814769232, + -0.3623501424) +points['bot_spawn_right_upper'] = (6.539735433, 2.814769232, -3.130071083) +points['bot_spawn_right_upper_more'] = (6.539735433, 2.814769232, -3.977427131) +points['bot_spawn_top'] = (-0.06281376545, 2.814769232, -5.833265855) +points['bot_spawn_top_half_left'] = (-1.494224867, 2.814769232, -5.833265855) +points['bot_spawn_top_half_right'] = (1.600833867, 2.814769232, -5.833265855) +points['bot_spawn_top_left'] = (-3.12023359, 2.814769232, -5.950680835) +points['bot_spawn_top_right'] = (3.396752931, 2.814769232, -5.950680835) +points['bot_spawn_turret_bottom_left'] = (-6.127144702, 3.3275475, 1.911189749) +points['bot_spawn_turret_bottom_right'] = (6.372913618, 3.3275475, 1.79864574) +points['bot_spawn_turret_top_left'] = (-6.127144702, 3.3275475, -6.572879116) +points['bot_spawn_turret_top_middle'] = (0.08149184008, 4.270281808, + -8.522292633) +points['bot_spawn_turret_top_middle_left'] = (-1.271380584, 4.270281808, + -8.522292633) +points['bot_spawn_turret_top_middle_right'] = (1.128462393, 4.270281808, + -8.522292633) +points['bot_spawn_turret_top_right'] = (6.372913618, 3.3275475, -6.603689486) +boxes['edge_box'] = (0.0, 1.036729365, -2.142494752) + (0.0, 0.0, 0.0) + ( + 12.01667356, 11.40580437, 7.808185564) +points['ffa_spawn1'] = (-6.228613999, 3.765660284, + -5.15969075) + (1.480100328, 1.0, 0.07121651432) +points['ffa_spawn2'] = (6.286481065, 3.765660284, + -4.923207718) + (1.419728931, 1.0, 0.07121651432) +points['ffa_spawn3'] = (-0.01917923364, 4.39873514, + -6.964732605) + (1.505953039, 1.0, 0.2494784408) +points['ffa_spawn4'] = (-0.01917923364, 3.792688047, + 3.453884398) + (4.987737689, 1.0, 0.1505089956) +points['flag1'] = (-5.965661853, 2.820013813, -2.428844806) +points['flag2'] = (5.905546426, 2.800475393, -2.218272564) +points['flag_default'] = (0.2516184246, 2.784213993, -2.644195211) +boxes['map_bounds'] = (0.2608783669, 4.899663734, -3.543675157) + ( + 0.0, 0.0, 0.0) + (29.23565494, 14.19991443, 29.92689344) +points['powerup_spawn1'] = (-3.555558641, 3.168458621, 0.3692836925) +points['powerup_spawn2'] = (3.625691848, 3.168458621, 0.4058534671) +points['powerup_spawn3'] = (3.625691848, 3.168458621, -4.987242873) +points['powerup_spawn4'] = (-3.555558641, 3.168458621, -5.023812647) +points['shadow_lower_bottom'] = (0.5236258282, 0.02085132358, 5.341226521) +points['shadow_lower_top'] = (0.5236258282, 1.206119006, 5.341226521) +points['shadow_upper_bottom'] = (0.5236258282, 6.359015684, 5.341226521) +points['shadow_upper_top'] = (0.5236258282, 10.12385584, 5.341226521) +points['spawn1'] = (-7.514831403, 3.803639368, + -2.102145502) + (0.0878727285, 1.0, 2.195980213) +points['spawn2'] = (7.462102032, 3.772786511, + -1.835207267) + (0.0288041898, 1.0, 2.221665995) diff --git a/assets/src/data/scripts/bastd/mapdata/crag_castle.py b/assets/src/data/scripts/bastd/mapdata/crag_castle.py new file mode 100644 index 00000000..9f51867f --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/crag_castle.py @@ -0,0 +1,40 @@ +# This file was automatically generated from "crag_castle.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (0.7033834902, 6.55869393, -3.153439808) + ( + 0.0, 0.0, 0.0) + (16.73648528, 14.94789935, 11.60063102) +points['ffa_spawn1'] = (-4.04166076, 7.54589296, -3.542792409) + ( + 2.471508516, 1.156019141, 0.1791707664) +points['ffa_spawn2'] = (5.429881832, 7.582951102, + -3.497145747) + (2.415753564, 1.12871694, 0.17898173) +points['ffa_spawn3'] = (4.8635999, 9.311949436, + -6.013939259) + (1.61785329, 1.12871694, 0.17898173) +points['ffa_spawn4'] = (-3.628023052, 9.311949436, + -6.013939259) + (1.61785329, 1.12871694, 0.17898173) +points['ffa_spawn5'] = (-2.414363536, 5.930994442, + 0.03036413701) + (1.61785329, 1.12871694, 0.17898173) +points['ffa_spawn6'] = (3.520989196, 5.930994442, + 0.03036413701) + (1.61785329, 1.12871694, 0.17898173) +points['flag1'] = (-1.900164924, 9.363050076, -6.441041548) +points['flag2'] = (3.240019982, 9.319215955, -6.392759924) +points['flag3'] = (-6.883672142, 7.475761129, 0.2098388241) +points['flag4'] = (8.193957063, 7.478129652, 0.1536410508) +points['flag_default'] = (0.6296142785, 6.221901832, -0.0435909658) +boxes['map_bounds'] = (0.4799042306, 9.085075529, -3.267604531) + ( + 0.0, 0.0, 0.0) + (22.9573075, 9.908550511, 14.17997333) +points['powerup_spawn1'] = (7.916483636, 7.83853949, -5.990841203) +points['powerup_spawn2'] = (-0.6978591232, 7.883836528, -6.066674247) +points['powerup_spawn3'] = (1.858093733, 7.893059862, -6.076932659) +points['powerup_spawn4'] = (-6.671997388, 7.992307645, -6.121432603) +points['spawn1'] = (-5.169730601, 7.54589296, + -3.542792409) + (1.057384557, 1.156019141, 0.1791707664) +points['spawn2'] = (6.203092708, 7.582951102, + -3.497145747) + (1.009865407, 1.12871694, 0.17898173) +points['spawn_by_flag1'] = (-2.872146219, 9.363050076, -6.041110823) +points['spawn_by_flag2'] = (4.313355684, 9.363050076, -6.041110823) +points['spawn_by_flag3'] = (-6.634074097, 7.508585058, -0.5918910315) +points['spawn_by_flag4'] = (7.868759529, 7.508585058, -0.5918910315) +points['tnt1'] = (-5.038090498, 10.0136642, -6.158580823) +points['tnt2'] = (6.203368846, 10.0136642, -6.158580823) diff --git a/assets/src/data/scripts/bastd/mapdata/doom_shroom.py b/assets/src/data/scripts/bastd/mapdata/doom_shroom.py new file mode 100644 index 00000000..a0812149 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/doom_shroom.py @@ -0,0 +1,33 @@ +# This file was automatically generated from "doom_shroom.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (0.4687647786, 2.320345088, + -3.219423694) + (0.0, 0.0, 0.0) + ( + 21.34898078, 10.25529817, 14.67298352) +points['ffa_spawn1'] = (-5.828122667, 2.301094498, + -3.445694701) + (1.0, 1.0, 2.682935578) +points['ffa_spawn2'] = (6.496252674, 2.397778847, -3.573241388) + (1.0, 1.0, + 2.682935578) +points['ffa_spawn3'] = (0.8835145921, 2.307217208, + -0.3552854962) + (4.455517747, 1.0, 0.2723037175) +points['ffa_spawn4'] = (0.8835145921, 2.307217208, + -7.124335491) + (4.455517747, 1.0, 0.2723037175) +points['flag1'] = (-7.153737138, 2.251993091, -3.427368878) +points['flag2'] = (8.103769491, 2.320591215, -3.548878069) +points['flag_default'] = (0.5964565429, 2.373456481, -4.241969517) +boxes['map_bounds'] = (0.4566560559, 1.332051421, -3.80651373) + ( + 0.0, 0.0, 0.0) + (27.75073129, 14.44528216, 22.9896617) +points['powerup_spawn1'] = (5.180858712, 4.278900266, -7.282758712) +points['powerup_spawn2'] = (-3.236908759, 4.159702067, -0.3232556512) +points['powerup_spawn3'] = (5.082843398, 4.159702067, -0.3232556512) +points['powerup_spawn4'] = (-3.401729203, 4.278900266, -7.425891191) +points['shadow_lower_bottom'] = (0.5964565429, -0.2279530265, 3.368035253) +points['shadow_lower_top'] = (0.5964565429, 0.6982784189, 3.368035253) +points['shadow_upper_bottom'] = (0.5964565429, 5.413250948, 3.368035253) +points['shadow_upper_top'] = (0.5964565429, 7.891484473, 3.368035253) +points['spawn1'] = (-5.828122667, 2.301094498, -3.445694701) + (1.0, 1.0, + 2.682935578) +points['spawn2'] = (6.496252674, 2.397778847, -3.573241388) + (1.0, 1.0, + 2.682935578) diff --git a/assets/src/data/scripts/bastd/mapdata/football_stadium.py b/assets/src/data/scripts/bastd/mapdata/football_stadium.py new file mode 100644 index 00000000..73f97a2b --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/football_stadium.py @@ -0,0 +1,29 @@ +# This file was automatically generated from "football_stadium.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (0.0, 1.185751251, 0.4326226188) + ( + 0.0, 0.0, 0.0) + (29.8180273, 11.57249038, 18.89134176) +boxes['edge_box'] = (-0.103873591, 0.4133341891, 0.4294651013) + ( + 0.0, 0.0, 0.0) + (22.48295719, 1.290242794, 8.990252454) +points['ffa_spawn1'] = (-0.08015551329, 0.02275111462, + -4.373674593) + (8.895057015, 1.0, 0.444350722) +points['ffa_spawn2'] = (-0.08015551329, 0.02275111462, + 4.076288941) + (8.895057015, 1.0, 0.444350722) +points['flag1'] = (-10.99027878, 0.05744967453, 0.1095578275) +points['flag2'] = (11.01486398, 0.03986567039, 0.1095578275) +points['flag_default'] = (-0.1001374046, 0.04180340146, 0.1095578275) +boxes['goal1'] = (12.22454533, 1.0, + 0.1087926362) + (0.0, 0.0, 0.0) + (2.0, 2.0, 12.97466313) +boxes['goal2'] = (-12.15961605, 1.0, + 0.1097860203) + (0.0, 0.0, 0.0) + (2.0, 2.0, 13.11856424) +boxes['map_bounds'] = (0.0, 1.185751251, 0.4326226188) + (0.0, 0.0, 0.0) + ( + 42.09506485, 22.81173179, 29.76723155) +points['powerup_spawn1'] = (5.414681236, 0.9515026107, -5.037912441) +points['powerup_spawn2'] = (-5.555402285, 0.9515026107, -5.037912441) +points['powerup_spawn3'] = (5.414681236, 0.9515026107, 5.148223181) +points['powerup_spawn4'] = (-5.737266365, 0.9515026107, 5.148223181) +points['spawn1'] = (-10.03866341, 0.02275111462, 0.0) + (0.5, 1.0, 4.0) +points['spawn2'] = (9.823107149, 0.01092306765, 0.0) + (0.5, 1.0, 4.0) +points['tnt1'] = (-0.08421587483, 0.9515026107, -0.7762602271) diff --git a/assets/src/data/scripts/bastd/mapdata/happy_thoughts.py b/assets/src/data/scripts/bastd/mapdata/happy_thoughts.py new file mode 100644 index 00000000..85099d14 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/happy_thoughts.py @@ -0,0 +1,43 @@ +# This file was automatically generated from "happy_thoughts.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (-1.045859963, 12.67722855, + -5.401537075) + (0.0, 0.0, 0.0) + ( + 34.46156851, 20.94044653, 0.6931564611) +points['ffa_spawn1'] = (-9.295167711, 8.010664315, + -5.44451005) + (1.555840357, 1.453808816, 0.1165648888) +points['ffa_spawn2'] = (7.484707127, 8.172681752, -5.614479365) + ( + 1.553861796, 1.453808816, 0.04419853907) +points['ffa_spawn3'] = (9.55724115, 11.30789446, -5.614479365) + ( + 1.337925849, 1.453808816, 0.04419853907) +points['ffa_spawn4'] = (-11.55747023, 10.99170684, -5.614479365) + ( + 1.337925849, 1.453808816, 0.04419853907) +points['ffa_spawn5'] = (-1.878892369, 9.46490571, -5.614479365) + ( + 1.337925849, 1.453808816, 0.04419853907) +points['ffa_spawn6'] = (-0.4912812943, 5.077006397, -5.521672101) + ( + 1.878332089, 1.453808816, 0.007578097856) +points['flag1'] = (-11.75152479, 8.057427485, -5.52) +points['flag2'] = (9.840909039, 8.188634282, -5.52) +points['flag3'] = (-0.2195258696, 5.010273907, -5.52) +points['flag4'] = (-0.04605809154, 12.73369108, -5.52) +points['flag_default'] = (-0.04201942896, 12.72374492, -5.52) +boxes['map_bounds'] = (-0.8748348681, 9.212941713, -5.729538885) + ( + 0.0, 0.0, 0.0) + (36.09666006, 26.19950145, 7.89541168) +points['powerup_spawn1'] = (1.160232442, 6.745963662, -5.469115985) +points['powerup_spawn2'] = (-1.899700206, 10.56447241, -5.505721177) +points['powerup_spawn3'] = (10.56098871, 12.25165669, -5.576232453) +points['powerup_spawn4'] = (-12.33530337, 12.25165669, -5.576232453) +points['spawn1'] = (-9.295167711, 8.010664315, + -5.44451005) + (1.555840357, 1.453808816, 0.1165648888) +points['spawn2'] = (7.484707127, 8.172681752, + -5.614479365) + (1.553861796, 1.453808816, 0.04419853907) +points['spawn_by_flag1'] = (-9.295167711, 8.010664315, -5.44451005) + ( + 1.555840357, 1.453808816, 0.1165648888) +points['spawn_by_flag2'] = (7.484707127, 8.172681752, -5.614479365) + ( + 1.553861796, 1.453808816, 0.04419853907) +points['spawn_by_flag3'] = (-1.45994593, 5.038762459, -5.535288724) + ( + 0.9516389866, 0.6666414677, 0.08607244075) +points['spawn_by_flag4'] = (0.4932087091, 12.74493212, -5.598987003) + ( + 0.5245740665, 0.5245740665, 0.01941146064) diff --git a/assets/src/data/scripts/bastd/mapdata/hockey_stadium.py b/assets/src/data/scripts/bastd/mapdata/hockey_stadium.py new file mode 100644 index 00000000..9279b006 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/hockey_stadium.py @@ -0,0 +1,25 @@ +# This file was automatically generated from "hockey_stadium.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (0.0, 0.7956858119, 0.0) + ( + 0.0, 0.0, 0.0) + (30.80223883, 0.5961646365, 13.88431707) +points['ffa_spawn1'] = (-0.001925625146, 0.02305323209, + -3.81971842) + (7.828121539, 1.0, 0.1588021252) +points['ffa_spawn2'] = (-0.001925625146, 0.02305323209, + 3.560115735) + (7.828121539, 1.0, 0.05859841271) +points['flag1'] = (-11.21689747, 0.09527878981, -0.07659307272) +points['flag2'] = (11.08204909, 0.04119542459, -0.07659307272) +points['flag_default'] = (-0.01690735171, 0.06139940044, -0.07659307272) +boxes['goal1'] = (8.45, 1.0, 0.0) + (0.0, 0.0, 0.0) + (0.4334079123, 1.6, 3.0) +boxes['goal2'] = (-8.45, 1.0, 0.0) + (0.0, 0.0, 0.0) + (0.4334079123, 1.6, 3.0) +boxes['map_bounds'] = (0.0, 0.7956858119, -0.4689020853) + (0.0, 0.0, 0.0) + ( + 35.16182389, 12.18696164, 21.52869693) +points['powerup_spawn1'] = (-3.654355317, 1.080990833, -4.765886164) +points['powerup_spawn2'] = (-3.654355317, 1.080990833, 4.599802158) +points['powerup_spawn3'] = (2.881071011, 1.080990833, -4.765886164) +points['powerup_spawn4'] = (2.881071011, 1.080990833, 4.599802158) +points['spawn1'] = (-6.835352227, 0.02305323209, 0.0) + (1.0, 1.0, 3.0) +points['spawn2'] = (6.857415055, 0.03938567998, 0.0) + (1.0, 1.0, 3.0) +points['tnt1'] = (-0.05791962398, 1.080990833, -4.765886164) diff --git a/assets/src/data/scripts/bastd/mapdata/lake_frigid.py b/assets/src/data/scripts/bastd/mapdata/lake_frigid.py new file mode 100644 index 00000000..66a16521 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/lake_frigid.py @@ -0,0 +1,74 @@ +# This file was automatically generated from "lake_frigid.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (0.622753268, 3.958431362, -2.48708008) + ( + 0.0, 0.0, 0.0) + (20.62310543, 7.755670126, 12.33155049) +points['ffa_spawn1'] = (-5.782222813, 2.601256429, + -2.116763055) + (0.4872664751, 1.0, 2.99296869) +points['ffa_spawn2'] = (8.331810793, 2.563661107, + -2.362712466) + (0.4929678379, 1.0, 2.590481339) +points['ffa_spawn3'] = (-0.01917923364, 2.623757527, + -6.518902459) + (4.450854686, 1.0, 0.2494784408) +points['ffa_spawn4'] = (-0.01917923364, 2.620884201, + 2.154362669) + (4.987737689, 1.0, 0.1505089956) +points['flag1'] = (-5.965661853, 2.60868975, -2.428844806) +points['flag2'] = (7.469054879, 2.600634569, -2.218272564) +points['flag_default'] = (0.5814687904, 2.593249132, -6.083520531) +boxes['map_bounds'] = (0.6679698457, 6.090222998, -2.478650859) + ( + 0.0, 0.0, 0.0) + (26.78420476, 12.49722958, 19.09355242) +points['powerup_spawn1'] = (-3.178773331, 3.168458621, 1.526824762) +points['powerup_spawn2'] = (3.625691848, 3.168458621, 1.563394537) +points['powerup_spawn3'] = (3.625691848, 3.168458621, -5.768903171) +points['powerup_spawn4'] = (-3.178773331, 3.168458621, -5.805472946) +points['race_mine1'] = (-5.299824547, 2.523837916, 1.955977372) +points['race_mine10'] = (-0.713193354, 2.523837916, 3.114668201) +points['race_mine11'] = (9.390106796, 2.523837916, -1.647082264) +points['race_mine12'] = (5.745749508, 2.523837916, -2.297908403) +points['race_mine13'] = (6.214639992, 2.523837916, -0.8145917891) +points['race_mine14'] = (5.376973913, 2.523837916, -4.165899043) +points['race_mine15'] = (1.502206718, 2.523837916, -5.822493321) +points['race_mine16'] = (-1.686183167, 2.523837916, -5.237149734) +points['race_mine17'] = (-3.868444888, 2.523837916, -4.147761517) +points['race_mine18'] = (-7.414697421, 2.523837916, -1.500814191) +points['race_mine19'] = (-2.188054758, 2.523837916, 1.864328906) +points['race_mine2'] = (-5.29052862, 2.523837916, -5.866528803) +points['race_mine20'] = (8.027819188, 2.523837916, -0.009084902544) +points['race_mine21'] = (7.380356464, 2.523837916, -5.780049322) +points['race_mine22'] = (-4.568354273, 2.523837916, -5.03102897) +points['race_mine23'] = (-5.874455776, 2.523837916, -0.2827536691) +points['race_mine24'] = (2.786967886, 2.523837916, -7.903057897) +points['race_mine25'] = (5.824160391, 2.523837916, -6.591849727) +points['race_mine26'] = (-3.971925479, 2.523837916, -0.04187571107) +points['race_mine3'] = (6.491590735, 2.523837916, 1.526824762) +points['race_mine4'] = (6.777341469, 2.523837916, -4.811127322) +points['race_mine5'] = (1.530288599, 2.523837916, -7.238282813) +points['race_mine6'] = (-1.547487434, 2.523837916, -6.391185182) +points['race_mine7'] = (-4.356878305, 2.523837916, -2.04510117) +points['race_mine8'] = (-0.713193354, 2.523837916, -0.1340958729) +points['race_mine9'] = (-0.713193354, 2.523837916, 1.275675237) +points['race_point1'] = (0.5901776337, 2.544287937, 1.543598704) + ( + 0.2824957007, 3.950514538, 2.292534365) +points['race_point2'] = (4.7526567, 2.489758467, + 1.09551316) + (0.2824957007, 3.950514538, 2.392880724) +points['race_point3'] = (7.450800117, 2.601570758, -2.248040576) + ( + 2.167067932, 3.950514538, 0.2574992262) +points['race_point4'] = (5.064768438, 2.489758467, -5.820463576) + ( + 0.2824957007, 3.950514538, 2.392880724) +points['race_point5'] = (0.5901776337, 2.67667329, -6.165424036) + ( + 0.2824957007, 3.950514538, 2.156382533) +points['race_point6'] = (-3.057459058, 2.489758467, -6.114179652) + ( + 0.2824957007, 3.950514538, 2.323773344) +points['race_point7'] = (-5.814316926, 2.57969886, + -2.248040576) + (2.0364457, 3.950514538, 0.2574992262) +points['race_point8'] = (-2.958397223, 2.489758467, 1.360005754) + ( + 0.2824957007, 3.950514538, 2.529692681) +points['shadow_lower_bottom'] = (0.5236258282, 1.516338013, 5.341226521) +points['shadow_lower_top'] = (0.5236258282, 2.516776651, 5.341226521) +points['shadow_upper_bottom'] = (0.5236258282, 4.543246769, 5.341226521) +points['shadow_upper_top'] = (0.5236258282, 5.917963067, 5.341226521) +points['spawn1'] = (-5.945079307, 2.524666031, + -2.102145502) + (0.0878727285, 1.0, 2.195980213) +points['spawn2'] = (8.079733391, 2.506883995, + -2.364598145) + (0.0288041898, 1.0, 2.221665995) diff --git a/assets/src/data/scripts/bastd/mapdata/monkey_face.py b/assets/src/data/scripts/bastd/mapdata/monkey_face.py new file mode 100644 index 00000000..9c4031bb --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/monkey_face.py @@ -0,0 +1,33 @@ +# This file was automatically generated from "monkey_face.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (-1.657177611, 4.132574186, + -1.580485661) + (0.0, 0.0, 0.0) + ( + 17.36258946, 10.49020453, 12.31460338) +points['ffa_spawn1'] = (-8.026373566, 3.349937889, -2.542088202) + ( + 0.9450583628, 0.9450583628, 1.181509268) +points['ffa_spawn2'] = (4.73470012, 3.308679998, + -2.757871588) + (0.9335931003, 1.0, 1.217352295) +points['ffa_spawn3'] = (-1.907161509, 3.326830784, + -6.572223028) + (4.080767643, 1.0, 0.2880331593) +points['ffa_spawn4'] = (-1.672823345, 3.326830784, + 2.405442985) + (3.870724402, 1.0, 0.2880331593) +points['flag1'] = (-8.968414135, 3.35709348, -2.804123917) +points['flag2'] = (5.945128279, 3.354825248, -2.663635497) +points['flag_default'] = (-1.688166134, 3.392387172, -2.238613943) +boxes['map_bounds'] = (-1.615296127, 6.825502312, -2.200965435) + ( + 0.0, 0.0, 0.0) + (22.51905077, 12.21074608, 15.9079565) +points['powerup_spawn1'] = (-6.859406739, 4.429165244, -6.588618549) +points['powerup_spawn2'] = (-5.422572086, 4.228850685, 2.803988636) +points['powerup_spawn3'] = (3.148493267, 4.429165244, -6.588618549) +points['powerup_spawn4'] = (1.830377363, 4.228850685, 2.803988636) +points['shadow_lower_bottom'] = (-1.877364768, 0.9878677276, 5.50201662) +points['shadow_lower_top'] = (-1.877364768, 2.881511768, 5.50201662) +points['shadow_upper_bottom'] = (-1.877364768, 6.169020542, 5.50201662) +points['shadow_upper_top'] = (-1.877364768, 10.2492777, 5.50201662) +points['spawn1'] = (-8.026373566, 3.349937889, + -2.542088202) + (0.9450583628, 0.9450583628, 1.181509268) +points['spawn2'] = (4.73470012, 3.308679998, -2.757871588) + (0.9335931003, + 1.0, 1.217352295) diff --git a/assets/src/data/scripts/bastd/mapdata/rampage.py b/assets/src/data/scripts/bastd/mapdata/rampage.py new file mode 100644 index 00000000..da8730cf --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/rampage.py @@ -0,0 +1,29 @@ +# This file was automatically generated from "rampage.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (0.3544110667, 5.616383286, + -4.066055072) + (0.0, 0.0, 0.0) + ( + 19.90053969, 10.34051135, 8.16221072) +boxes['edge_box'] = (0.3544110667, 5.438284793, -4.100357672) + ( + 0.0, 0.0, 0.0) + (12.57718032, 4.645176013, 3.605557343) +points['ffa_spawn1'] = (0.5006944438, 5.051501304, + -5.79356326) + (6.626174027, 1.0, 0.3402012662) +points['ffa_spawn2'] = (0.5006944438, 5.051501304, + -2.435321368) + (6.626174027, 1.0, 0.3402012662) +points['flag1'] = (-5.885814199, 5.112162255, -4.251754911) +points['flag2'] = (6.700855451, 5.10270501, -4.259912982) +points['flag_default'] = (0.3196701116, 5.110914413, -4.292515158) +boxes['map_bounds'] = (0.4528955042, 4.899663734, -3.543675157) + ( + 0.0, 0.0, 0.0) + (23.54502348, 14.19991443, 12.08017448) +points['powerup_spawn1'] = (-2.645358507, 6.426340583, -4.226597191) +points['powerup_spawn2'] = (3.540102796, 6.549722855, -4.198476335) +points['shadow_lower_bottom'] = (5.580073911, 3.136491026, 5.341226521) +points['shadow_lower_top'] = (5.580073911, 4.321758709, 5.341226521) +points['shadow_upper_bottom'] = (5.274539479, 8.425373402, 5.341226521) +points['shadow_upper_top'] = (5.274539479, 11.93458162, 5.341226521) +points['spawn1'] = (-4.745706238, 5.051501304, + -4.247934288) + (0.9186962739, 1.0, 0.5153189341) +points['spawn2'] = (5.838590388, 5.051501304, + -4.259627405) + (0.9186962739, 1.0, 0.5153189341) diff --git a/assets/src/data/scripts/bastd/mapdata/roundabout.py b/assets/src/data/scripts/bastd/mapdata/roundabout.py new file mode 100644 index 00000000..0ce30a54 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/roundabout.py @@ -0,0 +1,29 @@ +# This file was automatically generated from "roundabout.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (-1.552280404, 3.189001207, -2.40908495) + ( + 0.0, 0.0, 0.0) + (11.96255385, 8.857531648, 9.531689995) +points['ffa_spawn1'] = (-4.056288044, 3.85970651, + -4.6096757) + (0.9393824595, 1.0, 1.422669346) +points['ffa_spawn2'] = (0.9091263403, 3.849381394, + -4.673201431) + (0.9179219809, 1.0, 1.422669346) +points['ffa_spawn3'] = (-1.50312174, 1.498336991, + -0.7271163774) + (5.733928927, 1.0, 0.1877531607) +points['flag1'] = (-3.01567985, 3.846779683, -6.702828912) +points['flag2'] = (-0.01282460768, 3.828492613, -6.684991743) +points['flag_default'] = (-1.509110449, 1.447854976, -1.440324146) +boxes['map_bounds'] = (-1.615296127, 8.764115729, -2.663738363) + ( + 0.0, 0.0, 0.0) + (20.48886392, 18.92340529, 13.79786814) +points['powerup_spawn1'] = (-6.794510156, 2.660340814, 0.01205780317) +points['powerup_spawn2'] = (3.611953494, 2.660340814, 0.01205780317) +points['shadow_lower_bottom'] = (-1.848173322, 0.6339980822, 2.267036343) +points['shadow_lower_top'] = (-1.848173322, 1.077175164, 2.267036343) +points['shadow_upper_bottom'] = (-1.848173322, 6.04794944, 2.267036343) +points['shadow_upper_top'] = (-1.848173322, 9.186681264, 2.267036343) +points['spawn1'] = (-4.056288044, 3.85970651, -4.6096757) + (0.9393824595, 1.0, + 1.422669346) +points['spawn2'] = (0.9091263403, 3.849381394, + -4.673201431) + (0.9179219809, 1.0, 1.422669346) +points['tnt1'] = (-1.509110449, 2.457517361, 0.2340271555) diff --git a/assets/src/data/scripts/bastd/mapdata/step_right_up.py b/assets/src/data/scripts/bastd/mapdata/step_right_up.py new file mode 100644 index 00000000..0b442416 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/step_right_up.py @@ -0,0 +1,45 @@ +# This file was automatically generated from "step_right_up.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (0.3544110667, 6.07676405, -2.271833016) + ( + 0.0, 0.0, 0.0) + (22.55121262, 10.14644532, 14.66087273) +points['ffa_spawn1'] = (-6.989214197, 5.824902099, + -4.003708602) + (0.4063164993, 1.0, 3.6294637) +points['ffa_spawn2'] = (7.305179278, 5.866583139, + -4.003708602) + (0.4063164993, 1.0, 3.6294637) +points['ffa_spawn3'] = (2.641427041, 4.793721175, + -4.003708602) + (0.4063164993, 1.0, 3.6294637) +points['ffa_spawn4'] = (-2.36228023, 4.793721175, + -4.003708602) + (0.4063164993, 1.0, 3.6294637) +points['flag1'] = (-6.005199892, 5.824953504, -8.182477108) +points['flag2'] = (6.671556926, 5.819873617, -0.3196373496) +points['flag3'] = (-2.105198862, 4.785722143, -3.938339596) +points['flag4'] = (2.693244393, 4.785722143, -3.938339596) +points['flag_default'] = (0.2516184246, 4.163099318, -3.691279318) +boxes['map_bounds'] = (0.2608783669, 4.899663734, -3.543675157) + ( + 0.0, 0.0, 0.0) + (29.23565494, 14.19991443, 29.92689344) +points['powerup_spawn1'] = (-5.250579743, 4.725015518, 2.81876755) +points['powerup_spawn2'] = (5.694682017, 4.725015518, 2.81876755) +points['powerup_spawn3'] = (7.897510583, 6.314188898, -0.7152878923) +points['powerup_spawn4'] = (-7.218887205, 6.310285546, -7.944219924) +points['powerup_spawn5'] = (-1.826019337, 5.246212934, -7.961997906) +points['powerup_spawn6'] = (2.527964103, 5.246212934, -0.412538132) +points['shadow_lower_bottom'] = (0.5236258282, 2.599698775, 5.341226521) +points['shadow_lower_top'] = (0.5236258282, 3.784966458, 5.341226521) +points['shadow_upper_bottom'] = (0.5236258282, 7.323868662, 5.341226521) +points['shadow_upper_top'] = (0.5236258282, 11.08870881, 5.341226521) +points['spawn1'] = (-4.265431979, 5.461124528, + -4.003708602) + (0.4063164993, 1.0, 2.195980213) +points['spawn2'] = (5.073366552, 5.444726373, + -4.063095718) + (0.3465292314, 1.0, 2.221665995) +points['spawn_by_flag1'] = (-6.663464996, 5.978624661, + -6.165495294) + (0.7518730647, 1.0, 0.8453811633) +points['spawn_by_flag2'] = (7.389476227, 5.978624661, + -1.709266011) + (0.7518730647, 1.0, 0.8453811633) +points['spawn_by_flag3'] = (-2.112456453, 4.802744618, + -3.947702091) + (0.7518730647, 1.0, 0.8453811633) +points['spawn_by_flag4'] = (2.701146355, 4.802744618, + -3.947702091) + (0.7518730647, 1.0, 0.8453811633) +points['tnt1'] = (0.258764453, 4.834253071, -4.306874943) diff --git a/assets/src/data/scripts/bastd/mapdata/the_pad.py b/assets/src/data/scripts/bastd/mapdata/the_pad.py new file mode 100644 index 00000000..350dba7a --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/the_pad.py @@ -0,0 +1,34 @@ +# This file was automatically generated from "the_pad.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (0.3544110667, 4.493562578, + -2.518391331) + (0.0, 0.0, 0.0) + ( + 16.64754831, 8.06138989, 18.5029888) +points['ffa_spawn1'] = (-3.812275836, 4.380655495, + -8.962074979) + (2.371946621, 1.0, 0.8737798622) +points['ffa_spawn2'] = (4.472503025, 4.406820459, + -9.007239732) + (2.708525168, 1.0, 0.8737798622) +points['ffa_spawn3'] = (6.972673935, 4.380775486, + -7.424407061) + (0.4850648533, 1.0, 1.597018665) +points['ffa_spawn4'] = (-6.36978974, 4.380775486, + -7.424407061) + (0.4850648533, 1.0, 1.597018665) +points['flag1'] = (-7.026110145, 4.308759233, -6.302807727) +points['flag2'] = (7.632557137, 4.366002373, -6.287969342) +points['flag_default'] = (0.4611826686, 4.382076338, 3.680881802) +boxes['map_bounds'] = (0.2608783669, 4.899663734, -3.543675157) + ( + 0.0, 0.0, 0.0) + (29.23565494, 14.19991443, 29.92689344) +points['powerup_spawn1'] = (-4.166594349, 5.281834349, -6.427493781) +points['powerup_spawn2'] = (4.426873526, 5.342460464, -6.329745237) +points['powerup_spawn3'] = (-4.201686731, 5.123385835, 0.4400721376) +points['powerup_spawn4'] = (4.758924722, 5.123385835, 0.3494054559) +points['shadow_lower_bottom'] = (-0.2912522507, 2.020798381, 5.341226521) +points['shadow_lower_top'] = (-0.2912522507, 3.206066063, 5.341226521) +points['shadow_upper_bottom'] = (-0.2912522507, 6.062361813, 5.341226521) +points['shadow_upper_top'] = (-0.2912522507, 9.827201965, 5.341226521) +points['spawn1'] = (-3.902942148, 4.380655495, + -8.962074979) + (1.66339533, 1.0, 0.8737798622) +points['spawn2'] = (4.775040345, 4.406820459, -9.007239732) + (1.66339533, 1.0, + 0.8737798622) +points['tnt1'] = (0.4599593402, 4.044276501, -6.573537395) diff --git a/assets/src/data/scripts/bastd/mapdata/tip_top.py b/assets/src/data/scripts/bastd/mapdata/tip_top.py new file mode 100644 index 00000000..20c0c6d2 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/tip_top.py @@ -0,0 +1,39 @@ +# This file was automatically generated from "tip_top.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (0.004375512593, 7.141135803, + -0.01745294675) + (0.0, 0.0, 0.0) + ( + 21.12506141, 4.959977313, 16.6885592) +points['ffa_spawn1'] = (-4.211611443, 6.96684623, -3.792009469) + ( + 0.3862608373, 1.155037873, 0.301362335) +points['ffa_spawn2'] = (7.384331793, 5.212769921, + -2.788130319) + (1.155037873, 1.155037873, 1.155037873) +points['ffa_spawn3'] = (-7.264053816, 5.461241477, + -3.095884089) + (1.155037873, 1.155037873, 1.155037873) +points['ffa_spawn4'] = (0.02413253541, 5.367227206, + 4.075190968) + (1.875050182, 1.155037873, 0.2019443553) +points['ffa_spawn5'] = (-1.571185756, 7.042332385, -0.4760548825) + ( + 0.3862608373, 1.155037873, 0.301362335) +points['ffa_spawn6'] = (1.693597207, 7.042332385, -0.4760548825) + ( + 0.3862608373, 1.155037873, 0.301362335) +points['ffa_spawn7'] = (4.398059102, 6.96684623, -3.802802846) + ( + 0.3862608373, 1.155037873, 0.301362335) +points['flag1'] = (-7.006685836, 5.420897881, -2.717154638) +points['flag2'] = (7.166003893, 5.166226103, -2.651234621) +points['flag_default'] = (0.07287520555, 8.865234972, -4.988876512) +boxes['map_bounds'] = (-0.2103025678, 7.746661892, -0.3767425594) + ( + 0.0, 0.0, 0.0) + (23.8148841, 13.86473252, 16.37749544) +points['powerup_spawn1'] = (1.660037213, 8.050002248, -1.221221367) +points['powerup_spawn2'] = (-1.486576666, 7.912313704, -1.233393956) +points['powerup_spawn3'] = (2.629546191, 6.361794487, 1.399066775) +points['powerup_spawn4'] = (-2.695410503, 6.357885555, 1.428685156) +points['shadow_lower_bottom'] = (0.07287520555, 4.000760229, 6.31658856) +points['shadow_lower_top'] = (0.07287520555, 4.751182548, 6.31658856) +points['shadow_upper_bottom'] = (0.07287520555, 9.154987885, 6.31658856) +points['shadow_upper_top'] = (0.07287520555, 13.8166857, 6.31658856) +points['spawn1'] = (-7.264053816, 5.461241477, + -3.095884089) + (1.155037873, 1.155037873, 1.155037873) +points['spawn2'] = (7.384331793, 5.212769921, + -2.788130319) + (1.155037873, 1.155037873, 1.155037873) diff --git a/assets/src/data/scripts/bastd/mapdata/tower_d.py b/assets/src/data/scripts/bastd/mapdata/tower_d.py new file mode 100644 index 00000000..f28e9a86 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/tower_d.py @@ -0,0 +1,60 @@ +# This file was automatically generated from "tower_d.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (-0.4714933293, 2.887077774, + -1.505479919) + (0.0, 0.0, 0.0) + ( + 17.90145968, 6.188484831, 15.96149117) +boxes['b1'] = (-4.832680224, 2.581977222, -2.345017289) + (0.0, 0.0, 0.0) + ( + 0.9362069954, 2.750877211, 7.267441751) +boxes['b2'] = (4.74117666, 2.581977222, -3.341802598) + (0.0, 0.0, 0.0) + ( + 0.8162971446, 2.750877211, 7.222259865) +boxes['b3'] = (6.423991249, 2.581977222, -4.423628975) + (0.0, 0.0, 0.0) + ( + 0.8263776825, 4.534503966, 9.174814862) +boxes['b4'] = (-6.647568724, 3.229920028, -3.057507544) + (0.0, 0.0, 0.0) + ( + 0.9362069954, 2.750877211, 8.925082868) +boxes['b5'] = (3.537030094, 2.581977222, -2.366599524) + (0.0, 0.0, 0.0) + ( + 0.6331341496, 2.750877211, 5.327279595) +boxes['b6'] = (5.758481164, 2.581977222, 1.147676334) + (0.0, 0.0, 0.0) + ( + 2.300443227, 2.182113263, 0.5026852656) +boxes['b7'] = (-2.872805708, 2.581977222, -1.563390778) + (0.0, 0.0, 0.0) + ( + 0.9362069954, 2.750877211, 5.916439874) +boxes['b8'] = (-0.6884909563, 4.756572255, -5.317308535) + (0.0, 0.0, 0.0) + ( + 24.86081756, 9.001418153, 13.43159632) +boxes['b9'] = (-0.01526615369, 2.860860149, -0.9527080851) + ( + 0.0, 0.0, 0.0) + (4.899031047, 3.041987448, 6.142004808) +points['bot_spawn_bottom_left'] = (-7.400801881, 1.617640411, 5.384327397) + ( + 1.66339533, 1.0, 0.8737798622) +points['bot_spawn_bottom_right'] = (6.492270514, 1.617640411, 5.384327397) + ( + 1.66339533, 1.0, 0.8737798622) +points['bot_spawn_start'] = (-9.000552706, 3.1524, + 0.3095359717) + (1.66339533, 1.0, 0.8737798622) +boxes['edge_box'] = (-0.6502137344, 3.189969807, 4.099657551) + ( + 0.0, 0.0, 0.0) + (14.24474785, 1.469696777, 2.898807504) +boxes['edge_box2'] = (-0.144434237, 2.297109431, 0.3535332953) + ( + 0.0, 0.0, 0.0) + (1.744002325, 1.060637489, 4.905560105) +points['ffa_spawn1'] = (0.1024602894, 2.713599022, + -0.3639688715) + (1.66339533, 1.0, 0.8737798622) +points['flag1'] = (-7.751267587, 3.143417127, 0.266009523) +points['flag2'] = (6.851493725, 2.251048381, 0.3832888796) +points['flag_default'] = (0.01935110284, 2.227757733, -6.312658764) +boxes['map_bounds'] = (0.2608783669, 4.899663734, -3.543675157) + ( + 0.0, 0.0, 0.0) + (29.23565494, 14.19991443, 29.92689344) +boxes['powerup_region'] = (0.3544110667, 4.036899334, 3.413130338) + ( + 0.0, 0.0, 0.0) + (12.48542377, 0.3837028864, 1.622880843) +points['powerup_spawn1'] = (-4.926642821, 2.397645612, 2.876782366) +points['powerup_spawn2'] = (-1.964335546, 2.397645612, 3.751374716) +points['powerup_spawn3'] = (1.64883201, 2.397645612, 3.751374716) +points['powerup_spawn4'] = (4.398865293, 2.397645612, 2.877618924) +boxes['score_region'] = (8.378672831, 3.049210364, 0.5079393073) + ( + 0.0, 0.0, 0.0) + (1.276586051, 1.368127109, 0.6915290191) +points['shadow_lower_bottom'] = (-0.2912522507, 0.9466821599, 5.341226521) +points['shadow_lower_top'] = (-0.2912522507, 2.131949842, 5.341226521) +points['shadow_upper_bottom'] = (-0.4529958189, 6.062361813, 5.341226521) +points['shadow_upper_top'] = (-0.2912522507, 9.827201965, 5.341226521) +points['spawn1'] = (0.1024602894, 2.713599022, + -0.3639688715) + (1.66339533, 1.0, 0.8737798622) +points['spawn2'] = (0.1449413466, 2.739700702, + -0.3431945526) + (1.66339533, 1.0, 0.8737798622) +points['tnt_loc'] = (0.008297003631, 2.777570118, 3.894548697) diff --git a/assets/src/data/scripts/bastd/mapdata/zig_zag.py b/assets/src/data/scripts/bastd/mapdata/zig_zag.py new file mode 100644 index 00000000..8f8ccad1 --- /dev/null +++ b/assets/src/data/scripts/bastd/mapdata/zig_zag.py @@ -0,0 +1,43 @@ +# This file was automatically generated from "zig_zag.ma" +# pylint: disable=all +points = {} +# noinspection PyDictCreation +boxes = {} +boxes['area_of_interest_bounds'] = (-1.807378035, 3.943412768, -1.61304303) + ( + 0.0, 0.0, 0.0) + (23.01413538, 13.27980464, 10.0098376) +points['ffa_spawn1'] = (-9.523537347, 4.645005984, + -3.193868606) + (0.8511546708, 1.0, 1.303629055) +points['ffa_spawn2'] = (6.217011718, 4.632396767, + -3.190279407) + (0.8844870264, 1.0, 1.303629055) +points['ffa_spawn3'] = (-4.433947713, 3.005988298, + -4.908942678) + (1.531254794, 1.0, 0.7550370795) +points['ffa_spawn4'] = (1.457496709, 3.01461103, + -4.907036892) + (1.531254794, 1.0, 0.7550370795) +points['flag1'] = (-9.969465388, 4.651689505, -4.965994534) +points['flag2'] = (6.844972512, 4.652142027, -4.951156148) +points['flag3'] = (1.155692239, 2.973774873, -4.890524808) +points['flag4'] = (-4.224099354, 3.006208239, -4.890524808) +points['flag_default'] = (-1.42651413, 3.018804771, 0.8808626995) +boxes['map_bounds'] = (-1.567840276, 8.764115729, -1.311948055) + ( + 0.0, 0.0, 0.0) + (28.76792809, 17.64963076, 19.52220249) +points['powerup_spawn1'] = (2.55551802, 4.369175386, -4.798598276) +points['powerup_spawn2'] = (-6.02484656, 4.369175386, -4.798598276) +points['powerup_spawn3'] = (5.557525662, 5.378865401, -4.798598276) +points['powerup_spawn4'] = (-8.792856883, 5.378865401, -4.816871147) +points['shadow_lower_bottom'] = (-1.42651413, 1.681630068, 4.790029929) +points['shadow_lower_top'] = (-1.42651413, 2.54918356, 4.790029929) +points['shadow_upper_bottom'] = (-1.42651413, 6.802574329, 4.790029929) +points['shadow_upper_top'] = (-1.42651413, 8.779767257, 4.790029929) +points['spawn1'] = (-9.523537347, 4.645005984, + -3.193868606) + (0.8511546708, 1.0, 1.303629055) +points['spawn2'] = (6.217011718, 4.632396767, + -3.190279407) + (0.8844870264, 1.0, 1.303629055) +points['spawn_by_flag1'] = (-9.523537347, 4.645005984, + -3.193868606) + (0.8511546708, 1.0, 1.303629055) +points['spawn_by_flag2'] = (6.217011718, 4.632396767, + -3.190279407) + (0.8844870264, 1.0, 1.303629055) +points['spawn_by_flag3'] = (1.457496709, 3.01461103, + -4.907036892) + (1.531254794, 1.0, 0.7550370795) +points['spawn_by_flag4'] = (-4.433947713, 3.005988298, + -4.908942678) + (1.531254794, 1.0, 0.7550370795) +points['tnt1'] = (-1.42651413, 4.045239665, 0.04094631341) diff --git a/assets/src/data/scripts/bastd/maps.py b/assets/src/data/scripts/bastd/maps.py new file mode 100644 index 00000000..618f1bdb --- /dev/null +++ b/assets/src/data/scripts/bastd/maps.py @@ -0,0 +1,1538 @@ +"""Standard maps.""" +# pylint: disable=too-many-lines + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd import stdmap + +if TYPE_CHECKING: + from typing import Any, List, Dict + + +class HockeyStadium(ba.Map): + """Stadium map used for ice hockey games.""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import hockey_stadium as defs + name = "Hockey Stadium" + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'hockey', 'team_flag', 'keep_away'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'hockeyStadiumPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'models': (ba.getmodel('hockeyStadiumOuter'), + ba.getmodel('hockeyStadiumInner'), + ba.getmodel('hockeyStadiumStands')), + 'vr_fill_model': ba.getmodel('footballStadiumVRFill'), + 'collide_model': ba.getcollidemodel('hockeyStadiumCollide'), + 'tex': ba.gettexture('hockeyStadium'), + 'stands_tex': ba.gettexture('footballStadium') + } + mat = ba.Material() + mat.add_actions(actions=('modify_part_collision', 'friction', 0.01)) + data['ice_material'] = mat + return data + + def __init__(self) -> None: + super().__init__() + self.node = ba.newnode("terrain", + delegate=self, + attrs={ + 'model': + self.preloaddata['models'][0], + 'collide_model': + self.preloaddata['collide_model'], + 'color_texture': + self.preloaddata['tex'], + 'materials': [ + ba.sharedobj('footing_material'), + self.preloaddata['ice_material'] + ] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['vr_fill_model'], + 'vr_only': True, + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['stands_tex'] + }) + mats = [ + ba.sharedobj('footing_material'), self.preloaddata['ice_material'] + ] + self.floor = ba.newnode("terrain", + attrs={ + "model": self.preloaddata['models'][1], + "color_texture": self.preloaddata['tex'], + "opacity": 0.92, + "opacity_in_low_or_medium_quality": 1.0, + "materials": mats + }) + self.stands = ba.newnode( + "terrain", + attrs={ + "model": self.preloaddata['models'][2], + "visible_in_reflections": False, + "color_texture": self.preloaddata['stands_tex'] + }) + gnode = ba.sharedobj('globals') + gnode.floor_reflection = True + gnode.debris_friction = 0.3 + gnode.debris_kill_height = -0.3 + gnode.tint = (1.2, 1.3, 1.33) + gnode.ambient_color = (1.15, 1.25, 1.6) + gnode.vignette_outer = (0.66, 0.67, 0.73) + gnode.vignette_inner = (0.93, 0.93, 0.95) + gnode.vr_camera_offset = (0, -0.8, -1.1) + gnode.vr_near_clip = 0.5 + self.is_hockey = True + + +class FootballStadium(ba.Map): + """Stadium map for football games.""" + from bastd.mapdata import football_stadium as defs + + name = "Football Stadium" + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'football', 'team_flag', 'keep_away'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'footballStadiumPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel("footballStadium"), + 'vr_fill_model': ba.getmodel('footballStadiumVRFill'), + 'collide_model': ba.getcollidemodel("footballStadiumCollide"), + 'tex': ba.gettexture("footballStadium") + } + return data + + def __init__(self) -> None: + super().__init__() + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'model': self.preloaddata['model'], + 'collide_model': self.preloaddata['collide_model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['vr_fill_model'], + 'lighting': False, + 'vr_only': True, + 'background': True, + 'color_texture': self.preloaddata['tex'] + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1.3, 1.2, 1.0) + gnode.ambient_color = (1.3, 1.2, 1.0) + gnode.vignette_outer = (0.57, 0.57, 0.57) + gnode.vignette_inner = (0.9, 0.9, 0.9) + gnode.vr_camera_offset = (0, -0.8, -1.1) + gnode.vr_near_clip = 0.5 + + def is_point_near_edge(self, point: ba.Vec3, + running: bool = False) -> bool: + box_position = self.defs.boxes['edge_box'][0:3] + box_scale = self.defs.boxes['edge_box'][6:9] + xpos = (point.x - box_position[0]) / box_scale[0] + zpos = (point.z - box_position[2]) / box_scale[2] + return xpos < -0.5 or xpos > 0.5 or zpos < -0.5 or zpos > 0.5 + + +class Bridgit(stdmap.StdMap): + """Map with a narrow bridge in the middle.""" + # noinspection PyUnresolvedReferences + from bastd.mapdata import bridgit as defs + + name = 'Bridgit' + dataname = 'bridgit' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + print('getting playtypes', cls._getdata()['play_types']) + return ['melee', 'team_flag', 'keep_away'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'bridgitPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model_top': ba.getmodel("bridgitLevelTop"), + 'model_bottom': ba.getmodel("bridgitLevelBottom"), + 'model_bg': ba.getmodel("natureBackground"), + 'bg_vr_fill_model': ba.getmodel('natureBackgroundVRFill'), + 'collide_model': ba.getcollidemodel("bridgitLevelCollide"), + 'tex': ba.gettexture("bridgitLevelColor"), + 'model_bg_tex': ba.gettexture("natureBackgroundColor"), + 'collide_bg': ba.getcollidemodel("natureBackgroundCollide"), + 'railing_collide_model': + (ba.getcollidemodel("bridgitLevelRailingCollide")), + 'bg_material': ba.Material() + } + data['bg_material'].add_actions(actions=('modify_part_collision', + 'friction', 10.0)) + return data + + def __init__(self) -> None: + super().__init__() + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model_top'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.bottom = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['model_bottom'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['model_bg'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['model_bg_tex'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bg_vr_fill_model'], + 'lighting': False, + 'vr_only': True, + 'background': True, + 'color_texture': self.preloaddata['model_bg_tex'] + }) + self.railing = ba.newnode( + 'terrain', + attrs={ + 'collide_model': self.preloaddata['railing_collide_model'], + 'materials': [ba.sharedobj('railing_material')], + 'bumper': True + }) + self.bg_collide = ba.newnode('terrain', + attrs={ + 'collide_model': + self.preloaddata['collide_bg'], + 'materials': [ + ba.sharedobj('footing_material'), + self.preloaddata['bg_material'], + ba.sharedobj('death_material') + ] + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1.1, 1.2, 1.3) + gnode.ambient_color = (1.1, 1.2, 1.3) + gnode.vignette_outer = (0.65, 0.6, 0.55) + gnode.vignette_inner = (0.9, 0.9, 0.93) + + +class BigG(ba.Map): + """Large G shaped map for racing""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import big_g as defs + + name = 'Big G' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return [ + 'race', 'melee', 'keep_away', 'team_flag', 'king_of_the_hill', + 'conquest' + ] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'bigGPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model_top': ba.getmodel('bigG'), + 'model_bottom': ba.getmodel('bigGBottom'), + 'model_bg': ba.getmodel('natureBackground'), + 'bg_vr_fill_model': ba.getmodel('natureBackgroundVRFill'), + 'collide_model': ba.getcollidemodel('bigGCollide'), + 'tex': ba.gettexture('bigG'), + 'model_bg_tex': ba.gettexture('natureBackgroundColor'), + 'collide_bg': ba.getcollidemodel('natureBackgroundCollide'), + 'bumper_collide_model': ba.getcollidemodel('bigGBumper'), + 'bg_material': ba.Material() + } + data['bg_material'].add_actions(actions=('modify_part_collision', + 'friction', 10.0)) + return data + + def __init__(self) -> None: + super().__init__() + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'color': (0.7, 0.7, 0.7), + 'model': self.preloaddata['model_top'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.bottom = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['model_bottom'], + 'color': (0.7, 0.7, 0.7), + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['model_bg'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['model_bg_tex'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bg_vr_fill_model'], + 'lighting': False, + 'vr_only': True, + 'background': True, + 'color_texture': self.preloaddata['model_bg_tex'] + }) + self.railing = ba.newnode( + 'terrain', + attrs={ + 'collide_model': self.preloaddata['bumper_collide_model'], + 'materials': [ba.sharedobj('railing_material')], + 'bumper': True + }) + self.bg_collide = ba.newnode('terrain', + attrs={ + 'collide_model': + self.preloaddata['collide_bg'], + 'materials': [ + ba.sharedobj('footing_material'), + self.preloaddata['bg_material'], + ba.sharedobj('death_material') + ] + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1.1, 1.2, 1.3) + gnode.ambient_color = (1.1, 1.2, 1.3) + gnode.vignette_outer = (0.65, 0.6, 0.55) + gnode.vignette_inner = (0.9, 0.9, 0.93) + + +class Roundabout(ba.Map): + """CTF map featuring two platforms and a long way around between them""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import roundabout as defs + + name = 'Roundabout' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'keep_away', 'team_flag'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'roundaboutPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('roundaboutLevel'), + 'model_bottom': ba.getmodel('roundaboutLevelBottom'), + 'model_bg': ba.getmodel('natureBackground'), + 'bg_vr_fill_model': ba.getmodel('natureBackgroundVRFill'), + 'collide_model': ba.getcollidemodel('roundaboutLevelCollide'), + 'tex': ba.gettexture('roundaboutLevelColor'), + 'model_bg_tex': ba.gettexture('natureBackgroundColor'), + 'collide_bg': ba.getcollidemodel('natureBackgroundCollide'), + 'railing_collide_model': + (ba.getcollidemodel('roundaboutLevelBumper')), + 'bg_material': ba.Material() + } + data['bg_material'].add_actions(actions=('modify_part_collision', + 'friction', 10.0)) + return data + + def __init__(self) -> None: + super().__init__(vr_overlay_offset=(0, -1, 1)) + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.bottom = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['model_bottom'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['model_bg'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['model_bg_tex'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bg_vr_fill_model'], + 'lighting': False, + 'vr_only': True, + 'background': True, + 'color_texture': self.preloaddata['model_bg_tex'] + }) + self.bg_collide = ba.newnode('terrain', + attrs={ + 'collide_model': + self.preloaddata['collide_bg'], + 'materials': [ + ba.sharedobj('footing_material'), + self.preloaddata['bg_material'], + ba.sharedobj('death_material') + ] + }) + self.railing = ba.newnode( + 'terrain', + attrs={ + 'collide_model': self.preloaddata['railing_collide_model'], + 'materials': [ba.sharedobj('railing_material')], + 'bumper': True + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1.0, 1.05, 1.1) + gnode.ambient_color = (1.0, 1.05, 1.1) + gnode.shadow_ortho = True + gnode.vignette_outer = (0.63, 0.65, 0.7) + gnode.vignette_inner = (0.97, 0.95, 0.93) + + +class MonkeyFace(ba.Map): + """Map sorta shaped like a monkey face; teehee!""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import monkey_face as defs + + name = 'Monkey Face' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'keep_away', 'team_flag'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'monkeyFacePreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('monkeyFaceLevel'), + 'bottom_model': ba.getmodel('monkeyFaceLevelBottom'), + 'model_bg': ba.getmodel('natureBackground'), + 'bg_vr_fill_model': ba.getmodel('natureBackgroundVRFill'), + 'collide_model': ba.getcollidemodel('monkeyFaceLevelCollide'), + 'tex': ba.gettexture('monkeyFaceLevelColor'), + 'model_bg_tex': ba.gettexture('natureBackgroundColor'), + 'collide_bg': ba.getcollidemodel('natureBackgroundCollide'), + 'railing_collide_model': + (ba.getcollidemodel('monkeyFaceLevelBumper')), + 'bg_material': ba.Material() + } + data['bg_material'].add_actions(actions=('modify_part_collision', + 'friction', 10.0)) + return data + + def __init__(self) -> None: + super().__init__() + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.bottom = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bottom_model'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['model_bg'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['model_bg_tex'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bg_vr_fill_model'], + 'lighting': False, + 'vr_only': True, + 'background': True, + 'color_texture': self.preloaddata['model_bg_tex'] + }) + self.bg_collide = ba.newnode('terrain', + attrs={ + 'collide_model': + self.preloaddata['collide_bg'], + 'materials': [ + ba.sharedobj('footing_material'), + self.preloaddata['bg_material'], + ba.sharedobj('death_material') + ] + }) + self.railing = ba.newnode( + 'terrain', + attrs={ + 'collide_model': self.preloaddata['railing_collide_model'], + 'materials': [ba.sharedobj('railing_material')], + 'bumper': True + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1.1, 1.2, 1.2) + gnode.ambient_color = (1.2, 1.3, 1.3) + gnode.vignette_outer = (0.60, 0.62, 0.66) + gnode.vignette_inner = (0.97, 0.95, 0.93) + gnode.vr_camera_offset = (-1.4, 0, 0) + + +class ZigZag(ba.Map): + """A very long zig-zaggy map""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import zig_zag as defs + + name = 'Zigzag' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return [ + 'melee', 'keep_away', 'team_flag', 'conquest', 'king_of_the_hill' + ] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'zigzagPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('zigZagLevel'), + 'model_bottom': ba.getmodel('zigZagLevelBottom'), + 'model_bg': ba.getmodel('natureBackground'), + 'bg_vr_fill_model': ba.getmodel('natureBackgroundVRFill'), + 'collide_model': ba.getcollidemodel('zigZagLevelCollide'), + 'tex': ba.gettexture('zigZagLevelColor'), + 'model_bg_tex': ba.gettexture('natureBackgroundColor'), + 'collide_bg': ba.getcollidemodel('natureBackgroundCollide'), + 'railing_collide_model': ba.getcollidemodel('zigZagLevelBumper'), + 'bg_material': ba.Material() + } + data['bg_material'].add_actions(actions=('modify_part_collision', + 'friction', 10.0)) + return data + + def __init__(self) -> None: + super().__init__() + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['model_bg'], + 'lighting': False, + 'color_texture': self.preloaddata['model_bg_tex'] + }) + self.bottom = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['model_bottom'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bg_vr_fill_model'], + 'lighting': False, + 'vr_only': True, + 'background': True, + 'color_texture': self.preloaddata['model_bg_tex'] + }) + self.bg_collide = ba.newnode('terrain', + attrs={ + 'collide_model': + self.preloaddata['collide_bg'], + 'materials': [ + ba.sharedobj('footing_material'), + self.preloaddata['bg_material'], + ba.sharedobj('death_material') + ] + }) + self.railing = ba.newnode( + 'terrain', + attrs={ + 'collide_model': self.preloaddata['railing_collide_model'], + 'materials': [ba.sharedobj('railing_material')], + 'bumper': True + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1.0, 1.15, 1.15) + gnode.ambient_color = (1.0, 1.15, 1.15) + gnode.vignette_outer = (0.57, 0.59, 0.63) + gnode.vignette_inner = (0.97, 0.95, 0.93) + gnode.vr_camera_offset = (-1.5, 0, 0) + + +class ThePad(ba.Map): + """A simple square shaped map with a raised edge.""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import the_pad as defs + + name = 'The Pad' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'keep_away', 'team_flag', 'king_of_the_hill'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'thePadPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('thePadLevel'), + 'bottom_model': ba.getmodel('thePadLevelBottom'), + 'collide_model': ba.getcollidemodel('thePadLevelCollide'), + 'tex': ba.gettexture('thePadLevelColor'), + 'bgtex': ba.gettexture('menuBG'), + 'bgmodel': ba.getmodel('thePadBG'), + 'railing_collide_model': ba.getcollidemodel('thePadLevelBumper'), + 'vr_fill_mound_model': ba.getmodel('thePadVRFillMound'), + 'vr_fill_mound_tex': ba.gettexture('vrFillMound') + } + # fixme should chop this into vr/non-vr sections for efficiency + return data + + def __init__(self) -> None: + super().__init__() + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.bottom = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bottom_model'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['bgmodel'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['bgtex'] + }) + self.railing = ba.newnode( + 'terrain', + attrs={ + 'collide_model': self.preloaddata['railing_collide_model'], + 'materials': [ba.sharedobj('railing_material')], + 'bumper': True + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['vr_fill_mound_model'], + 'lighting': False, + 'vr_only': True, + 'color': (0.56, 0.55, 0.47), + 'background': True, + 'color_texture': self.preloaddata['vr_fill_mound_tex'] + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1.1, 1.1, 1.0) + gnode.ambient_color = (1.1, 1.1, 1.0) + gnode.vignette_outer = (0.7, 0.65, 0.75) + gnode.vignette_inner = (0.95, 0.95, 0.93) + + +class DoomShroom(ba.Map): + """A giant mushroom. Of doom.""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import doom_shroom as defs + + name = 'Doom Shroom' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'keep_away', 'team_flag'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'doomShroomPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('doomShroomLevel'), + 'collide_model': ba.getcollidemodel('doomShroomLevelCollide'), + 'tex': ba.gettexture('doomShroomLevelColor'), + 'bgtex': ba.gettexture('doomShroomBGColor'), + 'bgmodel': ba.getmodel('doomShroomBG'), + 'vr_fill_model': ba.getmodel('doomShroomVRFill'), + 'stem_model': ba.getmodel('doomShroomStem'), + 'collide_bg': ba.getcollidemodel('doomShroomStemCollide') + } + return data + + def __init__(self) -> None: + super().__init__() + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['bgmodel'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['bgtex'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['vr_fill_model'], + 'lighting': False, + 'vr_only': True, + 'background': True, + 'color_texture': self.preloaddata['bgtex'] + }) + self.stem = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['stem_model'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + self.bg_collide = ba.newnode('terrain', + attrs={ + 'collide_model': + self.preloaddata['collide_bg'], + 'materials': [ + ba.sharedobj('footing_material'), + ba.sharedobj('death_material') + ] + }) + gnode = ba.sharedobj('globals') + gnode.tint = (0.82, 1.10, 1.15) + gnode.ambient_color = (0.9, 1.3, 1.1) + gnode.shadow_ortho = False + gnode.vignette_outer = (0.76, 0.76, 0.76) + gnode.vignette_inner = (0.95, 0.95, 0.99) + + def is_point_near_edge(self, point: ba.Vec3, + running: bool = False) -> bool: + xpos = point.x + zpos = point.z + x_adj = xpos * 0.125 + z_adj = (zpos + 3.7) * 0.2 + if running: + x_adj *= 1.4 + z_adj *= 1.4 + return x_adj * x_adj + z_adj * z_adj > 1.0 + + +class LakeFrigid(ba.Map): + """An icy lake fit for racing.""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import lake_frigid as defs + + name = 'Lake Frigid' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'keep_away', 'team_flag', 'race'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'lakeFrigidPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('lakeFrigid'), + 'model_top': ba.getmodel('lakeFrigidTop'), + 'model_reflections': ba.getmodel('lakeFrigidReflections'), + 'collide_model': ba.getcollidemodel('lakeFrigidCollide'), + 'tex': ba.gettexture('lakeFrigid'), + 'tex_reflections': ba.gettexture('lakeFrigidReflections'), + 'vr_fill_model': ba.getmodel('lakeFrigidVRFill') + } + mat = ba.Material() + mat.add_actions(actions=('modify_part_collision', 'friction', 0.01)) + data['ice_material'] = mat + return data + + def __init__(self) -> None: + super().__init__() + self.node = ba.newnode('terrain', + delegate=self, + attrs={ + 'collide_model': + self.preloaddata['collide_model'], + 'model': + self.preloaddata['model'], + 'color_texture': + self.preloaddata['tex'], + 'materials': [ + ba.sharedobj('footing_material'), + self.preloaddata['ice_material'] + ] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['model_top'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['model_reflections'], + 'lighting': False, + 'overlay': True, + 'opacity': 0.15, + 'color_texture': self.preloaddata['tex_reflections'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['vr_fill_model'], + 'lighting': False, + 'vr_only': True, + 'background': True, + 'color_texture': self.preloaddata['tex'] + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1, 1, 1) + gnode.ambient_color = (1, 1, 1) + gnode.shadow_ortho = True + gnode.vignette_outer = (0.86, 0.86, 0.86) + gnode.vignette_inner = (0.95, 0.95, 0.99) + gnode.vr_near_clip = 0.5 + self.is_hockey = True + + +class TipTop(ba.Map): + """A pointy map good for king-of-the-hill-ish games.""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import tip_top as defs + + name = 'Tip Top' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'keep_away', 'team_flag', 'king_of_the_hill'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'tipTopPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('tipTopLevel'), + 'bottom_model': ba.getmodel('tipTopLevelBottom'), + 'collide_model': ba.getcollidemodel('tipTopLevelCollide'), + 'tex': ba.gettexture('tipTopLevelColor'), + 'bgtex': ba.gettexture('tipTopBGColor'), + 'bgmodel': ba.getmodel('tipTopBG'), + 'railing_collide_model': ba.getcollidemodel('tipTopLevelBumper') + } + return data + + def __init__(self) -> None: + super().__init__(vr_overlay_offset=(0, -0.2, 2.5)) + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'color': (0.7, 0.7, 0.7), + 'materials': [ba.sharedobj('footing_material')] + }) + self.bottom = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bottom_model'], + 'lighting': False, + 'color': (0.7, 0.7, 0.7), + 'color_texture': self.preloaddata['tex'] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['bgmodel'], + 'lighting': False, + 'color': (0.4, 0.4, 0.4), + 'background': True, + 'color_texture': self.preloaddata['bgtex'] + }) + self.railing = ba.newnode( + 'terrain', + attrs={ + 'collide_model': self.preloaddata['railing_collide_model'], + 'materials': [ba.sharedobj('railing_material')], + 'bumper': True + }) + gnode = ba.sharedobj('globals') + gnode.tint = (0.8, 0.9, 1.3) + gnode.ambient_color = (0.8, 0.9, 1.3) + gnode.vignette_outer = (0.79, 0.79, 0.69) + gnode.vignette_inner = (0.97, 0.97, 0.99) + + +class CragCastle(ba.Map): + """A lovely castle map.""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import crag_castle as defs + + name = 'Crag Castle' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'keep_away', 'team_flag', 'conquest'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'cragCastlePreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('cragCastleLevel'), + 'bottom_model': ba.getmodel('cragCastleLevelBottom'), + 'collide_model': ba.getcollidemodel('cragCastleLevelCollide'), + 'tex': ba.gettexture('cragCastleLevelColor'), + 'bgtex': ba.gettexture('menuBG'), + 'bgmodel': ba.getmodel('thePadBG'), + 'railing_collide_model': + (ba.getcollidemodel('cragCastleLevelBumper')), + 'vr_fill_mound_model': ba.getmodel('cragCastleVRFillMound'), + 'vr_fill_mound_tex': ba.gettexture('vrFillMound') + } + # fixme should chop this into vr/non-vr sections + return data + + def __init__(self) -> None: + super().__init__() + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.bottom = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bottom_model'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['bgmodel'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['bgtex'] + }) + self.railing = ba.newnode( + 'terrain', + attrs={ + 'collide_model': self.preloaddata['railing_collide_model'], + 'materials': [ba.sharedobj('railing_material')], + 'bumper': True + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['vr_fill_mound_model'], + 'lighting': False, + 'vr_only': True, + 'color': (0.2, 0.25, 0.2), + 'background': True, + 'color_texture': self.preloaddata['vr_fill_mound_tex'] + }) + gnode = ba.sharedobj('globals') + gnode.shadow_ortho = True + gnode.shadow_offset = (0, 0, -5.0) + gnode.tint = (1.15, 1.05, 0.75) + gnode.ambient_color = (1.15, 1.05, 0.75) + gnode.vignette_outer = (0.6, 0.65, 0.6) + gnode.vignette_inner = (0.95, 0.95, 0.95) + gnode.vr_near_clip = 1.0 + + +class TowerD(ba.Map): + """Map used for runaround mini-game.""" + + from bastd.mapdata import tower_d as defs + + name = 'Tower D' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return [] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'towerDPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': + ba.getmodel('towerDLevel'), + 'model_bottom': + ba.getmodel('towerDLevelBottom'), + 'collide_model': + ba.getcollidemodel('towerDLevelCollide'), + 'tex': + ba.gettexture('towerDLevelColor'), + 'bgtex': + ba.gettexture('menuBG'), + 'bgmodel': + ba.getmodel('thePadBG'), + 'player_wall_collide_model': + ba.getcollidemodel('towerDPlayerWall'), + 'player_wall_material': + ba.Material() + } + # fixme should chop this into vr/non-vr sections + data['player_wall_material'].add_actions( + actions=('modify_part_collision', 'friction', 0.0)) + # anything that needs to hit the wall can apply this material + data['collide_with_wall_material'] = ba.Material() + data['player_wall_material'].add_actions( + conditions=('they_dont_have_material', + data['collide_with_wall_material']), + actions=('modify_part_collision', 'collide', False)) + data['vr_fill_mound_model'] = ba.getmodel('stepRightUpVRFillMound') + data['vr_fill_mound_tex'] = ba.gettexture('vrFillMound') + return data + + def __init__(self) -> None: + super().__init__(vr_overlay_offset=(0, 1, 1)) + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.node_bottom = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'model': self.preloaddata['model_bottom'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['vr_fill_mound_model'], + 'lighting': False, + 'vr_only': True, + 'color': (0.53, 0.57, 0.5), + 'background': True, + 'color_texture': self.preloaddata['vr_fill_mound_tex'] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['bgmodel'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['bgtex'] + }) + self.player_wall = ba.newnode( + 'terrain', + attrs={ + 'collide_model': self.preloaddata['player_wall_collide_model'], + 'affect_bg_dynamics': False, + 'materials': [self.preloaddata['player_wall_material']] + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1.15, 1.11, 1.03) + gnode.ambient_color = (1.2, 1.1, 1.0) + gnode.vignette_outer = (0.7, 0.73, 0.7) + gnode.vignette_inner = (0.95, 0.95, 0.95) + + def is_point_near_edge(self, point: ba.Vec3, + running: bool = False) -> bool: + # see if we're within edge_box + boxes = self.defs.boxes + box_position = boxes['edge_box1'][0:3] + box_scale = boxes['edge_box1'][6:9] + box_position2 = boxes['edge_box2'][0:3] + box_scale2 = boxes['edge_box2'][6:9] + xpos = (point.x - box_position[0]) / box_scale[0] + zpos = (point.z - box_position[2]) / box_scale[2] + xpos2 = (point.x - box_position2[0]) / box_scale2[0] + zpos2 = (point.z - box_position2[2]) / box_scale2[2] + # if we're outside of *both* boxes we're near the edge + return ((xpos < -0.5 or xpos > 0.5 or zpos < -0.5 or zpos > 0.5) and + (xpos2 < -0.5 or xpos2 > 0.5 or zpos2 < -0.5 or zpos2 > 0.5)) + + +class HappyThoughts(ba.Map): + """Flying map.""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import happy_thoughts as defs + + name = 'Happy Thoughts' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return [ + 'melee', 'keep_away', 'team_flag', 'conquest', 'king_of_the_hill' + ] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'alwaysLandPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('alwaysLandLevel'), + 'bottom_model': ba.getmodel('alwaysLandLevelBottom'), + 'bgmodel': ba.getmodel('alwaysLandBG'), + 'collide_model': ba.getcollidemodel('alwaysLandLevelCollide'), + 'tex': ba.gettexture('alwaysLandLevelColor'), + 'bgtex': ba.gettexture('alwaysLandBGColor'), + 'vr_fill_mound_model': ba.getmodel('alwaysLandVRFillMound'), + 'vr_fill_mound_tex': ba.gettexture('vrFillMound') + } + return data + + @classmethod + def get_music_type(cls) -> str: + return 'Flying' + + def __init__(self) -> None: + super().__init__(vr_overlay_offset=(0, -3.7, 2.5)) + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.bottom = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bottom_model'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['bgmodel'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['bgtex'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['vr_fill_mound_model'], + 'lighting': False, + 'vr_only': True, + 'color': (0.2, 0.25, 0.2), + 'background': True, + 'color_texture': self.preloaddata['vr_fill_mound_tex'] + }) + gnode = ba.sharedobj('globals') + gnode.happy_thoughts_mode = True + gnode.shadow_offset = (0.0, 8.0, 5.0) + gnode.tint = (1.3, 1.23, 1.0) + gnode.ambient_color = (1.3, 1.23, 1.0) + gnode.vignette_outer = (0.64, 0.59, 0.69) + gnode.vignette_inner = (0.95, 0.95, 0.93) + gnode.vr_near_clip = 1.0 + self.is_flying = True + + # throw out some tips on flying + txt = ba.newnode('text', + attrs={ + 'text': ba.Lstr(resource='pressJumpToFlyText'), + 'scale': 1.2, + 'maxwidth': 800, + 'position': (0, 200), + 'shadow': 0.5, + 'flatness': 0.5, + 'h_align': 'center', + 'v_attach': 'bottom' + }) + cmb = ba.newnode('combine', + owner=txt, + attrs={ + 'size': 4, + 'input0': 0.3, + 'input1': 0.9, + 'input2': 0.0 + }) + ba.animate(cmb, 'input3', {3.0: 0, 4.0: 1, 9.0: 1, 10.0: 0}) + cmb.connectattr('output', txt, 'color') + ba.timer(10.0, txt.delete) + + +class StepRightUp(ba.Map): + """Wide stepped map good for CTF or Assault.""" + + # noinspection PyUnresolvedReferences + from bastd.mapdata import step_right_up as defs + + name = 'Step Right Up' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'keep_away', 'team_flag', 'conquest'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'stepRightUpPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('stepRightUpLevel'), + 'model_bottom': ba.getmodel('stepRightUpLevelBottom'), + 'collide_model': ba.getcollidemodel('stepRightUpLevelCollide'), + 'tex': ba.gettexture('stepRightUpLevelColor'), + 'bgtex': ba.gettexture('menuBG'), + 'bgmodel': ba.getmodel('thePadBG'), + 'vr_fill_mound_model': ba.getmodel('stepRightUpVRFillMound'), + 'vr_fill_mound_tex': ba.gettexture('vrFillMound') + } + # fixme should chop this into vr/non-vr chunks + return data + + def __init__(self) -> None: + super().__init__(vr_overlay_offset=(0, -1, 2)) + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.node_bottom = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'model': self.preloaddata['model_bottom'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['vr_fill_mound_model'], + 'lighting': False, + 'vr_only': True, + 'color': (0.53, 0.57, 0.5), + 'background': True, + 'color_texture': self.preloaddata['vr_fill_mound_tex'] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['bgmodel'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['bgtex'] + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1.2, 1.1, 1.0) + gnode.ambient_color = (1.2, 1.1, 1.0) + gnode.vignette_outer = (0.7, 0.65, 0.75) + gnode.vignette_inner = (0.95, 0.95, 0.93) + + +class Courtyard(ba.Map): + """A courtyard-ish looking map for co-op levels.""" + + from bastd.mapdata import courtyard as defs + + name = 'Courtyard' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'keep_away', 'team_flag'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'courtyardPreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('courtyardLevel'), + 'model_bottom': ba.getmodel('courtyardLevelBottom'), + 'collide_model': ba.getcollidemodel('courtyardLevelCollide'), + 'tex': ba.gettexture('courtyardLevelColor'), + 'bgtex': ba.gettexture('menuBG'), + 'bgmodel': ba.getmodel('thePadBG'), + 'player_wall_collide_model': + (ba.getcollidemodel('courtyardPlayerWall')), + 'player_wall_material': ba.Material() + } + # FIXME: Chop this into vr and non-vr chunks. + data['player_wall_material'].add_actions( + actions=('modify_part_collision', 'friction', 0.0)) + # anything that needs to hit the wall should apply this. + data['collide_with_wall_material'] = ba.Material() + data['player_wall_material'].add_actions( + conditions=('they_dont_have_material', + data['collide_with_wall_material']), + actions=('modify_part_collision', 'collide', False)) + data['vr_fill_mound_model'] = ba.getmodel('stepRightUpVRFillMound') + data['vr_fill_mound_tex'] = ba.gettexture('vrFillMound') + return data + + def __init__(self) -> None: + super().__init__() + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['bgmodel'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['bgtex'] + }) + self.bottom = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['model_bottom'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['vr_fill_mound_model'], + 'lighting': False, + 'vr_only': True, + 'color': (0.53, 0.57, 0.5), + 'background': True, + 'color_texture': self.preloaddata['vr_fill_mound_tex'] + }) + # in co-op mode games, put up a wall to prevent players + # from getting in the turrets (that would foil our brilliant AI) + if isinstance(ba.getsession(), ba.CoopSession): + cmodel = self.preloaddata['player_wall_collide_model'] + self.player_wall = ba.newnode( + 'terrain', + attrs={ + 'collide_model': cmodel, + 'affect_bg_dynamics': False, + 'materials': [self.preloaddata['player_wall_material']] + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1.2, 1.17, 1.1) + gnode.ambient_color = (1.2, 1.17, 1.1) + gnode.vignette_outer = (0.6, 0.6, 0.64) + gnode.vignette_inner = (0.95, 0.95, 0.93) + + def is_point_near_edge(self, point: ba.Vec3, + running: bool = False) -> bool: + # count anything off our ground level as safe (for our platforms) + # see if we're within edge_box + box_position = self.defs.boxes['edge_box'][0:3] + box_scale = self.defs.boxes['edge_box'][6:9] + xpos = (point.x - box_position[0]) / box_scale[0] + zpos = (point.z - box_position[2]) / box_scale[2] + return xpos < -0.5 or xpos > 0.5 or zpos < -0.5 or zpos > 0.5 + + +class Rampage(ba.Map): + """Wee little map with ramps on the sides.""" + + from bastd.mapdata import rampage as defs + + name = 'Rampage' + + @classmethod + def get_play_types(cls) -> List[str]: + """Return valid play types for this map.""" + return ['melee', 'keep_away', 'team_flag'] + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'rampagePreview' + + @classmethod + def on_preload(cls) -> Any: + data: Dict[str, Any] = { + 'model': ba.getmodel('rampageLevel'), + 'bottom_model': ba.getmodel('rampageLevelBottom'), + 'collide_model': ba.getcollidemodel('rampageLevelCollide'), + 'tex': ba.gettexture('rampageLevelColor'), + 'bgtex': ba.gettexture('rampageBGColor'), + 'bgtex2': ba.gettexture('rampageBGColor2'), + 'bgmodel': ba.getmodel('rampageBG'), + 'bgmodel2': ba.getmodel('rampageBG2'), + 'vr_fill_model': ba.getmodel('rampageVRFill'), + 'railing_collide_model': ba.getcollidemodel('rampageBumper') + } + return data + + def __init__(self) -> None: + super().__init__(vr_overlay_offset=(0, 0, 2)) + self.node = ba.newnode( + 'terrain', + delegate=self, + attrs={ + 'collide_model': self.preloaddata['collide_model'], + 'model': self.preloaddata['model'], + 'color_texture': self.preloaddata['tex'], + 'materials': [ba.sharedobj('footing_material')] + }) + self.background = ba.newnode( + 'terrain', + attrs={ + 'model': self.preloaddata['bgmodel'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['bgtex'] + }) + self.bottom = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bottom_model'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'] + }) + self.bg2 = ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['bgmodel2'], + 'lighting': False, + 'background': True, + 'color_texture': self.preloaddata['bgtex2'] + }) + ba.newnode('terrain', + attrs={ + 'model': self.preloaddata['vr_fill_model'], + 'lighting': False, + 'vr_only': True, + 'background': True, + 'color_texture': self.preloaddata['bgtex2'] + }) + self.railing = ba.newnode( + 'terrain', + attrs={ + 'collide_model': self.preloaddata['railing_collide_model'], + 'materials': [ba.sharedobj('railing_material')], + 'bumper': True + }) + gnode = ba.sharedobj('globals') + gnode.tint = (1.2, 1.1, 0.97) + gnode.ambient_color = (1.3, 1.2, 1.03) + gnode.vignette_outer = (0.62, 0.64, 0.69) + gnode.vignette_inner = (0.97, 0.95, 0.93) + + def is_point_near_edge(self, point: ba.Vec3, + running: bool = False) -> bool: + box_position = self.defs.boxes['edge_box'][0:3] + box_scale = self.defs.boxes['edge_box'][6:9] + xpos = (point.x - box_position[0]) / box_scale[0] + zpos = (point.z - box_position[2]) / box_scale[2] + return xpos < -0.5 or xpos > 0.5 or zpos < -0.5 or zpos > 0.5 diff --git a/assets/src/data/scripts/bastd/session/__init__.py b/assets/src/data/scripts/bastd/session/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assets/src/data/scripts/bastd/stdmap.py b/assets/src/data/scripts/bastd/stdmap.py new file mode 100644 index 00000000..d1d470cb --- /dev/null +++ b/assets/src/data/scripts/bastd/stdmap.py @@ -0,0 +1,35 @@ +"""Defines standard map type.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Dict, Any, Optional + + +def _get_map_data(name: str) -> Dict[str, Any]: + import json + print('Would get map data', name) + with open('data/data/maps/' + name + '.json') as infile: + mapdata = json.loads(infile.read()) + assert isinstance(mapdata, dict) + return mapdata + + +class StdMap(ba.Map): + """A map completely defined by asset data. + + """ + _data: Optional[Dict[str, Any]] = None + + @classmethod + def _getdata(cls) -> Dict[str, Any]: + if cls._data is None: + cls._data = _get_map_data('bridgit') + return cls._data + + def __init__(self) -> None: + super().__init__() diff --git a/assets/src/data/scripts/bastd/tutorial.py b/assets/src/data/scripts/bastd/tutorial.py new file mode 100644 index 00000000..61bcc688 --- /dev/null +++ b/assets/src/data/scripts/bastd/tutorial.py @@ -0,0 +1,2401 @@ +"""Wrangles the game tutorial sequence.""" + +# Not too concerned with keeping this old module pretty; +# don't expect to be revisiting it. +# pylint: disable=too-many-branches +# pylint: disable=too-many-statements +# pylint: disable=too-many-lines +# pylint: disable=missing-function-docstring, missing-class-docstring +# pylint: disable=invalid-name +# pylint: disable=too-many-locals +# pylint: disable=unused-variable +# pylint: disable=unused-argument + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.actor import spaz as basespaz + +if TYPE_CHECKING: + from typing import (Any, Optional, Dict, List, Tuple, Callable, Sequence, + Union) + + +def _safesetattr(node: Optional[ba.Node], attr: str, value: Any) -> None: + if node: + setattr(node, attr, value) + + +class ButtonPress: + + def __init__(self, + button: str, + delay: int = 0, + release: bool = True, + release_delay: int = 0): + self._button = button + self._delay = delay + self._release = release + self._release_delay = release_delay + + def run(self, a: TutorialActivity) -> None: + s = a.current_spaz + assert s is not None + img: Optional[ba.Node] + release_call: Optional[Callable] + color: Optional[Sequence[float]] + if self._button == 'punch': + call = s.on_punch_press + release_call = s.on_punch_release + img = a.punch_image + color = a.punch_image_color + elif self._button == 'jump': + call = s.on_jump_press + release_call = s.on_jump_release + img = a.jump_image + color = a.jump_image_color + elif self._button == 'bomb': + call = s.on_bomb_press + release_call = s.on_bomb_release + img = a.bomb_image + color = a.bomb_image_color + elif self._button == 'pickUp': + call = s.on_pickup_press + release_call = s.on_pickup_release + img = a.pickup_image + color = a.pickup_image_color + elif self._button == 'run': + call = ba.Call(s.on_run, 1.0) + release_call = ba.Call(s.on_run, 0.0) + img = None + color = None + else: + raise Exception(f"invalid button: {self._button}") + + brightness = 4.0 + if color is not None: + c_bright = list(color) + c_bright[0] *= brightness + c_bright[1] *= brightness + c_bright[2] *= brightness + else: + c_bright = [1.0, 1.0, 1.0] + + if self._delay == 0: + call() + if img is not None: + img.color = c_bright + img.vr_depth = -40 + else: + ba.timer(self._delay, call, timeformat=ba.TimeFormat.MILLISECONDS) + if img is not None: + ba.timer(self._delay, + ba.Call(_safesetattr, img, 'color', c_bright), + timeformat=ba.TimeFormat.MILLISECONDS) + ba.timer(self._delay, + ba.Call(_safesetattr, img, 'vr_depth', -30), + timeformat=ba.TimeFormat.MILLISECONDS) + if self._release: + if self._delay == 0 and self._release_delay == 0: + release_call() + else: + ba.timer(0.001 * (self._delay + self._release_delay), + release_call) + if img is not None: + ba.timer(self._delay + self._release_delay + 100, + ba.Call(_safesetattr, img, 'color', color), + timeformat=ba.TimeFormat.MILLISECONDS) + ba.timer(self._delay + self._release_delay + 100, + ba.Call(_safesetattr, img, 'vr_depth', -20), + timeformat=ba.TimeFormat.MILLISECONDS) + + +class ButtonRelease: + + def __init__(self, button: str, delay: int = 0): + self._button = button + self._delay = delay + + def run(self, a: TutorialActivity) -> None: + s = a.current_spaz + assert s is not None + call: Optional[Callable] + img: Optional[ba.Node] + color: Optional[Sequence[float]] + if self._button == 'punch': + call = s.on_punch_release + img = a.punch_image + color = a.punch_image_color + elif self._button == 'jump': + call = s.on_jump_release + img = a.jump_image + color = a.jump_image_color + elif self._button == 'bomb': + call = s.on_bomb_release + img = a.bomb_image + color = a.bomb_image_color + elif self._button == 'pickUp': + call = s.on_pickup_press + img = a.pickup_image + color = a.pickup_image_color + elif self._button == 'run': + call = ba.Call(s.on_run, 0.0) + img = None + color = None + else: + raise Exception("invalid button: " + self._button) + if self._delay == 0: + call() + else: + ba.timer(self._delay, call, timeformat=ba.TimeFormat.MILLISECONDS) + if img is not None: + ba.timer(self._delay + 100, + ba.Call(_safesetattr, img, 'color', color), + timeformat=ba.TimeFormat.MILLISECONDS) + ba.timer(self._delay + 100, + ba.Call(_safesetattr, img, 'vr_depth', -20), + timeformat=ba.TimeFormat.MILLISECONDS) + + +class TutorialActivity(ba.Activity): + + def __init__(self, settings: Dict[str, Any] = None): + from bastd.maps import Rampage + if settings is None: + settings = {} + super().__init__(settings) + self.current_spaz: Optional[basespaz.Spaz] = None + self._benchmark_type = getattr(ba.getsession(), 'benchmark_type', None) + self.last_start_time: Optional[int] = None + self.cycle_times: List[int] = [] + self.allow_pausing = True + self.allow_kick_idle_players = False + self._issued_warning = False + self._map_type = Rampage + self._map_type.preload() + self._jump_button_tex = ba.gettexture('buttonJump') + self._pick_up_button_tex = ba.gettexture('buttonPickUp') + self._bomb_button_tex = ba.gettexture('buttonBomb') + self._punch_button_tex = ba.gettexture('buttonPunch') + self._r = 'tutorial' + self._have_skipped = False + self.stick_image_position_x = self.stick_image_position_y = 0.0 + self.spawn_sound = ba.getsound('spawn') + self._map: Optional[ba.Map] = None + self.text: Optional[ba.Node] = None + self._skip_text: Optional[ba.Node] = None + self._skip_count_text: Optional[ba.Node] = None + self._scale: Optional[float] = None + self._stick_base_position: Tuple[float, float] = (0.0, 0.0) + self._stick_nub_position: Tuple[float, float] = (0.0, 0.0) + self._stick_base_image_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0) + self._stick_nub_image_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0) + self._time: int = -1 + self.punch_image_color = (1.0, 1.0, 1.0) + self.punch_image: Optional[ba.Node] = None + self.bomb_image: Optional[ba.Node] = None + self.jump_image: Optional[ba.Node] = None + self.pickup_image: Optional[ba.Node] = None + self._stick_base_image: Optional[ba.Node] = None + self._stick_nub_image: Optional[ba.Node] = None + self.bomb_image_color = (1.0, 1.0, 1.0) + self.pickup_image_color = (1.0, 1.0, 1.0) + self.control_ui_nodes: List[ba.Node] = [] + self._test_file = '' + self.spazzes: Dict[int, basespaz.Spaz] = {} + self.jump_image_color = (1.0, 1.0, 1.0) + self._entries: List[Any] = [] + self._read_entries_timer: Optional[ba.Timer] = None + self._entry_timer: Optional[ba.Timer] = None + + def on_transition_in(self) -> None: + super().on_transition_in() + ba.setmusic('CharSelect', continuous=True) + self._map = self._map_type() + + def on_begin(self) -> None: + super().on_begin() + + ba.set_analytics_screen('Tutorial Start') + _ba.increment_analytics_count('Tutorial start') + + # noinspection PyUnreachableCode + if 0: # pylint: disable=using-constant-test + # Buttons on top. + text_y = 140 + buttons_y = 250 + else: + # Buttons on bottom. + text_y = 260 + buttons_y = 160 + + # Need different versions of this: taps/buttons/keys. + self.text = ba.newnode('text', + attrs={ + 'text': '', + 'scale': 1.9, + 'position': (0, text_y), + 'maxwidth': 500, + 'flatness': 0.0, + 'shadow': 0.5, + 'h_align': 'center', + 'v_align': 'center', + 'v_attach': 'center' + }) + + # Need different versions of this: taps/buttons/keys. + txt = ba.Lstr( + resource=self._r + + '.cpuBenchmarkText') if self._benchmark_type == 'cpu' else ba.Lstr( + resource=self._r + '.toSkipPressAnythingText') + t = self._skip_text = ba.newnode('text', + attrs={ + 'text': txt, + 'maxwidth': 900, + 'scale': 1.1, + 'vr_depth': 100, + 'position': (0, 30), + 'h_align': 'center', + 'v_align': 'center', + 'v_attach': 'bottom' + }) + ba.animate(t, 'opacity', {1.0: 0.0, 2.0: 0.7}) + self._skip_count_text = ba.newnode('text', + attrs={ + 'text': '', + 'scale': 1.4, + 'vr_depth': 90, + 'position': (0, 70), + 'h_align': 'center', + 'v_align': 'center', + 'v_attach': 'bottom' + }) + + ouya = False + + self._scale = scale = 0.6 + center_offs = 130.0 * scale + offs = 65.0 * scale + position = (0, buttons_y) + image_size = 90.0 * scale + image_size_2 = 220.0 * scale + nub_size = 110.0 * scale + p = (position[0] + center_offs, position[1] - offs) + + def _sc(r: float, g: float, b: float) -> Tuple[float, float, float]: + return 0.6 * r, 0.6 * g, 0.6 * b + + self.jump_image_color = c = _sc(0.4, 1, 0.4) + self.jump_image = ba.newnode('image', + attrs={ + 'texture': self._jump_button_tex, + 'absolute_scale': True, + 'vr_depth': -20, + 'position': p, + 'scale': (image_size, image_size), + 'color': c + }) + p = (position[0] + center_offs - offs, position[1]) + self.punch_image_color = c = _sc(0.2, 0.6, 1) if ouya else _sc( + 1, 0.7, 0.3) + self.punch_image = ba.newnode( + 'image', + attrs={ + 'texture': ba.gettexture('buttonPunch'), + 'absolute_scale': True, + 'vr_depth': -20, + 'position': p, + 'scale': (image_size, image_size), + 'color': c + }) + p = (position[0] + center_offs + offs, position[1]) + self.bomb_image_color = c = _sc(1, 0.3, 0.3) + self.bomb_image = ba.newnode( + 'image', + attrs={ + 'texture': ba.gettexture('buttonBomb'), + 'absolute_scale': True, + 'vr_depth': -20, + 'position': p, + 'scale': (image_size, image_size), + 'color': c + }) + p = (position[0] + center_offs, position[1] + offs) + self.pickup_image_color = c = _sc(1, 0.8, 0.3) if ouya else _sc( + 0.5, 0.5, 1) + self.pickup_image = ba.newnode( + 'image', + attrs={ + 'texture': ba.gettexture('buttonPickUp'), + 'absolute_scale': True, + 'vr_depth': -20, + 'position': p, + 'scale': (image_size, image_size), + 'color': c + }) + + self._stick_base_position = p = (position[0] - center_offs, + position[1]) + self._stick_base_image_color = c2 = (0.25, 0.25, 0.25, 1.0) + self._stick_base_image = ba.newnode( + 'image', + attrs={ + 'texture': ba.gettexture('nub'), + 'absolute_scale': True, + 'vr_depth': -40, + 'position': p, + 'scale': (image_size_2, image_size_2), + 'color': c2 + }) + self._stick_nub_position = p = (position[0] - center_offs, position[1]) + self._stick_nub_image_color = c3 = (0.4, 0.4, 0.4, 1.0) + self._stick_nub_image = ba.newnode('image', + attrs={ + 'texture': ba.gettexture('nub'), + 'absolute_scale': True, + 'position': p, + 'scale': (nub_size, nub_size), + 'color': c3 + }) + self.control_ui_nodes = [ + self.jump_image, self.punch_image, self.bomb_image, + self.pickup_image, self._stick_base_image, self._stick_nub_image + ] + for n in self.control_ui_nodes: + n.opacity = 0.0 + self._test_file = ('/Users/ericf/Library/Containers/' + 'net.froemling.ballisticacore/Data/' + 'Library/Application Support/Ballisticacore/foo.py') + self._read_entries() + + def set_stick_image_position(self, x: float, y: float) -> None: + + # Clamp this to a circle. + len_squared = x * x + y * y + if len_squared > 1.0: + length = math.sqrt(len_squared) + mult = 1.0 / length + x *= mult + y *= mult + + self.stick_image_position_x = x + self.stick_image_position_y = y + offs = 50.0 + assert self._scale is not None + p = [ + self._stick_nub_position[0] + x * offs * self._scale, + self._stick_nub_position[1] + y * offs * self._scale + ] + c = list(self._stick_nub_image_color) + if abs(x) > 0.1 or abs(y) > 0.1: + c[0] *= 2.0 + c[1] *= 4.0 + c[2] *= 2.0 + assert self._stick_nub_image is not None + self._stick_nub_image.position = p + self._stick_nub_image.color = c + c = list(self._stick_base_image_color) + if abs(x) > 0.1 or abs(y) > 0.1: + c[0] *= 1.5 + c[1] *= 1.5 + c[2] *= 1.5 + assert self._stick_base_image is not None + self._stick_base_image.color = c + + def _read_entries(self) -> None: + try: + + class Reset: + + def __init__(self) -> None: + pass + + def run(self, a: TutorialActivity) -> None: + + # if we're looping, print out how long each cycle took + # print out how long each cycle took.. + if a.last_start_time is not None: + tval = ba.time( + ba.TimeType.REAL, + ba.TimeFormat.MILLISECONDS) - a.last_start_time + assert isinstance(tval, int) + diff = tval + a.cycle_times.append(diff) + ba.screenmessage( + "cycle time: " + str(diff) + " (average: " + + str(sum(a.cycle_times) / len(a.cycle_times)) + ")") + tval = ba.time(ba.TimeType.REAL, + ba.TimeFormat.MILLISECONDS) + assert isinstance(tval, int) + a.last_start_time = tval + + assert a.text + a.text.text = '' + for spaz in list(a.spazzes.values()): + spaz.handlemessage(ba.DieMessage(immediate=True)) + a.spazzes = {} + a.current_spaz = None + for n in a.control_ui_nodes: + n.opacity = 0.0 + a.set_stick_image_position(0, 0) + + # noinspection PyUnusedLocal + class SetSpeed: + + def __init__(self, speed: int): + self._speed = speed + + def run(self, a: TutorialActivity) -> None: + print('setting to', self._speed) + _ba.set_debug_speed_exponent(self._speed) + + class RemoveGloves: + + def __init__(self) -> None: + pass + + def run(self, a: TutorialActivity) -> None: + # pylint: disable=protected-access + assert a.current_spaz is not None + # noinspection PyProtectedMember + a.current_spaz._gloves_wear_off() + + class KillSpaz: + + def __init__(self, num: int, explode: bool = False): + self._num = num + self._explode = explode + + def run(self, a: TutorialActivity) -> None: + if self._explode: + a.spazzes[self._num].shatter() + del a.spazzes[self._num] + + class SpawnSpaz: + + def __init__(self, + num: int, + position: Sequence[float], + color: Sequence[float] = (1.0, 1.0, 1.0), + make_current: bool = False, + relative_to: int = None, + name: Union[str, ba.Lstr] = '', + flash: bool = True, + angle: float = 0.0): + self._num = num + self._position = position + self._make_current = make_current + self._color = color + self._relative_to = relative_to + self._name = name + self._flash = flash + self._angle = angle + + def run(self, a: TutorialActivity) -> None: + + # if they gave a 'relative to' spaz, position is relative + # to them + pos: Sequence[float] + if self._relative_to is not None: + snode = a.spazzes[self._relative_to].node + assert snode + their_pos = snode.position + pos = (their_pos[0] + self._position[0], + their_pos[1] + self._position[1], + their_pos[2] + self._position[2]) + else: + pos = self._position + + # if there's already a spaz at this spot, insta-kill it + if self._num in a.spazzes: + a.spazzes[self._num].handlemessage( + ba.DieMessage(immediate=True)) + + s = a.spazzes[self._num] = basespaz.Spaz( + color=self._color, + start_invincible=self._flash, + demo_mode=True) + + # FIXME: Should extend spaz to support Lstr names. + assert s.node + if isinstance(self._name, ba.Lstr): + s.node.name = self._name.evaluate() + else: + s.node.name = self._name + s.node.name_color = self._color + s.handlemessage(ba.StandMessage(pos, self._angle)) + if self._make_current: + a.current_spaz = s + if self._flash: + ba.playsound(a.spawn_sound, position=pos) + + # noinspection PyUnusedLocal + class Powerup: + + def __init__(self, + num: int, + position: Sequence[float], + color: Sequence[float] = (1.0, 1.0, 1.0), + make_current: bool = False, + relative_to: int = None): + self._position = position + self._relative_to = relative_to + + def run(self, a: TutorialActivity) -> None: + # If they gave a 'relative to' spaz, position is relative + # to them. + pos: Sequence[float] + if self._relative_to is not None: + snode = a.spazzes[self._relative_to].node + assert snode + their_pos = snode.position + pos = (their_pos[0] + self._position[0], + their_pos[1] + self._position[1], + their_pos[2] + self._position[2]) + else: + pos = self._position + from bastd.actor import powerupbox + powerupbox.PowerupBox(position=pos, + poweruptype='punch').autoretain() + + # noinspection PyUnusedLocal + class Delay: + + def __init__(self, time: int) -> None: + self._time = time + + def run(self, a: TutorialActivity) -> int: + return self._time + + # noinspection PyUnusedLocal + class AnalyticsScreen: + + def __init__(self, screen: str) -> None: + self._screen = screen + + def run(self, a: TutorialActivity) -> None: + ba.set_analytics_screen(self._screen) + + # noinspection PyUnusedLocal + class DelayOld: + + def __init__(self, time: int) -> None: + self._time = time + + def run(self, a: TutorialActivity) -> int: + return int(0.9 * self._time) + + # noinspection PyUnusedLocal + class DelayOld2: + + def __init__(self, time: int) -> None: + self._time = time + + def run(self, a: TutorialActivity) -> int: + return int(0.8 * self._time) + + class End: + + def __init__(self) -> None: + pass + + def run(self, a: TutorialActivity) -> None: + _ba.increment_analytics_count('Tutorial finish') + a.end() + + class Move: + + def __init__(self, x: float, y: float): + self._x = float(x) + self._y = float(y) + + def run(self, a: TutorialActivity) -> None: + s = a.current_spaz + assert s + # FIXME: Game should take floats for this. + x_clamped = self._x + y_clamped = self._y + s.on_move_left_right(x_clamped) + s.on_move_up_down(y_clamped) + a.set_stick_image_position(self._x, self._y) + + class MoveLR: + + def __init__(self, x: float): + self._x = float(x) + + def run(self, a: TutorialActivity) -> None: + s = a.current_spaz + assert s + # FIXME: Game should take floats for this. + x_clamped = self._x + s.on_move_left_right(x_clamped) + a.set_stick_image_position(self._x, + a.stick_image_position_y) + + class MoveUD: + + def __init__(self, y: float): + self._y = float(y) + + def run(self, a: TutorialActivity) -> None: + s = a.current_spaz + assert s + # FIXME: Game should take floats for this. + y_clamped = self._y + s.on_move_up_down(y_clamped) + a.set_stick_image_position(a.stick_image_position_x, + self._y) + + class Bomb(ButtonPress): + + def __init__(self, + delay: int = 0, + release: bool = True, + release_delay: int = 500): + ButtonPress.__init__(self, + 'bomb', + delay=delay, + release=release, + release_delay=release_delay) + + class Jump(ButtonPress): + + def __init__(self, + delay: int = 0, + release: bool = True, + release_delay: int = 500): + ButtonPress.__init__(self, + 'jump', + delay=delay, + release=release, + release_delay=release_delay) + + class Punch(ButtonPress): + + def __init__(self, + delay: int = 0, + release: bool = True, + release_delay: int = 500): + ButtonPress.__init__(self, + 'punch', + delay=delay, + release=release, + release_delay=release_delay) + + class PickUp(ButtonPress): + + def __init__(self, + delay: int = 0, + release: bool = True, + release_delay: int = 500): + ButtonPress.__init__(self, + 'pickUp', + delay=delay, + release=release, + release_delay=release_delay) + + class Run(ButtonPress): + + def __init__(self, + delay: int = 0, + release: bool = True, + release_delay: int = 500): + ButtonPress.__init__(self, + 'run', + delay=delay, + release=release, + release_delay=release_delay) + + class BombRelease(ButtonRelease): + + def __init__(self, delay: int = 0): + super().__init__('bomb', delay=delay) + + class JumpRelease(ButtonRelease): + + def __init__(self, delay: int = 0): + super().__init__('jump', delay=delay) + + class PunchRelease(ButtonRelease): + + def __init__(self, delay: int = 0): + super().__init__('punch', delay=delay) + + # noinspection PyUnusedLocal + class PickUpRelease(ButtonRelease): + + def __init__(self, delay: int = 0): + super().__init__('pickUp', delay=delay) + + class RunRelease(ButtonRelease): + + def __init__(self, delay: int = 0): + super().__init__('run', delay=delay) + + class ShowControls: + + def __init__(self) -> None: + pass + + def run(self, a: TutorialActivity) -> None: + for n in a.control_ui_nodes: + ba.animate(n, 'opacity', {0.0: 0.0, 1.0: 1.0}) + + class Text: + + def __init__(self, text: Union[str, ba.Lstr]): + self.text = text + + def run(self, a: TutorialActivity) -> None: + assert a.text + a.text.text = self.text + + # noinspection PyUnusedLocal + class PrintPos: + + def __init__(self, spaz_num: int = None): + self._spaz_num = spaz_num + + def run(self, a: TutorialActivity) -> None: + if self._spaz_num is None: + s = a.current_spaz + else: + s = a.spazzes[self._spaz_num] + assert s and s.node + t = list(s.node.position) + print('RestorePos(' + str((t[0], t[1] - 1.0, t[2])) + '),') + + # noinspection PyUnusedLocal + class RestorePos: + + def __init__(self, pos: Sequence[float]) -> None: + self._pos = pos + + def run(self, a: TutorialActivity) -> None: + s = a.current_spaz + assert s + s.handlemessage(ba.StandMessage(self._pos, 0)) + + class Celebrate: + + def __init__(self, + celebrate_type: str = 'both', + spaz_num: int = None, + duration: int = 1000): + self._spaz_num = spaz_num + self._celebrate_type = celebrate_type + self._duration = duration + + def run(self, a: TutorialActivity) -> None: + if self._spaz_num is None: + s = a.current_spaz + else: + s = a.spazzes[self._spaz_num] + assert s and s.node + if self._celebrate_type == 'right': + s.node.handlemessage('celebrate_r', self._duration) + elif self._celebrate_type == 'left': + s.node.handlemessage('celebrate_l', self._duration) + elif self._celebrate_type == 'both': + s.node.handlemessage('celebrate', self._duration) + else: + raise Exception("invalid celebrate type " + + self._celebrate_type) + + self._entries = [ + Reset(), + SpawnSpaz(0, (0, 5.5, -3.0), make_current=True), + DelayOld(1000), + AnalyticsScreen('Tutorial Section 1'), + Text(ba.Lstr(resource=self._r + '.phrase01Text')), # hi there + Celebrate('left'), + DelayOld(2000), + Text( + ba.Lstr(resource=self._r + '.phrase02Text', + subs=[ + ('${APP_NAME}', ba.Lstr(resource='titleText')) + ])), # welcome to ballisticacore + DelayOld(80), + Run(release=False), + Jump(release=False), + MoveLR(1), + MoveUD(0), + DelayOld(70), + RunRelease(), + JumpRelease(), + DelayOld(60), + MoveUD(1), + DelayOld(30), + MoveLR(0), + DelayOld(90), + MoveLR(-1), + DelayOld(20), + MoveUD(0), + DelayOld(70), + MoveUD(-1), + DelayOld(20), + MoveLR(0), + DelayOld(80), + MoveUD(0), + DelayOld(1500), + Text(ba.Lstr(resource=self._r + + '.phrase03Text')), # here's a few tips + DelayOld(1000), + ShowControls(), + DelayOld(1000), + Jump(), + DelayOld(1000), + Jump(), + DelayOld(1000), + AnalyticsScreen('Tutorial Section 2'), + Text( + ba.Lstr(resource=self._r + '.phrase04Text', + subs=[ + ('${APP_NAME}', ba.Lstr(resource='titleText')) + ])), # many things are based on physics + DelayOld(20), + MoveUD(0), + DelayOld(60), + MoveLR(0), + DelayOld(10), + MoveLR(0), + MoveUD(0), + DelayOld(10), + MoveLR(0), + MoveUD(0), + DelayOld(20), + MoveUD(-0.0575579), + DelayOld(10), + MoveUD(-0.207831), + DelayOld(30), + MoveUD(-0.309793), + DelayOld(10), + MoveUD(-0.474502), + DelayOld(10), + MoveLR(0.00390637), + MoveUD(-0.647053), + DelayOld(20), + MoveLR(-0.0745262), + MoveUD(-0.819605), + DelayOld(10), + MoveLR(-0.168645), + MoveUD(-0.937254), + DelayOld(30), + MoveLR(-0.294137), + MoveUD(-1), + DelayOld(10), + MoveLR(-0.411786), + DelayOld(10), + MoveLR(-0.639241), + DelayOld(30), + MoveLR(-0.75689), + DelayOld(10), + MoveLR(-0.905911), + DelayOld(20), + MoveLR(-1), + DelayOld(50), + MoveUD(-0.960784), + DelayOld(20), + MoveUD(-0.819605), + MoveUD(-0.61568), + DelayOld(20), + MoveUD(-0.427442), + DelayOld(20), + MoveUD(-0.231361), + DelayOld(10), + MoveUD(-0.00390637), + DelayOld(30), + MoveUD(0.333354), + MoveUD(0.584338), + DelayOld(20), + MoveUD(0.764733), + DelayOld(30), + MoveLR(-0.803949), + MoveUD(0.913755), + DelayOld(10), + MoveLR(-0.647084), + MoveUD(0.992187), + DelayOld(20), + MoveLR(-0.435316), + MoveUD(1), + DelayOld(20), + MoveLR(-0.168645), + MoveUD(0.976501), + MoveLR(0.0744957), + MoveUD(0.905911), + DelayOld(20), + MoveLR(0.270577), + MoveUD(0.843165), + DelayOld(20), + MoveLR(0.435286), + MoveUD(0.780419), + DelayOld(10), + MoveLR(0.66274), + MoveUD(0.647084), + DelayOld(30), + MoveLR(0.803919), + MoveUD(0.458846), + MoveLR(0.929411), + MoveUD(0.223548), + DelayOld(20), + MoveLR(0.95294), + MoveUD(0.137272), + DelayOld(20), + MoveLR(1), + MoveUD(-0.0509659), + DelayOld(20), + MoveUD(-0.247047), + DelayOld(20), + MoveUD(-0.443129), + DelayOld(20), + MoveUD(-0.694113), + MoveUD(-0.921567), + DelayOld(30), + MoveLR(0.858821), + MoveUD(-1), + DelayOld(10), + MoveLR(0.68627), + DelayOld(10), + MoveLR(0.364696), + DelayOld(20), + MoveLR(0.0509659), + DelayOld(20), + MoveLR(-0.223548), + DelayOld(10), + MoveLR(-0.600024), + MoveUD(-0.913724), + DelayOld(30), + MoveLR(-0.858852), + MoveUD(-0.717643), + MoveLR(-1), + MoveUD(-0.474502), + DelayOld(20), + MoveUD(-0.396069), + DelayOld(20), + MoveUD(-0.286264), + DelayOld(20), + MoveUD(-0.137242), + DelayOld(20), + MoveUD(0.0353099), + DelayOld(10), + MoveUD(0.32551), + DelayOld(20), + MoveUD(0.592181), + DelayOld(10), + MoveUD(0.851009), + DelayOld(10), + MoveUD(1), + DelayOld(30), + MoveLR(-0.764733), + DelayOld(20), + MoveLR(-0.403943), + MoveLR(-0.145116), + DelayOld(30), + MoveLR(0.0901822), + MoveLR(0.32548), + DelayOld(30), + MoveLR(0.560778), + MoveUD(0.929441), + DelayOld(20), + MoveLR(0.709799), + MoveUD(0.73336), + MoveLR(0.803919), + MoveUD(0.545122), + DelayOld(20), + MoveLR(0.882351), + MoveUD(0.356883), + DelayOld(10), + MoveLR(0.968627), + MoveUD(0.113742), + DelayOld(20), + MoveLR(0.992157), + MoveUD(-0.0823389), + DelayOld(30), + MoveUD(-0.309793), + DelayOld(10), + MoveUD(-0.545091), + DelayOld(20), + MoveLR(0.882351), + MoveUD(-0.874508), + DelayOld(20), + MoveLR(0.756859), + MoveUD(-1), + DelayOld(10), + MoveLR(0.576464), + DelayOld(20), + MoveLR(0.254891), + DelayOld(10), + MoveLR(-0.0274667), + DelayOld(10), + MoveLR(-0.356883), + DelayOld(30), + MoveLR(-0.592181), + MoveLR(-0.827479), + MoveUD(-0.921567), + DelayOld(20), + MoveLR(-1), + MoveUD(-0.749016), + DelayOld(20), + MoveUD(-0.61568), + DelayOld(10), + MoveUD(-0.403912), + DelayOld(20), + MoveUD(-0.207831), + DelayOld(10), + MoveUD(0.121586), + DelayOld(30), + MoveUD(0.34904), + DelayOld(10), + MoveUD(0.560808), + DelayOld(10), + MoveUD(0.827479), + DelayOld(30), + MoveUD(1), + DelayOld(20), + MoveLR(-0.976501), + MoveLR(-0.670614), + DelayOld(20), + MoveLR(-0.239235), + DelayOld(20), + MoveLR(0.160772), + DelayOld(20), + MoveLR(0.443129), + DelayOld(10), + MoveLR(0.68627), + MoveUD(0.976501), + DelayOld(30), + MoveLR(0.929411), + MoveUD(0.73336), + MoveLR(1), + MoveUD(0.482376), + DelayOld(20), + MoveUD(0.34904), + DelayOld(10), + MoveUD(0.160802), + DelayOld(30), + MoveUD(-0.0744957), + DelayOld(10), + MoveUD(-0.333323), + DelayOld(20), + MoveUD(-0.647053), + DelayOld(20), + MoveUD(-0.937254), + DelayOld(10), + MoveLR(0.858821), + MoveUD(-1), + DelayOld(10), + MoveLR(0.576464), + DelayOld(30), + MoveLR(0.184301), + DelayOld(10), + MoveLR(-0.121586), + DelayOld(10), + MoveLR(-0.474532), + DelayOld(30), + MoveLR(-0.670614), + MoveLR(-0.851009), + DelayOld(30), + MoveLR(-1), + MoveUD(-0.968627), + DelayOld(20), + MoveUD(-0.843135), + DelayOld(10), + MoveUD(-0.631367), + DelayOld(20), + MoveUD(-0.403912), + MoveUD(-0.176458), + DelayOld(20), + MoveUD(0.0902127), + DelayOld(20), + MoveUD(0.380413), + DelayOld(10), + MoveUD(0.717673), + DelayOld(30), + MoveUD(1), + DelayOld(10), + MoveLR(-0.741203), + DelayOld(20), + MoveLR(-0.458846), + DelayOld(10), + MoveLR(-0.145116), + DelayOld(10), + MoveLR(0.0980255), + DelayOld(20), + MoveLR(0.294107), + DelayOld(30), + MoveLR(0.466659), + MoveLR(0.717643), + MoveUD(0.796106), + DelayOld(20), + MoveLR(0.921567), + MoveUD(0.443159), + DelayOld(20), + MoveLR(1), + MoveUD(0.145116), + DelayOld(10), + MoveUD(-0.0274361), + DelayOld(30), + MoveUD(-0.223518), + MoveUD(-0.427442), + DelayOld(20), + MoveUD(-0.874508), + DelayOld(20), + MoveUD(-1), + DelayOld(10), + MoveLR(0.929411), + DelayOld(20), + MoveLR(0.68627), + DelayOld(20), + MoveLR(0.364696), + DelayOld(20), + MoveLR(0.0431227), + DelayOld(10), + MoveLR(-0.333354), + DelayOld(20), + MoveLR(-0.639241), + DelayOld(20), + MoveLR(-0.968657), + MoveUD(-0.968627), + DelayOld(20), + MoveLR(-1), + MoveUD(-0.890194), + MoveUD(-0.866665), + DelayOld(20), + MoveUD(-0.749016), + DelayOld(20), + MoveUD(-0.529405), + DelayOld(20), + MoveUD(-0.30195), + DelayOld(10), + MoveUD(-0.00390637), + DelayOld(10), + MoveUD(0.262764), + DelayOld(30), + MoveLR(-0.600024), + MoveUD(0.458846), + DelayOld(10), + MoveLR(-0.294137), + MoveUD(0.482376), + DelayOld(20), + MoveLR(-0.200018), + MoveUD(0.505905), + DelayOld(10), + MoveLR(-0.145116), + MoveUD(0.545122), + DelayOld(20), + MoveLR(-0.0353099), + MoveUD(0.584338), + DelayOld(20), + MoveLR(0.137242), + MoveUD(0.592181), + DelayOld(20), + MoveLR(0.30195), + DelayOld(10), + MoveLR(0.490188), + DelayOld(10), + MoveLR(0.599994), + MoveUD(0.529435), + DelayOld(30), + MoveLR(0.66274), + MoveUD(0.3961), + DelayOld(20), + MoveLR(0.670583), + MoveUD(0.231391), + MoveLR(0.68627), + MoveUD(0.0745262), + Move(0, -0.01), + DelayOld(100), + Move(0, 0), + DelayOld(1000), + Text(ba.Lstr(resource=self._r + + '.phrase05Text')), # for example when you punch.. + DelayOld(510), + Move(0, -0.01), + DelayOld(100), + Move(0, 0), + DelayOld(500), + SpawnSpaz(0, (-0.09249162673950195, 4.337906360626221, -2.3), + make_current=True, + flash=False), + SpawnSpaz(1, (-3.1, 4.3, -2.0), + make_current=False, + color=(1, 1, 0.4), + name=ba.Lstr(resource=self._r + '.randomName1Text')), + Move(-1.0, 0), + DelayOld(1050), + Move(0, -0.01), + DelayOld(100), + Move(0, 0), + DelayOld(1000), + Text(ba.Lstr(resource=self._r + + '.phrase06Text')), # your damage is based + DelayOld(1200), + Move(-0.05, 0), + DelayOld(200), + Punch(), + DelayOld(800), + Punch(), + DelayOld(800), + Punch(), + DelayOld(800), + Move(0, -0.01), + DelayOld(100), + Move(0, 0), + Text( + ba.Lstr(resource=self._r + '.phrase07Text', + subs=[('${NAME}', + ba.Lstr(resource=self._r + + '.randomName1Text')) + ])), # see that didn't hurt fred + DelayOld(2000), + Celebrate('right', spaz_num=1), + DelayOld(1400), + Text(ba.Lstr( + resource=self._r + + '.phrase08Text')), # lets jump and spin to get more speed + DelayOld(30), + MoveLR(0), + DelayOld(40), + MoveLR(0), + DelayOld(40), + MoveLR(0), + DelayOld(130), + MoveLR(0), + DelayOld(100), + MoveLR(0), + DelayOld(10), + MoveLR(0.0480667), + DelayOld(40), + MoveLR(0.056093), + MoveLR(0.0681173), + DelayOld(30), + MoveLR(0.0801416), + DelayOld(10), + MoveLR(0.184301), + DelayOld(10), + MoveLR(0.207831), + DelayOld(20), + MoveLR(0.231361), + DelayOld(30), + MoveLR(0.239204), + DelayOld(30), + MoveLR(0.254891), + DelayOld(40), + MoveLR(0.270577), + DelayOld(10), + MoveLR(0.30195), + DelayOld(20), + MoveLR(0.341166), + DelayOld(30), + MoveLR(0.388226), + MoveLR(0.435286), + DelayOld(30), + MoveLR(0.490188), + DelayOld(10), + MoveLR(0.560778), + DelayOld(20), + MoveLR(0.599994), + DelayOld(10), + MoveLR(0.647053), + DelayOld(10), + MoveLR(0.68627), + DelayOld(30), + MoveLR(0.733329), + DelayOld(20), + MoveLR(0.764702), + DelayOld(10), + MoveLR(0.827448), + DelayOld(20), + MoveLR(0.874508), + DelayOld(20), + MoveLR(0.929411), + DelayOld(10), + MoveLR(1), + DelayOld(830), + MoveUD(0.0274667), + DelayOld(10), + MoveLR(0.95294), + MoveUD(0.113742), + DelayOld(30), + MoveLR(0.780389), + MoveUD(0.184332), + DelayOld(10), + MoveLR(0.27842), + MoveUD(0.0745262), + DelayOld(20), + MoveLR(0), + MoveUD(0), + DelayOld(390), + MoveLR(0), + MoveLR(0), + DelayOld(20), + MoveLR(0), + DelayOld(20), + MoveLR(0), + DelayOld(10), + MoveLR(-0.0537431), + DelayOld(20), + MoveLR(-0.215705), + DelayOld(30), + MoveLR(-0.388256), + MoveLR(-0.529435), + DelayOld(30), + MoveLR(-0.694143), + DelayOld(20), + MoveLR(-0.851009), + MoveUD(0.0588397), + DelayOld(10), + MoveLR(-1), + MoveUD(0.0745262), + Run(release=False), + DelayOld(200), + MoveUD(0.0509964), + DelayOld(30), + MoveUD(0.0117801), + DelayOld(20), + MoveUD(-0.0901822), + MoveUD(-0.372539), + DelayOld(30), + MoveLR(-0.898068), + MoveUD(-0.890194), + Jump(release=False), + DelayOld(20), + MoveLR(-0.647084), + MoveUD(-1), + MoveLR(-0.427473), + DelayOld(20), + MoveLR(-0.00393689), + DelayOld(10), + MoveLR(0.537248), + DelayOld(30), + MoveLR(1), + DelayOld(50), + RunRelease(), + JumpRelease(), + DelayOld(50), + MoveUD(-0.921567), + MoveUD(-0.749016), + DelayOld(30), + MoveUD(-0.552934), + DelayOld(10), + MoveUD(-0.247047), + DelayOld(20), + MoveUD(0.200018), + DelayOld(20), + MoveUD(0.670614), + MoveUD(1), + DelayOld(70), + MoveLR(0.97647), + DelayOld(20), + MoveLR(0.764702), + DelayOld(20), + MoveLR(0.364696), + DelayOld(20), + MoveLR(0.00390637), + MoveLR(-0.309824), + DelayOld(20), + MoveLR(-0.576495), + DelayOld(30), + MoveLR(-0.898068), + DelayOld(10), + MoveLR(-1), + MoveUD(0.905911), + DelayOld(20), + MoveUD(0.498062), + DelayOld(20), + MoveUD(0.0274667), + MoveUD(-0.403912), + DelayOld(20), + MoveUD(-1), + Run(release=False), + Jump(release=False), + DelayOld(10), + Punch(release=False), + DelayOld(70), + JumpRelease(), + DelayOld(110), + MoveLR(-0.976501), + RunRelease(), + PunchRelease(), + DelayOld(10), + MoveLR(-0.952971), + DelayOld(20), + MoveLR(-0.905911), + MoveLR(-0.827479), + DelayOld(20), + MoveLR(-0.75689), + DelayOld(30), + MoveLR(-0.73336), + MoveLR(-0.694143), + DelayOld(20), + MoveLR(-0.670614), + DelayOld(30), + MoveLR(-0.66277), + DelayOld(10), + MoveUD(-0.960784), + DelayOld(20), + MoveLR(-0.623554), + MoveUD(-0.874508), + DelayOld(10), + MoveLR(-0.545122), + MoveUD(-0.694113), + DelayOld(20), + MoveLR(-0.505905), + MoveUD(-0.474502), + DelayOld(20), + MoveLR(-0.458846), + MoveUD(-0.356853), + MoveLR(-0.364727), + MoveUD(-0.27842), + DelayOld(20), + MoveLR(0.00390637), + Move(0, 0), + DelayOld(1000), + Text(ba.Lstr(resource=self._r + + '.phrase09Text')), # ah that's better + DelayOld(1900), + AnalyticsScreen('Tutorial Section 3'), + Text(ba.Lstr(resource=self._r + + '.phrase10Text')), # running also helps + DelayOld(100), + SpawnSpaz(0, (-3.2, 4.3, -4.4), make_current=True, + flash=False), + SpawnSpaz(1, (3.3, 4.2, -5.8), + make_current=False, + color=(0.9, 0.5, 1.0), + name=ba.Lstr(resource=self._r + '.randomName2Text')), + DelayOld(1800), + Text(ba.Lstr(resource=self._r + + '.phrase11Text')), # hold ANY button to run + DelayOld(300), + MoveUD(0), + DelayOld(20), + MoveUD(-0.0520646), + DelayOld(20), + MoveLR(0), + MoveUD(-0.223518), + Run(release=False), + Jump(release=False), + DelayOld(10), + MoveLR(0.0980255), + MoveUD(-0.309793), + DelayOld(30), + MoveLR(0.160772), + MoveUD(-0.427442), + DelayOld(20), + MoveLR(0.231361), + MoveUD(-0.545091), + DelayOld(10), + MoveLR(0.317637), + MoveUD(-0.678426), + DelayOld(20), + MoveLR(0.396069), + MoveUD(-0.819605), + MoveLR(0.482345), + MoveUD(-0.913724), + DelayOld(20), + MoveLR(0.560778), + MoveUD(-1), + DelayOld(20), + MoveLR(0.607837), + DelayOld(10), + MoveLR(0.623524), + DelayOld(30), + MoveLR(0.647053), + DelayOld(20), + MoveLR(0.670583), + MoveLR(0.694113), + DelayOld(30), + MoveLR(0.733329), + DelayOld(20), + MoveLR(0.764702), + MoveLR(0.788232), + DelayOld(20), + MoveLR(0.827448), + DelayOld(10), + MoveLR(0.858821), + DelayOld(20), + MoveLR(0.921567), + DelayOld(30), + MoveLR(0.97647), + MoveLR(1), + DelayOld(130), + MoveUD(-0.960784), + DelayOld(20), + MoveUD(-0.921567), + DelayOld(30), + MoveUD(-0.866665), + MoveUD(-0.819605), + DelayOld(30), + MoveUD(-0.772546), + MoveUD(-0.725486), + DelayOld(30), + MoveUD(-0.631367), + DelayOld(10), + MoveUD(-0.552934), + DelayOld(20), + MoveUD(-0.474502), + DelayOld(10), + MoveUD(-0.403912), + DelayOld(30), + MoveUD(-0.356853), + DelayOld(30), + MoveUD(-0.34901), + DelayOld(20), + MoveUD(-0.333323), + DelayOld(20), + MoveUD(-0.32548), + DelayOld(10), + MoveUD(-0.30195), + DelayOld(20), + MoveUD(-0.27842), + DelayOld(30), + MoveUD(-0.254891), + MoveUD(-0.231361), + DelayOld(30), + MoveUD(-0.207831), + DelayOld(20), + MoveUD(-0.199988), + MoveUD(-0.176458), + DelayOld(30), + MoveUD(-0.137242), + MoveUD(-0.0823389), + DelayOld(20), + MoveUD(-0.0274361), + DelayOld(20), + MoveUD(0.00393689), + DelayOld(40), + MoveUD(0.0353099), + DelayOld(20), + MoveUD(0.113742), + DelayOld(10), + MoveUD(0.137272), + DelayOld(20), + MoveUD(0.160802), + MoveUD(0.184332), + DelayOld(20), + MoveUD(0.207862), + DelayOld(30), + MoveUD(0.247078), + MoveUD(0.262764), + DelayOld(20), + MoveUD(0.270608), + DelayOld(30), + MoveUD(0.294137), + MoveUD(0.32551), + DelayOld(30), + MoveUD(0.37257), + Celebrate('left', 1), + DelayOld(20), + MoveUD(0.498062), + MoveUD(0.560808), + DelayOld(30), + MoveUD(0.654927), + MoveUD(0.694143), + DelayOld(30), + MoveUD(0.741203), + DelayOld(20), + MoveUD(0.780419), + MoveUD(0.819636), + DelayOld(20), + MoveUD(0.843165), + DelayOld(20), + MoveUD(0.882382), + DelayOld(10), + MoveUD(0.913755), + DelayOld(30), + MoveUD(0.968657), + MoveUD(1), + DelayOld(560), + Punch(release=False), + DelayOld(210), + MoveUD(0.968657), + DelayOld(30), + MoveUD(0.75689), + PunchRelease(), + DelayOld(20), + MoveLR(0.95294), + MoveUD(0.435316), + RunRelease(), + JumpRelease(), + MoveLR(0.811762), + MoveUD(0.270608), + DelayOld(20), + MoveLR(0.670583), + MoveUD(0.160802), + DelayOld(20), + MoveLR(0.466659), + MoveUD(0.0588397), + DelayOld(10), + MoveLR(0.317637), + MoveUD(-0.00390637), + DelayOld(20), + MoveLR(0.0801416), + DelayOld(10), + MoveLR(0), + DelayOld(20), + MoveLR(0), + DelayOld(30), + MoveLR(0), + DelayOld(30), + MoveLR(0), + DelayOld(20), + MoveLR(0), + DelayOld(100), + MoveLR(0), + DelayOld(30), + MoveUD(0), + DelayOld(30), + MoveUD(0), + DelayOld(50), + MoveUD(0), + MoveUD(0), + DelayOld(30), + MoveLR(0), + MoveUD(-0.0520646), + MoveLR(0), + MoveUD(-0.0640889), + DelayOld(20), + MoveLR(0), + MoveUD(-0.0881375), + DelayOld(30), + MoveLR(-0.0498978), + MoveUD(-0.199988), + MoveLR(-0.121586), + MoveUD(-0.207831), + DelayOld(20), + MoveLR(-0.145116), + MoveUD(-0.223518), + DelayOld(30), + MoveLR(-0.152959), + MoveUD(-0.231361), + MoveLR(-0.192175), + MoveUD(-0.262734), + DelayOld(30), + MoveLR(-0.200018), + MoveUD(-0.27842), + DelayOld(20), + MoveLR(-0.239235), + MoveUD(-0.30195), + MoveUD(-0.309793), + DelayOld(40), + MoveUD(-0.333323), + DelayOld(10), + MoveUD(-0.34901), + DelayOld(30), + MoveUD(-0.372539), + MoveUD(-0.396069), + DelayOld(20), + MoveUD(-0.443129), + DelayOld(20), + MoveUD(-0.458815), + DelayOld(10), + MoveUD(-0.474502), + DelayOld(50), + MoveUD(-0.482345), + DelayOld(30), + MoveLR(-0.215705), + DelayOld(30), + MoveLR(-0.200018), + DelayOld(10), + MoveLR(-0.192175), + DelayOld(10), + MoveLR(-0.176489), + DelayOld(30), + MoveLR(-0.152959), + DelayOld(20), + MoveLR(-0.145116), + MoveLR(-0.121586), + MoveUD(-0.458815), + DelayOld(30), + MoveLR(-0.098056), + MoveUD(-0.419599), + DelayOld(10), + MoveLR(-0.0745262), + MoveUD(-0.333323), + DelayOld(10), + MoveLR(0.00390637), + MoveUD(0), + DelayOld(990), + MoveLR(0), + DelayOld(660), + MoveUD(0), + AnalyticsScreen('Tutorial Section 4'), + Text( + ba.Lstr(resource=self._r + + '.phrase12Text')), # for extra-awesome punches,... + DelayOld(200), + SpawnSpaz( + 0, + (2.368781805038452, 4.337533950805664, -4.360159873962402), + make_current=True, + flash=False), + SpawnSpaz( + 1, + (-3.2, 4.3, -4.5), + make_current=False, + color=(1.0, 0.7, 0.3), + # name=R.randomName3Text), + name=ba.Lstr(resource=self._r + '.randomName3Text')), + DelayOld(100), + Powerup(1, (2.5, 0.0, 0), relative_to=0), + Move(1, 0), + DelayOld(1700), + Move(0, -0.1), + DelayOld(100), + Move(0, 0), + DelayOld(500), + DelayOld(320), + MoveLR(0), + DelayOld(20), + MoveLR(0), + DelayOld(10), + MoveLR(0), + DelayOld(20), + MoveLR(-0.333354), + MoveLR(-0.592181), + DelayOld(20), + MoveLR(-0.788263), + DelayOld(20), + MoveLR(-1), + MoveUD(0.0353099), + MoveUD(0.0588397), + DelayOld(10), + Run(release=False), + DelayOld(780), + MoveUD(0.0274667), + MoveUD(0.00393689), + DelayOld(10), + MoveUD(-0.00390637), + DelayOld(440), + MoveUD(0.0353099), + DelayOld(20), + MoveUD(0.0588397), + DelayOld(10), + MoveUD(0.0902127), + DelayOld(260), + MoveUD(0.0353099), + DelayOld(30), + MoveUD(0.00393689), + DelayOld(10), + MoveUD(-0.00390637), + MoveUD(-0.0274361), + Celebrate('left', 1), + DelayOld(10), + MoveUD(-0.0823389), + DelayOld(30), + MoveUD(-0.176458), + MoveUD(-0.286264), + DelayOld(20), + MoveUD(-0.498032), + Jump(release=False), + MoveUD(-0.764702), + DelayOld(30), + MoveLR(-0.858852), + MoveUD(-1), + MoveLR(-0.780419), + DelayOld(20), + MoveLR(-0.717673), + DelayOld(10), + MoveLR(-0.552965), + DelayOld(10), + MoveLR(-0.341197), + DelayOld(10), + MoveLR(-0.0274667), + DelayOld(10), + MoveLR(0.27842), + DelayOld(20), + MoveLR(0.811762), + MoveLR(1), + RunRelease(), + JumpRelease(), + DelayOld(260), + MoveLR(0.95294), + DelayOld(30), + MoveLR(0.756859), + DelayOld(10), + MoveLR(0.317637), + MoveLR(-0.00393689), + DelayOld(10), + MoveLR(-0.341197), + DelayOld(10), + MoveLR(-0.647084), + MoveUD(-0.921567), + DelayOld(10), + MoveLR(-1), + MoveUD(-0.599994), + MoveUD(-0.474502), + DelayOld(10), + MoveUD(-0.309793), + DelayOld(10), + MoveUD(-0.160772), + MoveUD(-0.0352794), + Delay(10), + MoveUD(0.176489), + Delay(10), + MoveUD(0.607868), + Run(release=False), + Jump(release=False), + DelayOld(20), + MoveUD(1), + DelayOld(30), + MoveLR(-0.921598), + DelayOld(10), + Punch(release=False), + MoveLR(-0.639241), + DelayOld(10), + MoveLR(-0.223548), + DelayOld(10), + MoveLR(0.254891), + DelayOld(10), + MoveLR(0.741172), + MoveLR(1), + DelayOld(40), + JumpRelease(), + DelayOld(40), + MoveUD(0.976501), + DelayOld(10), + MoveUD(0.73336), + DelayOld(10), + MoveUD(0.309824), + DelayOld(20), + MoveUD(-0.184301), + DelayOld(20), + MoveUD(-0.811762), + MoveUD(-1), + KillSpaz(1, explode=True), + DelayOld(10), + RunRelease(), + PunchRelease(), + DelayOld(110), + MoveLR(0.97647), + MoveLR(0.898038), + DelayOld(20), + MoveLR(0.788232), + DelayOld(20), + MoveLR(0.670583), + DelayOld(10), + MoveLR(0.505875), + DelayOld(10), + MoveLR(0.32548), + DelayOld(20), + MoveLR(0.137242), + DelayOld(10), + MoveLR(-0.00393689), + DelayOld(10), + MoveLR(-0.215705), + MoveLR(-0.356883), + DelayOld(20), + MoveLR(-0.451003), + DelayOld(10), + MoveLR(-0.552965), + DelayOld(20), + MoveLR(-0.670614), + MoveLR(-0.780419), + DelayOld(10), + MoveLR(-0.898068), + DelayOld(20), + MoveLR(-1), + DelayOld(370), + MoveLR(-0.976501), + DelayOld(10), + MoveLR(-0.952971), + DelayOld(10), + MoveLR(-0.929441), + MoveLR(-0.898068), + DelayOld(30), + MoveLR(-0.874538), + DelayOld(10), + MoveLR(-0.851009), + DelayOld(10), + MoveLR(-0.835322), + MoveUD(-0.968627), + DelayOld(10), + MoveLR(-0.827479), + MoveUD(-0.960784), + DelayOld(20), + MoveUD(-0.945097), + DelayOld(70), + MoveUD(-0.937254), + DelayOld(20), + MoveUD(-0.913724), + DelayOld(20), + MoveUD(-0.890194), + MoveLR(-0.780419), + MoveUD(-0.827448), + DelayOld(20), + MoveLR(0.317637), + MoveUD(0.3961), + MoveLR(0.0195929), + MoveUD(0.056093), + DelayOld(20), + MoveUD(0), + DelayOld(750), + MoveLR(0), + Text( + ba.Lstr(resource=self._r + '.phrase13Text', + subs=[('${NAME}', + ba.Lstr(resource=self._r + + '.randomName3Text')) + ])), # whoops sorry bill + RemoveGloves(), + DelayOld(2000), + AnalyticsScreen('Tutorial Section 5'), + Text( + ba.Lstr(resource=self._r + '.phrase14Text', + subs=[('${NAME}', + ba.Lstr(resource=self._r + + '.randomName4Text'))]) + ), # you can pick up and throw things such as chuck here + SpawnSpaz(0, (-4.0, 4.3, -2.5), + make_current=True, + flash=False, + angle=90), + SpawnSpaz(1, (5, 0, -1.0), + relative_to=0, + make_current=False, + color=(0.4, 1.0, 0.7), + name=ba.Lstr(resource=self._r + '.randomName4Text')), + DelayOld(1000), + Celebrate('left', 1, duration=1000), + Move(1, 0.2), + DelayOld(2000), + PickUp(), + DelayOld(200), + Move(0.5, 1.0), + DelayOld(1200), + PickUp(), + Move(0, 0), + DelayOld(1000), + Celebrate('left'), + DelayOld(1500), + Move(0, -1.0), + DelayOld(800), + Move(0, 0), + DelayOld(800), + SpawnSpaz(0, (1.5, 4.3, -4.0), + make_current=True, + flash=False, + angle=0), + AnalyticsScreen('Tutorial Section 6'), + Text(ba.Lstr(resource=self._r + + '.phrase15Text')), # lastly there's bombs + DelayOld(1900), + Text( + ba.Lstr(resource=self._r + + '.phrase16Text')), # throwing bombs takes practice + DelayOld(2000), + Bomb(), + Move(-0.1, -0.1), + DelayOld(100), + Move(0, 0), + DelayOld(500), + DelayOld(1000), + Bomb(), + DelayOld(2000), + Text(ba.Lstr(resource=self._r + + '.phrase17Text')), # not a very good throw + DelayOld(3000), + Text( + ba.Lstr(resource=self._r + + '.phrase18Text')), # moving helps you get distance + DelayOld(1000), + Bomb(), + DelayOld(500), + Move(-0.3, 0), + DelayOld(100), + Move(-0.6, 0), + DelayOld(100), + Move(-1, 0), + DelayOld(800), + Bomb(), + DelayOld(400), + Move(0, -0.1), + DelayOld(100), + Move(0, 0), + DelayOld(2500), + Text(ba.Lstr(resource=self._r + + '.phrase19Text')), # jumping helps you get height + DelayOld(2000), + Bomb(), + DelayOld(500), + Move(1, 0), + DelayOld(300), + Jump(release_delay=250), + DelayOld(500), + Jump(release_delay=250), + DelayOld(550), + Jump(release_delay=250), + DelayOld(160), + Punch(), + DelayOld(500), + Move(0, -0.1), + DelayOld(100), + Move(0, 0), + DelayOld(2000), + Text(ba.Lstr(resource=self._r + + '.phrase20Text')), # whiplash your bombs + DelayOld(1000), + Bomb(release=False), + DelayOld2(80), + RunRelease(), + BombRelease(), + DelayOld2(620), + MoveLR(0), + DelayOld2(10), + MoveLR(0), + DelayOld2(40), + MoveLR(0), + DelayOld2(10), + MoveLR(-0.0537431), + MoveUD(0), + DelayOld2(20), + MoveLR(-0.262764), + DelayOld2(20), + MoveLR(-0.498062), + DelayOld2(10), + MoveLR(-0.639241), + DelayOld2(20), + MoveLR(-0.73336), + DelayOld2(10), + MoveLR(-0.843165), + MoveUD(-0.0352794), + DelayOld2(30), + MoveLR(-1), + DelayOld2(10), + MoveUD(-0.0588092), + DelayOld2(10), + MoveUD(-0.160772), + DelayOld2(20), + MoveUD(-0.286264), + DelayOld2(20), + MoveUD(-0.427442), + DelayOld2(10), + MoveUD(-0.623524), + DelayOld2(20), + MoveUD(-0.843135), + DelayOld2(10), + MoveUD(-1), + DelayOld2(40), + MoveLR(-0.890225), + DelayOld2(10), + MoveLR(-0.670614), + DelayOld2(20), + MoveLR(-0.435316), + DelayOld2(20), + MoveLR(-0.184332), + DelayOld2(10), + MoveLR(0.00390637), + DelayOld2(20), + MoveLR(0.223518), + DelayOld2(10), + MoveLR(0.388226), + DelayOld2(20), + MoveLR(0.560778), + DelayOld2(20), + MoveLR(0.717643), + DelayOld2(10), + MoveLR(0.890194), + DelayOld2(20), + MoveLR(1), + DelayOld2(30), + MoveUD(-0.968627), + DelayOld2(20), + MoveUD(-0.898038), + DelayOld2(10), + MoveUD(-0.741172), + DelayOld2(20), + MoveUD(-0.498032), + DelayOld2(20), + MoveUD(-0.247047), + DelayOld2(10), + MoveUD(0.00393689), + DelayOld2(20), + MoveUD(0.239235), + DelayOld2(20), + MoveUD(0.458846), + DelayOld2(10), + MoveUD(0.70983), + DelayOld2(30), + MoveUD(1), + DelayOld2(10), + MoveLR(0.827448), + DelayOld2(10), + MoveLR(0.678426), + DelayOld2(20), + MoveLR(0.396069), + DelayOld2(10), + MoveLR(0.0980255), + DelayOld2(20), + MoveLR(-0.160802), + DelayOld2(20), + MoveLR(-0.388256), + DelayOld2(10), + MoveLR(-0.545122), + DelayOld2(30), + MoveLR(-0.73336), + DelayOld2(10), + MoveLR(-0.945128), + DelayOld2(10), + MoveLR(-1), + DelayOld2(50), + MoveUD(0.960814), + DelayOld2(20), + MoveUD(0.890225), + DelayOld2(10), + MoveUD(0.749046), + DelayOld2(20), + MoveUD(0.623554), + DelayOld2(20), + MoveUD(0.498062), + DelayOld2(10), + MoveUD(0.34904), + DelayOld2(20), + MoveUD(0.239235), + DelayOld2(20), + MoveUD(0.137272), + DelayOld2(10), + MoveUD(0.0117801), + DelayOld2(20), + MoveUD(-0.0117496), + DelayOld2(10), + MoveUD(-0.0274361), + DelayOld2(90), + MoveUD(-0.0352794), + Run(release=False), + Jump(release=False), + Delay(80), + Punch(release=False), + DelayOld2(60), + MoveLR(-0.968657), + DelayOld2(20), + MoveLR(-0.835322), + DelayOld2(10), + MoveLR(-0.70983), + JumpRelease(), + DelayOld2(30), + MoveLR(-0.592181), + MoveUD(-0.0588092), + DelayOld2(10), + MoveLR(-0.490219), + MoveUD(-0.0744957), + DelayOld2(10), + MoveLR(-0.41963), + DelayOld2(20), + MoveLR(0), + MoveUD(0), + DelayOld2(20), + MoveUD(0), + PunchRelease(), + RunRelease(), + DelayOld(500), + Move(0, -0.1), + DelayOld(100), + Move(0, 0), + DelayOld(2000), + AnalyticsScreen('Tutorial Section 7'), + Text(ba.Lstr( + resource=self._r + + '.phrase21Text')), # timing your bombs can be tricky + Move(-1, 0), + DelayOld(1000), + Move(0, -0.1), + DelayOld(100), + Move(0, 0), + SpawnSpaz(0, (-0.7, 4.3, -3.9), + make_current=True, + flash=False, + angle=-30), + SpawnSpaz(1, (6.5, 0, -0.75), + relative_to=0, + make_current=False, + color=(0.3, 0.8, 1.0), + name=ba.Lstr(resource=self._r + '.randomName5Text')), + DelayOld2(1000), + Move(-1, 0), + DelayOld2(1800), + Bomb(), + Move(0, 0), + DelayOld2(300), + Move(1, 0), + DelayOld2(600), + Jump(), + DelayOld2(150), + Punch(), + DelayOld2(800), + Move(-1, 0), + DelayOld2(1000), + Move(0, 0), + DelayOld2(1500), + Text(ba.Lstr(resource=self._r + '.phrase22Text')), # dang + Delay(1500), + Text(''), + Delay(200), + Text(ba.Lstr(resource=self._r + + '.phrase23Text')), # try cooking off + Delay(1500), + Bomb(), + Delay(800), + Move(1, 0.12), + Delay(1100), + Jump(), + Delay(100), + Punch(), + Delay(100), + Move(0, -0.1), + Delay(100), + Move(0, 0), + Delay(2000), + Text(ba.Lstr(resource=self._r + + '.phrase24Text')), # hooray nicely cooked + Celebrate(), + DelayOld(2000), + KillSpaz(1), + Text(""), + Move(0.5, -0.5), + DelayOld(1000), + Move(0, -0.1), + DelayOld(100), + Move(0, 0), + DelayOld(1000), + AnalyticsScreen('Tutorial Section 8'), + Text(ba.Lstr(resource=self._r + + '.phrase25Text')), # well that's just about it + DelayOld(2000), + Text(ba.Lstr(resource=self._r + + '.phrase26Text')), # go get em tiger + DelayOld(2000), + Text(ba.Lstr(resource=self._r + + '.phrase27Text')), # remember you training + DelayOld(3000), + Text(ba.Lstr(resource=self._r + + '.phrase28Text')), # well maybe + DelayOld(1600), + Text(ba.Lstr(resource=self._r + '.phrase29Text')), # good luck + Celebrate('right', duration=10000), + DelayOld(1000), + AnalyticsScreen('Tutorial Complete'), + End(), + ] + + except Exception: + import traceback + traceback.print_exc() + + # If we read some, exec them. + if self._entries: + self._run_next_entry() + # Otherwise try again in a few seconds. + else: + self._read_entries_timer = ba.Timer( + 3.0, ba.WeakCall(self._read_entries)) + + def _run_next_entry(self) -> None: + + while self._entries: + entry = self._entries.pop(0) + try: + result = entry.run(self) + except Exception: + result = None + import traceback + traceback.print_exc() + + # If the entry returns an int value, set a timer; + # otherwise just keep going. + if result is not None: + self._entry_timer = ba.Timer( + result, + ba.WeakCall(self._run_next_entry), + timeformat=ba.TimeFormat.MILLISECONDS) + return + + # Done with these entries.. start over soon. + self._read_entries_timer = ba.Timer(1.0, + ba.WeakCall(self._read_entries)) + + def _update_skip_votes(self) -> None: + count = sum(1 for player in self.players if player.gamedata['pressed']) + assert self._skip_count_text + self._skip_count_text.text = ba.Lstr( + resource=self._r + '.skipVoteCountText', + subs=[('${COUNT}', str(count)), + ('${TOTAL}', str(len(self.players)))]) if count > 0 else '' + if (count >= len(self.players) and self.players + and not self._have_skipped): + _ba.increment_analytics_count('Tutorial skip') + ba.set_analytics_screen('Tutorial Skip') + self._have_skipped = True + ba.playsound(ba.getsound('swish')) + # self._skip_count_text.text = self._r.skippingText + self._skip_count_text.text = ba.Lstr(resource=self._r + + '.skippingText') + assert self._skip_text + self._skip_text.text = '' + self.end() + + def _player_pressed_button(self, player: ba.Player) -> None: + + # Special case: if there's only one player, we give them a + # warning on their first press (some players were thinking the + # on-screen guide meant they were supposed to press something). + if len(self.players) == 1 and not self._issued_warning: + self._issued_warning = True + assert self._skip_text + self._skip_text.text = ba.Lstr(resource=self._r + + '.skipConfirmText') + self._skip_text.color = (1, 1, 1) + self._skip_text.scale = 1.3 + incr = 50 + t = incr + for i in range(6): + ba.timer(t, + ba.Call(setattr, self._skip_text, 'color', + (1, 0.5, 0.1)), + timeformat=ba.TimeFormat.MILLISECONDS) + t += incr + ba.timer(t, + ba.Call(setattr, self._skip_text, 'color', (1, 1, 0)), + timeformat=ba.TimeFormat.MILLISECONDS) + t += incr + ba.timer(6.0, ba.WeakCall(self._revert_confirm)) + return + + player.gamedata['pressed'] = True + + # test... + if not all(self.players): + ba.print_error("Nonexistent player in _player_pressed_button: " + + str([str(p) for p in self.players]) + ': we are ' + + str(player)) + + self._update_skip_votes() + + def _revert_confirm(self) -> None: + assert self._skip_text + self._skip_text.text = ba.Lstr(resource=self._r + + '.toSkipPressAnythingText') + self._skip_text.color = (1, 1, 1) + self._issued_warning = False + + def on_player_join(self, player: ba.Player) -> None: + super().on_player_join(player) + player.gamedata['pressed'] = False + # we just wanna know if this player presses anything.. + player.assign_input_call( + ('jumpPress', 'punchPress', 'bombPress', 'pickUpPress'), + ba.Call(self._player_pressed_button, player)) + + def on_player_leave(self, player: ba.Player) -> None: + if not all(self.players): + ba.print_error("Nonexistent player in on_player_leave: " + + str([str(p) for p in self.players]) + ': we are ' + + str(player)) + super().on_player_leave(player) + # our leaving may influence the vote total needed/etc + self._update_skip_votes() diff --git a/assets/src/data/scripts/bastd/ui/__init__.py b/assets/src/data/scripts/bastd/ui/__init__.py new file mode 100644 index 00000000..0e9370f6 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/__init__.py @@ -0,0 +1,3 @@ +""" +Provide top level UI related functionality. +""" diff --git a/assets/src/data/scripts/bastd/ui/account/__init__.py b/assets/src/data/scripts/bastd/ui/account/__init__.py new file mode 100644 index 00000000..c48a83b5 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/account/__init__.py @@ -0,0 +1,28 @@ +"""UI functionality related to accounts.""" + +from __future__ import annotations + +import _ba +import ba + + +def show_sign_in_prompt(account_type: str = None) -> None: + """Bring up a prompt telling the user they must sign in.""" + from bastd.ui import confirm + from bastd.ui.account import settings + if account_type == 'Google Play': + confirm.ConfirmWindow( + ba.Lstr(resource='notSignedInGooglePlayErrorText'), + lambda: _ba.sign_in('Google Play'), + ok_text=ba.Lstr(resource='accountSettingsWindow.signInText'), + width=460, + height=130) + else: + confirm.ConfirmWindow( + ba.Lstr(resource='notSignedInErrorText'), + ba.Call(settings.AccountSettingsWindow, + modal=True, + close_once_signed_in=True), + ok_text=ba.Lstr(resource='accountSettingsWindow.signInText'), + width=460, + height=130) diff --git a/assets/src/data/scripts/bastd/ui/account/link.py b/assets/src/data/scripts/bastd/ui/account/link.py new file mode 100644 index 00000000..e11c983d --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/account/link.py @@ -0,0 +1,146 @@ +"""UI functionality for linking accounts.""" + +from __future__ import annotations + +import copy +import time +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Tuple, Optional, Dict + + +class AccountLinkWindow(ba.OldWindow): + """Window for linking accounts.""" + + def __init__(self, origin_widget: ba.Widget = None): + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + transition = 'in_right' + bg_color = (0.4, 0.4, 0.5) + self._width = 560 + self._height = 420 + base_scale = (1.65 + if ba.app.small_ui else 1.5 if ba.app.med_ui else 1.1) + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition=transition, + scale=base_scale, + scale_origin_stack_offset=scale_origin, + stack_offset=(0, -10) if ba.app.small_ui else (0, 0))) + self._cancel_button = ba.buttonwidget(parent=self._root_widget, + position=(40, self._height - 45), + size=(50, 50), + scale=0.7, + label='', + color=bg_color, + on_activate_call=self._cancel, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + maxlinks = _ba.get_account_misc_read_val('maxLinkAccounts', 5) + ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.56), + size=(0, 0), + text=ba.Lstr(resource=( + 'accountSettingsWindow.linkAccountsInstructionsNewText'), + subs=[('${COUNT}', str(maxlinks))]), + maxwidth=self._width * 0.9, + color=ba.app.infotextcolor, + max_height=self._height * 0.6, + h_align='center', + v_align='center') + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + ba.buttonwidget( + parent=self._root_widget, + position=(40, 30), + size=(200, 60), + label=ba.Lstr( + resource='accountSettingsWindow.linkAccountsGenerateCodeText'), + autoselect=True, + on_activate_call=self._generate_press) + self._enter_code_button = ba.buttonwidget( + parent=self._root_widget, + position=(self._width - 240, 30), + size=(200, 60), + label=ba.Lstr( + resource='accountSettingsWindow.linkAccountsEnterCodeText'), + autoselect=True, + on_activate_call=self._enter_code_press) + + def _generate_press(self) -> None: + from bastd.ui import account + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + ba.screenmessage( + ba.Lstr(resource='gatherWindow.requestingAPromoCodeText'), + color=(0, 1, 0)) + _ba.add_transaction({ + 'type': 'ACCOUNT_LINK_CODE_REQUEST', + 'expire_time': time.time() + 5 + }) + _ba.run_transactions() + + def _enter_code_press(self) -> None: + from bastd.ui import promocode + promocode.PromoCodeWindow(modal=True, + origin_widget=self._enter_code_button) + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + + def _cancel(self) -> None: + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + + +class AccountLinkCodeWindow(ba.OldWindow): + """Window showing code for account-linking.""" + + def __init__(self, data: Dict[str, Any]): + self._width = 350 + self._height = 200 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + color=(0.45, 0.63, 0.15), + transition='in_scale', + scale=1.8 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0)) + self._data = copy.deepcopy(data) + ba.playsound(ba.getsound('cashRegister')) + ba.playsound(ba.getsound('swish')) + self._cancel_button = ba.buttonwidget(parent=self._root_widget, + scale=0.5, + position=(40, self._height - 40), + size=(50, 50), + label='', + on_activate_call=self.close, + autoselect=True, + color=(0.45, 0.63, 0.15), + icon=ba.gettexture('crossOut'), + iconscale=1.2) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.5), + size=(0, 0), + color=(1.0, 3.0, 1.0), + scale=2.0, + h_align="center", + v_align="center", + text=data['code'], + maxwidth=self._width * 0.85) + + def close(self) -> None: + """close the window""" + ba.containerwidget(edit=self._root_widget, transition='out_scale') diff --git a/assets/src/data/scripts/bastd/ui/account/settings.py b/assets/src/data/scripts/bastd/ui/account/settings.py new file mode 100644 index 00000000..96e62949 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/account/settings.py @@ -0,0 +1,1107 @@ +"""Provides UI for account functionality.""" +# pylint: disable=too-many-lines + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Optional, Tuple, List, Union + + +class AccountSettingsWindow(ba.OldWindow): + """Window for account related functionality.""" + + def __init__(self, + transition: str = 'in_right', + modal: bool = False, + origin_widget: ba.Widget = None, + close_once_signed_in: bool = False): + # pylint: disable=too-many-statements + + self._close_once_signed_in = close_once_signed_in + ba.set_analytics_screen('Account Window') + + # If they provided an origin-widget, scale up from that. + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self._r = 'accountSettingsWindow' + self._modal = modal + self._needs_refresh = False + self._signed_in = (_ba.get_account_state() == 'signed_in') + self._account_state_num = _ba.get_account_state_num() + self._show_linked = (self._signed_in and _ba.get_account_misc_read_val( + 'allowAccountLinking2', False)) + self._check_sign_in_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + + # Currently we can only reset achievements on game-center. + account_type: Optional[str] + if self._signed_in: + account_type = _ba.get_account_type() + else: + account_type = None + self._can_reset_achievements = (account_type == 'Game Center') + + app = ba.app + + self._width = 760 if ba.app.small_ui else 660 + x_offs = 50 if ba.app.small_ui else 0 + self._height = (390 + if ba.app.small_ui else 430 if ba.app.med_ui else 490) + + self._sign_in_button = None + self._sign_in_text = None + + self._scroll_width = self._width - (100 + x_offs * 2) + self._scroll_height = self._height - 120 + self._sub_width = self._scroll_width - 20 + + # Determine which sign-in/sign-out buttons we should show. + self._show_sign_in_buttons: List[str] = [] + + if app.platform == 'android' and app.subplatform == 'google': + self._show_sign_in_buttons.append('Google Play') + + elif app.platform == 'android' and app.subplatform == 'amazon': + self._show_sign_in_buttons.append('Game Circle') + + # Local accounts are generally always available with a few key + # exceptions. + self._show_sign_in_buttons.append('Local') + + top_extra = 15 if ba.app.small_ui else 0 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + top_extra), + transition=transition, + toolbar_visibility='menu_minimal', + scale_origin_stack_offset=scale_origin, + scale=(2.09 if ba.app.small_ui else 1.4 if ba.app.med_ui else 1.0), + stack_offset=(0, -19) if ba.app.small_ui else (0, 0))) + if ba.app.small_ui and ba.app.toolbars: + self._back_button = None + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._back) + else: + self._back_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(51 + x_offs, self._height - 62), + size=(120, 60), + scale=0.8, + text_scale=1.2, + autoselect=True, + label=ba.Lstr( + resource='doneText' if self._modal else 'backText'), + button_type='regular' if self._modal else 'back', + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + if not self._modal: + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 56), + label=ba.charstr(ba.SpecialChar.BACK)) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 41), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.titleText'), + color=ba.app.title_color, + maxwidth=self._width - 340, + h_align="center", + v_align="center") + + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + highlight=False, + position=((self._width - self._scroll_width) * 0.5, + self._height - 65 - self._scroll_height), + size=(self._scroll_width, self._scroll_height)) + self._subcontainer: Optional[ba.Widget] = None + self._refresh() + self._restore_state() + + def _update(self) -> None: + + # If they want us to close once we're signed in, do so. + if self._close_once_signed_in and self._signed_in: + self._back() + return + + # Hmm should update this to use get_account_state_num. + # Theoretically if we switch from one signed-in account to another + # in the background this would break. + account_state_num = _ba.get_account_state_num() + account_state = _ba.get_account_state() + + show_linked = (self._signed_in and _ba.get_account_misc_read_val( + 'allowAccountLinking2', False)) + + if (account_state_num != self._account_state_num + or self._show_linked != show_linked or self._needs_refresh): + self._show_linked = show_linked + self._account_state_num = account_state_num + self._signed_in = (account_state == 'signed_in') + self._refresh() + + # Go ahead and refresh some individual things + # that may change under us. + self._update_linked_accounts_text() + self._update_unlink_accounts_button() + self._refresh_campaign_progress_text() + self._refresh_achievements() + self._refresh_tickets_text() + self._refresh_account_name_text() + + def _get_sign_in_text(self) -> ba.Lstr: + return ba.Lstr(resource=self._r + '.signInText') + + def _refresh(self) -> None: + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + from bastd.ui import confirm + + account_state = _ba.get_account_state() + account_type = (_ba.get_account_type() + if account_state == 'signed_in' else 'unknown') + + is_google = account_type == 'Google Play' + + show_local_signed_in_as = False + local_signed_in_as_space = 50.0 + + show_signed_in_as = self._signed_in + signed_in_as_space = 95.0 + + show_sign_in_benefits = not self._signed_in + sign_in_benefits_space = 80.0 + + show_signing_in_text = account_state == 'signing_in' + signing_in_text_space = 80.0 + + show_google_play_sign_in_button = ( + account_state == 'signed_out' + and 'Google Play' in self._show_sign_in_buttons) + show_game_circle_sign_in_button = ( + account_state == 'signed_out' + and 'Game Circle' in self._show_sign_in_buttons) + show_ali_sign_in_button = (account_state == 'signed_out' + and 'Ali' in self._show_sign_in_buttons) + show_test_sign_in_button = (account_state == 'signed_out' + and 'Test' in self._show_sign_in_buttons) + show_device_sign_in_button = (account_state == 'signed_out' and + 'Local' in self._show_sign_in_buttons) + sign_in_button_space = 70.0 + + show_game_service_button = ( + self._signed_in and account_type in ['Game Center', 'Game Circle']) + game_service_button_space = 60.0 + + show_linked_accounts_text = (self._signed_in + and _ba.get_account_misc_read_val( + 'allowAccountLinking2', False)) + linked_accounts_text_space = 60.0 + + show_achievements_button = (self._signed_in and + account_type in ('Google Play', 'Alibaba', + 'Local', 'OUYA', 'Test')) + achievements_button_space = 60.0 + + show_achievements_text = (self._signed_in + and not show_achievements_button) + achievements_text_space = 27.0 + + show_leaderboards_button = (self._signed_in and is_google) + leaderboards_button_space = 60.0 + + show_campaign_progress = self._signed_in + campaign_progress_space = 27.0 + + show_tickets = self._signed_in + tickets_space = 27.0 + + show_reset_progress_button = False + reset_progress_button_space = 70.0 + + show_player_profiles_button = self._signed_in + player_profiles_button_space = 100.0 + + show_link_accounts_button = (self._signed_in + and _ba.get_account_misc_read_val( + 'allowAccountLinking2', False)) + link_accounts_button_space = 70.0 + + show_unlink_accounts_button = show_link_accounts_button + unlink_accounts_button_space = 90.0 + + show_sign_out_button = ( + self._signed_in + and account_type in ['Test', 'Local', 'Google Play']) + sign_out_button_space = 70.0 + + if self._subcontainer is not None: + self._subcontainer.delete() + self._sub_height = 60.0 + if show_local_signed_in_as: + self._sub_height += local_signed_in_as_space + if show_signed_in_as: + self._sub_height += signed_in_as_space + if show_signing_in_text: + self._sub_height += signing_in_text_space + if show_google_play_sign_in_button: + self._sub_height += sign_in_button_space + if show_game_circle_sign_in_button: + self._sub_height += sign_in_button_space + if show_ali_sign_in_button: + self._sub_height += sign_in_button_space + if show_test_sign_in_button: + self._sub_height += sign_in_button_space + if show_device_sign_in_button: + self._sub_height += sign_in_button_space + if show_game_service_button: + self._sub_height += game_service_button_space + if show_linked_accounts_text: + self._sub_height += linked_accounts_text_space + if show_achievements_text: + self._sub_height += achievements_text_space + if show_achievements_button: + self._sub_height += achievements_button_space + if show_leaderboards_button: + self._sub_height += leaderboards_button_space + if show_campaign_progress: + self._sub_height += campaign_progress_space + if show_tickets: + self._sub_height += tickets_space + if show_sign_in_benefits: + self._sub_height += sign_in_benefits_space + if show_reset_progress_button: + self._sub_height += reset_progress_button_space + if show_player_profiles_button: + self._sub_height += player_profiles_button_space + if show_link_accounts_button: + self._sub_height += link_accounts_button_space + if show_unlink_accounts_button: + self._sub_height += unlink_accounts_button_space + if show_sign_out_button: + self._sub_height += sign_out_button_space + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._sub_width, + self._sub_height), + background=False) + ba.containerwidget(edit=self._scrollwidget, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + ba.containerwidget(edit=self._subcontainer, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + + first_selectable = None + v = self._sub_height - 10.0 + + if show_local_signed_in_as: + v -= local_signed_in_as_space * 0.6 + ba.textwidget( + parent=self._subcontainer, + position=(self._sub_width * 0.5, v), + size=(0, 0), + text=ba.Lstr( + resource='accountSettingsWindow.deviceSpecificAccountText', + subs=[('${NAME}', _ba.get_account_display_string())]), + scale=0.7, + color=(0.5, 0.5, 0.6), + maxwidth=self._sub_width * 0.9, + flatness=1.0, + h_align="center", + v_align="center") + v -= local_signed_in_as_space * 0.4 + + self._account_name_text: Optional[ba.Widget] + if show_signed_in_as: + v -= signed_in_as_space * 0.2 + txt = ba.Lstr( + resource='accountSettingsWindow.youAreSignedInAsText', + fallback_resource='accountSettingsWindow.youAreLoggedInAsText') + ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, v), + size=(0, 0), + text=txt, + scale=0.9, + color=ba.app.title_color, + maxwidth=self._sub_width * 0.9, + h_align="center", + v_align="center") + v -= signed_in_as_space * 0.4 + self._account_name_text = ba.textwidget( + parent=self._subcontainer, + position=(self._sub_width * 0.5, v), + size=(0, 0), + scale=1.5, + maxwidth=self._sub_width * 0.9, + res_scale=1.5, + color=(1, 1, 1, 1), + h_align="center", + v_align="center") + self._refresh_account_name_text() + v -= signed_in_as_space * 0.4 + else: + self._account_name_text = None + + if self._back_button is None: + bbtn = _ba.get_special_widget('back_button') + else: + bbtn = self._back_button + + if show_sign_in_benefits: + v -= sign_in_benefits_space + app = ba.app + extra: Optional[Union[str, ba.Lstr]] + if (app.platform in ['mac', 'ios'] + and app.subplatform == 'appstore'): + extra = ba.Lstr( + value='\n${S}', + subs=[('${S}', + ba.Lstr(resource='signInWithGameCenterText'))]) + else: + extra = '' + + ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, + v + sign_in_benefits_space * 0.4), + size=(0, 0), + text=ba.Lstr(value='${A}${B}', + subs=[('${A}', + ba.Lstr(resource=self._r + + '.signInInfoText')), + ('${B}', extra)]), + max_height=sign_in_benefits_space * 0.9, + scale=0.9, + color=(0.75, 0.7, 0.8), + maxwidth=self._sub_width * 0.8, + h_align="center", + v_align="center") + + if show_signing_in_text: + v -= signing_in_text_space + + ba.textwidget( + parent=self._subcontainer, + position=(self._sub_width * 0.5, + v + signing_in_text_space * 0.5), + size=(0, 0), + text=ba.Lstr(resource='accountSettingsWindow.signingInText'), + scale=0.9, + color=(0, 1, 0), + maxwidth=self._sub_width * 0.8, + h_align="center", + v_align="center") + + if show_google_play_sign_in_button: + button_width = 350 + v -= sign_in_button_space + self._sign_in_google_play_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v - 20), + autoselect=True, + size=(button_width, 60), + label=ba.Lstr( + value='${A}${B}', + subs=[('${A}', + ba.charstr(ba.SpecialChar.GOOGLE_PLAY_GAMES_LOGO)), + ('${B}', + ba.Lstr(resource=self._r + + '.signInWithGooglePlayText'))]), + on_activate_call=lambda: self._sign_in_press('Google Play')) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn) + ba.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) + self._sign_in_text = None + + if show_game_circle_sign_in_button: + button_width = 350 + v -= sign_in_button_space + self._sign_in_game_circle_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v - 20), + autoselect=True, + size=(button_width, 60), + label=ba.Lstr(value='${A}${B}', + subs=[('${A}', + ba.charstr( + ba.SpecialChar.GAME_CIRCLE_LOGO)), + ('${B}', + ba.Lstr(resource=self._r + + '.signInWithGameCircleText'))]), + on_activate_call=lambda: self._sign_in_press('Game Circle')) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn) + ba.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) + self._sign_in_text = None + + if show_ali_sign_in_button: + button_width = 350 + v -= sign_in_button_space + self._sign_in_ali_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v - 20), + autoselect=True, + size=(button_width, 60), + label=ba.Lstr(value='${A}${B}', + subs=[('${A}', + ba.charstr(ba.SpecialChar.ALIBABA_LOGO)), + ('${B}', + ba.Lstr(resource=self._r + '.signInText')) + ]), + on_activate_call=lambda: self._sign_in_press('Ali')) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn) + ba.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) + self._sign_in_text = None + + if show_device_sign_in_button: + button_width = 350 + v -= sign_in_button_space + self._sign_in_device_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v - 20), + autoselect=True, + size=(button_width, 60), + label='', + on_activate_call=lambda: self._sign_in_press('Local')) + ba.textwidget(parent=self._subcontainer, + draw_controller=btn, + h_align='center', + v_align='center', + size=(0, 0), + position=(self._sub_width * 0.5, v + 17), + text=ba.Lstr( + value='${A}${B}', + subs=[('${A}', + ba.charstr(ba.SpecialChar.LOCAL_ACCOUNT)), + ('${B}', + ba.Lstr(resource=self._r + + '.signInWithDeviceText'))]), + maxwidth=button_width * 0.8, + color=(0.75, 1.0, 0.7)) + ba.textwidget(parent=self._subcontainer, + draw_controller=btn, + h_align='center', + v_align='center', + size=(0, 0), + position=(self._sub_width * 0.5, v - 4), + text=ba.Lstr(resource=self._r + + '.signInWithDeviceInfoText'), + flatness=1.0, + scale=0.57, + maxwidth=button_width * 0.9, + color=(0.55, 0.8, 0.5)) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn) + ba.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) + self._sign_in_text = None + + # Old test-account option. + if show_test_sign_in_button: + button_width = 350 + v -= sign_in_button_space + self._sign_in_test_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v - 20), + autoselect=True, + size=(button_width, 60), + label='', + on_activate_call=lambda: self._sign_in_press('Test')) + ba.textwidget(parent=self._subcontainer, + draw_controller=btn, + h_align='center', + v_align='center', + size=(0, 0), + position=(self._sub_width * 0.5, v + 17), + text=ba.Lstr( + value='${A}${B}', + subs=[('${A}', + ba.charstr(ba.SpecialChar.TEST_ACCOUNT)), + ('${B}', + ba.Lstr(resource=self._r + + '.signInWithTestAccountText'))]), + maxwidth=button_width * 0.8, + color=(0.75, 1.0, 0.7)) + ba.textwidget(parent=self._subcontainer, + draw_controller=btn, + h_align='center', + v_align='center', + size=(0, 0), + position=(self._sub_width * 0.5, v - 4), + text=ba.Lstr(resource=self._r + + '.signInWithTestAccountInfoText'), + flatness=1.0, + scale=0.57, + maxwidth=button_width * 0.9, + color=(0.55, 0.8, 0.5)) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn) + ba.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) + self._sign_in_text = None + + if show_player_profiles_button: + button_width = 300 + v -= player_profiles_button_space + self._player_profiles_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v + 30), + autoselect=True, + size=(button_width, 60), + label=ba.Lstr(resource='playerProfilesWindow.titleText'), + color=(0.55, 0.5, 0.6), + icon=ba.gettexture('cuteSpaz'), + textcolor=(0.75, 0.7, 0.8), + on_activate_call=self._player_profiles_press) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=0) + + # the button to go to OS-Specific leaderboards/high-score-lists/etc. + if show_game_service_button: + button_width = 300 + v -= game_service_button_space * 0.85 + account_type = _ba.get_account_type() + if account_type == 'Game Center': + account_type_name = ba.Lstr(resource='gameCenterText') + elif account_type == 'Game Circle': + account_type_name = ba.Lstr(resource='gameCircleText') + else: + raise Exception("unknown account type: '" + str(account_type) + + "'") + self._game_service_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v), + color=(0.55, 0.5, 0.6), + textcolor=(0.75, 0.7, 0.8), + autoselect=True, + on_activate_call=_ba.show_online_score_ui, + size=(button_width, 50), + label=account_type_name) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn) + v -= game_service_button_space * 0.15 + else: + self.game_service_button = None + + self._achievements_text: Optional[ba.Widget] + if show_achievements_text: + v -= achievements_text_space * 0.5 + self._achievements_text = ba.textwidget( + parent=self._subcontainer, + position=(self._sub_width * 0.5, v), + size=(0, 0), + scale=0.9, + color=(0.75, 0.7, 0.8), + maxwidth=self._sub_width * 0.8, + h_align="center", + v_align="center") + v -= achievements_text_space * 0.5 + else: + self._achievements_text = None + + self._achievements_button: Optional[ba.Widget] + if show_achievements_button: + button_width = 300 + v -= achievements_button_space * 0.85 + self._achievements_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v), + color=(0.55, 0.5, 0.6), + textcolor=(0.75, 0.7, 0.8), + autoselect=True, + icon=ba.gettexture('googlePlayAchievementsIcon' + if is_google else 'achievementsIcon'), + icon_color=(0.8, 0.95, 0.7) if is_google else (0.85, 0.8, 0.9), + on_activate_call=self._on_achievements_press, + size=(button_width, 50), + label='') + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn) + v -= achievements_button_space * 0.15 + else: + self._achievements_button = None + + if show_achievements_text or show_achievements_button: + self._refresh_achievements() + + self._leaderboards_button: Optional[ba.Widget] + if show_leaderboards_button: + button_width = 300 + v -= leaderboards_button_space * 0.85 + self._leaderboards_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v), + color=(0.55, 0.5, 0.6), + textcolor=(0.75, 0.7, 0.8), + autoselect=True, + icon=ba.gettexture('googlePlayLeaderboardsIcon'), + icon_color=(0.8, 0.95, 0.7), + on_activate_call=self._on_leaderboards_press, + size=(button_width, 50), + label=ba.Lstr(resource='leaderboardsText')) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn) + v -= leaderboards_button_space * 0.15 + else: + self._leaderboards_button = None + + self._campaign_progress_text: Optional[ba.Widget] + if show_campaign_progress: + v -= campaign_progress_space * 0.5 + self._campaign_progress_text = ba.textwidget( + parent=self._subcontainer, + position=(self._sub_width * 0.5, v), + size=(0, 0), + scale=0.9, + color=(0.75, 0.7, 0.8), + maxwidth=self._sub_width * 0.8, + h_align="center", + v_align="center") + v -= campaign_progress_space * 0.5 + self._refresh_campaign_progress_text() + else: + self._campaign_progress_text = None + + self._tickets_text: Optional[ba.Widget] + if show_tickets: + v -= tickets_space * 0.5 + self._tickets_text = ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, + v), + size=(0, 0), + scale=0.9, + color=(0.75, 0.7, 0.8), + maxwidth=self._sub_width * 0.8, + flatness=1.0, + h_align="center", + v_align="center") + v -= tickets_space * 0.5 + self._refresh_tickets_text() + + else: + self._tickets_text = None + + # bit of spacing before the reset/sign-out section + v -= 5 + + button_width = 250 + if show_reset_progress_button: + confirm_text = (ba.Lstr(resource=self._r + + '.resetProgressConfirmText') + if self._can_reset_achievements else ba.Lstr( + resource=self._r + + '.resetProgressConfirmNoAchievementsText')) + v -= reset_progress_button_space + self._reset_progress_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v), + color=(0.55, 0.5, 0.6), + textcolor=(0.75, 0.7, 0.8), + autoselect=True, + size=(button_width, 60), + label=ba.Lstr(resource=self._r + '.resetProgressText'), + on_activate_call=ba.Call(confirm.ConfirmWindow, + text=confirm_text, + width=500, + height=200, + action=self._reset_progress)) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn) + + self._linked_accounts_text: Optional[ba.Widget] + if show_linked_accounts_text: + v -= linked_accounts_text_space * 0.8 + self._linked_accounts_text = ba.textwidget( + parent=self._subcontainer, + position=(self._sub_width * 0.5, v), + size=(0, 0), + scale=0.9, + color=(0.75, 0.7, 0.8), + maxwidth=self._sub_width * 0.95, + h_align="center", + v_align="center") + v -= linked_accounts_text_space * 0.2 + self._update_linked_accounts_text() + else: + self._linked_accounts_text = None + + if show_link_accounts_button: + v -= link_accounts_button_space + self._link_accounts_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v), + autoselect=True, + size=(button_width, 60), + label='', + color=(0.55, 0.5, 0.6), + on_activate_call=self._link_accounts_press) + ba.textwidget(parent=self._subcontainer, + draw_controller=btn, + h_align='center', + v_align='center', + size=(0, 0), + position=(self._sub_width * 0.5, v + 17 + 20), + text=ba.Lstr(resource=self._r + '.linkAccountsText'), + maxwidth=button_width * 0.8, + color=(0.75, 0.7, 0.8)) + ba.textwidget(parent=self._subcontainer, + draw_controller=btn, + h_align='center', + v_align='center', + size=(0, 0), + position=(self._sub_width * 0.5, v - 4 + 20), + text=ba.Lstr(resource=self._r + + '.linkAccountsInfoText'), + flatness=1.0, + scale=0.5, + maxwidth=button_width * 0.8, + color=(0.75, 0.7, 0.8)) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=50) + + self._unlink_accounts_button: Optional[ba.Widget] + if show_unlink_accounts_button: + v -= unlink_accounts_button_space + self._unlink_accounts_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v + 25), + autoselect=True, + size=(button_width, 60), + label='', + color=(0.55, 0.5, 0.6), + on_activate_call=self._unlink_accounts_press) + self._unlink_accounts_button_label = ba.textwidget( + parent=self._subcontainer, + draw_controller=btn, + h_align='center', + v_align='center', + size=(0, 0), + position=(self._sub_width * 0.5, v + 55), + text=ba.Lstr(resource=self._r + '.unlinkAccountsText'), + maxwidth=button_width * 0.8, + color=(0.75, 0.7, 0.8)) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=50) + self._update_unlink_accounts_button() + else: + self._unlink_accounts_button = None + + if show_sign_out_button: + v -= sign_out_button_space + self._sign_out_button = btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v), + size=(button_width, 60), + label=ba.Lstr(resource=self._r + '.signOutText'), + color=(0.55, 0.5, 0.6), + textcolor=(0.75, 0.7, 0.8), + autoselect=True, + on_activate_call=self._sign_out_press) + if first_selectable is None: + first_selectable = btn + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15) + + # Whatever the topmost selectable thing is, we want it to scroll all + # the way up when we select it. + if first_selectable is not None: + ba.widget(edit=first_selectable, + up_widget=bbtn, + show_buffer_top=400) + # (this should re-scroll us to the top..) + ba.containerwidget(edit=self._subcontainer, + visible_child=first_selectable) + self._needs_refresh = False + + def _on_achievements_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import achievements + account_state = _ba.get_account_state() + account_type = (_ba.get_account_type() + if account_state == 'signed_in' else 'unknown') + # for google play we use the built-in UI; otherwise pop up our own + if account_type == 'Google Play': + ba.timer(0.15, + ba.Call(_ba.show_online_score_ui, 'achievements'), + timetype=ba.TimeType.REAL) + elif account_type != 'unknown': + assert self._achievements_button is not None + achievements.AchievementsWindow( + position=self._achievements_button.get_screen_space_center()) + else: + print('ERROR: unknown account type in on_achievements_press:', + account_type) + + def _on_leaderboards_press(self) -> None: + ba.timer(0.15, + ba.Call(_ba.show_online_score_ui, 'leaderboards'), + timetype=ba.TimeType.REAL) + + def _have_unlinkable_accounts(self) -> bool: + # if this is not present, we haven't had contact from the server so + # let's not proceed.. + if _ba.get_public_login_id() is None: + return False + accounts = _ba.get_account_misc_read_val_2('linkedAccounts', []) + return len(accounts) > 1 + + def _update_unlink_accounts_button(self) -> None: + if self._unlink_accounts_button is None: + return + if self._have_unlinkable_accounts(): + clr = (0.75, 0.7, 0.8, 1.0) + else: + clr = (1.0, 1.0, 1.0, 0.25) + ba.textwidget(edit=self._unlink_accounts_button_label, color=clr) + + def _update_linked_accounts_text(self) -> None: + if self._linked_accounts_text is None: + return + + # if this is not present, we haven't had contact from the server so + # let's not proceed.. + if _ba.get_public_login_id() is None: + num = int(time.time()) % 4 + accounts_str = num * '.' + (4 - num) * ' ' + else: + accounts = _ba.get_account_misc_read_val_2('linkedAccounts', []) + # our_account = _bs.get_account_display_string() + # accounts = [a for a in accounts if a != our_account] + # accounts_str = u', '.join(accounts) if accounts else + # ba.Lstr(translate=('settingNames', 'None')) + # UPDATE - we now just print the number here; not the actual + # accounts + # (they can see that in the unlink section if they're curious) + accounts_str = str(max(0, len(accounts) - 1)) + ba.textwidget(edit=self._linked_accounts_text, + text=ba.Lstr(value='${L} ${A}', + subs=[('${L}', + ba.Lstr(resource=self._r + + '.linkedAccountsText')), + ('${A}', accounts_str)])) + + def _refresh_campaign_progress_text(self) -> None: + from ba.internal import get_campaign + if self._campaign_progress_text is None: + return + p_str: Union[str, ba.Lstr] + try: + campaign = get_campaign('Default') + levels = campaign.get_levels() + levels_complete = sum((1 if l.complete else 0) for l in levels) + + # Last level cant be completed; hence the -1; + progress = min(1.0, float(levels_complete) / (len(levels) - 1)) + p_str = ba.Lstr(resource=self._r + '.campaignProgressText', + subs=[('${PROGRESS}', + str(int(progress * 100.0)) + '%')]) + except Exception: + p_str = '?' + ba.print_exception('error calculating co-op campaign progress') + ba.textwidget(edit=self._campaign_progress_text, text=p_str) + + def _refresh_tickets_text(self) -> None: + if self._tickets_text is None: + return + try: + tc_str = str(_ba.get_account_ticket_count()) + except Exception: + ba.print_exception() + tc_str = '-' + ba.textwidget(edit=self._tickets_text, + text=ba.Lstr(resource=self._r + '.ticketsText', + subs=[('${COUNT}', tc_str)])) + + def _refresh_account_name_text(self) -> None: + if self._account_name_text is None: + return + try: + name_str = _ba.get_account_display_string() + except Exception: + ba.print_exception() + name_str = '??' + ba.textwidget(edit=self._account_name_text, text=name_str) + + def _refresh_achievements(self) -> None: + if (self._achievements_text is None + and self._achievements_button is None): + return + complete = sum(1 if a.complete else 0 for a in ba.app.achievements) + total = len(ba.app.achievements) + txt_final = ba.Lstr(resource=self._r + '.achievementProgressText', + subs=[('${COUNT}', str(complete)), + ('${TOTAL}', str(total))]) + + if self._achievements_text is not None: + ba.textwidget(edit=self._achievements_text, text=txt_final) + if self._achievements_button is not None: + ba.buttonwidget(edit=self._achievements_button, label=txt_final) + + def _link_accounts_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.account import link + link.AccountLinkWindow(origin_widget=self._link_accounts_button) + + def _unlink_accounts_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.account import unlink + if not self._have_unlinkable_accounts(): + ba.playsound(ba.getsound('error')) + return + unlink.AccountUnlinkWindow(origin_widget=self._unlink_accounts_button) + + def _player_profiles_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.profile import browser as pbrowser + self._save_state() + ba.containerwidget(edit=self._root_widget, transition="out_left") + pbrowser.ProfileBrowserWindow( + origin_widget=self._player_profiles_button) + + def _sign_out_press(self) -> None: + _ba.sign_out() + cfg = ba.app.config + + # Take note that its our *explicit* intention to not be signed in at + # this point. + cfg['Auto Account State'] = 'signed_out' + cfg.commit() + ba.buttonwidget(edit=self._sign_out_button, + label=ba.Lstr(resource=self._r + '.signingOutText')) + + def _sign_in_press(self, account_type: str, + show_test_warning: bool = True) -> None: + del show_test_warning # unused + _ba.sign_in(account_type) + + # Make note of the type account we're *wanting* to be signed in with. + cfg = ba.app.config + cfg['Auto Account State'] = account_type + cfg.commit() + self._needs_refresh = True + ba.timer(0.1, ba.WeakCall(self._update), timetype=ba.TimeType.REAL) + + def _reset_progress(self) -> None: + try: + from ba.internal import get_campaign + # FIXME: This would need to happen server-side these days. + if self._can_reset_achievements: + ba.app.config['Achievements'] = {} + _ba.reset_achievements() + campaign = get_campaign('Default') + campaign.reset() # also writes the config.. + campaign = get_campaign('Challenges') + campaign.reset() # also writes the config.. + except Exception: + ba.print_exception('exception resetting co-op campaign progress') + + ba.playsound(ba.getsound('shieldDown')) + self._refresh() + + def _back(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import mainmenu + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + + if not self._modal: + ba.app.main_menu_window = (mainmenu.MainMenuWindow( + transition='in_left').get_root_widget()) + + def _save_state(self) -> None: + try: + sel = self._root_widget.get_selected_child() + if sel == self._back_button: + sel_name = 'Back' + elif sel == self._scrollwidget: + sel_name = 'Scroll' + else: + raise Exception("unrecognized selection") + ba.app.window_states[self.__class__.__name__] = sel_name + except Exception: + ba.print_exception('exception saving state for', self.__class__) + + def _restore_state(self) -> None: + try: + try: + sel_name = ba.app.window_states[self.__class__.__name__] + except Exception: + sel_name = None + if sel_name == 'Back': + sel = self._back_button + elif sel_name == 'Scroll': + sel = self._scrollwidget + else: + sel = self._back_button + ba.containerwidget(edit=self._root_widget, selected_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) diff --git a/assets/src/data/scripts/bastd/ui/account/unlink.py b/assets/src/data/scripts/bastd/ui/account/unlink.py new file mode 100644 index 00000000..e85b9dec --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/account/unlink.py @@ -0,0 +1,117 @@ +"""UI functionality for unlinking accounts.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Optional, Tuple, Dict + + +class AccountUnlinkWindow(ba.OldWindow): + """A window to kick off account unlinks.""" + + def __init__(self, origin_widget: ba.Widget = None): + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + transition = 'in_right' + bg_color = (0.4, 0.4, 0.5) + self._width = 540 + self._height = 350 + self._scroll_width = 400 + self._scroll_height = 200 + base_scale = (2.0 + if ba.app.small_ui else 1.6 if ba.app.med_ui else 1.1) + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition=transition, + scale=base_scale, + scale_origin_stack_offset=scale_origin, + stack_offset=(0, -10) if ba.app.small_ui else (0, 0))) + self._cancel_button = ba.buttonwidget(parent=self._root_widget, + position=(30, self._height - 50), + size=(50, 50), + scale=0.7, + label='', + color=bg_color, + on_activate_call=self._cancel, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.88), + size=(0, 0), + text=ba.Lstr( + resource='accountSettingsWindow.unlinkAccountsInstructionsText' + ), + maxwidth=self._width * 0.7, + color=ba.app.infotextcolor, + h_align='center', + v_align='center') + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + highlight=False, + position=((self._width - self._scroll_width) * 0.5, + self._height - 85 - self._scroll_height), + size=(self._scroll_width, self._scroll_height)) + ba.containerwidget(edit=self._scrollwidget, claims_left_right=True) + self._columnwidget = ba.columnwidget(parent=self._scrollwidget, + left_border=10) + + our_login_id = _ba.get_public_login_id() + if our_login_id is None: + entries = [] + else: + account_infos = _ba.get_account_misc_read_val_2( + 'linkedAccounts2', []) + entries = [{ + 'name': ai['d'], + 'id': ai['id'] + } for ai in account_infos if ai['id'] != our_login_id] + + # (avoid getting our selection stuck on an empty column widget) + if not entries: + ba.containerwidget(edit=self._scrollwidget, selectable=False) + for i, entry in enumerate(entries): + txt = ba.textwidget(parent=self._columnwidget, + selectable=True, + text=entry['name'], + size=(self._scroll_width - 30, 30), + autoselect=True, + click_activate=True, + on_activate_call=ba.Call( + self._on_entry_selected, entry)) + ba.widget(edit=txt, left_widget=self._cancel_button) + if i == 0: + ba.widget(edit=txt, up_widget=self._cancel_button) + + def _on_entry_selected(self, entry: Dict[str, Any]) -> None: + ba.screenmessage(ba.Lstr(resource='pleaseWaitText', + fallback_resource='requestingText'), + color=(0, 1, 0)) + _ba.add_transaction({ + 'type': 'ACCOUNT_UNLINK_REQUEST', + 'accountID': entry['id'], + 'expire_time': time.time() + 5 + }) + _ba.run_transactions() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + + def _cancel(self) -> None: + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) diff --git a/assets/src/data/scripts/bastd/ui/account/viewer.py b/assets/src/data/scripts/bastd/ui/account/viewer.py new file mode 100644 index 00000000..48398f26 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/account/viewer.py @@ -0,0 +1,470 @@ +"""Provides a popup for displaying info about any account.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.ui import popup + +if TYPE_CHECKING: + from typing import Any, Tuple, Dict, Optional + + +class AccountViewerWindow(popup.PopupWindow): + """Popup window that displays info for an account.""" + + def __init__(self, + account_id: str, + profile_id: str = None, + position: Tuple[float, float] = (0.0, 0.0), + scale: float = None, + offset: Tuple[float, float] = (0.0, 0.0)): + from ba.internal import is_browser_likely_available, serverget + + self._account_id = account_id + self._profile_id = profile_id + + if scale is None: + scale = (2.6 if ba.app.small_ui else 1.8 if ba.app.med_ui else 1.4) + self._transitioning_out = False + + self._width = 400 + self._height = (300 + if ba.app.small_ui else 400 if ba.app.med_ui else 450) + self._subcontainer: Optional[ba.Widget] = None + + bg_color = (0.5, 0.4, 0.6) + + # Creates our _root_widget. + popup.PopupWindow.__init__(self, + position=position, + size=(self._width, self._height), + scale=scale, + bg_color=bg_color, + offset=offset) + + self._cancel_button = ba.buttonwidget( + parent=self.root_widget, + position=(50, self._height - 30), + size=(50, 50), + scale=0.5, + label='', + color=bg_color, + on_activate_call=self._on_cancel_press, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + + self._title_text = ba.textwidget( + parent=self.root_widget, + position=(self._width * 0.5, self._height - 20), + size=(0, 0), + h_align='center', + v_align='center', + scale=0.6, + text=ba.Lstr(resource='playerInfoText'), + maxwidth=200, + color=(0.7, 0.7, 0.7, 0.7)) + + self._scrollwidget = ba.scrollwidget(parent=self.root_widget, + size=(self._width - 60, + self._height - 70), + position=(30, 30), + capture_arrows=True, + simple_culling_v=10) + ba.widget(edit=self._scrollwidget, autoselect=True) + + self._loading_text = ba.textwidget( + parent=self._scrollwidget, + scale=0.5, + text=ba.Lstr(value='${A}...', + subs=[('${A}', ba.Lstr(resource='loadingText'))]), + size=(self._width - 60, 100), + h_align='center', + v_align='center') + + # In cases where the user most likely has a browser/email, lets + # offer a 'report this user' button. + if (is_browser_likely_available() and _ba.get_account_misc_read_val( + 'showAccountExtrasMenu', False)): + + self._extras_menu_button = ba.buttonwidget( + parent=self.root_widget, + size=(20, 20), + position=(self._width - 60, self._height - 30), + autoselect=True, + label='...', + button_type='square', + color=(0.64, 0.52, 0.69), + textcolor=(0.57, 0.47, 0.57), + on_activate_call=self._on_extras_menu_press) + + ba.containerwidget(edit=self.root_widget, + cancel_button=self._cancel_button) + + serverget('bsAccountInfo', { + 'buildNumber': ba.app.build_number, + 'accountID': self._account_id, + 'profileID': self._profile_id + }, + callback=ba.WeakCall(self._on_query_response)) + + def popup_menu_selected_choice(self, window: popup.PopupMenu, + choice: str) -> None: + """Called when a menu entry is selected.""" + del window # Unused arg. + if choice == 'more': + self._on_more_press() + elif choice == 'report': + self._on_report_press() + elif choice == 'ban': + self._on_ban_press() + else: + print('ERROR: unknown account info extras menu item:', choice) + + def popup_menu_closing(self, window: popup.PopupMenu) -> None: + """Called when the popup menu is closing.""" + + def _on_extras_menu_press(self) -> None: + choices = ['more', 'report'] + choices_display = [ + ba.Lstr(resource='coopSelectWindow.seeMoreText'), + ba.Lstr(resource='reportThisPlayerText') + ] + is_admin = False + if is_admin: + ba.screenmessage('TEMP FORCING ADMIN ON') + choices.append('ban') + choices_display.append(ba.Lstr(resource='banThisPlayerText')) + + popup.PopupMenuWindow( + position=self._extras_menu_button.get_screen_space_center(), + scale=2.3 if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23, + choices=choices, + choices_display=choices_display, + current_choice='more', + delegate=self) + + def _on_ban_press(self) -> None: + _ba.add_transaction({ + 'type': 'BAN_ACCOUNT', + 'account': self._account_id + }) + _ba.run_transactions() + + def _on_report_press(self) -> None: + from bastd.ui import report + report.ReportPlayerWindow(self._account_id, + origin_widget=self._extras_menu_button) + + def _on_more_press(self) -> None: + ba.open_url(_ba.get_master_server_address() + '/highscores?profile=' + + self._account_id) + + def _on_query_response(self, data: Optional[Dict[str, Any]]) -> None: + # FIXME: Tidy this up. + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-nested-blocks + if data is None: + ba.textwidget( + edit=self._loading_text, + text=ba.Lstr(resource='internal.unavailableNoConnectionText')) + else: + try: + self._loading_text.delete() + trophystr = '' + try: + trophystr = data['trophies'] + num = 10 + chunks = [ + trophystr[i:i + num] + for i in range(0, len(trophystr), num) + ] + trophystr = ('\n\n'.join(chunks)) + if trophystr == '': + trophystr = '-' + except Exception: + ba.print_exception("Error displaying trophies") + account_name_spacing = 15 + tscale = 0.65 + ts_height = _ba.get_string_height(trophystr, + suppress_warning=True) + sub_width = self._width - 80 + sub_height = 200 + ts_height * tscale + \ + account_name_spacing * len(data['accountDisplayStrings']) + self._subcontainer = ba.containerwidget( + parent=self._scrollwidget, + size=(sub_width, sub_height), + background=False) + v = sub_height - 20 + + title_scale = 0.37 + center = 0.3 + maxwidth_scale = 0.45 + showing_character = False + if data['profileDisplayString'] is not None: + tint_color = (1, 1, 1) + try: + if data['profile'] is not None: + profile = data['profile'] + character = ba.app.spaz_appearances.get( + profile['character'], None) + if character is not None: + tint_color = (profile['color'] + if 'color' in profile else + (1, 1, 1)) + tint2_color = (profile['highlight'] + if 'highlight' in profile else + (1, 1, 1)) + icon_tex = character.icon_texture + tint_tex = character.icon_mask_texture + mask_texture = ba.gettexture( + 'characterIconMask') + ba.imagewidget( + parent=self._subcontainer, + position=(sub_width * center - 40, v - 80), + size=(80, 80), + color=(1, 1, 1), + mask_texture=mask_texture, + texture=ba.gettexture(icon_tex), + tint_texture=ba.gettexture(tint_tex), + tint_color=tint_color, + tint2_color=tint2_color) + v -= 95 + except Exception: + ba.print_exception("Error displaying character") + ba.textwidget( + parent=self._subcontainer, + size=(0, 0), + position=(sub_width * center, v), + h_align='center', + v_align='center', + scale=0.9, + color=ba.safecolor(tint_color, 0.7), + shadow=1.0, + text=ba.Lstr(value=data['profileDisplayString']), + maxwidth=sub_width * maxwidth_scale * 0.75) + showing_character = True + v -= 33 + + center = 0.75 if showing_character else 0.5 + maxwidth_scale = 0.45 if showing_character else 0.9 + + v = sub_height - 20 + if len(data['accountDisplayStrings']) <= 1: + account_title = ba.Lstr( + resource='settingsWindow.accountText') + else: + account_title = ba.Lstr( + resource='accountSettingsWindow.accountsText', + fallback_resource='settingsWindow.accountText') + ba.textwidget(parent=self._subcontainer, + size=(0, 0), + position=(sub_width * center, v), + flatness=1.0, + h_align='center', + v_align='center', + scale=title_scale, + color=ba.app.infotextcolor, + text=account_title, + maxwidth=sub_width * maxwidth_scale) + draw_small = (showing_character + or len(data['accountDisplayStrings']) > 1) + v -= 14 if draw_small else 20 + for account_string in data['accountDisplayStrings']: + ba.textwidget(parent=self._subcontainer, + size=(0, 0), + position=(sub_width * center, v), + h_align='center', + v_align='center', + scale=0.55 if draw_small else 0.8, + text=account_string, + maxwidth=sub_width * maxwidth_scale) + v -= account_name_spacing + + v += account_name_spacing + v -= 25 if showing_character else 29 + + ba.textwidget(parent=self._subcontainer, + size=(0, 0), + position=(sub_width * center, v), + flatness=1.0, + h_align='center', + v_align='center', + scale=title_scale, + color=ba.app.infotextcolor, + text=ba.Lstr(resource='rankText'), + maxwidth=sub_width * maxwidth_scale) + v -= 14 + if data['rank'] is None: + rank_str = '-' + suffix_offset = None + else: + str_raw = ba.Lstr( + resource='league.rankInLeagueText').evaluate() + # FIXME: Would be nice to not have to eval this. + rank_str = ba.Lstr( + resource='league.rankInLeagueText', + subs=[('${RANK}', str(data['rank'][2])), + ('${NAME}', + ba.Lstr(translate=('leagueNames', + data['rank'][0]))), + ('${SUFFIX}', '')]).evaluate() + rank_str_width = min( + sub_width * maxwidth_scale, + _ba.get_string_width(rank_str, suppress_warning=True) * + 0.55) + + # Only tack our suffix on if its at the end and only for + # non-diamond leagues. + if (str_raw.endswith('${SUFFIX}') + and data['rank'][0] != 'Diamond'): + suffix_offset = rank_str_width * 0.5 + 2 + else: + suffix_offset = None + + ba.textwidget(parent=self._subcontainer, + size=(0, 0), + position=(sub_width * center, v), + h_align='center', + v_align='center', + scale=0.55, + text=rank_str, + maxwidth=sub_width * maxwidth_scale) + if suffix_offset is not None: + assert data['rank'] is not None + ba.textwidget(parent=self._subcontainer, + size=(0, 0), + position=(sub_width * center + suffix_offset, + v + 3), + h_align='left', + v_align='center', + scale=0.29, + flatness=1.0, + text='[' + str(data['rank'][1]) + ']') + v -= 14 + + str_raw = ba.Lstr( + resource='league.rankInLeagueText').evaluate() + old_offs = -50 + prev_ranks_shown = 0 + for prev_rank in data['prevRanks']: + rank_str = ba.Lstr( + value='${S}: ${I}', + subs=[ + ('${S}', + ba.Lstr(resource='league.seasonText', + subs=[('${NUMBER}', str(prev_rank[0]))])), + ('${I}', + ba.Lstr(resource='league.rankInLeagueText', + subs=[('${RANK}', str(prev_rank[3])), + ('${NAME}', + ba.Lstr(translate=('leagueNames', + prev_rank[1]))), + ('${SUFFIX}', '')])) + ]).evaluate() + rank_str_width = min( + sub_width * maxwidth_scale, + _ba.get_string_width(rank_str, suppress_warning=True) * + 0.3) + + # Only tack our suffix on if its at the end and only for + # non-diamond leagues. + if (str_raw.endswith('${SUFFIX}') + and prev_rank[1] != 'Diamond'): + suffix_offset = rank_str_width + 2 + else: + suffix_offset = None + ba.textwidget(parent=self._subcontainer, + size=(0, 0), + position=(sub_width * center + old_offs, v), + h_align='left', + v_align='center', + scale=0.3, + text=rank_str, + flatness=1.0, + maxwidth=sub_width * maxwidth_scale) + if suffix_offset is not None: + ba.textwidget(parent=self._subcontainer, + size=(0, 0), + position=(sub_width * center + old_offs + + suffix_offset, v + 1), + h_align='left', + v_align='center', + scale=0.20, + flatness=1.0, + text='[' + str(prev_rank[2]) + ']') + prev_ranks_shown += 1 + v -= 10 + + v -= 13 + + ba.textwidget(parent=self._subcontainer, + size=(0, 0), + position=(sub_width * center, v), + flatness=1.0, + h_align='center', + v_align='center', + scale=title_scale, + color=ba.app.infotextcolor, + text=ba.Lstr(resource='achievementsText'), + maxwidth=sub_width * maxwidth_scale) + v -= 14 + ba.textwidget(parent=self._subcontainer, + size=(0, 0), + position=(sub_width * center, v), + h_align='center', + v_align='center', + scale=0.55, + text=str(data['achievementsCompleted']) + ' / ' + + str(len(ba.app.achievements)), + maxwidth=sub_width * maxwidth_scale) + v -= 25 + + if prev_ranks_shown == 0 and showing_character: + v -= 20 + elif prev_ranks_shown == 1 and showing_character: + v -= 10 + + center = 0.5 + maxwidth_scale = 0.9 + + ba.textwidget(parent=self._subcontainer, + size=(0, 0), + position=(sub_width * center, v), + h_align='center', + v_align='center', + scale=title_scale, + color=ba.app.infotextcolor, + flatness=1.0, + text=ba.Lstr(resource='trophiesThisSeasonText', + fallback_resource='trophiesText'), + maxwidth=sub_width * maxwidth_scale) + v -= 19 + ba.textwidget(parent=self._subcontainer, + size=(0, ts_height), + position=(sub_width * 0.5, + v - ts_height * tscale), + h_align='center', + v_align='top', + corner_scale=tscale, + text=trophystr) + + except Exception: + ba.print_exception('Error displaying account info') + + def _on_cancel_press(self) -> None: + self._transition_out() + + def _transition_out(self) -> None: + if not self._transitioning_out: + self._transitioning_out = True + ba.containerwidget(edit=self.root_widget, transition='out_scale') + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._transition_out() diff --git a/assets/src/data/scripts/bastd/ui/achievements.py b/assets/src/data/scripts/bastd/ui/achievements.py new file mode 100644 index 00000000..163944b0 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/achievements.py @@ -0,0 +1,197 @@ +"""Provides a popup window to view achievements.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.ui import popup + +if TYPE_CHECKING: + from typing import Tuple + + +class AchievementsWindow(popup.PopupWindow): + """Popup window to view achievements.""" + + def __init__(self, position: Tuple[float, float], scale: float = None): + # pylint: disable=too-many-locals + if scale is None: + scale = (2.3 + if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + self._transitioning_out = False + self._width = 450 + self._height = (300 + if ba.app.small_ui else 370 if ba.app.med_ui else 450) + bg_color = (0.5, 0.4, 0.6) + + # creates our _root_widget + popup.PopupWindow.__init__(self, + position=position, + size=(self._width, self._height), + scale=scale, + bg_color=bg_color) + + self._cancel_button = ba.buttonwidget( + parent=self.root_widget, + position=(50, self._height - 30), + size=(50, 50), + scale=0.5, + label='', + color=bg_color, + on_activate_call=self._on_cancel_press, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + + achievements = ba.app.achievements + num_complete = len([a for a in achievements if a.complete]) + + txt_final = ba.Lstr( + resource='accountSettingsWindow.achievementProgressText', + subs=[('${COUNT}', str(num_complete)), + ('${TOTAL}', str(len(achievements)))]) + self._title_text = ba.textwidget(parent=self.root_widget, + position=(self._width * 0.5, + self._height - 20), + size=(0, 0), + h_align='center', + v_align='center', + scale=0.6, + text=txt_final, + maxwidth=200, + color=(1, 1, 1, 0.4)) + + self._scrollwidget = ba.scrollwidget(parent=self.root_widget, + size=(self._width - 60, + self._height - 70), + position=(30, 30), + capture_arrows=True, + simple_culling_v=10) + ba.widget(edit=self._scrollwidget, autoselect=True) + + ba.containerwidget(edit=self.root_widget, + cancel_button=self._cancel_button) + + incr = 36 + sub_width = self._width - 90 + sub_height = 40 + len(achievements) * incr + + eq_rsrc = 'coopSelectWindow.powerRankingPointsEqualsText' + pts_rsrc = 'coopSelectWindow.powerRankingPointsText' + + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(sub_width, sub_height), + background=False) + + total_pts = 0 + for i, ach in enumerate(achievements): + complete = ach.complete + ba.textwidget(parent=self._subcontainer, + position=(sub_width * 0.08 - 5, + sub_height - 20 - incr * i), + maxwidth=20, + scale=0.5, + color=(0.6, 0.6, 0.7) if complete else + (0.6, 0.6, 0.7, 0.2), + flatness=1.0, + shadow=0.0, + text=str(i + 1), + size=(0, 0), + h_align='right', + v_align='center') + + ba.imagewidget(parent=self._subcontainer, + position=(sub_width * 0.10 + 1, sub_height - 20 - + incr * i - 9) if complete else + (sub_width * 0.10 - 4, + sub_height - 20 - incr * i - 14), + size=(18, 18) if complete else (27, 27), + opacity=1.0 if complete else 0.3, + color=ach.get_icon_color(complete)[:3], + texture=ach.get_icon_texture(complete)) + if complete: + ba.imagewidget(parent=self._subcontainer, + position=(sub_width * 0.10 - 4, + sub_height - 25 - incr * i - 9), + size=(28, 28), + color=(2, 1.4, 0), + texture=ba.gettexture('achievementOutline')) + ba.textwidget(parent=self._subcontainer, + position=(sub_width * 0.19, + sub_height - 19 - incr * i + 3), + maxwidth=sub_width * 0.62, + scale=0.6, + flatness=1.0, + shadow=0.0, + color=(1, 1, 1) if complete else (1, 1, 1, 0.2), + text=ach.display_name, + size=(0, 0), + h_align='left', + v_align='center') + + ba.textwidget(parent=self._subcontainer, + position=(sub_width * 0.19, + sub_height - 19 - incr * i - 10), + maxwidth=sub_width * 0.62, + scale=0.4, + flatness=1.0, + shadow=0.0, + color=(0.83, 0.8, 0.85) if complete else + (0.8, 0.8, 0.8, 0.2), + text=ach.description_full_complete + if complete else ach.description_full, + size=(0, 0), + h_align='left', + v_align='center') + + pts = ach.power_ranking_value + ba.textwidget(parent=self._subcontainer, + position=(sub_width * 0.92, + sub_height - 20 - incr * i), + maxwidth=sub_width * 0.15, + color=(0.7, 0.8, 1.0) if complete else + (0.9, 0.9, 1.0, 0.3), + flatness=1.0, + shadow=0.0, + scale=0.6, + text=ba.Lstr(resource=pts_rsrc, + subs=[('${NUMBER}', str(pts))]), + size=(0, 0), + h_align='center', + v_align='center') + if complete: + total_pts += pts + + ba.textwidget(parent=self._subcontainer, + position=(sub_width * 1.0, + sub_height - 20 - incr * len(achievements)), + maxwidth=sub_width * 0.5, + scale=0.7, + color=(0.7, 0.8, 1.0), + flatness=1.0, + shadow=0.0, + text=ba.Lstr( + value='${A} ${B}', + subs=[ + ('${A}', + ba.Lstr(resource='coopSelectWindow.totalText')), + ('${B}', + ba.Lstr(resource=eq_rsrc, + subs=[('${NUMBER}', str(total_pts))])) + ]), + size=(0, 0), + h_align='right', + v_align='center') + + def _on_cancel_press(self) -> None: + self._transition_out() + + def _transition_out(self) -> None: + if not self._transitioning_out: + self._transitioning_out = True + ba.containerwidget(edit=self.root_widget, transition='out_scale') + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._transition_out() diff --git a/assets/src/data/scripts/bastd/ui/appinvite.py b/assets/src/data/scripts/bastd/ui/appinvite.py new file mode 100644 index 00000000..5e6b6048 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/appinvite.py @@ -0,0 +1,329 @@ +"""UI functionality related to inviting people to try the game.""" + +from __future__ import annotations + +import copy +import time +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Optional, Dict, Union + + +class AppInviteWindow(ba.OldWindow): + """Window for showing different ways to invite people to try the game.""" + + def __init__(self) -> None: + ba.set_analytics_screen('AppInviteWindow') + self._data: Optional[Dict[str, Any]] = None + self._width = 650 + self._height = 400 + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition='in_scale', + scale=1.8 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0)) + + self._cancel_button = ba.buttonwidget(parent=self._root_widget, + scale=0.8, + position=(60, self._height - 50), + size=(50, 50), + label='', + on_activate_call=self.close, + autoselect=True, + color=(0.4, 0.4, 0.6), + icon=ba.gettexture('crossOut'), + iconscale=1.2) + + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + + ba.textwidget( + parent=self._root_widget, + size=(0, 0), + position=(self._width * 0.5, self._height * 0.5 + 110), + autoselect=True, + scale=0.8, + maxwidth=self._width * 0.9, + h_align='center', + v_align='center', + color=(0.3, 0.8, 0.3), + flatness=1.0, + text=ba.Lstr( + resource='gatherWindow.earnTicketsForRecommendingAmountText', + fallback_resource=( + 'gatherWindow.earnTicketsForRecommendingText'), + subs=[ + ('${COUNT}', + str(_ba.get_account_misc_read_val('friendTryTickets', + 300))), + ('${YOU_COUNT}', + str( + _ba.get_account_misc_read_val('friendTryAwardTickets', + 100))) + ])) + + or_text = ba.Lstr(resource='orText', + subs=[('${A}', ''), + ('${B}', '')]).evaluate().strip() + ba.buttonwidget( + parent=self._root_widget, + size=(250, 150), + position=(self._width * 0.5 - 125, self._height * 0.5 - 80), + autoselect=True, + button_type='square', + label=ba.Lstr(resource='gatherWindow.inviteFriendsText'), + on_activate_call=ba.WeakCall(self._google_invites)) + + ba.textwidget(parent=self._root_widget, + size=(0, 0), + position=(self._width * 0.5, self._height * 0.5 - 94), + autoselect=True, + scale=0.9, + h_align='center', + v_align='center', + color=(0.5, 0.5, 0.5), + flatness=1.0, + text=or_text) + + ba.buttonwidget( + parent=self._root_widget, + size=(180, 50), + position=(self._width * 0.5 - 90, self._height * 0.5 - 170), + autoselect=True, + color=(0.5, 0.5, 0.6), + textcolor=(0.7, 0.7, 0.8), + text_scale=0.8, + label=ba.Lstr(resource='gatherWindow.appInviteSendACodeText'), + on_activate_call=ba.WeakCall(self._send_code)) + + # kick off a transaction to get our code + _ba.add_transaction( + { + 'type': 'FRIEND_PROMO_CODE_REQUEST', + 'ali': False, + 'expire_time': time.time() + 20 + }, + callback=ba.WeakCall(self._on_code_result)) + _ba.run_transactions() + + def _on_code_result(self, result: Optional[Dict[str, Any]]) -> None: + if result is not None: + self._data = result + + def _send_code(self) -> None: + handle_app_invites_press(force_code=True) + + def _google_invites(self) -> None: + if self._data is None: + ba.screenmessage(ba.Lstr( + resource='getTicketsWindow.unavailableTemporarilyText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + if _ba.get_account_state() == 'signed_in': + ba.set_analytics_screen('App Invite UI') + _ba.show_app_invite( + ba.Lstr(resource='gatherWindow.appInviteTitleText', + subs=[('${APP_NAME}', ba.Lstr(resource='titleText')) + ]).evaluate(), + ba.Lstr(resource='gatherWindow.appInviteMessageText', + subs=[('${COUNT}', str(self._data['tickets'])), + ('${NAME}', _ba.get_account_name().split()[0]), + ('${APP_NAME}', ba.Lstr(resource='titleText')) + ]).evaluate(), self._data['code']) + else: + ba.playsound(ba.getsound('error')) + + def close(self) -> None: + """Close the window.""" + ba.containerwidget(edit=self._root_widget, transition='out_scale') + + +class ShowFriendCodeWindow(ba.OldWindow): + """Window showing a code for sharing with friends.""" + + def __init__(self, data: Dict[str, Any]): + from ba.internal import is_browser_likely_available + ba.set_analytics_screen('Friend Promo Code') + self._width = 650 + self._height = 400 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + color=(0.45, 0.63, 0.15), + transition='in_scale', + scale=1.7 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0)) + self._data = copy.deepcopy(data) + ba.playsound(ba.getsound('cashRegister')) + ba.playsound(ba.getsound('swish')) + + self._cancel_button = ba.buttonwidget(parent=self._root_widget, + scale=0.7, + position=(50, self._height - 50), + size=(60, 60), + label='', + on_activate_call=self.close, + autoselect=True, + color=(0.45, 0.63, 0.15), + icon=ba.gettexture('crossOut'), + iconscale=1.2) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + + ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.8), + size=(0, 0), + color=ba.app.infotextcolor, + scale=1.0, + flatness=1.0, + h_align="center", + v_align="center", + text=ba.Lstr(resource='gatherWindow.shareThisCodeWithFriendsText'), + maxwidth=self._width * 0.85) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.645), + size=(0, 0), + color=(1.0, 3.0, 1.0), + scale=2.0, + h_align="center", + v_align="center", + text=data['code'], + maxwidth=self._width * 0.85) + + award_str: Optional[Union[str, ba.Lstr]] + if self._data['awardTickets'] != 0: + award_str = ba.Lstr( + resource='gatherWindow.friendPromoCodeAwardText', + subs=[('${COUNT}', str(self._data['awardTickets']))]) + else: + award_str = '' + ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.37), + size=(0, 0), + color=ba.app.infotextcolor, + scale=1.0, + flatness=1.0, + h_align="center", + v_align="center", + text=ba.Lstr( + value='${A}\n${B}\n${C}\n${D}', + subs=[ + ('${A}', + ba.Lstr( + resource='gatherWindow.friendPromoCodeRedeemLongText', + subs=[('${COUNT}', str(self._data['tickets'])), + ('${MAX_USES}', + str(self._data['usesRemaining']))])), + ('${B}', + ba.Lstr(resource=( + 'gatherWindow.friendPromoCodeWhereToEnterText'))), + ('${C}', award_str), + ('${D}', + ba.Lstr(resource='gatherWindow.friendPromoCodeExpireText', + subs=[('${EXPIRE_HOURS}', + str(self._data['expireHours']))])) + ]), + maxwidth=self._width * 0.9, + max_height=self._height * 0.35) + + if is_browser_likely_available(): + xoffs = 0 + ba.buttonwidget(parent=self._root_widget, + size=(200, 40), + position=(self._width * 0.5 - 100 + xoffs, 39), + autoselect=True, + label=ba.Lstr(resource='gatherWindow.emailItText'), + on_activate_call=ba.WeakCall(self._email)) + + def _google_invites(self) -> None: + ba.set_analytics_screen('App Invite UI') + _ba.show_app_invite( + ba.Lstr(resource='gatherWindow.appInviteTitleText', + subs=[('${APP_NAME}', ba.Lstr(resource='titleText')) + ]).evaluate(), + ba.Lstr(resource='gatherWindow.appInviteMessageText', + subs=[('${COUNT}', str(self._data['tickets'])), + ('${NAME}', _ba.get_account_name().split()[0]), + ('${APP_NAME}', ba.Lstr(resource='titleText')) + ]).evaluate(), self._data['code']) + + def _email(self) -> None: + import urllib.parse + + # If somehow we got signed out. + if _ba.get_account_state() != 'signed_in': + ba.screenmessage(ba.Lstr(resource='notSignedInText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + ba.set_analytics_screen('Email Friend Code') + subject = (ba.Lstr(resource='gatherWindow.friendHasSentPromoCodeText'). + evaluate().replace( + '${NAME}', _ba.get_account_name()).replace( + '${APP_NAME}', + ba.Lstr(resource='titleText').evaluate()).replace( + '${COUNT}', str(self._data['tickets']))) + body = (ba.Lstr(resource='gatherWindow.youHaveBeenSentAPromoCodeText'). + evaluate().replace('${APP_NAME}', + ba.Lstr(resource='titleText').evaluate()) + + '\n\n' + str(self._data['code']) + '\n\n') + body += ( + (ba.Lstr(resource='gatherWindow.friendPromoCodeRedeemShortText'). + evaluate().replace('${COUNT}', str(self._data['tickets']))) + + '\n\n' + + ba.Lstr(resource='gatherWindow.friendPromoCodeInstructionsText'). + evaluate().replace('${APP_NAME}', + ba.Lstr(resource='titleText').evaluate()) + + '\n' + ba.Lstr(resource='gatherWindow.friendPromoCodeExpireText'). + evaluate().replace('${EXPIRE_HOURS}', str( + self._data['expireHours'])) + '\n' + + ba.Lstr(resource='enjoyText').evaluate()) + ba.open_url('mailto:?subject=' + urllib.parse.quote(subject) + + '&body=' + urllib.parse.quote(body)) + + def close(self) -> None: + """Close the window.""" + ba.containerwidget(edit=self._root_widget, transition='out_scale') + + +def handle_app_invites_press(force_code: bool = False) -> None: + """(internal)""" + app = ba.app + do_app_invites = (app.platform == 'android' and app.subplatform == 'google' + and _ba.get_account_misc_read_val( + 'enableAppInvites', False) and not app.on_tv) + if force_code: + do_app_invites = False + # FIXME: Should update this to grab a code before showing the invite UI. + if do_app_invites: + AppInviteWindow() + else: + ba.screenmessage( + ba.Lstr(resource='gatherWindow.requestingAPromoCodeText'), + color=(0, 1, 0)) + + def handle_result(result: Dict[str, Any]) -> None: + with ba.Context('ui'): + if result is None: + ba.screenmessage(ba.Lstr(resource='errorText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + else: + ShowFriendCodeWindow(result) + + _ba.add_transaction( + { + 'type': 'FRIEND_PROMO_CODE_REQUEST', + 'ali': False, + 'expire_time': time.time() + 10 + }, + callback=handle_result) + _ba.run_transactions() diff --git a/assets/src/data/scripts/bastd/ui/characterpicker.py b/assets/src/data/scripts/bastd/ui/characterpicker.py new file mode 100644 index 00000000..a44b2459 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/characterpicker.py @@ -0,0 +1,176 @@ +"""Provides a picker for characters.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.ui import popup + +if TYPE_CHECKING: + from typing import Any, Tuple, Sequence + + +class CharacterPicker(popup.PopupWindow): + """Popup window for selecting characters.""" + + def __init__(self, + parent: ba.Widget, + position: Tuple[float, float] = (0.0, 0.0), + delegate: Any = None, + scale: float = None, + offset: Tuple[float, float] = (0.0, 0.0), + tint_color: Sequence[float] = (1.0, 1.0, 1.0), + tint2_color: Sequence[float] = (1.0, 1.0, 1.0), + selected_character: str = None): + # pylint: disable=too-many-locals + from bastd.actor import spazappearance + del parent # unused here + if scale is None: + scale = (1.85 + if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + + self._delegate = delegate + self._transitioning_out = False + + # make a list of spaz icons + self._spazzes = spazappearance.get_appearances() + self._spazzes.sort() + self._icon_textures = [ + ba.gettexture(ba.app.spaz_appearances[s].icon_texture) + for s in self._spazzes + ] + self._icon_tint_textures = [ + ba.gettexture(ba.app.spaz_appearances[s].icon_mask_texture) + for s in self._spazzes + ] + + count = len(self._spazzes) + + columns = 3 + rows = int(math.ceil(float(count) / columns)) + + button_width = 100 + button_height = 100 + button_buffer_h = 10 + button_buffer_v = 15 + + self._width = (10 + columns * (button_width + 2 * button_buffer_h) * + (1.0 / 0.95) * (1.0 / 0.8)) + self._height = self._width * (0.8 if ba.app.small_ui else 1.06) + + self._scroll_width = self._width * 0.8 + self._scroll_height = self._height * 0.8 + self._scroll_position = ((self._width - self._scroll_width) * 0.5, + (self._height - self._scroll_height) * 0.5) + + # creates our _root_widget + popup.PopupWindow.__init__(self, + position=position, + size=(self._width, self._height), + scale=scale, + bg_color=(0.5, 0.5, 0.5), + offset=offset, + focus_position=self._scroll_position, + focus_size=(self._scroll_width, + self._scroll_height)) + + self._scrollwidget = ba.scrollwidget(parent=self.root_widget, + size=(self._scroll_width, + self._scroll_height), + color=(0.55, 0.55, 0.55), + highlight=False, + position=self._scroll_position) + ba.containerwidget(edit=self._scrollwidget, claims_left_right=True) + + self._sub_width = self._scroll_width * 0.95 + self._sub_height = 5 + rows * (button_height + + 2 * button_buffer_v) + 100 + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._sub_width, + self._sub_height), + background=False) + index = 0 + mask_texture = ba.gettexture('characterIconMask') + for y in range(rows): + for x in range(columns): + pos = (x * (button_width + 2 * button_buffer_h) + + button_buffer_h, self._sub_height - (y + 1) * + (button_height + 2 * button_buffer_v) + 12) + btn = ba.buttonwidget( + parent=self._subcontainer, + button_type='square', + size=(button_width, button_height), + autoselect=True, + texture=self._icon_textures[index], + tint_texture=self._icon_tint_textures[index], + mask_texture=mask_texture, + label='', + color=(1, 1, 1), + tint_color=tint_color, + tint2_color=tint2_color, + on_activate_call=ba.Call(self._select_character, + self._spazzes[index]), + position=pos) + ba.widget(edit=btn, show_buffer_top=60, show_buffer_bottom=60) + if self._spazzes[index] == selected_character: + ba.containerwidget(edit=self._subcontainer, + selected_child=btn, + visible_child=btn) + name = ba.Lstr(translate=('characterNames', + self._spazzes[index])) + ba.textwidget(parent=self._subcontainer, + text=name, + position=(pos[0] + button_width * 0.5, + pos[1] - 12), + size=(0, 0), + scale=0.5, + maxwidth=button_width, + draw_controller=btn, + h_align='center', + v_align='center', + color=(0.8, 0.8, 0.8, 0.8)) + index += 1 + + if index >= count: + break + if index >= count: + break + self._get_more_characters_button = btn = ba.buttonwidget( + parent=self._subcontainer, + size=(self._sub_width * 0.8, 60), + position=(self._sub_width * 0.1, 30), + label=ba.Lstr(resource='editProfileWindow.getMoreCharactersText'), + on_activate_call=self._on_store_press, + color=(0.6, 0.6, 0.6), + textcolor=(0.8, 0.8, 0.8), + autoselect=True) + ba.widget(edit=btn, show_buffer_top=30, show_buffer_bottom=30) + + def _on_store_press(self) -> None: + from bastd.ui import account + from bastd.ui.store import browser + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + self._transition_out() + browser.StoreBrowserWindow( + modal=True, + show_tab='characters', + origin_widget=self._get_more_characters_button) + + def _select_character(self, character: str) -> None: + if self._delegate is not None: + self._delegate.on_character_picker_pick(character) + self._transition_out() + + def _transition_out(self) -> None: + if not self._transitioning_out: + self._transitioning_out = True + ba.containerwidget(edit=self.root_widget, transition='out_scale') + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._transition_out() diff --git a/assets/src/data/scripts/bastd/ui/colorpicker.py b/assets/src/data/scripts/bastd/ui/colorpicker.py new file mode 100644 index 00000000..d0b4ed87 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/colorpicker.py @@ -0,0 +1,297 @@ +"""Provides popup windows for choosing colors.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.ui import popup + +if TYPE_CHECKING: + from typing import Any, Tuple, Sequence, List, Optional + + +class ColorPicker(popup.PopupWindow): + """A popup UI to select from a set of colors. + + Passes the color to the delegate's color_picker_selected_color() method. + """ + + def __init__(self, + parent: ba.Widget, + position: Tuple[float, float], + initial_color: Sequence[float] = (1.0, 1.0, 1.0), + delegate: Any = None, + scale: float = None, + offset: Tuple[float, float] = (0.0, 0.0), + tag: Any = ''): + # pylint: disable=too-many-locals + from ba.internal import have_pro, get_player_colors + + c_raw = get_player_colors() + if len(c_raw) != 16: + raise Exception("expected 16 player colors") + self.colors = [c_raw[0:4], c_raw[4:8], c_raw[8:12], c_raw[12:16]] + + if scale is None: + scale = (2.3 + if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + self._parent = parent + self._position = position + self._scale = scale + self._offset = offset + self._delegate = delegate + self._transitioning_out = False + self._tag = tag + self._initial_color = initial_color + + # Create our _root_widget. + popup.PopupWindow.__init__(self, + position=position, + size=(210, 240), + scale=scale, + focus_position=(10, 10), + focus_size=(190, 220), + bg_color=(0.5, 0.5, 0.5), + offset=offset) + rows: List[List[ba.Widget]] = [] + closest_dist = 9999.0 + closest = (0, 0) + for y in range(4): + row: List[ba.Widget] = [] + rows.append(row) + for x in range(4): + color = self.colors[y][x] + dist = (abs(color[0] - initial_color[0]) + + abs(color[1] - initial_color[1]) + + abs(color[2] - initial_color[2])) + if dist < closest_dist: + closest = (x, y) + closest_dist = dist + btn = ba.buttonwidget(parent=self.root_widget, + position=(22 + 45 * x, 185 - 45 * y), + size=(35, 40), + label='', + button_type='square', + on_activate_call=ba.WeakCall( + self._select, x, y), + autoselect=True, + color=color, + extra_touch_border_scale=0.0) + row.append(btn) + other_button = ba.buttonwidget( + parent=self.root_widget, + position=(105 - 60, 13), + color=(0.7, 0.7, 0.7), + text_scale=0.5, + textcolor=(0.8, 0.8, 0.8), + size=(120, 30), + label=ba.Lstr(resource='otherText', + fallback_resource='coopSelectWindow.customText'), + autoselect=True, + on_activate_call=ba.WeakCall(self._select_other)) + + # Custom colors are limited to pro currently. + if not have_pro(): + ba.imagewidget(parent=self.root_widget, + position=(50, 12), + size=(30, 30), + texture=ba.gettexture('lock'), + draw_controller=other_button) + + # If their color is close to one of our swatches, select it. + # Otherwise select 'other'. + if closest_dist < 0.03: + ba.containerwidget(edit=self.root_widget, + selected_child=rows[closest[1]][closest[0]]) + else: + ba.containerwidget(edit=self.root_widget, + selected_child=other_button) + + def get_tag(self) -> Any: + """Return this popup's tag.""" + return self._tag + + def _select_other(self) -> None: + from bastd.ui import purchase + from ba.internal import have_pro + + # Requires pro. + if not have_pro(): + purchase.PurchaseWindow(items=['pro']) + self._transition_out() + return + ColorPickerExact(parent=self._parent, + position=self._position, + initial_color=self._initial_color, + delegate=self._delegate, + scale=self._scale, + offset=self._offset, + tag=self._tag) + + # New picker now 'owns' the delegate; we shouldn't send it any + # more messages. + self._delegate = None + self._transition_out() + + def _select(self, x: int, y: int) -> None: + if self._delegate: + self._delegate.color_picker_selected_color(self, self.colors[y][x]) + ba.timer(0.05, self._transition_out, timetype=ba.TimeType.REAL) + + def _transition_out(self) -> None: + if not self._transitioning_out: + self._transitioning_out = True + if self._delegate is not None: + self._delegate.color_picker_closing(self) + ba.containerwidget(edit=self.root_widget, transition='out_scale') + + def on_popup_cancel(self) -> None: + if not self._transitioning_out: + ba.playsound(ba.getsound('swish')) + self._transition_out() + + +class ColorPickerExact(popup.PopupWindow): + """ pops up a ui to select from a set of colors. + passes the color to the delegate's color_picker_selected_color() method """ + + def __init__(self, + parent: ba.Widget, + position: Tuple[float, float], + initial_color: Sequence[float] = (1.0, 1.0, 1.0), + delegate: Any = None, + scale: float = None, + offset: Tuple[float, float] = (0.0, 0.0), + tag: Any = ''): + # pylint: disable=too-many-locals + del parent # unused var + from ba.internal import get_player_colors + c_raw = get_player_colors() + if len(c_raw) != 16: + raise Exception("expected 16 player colors") + self.colors = [c_raw[0:4], c_raw[4:8], c_raw[8:12], c_raw[12:16]] + + if scale is None: + scale = (2.3 + if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + self._delegate = delegate + self._transitioning_out = False + self._tag = tag + self._color = list(initial_color) + self._last_press_time = ba.time(ba.TimeType.REAL, + ba.TimeFormat.MILLISECONDS) + self._last_press_color_name: Optional[str] = None + self._last_press_increasing: Optional[bool] = None + self._change_speed = 1.0 + width = 180.0 + height = 240.0 + + # creates our _root_widget + popup.PopupWindow.__init__(self, + position=position, + size=(width, height), + scale=scale, + focus_position=(10, 10), + focus_size=(width - 20, height - 20), + bg_color=(0.5, 0.5, 0.5), + offset=offset) + self._swatch = ba.imagewidget(parent=self.root_widget, + position=(width * 0.5 - 50, height - 70), + size=(100, 70), + texture=ba.gettexture('buttonSquare'), + color=(1, 0, 0)) + x = 50 + y = height - 90 + self._label_r: ba.Widget + self._label_g: ba.Widget + self._label_b: ba.Widget + for color_name, color_val in [('r', (1, 0.15, 0.15)), + ('g', (0.15, 1, 0.15)), + ('b', (0.15, 0.15, 1))]: + txt = ba.textwidget(parent=self.root_widget, + position=(x - 10, y), + size=(0, 0), + h_align='center', + color=color_val, + v_align='center', + text='0.12') + setattr(self, '_label_' + color_name, txt) + for b_label, bhval, binc in [('-', 30, False), ('+', 75, True)]: + ba.buttonwidget(parent=self.root_widget, + position=(x + bhval, y - 15), + scale=0.8, + repeat=True, + text_scale=1.3, + size=(40, 40), + label=b_label, + autoselect=True, + enable_sound=False, + on_activate_call=ba.WeakCall( + self._color_change_press, color_name, + binc)) + y -= 42 + + btn = ba.buttonwidget(parent=self.root_widget, + position=(width * 0.5 - 40, 10), + size=(80, 30), + text_scale=0.6, + color=(0.6, 0.6, 0.6), + textcolor=(0.7, 0.7, 0.7), + label=ba.Lstr(resource='doneText'), + on_activate_call=ba.WeakCall( + self._transition_out), + autoselect=True) + ba.containerwidget(edit=self.root_widget, start_button=btn) + + # unlike the swatch picker, we stay open and constantly push our + # color to the delegate, so start doing that... + self._update_for_color() + + # noinspection PyUnresolvedReferences + def _update_for_color(self) -> None: + if not self.root_widget: + return + ba.imagewidget(edit=self._swatch, color=self._color) + # We generate these procedurally, so pylint misses them. + # FIXME: create static attrs instead. + ba.textwidget(edit=self._label_r, text='%.2f' % self._color[0]) + ba.textwidget(edit=self._label_g, text='%.2f' % self._color[1]) + ba.textwidget(edit=self._label_b, text='%.2f' % self._color[2]) + if self._delegate is not None: + self._delegate.color_picker_selected_color(self, self._color) + + def _color_change_press(self, color_name: str, increasing: bool) -> None: + # If we get rapid-fire presses, eventually start moving faster. + current_time = ba.time(ba.TimeType.REAL, ba.TimeFormat.MILLISECONDS) + since_last = current_time - self._last_press_time + if (since_last < 200 and self._last_press_color_name == color_name + and self._last_press_increasing == increasing): + self._change_speed += 0.25 + else: + self._change_speed = 1.0 + self._last_press_time = current_time + self._last_press_color_name = color_name + self._last_press_increasing = increasing + + color_index = ('r', 'g', 'b').index(color_name) + offs = int(self._change_speed) * (0.01 if increasing else -0.01) + self._color[color_index] = max( + 0.0, min(1.0, self._color[color_index] + offs)) + self._update_for_color() + + def get_tag(self) -> Any: + """Return this popup's tag value.""" + return self._tag + + def _transition_out(self) -> None: + if not self._transitioning_out: + self._transitioning_out = True + if self._delegate is not None: + self._delegate.color_picker_closing(self) + ba.containerwidget(edit=self.root_widget, transition='out_scale') + + def on_popup_cancel(self) -> None: + if not self._transitioning_out: + ba.playsound(ba.getsound('swish')) + self._transition_out() diff --git a/assets/src/data/scripts/bastd/ui/config.py b/assets/src/data/scripts/bastd/ui/config.py new file mode 100644 index 00000000..974744fb --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/config.py @@ -0,0 +1,160 @@ +"""Functionality for editing config values and applying them to the game.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Tuple, Union, Callable + + +class ConfigCheckBox: + """A checkbox wired up to control a config value. + + It will automatically save and apply the config when its + value changes. + + Attributes: + + widget + The underlying ba.Widget instance. + """ + + def __init__(self, + parent: ba.Widget, + configkey: str, + position: Tuple[float, float], + size: Tuple[float, float], + displayname: Union[str, ba.Lstr] = None, + scale: float = None, + maxwidth: float = None, + autoselect: bool = True, + value_change_call: Callable[[Any], Any] = None): + if displayname is None: + displayname = configkey + self._value_change_call = value_change_call + self._configkey = configkey + self.widget = ba.checkboxwidget( + parent=parent, + autoselect=autoselect, + position=position, + size=size, + text=displayname, + textcolor=(0.8, 0.8, 0.8), + value=ba.app.config.resolve(configkey), + on_value_change_call=self._value_changed, + scale=scale, + maxwidth=maxwidth) + # complain if we outlive our checkbox + ba.uicleanupcheck(self, self.widget) + + def _value_changed(self, val: bool) -> None: + cfg = ba.app.config + cfg[self._configkey] = val + if self._value_change_call is not None: + self._value_change_call(val) + cfg.apply_and_commit() + + +class ConfigNumberEdit: + """A set of controls for editing a numeric config value. + + It will automatically save and apply the config when its + value changes. + + Attributes: + + nametext + The text widget displaying the name. + + valuetext + The text widget displaying the current value. + + minusbutton + The button widget used to reduce the value. + + plusbutton + The button widget used to increase the value. + """ + + def __init__(self, + parent: ba.Widget, + configkey: str, + position: Tuple[float, float], + minval: float = 0.0, + maxval: float = 100.0, + increment: float = 1.0, + callback: Callable[[float], Any] = None, + xoffset: float = 0.0, + displayname: Union[str, ba.Lstr] = None, + changesound: bool = True, + textscale: float = 1.0): + if displayname is None: + displayname = configkey + + self._configkey = configkey + self._minval = minval + self._maxval = maxval + self._increment = increment + self._callback = callback + self._value = ba.app.config.resolve(configkey) + + self.nametext = ba.textwidget(parent=parent, + position=position, + size=(100, 30), + text=displayname, + maxwidth=160 + xoffset, + color=(0.8, 0.8, 0.8, 1.0), + h_align="left", + v_align="center", + scale=textscale) + self.valuetext = ba.textwidget(parent=parent, + position=(246 + xoffset, position[1]), + size=(60, 28), + editable=False, + color=(0.3, 1.0, 0.3, 1.0), + h_align="right", + v_align="center", + text=str(self._value), + padding=2) + self.minusbutton = ba.buttonwidget( + parent=parent, + position=(330 + xoffset, position[1]), + size=(28, 28), + label="-", + autoselect=True, + on_activate_call=ba.Call(self._down), + repeat=True, + enable_sound=changesound) + self.plusbutton = ba.buttonwidget(parent=parent, + position=(380 + xoffset, + position[1]), + size=(28, 28), + label="+", + autoselect=True, + on_activate_call=ba.Call(self._up), + repeat=True, + enable_sound=changesound) + # complain if we outlive our widgets + ba.uicleanupcheck(self, self.nametext) + self._update_display() + + def _up(self) -> None: + self._value = min(self._maxval, self._value + self._increment) + self._changed() + + def _down(self) -> None: + self._value = max(self._minval, self._value - self._increment) + self._changed() + + def _changed(self) -> None: + self._update_display() + if self._callback: + self._callback(self._value) + ba.app.config[self._configkey] = self._value + ba.app.config.apply_and_commit() + + def _update_display(self) -> None: + ba.textwidget(edit=self.valuetext, text=str(round(self._value, 2))) diff --git a/assets/src/data/scripts/bastd/ui/configerror.py b/assets/src/data/scripts/bastd/ui/configerror.py new file mode 100644 index 00000000..da9b2f6c --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/configerror.py @@ -0,0 +1,73 @@ +"""UI for dealing with broken config files.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + pass + + +class ConfigErrorWindow(ba.OldWindow): + """Window for dealing with a broken config.""" + + def __init__(self) -> None: + self._config_file_path = ba.app.config_file_path + width = 800 + super().__init__( + ba.containerwidget(size=(width, 300), transition='in_right')) + padding = 20 + ba.textwidget( + parent=self._root_widget, + position=(padding, 220), + size=(width - 2 * padding, 100 - 2 * padding), + h_align="center", + v_align="top", + scale=0.73, + text=("Error reading BallisticaCore config file" + ":\n\n\nCheck the console" + " (press ~ twice) for details.\n\nWould you like to quit and" + " try to fix it by hand\nor overwrite it with defaults?\n\n" + "(high scores, player profiles, etc will be lost if you" + " overwrite)")) + ba.textwidget(parent=self._root_widget, + position=(padding, 198), + size=(width - 2 * padding, 100 - 2 * padding), + h_align="center", + v_align="top", + scale=0.5, + text=self._config_file_path) + quit_button = ba.buttonwidget(parent=self._root_widget, + position=(35, 30), + size=(240, 54), + label="Quit and Edit", + on_activate_call=self._quit) + ba.buttonwidget(parent=self._root_widget, + position=(width - 370, 30), + size=(330, 54), + label="Overwrite with Defaults", + on_activate_call=self._defaults) + ba.containerwidget(edit=self._root_widget, + cancel_button=quit_button, + selected_child=quit_button) + + def _quit(self) -> None: + ba.timer(0.001, self._edit_and_quit, timetype=ba.TimeType.REAL) + _ba.lock_all_input() + + def _edit_and_quit(self) -> None: + _ba.open_file_externally(self._config_file_path) + ba.timer(0.1, ba.quit, timetype=ba.TimeType.REAL) + + def _defaults(self) -> None: + from ba.internal import commit_app_config + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.playsound(ba.getsound('gunCocking')) + ba.screenmessage("settings reset.", color=(1, 1, 0)) + + # At this point settings are already set; lets just commit them + # to disk. + commit_app_config(force=True) diff --git a/assets/src/data/scripts/bastd/ui/confirm.py b/assets/src/data/scripts/bastd/ui/confirm.py new file mode 100644 index 00000000..b78dffea --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/confirm.py @@ -0,0 +1,152 @@ +"""Provides ConfirmWindow base class and commonly used derivatives.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Union, Callable, Tuple, Optional + + +class ConfirmWindow: + """Window for answering simple yes/no questions.""" + + def __init__(self, + text: Union[str, ba.Lstr] = "Are you sure?", + action: Callable[[], Any] = None, + width: float = 360.0, + height: float = 100.0, + cancel_button: bool = True, + cancel_is_selected: bool = False, + color: Tuple[float, float, float] = (1, 1, 1), + text_scale: float = 1.0, + ok_text: Union[str, ba.Lstr] = None, + cancel_text: Union[str, ba.Lstr] = None, + origin_widget: ba.Widget = None): + # pylint: disable=too-many-locals + if ok_text is None: + ok_text = ba.Lstr(resource='okText') + if cancel_text is None: + cancel_text = ba.Lstr(resource='cancelText') + height += 40 + if width < 360: + width = 360 + self._action = action + + # if they provided an origin-widget, scale up from that + self._transition_out: Optional[str] + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = None + scale_origin = None + transition = 'in_right' + + self.root_widget = ba.containerwidget( + size=(width, height), + transition=transition, + toolbar_visibility='menu_minimal_no_back', + parent=_ba.get_special_widget('overlay_stack'), + scale=2.1 if ba.app.small_ui else 1.5 if ba.app.med_ui else 1.0, + scale_origin_stack_offset=scale_origin) + + ba.textwidget(parent=self.root_widget, + position=(width * 0.5, height - 5 - (height - 75) * 0.5), + size=(0, 0), + h_align="center", + v_align="center", + text=text, + scale=text_scale, + color=color, + maxwidth=width * 0.9, + max_height=height - 75) + + cbtn: Optional[ba.Widget] + if cancel_button: + cbtn = btn = ba.buttonwidget(parent=self.root_widget, + autoselect=True, + position=(20, 20), + size=(150, 50), + label=cancel_text, + on_activate_call=self._cancel) + ba.containerwidget(edit=self.root_widget, cancel_button=btn) + ok_button_h = width - 175 + else: + # if they don't want a cancel button, we still want back presses to + # be able to dismiss the window; just wire it up to do the ok + # button + ok_button_h = width * 0.5 - 75 + cbtn = None + btn = ba.buttonwidget(parent=self.root_widget, + autoselect=True, + position=(ok_button_h, 20), + size=(150, 50), + label=ok_text, + on_activate_call=self._ok) + + # if they didn't want a cancel button, we still want to be able to hit + # cancel/back/etc to dismiss the window + if not cancel_button: + ba.containerwidget(edit=self.root_widget, + on_cancel_call=btn.activate) + + ba.containerwidget(edit=self.root_widget, + selected_child=(cbtn if cbtn is not None + and cancel_is_selected else btn), + start_button=btn) + + def _cancel(self) -> None: + ba.containerwidget( + edit=self.root_widget, + transition=('out_right' if self._transition_out is None else + self._transition_out)) + + def _ok(self) -> None: + if not self.root_widget: + return + ba.containerwidget( + edit=self.root_widget, + transition=('out_left' if self._transition_out is None else + self._transition_out)) + if self._action is not None: + self._action() + + +class QuitWindow: + """Popup window to confirm quitting.""" + + def __init__(self, + swish: bool = False, + back: bool = False, + origin_widget: ba.Widget = None): + app = ba.app + self._back = back + # if there's already one of us up somewhere, kill it + if app.quit_window is not None: + app.quit_window.delete() + app.quit_window = None + if swish: + ba.playsound(ba.getsound('swish')) + quit_resource = ('quitGameText' + if app.platform == 'mac' else 'exitGameText') + self._root_widget = app.quit_window = (ConfirmWindow( + ba.Lstr(resource=quit_resource, + subs=[('${APP_NAME}', ba.Lstr(resource='titleText'))]), + self._fade_and_quit, + origin_widget=origin_widget).root_widget) + + def _fade_and_quit(self) -> None: + _ba.fade_screen(False, + time=0.2, + endcall=ba.Call(ba.quit, soft=True, back=self._back)) + _ba.lock_all_input() + # unlock and fade back in shortly.. just in case something goes wrong + # (or on android where quit just backs out of our activity and + # we may come back) + ba.timer(0.3, _ba.unlock_all_input, timetype=ba.TimeType.REAL) diff --git a/assets/src/data/scripts/bastd/ui/continues.py b/assets/src/data/scripts/bastd/ui/continues.py new file mode 100644 index 00000000..d4a93247 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/continues.py @@ -0,0 +1,204 @@ +"""Provides a popup window to continue a game.""" + +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + + +class ContinuesWindow(ba.OldWindow): + """A window to continue a game.""" + + def __init__(self, activity: ba.Activity, cost: int, + continue_call: Callable[[], Any], + cancel_call: Callable[[], Any]): + self._activity = weakref.ref(activity) + self._cost = cost + self._continue_call = continue_call + self._cancel_call = cancel_call + self._start_count = self._count = 20 + self._width = 300 + self._height = 200 + self._transitioning_out = False + super().__init__( + ba.containerwidget(size=(self._width, self._height), + background=False, + toolbar_visibility='menu_currency', + transition='in_scale', + scale=1.5)) + txt = (ba.Lstr( + resource='continuePurchaseText').evaluate().split('${PRICE}')) + t_left = txt[0] + t_left_width = _ba.get_string_width(t_left, suppress_warning=True) + t_price = ba.charstr(ba.SpecialChar.TICKET) + str(self._cost) + t_price_width = _ba.get_string_width(t_price, suppress_warning=True) + t_right = txt[-1] + t_right_width = _ba.get_string_width(t_right, suppress_warning=True) + width_total_half = (t_left_width + t_price_width + t_right_width) * 0.5 + + ba.textwidget(parent=self._root_widget, + text=t_left, + flatness=1.0, + shadow=1.0, + size=(0, 0), + h_align='left', + v_align='center', + position=(self._width * 0.5 - width_total_half, + self._height - 30)) + ba.textwidget(parent=self._root_widget, + text=t_price, + flatness=1.0, + shadow=1.0, + color=(0.2, 1.0, 0.2), + size=(0, 0), + position=(self._width * 0.5 - width_total_half + + t_left_width, self._height - 30), + h_align='left', + v_align='center') + ba.textwidget(parent=self._root_widget, + text=t_right, + flatness=1.0, + shadow=1.0, + size=(0, 0), + h_align='left', + v_align='center', + position=(self._width * 0.5 - width_total_half + + t_left_width + t_price_width + 5, + self._height - 30)) + + self._tickets_text_base: Optional[str] + self._tickets_text: Optional[ba.Widget] + if not ba.app.toolbars: + self._tickets_text_base = ba.Lstr( + resource='getTicketsWindow.youHaveShortText', + fallback_resource='getTicketsWindow.youHaveText').evaluate() + self._tickets_text = ba.textwidget( + parent=self._root_widget, + text='', + flatness=1.0, + color=(0.2, 1.0, 0.2), + shadow=1.0, + position=(self._width * 0.5 + width_total_half, + self._height - 50), + size=(0, 0), + scale=0.35, + h_align='right', + v_align='center') + else: + self._tickets_text_base = None + self._tickets_text = None + + self._counter_text = ba.textwidget(parent=self._root_widget, + text=str(self._count), + color=(0.7, 0.7, 0.7), + scale=1.2, + size=(0, 0), + big=True, + position=(self._width * 0.5, + self._height - 80), + flatness=1.0, + shadow=1.0, + h_align='center', + v_align='center') + self._cancel_button = ba.buttonwidget( + parent=self._root_widget, + position=(30, 30), + size=(120, 50), + label=ba.Lstr(resource='endText', fallback_resource='cancelText'), + autoselect=True, + enable_sound=False, + on_activate_call=self._on_cancel_press) + self._continue_button = ba.buttonwidget( + parent=self._root_widget, + label=ba.Lstr(resource='continueText'), + autoselect=True, + position=(self._width - 130, 30), + size=(120, 50), + on_activate_call=self._on_continue_press) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button, + start_button=self._continue_button, + selected_child=self._cancel_button) + + self._counting_down = True + self._countdown_timer = ba.Timer(1.0, + ba.WeakCall(self._tick), + repeat=True, + timetype=ba.TimeType.REAL) + self._tick() + + def _tick(self) -> None: + # if our target activity is gone or has ended, go away + activity = self._activity() + if activity is None or activity.has_ended(): + self._on_cancel() + return + + if _ba.get_account_state() == 'signed_in': + sval = (ba.charstr(ba.SpecialChar.TICKET) + + str(_ba.get_account_ticket_count())) + else: + sval = '?' + if self._tickets_text is not None: + assert self._tickets_text_base is not None + ba.textwidget(edit=self._tickets_text, + text=self._tickets_text_base.replace( + '${COUNT}', sval)) + + if self._counting_down: + self._count -= 1 + ba.playsound(ba.getsound('tick')) + if self._count <= 0: + self._on_cancel() + else: + ba.textwidget(edit=self._counter_text, text=str(self._count)) + + def _on_cancel_press(self) -> None: + # disallow for first second + if self._start_count - self._count < 2: + ba.playsound(ba.getsound('error')) + else: + self._on_cancel() + + def _on_continue_press(self) -> None: + from bastd.ui import getcurrency + + # Disallow for first second. + if self._start_count - self._count < 2: + ba.playsound(ba.getsound('error')) + else: + # If somehow we got signed out... + if _ba.get_account_state() != 'signed_in': + ba.screenmessage(ba.Lstr(resource='notSignedInText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + # If it appears we don't have enough tickets, offer to buy more. + tickets = _ba.get_account_ticket_count() + if tickets < self._cost: + # FIXME: Should we start the timer back up again after? + self._counting_down = False + ba.textwidget(edit=self._counter_text, text='') + ba.playsound(ba.getsound('error')) + getcurrency.show_get_tickets_prompt() + return + if not self._transitioning_out: + ba.playsound(ba.getsound('swish')) + self._transitioning_out = True + ba.containerwidget(edit=self._root_widget, + transition='out_scale') + self._continue_call() + + def _on_cancel(self) -> None: + if not self._transitioning_out: + ba.playsound(ba.getsound('swish')) + self._transitioning_out = True + ba.containerwidget(edit=self._root_widget, transition='out_scale') + self._cancel_call() diff --git a/assets/src/data/scripts/bastd/ui/coop/__init__.py b/assets/src/data/scripts/bastd/ui/coop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assets/src/data/scripts/bastd/ui/coop/browser.py b/assets/src/data/scripts/bastd/ui/coop/browser.py new file mode 100644 index 00000000..3450cc91 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/coop/browser.py @@ -0,0 +1,1568 @@ +"""UI for browsing available co-op levels/games/etc.""" +# FIXME: Break this up. +# pylint: disable=too-many-lines + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Optional, Tuple, Dict, List, Union + + +class CoopBrowserWindow(ba.OldWindow): + """Window for browsing co-op levels/games/etc.""" + + def _update_corner_button_positions(self) -> None: + offs = (-55 if ba.app.small_ui and _ba.is_party_icon_visible() else 0) + if self._league_rank_button is not None: + self._league_rank_button.set_position( + (self._width - 282 + offs - self._x_inset, + self._height - 85 - (4 if ba.app.small_ui else 0))) + if self._store_button is not None: + self._store_button.set_position( + (self._width - 170 + offs - self._x_inset, + self._height - 85 - (4 if ba.app.small_ui else 0))) + + def __init__(self, + transition: str = 'in_right', + origin_widget: ba.Widget = None): + # pylint: disable=cyclic-import + # pylint: disable=too-many-statements + # pylint: disable=cyclic-import + from bastd.ui.store.button import StoreButton + from bastd.ui.league.rankbutton import LeagueRankButton + ba.set_analytics_screen('Coop Window') + + app = ba.app + cfg = app.config + + # if they provided an origin-widget, scale up from that + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + # try to recreate the same number of buttons we had last time so our + # re-selection code works + try: + self._tournament_button_count = app.config['Tournament Rows'] + except Exception: + self._tournament_button_count = 0 + + self._easy_button: Optional[ba.Widget] = None + self._hard_button: Optional[ba.Widget] = None + self._hard_button_lock_image: Optional[ba.Widget] = None + self._campaign_percent_text: Optional[ba.Widget] = None + + self._width = 1320 if app.small_ui else 1120 + self._x_inset = x_inset = 100 if app.small_ui else 0 + self._height = (657 if app.small_ui else 730 if app.med_ui else 800) + app.main_window = "Coop Select" + self._r = 'coopSelectWindow' + top_extra = 20 if app.small_ui else 0 + + self._tourney_data_up_to_date = False + + self._campaign_difficulty = _ba.get_account_misc_val( + 'campaignDifficulty', 'easy') + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + top_extra), + toolbar_visibility='menu_full', + scale_origin_stack_offset=scale_origin, + stack_offset=(0, + -15) if app.small_ui else (0, + 0) if app.med_ui else (0, + 0), + transition=transition, + scale=1.2 if app.small_ui else 0.8 if app.med_ui else 0.75)) + + if app.toolbars and app.small_ui: + self._back_button = None + else: + self._back_button = ba.buttonwidget( + parent=self._root_widget, + position=(75 + x_inset, + self._height - 87 - (4 if app.small_ui else 0)), + size=(120, 60), + scale=1.2, + autoselect=True, + label=ba.Lstr(resource='backText'), + button_type='back') + + self._league_rank_button: Optional[LeagueRankButton] + self._store_button: Optional[StoreButton] + self._store_button_widget: Optional[ba.Widget] + self._league_rank_button_widget: Optional[ba.Widget] + + if not app.toolbars: + prb = self._league_rank_button = LeagueRankButton( + parent=self._root_widget, + position=(self._width - (282 + x_inset), + self._height - 85 - (4 if app.small_ui else 0)), + size=(100, 60), + color=(0.4, 0.4, 0.9), + textcolor=(0.9, 0.9, 2.0), + scale=1.05, + on_activate_call=ba.WeakCall(self._switch_to_league_rankings)) + self._league_rank_button_widget = prb.get_button() + + sbtn = self._store_button = StoreButton( + parent=self._root_widget, + position=(self._width - (170 + x_inset), + self._height - 85 - (4 if app.small_ui else 0)), + size=(100, 60), + color=(0.6, 0.4, 0.7), + show_tickets=True, + button_type='square', + sale_scale=0.85, + textcolor=(0.9, 0.7, 1.0), + scale=1.05, + on_activate_call=ba.WeakCall(self._switch_to_score, None)) + self._store_button_widget = sbtn.get_button() + ba.widget(edit=self._back_button, + right_widget=self._league_rank_button_widget) + ba.widget(edit=self._league_rank_button_widget, + left_widget=self._back_button) + else: + self._league_rank_button = None + self._store_button = None + self._store_button_widget = None + self._league_rank_button_widget = None + + # move our corner buttons dynamically to keep them out of the way of + # the party icon :-( + self._update_corner_button_positions() + self._update_corner_button_positions_timer = ba.Timer( + 1.0, + ba.WeakCall(self._update_corner_button_positions), + repeat=True, + timetype=ba.TimeType.REAL) + + self._last_tournament_query_time: Optional[float] = None + self._last_tournament_query_response_time: Optional[float] = None + self._doing_tournament_query = False + + self._selected_campaign_level = (cfg.get( + 'Selected Coop Campaign Level', None)) + self._selected_custom_level = (cfg.get('Selected Coop Custom Level', + None)) + self._selected_challenge_level = (cfg.get( + 'Selected Coop Challenge Level', None)) + + # Don't want initial construction affecting our last-selected + self._do_selection_callbacks = False + v = self._height - 95 + txt = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, v + 40 - (0 if app.small_ui else 0)), + size=(0, 0), + text=ba.Lstr(resource='playModes.singlePlayerCoopText', + fallback_resource='playModes.coopText'), + h_align="center", + color=app.title_color, + scale=1.5, + maxwidth=500, + v_align="center") + + if app.toolbars and app.small_ui: + ba.textwidget(edit=txt, text='') + + if self._back_button is not None: + ba.buttonwidget(edit=self._back_button, + button_type='backSmall', + size=(60, 50), + position=(75 + x_inset, self._height - 87 - + (4 if app.small_ui else 0) + 6), + label=ba.charstr(ba.SpecialChar.BACK)) + + self._selected_row = cfg.get('Selected Coop Row', None) + + self.star_tex = ba.gettexture('star') + self.lsbt = ba.getmodel('level_select_button_transparent') + self.lsbo = ba.getmodel('level_select_button_opaque') + self.a_outline_tex = ba.gettexture('achievementOutline') + self.a_outline_model = ba.getmodel('achievementOutline') + + self._scroll_width = self._width - (130 + 2 * x_inset) + self._scroll_height = self._height - (190 if app.small_ui + and app.toolbars else 160) + + self._subcontainerwidth = 800.0 + self._subcontainerheight = 1400.0 + + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + highlight=False, + position=(65 + x_inset, 120) if app.small_ui and app.toolbars else + (65 + x_inset, 70), + size=(self._scroll_width, self._scroll_height), + simple_culling_v=10.0) + self._subcontainer: Optional[ba.Widget] = None + + # take note of our account state; we'll refresh later if this changes + self._account_state_num = _ba.get_account_state_num() + # same for fg/bg state.. + self._fg_state = app.fg_state + + self._refresh() + self._restore_state() + + # even though we might display cached tournament data immediately, we + # don't consider it valid until we've pinged + # the server for an update + self._tourney_data_up_to_date = False + + # if we've got a cached tournament list for our account and info for + # each one of those tournaments, + # go ahead and display it as a starting point... + if (app.account_tournament_list is not None and + app.account_tournament_list[0] == _ba.get_account_state_num() + and all([ + t_id in app.tournament_info + for t_id in app.account_tournament_list[1] + ])): + tourney_data = [ + app.tournament_info[t_id] + for t_id in app.account_tournament_list[1] + ] + self._update_for_data(tourney_data) + + # this will pull new data periodically, update timers, etc.. + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + self._update() + + def _update(self) -> None: + cur_time = ba.time(ba.TimeType.REAL) + + # if its been a while since we got a tournament update, consider the + # data invalid (prevents us from joining tournaments if our internet + # connection goes down for a while) + if (self._last_tournament_query_response_time is None + or ba.time(ba.TimeType.REAL) - + self._last_tournament_query_response_time > 60.0 * 2): + self._tourney_data_up_to_date = False + + # if our account state has changed, do a full request + account_state_num = _ba.get_account_state_num() + if account_state_num != self._account_state_num: + self._account_state_num = account_state_num + self._save_state() + self._refresh() + # also encourage a new tournament query since this will clear out + # our current results.. + if not self._doing_tournament_query: + self._last_tournament_query_time = None + + # if we've been backgrounded/foregrounded, invalidate our + # tournament entries (they will be refreshed below asap) + if self._fg_state != ba.app.fg_state: + self._tourney_data_up_to_date = False + + # send off a new tournament query if its been long enough or whatnot.. + if not self._doing_tournament_query and ( + self._last_tournament_query_time is None + or cur_time - self._last_tournament_query_time > 30.0 + or self._fg_state != ba.app.fg_state): + self._fg_state = ba.app.fg_state + self._last_tournament_query_time = cur_time + self._doing_tournament_query = True + _ba.tournament_query(args={ + 'source': 'coop window refresh', + 'numScores': 1 + }, + callback=ba.WeakCall( + self._on_tournament_query_response)) + + # decrement time on our tournament buttons.. + ads_enabled = _ba.have_incentivized_ad() + for tbtn in self._tournament_buttons: + tbtn['time_remaining'] = max(0, tbtn['time_remaining'] - 1) + if tbtn['time_remaining_value_text'] is not None: + ba.textwidget( + edit=tbtn['time_remaining_value_text'], + text=ba.timestring(tbtn['time_remaining'], centi=False) if + (tbtn['has_time_remaining'] + and self._tourney_data_up_to_date) else '-') + # also adjust the ad icon visibility + if tbtn.get('allow_ads', False) and _ba.has_video_ads(): + ba.imagewidget(edit=tbtn['entry_fee_ad_image'], + opacity=1.0 if ads_enabled else 0.25) + ba.textwidget(edit=tbtn['entry_fee_text_remaining'], + color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2)) + + self._update_hard_mode_lock_image() + + def _update_hard_mode_lock_image(self) -> None: + from ba.internal import have_pro_options + try: + ba.imagewidget(edit=self._hard_button_lock_image, + opacity=0.0 if have_pro_options() else 1.0) + except Exception: + ba.print_exception('error updating campaign lock') + + def _update_for_data(self, data: Optional[List[Dict[str, Any]]]) -> None: + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + from ba.internal import get_campaign, get_tournament_prize_strings + + # If the number of tournaments or challenges in the data differs from + # our current arrangement, refresh with the new number. + if (((data is None and (self._tournament_button_count != 0)) + or (data is not None and + (len(data) != self._tournament_button_count)))): + self._tournament_button_count = len( + data) if data is not None else 0 + ba.app.config['Tournament Rows'] = self._tournament_button_count + self._refresh() + + # Update all of our tourney buttons based on whats in data. + for i, tbtn in enumerate(self._tournament_buttons): + entry: Optional[Dict[str, Any]] + try: + assert data is not None + entry = data[i] + except Exception: + entry = None + prize_y_offs = (0 if entry is None else 34 if 'prizeRange3' in + entry else 20 if 'prizeRange2' in entry else 12) + x_offs = 90 + + # This seems to be a false alarm. + # pylint: disable=unbalanced-tuple-unpacking + assert entry is not None + pr1, pv1, pr2, pv2, pr3, pv3 = ( + get_tournament_prize_strings(entry)) + # pylint: enable=unbalanced-tuple-unpacking + enabled = 'requiredLeague' not in entry + ba.buttonwidget(edit=tbtn['button'], + color=(0.5, 0.7, 0.2) if enabled else + (0.5, 0.5, 0.5)) + ba.imagewidget(edit=tbtn['lock_image'], + opacity=0.0 if enabled else 1.0) + ba.textwidget(edit=tbtn['prize_range_1_text'], + text='-' if pr1 == '' else pr1, + position=(tbtn['button_x'] + 365 + x_offs, + tbtn['button_y'] + tbtn['button_scale_y'] - + 93 + prize_y_offs)) + + # We want to draw values containing tickets a bit smaller + # (scratch that; we now draw medals a bit bigger). + ticket_char = ba.charstr(ba.SpecialChar.TICKET_BACKING) + prize_value_scale_large = 1.0 + prize_value_scale_small = 1.0 + + ba.textwidget(edit=tbtn['prize_value_1_text'], + text='-' if pv1 == '' else pv1, + scale=prize_value_scale_large if + ticket_char not in pv1 else prize_value_scale_small, + position=(tbtn['button_x'] + 380 + x_offs, + tbtn['button_y'] + tbtn['button_scale_y'] - + 93 + prize_y_offs)) + + ba.textwidget(edit=tbtn['prize_range_2_text'], + text=pr2, + position=(tbtn['button_x'] + 365 + x_offs, + tbtn['button_y'] + tbtn['button_scale_y'] - + 93 - 45 + prize_y_offs)) + ba.textwidget(edit=tbtn['prize_value_2_text'], + text=pv2, + scale=prize_value_scale_large if + ticket_char not in pv2 else prize_value_scale_small, + position=(tbtn['button_x'] + 380 + x_offs, + tbtn['button_y'] + tbtn['button_scale_y'] - + 93 - 45 + prize_y_offs)) + + ba.textwidget(edit=tbtn['prize_range_3_text'], + text=pr3, + position=(tbtn['button_x'] + 365 + x_offs, + tbtn['button_y'] + tbtn['button_scale_y'] - + 93 - 90 + prize_y_offs)) + ba.textwidget(edit=tbtn['prize_value_3_text'], + text=pv3, + scale=prize_value_scale_large if + ticket_char not in pv3 else prize_value_scale_small, + position=(tbtn['button_x'] + 380 + x_offs, + tbtn['button_y'] + tbtn['button_scale_y'] - + 93 - 90 + prize_y_offs)) + + leader_name = '-' + leader_score: Union[str, ba.Lstr] = '-' + if entry is not None and entry['scores']: + score = tbtn['leader'] = copy.deepcopy(entry['scores'][0]) + leader_name = score[1] + leader_score = ( + ba.timestring(score[0] * 10, + centi=True, + timeformat=ba.TimeFormat.MILLISECONDS) + if entry['scoreType'] == 'time' else str(score[0])) + else: + tbtn['leader'] = None + + ba.textwidget(edit=tbtn['current_leader_name_text'], + text=ba.Lstr(value=leader_name)) + self._tournament_leader_score_type = (None if entry is None else + entry['scoreType']) + ba.textwidget(edit=tbtn['current_leader_score_text'], + text=leader_score) + ba.buttonwidget(edit=tbtn['more_scores_button'], + label='-' if entry is None else ba.Lstr( + resource=self._r + '.seeMoreText')) + out_of_time_text: Union[str, ba.Lstr] = ( + '-' if entry is None or 'totalTime' not in entry else ba.Lstr( + resource=self._r + '.ofTotalTimeText', + subs=[('${TOTAL}', + ba.timestring(entry['totalTime'], centi=False))])) + ba.textwidget(edit=tbtn['time_remaining_out_of_text'], + text=out_of_time_text) + + tbtn['time_remaining'] = 0 if entry is None else entry[ + 'timeRemaining'] + tbtn['has_time_remaining'] = entry is not None + tbtn['tournament_id'] = (None if entry is None else + entry['tournamentID']) + tbtn['required_league'] = (None if 'requiredLeague' not in entry + else entry['requiredLeague']) + + game = (None if entry is None else + ba.app.tournament_info[tbtn['tournament_id']]['game']) + + if game is None: + ba.textwidget(edit=tbtn['button_text'], text='-') + ba.imagewidget(edit=tbtn['image'], + texture=ba.gettexture('black'), + opacity=0.2) + else: + campaignname, levelname = game.split(':') + campaign = get_campaign(campaignname) + max_players = ba.app.tournament_info[ + tbtn['tournament_id']]['maxPlayers'] + txt = ba.Lstr( + value='${A} ${B}', + subs=[('${A}', campaign.get_level(levelname).displayname), + ('${B}', + ba.Lstr(resource='playerCountAbbreviatedText', + subs=[('${COUNT}', str(max_players))]))]) + ba.textwidget(edit=tbtn['button_text'], text=txt) + ba.imagewidget(edit=tbtn['image'], + texture=campaign.get_level( + levelname).get_preview_texture(), + opacity=1.0 if enabled else 0.5) + + fee = None if entry is None else entry['fee'] + + if fee is None: + fee_var = None + elif fee == 4: + fee_var = 'price.tournament_entry_4' + elif fee == 3: + fee_var = 'price.tournament_entry_3' + elif fee == 2: + fee_var = 'price.tournament_entry_2' + elif fee == 1: + fee_var = 'price.tournament_entry_1' + else: + if fee != 0: + print('Unknown fee value:', fee) + fee_var = 'price.tournament_entry_0' + + tbtn['allow_ads'] = allow_ads = entry['allowAds'] + + final_fee: Optional[int] = (None if fee_var is None else + _ba.get_account_misc_read_val( + fee_var, '?')) + + final_fee_str: Union[str, ba.Lstr] + if fee_var is None: + final_fee_str = '' + else: + if final_fee == 0: + final_fee_str = ba.Lstr( + resource='getTicketsWindow.freeText') + else: + final_fee_str = ( + ba.charstr(ba.SpecialChar.TICKET_BACKING) + + str(final_fee)) + # final_fee_str: Union[str, ba.Lstr] = ( + # '' if fee_var is None else ba.Lstr( + # resource='getTicketsWindow.freeText') if final_fee == 0 + # else (ba.specialchar('ticket_backing') + str(final_fee))) + + ad_tries_remaining = ba.app.tournament_info[ + tbtn['tournament_id']]['adTriesRemaining'] + free_tries_remaining = ba.app.tournament_info[ + tbtn['tournament_id']]['freeTriesRemaining'] + + # now, if this fee allows ads and we support video ads, show + # the 'or ad' version + if allow_ads and _ba.has_video_ads(): + ads_enabled = _ba.have_incentivized_ad() + ba.imagewidget(edit=tbtn['entry_fee_ad_image'], + opacity=1.0 if ads_enabled else 0.25) + or_text = ba.Lstr(resource='orText', + subs=[('${A}', ''), + ('${B}', '')]).evaluate().strip() + ba.textwidget(edit=tbtn['entry_fee_text_or'], text=or_text) + ba.textwidget( + edit=tbtn['entry_fee_text_top'], + position=(tbtn['button_x'] + 360, + tbtn['button_y'] + tbtn['button_scale_y'] - 60), + scale=1.3, + text=final_fee_str) + # possibly show number of ad-plays remaining + ba.textwidget( + edit=tbtn['entry_fee_text_remaining'], + position=(tbtn['button_x'] + 360, + tbtn['button_y'] + tbtn['button_scale_y'] - 146), + text='' if ad_tries_remaining in [None, 0] else + ('' + str(ad_tries_remaining)), + color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2)) + else: + ba.imagewidget(edit=tbtn['entry_fee_ad_image'], opacity=0.0) + ba.textwidget(edit=tbtn['entry_fee_text_or'], text='') + ba.textwidget( + edit=tbtn['entry_fee_text_top'], + position=(tbtn['button_x'] + 360, + tbtn['button_y'] + tbtn['button_scale_y'] - 80), + scale=1.3, + text=final_fee_str) + # possibly show number of free-plays remaining + ba.textwidget( + edit=tbtn['entry_fee_text_remaining'], + position=(tbtn['button_x'] + 360, + tbtn['button_y'] + tbtn['button_scale_y'] - 100), + text=('' if (free_tries_remaining in [None, 0] + or final_fee != 0) else + ('' + str(free_tries_remaining))), + color=(0.6, 0.6, 0.6, 1)) + + def _on_tournament_query_response(self, + data: Optional[Dict[str, Any]]) -> None: + from ba.internal import cache_tournament_info + app = ba.app + if data is not None: + tournament_data = data['t'] # This used to be the whole payload. + self._last_tournament_query_response_time = ba.time( + ba.TimeType.REAL) + else: + tournament_data = None + + # Keep our cached tourney info up to date. + if data is not None: + self._tourney_data_up_to_date = True + cache_tournament_info(tournament_data) + + # Also cache the current tourney list/order for this account. + app.account_tournament_list = (_ba.get_account_state_num(), [ + e['tournamentID'] for e in tournament_data + ]) + + self._doing_tournament_query = False + self._update_for_data(tournament_data) + + def _set_campaign_difficulty(self, difficulty: str) -> None: + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui.purchase import PurchaseWindow + if difficulty != self._campaign_difficulty: + if difficulty == 'hard' and not have_pro_options(): + PurchaseWindow(items=['pro']) + return + ba.playsound(ba.getsound('gunCocking')) + if difficulty not in ('easy', 'hard'): + print('ERROR: invalid campaign difficulty:', difficulty) + difficulty = 'easy' + self._campaign_difficulty = difficulty + _ba.add_transaction({ + 'type': 'SET_MISC_VAL', + 'name': 'campaignDifficulty', + 'value': difficulty + }) + self._refresh_campaign_row() + else: + ba.playsound(ba.getsound('click01')) + + def _refresh_campaign_row(self) -> None: + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + from ba.internal import get_campaign + from bastd.ui.coop.gamebutton import GameButton + parent_widget = self._campaign_sub_container + + # Clear out anything in the parent widget already. + for child in parent_widget.get_children(): + child.delete() + + next_widget_down = self._tournament_info_button + + h = 0 + v2 = -2 + sel_color = (0.75, 0.85, 0.5) + sel_color_hard = (0.4, 0.7, 0.2) + un_sel_color = (0.5, 0.5, 0.5) + sel_textcolor = (2, 2, 0.8) + un_sel_textcolor = (0.6, 0.6, 0.6) + self._easy_button = ba.buttonwidget( + parent=parent_widget, + position=(h + 30, v2 + 105), + size=(120, 70), + label=ba.Lstr(resource='difficultyEasyText'), + button_type='square', + autoselect=True, + enable_sound=False, + on_activate_call=ba.Call(self._set_campaign_difficulty, 'easy'), + on_select_call=ba.Call(self.sel_change, 'campaign', 'easyButton'), + color=sel_color + if self._campaign_difficulty == 'easy' else un_sel_color, + textcolor=sel_textcolor + if self._campaign_difficulty == 'easy' else un_sel_textcolor) + ba.widget(edit=self._easy_button, show_buffer_left=100) + if self._selected_campaign_level == 'easyButton': + ba.containerwidget(edit=parent_widget, + selected_child=self._easy_button, + visible_child=self._easy_button) + lock_tex = ba.gettexture('lock') + + self._hard_button = ba.buttonwidget( + parent=parent_widget, + position=(h + 30, v2 + 32), + size=(120, 70), + label=ba.Lstr(resource='difficultyHardText'), + button_type='square', + autoselect=True, + enable_sound=False, + on_activate_call=ba.Call(self._set_campaign_difficulty, 'hard'), + on_select_call=ba.Call(self.sel_change, 'campaign', 'hardButton'), + color=sel_color_hard + if self._campaign_difficulty == 'hard' else un_sel_color, + textcolor=sel_textcolor + if self._campaign_difficulty == 'hard' else un_sel_textcolor) + self._hard_button_lock_image = ba.imagewidget( + parent=parent_widget, + size=(30, 30), + draw_controller=self._hard_button, + position=(h + 30 - 10, v2 + 32 + 70 - 35), + texture=lock_tex) + self._update_hard_mode_lock_image() + ba.widget(edit=self._hard_button, show_buffer_left=100) + if self._selected_campaign_level == 'hardButton': + ba.containerwidget(edit=parent_widget, + selected_child=self._hard_button, + visible_child=self._hard_button) + + ba.widget(edit=self._hard_button, down_widget=next_widget_down) + h_spacing = 200 + campaign_buttons = [] + if self._campaign_difficulty == 'easy': + campaignname = 'Easy' + else: + campaignname = 'Default' + items = [ + campaignname + ':Onslaught Training', + campaignname + ':Rookie Onslaught', + campaignname + ':Rookie Football', campaignname + ':Pro Onslaught', + campaignname + ':Pro Football', campaignname + ':Pro Runaround', + campaignname + ':Uber Onslaught', campaignname + ':Uber Football', + campaignname + ':Uber Runaround' + ] + items += [campaignname + ':The Last Stand'] + if self._selected_campaign_level is None: + self._selected_campaign_level = items[0] + h = 150 + for i in items: + is_last_sel = (i == self._selected_campaign_level) + campaign_buttons.append( + GameButton(self, parent_widget, i, h, v2, is_last_sel, + 'campaign').get_button()) + h += h_spacing + + ba.widget(edit=campaign_buttons[0], left_widget=self._easy_button) + + if self._back_button is not None: + ba.widget(edit=self._easy_button, up_widget=self._back_button) + for btn in campaign_buttons: + ba.widget(edit=btn, + up_widget=self._back_button, + down_widget=next_widget_down) + + # Update our existing percent-complete text. + campaign = get_campaign(campaignname) + levels = campaign.get_levels() + levels_complete = sum((1 if l.complete else 0) for l in levels) + + # Last level cant be completed; hence the -1. + progress = min(1.0, float(levels_complete) / (len(levels) - 1)) + p_str = str(int(progress * 100.0)) + '%' + + self._campaign_percent_text = ba.textwidget( + edit=self._campaign_percent_text, + text=ba.Lstr(value='${C} (${P})', + subs=[('${C}', + ba.Lstr(resource=self._r + '.campaignText')), + ('${P}', p_str)])) + + def _on_tournament_info_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import confirm + txt = ba.Lstr(resource=self._r + '.tournamentInfoText') + confirm.ConfirmWindow(txt, + cancel_button=False, + width=550, + height=260, + origin_widget=self._tournament_info_button) + + def _refresh(self) -> None: + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + from bastd.ui.coop.gamebutton import GameButton + + # (Re)create the sub-container if need be. + if self._subcontainer is not None: + self._subcontainer.delete() + + tourney_row_height = 200 + self._subcontainerheight = ( + 620 + self._tournament_button_count * tourney_row_height) + + self._subcontainer = ba.containerwidget( + parent=self._scrollwidget, + size=(self._subcontainerwidth, self._subcontainerheight), + background=False) + + # So we can still select root level widgets with controllers. + ba.containerwidget(edit=self._scrollwidget, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + ba.containerwidget(edit=self._subcontainer, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget) + if self._back_button is not None: + ba.containerwidget(edit=self._root_widget, + cancel_button=self._back_button) + + w_parent = self._subcontainer + h_base = 6 + + v = self._subcontainerheight - 73 + + self._campaign_percent_text = ba.textwidget(parent=w_parent, + position=(h_base + 27, + v + 30), + size=(0, 0), + text='', + h_align="left", + v_align='center', + color=ba.app.title_color, + scale=1.1) + + row_v_show_buffer = 100 + v -= 198 + + h_scroll = ba.hscrollwidget(parent=w_parent, + size=(self._scroll_width - 10, 205), + position=(-5, v), + simple_culling_h=70, + highlight=False, + border_opacity=0.0, + color=(0.45, 0.4, 0.5), + on_select_call=ba.Call( + self._on_row_selected, 'campaign')) + self._campaign_h_scroll = h_scroll + ba.widget(edit=h_scroll, + show_buffer_top=row_v_show_buffer, + show_buffer_bottom=row_v_show_buffer, + autoselect=True) + if self._selected_row == 'campaign': + ba.containerwidget(edit=w_parent, + selected_child=h_scroll, + visible_child=h_scroll) + ba.containerwidget(edit=h_scroll, claims_left_right=True) + self._campaign_sub_container = ba.containerwidget(parent=h_scroll, + size=(180 + 200 * 10, + 200), + background=False) + + # Tournaments + + self._tournament_buttons: List[Dict[str, Any]] = [] + + v -= 53 + # FIXME shouldn't use hard-coded strings here. + txt = ba.Lstr(resource='tournamentsText', + fallback_resource='tournamentText').evaluate() + t_width = _ba.get_string_width(txt, suppress_warning=True) + ba.textwidget(parent=w_parent, + position=(h_base + 27, v + 30), + size=(0, 0), + text=txt, + h_align="left", + v_align='center', + color=ba.app.title_color, + scale=1.1) + self._tournament_info_button = ba.buttonwidget( + parent=w_parent, + label='?', + size=(20, 20), + text_scale=0.6, + position=(h_base + 27 + t_width * 1.1 + 15, v + 18), + button_type='square', + color=(0.6, 0.5, 0.65), + textcolor=(0.7, 0.6, 0.75), + autoselect=True, + up_widget=self._campaign_h_scroll, + on_activate_call=self._on_tournament_info_press) + ba.widget(edit=self._tournament_info_button, + left_widget=self._tournament_info_button, + right_widget=self._tournament_info_button) + + # Say 'unavailable' if there are zero tournaments, and if we're not + # signed in add that as well (that's probably why we see + # no tournaments). + if self._tournament_button_count == 0: + unavailable_text = ba.Lstr(resource='unavailableText') + if _ba.get_account_state() != 'signed_in': + unavailable_text = ba.Lstr( + value='${A} (${B})', + subs=[('${A}', unavailable_text), + ('${B}', ba.Lstr(resource='notSignedInText'))]) + ba.textwidget(parent=w_parent, + position=(h_base + 47, v), + size=(0, 0), + text=unavailable_text, + h_align="left", + v_align='center', + color=ba.app.title_color, + scale=0.9) + v -= 40 + v -= 198 + + tournament_h_scroll = None + if self._tournament_button_count > 0: + for i in range(self._tournament_button_count): + tournament_h_scroll = h_scroll = ba.hscrollwidget( + parent=w_parent, + size=(self._scroll_width - 10, 205), + position=(-5, v), + highlight=False, + border_opacity=0.0, + color=(0.45, 0.4, 0.5), + on_select_call=ba.Call(self._on_row_selected, + 'tournament' + str(i + 1))) + ba.widget(edit=h_scroll, + show_buffer_top=row_v_show_buffer, + show_buffer_bottom=row_v_show_buffer, + autoselect=True) + if self._selected_row == 'tournament' + str(i + 1): + ba.containerwidget(edit=w_parent, + selected_child=h_scroll, + visible_child=h_scroll) + ba.containerwidget(edit=h_scroll, claims_left_right=True) + sc2 = ba.containerwidget(parent=h_scroll, + size=(self._scroll_width - 24, 200), + background=False) + h = 0 + v2 = -2 + is_last_sel = True + self._tournament_buttons.append( + self._tournament_button(sc2, h, v2, is_last_sel)) + v -= 200 + + # Custom Games. + v -= 50 + ba.textwidget(parent=w_parent, + position=(h_base + 27, v + 30 + 198), + size=(0, 0), + text=ba.Lstr( + resource='practiceText', + fallback_resource='coopSelectWindow.customText'), + h_align="left", + v_align='center', + color=ba.app.title_color, + scale=1.1) + + items = [ + 'Challenges:Infinite Onslaught', + 'Challenges:Infinite Runaround', + 'Challenges:Ninja Fight', + 'Challenges:Pro Ninja Fight', + 'Challenges:Meteor Shower', + 'Challenges:Target Practice B', + 'Challenges:Target Practice', + # 'Challenges:Lake Frigid Race', + # 'Challenges:Uber Runaround', + # 'Challenges:Runaround', + # 'Challenges:Pro Race', + # 'Challenges:Pro Football', + # 'Challenges:Epic Meteor Shower', + # 'Challenges:Testing', + # 'User:Ninja Fight', + ] + # Show easter-egg-hunt either if its easter or we own it. + if _ba.get_account_misc_read_val( + 'easter', False) or _ba.get_purchased('games.easter_egg_hunt'): + items = [ + 'Challenges:Easter Egg Hunt', 'Challenges:Pro Easter Egg Hunt' + ] + items + + # add all custom user levels here.. + # items += [ + # 'User:' + l.get_name() + # for l in get_campaign('User').get_levels() + # ] + + self._custom_h_scroll = custom_h_scroll = h_scroll = ba.hscrollwidget( + parent=w_parent, + size=(self._scroll_width - 10, 205), + position=(-5, v), + highlight=False, + border_opacity=0.0, + color=(0.45, 0.4, 0.5), + on_select_call=ba.Call(self._on_row_selected, 'custom')) + ba.widget(edit=h_scroll, + show_buffer_top=row_v_show_buffer, + show_buffer_bottom=1.5 * row_v_show_buffer, + autoselect=True) + if self._selected_row == 'custom': + ba.containerwidget(edit=w_parent, + selected_child=h_scroll, + visible_child=h_scroll) + ba.containerwidget(edit=h_scroll, claims_left_right=True) + sc2 = ba.containerwidget(parent=h_scroll, + size=(max(self._scroll_width - 24, + 30 + 200 * len(items)), 200), + background=False) + h_spacing = 200 + self._custom_buttons: List[GameButton] = [] + h = 0 + v2 = -2 + for item in items: + is_last_sel = (item == self._selected_custom_level) + self._custom_buttons.append( + GameButton(self, sc2, item, h, v2, is_last_sel, 'custom')) + h += h_spacing + + # We can't fill in our campaign row until tourney buttons are in place. + # (for wiring up) + self._refresh_campaign_row() + + for i in range(len(self._tournament_buttons)): + ba.widget( + edit=self._tournament_buttons[i]['button'], + up_widget=self._tournament_info_button + if i == 0 else self._tournament_buttons[i - 1]['button'], + down_widget=self._tournament_buttons[(i + 1)]['button'] + if i + 1 < len(self._tournament_buttons) else custom_h_scroll) + ba.widget( + edit=self._tournament_buttons[i]['more_scores_button'], + down_widget=self._tournament_buttons[( + i + 1)]['current_leader_name_text'] + if i + 1 < len(self._tournament_buttons) else custom_h_scroll) + ba.widget( + edit=self._tournament_buttons[i]['current_leader_name_text'], + up_widget=self._tournament_info_button if i == 0 else + self._tournament_buttons[i - 1]['more_scores_button']) + + for btn in self._custom_buttons: + try: + ba.widget( + edit=btn.get_button(), + up_widget=tournament_h_scroll if self._tournament_buttons + else self._tournament_info_button) + except Exception: + ba.print_exception('Error wiring up custom buttons') + + if self._back_button is not None: + ba.buttonwidget(edit=self._back_button, + on_activate_call=self._back) + else: + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._back) + + # There's probably several 'onSelected' callbacks pushed onto the + # event queue.. we need to push ours too so we're enabled *after* them. + ba.pushcall(self._enable_selectable_callback) + + def _on_row_selected(self, row: int) -> None: + if self._do_selection_callbacks: + if self._selected_row != row: + self._selected_row = row + + def _enable_selectable_callback(self) -> None: + self._do_selection_callbacks = True + + def _tournament_button(self, parent: ba.Widget, x: float, y: float, + select: bool) -> Dict[str, Any]: + sclx = 300 + scly = 195.0 + data: Dict[str, Any] = { + 'tournament_id': None, + 'time_remaining': 0, + 'has_time_remaining': False, + 'leader': None + } + data['button'] = btn = ba.buttonwidget(parent=parent, + position=(x + 23, y + 4), + size=(sclx, scly), + label='', + button_type='square', + autoselect=True, + on_activate_call=ba.Call( + self.run, + None, + tournament_button=data)) + ba.widget(edit=btn, + show_buffer_bottom=50, + show_buffer_top=50, + show_buffer_left=400, + show_buffer_right=200) + if select: + ba.containerwidget(edit=parent, + selected_child=btn, + visible_child=btn) + image_width = sclx * 0.85 * 0.75 + + data['image'] = ba.imagewidget( + parent=parent, + draw_controller=btn, + position=(x + 21 + sclx * 0.5 - image_width * 0.5, y + scly - 150), + size=(image_width, image_width * 0.5), + model_transparent=self.lsbt, + model_opaque=self.lsbo, + texture=ba.gettexture('black'), + opacity=0.2, + mask_texture=ba.gettexture('mapPreviewMask')) + + data['lock_image'] = ba.imagewidget( + parent=parent, + draw_controller=btn, + position=(x + 21 + sclx * 0.5 - image_width * 0.25, + y + scly - 150), + size=(image_width * 0.5, image_width * 0.5), + texture=ba.gettexture('lock'), + opacity=0.0) + + data['button_text'] = ba.textwidget(parent=parent, + draw_controller=btn, + position=(x + 20 + sclx * 0.5, + y + scly - 35), + size=(0, 0), + h_align='center', + text='-', + v_align='center', + maxwidth=sclx * 0.76, + scale=0.85, + color=(0.8, 1.0, 0.8, 1.0)) + + header_color = (0.43, 0.4, 0.5, 1) + value_color = (0.6, 0.6, 0.6, 1) + + x_offs = 0 + ba.textwidget(parent=parent, + draw_controller=btn, + position=(x + 360, y + scly - 20), + size=(0, 0), + h_align='center', + text=ba.Lstr(resource=self._r + '.entryFeeText'), + v_align='center', + maxwidth=100, + scale=0.9, + color=header_color, + flatness=1.0) + + data['entry_fee_text_top'] = ba.textwidget(parent=parent, + draw_controller=btn, + position=(x + 360, + y + scly - 60), + size=(0, 0), + h_align='center', + text='-', + v_align='center', + maxwidth=60, + scale=1.3, + color=value_color, + flatness=1.0) + data['entry_fee_text_or'] = ba.textwidget(parent=parent, + draw_controller=btn, + position=(x + 360, + y + scly - 90), + size=(0, 0), + h_align='center', + text='', + v_align='center', + maxwidth=60, + scale=0.5, + color=value_color, + flatness=1.0) + data['entry_fee_text_remaining'] = ba.textwidget(parent=parent, + draw_controller=btn, + position=(x + 360, y + + scly - 90), + size=(0, 0), + h_align='center', + text='', + v_align='center', + maxwidth=60, + scale=0.5, + color=value_color, + flatness=1.0) + + data['entry_fee_ad_image'] = ba.imagewidget( + parent=parent, + size=(40, 40), + draw_controller=btn, + position=(x + 360 - 20, y + scly - 140), + opacity=0.0, + texture=ba.gettexture('tv')) + + x_offs += 50 + + ba.textwidget(parent=parent, + draw_controller=btn, + position=(x + 447 + x_offs, y + scly - 20), + size=(0, 0), + h_align='center', + text=ba.Lstr(resource=self._r + '.prizesText'), + v_align='center', + maxwidth=130, + scale=0.9, + color=header_color, + flatness=1.0) + + data['button_x'] = x + data['button_y'] = y + data['button_scale_y'] = scly + + xo2 = 0 + prize_value_scale = 1.5 + + data['prize_range_1_text'] = ba.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 355 + xo2 + x_offs, y + scly - 93), + size=(0, 0), + h_align='right', + v_align='center', + maxwidth=50, + text='-', + scale=0.8, + color=header_color, + flatness=1.0) + data['prize_value_1_text'] = ba.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 380 + xo2 + x_offs, y + scly - 93), + size=(0, 0), + h_align='left', + text='-', + v_align='center', + maxwidth=100, + scale=prize_value_scale, + color=value_color, + flatness=1.0) + + data['prize_range_2_text'] = ba.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 355 + xo2 + x_offs, y + scly - 93), + size=(0, 0), + h_align='right', + v_align='center', + maxwidth=50, + scale=0.8, + color=header_color, + flatness=1.0) + data['prize_value_2_text'] = ba.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 380 + xo2 + x_offs, y + scly - 93), + size=(0, 0), + h_align='left', + text='', + v_align='center', + maxwidth=100, + scale=prize_value_scale, + color=value_color, + flatness=1.0) + + data['prize_range_3_text'] = ba.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 355 + xo2 + x_offs, y + scly - 93), + size=(0, 0), + h_align='right', + v_align='center', + maxwidth=50, + scale=0.8, + color=header_color, + flatness=1.0) + data['prize_value_3_text'] = ba.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 380 + xo2 + x_offs, y + scly - 93), + size=(0, 0), + h_align='left', + text='', + v_align='center', + maxwidth=100, + scale=prize_value_scale, + color=value_color, + flatness=1.0) + + ba.textwidget(parent=parent, + draw_controller=btn, + position=(x + 620 + x_offs, y + scly - 20), + size=(0, 0), + h_align='center', + text=ba.Lstr(resource=self._r + '.currentBestText'), + v_align='center', + maxwidth=180, + scale=0.9, + color=header_color, + flatness=1.0) + data['current_leader_name_text'] = ba.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 620 + x_offs - (170 / 1.4) * 0.5, + y + scly - 60 - 40 * 0.5), + selectable=True, + click_activate=True, + autoselect=True, + on_activate_call=lambda: self._show_leader(tournament_button=data), + size=(170 / 1.4, 40), + h_align='center', + text='-', + v_align='center', + maxwidth=170, + scale=1.4, + color=value_color, + flatness=1.0) + data['current_leader_score_text'] = ba.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 620 + x_offs, y + scly - 113 + 10), + size=(0, 0), + h_align='center', + text='-', + v_align='center', + maxwidth=170, + scale=1.8, + color=value_color, + flatness=1.0) + + data['more_scores_button'] = ba.buttonwidget( + parent=parent, + position=(x + 620 + x_offs - 60, y + scly - 50 - 125), + color=(0.5, 0.5, 0.6), + textcolor=(0.7, 0.7, 0.8), + label='-', + size=(120, 40), + autoselect=True, + up_widget=data['current_leader_name_text'], + text_scale=0.6, + on_activate_call=lambda: self._show_scores(tournament_button=data)) + ba.widget(edit=data['current_leader_name_text'], + down_widget=data['more_scores_button']) + + ba.textwidget(parent=parent, + draw_controller=btn, + position=(x + 820 + x_offs, y + scly - 20), + size=(0, 0), + h_align='center', + text=ba.Lstr(resource=self._r + '.timeRemainingText'), + v_align='center', + maxwidth=180, + scale=0.9, + color=header_color, + flatness=1.0) + data['time_remaining_value_text'] = ba.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 820 + x_offs, y + scly - 68), + size=(0, 0), + h_align='center', + text='-', + v_align='center', + maxwidth=180, + scale=2.0, + color=value_color, + flatness=1.0) + data['time_remaining_out_of_text'] = ba.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 820 + x_offs, y + scly - 110), + size=(0, 0), + h_align='center', + text='-', + v_align='center', + maxwidth=120, + scale=0.72, + color=(0.4, 0.4, 0.5), + flatness=1.0) + return data + + def _switch_to_league_rankings(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import account + from bastd.ui.league.rankwindow import LeagueRankWindow + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + assert self._league_rank_button is not None + ba.app.main_menu_window = (LeagueRankWindow( + origin_widget=self._league_rank_button.get_button()). + get_root_widget()) + + def _switch_to_score(self, show_tab: str = 'extras') -> None: + # pylint: disable=cyclic-import + from bastd.ui import account + from bastd.ui.store import browser + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + assert self._store_button is not None + ba.app.main_menu_window = (browser.StoreBrowserWindow( + origin_widget=self._store_button.get_button(), + show_tab=show_tab, + back_location='CoopBrowserWindow').get_root_widget()) + + def _show_leader(self, tournament_button: Dict[str, Any]) -> None: + # pylint: disable=cyclic-import + from bastd.ui.account import viewer + tournament_id = tournament_button['tournament_id'] + + # FIXME: This assumes a single player entry in leader; should expand + # this to work with multiple. + if tournament_id is None or tournament_button['leader'] is None or len( + tournament_button['leader'][2]) != 1: + ba.playsound(ba.getsound('error')) + return + ba.playsound(ba.getsound('swish')) + viewer.AccountViewerWindow( + account_id=tournament_button['leader'][2][0].get('a', None), + profile_id=tournament_button['leader'][2][0].get('p', None), + position=tournament_button['current_leader_name_text']. + get_screen_space_center()) + + def _show_scores(self, tournament_button: Dict[str, Any]) -> None: + # pylint: disable=cyclic-import + from bastd.ui import tournamentscores + tournament_id = tournament_button['tournament_id'] + if tournament_id is None: + ba.playsound(ba.getsound('error')) + return + + tournamentscores.TournamentScoresWindow( + tournament_id=tournament_id, + position=tournament_button['more_scores_button']. + get_screen_space_center()) + + def is_tourney_data_up_to_date(self) -> bool: + """Return whether our tourney data is up to date.""" + return self._tourney_data_up_to_date + + def run(self, game: str, tournament_button: Dict[str, Any] = None) -> None: + """Run the provided game.""" + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-return-statements + # pylint: disable=cyclic-import + from ba.internal import have_pro + from bastd.ui import confirm + from bastd.ui import tournamententry + from bastd.ui.purchase import PurchaseWindow + from bastd.ui.account import show_sign_in_prompt + args: Dict[str, Any] = {} + + # Do a bit of pre-flight for tournament options. + if tournament_button is not None: + + if _ba.get_account_state() != 'signed_in': + show_sign_in_prompt() + return + + if not self._tourney_data_up_to_date: + ba.screenmessage( + ba.Lstr(resource='tournamentCheckingStateText'), + color=(1, 1, 0)) + ba.playsound(ba.getsound('error')) + return + + if tournament_button['tournament_id'] is None: + ba.screenmessage( + ba.Lstr(resource='internal.unavailableNoConnectionText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + if tournament_button['required_league'] is not None: + ba.screenmessage(ba.Lstr( + resource='league.tournamentLeagueText', + subs=[ + ('${NAME}', + ba.Lstr( + translate=('leagueNames', + tournament_button['required_league']))) + ]), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + if tournament_button['time_remaining'] <= 0: + ba.screenmessage(ba.Lstr(resource='tournamentEndedText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + # game is whatever the tournament tells us it is + game = ba.app.tournament_info[ + tournament_button['tournament_id']]['game'] + + if tournament_button is None and game == 'Easy:The Last Stand': + confirm.ConfirmWindow(ba.Lstr( + resource='difficultyHardUnlockOnlyText', + fallback_resource='difficultyHardOnlyText'), + cancel_button=False, + width=460, + height=130) + return + + # infinite onslaught/runaround require pro; bring up a store link if + # need be. + if tournament_button is None and game in ( + 'Challenges:Infinite Runaround', + 'Challenges:Infinite Onslaught') and not have_pro(): + if _ba.get_account_state() != 'signed_in': + show_sign_in_prompt() + else: + PurchaseWindow(items=['pro']) + return + + required_purchase: Optional[str] + if game in ['Challenges:Meteor Shower']: + required_purchase = 'games.meteor_shower' + elif game in [ + 'Challenges:Target Practice', 'Challenges:Target Practice B' + ]: + required_purchase = 'games.target_practice' + elif game in ['Challenges:Ninja Fight']: + required_purchase = 'games.ninja_fight' + elif game in ['Challenges:Pro Ninja Fight']: + required_purchase = 'games.ninja_fight' + elif game in [ + 'Challenges:Easter Egg Hunt', 'Challenges:Pro Easter Egg Hunt' + ]: + required_purchase = 'games.easter_egg_hunt' + else: + required_purchase = None + + if (tournament_button is None and required_purchase is not None + and not _ba.get_purchased(required_purchase)): + if _ba.get_account_state() != 'signed_in': + show_sign_in_prompt() + else: + PurchaseWindow(items=[required_purchase]) + return + + self._save_state() + + # For tournaments, we pop up the entry window. + if tournament_button is not None: + tournamententry.TournamentEntryWindow( + tournament_id=tournament_button['tournament_id'], + position=tournament_button['button'].get_screen_space_center()) + else: + # Otherwise just dive right in. + if ba.app.launch_coop_game(game, args=args): + ba.containerwidget(edit=self._root_widget, + transition='out_left') + + def _back(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.play import PlayWindow + + # If something is selected, store it. + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = (PlayWindow( + transition='in_left').get_root_widget()) + + def _restore_state(self) -> None: + try: + try: + sel_name = ba.app.window_states[ + self.__class__.__name__]['sel_name'] + except Exception: + sel_name = None + if sel_name == 'Back': + sel = self._back_button + elif sel_name == 'Scroll': + sel = self._scrollwidget + elif sel_name == 'PowerRanking': + sel = self._league_rank_button_widget + elif sel_name == 'Store': + sel = self._store_button_widget + else: + sel = self._scrollwidget + ba.containerwidget(edit=self._root_widget, selected_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) + + def _save_state(self) -> None: + cfg = ba.app.config + try: + sel = self._root_widget.get_selected_child() + if sel == self._back_button: + sel_name = 'Back' + elif sel == self._store_button_widget: + sel_name = 'Store' + elif sel == self._league_rank_button_widget: + sel_name = 'PowerRanking' + elif sel == self._scrollwidget: + sel_name = 'Scroll' + else: + raise Exception("unrecognized selection") + ba.app.window_states[self.__class__.__name__] = { + 'sel_name': sel_name + } + except Exception: + ba.print_exception('error saving state for', self.__class__) + + cfg['Selected Coop Row'] = self._selected_row + cfg['Selected Coop Custom Level'] = self._selected_custom_level + cfg['Selected Coop Challenge Level'] = self._selected_challenge_level + cfg['Selected Coop Campaign Level'] = self._selected_campaign_level + cfg.commit() + + def sel_change(self, row: str, game: str) -> None: + """(internal)""" + if self._do_selection_callbacks: + if row == 'custom': + self._selected_custom_level = game + if row == 'challenges': + self._selected_challenge_level = game + elif row == 'campaign': + self._selected_campaign_level = game diff --git a/assets/src/data/scripts/bastd/ui/coop/gamebutton.py b/assets/src/data/scripts/bastd/ui/coop/gamebutton.py new file mode 100644 index 00000000..b5879bfd --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/coop/gamebutton.py @@ -0,0 +1,236 @@ +"""Defines button for co-op games.""" + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Optional, List, Tuple + from bastd.ui.coop.browser import CoopBrowserWindow + + +class GameButton: + """Button for entering co-op games.""" + + def __init__(self, window: CoopBrowserWindow, parent: ba.Widget, game: str, + x: float, y: float, select: bool, row: str): + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + from ba.internal import (get_achievements_for_coop_level, get_campaign) + self._game = game + sclx = 195.0 + scly = 195.0 + + campaignname, levelname = game.split(':') + + # Hack: The Last Stand doesn't actually exist in the easy + # tourney. We just want it for display purposes. Map it to + # the hard-mode version. + if game == 'Easy:The Last Stand': + campaignname = 'Default' + + rating: Optional[float] + campaign = get_campaign(campaignname) + rating = campaign.get_level(levelname).rating + + if game == 'Easy:The Last Stand': + rating = None + + if rating is None or rating == 0.0: + stars = 0 + elif rating >= 9.5: + stars = 3 + elif rating >= 7.5: + stars = 2 + else: + stars = 1 + + self._button = btn = ba.buttonwidget( + parent=parent, + position=(x + 23, y + 4), + size=(sclx, scly), + label='', + on_activate_call=ba.Call(window.run, game), + button_type='square', + autoselect=True, + on_select_call=ba.Call(window.sel_change, row, game)) + ba.widget(edit=btn, + show_buffer_bottom=50, + show_buffer_top=50, + show_buffer_left=400, + show_buffer_right=200) + if select: + ba.containerwidget(edit=parent, + selected_child=btn, + visible_child=btn) + image_width = sclx * 0.85 * 0.75 + self._preview_widget = ba.imagewidget( + parent=parent, + draw_controller=btn, + position=(x + 21 + sclx * 0.5 - image_width * 0.5, y + scly - 104), + size=(image_width, image_width * 0.5), + model_transparent=window.lsbt, + model_opaque=window.lsbo, + texture=campaign.get_level(levelname).get_preview_texture(), + mask_texture=ba.gettexture('mapPreviewMask')) + + translated = campaign.get_level(levelname).displayname + self._achievements = (get_achievements_for_coop_level(game)) + + self._name_widget = ba.textwidget(parent=parent, + draw_controller=btn, + position=(x + 20 + sclx * 0.5, + y + scly - 27), + size=(0, 0), + h_align='center', + text=translated, + v_align='center', + maxwidth=sclx * 0.76, + scale=0.85) + xscl = x + (67 if self._achievements else 50) + yscl = y + scly - (137 if self._achievements else 157) + + starscale = 35.0 if self._achievements else 45.0 + + self._star_widgets: List[ba.Widget] = [] + for _i in range(stars): + imw = ba.imagewidget(parent=parent, + draw_controller=btn, + position=(xscl, yscl), + size=(starscale, starscale), + texture=window.star_tex) + self._star_widgets.append(imw) + xscl += starscale + for _i in range(3 - stars): + ba.imagewidget(parent=parent, + draw_controller=btn, + position=(xscl, yscl), + size=(starscale, starscale), + color=(0, 0, 0), + texture=window.star_tex, + opacity=0.3) + xscl += starscale + + xach = x + 69 + yach = y + scly - 168 + a_scale = 30.0 + self._achievement_widgets: List[Tuple[ba.Widget, ba.Widget]] = [] + for ach in self._achievements: + a_complete = ach.complete + imw = ba.imagewidget( + parent=parent, + draw_controller=btn, + position=(xach, yach), + size=(a_scale, a_scale), + color=tuple(ach.get_icon_color(a_complete)[:3]) + if a_complete else (1.2, 1.2, 1.2), + texture=ach.get_icon_texture(a_complete)) + imw2 = ba.imagewidget(parent=parent, + draw_controller=btn, + position=(xach, yach), + size=(a_scale, a_scale), + color=(2, 1.4, 0.4), + texture=window.a_outline_tex, + model_transparent=window.a_outline_model) + self._achievement_widgets.append((imw, imw2)) + # if a_complete: + xach += a_scale * 1.2 + + # if not unlocked: + self._lock_widget = ba.imagewidget(parent=parent, + draw_controller=btn, + position=(x - 8 + sclx * 0.5, + y + scly * 0.5 - 20), + size=(60, 60), + opacity=0.0, + texture=ba.gettexture('lock')) + + # give a quasi-random update increment to spread the load.. + self._update_timer = ba.Timer(0.001 * (900 + random.randrange(200)), + ba.WeakCall(self._update), + repeat=True, + timetype=ba.TimeType.REAL) + self._update() + + def get_button(self) -> ba.Widget: + """Return the underlying button ba.Widget.""" + return self._button + + def _update(self) -> None: + # pylint: disable=too-many-boolean-expressions + from ba.internal import have_pro, get_campaign + game = self._game + campaignname, levelname = game.split(':') + + # Hack - The Last Stand doesn't actually exist in the + # easy tourney; we just want it for display purposes. Map it to + # the hard-mode version. + if game == 'Easy:The Last Stand': + campaignname = 'Default' + + campaign = get_campaign(campaignname) + + levels = campaign.get_levels() + + # If this campaign is sequential, make sure we've unlocked + # everything up to here. + unlocked = True + if campaign.sequential: + for level in levels: + if level.name == levelname: + break + if not level.complete: + unlocked = False + break + + # We never actually allow playing last-stand on easy mode. + if game == 'Easy:The Last Stand': + unlocked = False + + # Hard-code games we haven't unlocked. + if ((game in ('Challenges:Infinite Runaround', + 'Challenges:Infinite Onslaught') and not have_pro()) + or (game in ('Challenges:Meteor Shower', ) + and not _ba.get_purchased('games.meteor_shower')) + or (game in ('Challenges:Target Practice', + 'Challenges:Target Practice B') + and not _ba.get_purchased('games.target_practice')) + or (game in ('Challenges:Ninja Fight', ) + and not _ba.get_purchased('games.ninja_fight')) + or (game in ('Challenges:Pro Ninja Fight', ) + and not _ba.get_purchased('games.ninja_fight')) + or (game in ('Challenges:Easter Egg Hunt', + 'Challenges:Pro Easter Egg Hunt') + and not _ba.get_purchased('games.easter_egg_hunt'))): + unlocked = False + + # Let's tint levels a slightly different color when easy mode + # is selected. + unlocked_color = (0.85, 0.95, + 0.5) if game.startswith('Easy:') else (0.5, 0.7, 0.2) + + ba.buttonwidget(edit=self._button, + color=unlocked_color if unlocked else (0.5, 0.5, 0.5)) + + ba.imagewidget(edit=self._lock_widget, + opacity=0.0 if unlocked else 1.0) + ba.imagewidget(edit=self._preview_widget, + opacity=1.0 if unlocked else 0.3) + ba.textwidget(edit=self._name_widget, + color=(0.8, 1.0, 0.8, 1.0) if unlocked else + (0.7, 0.7, 0.7, 0.7)) + for widget in self._star_widgets: + ba.imagewidget(edit=widget, + opacity=1.0 if unlocked else 0.3, + color=(2.2, 1.2, 0.3) if unlocked else (1, 1, 1)) + for i, ach in enumerate(self._achievements): + a_complete = ach.complete + ba.imagewidget(edit=self._achievement_widgets[i][0], + opacity=1.0 if (a_complete and unlocked) else 0.3) + ba.imagewidget(edit=self._achievement_widgets[i][1], + opacity=(1.0 if (a_complete and unlocked) else + 0.2 if a_complete else 0.0)) diff --git a/assets/src/data/scripts/bastd/ui/coop/level.py b/assets/src/data/scripts/bastd/ui/coop/level.py new file mode 100644 index 00000000..b3613f4c --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/coop/level.py @@ -0,0 +1,55 @@ +"""Bits of utility functionality related to co-op levels.""" + +from __future__ import annotations + +import ba + + +class CoopLevelLockedWindow(ba.OldWindow): + """Window showing that a level is locked.""" + + def __init__(self, name: ba.Lstr, dep_name: ba.Lstr): + width = 550.0 + height = 250.0 + lock_tex = ba.gettexture('lock') + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition='in_right', + scale=1.7 if ba.app.small_ui else 1.3 if ba.app.med_ui else 1.0)) + ba.textwidget(parent=self._root_widget, + position=(150 - 20, height * 0.63), + size=(0, 0), + h_align="left", + v_align='center', + text=ba.Lstr(resource='levelIsLockedText', + subs=[('${LEVEL}', name)]), + maxwidth=400, + color=(1, 0.8, 0.3, 1), + scale=1.1) + ba.textwidget(parent=self._root_widget, + position=(150 - 20, height * 0.48), + size=(0, 0), + h_align="left", + v_align='center', + text=ba.Lstr(resource='levelMustBeCompletedFirstText', + subs=[('${LEVEL}', dep_name)]), + maxwidth=400, + color=ba.app.infotextcolor, + scale=0.8) + ba.imagewidget(parent=self._root_widget, + position=(56 - 20, height * 0.39), + size=(80, 80), + texture=lock_tex, + opacity=1.0) + btn = ba.buttonwidget(parent=self._root_widget, + position=((width - 140) / 2, 30), + size=(140, 50), + label=ba.Lstr(resource='okText'), + on_activate_call=self._ok) + ba.containerwidget(edit=self._root_widget, + selected_child=btn, + start_button=btn) + ba.playsound(ba.getsound('error')) + + def _ok(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_left') diff --git a/assets/src/data/scripts/bastd/ui/creditslist.py b/assets/src/data/scripts/bastd/ui/creditslist.py new file mode 100644 index 00000000..48660c0f --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/creditslist.py @@ -0,0 +1,262 @@ +"""Provides a window to display game credits.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Tuple, Optional, Sequence + + +class CreditsListWindow(ba.OldWindow): + """Window for displaying game credits.""" + + def __init__(self, origin_widget: ba.Widget = None): + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + import json + ba.set_analytics_screen('Credits Window') + + # if they provided an origin-widget, scale up from that + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + transition = 'in_right' + + width = 870 if ba.app.small_ui else 670 + x_inset = 100 if ba.app.small_ui else 0 + height = 398 if ba.app.small_ui else 500 + + self._r = 'creditsWindow' + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition=transition, + toolbar_visibility='menu_minimal', + scale_origin_stack_offset=scale_origin, + scale=(2.0 if ba.app.small_ui else 1.3 if ba.app.med_ui else 1.0), + stack_offset=(0, -8) if ba.app.small_ui else (0, 0))) + + if ba.app.toolbars and ba.app.small_ui: + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._back) + else: + btn = ba.buttonwidget(parent=self._root_widget, + position=(40 + x_inset, height - + (68 if ba.app.small_ui else 62)), + size=(140, 60), + scale=0.8, + label=ba.Lstr(resource='backText'), + button_type='back', + on_activate_call=self._back, + autoselect=True) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + ba.buttonwidget(edit=btn, + button_type='backSmall', + position=(40 + x_inset, height - + (68 if ba.app.small_ui else 62) + 5), + size=(60, 48), + label=ba.charstr(ba.SpecialChar.BACK)) + + ba.textwidget(parent=self._root_widget, + position=(0, height - (59 if ba.app.small_ui else 54)), + size=(width, 30), + text=ba.Lstr(resource=self._r + '.titleText', + subs=[('${APP_NAME}', + ba.Lstr(resource='titleText'))]), + h_align="center", + color=ba.app.title_color, + maxwidth=330, + v_align="center") + + scroll = ba.scrollwidget(parent=self._root_widget, + position=(40 + x_inset, 35), + size=(width - (80 + 2 * x_inset), + height - 100), + capture_arrows=True) + + if ba.app.toolbars: + ba.widget(edit=scroll, + right_widget=_ba.get_special_widget('party_button')) + if ba.app.small_ui: + ba.widget(edit=scroll, + left_widget=_ba.get_special_widget('back_button')) + + def _format_names(names2: Sequence[str], inset: float) -> str: + sval = '' + # measure a series since there's overlaps and stuff.. + space_width = _ba.get_string_width(' ' * 10, + suppress_warning=True) / 10.0 + spacing = 330.0 + col1 = inset + col2 = col1 + spacing + col3 = col2 + spacing + line_width = 0.0 + nline = '' + for name in names2: + # move to the next column (or row) and print + if line_width > col3: + sval += nline + '\n' + nline = '' + line_width = 0 + + if line_width > col2: + target = col3 + elif line_width > col1: + target = col2 + else: + target = col1 + spacingstr = ' ' * int((target - line_width) / space_width) + nline += spacingstr + nline += name + line_width = _ba.get_string_width(nline, suppress_warning=True) + if nline != '': + sval += nline + '\n' + return sval + + sound_and_music = ba.Lstr(resource=self._r + + '.songCreditText').evaluate() + sound_and_music = sound_and_music.replace( + '${TITLE}', "'William Tell (Trumpet Entry)'") + sound_and_music = sound_and_music.replace( + '${PERFORMER}', 'The Apollo Symphony Orchestra') + sound_and_music = sound_and_music.replace( + '${PERFORMER}', 'The Apollo Symphony Orchestra') + sound_and_music = sound_and_music.replace('${COMPOSER}', + 'Gioacchino Rossini') + sound_and_music = sound_and_music.replace('${ARRANGER}', 'Chris Worth') + sound_and_music = sound_and_music.replace('${PUBLISHER}', 'BMI') + sound_and_music = sound_and_music.replace('${SOURCE}', + 'www.AudioSparx.com') + spc = ' ' + sound_and_music = spc + sound_and_music.replace('\n', '\n' + spc) + names = [ + 'HubOfTheUniverseProd', 'Jovica', 'LG', 'Leady', 'Percy Duke', + 'PhreaKsAccount', 'Pogotron', 'Rock Savage', 'anamorphosis', + 'benboncan', 'cdrk', 'chipfork', 'guitarguy1985', 'jascha', + 'joedeshon', 'loofa', 'm_O_m', 'mich3d', 'sandyrb', 'shakaharu', + 'sirplus', 'stickman', 'thanvannispen', 'virotic', 'zimbot' + ] + names.sort(key=lambda x: x.lower()) + freesound_names = _format_names(names, 90) + + try: + with open('data/data/langdata.json') as infile: + translation_contributors = (json.loads( + infile.read())['translation_contributors']) + except Exception: + ba.print_exception('error reading translation contributors') + translation_contributors = [] + + translation_names = _format_names(translation_contributors, 60) + + # Need to bake this out and chop it up since we're passing our + # 65535 vertex limit for meshes.. + # We can remove that limit once we drop support for GL ES2.. :-/ + # (or add mesh splitting under the hood) + credits_text = ( + ' ' + ba.Lstr(resource=self._r + + '.codingGraphicsAudioText').evaluate().replace( + '${NAME}', 'Eric Froemling') + '\n' + '\n' + ' ' + ba.Lstr(resource=self._r + + '.additionalAudioArtIdeasText').evaluate().replace( + '${NAME}', 'Raphael Suter') + '\n' + '\n' + ' ' + + ba.Lstr(resource=self._r + '.soundAndMusicText').evaluate() + '\n' + '\n' + sound_and_music + '\n' + '\n' + ' ' + ba.Lstr(resource=self._r + + '.publicDomainMusicViaText').evaluate().replace( + '${NAME}', 'Musopen.com') + '\n' + ' ' + + ba.Lstr(resource=self._r + + '.thanksEspeciallyToText').evaluate().replace( + '${NAME}', 'the US Army, Navy, and Marine Bands') + + '\n' + '\n' + ' ' + ba.Lstr(resource=self._r + + '.additionalMusicFromText').evaluate().replace( + '${NAME}', 'The YouTube Audio Library') + + '\n' + '\n' + ' ' + + ba.Lstr(resource=self._r + '.soundsText').evaluate().replace( + '${SOURCE}', 'Freesound.org') + '\n' + '\n' + freesound_names + '\n' + '\n' + ' ' + ba.Lstr(resource=self._r + + '.languageTranslationsText').evaluate() + '\n' + '\n' + '\n'.join(translation_names.splitlines()[:146]) + + '\n'.join(translation_names.splitlines()[146:]) + '\n' + '\n' + ' Shout Out to Awesome Mods / Modders:\n\n' + ' BombDash ModPack\n' + ' TheMikirog & SoK - BombSquad Joyride Modpack\n' + ' Mrmaxmeier - BombSquad-Community-Mod-Manager\n' + '\n' + ' Holiday theme vector art designed by Freepik\n' + '\n' + ' ' + + ba.Lstr(resource=self._r + '.specialThanksText').evaluate() + '\n' + '\n' + ' Todd, Laura, and Robert Froemling\n' + ' ' + + ba.Lstr(resource=self._r + '.allMyFamilyText').evaluate().replace( + '\n', '\n ') + '\n' + ' ' + ba.Lstr(resource=self._r + + '.whoeverInventedCoffeeText').evaluate() + '\n' + '\n' + ' ' + ba.Lstr(resource=self._r + '.legalText').evaluate() + '\n' + '\n' + ' ' + ba.Lstr(resource=self._r + + '.softwareBasedOnText').evaluate().replace( + '${NAME}', 'the Khronos Group') + '\n' + '\n' + ' ' + ' www.froemling.net\n') + + txt = credits_text + lines = txt.splitlines() + line_height = 20 + + scale = 0.55 + self._sub_width = width - 80 + self._sub_height = line_height * len(lines) + 40 + + container = self._subcontainer = ba.containerwidget( + parent=scroll, + size=(self._sub_width, self._sub_height), + background=False, + claims_left_right=False, + claims_tab=False) + + voffs = 0 + for line in lines: + ba.textwidget(parent=container, + padding=4, + color=(0.7, 0.9, 0.7, 1.0), + scale=scale, + flatness=1.0, + size=(0, 0), + position=(0, self._sub_height - 20 + voffs), + h_align='left', + v_align='top', + text=ba.Lstr(value=line)) + voffs -= line_height + + def _back(self) -> None: + from bastd.ui import mainmenu + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = mainmenu.MainMenuWindow( + transition='in_left').get_root_widget() diff --git a/assets/src/data/scripts/bastd/ui/debug.py b/assets/src/data/scripts/bastd/ui/debug.py new file mode 100644 index 00000000..ec95b35b --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/debug.py @@ -0,0 +1,315 @@ +"""UIs for debugging purposes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import ba + +if TYPE_CHECKING: + pass + + +class DebugWindow(ba.OldWindow): + """Window for debugging internal values.""" + + def __init__(self, transition: str = 'in_right'): + # pylint: disable=too-many-statements + # pylint: disable=cyclic-import + from bastd.ui import popup + + self._width = width = 580 + self._height = height = (350 if ba.app.small_ui else + 420 if ba.app.med_ui else 520) + + self._scroll_width = self._width - 100 + self._scroll_height = self._height - 120 + + self._sub_width = self._scroll_width * 0.95 + self._sub_height = 520 + + self._stress_test_game_type = 'Random' + self._stress_test_playlist = '__default__' + self._stress_test_player_count = 8 + self._stress_test_round_duration = 30 + + self._r = 'debugWindow' + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition=transition, + scale=( + 2.35 if ba.app.small_ui else 1.55 if ba.app.med_ui else 1.0), + stack_offset=(0, -30) if ba.app.small_ui else (0, 0))) + + self._done_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(40, height - 67), + size=(120, 60), + scale=0.8, + autoselect=True, + label=ba.Lstr(resource='doneText'), + on_activate_call=self._done) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + ba.textwidget(parent=self._root_widget, + position=(0, height - 60), + size=(width, 30), + text=ba.Lstr(resource=self._r + '.titleText'), + h_align="center", + color=ba.app.title_color, + v_align="center", + maxwidth=260) + + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + highlight=False, + size=(self._scroll_width, self._scroll_height), + position=((self._width - self._scroll_width) * 0.5, 50)) + ba.containerwidget(edit=self._scrollwidget, claims_left_right=True) + + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._sub_width, + self._sub_height), + background=False) + + v = self._sub_height - 70 + button_width = 300 + btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v), + size=(button_width, 60), + autoselect=True, + label=ba.Lstr(resource=self._r + '.runCPUBenchmarkText'), + on_activate_call=self._run_cpu_benchmark_pressed) + ba.widget(edit=btn, + up_widget=self._done_button, + left_widget=self._done_button) + v -= 60 + + ba.buttonwidget(parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v), + size=(button_width, 60), + autoselect=True, + label=ba.Lstr(resource=self._r + + '.runGPUBenchmarkText'), + on_activate_call=self._run_gpu_benchmark_pressed) + v -= 60 + + ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v), + size=(button_width, 60), + autoselect=True, + label=ba.Lstr(resource=self._r + '.runMediaReloadBenchmarkText'), + on_activate_call=self._run_media_reload_benchmark_pressed) + v -= 60 + + ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, v + 22), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.stressTestTitleText'), + maxwidth=200, + color=ba.app.heading_color, + scale=0.85, + h_align="center", + v_align="center") + v -= 45 + + x_offs = 165 + ba.textwidget(parent=self._subcontainer, + position=(x_offs - 10, v + 22), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.stressTestPlaylistTypeText'), + maxwidth=130, + color=ba.app.heading_color, + scale=0.65, + h_align="right", + v_align="center") + + popup.PopupMenu( + parent=self._subcontainer, + position=(x_offs, v), + width=150, + choices=['Random', 'Teams', 'Free-For-All'], + choices_display=[ + ba.Lstr(resource=a) for a in [ + 'randomText', 'playModes.teamsText', + 'playModes.freeForAllText' + ] + ], + current_choice='Auto', + on_value_change_call=self._stress_test_game_type_selected) + + v -= 46 + ba.textwidget(parent=self._subcontainer, + position=(x_offs - 10, v + 22), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.stressTestPlaylistNameText'), + maxwidth=130, + color=ba.app.heading_color, + scale=0.65, + h_align="right", + v_align="center") + + self._stress_test_playlist_name_field = ba.textwidget( + parent=self._subcontainer, + position=(x_offs + 5, v - 5), + size=(250, 46), + text=self._stress_test_playlist, + h_align="left", + v_align="center", + autoselect=True, + color=(0.9, 0.9, 0.9, 1.0), + description=ba.Lstr(resource=self._r + + '.stressTestPlaylistDescriptionText'), + editable=True, + padding=4) + v -= 29 + x_sub = 60 + + # Player count. + ba.textwidget(parent=self._subcontainer, + position=(x_offs - 10, v), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.stressTestPlayerCountText'), + color=(0.8, 0.8, 0.8, 1.0), + h_align="right", + v_align="center", + scale=0.65, + maxwidth=130) + self._stress_test_player_count_text = ba.textwidget( + parent=self._subcontainer, + position=(246 - x_sub, v - 14), + size=(60, 28), + editable=False, + color=(0.3, 1.0, 0.3, 1.0), + h_align="right", + v_align="center", + text=str(self._stress_test_player_count), + padding=2) + ba.buttonwidget(parent=self._subcontainer, + position=(330 - x_sub, v - 11), + size=(28, 28), + label="-", + autoselect=True, + on_activate_call=ba.Call( + self._stress_test_player_count_decrement), + repeat=True, + enable_sound=True) + ba.buttonwidget(parent=self._subcontainer, + position=(380 - x_sub, v - 11), + size=(28, 28), + label="+", + autoselect=True, + on_activate_call=ba.Call( + self._stress_test_player_count_increment), + repeat=True, + enable_sound=True) + v -= 42 + + # Round duration. + ba.textwidget(parent=self._subcontainer, + position=(x_offs - 10, v), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.stressTestRoundDurationText'), + color=(0.8, 0.8, 0.8, 1.0), + h_align="right", + v_align="center", + scale=0.65, + maxwidth=130) + self._stress_test_round_duration_text = ba.textwidget( + parent=self._subcontainer, + position=(246 - x_sub, v - 14), + size=(60, 28), + editable=False, + color=(0.3, 1.0, 0.3, 1.0), + h_align="right", + v_align="center", + text=str(self._stress_test_round_duration), + padding=2) + ba.buttonwidget(parent=self._subcontainer, + position=(330 - x_sub, v - 11), + size=(28, 28), + label="-", + autoselect=True, + on_activate_call=ba.Call( + self._stress_test_round_duration_decrement), + repeat=True, + enable_sound=True) + ba.buttonwidget(parent=self._subcontainer, + position=(380 - x_sub, v - 11), + size=(28, 28), + label="+", + autoselect=True, + on_activate_call=ba.Call( + self._stress_test_round_duration_increment), + repeat=True, + enable_sound=True) + v -= 82 + btn = ba.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v), + size=(button_width, 60), + autoselect=True, + label=ba.Lstr(resource=self._r + '.runStressTestText'), + on_activate_call=self._stress_test_pressed) + ba.widget(btn, show_buffer_bottom=50) + + def _stress_test_player_count_decrement(self) -> None: + self._stress_test_player_count = max( + 1, self._stress_test_player_count - 1) + ba.textwidget(edit=self._stress_test_player_count_text, + text=str(self._stress_test_player_count)) + + def _stress_test_player_count_increment(self) -> None: + self._stress_test_player_count = self._stress_test_player_count + 1 + ba.textwidget(edit=self._stress_test_player_count_text, + text=str(self._stress_test_player_count)) + + def _stress_test_round_duration_decrement(self) -> None: + self._stress_test_round_duration = max( + 10, self._stress_test_round_duration - 10) + ba.textwidget(edit=self._stress_test_round_duration_text, + text=str(self._stress_test_round_duration)) + + def _stress_test_round_duration_increment(self) -> None: + self._stress_test_round_duration = (self._stress_test_round_duration + + 10) + ba.textwidget(edit=self._stress_test_round_duration_text, + text=str(self._stress_test_round_duration)) + + def _stress_test_game_type_selected(self, game_type: str) -> None: + self._stress_test_game_type = game_type + + def _run_cpu_benchmark_pressed(self) -> None: + from ba.internal import run_cpu_benchmark + run_cpu_benchmark() + + def _run_gpu_benchmark_pressed(self) -> None: + from ba.internal import run_gpu_benchmark + run_gpu_benchmark() + + def _run_media_reload_benchmark_pressed(self) -> None: + from ba.internal import run_media_reload_benchmark + run_media_reload_benchmark() + + def _stress_test_pressed(self) -> None: + from ba.internal import run_stress_test + run_stress_test( + playlist_type=self._stress_test_game_type, + playlist_name=cast( + str, + ba.textwidget(query=self._stress_test_playlist_name_field)), + player_count=self._stress_test_player_count, + round_duration=self._stress_test_round_duration) + ba.containerwidget(edit=self._root_widget, transition='out_right') + + def _done(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings.advanced import AdvancedSettingsWindow + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (AdvancedSettingsWindow( + transition='in_left').get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/feedback.py b/assets/src/data/scripts/bastd/ui/feedback.py new file mode 100644 index 00000000..d9143ba9 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/feedback.py @@ -0,0 +1,77 @@ +"""UI functionality related to users rating the game.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Optional + + +def ask_for_rating() -> Optional[ba.Widget]: + """(internal)""" + app = ba.app + platform = app.platform + subplatform = app.subplatform + if not (platform == 'mac' or (platform == 'android' + and subplatform in ['google', 'cardboard'])): + return None + width = 700 + height = 400 + spacing = 40 + dlg = ba.containerwidget( + size=(width, height), + transition='in_right', + scale=1.6 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0) + v = height - 50 + v -= spacing + v -= 140 + ba.imagewidget(parent=dlg, + position=(width / 2 - 100, v + 10), + size=(200, 200), + texture=ba.gettexture("cuteSpaz")) + ba.textwidget(parent=dlg, + position=(15, v - 55), + size=(width - 30, 30), + color=ba.app.infotextcolor, + text=ba.Lstr(resource='pleaseRateText', + subs=[('${APP_NAME}', + ba.Lstr(resource='titleText'))]), + maxwidth=width * 0.95, + max_height=130, + scale=0.85, + h_align="center", + v_align="center") + + def do_rating() -> None: + if platform == 'android': + if subplatform == 'google': + url = 'market://details?id=net.froemling.ballisticacore' + else: + url = 'market://details?id=net.froemling.ballisticacorecb' + else: + url = 'macappstore://itunes.apple.com/app/id416482767?ls=1&mt=12' + + ba.open_url(url) + ba.containerwidget(edit=dlg, transition='out_left') + + ba.buttonwidget(parent=dlg, + position=(60, 20), + size=(200, 60), + label=ba.Lstr(resource='wellSureText'), + autoselect=True, + on_activate_call=do_rating) + + def close() -> None: + ba.containerwidget(edit=dlg, transition='out_left') + + btn = ba.buttonwidget(parent=dlg, + position=(width - 270, 20), + size=(200, 60), + label=ba.Lstr(resource='noThanksText'), + autoselect=True, + on_activate_call=close) + ba.containerwidget(edit=dlg, cancel_button=btn, selected_child=btn) + return dlg diff --git a/assets/src/data/scripts/bastd/ui/fileselector.py b/assets/src/data/scripts/bastd/ui/fileselector.py new file mode 100644 index 00000000..97c1655c --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/fileselector.py @@ -0,0 +1,379 @@ +"""UI functionality for selecting files.""" + +from __future__ import annotations + +import os +import threading +import time +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Callable, Sequence, List, Optional + + +class FileSelectorWindow(ba.OldWindow): + """Window for selecting files.""" + + def __init__(self, + path: str, + callback: Callable[[Optional[str]], Any] = None, + show_base_path: bool = True, + valid_file_extensions: Sequence[str] = None, + allow_folders: bool = False): + if valid_file_extensions is None: + valid_file_extensions = [] + self._width = 700 if ba.app.small_ui else 600 + self._x_inset = x_inset = 50 if ba.app.small_ui else 0 + self._height = 365 if ba.app.small_ui else 418 + self._callback = callback + self._base_path = path + self._path: Optional[str] = None + self._recent_paths: List[str] = [] + self._show_base_path = show_base_path + self._valid_file_extensions = [ + '.' + ext for ext in valid_file_extensions + ] + self._allow_folders = allow_folders + self._subcontainer: Optional[ba.Widget] = None + self._subcontainerheight: Optional[float] = None + self._scroll_width = self._width - (80 + 2 * x_inset) + self._scroll_height = self._height - 170 + self._r = 'fileSelectorWindow' + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition='in_right', + scale=(2.23 if ba.app.small_ui else 1.4 if ba.app.med_ui else 1.0), + stack_offset=(0, -35) if ba.app.small_ui else (0, 0))) + ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height - 42), + size=(0, 0), + color=ba.app.title_color, + h_align="center", + v_align="center", + text=ba.Lstr(resource=self._r + '.titleFolderText') if + (allow_folders and not valid_file_extensions) else ba.Lstr( + resource=self._r + + '.titleFileText') if not allow_folders else ba.Lstr( + resource=self._r + '.titleFileFolderText'), + maxwidth=210) + + self._button_width = 146 + self._cancel_button = ba.buttonwidget( + parent=self._root_widget, + position=(35 + x_inset, self._height - 67), + autoselect=True, + size=(self._button_width, 50), + label=ba.Lstr(resource='cancelText'), + on_activate_call=self._cancel) + ba.widget(edit=self._cancel_button, left_widget=self._cancel_button) + + b_color = (0.6, 0.53, 0.63) + + self._back_button = ba.buttonwidget( + parent=self._root_widget, + button_type='square', + position=(43 + x_inset, self._height - 113), + color=b_color, + textcolor=(0.75, 0.7, 0.8), + enable_sound=False, + size=(55, 35), + label=ba.charstr(ba.SpecialChar.LEFT_ARROW), + on_activate_call=self._on_back_press) + + self._folder_tex = ba.gettexture('folder') + self._folder_color = (1.1, 0.8, 0.2) + self._file_tex = ba.gettexture('file') + self._file_color = (1, 1, 1) + self._use_folder_button = None + self._folder_center = self._width * 0.5 + 15 + self._folder_icon = ba.imagewidget(parent=self._root_widget, + size=(40, 40), + position=(40, self._height - 117), + texture=self._folder_tex, + color=self._folder_color) + self._path_text = ba.textwidget(parent=self._root_widget, + position=(self._folder_center, + self._height - 98), + size=(0, 0), + color=ba.app.title_color, + h_align="center", + v_align="center", + text=self._path, + maxwidth=self._width * 0.9) + self._scrollwidget: Optional[ba.Widget] = None + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + self._set_path(path) + + def _on_up_press(self) -> None: + self._on_entry_activated('..') + + def _on_back_press(self) -> None: + if len(self._recent_paths) > 1: + ba.playsound(ba.getsound('swish')) + self._recent_paths.pop() + self._set_path(self._recent_paths.pop()) + else: + ba.playsound(ba.getsound('error')) + + def _on_folder_entry_activated(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_right') + if self._callback is not None: + assert self._path is not None + self._callback(self._path) + + def _on_entry_activated(self, entry: str) -> None: + # pylint: disable=too-many-branches + new_path = None + try: + assert self._path is not None + if entry == '..': + chunks = self._path.split('/') + if len(chunks) > 1: + new_path = '/'.join(chunks[:-1]) + if new_path == '': + new_path = '/' + else: + ba.playsound(ba.getsound('error')) + else: + if self._path == '/': + test_path = self._path + entry + else: + test_path = self._path + '/' + entry + if os.path.isdir(test_path): + ba.playsound(ba.getsound('swish')) + new_path = test_path + elif os.path.isfile(test_path): + if self._is_valid_file_path(test_path): + ba.playsound(ba.getsound('swish')) + ba.containerwidget(edit=self._root_widget, + transition='out_right') + if self._callback is not None: + self._callback(test_path) + else: + ba.playsound(ba.getsound('error')) + else: + print(('Error: FileSelectorWindow found non-file/dir:', + test_path)) + except Exception: + ba.print_exception( + 'error on FileSelectorWindow._on_entry_activated') + + if new_path is not None: + self._set_path(new_path) + + class _RefreshThread(threading.Thread): + + def __init__(self, path: str, + callback: Callable[[List[str], str], Any]): + super().__init__() + self._callback = callback + self._path = path + + def run(self) -> None: + try: + starttime = time.time() + files = os.listdir(self._path) + duration = time.time() - starttime + min_time = 0.1 + # make sure this takes at least 1/10 second so the user + # has time to see the selection highlight + if duration < min_time: + time.sleep(min_time - duration) + ba.pushcall(ba.Call(self._callback, file_names=files), + from_other_thread=True) + except Exception as exc: + # ignore permission-denied + if 'Errno 13' not in str(exc): + ba.print_exception() + ba.pushcall(ba.Call(self._callback, error=str(exc)), + from_other_thread=True) + + def _set_path(self, path: str, add_to_recent: bool = True) -> None: + self._path = path + if add_to_recent: + self._recent_paths.append(path) + self._RefreshThread(path, self._refresh).start() + + def _refresh(self, file_names: List[str], error: str) -> None: + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + if not self._root_widget: + return + + scrollwidget_selected = ( + self._scrollwidget is None + or self._root_widget.get_selected_child() == self._scrollwidget) + + in_top_folder = (self._path == self._base_path) + hide_top_folder = in_top_folder and self._show_base_path is False + + if hide_top_folder: + folder_name = '' + elif self._path == '/': + folder_name = '/' + else: + assert self._path is not None + folder_name = os.path.basename(self._path) + + b_color = (0.6, 0.53, 0.63) + b_color_disabled = (0.65, 0.65, 0.65) + + if len(self._recent_paths) < 2: + ba.buttonwidget(edit=self._back_button, + color=b_color_disabled, + textcolor=(0.5, 0.5, 0.5)) + else: + ba.buttonwidget(edit=self._back_button, + color=b_color, + textcolor=(0.75, 0.7, 0.8)) + + max_str_width = 300.0 + str_width = min( + max_str_width, + _ba.get_string_width(folder_name, suppress_warning=True)) + ba.textwidget(edit=self._path_text, + text=folder_name, + maxwidth=max_str_width) + ba.imagewidget(edit=self._folder_icon, + position=(self._folder_center - str_width * 0.5 - 40, + self._height - 117), + opacity=0.0 if hide_top_folder else 1.0) + + if self._scrollwidget is not None: + self._scrollwidget.delete() + + if self._use_folder_button is not None: + self._use_folder_button.delete() + ba.widget(edit=self._cancel_button, right_widget=self._back_button) + + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + position=((self._width - self._scroll_width) * 0.5, + self._height - self._scroll_height - 119), + size=(self._scroll_width, self._scroll_height)) + + if scrollwidget_selected: + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget) + + # show error case.. + if error is not None: + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._scroll_width, + self._scroll_height), + background=False) + ba.textwidget(parent=self._subcontainer, + color=(1, 1, 0, 1), + text=error, + maxwidth=self._scroll_width * 0.9, + position=(self._scroll_width * 0.48, + self._scroll_height * 0.57), + size=(0, 0), + h_align='center', + v_align='center') + + else: + file_names = [f for f in file_names if not f.startswith('.')] + file_names.sort(key=lambda x: x[0].lower()) + + entries = file_names + entry_height = 35 + folder_entry_height = 100 + show_folder_entry = False + show_use_folder_button = (self._allow_folders + and not in_top_folder) + + self._subcontainerheight = entry_height * len(entries) + ( + folder_entry_height if show_folder_entry else 0) + v = self._subcontainerheight - (folder_entry_height + if show_folder_entry else 0) + + self._subcontainer = ba.containerwidget( + parent=self._scrollwidget, + size=(self._scroll_width, self._subcontainerheight), + background=False) + + ba.containerwidget(edit=self._scrollwidget, + claims_left_right=False, + claims_tab=False) + ba.containerwidget(edit=self._subcontainer, + claims_left_right=False, + claims_tab=False, + selection_loops=False, + print_list_exit_instructions=False) + ba.widget(edit=self._subcontainer, up_widget=self._back_button) + + if show_use_folder_button: + self._use_folder_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(self._width - self._button_width - 35 - + self._x_inset, self._height - 67), + size=(self._button_width, 50), + label=ba.Lstr(resource=self._r + + '.useThisFolderButtonText'), + on_activate_call=self._on_folder_entry_activated) + ba.widget(edit=btn, + left_widget=self._cancel_button, + down_widget=self._scrollwidget) + ba.widget(edit=self._cancel_button, right_widget=btn) + ba.containerwidget(edit=self._root_widget, start_button=btn) + + folder_icon_size = 35 + for num, entry in enumerate(entries): + cnt = ba.containerwidget( + parent=self._subcontainer, + position=(0, v - entry_height), + size=(self._scroll_width, entry_height), + root_selectable=True, + background=False, + click_activate=True, + on_activate_call=ba.Call(self._on_entry_activated, entry)) + if num == 0: + ba.widget(edit=cnt, up_widget=self._back_button) + is_valid_file_path = self._is_valid_file_path(entry) + is_dir = os.path.isdir(self._path + '/' + entry) + if is_dir: + ba.imagewidget(parent=cnt, + size=(folder_icon_size, folder_icon_size), + position=(10, 0.5 * entry_height - + folder_icon_size * 0.5), + draw_controller=cnt, + texture=self._folder_tex, + color=self._folder_color) + else: + ba.imagewidget(parent=cnt, + size=(folder_icon_size, folder_icon_size), + position=(10, 0.5 * entry_height - + folder_icon_size * 0.5), + opacity=1.0 if is_valid_file_path else 0.5, + draw_controller=cnt, + texture=self._file_tex, + color=self._file_color) + ba.textwidget(parent=cnt, + draw_controller=cnt, + text=entry, + h_align='left', + v_align='center', + position=(10 + folder_icon_size * 1.05, + entry_height * 0.5), + size=(0, 0), + maxwidth=self._scroll_width * 0.93 - 50, + color=(1, 1, 1, 1) if + (is_valid_file_path or is_dir) else + (0.5, 0.5, 0.5, 1)) + v -= entry_height + + def _is_valid_file_path(self, path: str) -> bool: + return any(path.lower().endswith(ext) + for ext in self._valid_file_extensions) + + def _cancel(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_right') + if self._callback is not None: + self._callback(None) diff --git a/assets/src/data/scripts/bastd/ui/gather.py b/assets/src/data/scripts/bastd/ui/gather.py new file mode 100644 index 00000000..02d90044 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/gather.py @@ -0,0 +1,1965 @@ +"""Provides UI for inviting/joining friends.""" +# pylint: disable=too-many-lines + +from __future__ import annotations + +import threading +import time +from typing import TYPE_CHECKING, cast + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Optional, Tuple, Dict, List, Union, Callable + + +class GatherWindow(ba.OldWindow): + """Window for joining/inviting friends.""" + + def __del__(self) -> None: + _ba.set_party_icon_always_visible(False) + + def __init__(self, + transition: str = 'in_right', + origin_widget: ba.Widget = None): + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + from bastd.ui import tabs + ba.set_analytics_screen('Gather Window') + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + ba.app.main_window = "Gather" + _ba.set_party_icon_always_visible(True) + self._public_parties: Dict[str, Dict[str, Any]] = {} + self._width = 1240 if ba.app.small_ui else 1040 + x_offs = 100 if ba.app.small_ui else 0 + self._height = (582 + if ba.app.small_ui else 680 if ba.app.med_ui else 800) + self._current_tab: Optional[str] = None + extra_top = 20 if ba.app.small_ui else 0 + self._r = 'gatherWindow' + self._tab_data: Any = None + self._internet_local_address: Optional[str] = None + self._internet_host_text: Optional[ba.Widget] = None + self._internet_join_text: Optional[ba.Widget] = None + self._doing_access_check: Optional[bool] = None + self._access_check_count: Optional[int] = None + self._public_party_list_selection: Optional[Tuple[str, str]] = None + self._internet_tab: Optional[str] = None + self._internet_join_last_refresh_time = -99999.0 + self._last_public_party_list_rebuild_time: Optional[float] = None + self._first_public_party_list_rebuild_time: Optional[float] = None + self._internet_join_party_name_label: Optional[ba.Widget] = None + self._internet_join_party_language_label: Optional[ba.Widget] = None + self._internet_join_party_size_label: Optional[ba.Widget] = None + self._internet_join_party_ping_label: Optional[ba.Widget] = None + self._internet_host_scrollwidget: Optional[ba.Widget] = None + self._internet_host_columnwidget: Optional[ba.Widget] = None + self._internet_join_status_text: Optional[ba.Widget] = None + self._internet_host_name_label_text: Optional[ba.Widget] = None + self._internet_host_name_text: Optional[ba.Widget] = None + self._internet_host_max_party_size_label: Optional[ba.Widget] = None + self._internet_host_max_party_size_value: Optional[ba.Widget] = None + self._internet_host_max_party_size_minus_button: ( + Optional[ba.Widget]) = None + self._internet_host_max_party_size_plus_button: ( + Optional[ba.Widget]) = None + self._internet_host_toggle_button: Optional[ba.Widget] = None + self._internet_host_status_text: Optional[ba.Widget] = None + self._internet_host_dedicated_server_info_text: ( + Optional[ba.Widget]) = None + self._internet_lock_icon: Optional[ba.Widget] = None + self._next_public_party_entry_index = 0 + self._refreshing_public_party_list: Optional[bool] = None + self._last_public_party_connect_attempt_time: Optional[float] = None + self._t_addr: Optional[ba.Widget] = None + self._t_accessible: Optional[ba.Widget] = None + self._t_accessible_extra: Optional[ba.Widget] = None + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + extra_top), + transition=transition, + toolbar_visibility='menu_minimal', + scale_origin_stack_offset=scale_origin, + scale=(1.3 if ba.app.small_ui else 0.97 if ba.app.med_ui else 0.8), + stack_offset=(0, -11) if ba.app.small_ui else ( + 0, 0) if ba.app.med_ui else (0, 0))) + + if ba.app.small_ui and ba.app.toolbars: + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._back) + self._back_button = None + else: + self._back_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(70 + x_offs, self._height - 74), + size=(140, 60), + scale=1.1, + autoselect=True, + label=ba.Lstr(resource='backText'), + button_type='back', + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + ba.buttonwidget(edit=btn, + button_type='backSmall', + position=(70 + x_offs, self._height - 78), + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 42), + size=(0, 0), + color=ba.app.title_color, + scale=1.5, + h_align="center", + v_align="center", + text=ba.Lstr(resource=self._r + '.titleText'), + maxwidth=550) + + platform = ba.app.platform + subplatform = ba.app.subplatform + + tabs_def: List[Tuple[str, ba.Lstr]] = [ + ('about', ba.Lstr(resource=self._r + '.aboutText')) + ] + if True and _ba.get_account_misc_read_val('enablePublicParties', True): + tabs_def.append( + ('internet', ba.Lstr(resource=self._r + '.internetText'))) + if platform == 'android' and subplatform == 'google': + tabs_def.append( + ('google_play', ba.Lstr(resource=self._r + '.googlePlayText'))) + tabs_def.append( + ('local_network', ba.Lstr(resource=self._r + '.localNetworkText'))) + + tabs_def.append(('manual', ba.Lstr(resource=self._r + '.manualText'))) + + scroll_buffer_h = 130 + 2 * x_offs + tab_buffer_h = 250 + 2 * x_offs + + self._tab_buttons = tabs.create_tab_buttons( + self._root_widget, + tabs_def, + pos=(tab_buffer_h * 0.5, self._height - 130), + size=(self._width - tab_buffer_h, 50), + on_select_call=self._set_tab) + + if ba.app.toolbars: + ba.widget(edit=self._tab_buttons[tabs_def[-1][0]], + right_widget=_ba.get_special_widget('party_button')) + if ba.app.small_ui: + ba.widget(edit=self._tab_buttons[tabs_def[0][0]], + left_widget=_ba.get_special_widget('back_button')) + + self._scroll_width = self._width - scroll_buffer_h + self._scroll_height = self._height - 180.0 + + # not actually using a scroll widget anymore; just an image + scroll_left = (self._width - self._scroll_width) * 0.5 + scroll_bottom = self._height - self._scroll_height - 79 - 48 + buffer_h = 10 + buffer_v = 4 + ba.imagewidget(parent=self._root_widget, + position=(scroll_left - buffer_h, + scroll_bottom - buffer_v), + size=(self._scroll_width + 2 * buffer_h, + self._scroll_height + 2 * buffer_v), + texture=ba.gettexture('scrollWidget'), + model_transparent=ba.getmodel('softEdgeOutside')) + self._tab_container: Optional[ba.Widget] = None + self._restore_state() + + def get_r(self) -> str: + """(internal)""" + return self._r + + def _on_google_play_show_invites_press(self) -> None: + from bastd.ui import account + if (_ba.get_account_state() != 'signed_in' + or _ba.get_account_type() != 'Google Play'): + account.show_sign_in_prompt('Google Play') + else: + _ba.show_invites_ui() + + def _on_google_play_invite_press(self) -> None: + from bastd.ui import confirm + from bastd.ui import account + if (_ba.get_account_state() != 'signed_in' + or _ba.get_account_type() != 'Google Play'): + account.show_sign_in_prompt('Google Play') + else: + # if there's google play people connected to us, inform the user + # that they will get disconnected.. otherwise just go ahead.. + google_player_count = (_ba.get_google_play_party_client_count()) + if google_player_count > 0: + confirm.ConfirmWindow(ba.Lstr( + resource=self._r + '.googlePlayReInviteText', + subs=[('${COUNT}', str(google_player_count))]), + ba.Call(ba.timer, + 0.2, + _ba.invite_players, + timetype='real'), + width=500, + height=150, + ok_text=ba.Lstr(resource=self._r + + '.googlePlayInviteText')) + else: + ba.timer(0.1, _ba.invite_players, timetype=ba.TimeType.REAL) + + def _invite_to_try_press(self) -> None: + from bastd.ui import account + from bastd.ui import appinvite + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + appinvite.handle_app_invites_press() + + def _set_tab(self, tab: str) -> None: + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + from bastd.ui import tabs + if self._current_tab == tab: + return + self._current_tab = tab + + # we wanna preserve our current tab between runs + cfg = ba.app.config + cfg['Gather Tab'] = tab + cfg.commit() + + # update tab colors based on which is selected + tabs.update_tab_button_colors(self._tab_buttons, tab) + + # (re)create scroll widget + if self._tab_container: + self._tab_container.delete() + scroll_left = (self._width - self._scroll_width) * 0.5 + scroll_bottom = self._height - self._scroll_height - 79 - 48 + + # a place where tabs can store data to get cleared when switching to + # a different tab + self._tab_data = {} + + # so we can still select root level widgets with direction buttons + def _simple_message(tab2: str, + message: ba.Lstr, + string_height: float, + include_invite: bool = False) -> None: + msc_scale = 1.1 + c_width_2 = self._scroll_width + c_height_2 = min(self._scroll_height, + string_height * msc_scale + 100) + try_tickets = _ba.get_account_misc_read_val( + 'friendTryTickets', None) + if try_tickets is None: + include_invite = False + self._tab_container = cnt2 = ba.containerwidget( + parent=self._root_widget, + position=(scroll_left, scroll_bottom + + (self._scroll_height - c_height_2) * 0.5), + size=(c_width_2, c_height_2), + background=False, + selectable=include_invite) + ba.widget(edit=cnt2, up_widget=self._tab_buttons[tab2]) + + ba.textwidget( + parent=cnt2, + position=(c_width_2 * 0.5, + c_height_2 * (0.58 if include_invite else 0.5)), + color=(0.6, 1.0, 0.6), + scale=msc_scale, + size=(0, 0), + maxwidth=c_width_2 * 0.9, + max_height=c_height_2 * (0.7 if include_invite else 0.9), + h_align='center', + v_align='center', + text=message) + if include_invite: + ba.textwidget(parent=cnt2, + position=(c_width_2 * 0.57, 35), + color=(0, 1, 0), + scale=0.6, + size=(0, 0), + maxwidth=c_width_2 * 0.5, + h_align='right', + v_align='center', + flatness=1.0, + text=ba.Lstr( + resource=self._r + '.inviteAFriendText', + subs=[('${COUNT}', str(try_tickets))])) + ba.buttonwidget( + parent=cnt2, + position=(c_width_2 * 0.59, 10), + size=(230, 50), + color=(0.54, 0.42, 0.56), + textcolor=(0, 1, 0), + label=ba.Lstr(resource='gatherWindow.inviteFriendsText', + fallback_resource=( + 'gatherWindow.getFriendInviteCodeText')), + autoselect=True, + on_activate_call=ba.WeakCall(self._invite_to_try_press), + up_widget=self._tab_buttons[tab2]) + + if tab == 'about': + msg = ba.Lstr(resource=self._r + '.aboutDescriptionText', + subs=[('${PARTY}', + ba.charstr(ba.SpecialChar.PARTY_ICON)), + ('${BUTTON}', + ba.charstr(ba.SpecialChar.TOP_BUTTON))]) + + # let's not talk about sharing in vr-mode; its tricky to fit more + # than one head in a VR-headset ;-) + if not ba.app.vr_mode: + msg = ba.Lstr( + value='${A}\n\n${B}', + subs=[ + ('${A}', msg), + ('${B}', + ba.Lstr(resource=self._r + + '.aboutDescriptionLocalMultiplayerExtraText')) + ]) + + _simple_message(tab, msg, 400, include_invite=True) + + elif tab == 'google_play': + c_width = self._scroll_width + c_height = 380.0 + b_width = 250.0 + b_width2 = 230.0 + self._tab_container = cnt = ba.containerwidget( + parent=self._root_widget, + position=(scroll_left, scroll_bottom + + (self._scroll_height - c_height) * 0.5), + size=(c_width, c_height), + background=False, + selection_loop_to_parent=True) + img_size = 100 + v = c_height - 30 + ba.textwidget(parent=cnt, + position=(c_width * 0.5, v), + color=(0.6, 1.0, 0.6), + scale=1.3, + size=(0, 0), + maxwidth=c_width * 0.9, + h_align='center', + v_align='center', + text=ba.Lstr(resource=self._r + + '.googlePlayDescriptionText')) + v -= 35 + ba.textwidget(parent=cnt, + position=(c_width * 0.5, v), + color=(0.6, 1.0, 0.6), + scale=0.7, + size=(0, 0), + maxwidth=c_width * 0.9, + h_align='center', + v_align='center', + text=ba.Lstr(resource=self._r + + '.worksWithGooglePlayDevicesText')) + v -= 125 + btn = ba.buttonwidget( + parent=cnt, + label='', + position=(c_width * 0.5 - b_width * 0.5, v - b_width * 0.5), + size=(b_width, b_width * 0.9), + button_type='square', + on_activate_call=self._on_google_play_invite_press, + autoselect=True, + up_widget=self._tab_buttons[tab]) + ba.imagewidget(parent=cnt, + position=(c_width * 0.5 - img_size * 0.5, v - 35), + size=(img_size, img_size), + draw_controller=btn, + texture=ba.gettexture('googlePlayGamesIcon'), + color=(0, 1, 0)) + ba.textwidget(parent=cnt, + text=ba.Lstr(resource=self._r + + '.googlePlayInviteText'), + maxwidth=b_width * 0.8, + draw_controller=btn, + color=(0, 1, 0), + flatness=1.0, + position=(c_width * 0.5, v - 60), + scale=1.6, + size=(0, 0), + h_align='center', + v_align='center') + v -= 180 + ba.buttonwidget(parent=cnt, + label=ba.Lstr(resource=self._r + + '.googlePlaySeeInvitesText'), + color=(0.5, 0.5, 0.6), + textcolor=(0.75, 0.7, 0.8), + autoselect=True, + position=(c_width * 0.5 - b_width2 * 0.5, v), + size=(b_width2, 60), + on_activate_call=ba.Call( + ba.timer, + 0.1, + self._on_google_play_show_invites_press, + timetype='real')) + + elif tab == 'internet': + c_width = self._scroll_width + c_height = self._scroll_height - 20 + self._tab_container = cnt = ba.containerwidget( + parent=self._root_widget, + position=(scroll_left, scroll_bottom + + (self._scroll_height - c_height) * 0.5), + size=(c_width, c_height), + background=False, + selection_loop_to_parent=True) + v = c_height - 30 + self._internet_join_text = txt = ba.textwidget( + parent=cnt, + position=(c_width * 0.5 - 245, v - 13), + color=(0.6, 1.0, 0.6), + scale=1.3, + size=(200, 30), + maxwidth=250, + h_align='left', + v_align='center', + click_activate=True, + selectable=True, + autoselect=True, + on_activate_call=ba.Call(self._set_internet_tab, + 'join', + playsound=True), + text=ba.Lstr(resource=self._r + + '.joinPublicPartyDescriptionText')) + ba.widget(edit=txt, up_widget=self._tab_buttons[tab]) + self._internet_host_text = txt = ba.textwidget( + parent=cnt, + position=(c_width * 0.5 + 45, v - 13), + color=(0.6, 1.0, 0.6), + scale=1.3, + size=(200, 30), + maxwidth=250, + h_align='left', + v_align='center', + click_activate=True, + selectable=True, + autoselect=True, + on_activate_call=ba.Call(self._set_internet_tab, + 'host', + playsound=True), + text=ba.Lstr(resource=self._r + + '.hostPublicPartyDescriptionText')) + ba.widget(edit=txt, + left_widget=self._internet_join_text, + up_widget=self._tab_buttons[tab]) + ba.widget(edit=self._internet_join_text, right_widget=txt) + + # attempt to fetch our local address so we have it for + # error messages + self._internet_local_address = None + + class AddrFetchThread(threading.Thread): + """Thread for fetching an address in the bg.""" + + def __init__(self, call: Callable[[Any], Any]): + super().__init__() + self._call = call + + def run(self) -> None: + try: + # FIXME: Update this to work with IPv6 at some point. + import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect(('8.8.8.8', 80)) + val = sock.getsockname()[0] + sock.close() + # val = ([ + # (s.connect(('8.8.8.8', 80)), + # s.getsockname()[0], + # s.close()) for s in + # [socket.socket(socket.AF_INET, + # socket.SOCK_DGRAM)] + # ][0][1]) + ba.pushcall(ba.Call(self._call, val), + from_other_thread=True) + except Exception: + ba.print_exception() + # FIXME: Should screen out expected errors and + # report others here. + + AddrFetchThread(ba.WeakCall( + self._internet_fetch_local_addr_cb)).start() + + assert self._internet_tab is not None + self._set_internet_tab(self._internet_tab) + self._tab_data = { + 'update_timer': + ba.Timer(0.2, + ba.WeakCall(self._update_internet_tab), + repeat=True, + timetype=ba.TimeType.REAL) + } + + # also update it immediately so we don't have to wait for the + # initial query.. + self._update_internet_tab() + + elif tab == 'local_network': + c_width = self._scroll_width + c_height = self._scroll_height - 20 + sub_scroll_height = c_height - 85 + sub_scroll_width = 650 + + class NetScanner: + """Class for scanning for games on the lan.""" + + def __init__(self, scrollwidget: ba.Widget, + tab_button: ba.Widget, width: float): + self._scrollwidget = scrollwidget + self._tab_button = tab_button + self._columnwidget = ba.columnwidget( + parent=self._scrollwidget, left_border=10) + ba.widget(edit=self._columnwidget, up_widget=tab_button) + self._width = width + self._last_selected_host: Optional[Dict[str, Any]] = None + + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self.update), + timetype=ba.TimeType.REAL, + repeat=True) + # go ahead and run a few *almost* immediately so we don't + # have to wait a second + self.update() + ba.timer(0.25, + ba.WeakCall(self.update), + timetype=ba.TimeType.REAL) + + def __del__(self) -> None: + _ba.end_host_scanning() + + def _on_select(self, host: Dict[str, Any]) -> None: + self._last_selected_host = host + + def _on_activate(self, host: Dict[str, Any]) -> None: + _ba.connect_to_party(host['address']) + + def update(self) -> None: + """(internal)""" + t_scale = 1.6 + for child in self._columnwidget.get_children(): + child.delete() + # grab this now this since adding widgets will change it + last_selected_host = self._last_selected_host + hosts = _ba.host_scan_cycle() + for i, host in enumerate(hosts): + txt3 = ba.textwidget( + parent=self._columnwidget, + size=(self._width / t_scale, 30), + selectable=True, + color=(1, 1, 1), + on_select_call=ba.Call(self._on_select, host), + on_activate_call=ba.Call(self._on_activate, host), + click_activate=True, + text=host['display_string'], + h_align='left', + v_align='center', + corner_scale=t_scale, + maxwidth=(self._width / t_scale) * 0.93) + if host == last_selected_host: + ba.containerwidget(edit=self._columnwidget, + selected_child=txt3, + visible_child=txt3) + if i == 0: + ba.widget(edit=txt3, up_widget=self._tab_button) + + self._tab_container = cnt = ba.containerwidget( + parent=self._root_widget, + position=(scroll_left, scroll_bottom + + (self._scroll_height - c_height) * 0.5), + size=(c_width, c_height), + background=False, + selection_loop_to_parent=True) + v = c_height - 30 + ba.textwidget(parent=cnt, + position=(c_width * 0.5, v - 3), + color=(0.6, 1.0, 0.6), + scale=1.3, + size=(0, 0), + maxwidth=c_width * 0.9, + h_align='center', + v_align='center', + text=ba.Lstr(resource=self._r + + '.localNetworkDescriptionText')) + v -= 15 + v -= sub_scroll_height + 23 + scrollw = ba.scrollwidget( + parent=cnt, + position=((self._scroll_width - sub_scroll_width) * 0.5, v), + size=(sub_scroll_width, sub_scroll_height)) + + self._tab_data = NetScanner(scrollw, + self._tab_buttons[tab], + width=sub_scroll_width) + + ba.widget(edit=scrollw, + autoselect=True, + up_widget=self._tab_buttons[tab]) + + elif tab == 'bluetooth': + c_width = self._scroll_width + c_height = 380 + sub_scroll_width = 650 + + self._tab_container = cnt = ba.containerwidget( + parent=self._root_widget, + position=(scroll_left, scroll_bottom + + (self._scroll_height - c_height) * 0.5), + size=(c_width, c_height), + background=False, + selection_loop_to_parent=True) + v = c_height - 30 + ba.textwidget(parent=cnt, + position=(c_width * 0.5, v), + color=(0.6, 1.0, 0.6), + scale=1.3, + size=(0, 0), + maxwidth=c_width * 0.9, + h_align='center', + v_align='center', + text=ba.Lstr(resource=self._r + + '.bluetoothDescriptionText')) + v -= 35 + ba.textwidget(parent=cnt, + position=(c_width * 0.5, v), + color=(0.6, 1.0, 0.6), + scale=0.7, + size=(0, 0), + maxwidth=c_width * 0.9, + h_align='center', + v_align='center', + text=ba.Lstr(resource=self._r + + '.bluetoothAndroidSupportText')) + + v -= 55 + btn = ba.buttonwidget( + parent=cnt, + position=(c_width * 0.5 - sub_scroll_width * 0.5 + 10, v - 75), + size=(300, 70), + autoselect=True, + label=ba.Lstr(resource=self._r + '.bluetoothHostText')) + ba.widget(edit=btn, up_widget=self._tab_buttons[tab]) + btn = ba.buttonwidget( + parent=cnt, + position=(c_width * 0.5 - sub_scroll_width * 0.5 + 330, + v - 75), + size=(300, 70), + autoselect=True, + on_activate_call=ba.Call(ba.screenmessage, + 'FIXME: Not wired up yet.'), + label=ba.Lstr(resource=self._r + '.bluetoothJoinText')) + ba.widget(edit=btn, up_widget=self._tab_buttons[tab]) + ba.widget(edit=self._tab_buttons[tab], down_widget=btn) + + elif tab == 'wifi_direct': + c_width = self._scroll_width + c_height = self._scroll_height - 20 + self._tab_container = cnt = ba.containerwidget( + parent=self._root_widget, + position=(scroll_left, scroll_bottom + + (self._scroll_height - c_height) * 0.5), + size=(c_width, c_height), + background=False, + selection_loop_to_parent=True) + v = c_height - 80 + + ba.textwidget(parent=cnt, + position=(c_width * 0.5, v), + color=(0.6, 1.0, 0.6), + scale=1.0, + size=(0, 0), + maxwidth=c_width * 0.95, + max_height=140, + h_align='center', + v_align='center', + text=ba.Lstr(resource=self._r + + '.wifiDirectDescriptionTopText')) + v -= 140 + btn = ba.buttonwidget( + parent=cnt, + position=(c_width * 0.5 - 175, v), + size=(350, 65), + label=ba.Lstr(resource=self._r + + '.wifiDirectOpenWiFiSettingsText'), + autoselect=True, + on_activate_call=_ba.android_show_wifi_settings) + v -= 82 + + ba.widget(edit=btn, up_widget=self._tab_buttons[tab]) + + ba.textwidget(parent=cnt, + position=(c_width * 0.5, v), + color=(0.6, 1.0, 0.6), + scale=0.9, + size=(0, 0), + maxwidth=c_width * 0.95, + max_height=150, + h_align='center', + v_align='center', + text=ba.Lstr(resource=self._r + + '.wifiDirectDescriptionBottomText', + subs=[('${APP_NAME}', + ba.Lstr(resource='titleText'))])) + + elif tab == 'manual': + c_width = self._scroll_width + c_height = 380 + last_addr = ba.app.config.get('Last Manual Party Connect Address', + '') + + self._tab_container = cnt = ba.containerwidget( + parent=self._root_widget, + position=(scroll_left, scroll_bottom + + (self._scroll_height - c_height) * 0.5), + size=(c_width, c_height), + background=False, + selection_loop_to_parent=True) + v = c_height - 30 + ba.textwidget(parent=cnt, + position=(c_width * 0.5, v), + color=(0.6, 1.0, 0.6), + scale=1.3, + size=(0, 0), + maxwidth=c_width * 0.9, + h_align='center', + v_align='center', + text=ba.Lstr(resource=self._r + + '.manualDescriptionText')) + v -= 30 + v -= 70 + ba.textwidget(parent=cnt, + position=(c_width * 0.5 - 260 - 50, v), + color=(0.6, 1.0, 0.6), + scale=1.0, + size=(0, 0), + maxwidth=130, + h_align='right', + v_align='center', + text=ba.Lstr(resource=self._r + + '.manualAddressText')) + txt = ba.textwidget(parent=cnt, + editable=True, + description=ba.Lstr(resource=self._r + + '.manualAddressText'), + position=(c_width * 0.5 - 240 - 50, v - 30), + text=last_addr, + autoselect=True, + v_align='center', + scale=1.0, + size=(420, 60)) + ba.textwidget(parent=cnt, + position=(c_width * 0.5 - 260 + 490, v), + color=(0.6, 1.0, 0.6), + scale=1.0, + size=(0, 0), + maxwidth=80, + h_align='right', + v_align='center', + text=ba.Lstr(resource=self._r + '.portText')) + txt2 = ba.textwidget(parent=cnt, + editable=True, + description=ba.Lstr(resource=self._r + + '.portText'), + text='43210', + autoselect=True, + max_chars=5, + position=(c_width * 0.5 - 240 + 490, v - 30), + v_align='center', + scale=1.0, + size=(170, 60)) + + v -= 110 + + def _connect(textwidget: ba.Widget, + port_textwidget: ba.Widget) -> None: + addr = cast(str, ba.textwidget(query=textwidget)) + if addr == '': + ba.screenmessage( + ba.Lstr(resource='internal.invalidAddressErrorText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + try: + port = int(cast(str, ba.textwidget(query=port_textwidget))) + if port > 65535 or port < 0: + raise Exception() + except Exception: + ba.screenmessage( + ba.Lstr(resource='internal.invalidPortErrorText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + class HostAddrFetchThread(threading.Thread): + """Thread to fetch an addr.""" + + def __init__(self, name: str, call: Callable[[str], Any]): + super().__init__() + self._name = name + self._call = call + + def run(self) -> None: + try: + import socket + addr2 = socket.gethostbyname(self._name) + ba.pushcall(ba.Call(self._call, addr2), + from_other_thread=True) + except Exception: + ba.pushcall(ba.Call(self._call, None), + from_other_thread=True) + + def do_it_2(addr2: str) -> None: + if addr2 is None: + ba.screenmessage(ba.Lstr( + resource='internal.unableToResolveHostText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + else: + # store for later + cfg2 = ba.app.config + cfg2['Last Manual Party Connect Address'] = addr2 + cfg2.commit() + _ba.connect_to_party(addr2, port=port) + + HostAddrFetchThread(addr, do_it_2).start() + + btn = ba.buttonwidget( + parent=cnt, + size=(300, 70), + label=ba.Lstr(resource=self._r + '.manualConnectText'), + position=(c_width * 0.5 - 150, v), + autoselect=True, + on_activate_call=ba.Call(_connect, txt, txt2)) + ba.widget(edit=txt, up_widget=self._tab_buttons[tab]) + ba.textwidget(edit=txt, on_return_press_call=btn.activate) + ba.textwidget(edit=txt2, on_return_press_call=btn.activate) + v -= 45 + + tscl = 0.85 + tspc = 25 + + # v -= 35 + def _safe_set_text(txt3: ba.Widget, + val: Union[str, ba.Lstr], + success: bool = True) -> None: + if txt3: + ba.textwidget(edit=txt3, + text=val, + color=(0, 1, 0) if success else (1, 1, 0)) + + # this currently doesn't work from china since we go through a + # reverse proxy there + # EDIT - it should work now; our proxy server forwards along + # original IPs + # app = ba.app + do_internet_check = True + + def do_it(v2: float, cnt2: Optional[ba.Widget]) -> None: + if not cnt2: + return + + ba.playsound(ba.getsound('swish')) + ba.textwidget(parent=cnt2, + position=(c_width * 0.5 - 10, v2), + color=(0.6, 1.0, 0.6), + scale=tscl, + size=(0, 0), + maxwidth=c_width * 0.45, + flatness=1.0, + h_align='right', + v_align='center', + text=ba.Lstr(resource=self._r + + '.manualYourLocalAddressText')) + txt3 = ba.textwidget(parent=cnt2, + position=(c_width * 0.5, v2), + color=(0.5, 0.5, 0.5), + scale=tscl, + size=(0, 0), + maxwidth=c_width * 0.45, + flatness=1.0, + h_align='left', + v_align='center', + text=ba.Lstr(resource=self._r + + '.checkingText')) + + class AddrFetchThread2(threading.Thread): + """Thread for fetching an addr.""" + + def __init__(self, window: GatherWindow, + textwidget: ba.Widget): + super().__init__() + self._window = window + self._textwidget = textwidget + + def run(self) -> None: + try: + # FIXME: Update this to work with IPv6. + import socket + sock = socket.socket(socket.AF_INET, + socket.SOCK_DGRAM) + sock.connect(('8.8.8.8', 80)) + val = sock.getsockname()[0] + sock.close() + # val = ([(s.connect(('8.8.8.8', 80)), + # s.getsockname()[0], s.close()) + # for s in [ + # socket.socket( + # socket.AF_INET, socket. + # SOCK_DGRAM) + # ]][0][1]) + ba.pushcall(ba.Call(_safe_set_text, + self._textwidget, val), + from_other_thread=True) + except Exception as exc: + err_str = str(exc) + # FIXME: Should look at exception types here, + # not strings. + if 'Network is unreachable' in err_str: + ba.pushcall(ba.Call( + _safe_set_text, self._textwidget, + ba.Lstr(resource=self._window.get_r() + + '.noConnectionText'), False), + from_other_thread=True) + else: + ba.pushcall(ba.Call( + _safe_set_text, self._textwidget, + ba.Lstr(resource=self._window.get_r() + + '.addressFetchErrorText'), False), + from_other_thread=True) + ba.pushcall(ba.Call( + ba.print_error, + 'error in AddrFetchThread: ' + str(exc)), + from_other_thread=True) + + AddrFetchThread2(self, txt3).start() + + v2 -= tspc + ba.textwidget( + parent=cnt2, + position=(c_width * 0.5 - 10, v2), + color=(0.6, 1.0, 0.6), + scale=tscl, + size=(0, 0), + maxwidth=c_width * 0.45, + flatness=1.0, + h_align='right', + v_align='center', + text=ba.Lstr(resource=self._r + + '.manualYourAddressFromInternetText')) + + t_addr = ba.textwidget(parent=cnt2, + position=(c_width * 0.5, v2), + color=(0.5, 0.5, 0.5), + scale=tscl, + size=(0, 0), + maxwidth=c_width * 0.45, + h_align='left', + v_align='center', + flatness=1.0, + text=ba.Lstr(resource=self._r + + '.checkingText')) + v2 -= tspc + ba.textwidget(parent=cnt2, + position=(c_width * 0.5 - 10, v2), + color=(0.6, 1.0, 0.6), + scale=tscl, + size=(0, 0), + maxwidth=c_width * 0.45, + flatness=1.0, + h_align='right', + v_align='center', + text=ba.Lstr(resource=self._r + + '.manualJoinableFromInternetText')) + + t_accessible = ba.textwidget(parent=cnt2, + position=(c_width * 0.5, v2), + color=(0.5, 0.5, 0.5), + scale=tscl, + size=(0, 0), + maxwidth=c_width * 0.45, + flatness=1.0, + h_align='left', + v_align='center', + text=ba.Lstr(resource=self._r + + '.checkingText')) + v2 -= 28 + t_accessible_extra = ba.textwidget(parent=cnt2, + position=(c_width * 0.5, + v2), + color=(1, 0.5, 0.2), + scale=0.7, + size=(0, 0), + maxwidth=c_width * 0.9, + flatness=1.0, + h_align='center', + v_align='center', + text='') + + self._doing_access_check = False + self._access_check_count = 0 # cap our refreshes eventually.. + self._tab_data['access_check_timer'] = ba.Timer( + 10.0, + ba.WeakCall(self._access_check_update, t_addr, + t_accessible, t_accessible_extra), + repeat=True, + timetype=ba.TimeType.REAL) + # kick initial off + self._access_check_update(t_addr, t_accessible, + t_accessible_extra) + if check_button: + check_button.delete() + + if do_internet_check: + check_button = ba.textwidget( + parent=cnt, + size=(250, 60), + text=ba.Lstr(resource=self._r + '.showMyAddressText'), + v_align='center', + h_align='center', + click_activate=True, + position=(c_width * 0.5 - 125, v - 30), + autoselect=True, + color=(0.5, 0.9, 0.5), + scale=0.8, + selectable=True, + on_activate_call=ba.Call(do_it, v, cnt)) + + def _internet_fetch_local_addr_cb(self, val: str) -> None: + self._internet_local_address = str(val) + + def _set_internet_tab(self, value: str, playsound: bool = False) -> None: + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + if playsound: + ba.playsound(ba.getsound('click01')) + + # if we're switching in from elsewhere, reset our selection + # (prevents selecting something way down the list if we switched away + # and came back) + if self._internet_tab != value: + self._public_party_list_selection = None + + self._internet_tab = value + active_color = (0.6, 1.0, 0.6) + inactive_color = (0.5, 0.4, 0.5) + ba.textwidget( + edit=self._internet_join_text, + color=active_color if value == 'join' else inactive_color) + ba.textwidget( + edit=self._internet_host_text, + color=active_color if value == 'host' else inactive_color) + + # clear anything in existence.. + for widget in [ + self._internet_host_scrollwidget, + self._internet_host_name_text, + self._internet_host_toggle_button, + self._internet_host_name_label_text, + self._internet_host_status_text, + self._internet_join_party_size_label, + self._internet_join_party_name_label, + self._internet_join_party_language_label, + self._internet_join_party_ping_label, + self._internet_host_max_party_size_label, + self._internet_host_max_party_size_value, + self._internet_host_max_party_size_minus_button, + self._internet_host_max_party_size_plus_button, + self._internet_join_status_text, + self._internet_host_dedicated_server_info_text + ]: + # widget = getattr(self, attr, None) + if widget is not None: + widget.delete() + + c_width = self._scroll_width + c_height = self._scroll_height - 20 + sub_scroll_height = c_height - 90 + sub_scroll_width = 830 + v = c_height - 35 + v -= 25 + is_public_enabled = _ba.get_public_party_enabled() + if value == 'join': + # reset this so we do an immediate refresh query + self._internet_join_last_refresh_time = -99999.0 + # reset our list of public parties + self._public_parties = {} + self._last_public_party_list_rebuild_time = 0 + self._first_public_party_list_rebuild_time = None + self._internet_join_party_name_label = ba.textwidget( + text=ba.Lstr(resource='nameText'), + parent=self._tab_container, + size=(0, 0), + position=(90, v - 8), + maxwidth=60, + scale=0.6, + color=(0.5, 0.5, 0.5), + flatness=1.0, + shadow=0.0, + h_align='center', + v_align='center') + # noinspection PyUnreachableCode + if False: # pylint: disable=using-constant-test + self._internet_join_party_language_label = ba.textwidget( + text=ba.Lstr( + resource='settingsWindowAdvanced.languageText'), + parent=self._tab_container, + size=(0, 0), + position=(662, v - 8), + maxwidth=100, + scale=0.6, + color=(0.5, 0.5, 0.5), + flatness=1.0, + shadow=0.0, + h_align='center', + v_align='center') + self._internet_join_party_size_label = ba.textwidget( + text=ba.Lstr(resource=self._r + '.partySizeText'), + parent=self._tab_container, + size=(0, 0), + position=(755, v - 8), + maxwidth=60, + scale=0.6, + color=(0.5, 0.5, 0.5), + flatness=1.0, + shadow=0.0, + h_align='center', + v_align='center') + self._internet_join_party_ping_label = ba.textwidget( + text=ba.Lstr(resource=self._r + '.pingText'), + parent=self._tab_container, + size=(0, 0), + position=(825, v - 8), + maxwidth=60, + scale=0.6, + color=(0.5, 0.5, 0.5), + flatness=1.0, + shadow=0.0, + h_align='center', + v_align='center') + v -= sub_scroll_height + 23 + + self._internet_host_scrollwidget = scrollw = ba.scrollwidget( + parent=self._tab_container, + simple_culling_v=10, + position=((self._scroll_width - sub_scroll_width) * 0.5, v), + size=(sub_scroll_width, sub_scroll_height)) + ba.widget(edit=scrollw, autoselect=True) + colw = self._internet_host_columnwidget = ba.containerwidget( + parent=scrollw, background=False, size=(400, 400)) + ba.containerwidget(edit=scrollw, claims_left_right=True) + ba.containerwidget(edit=colw, claims_left_right=True) + + self._internet_join_status_text = ba.textwidget( + parent=self._tab_container, + text=ba.Lstr(value='${A}...', + subs=[('${A}', + ba.Lstr(resource='store.loadingText'))]), + size=(0, 0), + scale=0.9, + flatness=1.0, + shadow=0.0, + h_align='center', + v_align='top', + maxwidth=c_width, + color=(0.6, 0.6, 0.6), + position=(c_width * 0.5, c_height * 0.5)) + + # t_scale = 1.6 + + if value == 'host': + v -= 30 + party_name_text = ba.Lstr( + resource='gatherWindow.partyNameText', + fallback_resource='editGameListWindow.nameText') + self._internet_host_name_label_text = ba.textwidget( + parent=self._tab_container, + size=(0, 0), + h_align='right', + v_align='center', + maxwidth=200, + scale=0.8, + color=ba.app.infotextcolor, + position=(210, v - 9), + text=party_name_text) + self._internet_host_name_text = ba.textwidget( + parent=self._tab_container, + editable=True, + size=(535, 40), + position=(230, v - 30), + text=ba.app.config.get('Public Party Name', ''), + maxwidth=494, + shadow=0.3, + flatness=1.0, + description=party_name_text, + autoselect=True, + v_align='center', + corner_scale=1.0) + + v -= 60 + self._internet_host_max_party_size_label = ba.textwidget( + parent=self._tab_container, + size=(0, 0), + h_align='right', + v_align='center', + maxwidth=200, + scale=0.8, + color=ba.app.infotextcolor, + position=(210, v - 9), + text=ba.Lstr(resource='maxPartySizeText', + fallback_resource='maxConnectionsText')) + self._internet_host_max_party_size_value = ba.textwidget( + parent=self._tab_container, + size=(0, 0), + h_align='center', + v_align='center', + scale=1.2, + color=(1, 1, 1), + position=(240, v - 9), + text=str(_ba.get_public_party_max_size())) + btn1 = self._internet_host_max_party_size_minus_button = ( + ba.buttonwidget( + parent=self._tab_container, + size=(40, 40), + on_activate_call=ba.WeakCall( + self._on_max_public_party_size_minus_press), + position=(280, v - 26), + label='-', + autoselect=True)) + btn2 = self._internet_host_max_party_size_plus_button = ( + ba.buttonwidget(parent=self._tab_container, + size=(40, 40), + on_activate_call=ba.WeakCall( + self._on_max_public_party_size_plus_press), + position=(350, v - 26), + label='+', + autoselect=True)) + v -= 50 + v -= 70 + if is_public_enabled: + label = ba.Lstr( + resource='gatherWindow.makePartyPrivateText', + fallback_resource='gatherWindow.stopAdvertisingText') + else: + label = ba.Lstr( + resource='gatherWindow.makePartyPublicText', + fallback_resource='gatherWindow.startAdvertisingText') + self._internet_host_toggle_button = ba.buttonwidget( + parent=self._tab_container, + label=label, + size=(400, 80), + on_activate_call=self._on_stop_internet_advertising_press + if is_public_enabled else + self._on_start_internet_advertizing_press, + position=(c_width * 0.5 - 200, v), + autoselect=True, + up_widget=btn2) + ba.widget(edit=self._internet_host_name_text, down_widget=btn2) + ba.widget(edit=btn2, up_widget=self._internet_host_name_text) + ba.widget(edit=btn1, up_widget=self._internet_host_name_text) + ba.widget(edit=self._internet_join_text, + down_widget=self._internet_host_name_text) + v -= 10 + self._internet_host_status_text = ba.textwidget( + parent=self._tab_container, + text=ba.Lstr(resource=self._r + '.partyStatusNotPublicText'), + size=(0, 0), + scale=0.7, + flatness=1.0, + shadow=0.0, + h_align='center', + v_align='top', + maxwidth=c_width, + color=(0.6, 0.6, 0.6), + position=(c_width * 0.5, v)) + v -= 90 + self._internet_host_dedicated_server_info_text = ba.textwidget( + parent=self._tab_container, + text=ba.Lstr(resource=self._r + '.dedicatedServerInfoText'), + size=(0, 0), + scale=0.7, + flatness=1.0, + shadow=0.0, + h_align='center', + v_align='center', + maxwidth=c_width * 0.9, + color=ba.app.infotextcolor, + position=(c_width * 0.5, v)) + + # if public sharing is already on, + # launch a status-check immediately + if _ba.get_public_party_enabled(): + self._do_internet_status_check() + + # now add a lock icon overlay for if we don't have pro + icon = self._internet_lock_icon + if icon and self._internet_lock_icon: + self._internet_lock_icon.delete() # kill any existing + self._internet_lock_icon = ba.imagewidget( + parent=self._tab_container, + position=(c_width * 0.5 - 60, c_height * 0.5 - 50), + size=(120, 120), + opacity=0.0 if not self._is_internet_locked() else 0.5, + texture=ba.gettexture('lock')) + + def _is_internet_locked(self) -> bool: + from ba.internal import have_pro + if _ba.get_account_misc_read_val('ilck', False): + return not have_pro() + return False + + def _on_max_public_party_size_minus_press(self) -> None: + val = _ba.get_public_party_max_size() + val -= 1 + if val < 1: + val = 1 + _ba.set_public_party_max_size(val) + ba.textwidget(edit=self._internet_host_max_party_size_value, + text=str(val)) + + def _on_max_public_party_size_plus_press(self) -> None: + val = _ba.get_public_party_max_size() + val += 1 + _ba.set_public_party_max_size(val) + ba.textwidget(edit=self._internet_host_max_party_size_value, + text=str(val)) + + def _on_public_party_query_result(self, result: Optional[Dict[str, Any]] + ) -> None: + with ba.Context('ui'): + # any time we get any result at all, kill our loading status + status_text = self._internet_join_status_text + if status_text: + # don't show results if not signed in (probably didn't get any + # anyway) + if _ba.get_account_state() != 'signed_in': + ba.textwidget(edit=status_text, + text=ba.Lstr(resource='notSignedInText')) + else: + if result is None: + ba.textwidget(edit=status_text, + text=ba.Lstr(resource='errorText')) + else: + ba.textwidget(edit=status_text, text='') + + if result is not None: + parties_in = result['l'] + else: + parties_in = [] + + for partyval in list(self._public_parties.values()): + partyval['claimed'] = False + + for party_in in parties_in: + # party is indexed by (ADDR)_(PORT) + party_key = party_in['a'] + '_' + str(party_in['p']) + party = self._public_parties.get(party_key) + if party is None: + # if this party is new to us, init it.. + index = self._next_public_party_entry_index + self._next_public_party_entry_index = index + 1 + party = self._public_parties[party_key] = { + 'address': + party_in.get('a'), + 'next_ping_time': + ba.time(ba.TimeType.REAL) + 0.001 * party_in['pd'], + 'ping': + None, + 'index': + index, + } + # now, new or not, update its values + party['queue'] = party_in.get('q') + party['port'] = party_in.get('p') + party['name'] = party_in['n'] + party['size'] = party_in['s'] + party['language'] = party_in['l'] + party['size_max'] = party_in['sm'] + party['claimed'] = True + # (server provides this in milliseconds; we use seconds) + party['ping_interval'] = 0.001 * party_in['pi'] + party['stats_addr'] = party_in['sa'] + + # prune unclaimed party entries + self._public_parties = { + key: val + for key, val in list(self._public_parties.items()) + if val['claimed'] + } + + self._rebuild_public_party_list() + + def _rebuild_public_party_list(self) -> None: + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + cur_time = ba.time(ba.TimeType.REAL) + if self._first_public_party_list_rebuild_time is None: + self._first_public_party_list_rebuild_time = cur_time + # update faster for the first few seconds; + # then ease off to keep the list from jumping around + since_first = cur_time - self._first_public_party_list_rebuild_time + wait_time = (1.0 if since_first < 2.0 else + 2.5 if since_first < 10.0 else 5.0) + assert self._last_public_party_list_rebuild_time is not None + if cur_time - self._last_public_party_list_rebuild_time < wait_time: + return + self._last_public_party_list_rebuild_time = cur_time + + # first off, check for the existence of our column widget; + # if we don't have this, we're done + columnwidget = self._internet_host_columnwidget + if not columnwidget: + return + + with ba.Context('ui'): + + # now kill and recreate all widgets + for widget in columnwidget.get_children(): + widget.delete() + + # sort - show queue-enabled ones first and sort by lowest ping + ordered_parties = sorted( + list(self._public_parties.values()), + key=lambda p: ( + p['queue'] is None, # show non-queued last + p['ping'] if p['ping'] is not None else 999999, + p['index'], + p)) + existing_selection = self._public_party_list_selection + first = True + + sub_scroll_width = 830 + # rval = random.randrange(4, 10) + # print 'doing', rval + # ordered_parties = ordered_parties[:rval] + lineheight = 42 + sub_scroll_height = lineheight * len(ordered_parties) + 50 + ba.containerwidget(edit=columnwidget, + size=(sub_scroll_width, sub_scroll_height)) + + # ew; this rebuilding generates deferred selection callbacks + # so we need to generated deferred ignore notices for ourself + def refresh_on() -> None: + self._refreshing_public_party_list = True + + ba.pushcall(refresh_on) + + # janky - allow escaping if there's nothing in us + ba.containerwidget(edit=self._internet_host_scrollwidget, + claims_up_down=(len(ordered_parties) > 0)) + + for i, party in enumerate(ordered_parties): + hpos = 20 + vpos = sub_scroll_height - lineheight * i - 50 + party['name_widget'] = ba.textwidget( + text=ba.Lstr(value=party['name']), + parent=columnwidget, + size=(sub_scroll_width * 0.63, 20), + position=(0 + hpos, 4 + vpos), + selectable=True, + on_select_call=ba.WeakCall( + self._set_public_party_selection, + (party['address'], 'name')), + on_activate_call=ba.WeakCall( + self._on_public_party_activate, party), + click_activate=True, + maxwidth=sub_scroll_width * 0.45, + corner_scale=1.4, + autoselect=True, + color=(1, 1, 1, 0.3 if party['ping'] is None else 1.0), + h_align='left', + v_align='center') + ba.widget(edit=party['name_widget'], + left_widget=self._internet_join_text, + show_buffer_top=64.0, + show_buffer_bottom=64.0) + if existing_selection == (party['address'], 'name'): + ba.containerwidget(edit=columnwidget, + selected_child=party['name_widget']) + # noinspection PyUnreachableCode + if False: # pylint: disable=using-constant-test + party['language_widget'] = ba.textwidget( + text=ba.Lstr(translate=('languages', + party['language'])), + parent=columnwidget, + size=(0, 0), + position=(sub_scroll_width * 0.73 + hpos, 20 + vpos), + maxwidth=sub_scroll_width * 0.13, + scale=0.7, + color=(0.8, 0.8, 0.8), + h_align='center', + v_align='center') + if party['stats_addr'] != '': + url = party['stats_addr'].replace( + '${ACCOUNT}', + _ba.get_account_misc_read_val_2( + 'resolvedAccountID', 'UNKNOWN')) + party['stats_button'] = ba.buttonwidget( + color=(0.3, 0.6, 0.94), + textcolor=(1.0, 1.0, 1.0), + label=ba.Lstr(resource='statsText'), + parent=columnwidget, + autoselect=True, + on_activate_call=ba.Call(ba.open_url, url), + on_select_call=ba.WeakCall( + self._set_public_party_selection, + (party['address'], 'stats_button')), + size=(120, 40), + position=(sub_scroll_width * 0.66 + hpos, 1 + vpos), + scale=0.9) + if existing_selection == (party['address'], + 'stats_button'): + ba.containerwidget( + edit=columnwidget, + selected_child=party['stats_button']) + else: + if 'stats_button' in party: + del party['stats_button'] + + if first: + if 'stats_button' in party: + ba.widget(edit=party['stats_button'], + up_widget=self._internet_join_text) + if 'name_widget' in party: + ba.widget(edit=party['name_widget'], + up_widget=self._internet_join_text) + first = False + + party['size_widget'] = ba.textwidget( + text=str(party['size']) + '/' + str(party['size_max']), + parent=columnwidget, + size=(0, 0), + position=(sub_scroll_width * 0.86 + hpos, 20 + vpos), + scale=0.7, + color=(0.8, 0.8, 0.8), + h_align='right', + v_align='center') + party['ping_widget'] = ba.textwidget( + parent=columnwidget, + size=(0, 0), + position=(sub_scroll_width * 0.94 + hpos, 20 + vpos), + scale=0.7, + h_align='right', + v_align='center') + if party['ping'] is None: + ba.textwidget(edit=party['ping_widget'], + text='-', + color=(0.5, 0.5, 0.5)) + else: + ping_good = _ba.get_account_misc_read_val('pingGood', 100) + ping_med = _ba.get_account_misc_read_val('pingMed', 500) + ba.textwidget(edit=party['ping_widget'], + text=str(party['ping']), + color=(0, 1, + 0) if party['ping'] <= ping_good else + (1, 1, 0) if party['ping'] <= ping_med else + (1, 0, 0)) + + # So our selection callbacks can start firing.. + def refresh_on2() -> None: + self._refreshing_public_party_list = False + + ba.pushcall(refresh_on2) + + def _on_public_party_activate(self, party: Dict[str, Any]) -> None: + from bastd.ui import purchase + from bastd.ui import account + if party['queue'] is not None: + from bastd.ui import partyqueue + ba.playsound(ba.getsound('swish')) + partyqueue.PartyQueueWindow(party['queue'], party['address'], + party['port']) + else: + address = party['address'] + port = party['port'] + if self._is_internet_locked(): + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + else: + purchase.PurchaseWindow(items=['pro']) + return + # rate limit this a bit + now = time.time() + last_connect_time = self._last_public_party_connect_attempt_time + if last_connect_time is None or now - last_connect_time > 2.0: + _ba.connect_to_party(address, port=port) + self._last_public_party_connect_attempt_time = now + + def _set_public_party_selection(self, sel: Tuple[str, str]) -> None: + if self._refreshing_public_party_list: + return + self._public_party_list_selection = sel + + def _update_internet_tab(self) -> None: + # pylint: disable=too-many-statements + + # special case - if a party-queue window is up, don't do any of this + # (keeps things smoother) + if ba.app.have_party_queue_window: + return + + # if we've got a party-name text widget, keep its value plugged + # into our public host name... + text = self._internet_host_name_text + if text: + name = cast(str, + ba.textwidget(query=self._internet_host_name_text)) + _ba.set_public_party_name(name) + + # show/hide the lock icon depending on if we've got pro + icon = self._internet_lock_icon + if icon: + if self._is_internet_locked(): + ba.imagewidget(edit=icon, opacity=0.5) + else: + ba.imagewidget(edit=icon, opacity=0.0) + + if self._internet_tab == 'join': + now = ba.time(ba.TimeType.REAL) + if (now - self._internet_join_last_refresh_time > 0.001 * + _ba.get_account_misc_read_val('pubPartyRefreshMS', 10000)): + self._internet_join_last_refresh_time = now + app = ba.app + _ba.add_transaction( + { + 'type': 'PUBLIC_PARTY_QUERY', + 'proto': app.protocol_version, + 'lang': app.language + }, + callback=ba.WeakCall(self._on_public_party_query_result)) + _ba.run_transactions() + + # go through our existing public party entries firing off pings + # for any that have timed out + for party in list(self._public_parties.values()): + if (party['next_ping_time'] <= now + and ba.app.ping_thread_count < 15): + + # make sure to fully catch up and not to multi-ping if + # we're way behind somehow.. + while party['next_ping_time'] <= now: + # crank the interval up for high-latency parties to + # save us some work + mult = 1 + if party['ping'] is not None: + mult = (10 if party['ping'] > 300 else + 5 if party['ping'] > 150 else 2) + party[ + 'next_ping_time'] += party['ping_interval'] * mult + + class PingThread(threading.Thread): + """Thread for sending out pings.""" + + def __init__( + self, address: str, port: int, + call: Callable[[str, int, Optional[int]], Any] + ): + super().__init__() + # need utf8 here to avoid an error on our minimum + # bundled python + self._address = address + self._port = port + self._call = call + + def run(self) -> None: + ba.app.ping_thread_count += 1 + try: + import socket + from ba.internal import get_ip_address_type + socket_type = get_ip_address_type( + self._address) + sock = socket.socket(socket_type, + socket.SOCK_DGRAM) + sock.connect((self._address, self._port)) + + accessible = False + starttime = time.time() + # send a simple ping and wait for a response; + # if we get it, they're accessible... + + # send a few pings and wait a second for + # a response + sock.settimeout(1) + for _i in range(3): + sock.send(b'\x0b') + result: Optional[bytes] + try: + # 11: BA_PACKET_SIMPLE_PING + result = sock.recv(10) + except Exception: + result = None + if result == b'\x0c': + # 12: BA_PACKET_SIMPLE_PONG + accessible = True + break + time.sleep(1) + sock.close() + ping = int((time.time() - starttime) * 1000.0) + ba.pushcall(ba.Call( + self._call, self._address, self._port, + ping if accessible else None), + from_other_thread=True) + except OSError as exc: + import errno + # ignore harmless errors + if exc.errno != errno.EHOSTUNREACH: + ba.print_exception('error on ping', + once=True) + except Exception: + ba.print_exception('error on ping', once=True) + ba.app.ping_thread_count -= 1 + + PingThread(party['address'], party['port'], + ba.WeakCall(self._ping_callback)).start() + + def _ping_callback(self, address: str, port: int, result: int) -> None: + # Look for a widget corresponding to this target; if we find one, + # update our list. + party = self._public_parties.get(address + '_' + str(port)) + if party is not None: + # We now smooth ping a bit to reduce jumping around in the list + # (only where pings are relatively good). + current_ping = party.get('ping') + if (current_ping is not None and result is not None + and result < 150): + smoothing = 0.7 + party['ping'] = int(smoothing * current_ping + + (1.0 - smoothing) * result) + else: + party['ping'] = result + if 'ping_widget' not in party: + pass # This can happen if we switch away and then back to the + # client tab while pings are in flight. + elif party['ping_widget']: + self._rebuild_public_party_list() + + def _do_internet_status_check(self) -> None: + from ba.internal import serverget + ba.textwidget(edit=self._internet_host_status_text, + color=(1, 1, 0), + text=ba.Lstr(resource=self._r + + '.partyStatusCheckingText')) + serverget('bsAccessCheck', {'b': ba.app.build_number}, + callback=ba.WeakCall( + self._on_public_party_accessible_response)) + + def _on_start_internet_advertizing_press(self) -> None: + from bastd.ui import account + from bastd.ui import purchase + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + + # Requires sign-in and pro. + if self._is_internet_locked(): + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + else: + purchase.PurchaseWindow(items=['pro']) + return + + name = cast(str, ba.textwidget(query=self._internet_host_name_text)) + if name == '': + ba.screenmessage(ba.Lstr(resource='internal.invalidNameErrorText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + _ba.set_public_party_name(name) + cfg = ba.app.config + cfg['Public Party Name'] = name + cfg.commit() + ba.playsound(ba.getsound('shieldUp')) + _ba.set_public_party_enabled(True) + self._do_internet_status_check() + ba.buttonwidget( + edit=self._internet_host_toggle_button, + label=ba.Lstr( + resource='gatherWindow.makePartyPrivateText', + fallback_resource='gatherWindow.stopAdvertisingText'), + on_activate_call=self._on_stop_internet_advertising_press) + + def _on_public_party_accessible_response(self, + data: Optional[Dict[str, Any]] + ) -> None: + # If we've got status text widgets, update them. + text = self._internet_host_status_text + if text: + if data is None: + ba.textwidget(edit=text, + text=ba.Lstr(resource=self._r + + '.partyStatusNoConnectionText'), + color=(1, 0, 0)) + else: + if not data.get('accessible', False): + ex_line: Union[str, ba.Lstr] + if self._internet_local_address is not None: + ex_line = ba.Lstr( + value='\n${A} ${B}', + subs=[('${A}', + ba.Lstr(resource=self._r + + '.manualYourLocalAddressText')), + ('${B}', self._internet_local_address)]) + else: + ex_line = '' + ba.textwidget( + edit=text, + text=ba.Lstr( + value='${A}\n${B}${C}', + subs=[('${A}', + ba.Lstr(resource=self._r + + '.partyStatusNotJoinableText')), + ('${B}', + ba.Lstr(resource=self._r + + '.manualRouterForwardingText', + subs=[('${PORT}', + str(_ba.get_game_port()))])), + ('${C}', ex_line)]), + color=(1, 0, 0)) + else: + ba.textwidget(edit=text, + text=ba.Lstr(resource=self._r + + '.partyStatusJoinableText'), + color=(0, 1, 0)) + + def _on_stop_internet_advertising_press(self) -> None: + _ba.set_public_party_enabled(False) + ba.playsound(ba.getsound('shieldDown')) + text = self._internet_host_status_text + if text: + ba.textwidget(edit=text, + text=ba.Lstr(resource=self._r + + '.partyStatusNotPublicText'), + color=(0.6, 0.6, 0.6)) + + ba.buttonwidget( + edit=self._internet_host_toggle_button, + label=ba.Lstr( + resource='gatherWindow.makePartyPublicText', + fallback_resource='gatherWindow.startAdvertisingText'), + on_activate_call=self._on_start_internet_advertizing_press) + + def _access_check_update(self, t_addr: ba.Widget, t_accessible: ba.Widget, + t_accessible_extra: ba.Widget) -> None: + from ba.internal import serverget + + # If we don't have an outstanding query, start one.. + assert self._doing_access_check is not None + assert self._access_check_count is not None + if not self._doing_access_check and self._access_check_count < 100: + self._doing_access_check = True + self._access_check_count += 1 + self._t_addr = t_addr + self._t_accessible = t_accessible + self._t_accessible_extra = t_accessible_extra + serverget('bsAccessCheck', {'b': ba.app.build_number}, + callback=ba.WeakCall(self._on_accessible_response)) + + def _on_accessible_response(self, data: Optional[Dict[str, Any]]) -> None: + t_addr = self._t_addr + t_accessible = self._t_accessible + t_accessible_extra = self._t_accessible_extra + self._doing_access_check = False + color_bad = (1, 1, 0) + color_good = (0, 1, 0) + if data is None or 'address' not in data or 'accessible' not in data: + if t_addr: + ba.textwidget(edit=t_addr, + text=ba.Lstr(resource=self._r + + '.noConnectionText'), + color=color_bad) + if t_accessible: + ba.textwidget(edit=t_accessible, + text=ba.Lstr(resource=self._r + + '.noConnectionText'), + color=color_bad) + if t_accessible_extra: + ba.textwidget(edit=t_accessible_extra, + text='', + color=color_bad) + return + if t_addr: + ba.textwidget(edit=t_addr, text=data['address'], color=color_good) + if t_accessible: + if data['accessible']: + ba.textwidget(edit=t_accessible, + text=ba.Lstr(resource=self._r + + '.manualJoinableYesText'), + color=color_good) + if t_accessible_extra: + ba.textwidget(edit=t_accessible_extra, + text='', + color=color_good) + else: + ba.textwidget( + edit=t_accessible, + text=ba.Lstr(resource=self._r + + '.manualJoinableNoWithAsteriskText'), + color=color_bad) + if t_accessible_extra: + ba.textwidget(edit=t_accessible_extra, + text=ba.Lstr(resource=self._r + + '.manualRouterForwardingText', + subs=[('${PORT}', + str(_ba.get_game_port())) + ]), + color=color_bad) + + def _save_state(self) -> None: + try: + sel = self._root_widget.get_selected_child() + if sel == self._back_button: + sel_name = 'Back' + elif sel in list(self._tab_buttons.values()): + sel_name = 'Tab:' + list(self._tab_buttons.keys())[list( + self._tab_buttons.values()).index(sel)] + elif sel == self._tab_container: + sel_name = 'TabContainer' + else: + raise Exception("unrecognized selection: " + str(sel)) + ba.app.window_states[self.__class__.__name__] = { + 'sel_name': sel_name, + 'tab': self._current_tab, + 'internetTab': self._internet_tab + } + except Exception: + ba.print_exception('error saving state for', self.__class__) + + def _restore_state(self) -> None: + try: + winstate = ba.app.window_states.get(self.__class__.__name__, {}) + sel_name = winstate.get('sel_name', None) + self._internet_tab = winstate.get('internetTab', 'join') + current_tab = ba.app.config.get('Gather Tab', None) + if current_tab is None or current_tab not in self._tab_buttons: + current_tab = 'about' + self._set_tab(current_tab) + if sel_name == 'Back': + sel = self._back_button + elif sel_name == 'TabContainer': + sel = self._tab_container + elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): + sel = self._tab_buttons[sel_name.split(':')[-1]] + else: + sel = self._tab_buttons[current_tab] + ba.containerwidget(edit=self._root_widget, selected_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) + + def _back(self) -> None: + from bastd.ui import mainmenu + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = (mainmenu.MainMenuWindow( + transition='in_left').get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/getcurrency.py b/assets/src/data/scripts/bastd/ui/getcurrency.py new file mode 100644 index 00000000..e5fd9fec --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/getcurrency.py @@ -0,0 +1,595 @@ +"""UI functionality for purchasing/acquiring currency.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Optional, Tuple, Union, Dict + + +class GetCurrencyWindow(ba.OldWindow): + """Window for purchasing/acquiring currency.""" + + def __init__(self, + transition: str = 'in_right', + from_modal_store: bool = False, + modal: bool = False, + origin_widget: ba.Widget = None, + store_back_location: str = None): + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + + ba.set_analytics_screen('Get Tickets Window') + + self._transitioning_out = False + self._store_back_location = store_back_location # ew. + + self._ad_button_greyed = False + self._smooth_update_timer: Optional[ba.Timer] = None + self._ad_button = None + self._ad_label = None + self._ad_image = None + self._ad_time_text = None + + # If they provided an origin-widget, scale up from that. + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self._width = 1000.0 if ba.app.small_ui else 800.0 + x_inset = 100.0 if ba.app.small_ui else 0.0 + self._height = 480.0 + + self._modal = modal + self._from_modal_store = from_modal_store + self._r = 'getTicketsWindow' + + top_extra = 20 if ba.app.small_ui else 0 + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + top_extra), + transition=transition, + scale_origin_stack_offset=scale_origin, + color=(0.4, 0.37, 0.55), + scale=(1.63 if ba.app.small_ui else 1.2 if ba.app.med_ui else 1.0), + stack_offset=(0, -3) if ba.app.small_ui else (0, 0))) + + btn = ba.buttonwidget( + parent=self._root_widget, + position=(55 + x_inset, self._height - 79), + size=(140, 60), + scale=1.0, + autoselect=True, + label=ba.Lstr(resource='doneText' if modal else 'backText'), + button_type='regular' if modal else 'back', + on_activate_call=self._back) + + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 55), + size=(0, 0), + color=ba.app.title_color, + scale=1.2, + h_align="center", + v_align="center", + text=ba.Lstr(resource=self._r + '.titleText'), + maxwidth=290) + + if not modal: + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + b_size = (220.0, 180.0) + v = self._height - b_size[1] - 80 + spacing = 1 + + self._ad_button = None + + def _add_button(item: str, + position: Tuple[float, float], + size: Tuple[float, float], + label: ba.Lstr, + price: str = None, + tex_name: str = None, + tex_opacity: float = 1.0, + tex_scale: float = 1.0, + enabled: bool = True, + text_scale: float = 1.0) -> ba.Widget: + btn2 = ba.buttonwidget( + parent=self._root_widget, + position=position, + button_type='square', + size=size, + label='', + autoselect=True, + color=None if enabled else (0.5, 0.5, 0.5), + on_activate_call=(ba.Call(self._purchase, item) + if enabled else self._disabled_press)) + txt = ba.textwidget(parent=self._root_widget, + text=label, + position=(position[0] + size[0] * 0.5, + position[1] + size[1] * 0.3), + scale=text_scale, + maxwidth=size[0] * 0.75, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn2, + color=(0.7, 0.9, 0.7, 1.0 if enabled else 0.2)) + if price is not None and enabled: + ba.textwidget(parent=self._root_widget, + text=price, + position=(position[0] + size[0] * 0.5, + position[1] + size[1] * 0.17), + scale=0.7, + maxwidth=size[0] * 0.75, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn2, + color=(0.4, 0.9, 0.4, 1.0)) + i = None + if tex_name is not None: + tex_size = 90.0 * tex_scale + i = ba.imagewidget( + parent=self._root_widget, + texture=ba.gettexture(tex_name), + position=(position[0] + size[0] * 0.5 - tex_size * 0.5, + position[1] + size[1] * 0.66 - tex_size * 0.5), + size=(tex_size, tex_size), + draw_controller=btn2, + opacity=tex_opacity * (1.0 if enabled else 0.25)) + if item == 'ad': + self._ad_button = btn2 + self._ad_label = txt + assert i is not None + self._ad_image = i + self._ad_time_text = ba.textwidget( + parent=self._root_widget, + text='1m 10s', + position=(position[0] + size[0] * 0.5, + position[1] + size[1] * 0.5), + scale=text_scale * 1.2, + maxwidth=size[0] * 0.85, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn2, + color=(0.4, 0.9, 0.4, 1.0)) + return btn2 + + rsrc = self._r + '.ticketsText' + + c2txt = ba.Lstr( + resource=rsrc, + subs=[('${COUNT}', + str(_ba.get_account_misc_read_val('tickets2Amount', 500)))]) + c3txt = ba.Lstr( + resource=rsrc, + subs=[('${COUNT}', + str(_ba.get_account_misc_read_val('tickets3Amount', + 1500)))]) + c4txt = ba.Lstr( + resource=rsrc, + subs=[('${COUNT}', + str(_ba.get_account_misc_read_val('tickets4Amount', + 5000)))]) + c5txt = ba.Lstr( + resource=rsrc, + subs=[('${COUNT}', + str(_ba.get_account_misc_read_val('tickets5Amount', + 15000)))]) + + h = 110.0 + + # enable buttons if we have prices.. + tickets2_price = _ba.get_price('tickets2') + tickets3_price = _ba.get_price('tickets3') + tickets4_price = _ba.get_price('tickets4') + tickets5_price = _ba.get_price('tickets5') + + # TEMP + # tickets1_price = '$0.99' + # tickets2_price = '$4.99' + # tickets3_price = '$9.99' + # tickets4_price = '$19.99' + # tickets5_price = '$49.99' + + _add_button('tickets2', + enabled=(tickets2_price is not None), + position=(self._width * 0.5 - spacing * 1.5 - + b_size[0] * 2.0 + h, v), + size=b_size, + label=c2txt, + price=tickets2_price, + tex_name='ticketsMore') # 0.99-ish + _add_button('tickets3', + enabled=(tickets3_price is not None), + position=(self._width * 0.5 - spacing * 0.5 - + b_size[0] * 1.0 + h, v), + size=b_size, + label=c3txt, + price=tickets3_price, + tex_name='ticketRoll') # 4.99-ish + v -= b_size[1] - 5 + _add_button('tickets4', + enabled=(tickets4_price is not None), + position=(self._width * 0.5 - spacing * 1.5 - + b_size[0] * 2.0 + h, v), + size=b_size, + label=c4txt, + price=tickets4_price, + tex_name='ticketRollBig', + tex_scale=1.2) # 9.99-ish + _add_button('tickets5', + enabled=(tickets5_price is not None), + position=(self._width * 0.5 - spacing * 0.5 - + b_size[0] * 1.0 + h, v), + size=b_size, + label=c5txt, + price=tickets5_price, + tex_name='ticketRolls', + tex_scale=1.2) # 19.99-ish + + self._enable_ad_button = _ba.has_video_ads() + h = self._width * 0.5 + 110.0 + v = self._height - b_size[1] - 115.0 + + if self._enable_ad_button: + h_offs = 35 + b_size_3 = (150, 120) + cdb = _add_button( + 'ad', + position=(h + h_offs, v), + size=b_size_3, + label=ba.Lstr(resource=self._r + '.ticketsFromASponsorText', + subs=[('${COUNT}', + str( + _ba.get_account_misc_read_val( + 'sponsorTickets', 5)))]), + tex_name='ticketsMore', + enabled=self._enable_ad_button, + tex_opacity=0.6, + tex_scale=0.7, + text_scale=0.7) + ba.buttonwidget(edit=cdb, + color=(0.65, 0.5, + 0.7) if self._enable_ad_button else + (0.5, 0.5, 0.5)) + + self._ad_free_text = ba.textwidget( + parent=self._root_widget, + text=ba.Lstr(resource=self._r + '.freeText'), + position=(h + h_offs + b_size_3[0] * 0.5, + v + b_size_3[1] * 0.5 + 25), + size=(0, 0), + color=(1, 1, 0, 1.0) if self._enable_ad_button else + (1, 1, 1, 0.2), + draw_controller=cdb, + rotate=15, + shadow=1.0, + maxwidth=150, + h_align='center', + v_align='center', + scale=1.0) + v -= 125 + else: + v -= 20 + + if True: # pylint: disable=using-constant-test + h_offs = 35 + b_size_3 = (150, 120) + cdb = _add_button( + 'app_invite', + position=(h + h_offs, v), + size=b_size_3, + label=ba.Lstr( + resource='gatherWindow.earnTicketsForRecommendingText', + subs=[ + ('${COUNT}', + str(_ba.get_account_misc_read_val( + 'sponsorTickets', 5))) + ]), + tex_name='ticketsMore', + enabled=True, + tex_opacity=0.6, + tex_scale=0.7, + text_scale=0.7) + ba.buttonwidget(edit=cdb, color=(0.65, 0.5, 0.7)) + + ba.textwidget(parent=self._root_widget, + text=ba.Lstr(resource=self._r + '.freeText'), + position=(h + h_offs + b_size_3[0] * 0.5, + v + b_size_3[1] * 0.5 + 25), + size=(0, 0), + color=(1, 1, 0, 1.0), + draw_controller=cdb, + rotate=15, + shadow=1.0, + maxwidth=150, + h_align='center', + v_align='center', + scale=1.0) + tc_y_offs = 0 + + h = self._width - (185 + x_inset) + v = self._height - 95 + tc_y_offs + + txt1 = (ba.Lstr( + resource=self._r + + '.youHaveText').evaluate().split('${COUNT}')[0].strip()) + txt2 = (ba.Lstr( + resource=self._r + + '.youHaveText').evaluate().split('${COUNT}')[-1].strip()) + + ba.textwidget(parent=self._root_widget, + text=txt1, + position=(h, v), + size=(0, 0), + color=(0.5, 0.5, 0.6), + maxwidth=200, + h_align='center', + v_align='center', + scale=0.8) + v -= 30 + self._ticket_count_text = ba.textwidget(parent=self._root_widget, + position=(h, v), + size=(0, 0), + color=(0.2, 1.0, 0.2), + maxwidth=200, + h_align='center', + v_align='center', + scale=1.6) + v -= 30 + ba.textwidget(parent=self._root_widget, + text=txt2, + position=(h, v), + size=(0, 0), + color=(0.5, 0.5, 0.6), + maxwidth=200, + h_align='center', + v_align='center', + scale=0.8) + + # update count now and once per second going forward.. + self._ticking_node: Optional[ba.Node] = None + self._smooth_ticket_count: Optional[float] = None + self._ticket_count = 0 + self._update() + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + self._smooth_increase_speed = 1.0 + + def __del__(self) -> None: + if self._ticking_node is not None: + self._ticking_node.delete() + self._ticking_node = None + + def _smooth_update(self) -> None: + if not self._ticket_count_text: + self._smooth_update_timer = None + return + + finished = False + + # if we're going down, do it immediately + assert self._smooth_ticket_count is not None + if int(self._smooth_ticket_count) >= self._ticket_count: + self._smooth_ticket_count = float(self._ticket_count) + finished = True + else: + # we're going up; start a sound if need be + self._smooth_ticket_count = min( + self._smooth_ticket_count + 1.0 * self._smooth_increase_speed, + self._ticket_count) + if int(self._smooth_ticket_count) >= self._ticket_count: + finished = True + self._smooth_ticket_count = float(self._ticket_count) + elif self._ticking_node is None: + with ba.Context('ui'): + self._ticking_node = ba.newnode( + 'sound', + attrs={ + 'sound': ba.getsound('scoreIncrease'), + 'positional': False + }) + + ba.textwidget(edit=self._ticket_count_text, + text=str(int(self._smooth_ticket_count))) + + # if we've reached the target, kill the timer/sound/etc + if finished: + self._smooth_update_timer = None + if self._ticking_node is not None: + self._ticking_node.delete() + self._ticking_node = None + ba.playsound(ba.getsound('cashRegister2')) + + def _update(self) -> None: + import datetime + + # if we somehow get signed out, just die.. + if _ba.get_account_state() != 'signed_in': + self._back() + return + + self._ticket_count = _ba.get_account_ticket_count() + + # update our incentivized ad button depending on whether ads are + # available + if self._ad_button is not None: + next_reward_ad_time = _ba.get_account_misc_read_val_2( + 'nextRewardAdTime', None) + if next_reward_ad_time is not None: + next_reward_ad_time = datetime.datetime.utcfromtimestamp( + next_reward_ad_time) + now = datetime.datetime.utcnow() + if (_ba.have_incentivized_ad() and + (next_reward_ad_time is None or next_reward_ad_time <= now)): + self._ad_button_greyed = False + ba.buttonwidget(edit=self._ad_button, color=(0.65, 0.5, 0.7)) + ba.textwidget(edit=self._ad_label, color=(0.7, 0.9, 0.7, 1.0)) + ba.textwidget(edit=self._ad_free_text, color=(1, 1, 0, 1)) + ba.imagewidget(edit=self._ad_image, opacity=0.6) + ba.textwidget(edit=self._ad_time_text, text='') + else: + self._ad_button_greyed = True + ba.buttonwidget(edit=self._ad_button, color=(0.5, 0.5, 0.5)) + ba.textwidget(edit=self._ad_label, color=(0.7, 0.9, 0.7, 0.2)) + ba.textwidget(edit=self._ad_free_text, color=(1, 1, 0, 0.2)) + ba.imagewidget(edit=self._ad_image, opacity=0.6 * 0.25) + sval: Union[str, ba.Lstr] + if (next_reward_ad_time is not None + and next_reward_ad_time > now): + sval = ba.timestring( + (next_reward_ad_time - now).total_seconds() * 1000.0, + centi=False, + timeformat=ba.TimeFormat.MILLISECONDS) + else: + sval = '' + ba.textwidget(edit=self._ad_time_text, text=sval) + + # if this is our first update, assign immediately; otherwise kick + # off a smooth transition if the value has changed + if self._smooth_ticket_count is None: + self._smooth_ticket_count = float(self._ticket_count) + self._smooth_update() # will set the text widget + + elif (self._ticket_count != int(self._smooth_ticket_count) + and self._smooth_update_timer is None): + self._smooth_update_timer = ba.Timer(0.05, + ba.WeakCall( + self._smooth_update), + repeat=True, + timetype=ba.TimeType.REAL) + diff = abs(float(self._ticket_count) - self._smooth_ticket_count) + self._smooth_increase_speed = (diff / + 100.0 if diff >= 5000 else diff / + 50.0 if diff >= 1500 else diff / + 30.0 if diff >= 500 else diff / + 15.0) + + def _disabled_press(self) -> None: + + # if we're on a platform without purchases, inform the user they + # can link their accounts and buy stuff elsewhere + app = ba.app + if ((app.test_build or + (app.platform == 'android' + and app.subplatform in ['oculus', 'cardboard'])) and + _ba.get_account_misc_read_val('allowAccountLinking2', False)): + ba.screenmessage(ba.Lstr(resource=self._r + + '.unavailableLinkAccountText'), + color=(1, 0.5, 0)) + else: + ba.screenmessage(ba.Lstr(resource=self._r + '.unavailableText'), + color=(1, 0.5, 0)) + ba.playsound(ba.getsound('error')) + + def _purchase(self, item: str) -> None: + from bastd.ui import account + from bastd.ui import appinvite + from ba.internal import serverget + if item == 'app_invite': + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + appinvite.handle_app_invites_press() + return + # here we ping the server to ask if it's valid for us to + # purchase this.. (better to fail now than after we've paid locally) + app = ba.app + serverget('bsAccountPurchaseCheck', { + 'item': item, + 'platform': app.platform, + 'subplatform': app.subplatform, + 'version': app.version, + 'buildNumber': app.build_number + }, + callback=ba.WeakCall(self._purchase_check_result, item)) + + def _purchase_check_result(self, item: str, + result: Optional[Dict[str, Any]]) -> None: + if result is None: + ba.playsound(ba.getsound('error')) + ba.screenmessage( + ba.Lstr(resource='internal.unavailableNoConnectionText'), + color=(1, 0, 0)) + else: + if result['allow']: + self._do_purchase(item) + else: + if result['reason'] == 'versionTooOld': + ba.playsound(ba.getsound('error')) + ba.screenmessage( + ba.Lstr(resource='getTicketsWindow.versionTooOldText'), + color=(1, 0, 0)) + else: + ba.playsound(ba.getsound('error')) + ba.screenmessage( + ba.Lstr(resource='getTicketsWindow.unavailableText'), + color=(1, 0, 0)) + + # actually start the purchase locally.. + def _do_purchase(self, item: str) -> None: + from ba.internal import show_ad + if item == 'ad': + import datetime + # if ads are disabled until some time, error.. + next_reward_ad_time = _ba.get_account_misc_read_val_2( + 'nextRewardAdTime', None) + if next_reward_ad_time is not None: + next_reward_ad_time = datetime.datetime.utcfromtimestamp( + next_reward_ad_time) + now = datetime.datetime.utcnow() + if ((next_reward_ad_time is not None and next_reward_ad_time > now) + or self._ad_button_greyed): + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr( + resource='getTicketsWindow.unavailableTemporarilyText'), + color=(1, 0, 0)) + elif self._enable_ad_button: + show_ad('tickets') + else: + _ba.purchase(item) + + def _back(self) -> None: + from bastd.ui.store import browser + if self._transitioning_out: + return + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + if not self._modal: + window = browser.StoreBrowserWindow( + transition='in_left', + modal=self._from_modal_store, + back_location=self._store_back_location).get_root_widget() + if not self._from_modal_store: + ba.app.main_menu_window = window + self._transitioning_out = True + + +def show_get_tickets_prompt() -> None: + """Show a prompt to get more currency.""" + from bastd.ui import confirm + confirm.ConfirmWindow( + ba.Lstr(translate=('serverResponses', + 'You don\'t have enough tickets for this!')), + ba.Call(GetCurrencyWindow, modal=True), + ok_text=ba.Lstr(resource='getTicketsWindow.titleText'), + width=460, + height=130) diff --git a/assets/src/data/scripts/bastd/ui/getremote.py b/assets/src/data/scripts/bastd/ui/getremote.py new file mode 100644 index 00000000..612ed6b7 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/getremote.py @@ -0,0 +1,67 @@ +"""Provides a popup telling the user about the BSRemote app.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.ui import popup + +if TYPE_CHECKING: + pass + + +class GetBSRemoteWindow(popup.PopupWindow): + """Popup telling the user about BSRemote app.""" + + def __init__(self) -> None: + position = (0.0, 0.0) + scale = (2.3 if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + self._transitioning_out = False + self._width = 570 + self._height = 350 + bg_color = (0.5, 0.4, 0.6) + popup.PopupWindow.__init__(self, + position=position, + size=(self._width, self._height), + scale=scale, + bg_color=bg_color) + self._cancel_button = ba.buttonwidget( + parent=self.root_widget, + position=(50, self._height - 30), + size=(50, 50), + scale=0.5, + label='', + color=bg_color, + on_activate_call=self._on_cancel_press, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + ba.imagewidget(parent=self.root_widget, + position=(self._width * 0.5 - 110, + self._height * 0.67 - 110), + size=(220, 220), + texture=ba.gettexture('multiplayerExamples')) + ba.textwidget(parent=self.root_widget, + size=(0, 0), + h_align='center', + v_align='center', + maxwidth=self._width * 0.9, + position=(self._width * 0.5, 60), + text=ba.Lstr( + resource='remoteAppInfoShortText', + subs=[('${APP_NAME}', ba.Lstr(resource='titleText')), + ('${REMOTE_APP_NAME}', + ba.Lstr(resource='remote_app.app_name'))])) + + def _on_cancel_press(self) -> None: + self._transition_out() + + def _transition_out(self) -> None: + if not self._transitioning_out: + self._transitioning_out = True + ba.containerwidget(edit=self.root_widget, transition='out_scale') + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._transition_out() diff --git a/assets/src/data/scripts/bastd/ui/helpui.py b/assets/src/data/scripts/bastd/ui/helpui.py new file mode 100644 index 00000000..792072fc --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/helpui.py @@ -0,0 +1,585 @@ +"""Provides help related ui.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Optional, Tuple + + +class HelpWindow(ba.OldWindow): + """A window providing help on how to play.""" + + def __init__(self, + main_menu: bool = False, + origin_widget: ba.Widget = None): + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + from ba.internal import get_remote_app_name + from ba.deprecated import get_resource + ba.set_analytics_screen('Help Window') + + # If they provided an origin-widget, scale up from that. + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + transition = 'in_right' + + self._r = 'helpWindow' + + self._main_menu = main_menu + width = 950 if ba.app.small_ui else 750 + x_offs = 100 if ba.app.small_ui else 0 + height = 460 if ba.app.small_ui else 530 if ba.app.med_ui else 600 + + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition=transition, + toolbar_visibility='menu_minimal', + scale_origin_stack_offset=scale_origin, + scale=( + 1.77 if ba.app.small_ui else 1.25 if ba.app.med_ui else 1.0), + stack_offset=(0, -30) if ba.app.small_ui else ( + 0, 15) if ba.app.med_ui else (0, 0))) + + ba.textwidget(parent=self._root_widget, + position=(0, height - (50 if ba.app.small_ui else 45)), + size=(width, 25), + text=ba.Lstr(resource=self._r + '.titleText', + subs=[('${APP_NAME}', + ba.Lstr(resource='titleText'))]), + color=ba.app.title_color, + h_align="center", + v_align="top") + + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + position=(44 + x_offs, 55 if ba.app.small_ui else 55), + simple_culling_v=100.0, + size=(width - (88 + 2 * x_offs), + height - 120 + (5 if ba.app.small_ui else 0)), + capture_arrows=True) + + if ba.app.toolbars: + ba.widget(edit=self._scrollwidget, + right_widget=_ba.get_special_widget('party_button')) + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget) + + # ugly: create this last so it gets first dibs at touch events (since + # we have it close to the scroll widget) + if ba.app.small_ui and ba.app.toolbars: + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._close) + ba.widget(edit=self._scrollwidget, + left_widget=_ba.get_special_widget('back_button')) + else: + btn = ba.buttonwidget( + parent=self._root_widget, + position=(x_offs + (40 + 0 if ba.app.small_ui else 70), + height - (59 if ba.app.small_ui else 50)), + size=(140, 60), + scale=0.7 if ba.app.small_ui else 0.8, + label=ba.Lstr( + resource='backText') if self._main_menu else "Close", + button_type='back' if self._main_menu else None, + extra_touch_border_scale=2.0, + autoselect=True, + on_activate_call=self._close) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + if self._main_menu: + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 55), + label=ba.charstr(ba.SpecialChar.BACK)) + + # interface_type = ba.app.interface_type + + self._sub_width = 660 + self._sub_height = 1590 + get_resource( + self._r + '.someDaysExtraSpace') + get_resource( + self._r + '.orPunchingSomethingExtraSpace') + + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._sub_width, + self._sub_height), + background=False, + claims_left_right=False, + claims_tab=False) + + spacing = 1.0 + h = self._sub_width * 0.5 + v = self._sub_height - 55 + logo_tex = ba.gettexture('logo') + icon_buffer = 1.1 + header = (0.7, 1.0, 0.7, 1.0) + header2 = (0.8, 0.8, 1.0, 1.0) + paragraph = (0.8, 0.8, 1.0, 1.0) + + txt = ba.Lstr(resource=self._r + '.welcomeText', + subs=[('${APP_NAME}', ba.Lstr(resource='titleText')) + ]).evaluate() + txt_scale = 1.4 + txt_maxwidth = 480 + ba.textwidget(parent=self._subcontainer, + position=(h, v), + size=(0, 0), + scale=txt_scale, + flatness=0.5, + res_scale=1.5, + text=txt, + h_align="center", + color=header, + v_align="center", + maxwidth=txt_maxwidth) + txt_width = min( + txt_maxwidth, + _ba.get_string_width(txt, suppress_warning=True) * txt_scale) + + icon_size = 70 + hval2 = h - (txt_width * 0.5 + icon_size * 0.5 * icon_buffer) + ba.imagewidget(parent=self._subcontainer, + size=(icon_size, icon_size), + position=(hval2 - 0.5 * icon_size, + v - 0.45 * icon_size), + texture=logo_tex) + + force_test = False + app = ba.app + if (app.platform == 'android' + and app.subplatform == 'alibaba') or force_test: + v -= 120.0 + txtv = ( + '\xe8\xbf\x99\xe6\x98\xaf\xe4\xb8\x80\xe4\xb8\xaa\xe5\x8f\xaf' + '\xe4\xbb\xa5\xe5\x92\x8c\xe5\xae\xb6\xe4\xba\xba\xe6\x9c\x8b' + '\xe5\x8f\x8b\xe4\xb8\x80\xe8\xb5\xb7\xe7\x8e\xa9\xe7\x9a\x84' + '\xe6\xb8\xb8\xe6\x88\x8f,\xe5\x90\x8c\xe6\x97\xb6\xe6\x94\xaf' + '\xe6\x8c\x81\xe8\x81\x94 \xe2\x80\xa8\xe7\xbd\x91\xe5\xaf\xb9' + '\xe6\x88\x98\xe3\x80\x82\n' + '\xe5\xa6\x82\xe6\xb2\xa1\xe6\x9c\x89\xe6\xb8\xb8\xe6\x88\x8f' + '\xe6\x89\x8b\xe6\x9f\x84,\xe5\x8f\xaf\xe4\xbb\xa5\xe4\xbd\xbf' + '\xe7\x94\xa8\xe7\xa7\xbb\xe5\x8a\xa8\xe8\xae\xbe\xe5\xa4\x87' + '\xe6\x89\xab\xe7\xa0\x81\xe4\xb8\x8b\xe8\xbd\xbd\xe2\x80\x9c' + '\xe9\x98\xbf\xe9\x87\x8c\xc2' + '\xa0TV\xc2\xa0\xe5\x8a\xa9\xe6\x89' + '\x8b\xe2\x80\x9d\xe7\x94\xa8 \xe6\x9d\xa5\xe4\xbb\xa3\xe6\x9b' + '\xbf\xe5\xa4\x96\xe8\xae\xbe\xe3\x80\x82\n' + '\xe6\x9c\x80\xe5\xa4\x9a\xe6\x94\xaf\xe6\x8c\x81\xe6\x8e\xa5' + '\xe5\x85\xa5\xc2\xa08\xc2\xa0\xe4\xb8\xaa\xe5\xa4\x96\xe8' + '\xae\xbe') + ba.textwidget(parent=self._subcontainer, + size=(0, 0), + h_align='center', + v_align='center', + maxwidth=self._sub_width * 0.9, + position=(self._sub_width * 0.5, v - 180), + text=txtv) + ba.imagewidget(parent=self._subcontainer, + position=(self._sub_width - 320, v - 120), + size=(200, 200), + texture=ba.gettexture('aliControllerQR')) + ba.imagewidget(parent=self._subcontainer, + position=(90, v - 130), + size=(210, 210), + texture=ba.gettexture('multiplayerExamples')) + v -= 120.0 + + else: + v -= spacing * 50.0 + txt = ba.Lstr(resource=self._r + '.someDaysText').evaluate() + ba.textwidget(parent=self._subcontainer, + position=(h, v), + size=(0, 0), + scale=1.2, + maxwidth=self._sub_width * 0.9, + text=txt, + h_align="center", + color=paragraph, + v_align="center", + flatness=1.0) + v -= (spacing * 25.0 + + get_resource(self._r + '.someDaysExtraSpace')) + txt_scale = 0.66 + txt = ba.Lstr(resource=self._r + + '.orPunchingSomethingText').evaluate() + ba.textwidget(parent=self._subcontainer, + position=(h, v), + size=(0, 0), + scale=txt_scale, + maxwidth=self._sub_width * 0.9, + text=txt, + h_align="center", + color=paragraph, + v_align="center", + flatness=1.0) + v -= (spacing * 27.0 + + get_resource(self._r + '.orPunchingSomethingExtraSpace')) + txt_scale = 1.0 + txt = ba.Lstr(resource=self._r + '.canHelpText', + subs=[('${APP_NAME}', ba.Lstr(resource='titleText')) + ]).evaluate() + ba.textwidget(parent=self._subcontainer, + position=(h, v), + size=(0, 0), + scale=txt_scale, + flatness=1.0, + text=txt, + h_align="center", + color=paragraph, + v_align="center") + + v -= spacing * 70.0 + txt_scale = 1.0 + txt = ba.Lstr(resource=self._r + '.toGetTheMostText').evaluate() + ba.textwidget(parent=self._subcontainer, + position=(h, v), + size=(0, 0), + scale=txt_scale, + maxwidth=self._sub_width * 0.9, + text=txt, + h_align="center", + color=header, + v_align="center", + flatness=1.0) + + v -= spacing * 40.0 + txt_scale = 0.74 + txt = ba.Lstr(resource=self._r + '.friendsText').evaluate() + hval2 = h - 220 + ba.textwidget(parent=self._subcontainer, + position=(hval2, v), + size=(0, 0), + scale=txt_scale, + maxwidth=100, + text=txt, + h_align="right", + color=header, + v_align="center", + flatness=1.0) + + txt = ba.Lstr(resource=self._r + '.friendsGoodText', + subs=[('${APP_NAME}', ba.Lstr(resource='titleText')) + ]).evaluate() + txt_scale = 0.7 + ba.textwidget(parent=self._subcontainer, + position=(hval2 + 10, v + 8), + size=(0, 0), + scale=txt_scale, + maxwidth=500, + text=txt, + h_align="left", + color=paragraph, + flatness=1.0) + + app = ba.app + + v -= spacing * 45.0 + txt = (ba.Lstr(resource=self._r + '.devicesText').evaluate() + if app.vr_mode else ba.Lstr(resource=self._r + + '.controllersText').evaluate()) + txt_scale = 0.74 + hval2 = h - 220 + ba.textwidget(parent=self._subcontainer, + position=(hval2, v), + size=(0, 0), + scale=txt_scale, + maxwidth=100, + text=txt, + h_align="right", + color=header, + v_align="center", + flatness=1.0) + + txt_scale = 0.7 + if not app.vr_mode: + txt = ba.Lstr(resource=self._r + '.controllersInfoText', + subs=[('${APP_NAME}', + ba.Lstr(resource='titleText')), + ('${REMOTE_APP_NAME}', + get_remote_app_name())]).evaluate() + else: + txt = ba.Lstr(resource=self._r + '.devicesInfoText', + subs=[('${APP_NAME}', + ba.Lstr(resource='titleText')) + ]).evaluate() + + ba.textwidget(parent=self._subcontainer, + position=(hval2 + 10, v + 8), + size=(0, 0), + scale=txt_scale, + maxwidth=500, + max_height=105, + text=txt, + h_align="left", + color=paragraph, + flatness=1.0) + + v -= spacing * 150.0 + + txt = ba.Lstr(resource=self._r + '.controlsText').evaluate() + txt_scale = 1.4 + txt_maxwidth = 480 + ba.textwidget(parent=self._subcontainer, + position=(h, v), + size=(0, 0), + scale=txt_scale, + flatness=0.5, + text=txt, + h_align="center", + color=header, + v_align="center", + res_scale=1.5, + maxwidth=txt_maxwidth) + txt_width = min( + txt_maxwidth, + _ba.get_string_width(txt, suppress_warning=True) * txt_scale) + icon_size = 70 + + hval2 = h - (txt_width * 0.5 + icon_size * 0.5 * icon_buffer) + ba.imagewidget(parent=self._subcontainer, + size=(icon_size, icon_size), + position=(hval2 - 0.5 * icon_size, + v - 0.45 * icon_size), + texture=logo_tex) + + v -= spacing * 45.0 + + txt_scale = 0.7 + txt = ba.Lstr(resource=self._r + '.controlsSubtitleText', + subs=[('${APP_NAME}', ba.Lstr(resource='titleText')) + ]).evaluate() + ba.textwidget(parent=self._subcontainer, + position=(h, v), + size=(0, 0), + scale=txt_scale, + maxwidth=self._sub_width * 0.9, + flatness=1.0, + text=txt, + h_align="center", + color=paragraph, + v_align="center") + v -= spacing * 160.0 + + sep = 70 + icon_size = 100 + # icon_size_2 = 30 + hval2 = h - sep + vval2 = v + ba.imagewidget(parent=self._subcontainer, + size=(icon_size, icon_size), + position=(hval2 - 0.5 * icon_size, + vval2 - 0.5 * icon_size), + texture=ba.gettexture('buttonPunch'), + color=(1, 0.7, 0.3)) + + txt_scale = get_resource(self._r + '.punchInfoTextScale') + txt = ba.Lstr(resource=self._r + '.punchInfoText').evaluate() + ba.textwidget(parent=self._subcontainer, + position=(h - sep - 185 + 70, v + 120), + size=(0, 0), + scale=txt_scale, + flatness=1.0, + text=txt, + h_align="center", + color=(1, 0.7, 0.3, 1.0), + v_align="top") + + hval2 = h + sep + vval2 = v + ba.imagewidget(parent=self._subcontainer, + size=(icon_size, icon_size), + position=(hval2 - 0.5 * icon_size, + vval2 - 0.5 * icon_size), + texture=ba.gettexture('buttonBomb'), + color=(1, 0.3, 0.3)) + + txt = ba.Lstr(resource=self._r + '.bombInfoText').evaluate() + txt_scale = get_resource(self._r + '.bombInfoTextScale') + ba.textwidget(parent=self._subcontainer, + position=(h + sep + 50 + 60, v - 35), + size=(0, 0), + scale=txt_scale, + flatness=1.0, + maxwidth=270, + text=txt, + h_align="center", + color=(1, 0.3, 0.3, 1.0), + v_align="top") + + hval2 = h + vval2 = v + sep + ba.imagewidget(parent=self._subcontainer, + size=(icon_size, icon_size), + position=(hval2 - 0.5 * icon_size, + vval2 - 0.5 * icon_size), + texture=ba.gettexture('buttonPickUp'), + color=(0.5, 0.5, 1)) + + txtl = ba.Lstr(resource=self._r + '.pickUpInfoText') + txt_scale = get_resource(self._r + '.pickUpInfoTextScale') + ba.textwidget(parent=self._subcontainer, + position=(h + 60 + 120, v + sep + 50), + size=(0, 0), + scale=txt_scale, + flatness=1.0, + text=txtl, + h_align="center", + color=(0.5, 0.5, 1, 1.0), + v_align="top") + + hval2 = h + vval2 = v - sep + ba.imagewidget(parent=self._subcontainer, + size=(icon_size, icon_size), + position=(hval2 - 0.5 * icon_size, + vval2 - 0.5 * icon_size), + texture=ba.gettexture('buttonJump'), + color=(0.4, 1, 0.4)) + + txt = ba.Lstr(resource=self._r + '.jumpInfoText').evaluate() + txt_scale = get_resource(self._r + '.jumpInfoTextScale') + ba.textwidget(parent=self._subcontainer, + position=(h - 250 + 75, v - sep - 15 + 30), + size=(0, 0), + scale=txt_scale, + flatness=1.0, + text=txt, + h_align="center", + color=(0.4, 1, 0.4, 1.0), + v_align="top") + + txt = ba.Lstr(resource=self._r + '.runInfoText').evaluate() + txt_scale = get_resource(self._r + '.runInfoTextScale') + ba.textwidget(parent=self._subcontainer, + position=(h, v - sep - 100), + size=(0, 0), + scale=txt_scale, + maxwidth=self._sub_width * 0.93, + flatness=1.0, + text=txt, + h_align="center", + color=(0.7, 0.7, 1.0, 1.0), + v_align="center") + + v -= spacing * 280.0 + + txt = ba.Lstr(resource=self._r + '.powerupsText').evaluate() + txt_scale = 1.4 + txt_maxwidth = 480 + ba.textwidget(parent=self._subcontainer, + position=(h, v), + size=(0, 0), + scale=txt_scale, + flatness=0.5, + text=txt, + h_align="center", + color=header, + v_align="center", + maxwidth=txt_maxwidth) + txt_width = min( + txt_maxwidth, + _ba.get_string_width(txt, suppress_warning=True) * txt_scale) + icon_size = 70 + hval2 = h - (txt_width * 0.5 + icon_size * 0.5 * icon_buffer) + ba.imagewidget(parent=self._subcontainer, + size=(icon_size, icon_size), + position=(hval2 - 0.5 * icon_size, + v - 0.45 * icon_size), + texture=logo_tex) + + v -= spacing * 50.0 + txt_scale = get_resource(self._r + '.powerupsSubtitleTextScale') + txt = ba.Lstr(resource=self._r + '.powerupsSubtitleText').evaluate() + ba.textwidget(parent=self._subcontainer, + position=(h, v), + size=(0, 0), + scale=txt_scale, + maxwidth=self._sub_width * 0.9, + text=txt, + h_align="center", + color=paragraph, + v_align="center", + flatness=1.0) + + v -= spacing * 1.0 + + mm1 = -270 + mm2 = -215 + mm3 = 0 + icon_size = 50 + shadow_size = 80 + shadow_offs_x = 3 + shadow_offs_y = -4 + t_big = 1.1 + t_small = 0.65 + + shadow_tex = ba.gettexture('shadowSharp') + + for tex in [ + 'powerupPunch', 'powerupShield', 'powerupBomb', + 'powerupHealth', 'powerupIceBombs', 'powerupImpactBombs', + 'powerupStickyBombs', 'powerupLandMines', 'powerupCurse' + ]: + name = ba.Lstr(resource=self._r + '.' + tex + 'NameText') + desc = ba.Lstr(resource=self._r + '.' + tex + 'DescriptionText') + + v -= spacing * 60.0 + + ba.imagewidget( + parent=self._subcontainer, + size=(shadow_size, shadow_size), + position=(h + mm1 + shadow_offs_x - 0.5 * shadow_size, + v + shadow_offs_y - 0.5 * shadow_size), + texture=shadow_tex, + color=(0, 0, 0), + opacity=0.5) + ba.imagewidget(parent=self._subcontainer, + size=(icon_size, icon_size), + position=(h + mm1 - 0.5 * icon_size, + v - 0.5 * icon_size), + texture=ba.gettexture(tex)) + + txt_scale = t_big + txtl = name + ba.textwidget(parent=self._subcontainer, + position=(h + mm2, v + 3), + size=(0, 0), + scale=txt_scale, + maxwidth=200, + flatness=1.0, + text=txtl, + h_align="left", + color=header2, + v_align="center") + txt_scale = t_small + txtl = desc + ba.textwidget(parent=self._subcontainer, + position=(h + mm3, v), + size=(0, 0), + scale=txt_scale, + maxwidth=300, + flatness=1.0, + text=txtl, + h_align="left", + color=paragraph, + v_align="center", + res_scale=0.5) + + def _close(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import mainmenu + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + if self._main_menu: + ba.app.main_menu_window = (mainmenu.MainMenuWindow( + transition='in_left').get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/iconpicker.py b/assets/src/data/scripts/bastd/ui/iconpicker.py new file mode 100644 index 00000000..b17bea90 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/iconpicker.py @@ -0,0 +1,156 @@ +"""Provides a picker for icons.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.ui import popup + +if TYPE_CHECKING: + from typing import Any, Tuple, Sequence + + +class IconPicker(popup.PopupWindow): + """Picker for icons.""" + + def __init__(self, + parent: ba.Widget, + position: Tuple[float, float] = (0.0, 0.0), + delegate: Any = None, + scale: float = None, + offset: Tuple[float, float] = (0.0, 0.0), + tint_color: Sequence[float] = (1.0, 1.0, 1.0), + tint2_color: Sequence[float] = (1.0, 1.0, 1.0), + selected_icon: str = None): + # pylint: disable=too-many-locals + from ba.internal import get_purchased_icons + del parent # unused here + del tint_color # unused_here + del tint2_color # unused here + if scale is None: + scale = (1.85 + if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + + self._delegate = delegate + self._transitioning_out = False + + self._icons = [ba.charstr(ba.SpecialChar.LOGO)] + get_purchased_icons() + count = len(self._icons) + columns = 4 + rows = int(math.ceil(float(count) / columns)) + + button_width = 50 + button_height = 50 + button_buffer_h = 10 + button_buffer_v = 5 + + self._width = (10 + columns * (button_width + 2 * button_buffer_h) * + (1.0 / 0.95) * (1.0 / 0.8)) + self._height = self._width * (0.8 if ba.app.small_ui else 1.06) + + self._scroll_width = self._width * 0.8 + self._scroll_height = self._height * 0.8 + self._scroll_position = ((self._width - self._scroll_width) * 0.5, + (self._height - self._scroll_height) * 0.5) + + # creates our _root_widget + popup.PopupWindow.__init__(self, + position=position, + size=(self._width, self._height), + scale=scale, + bg_color=(0.5, 0.5, 0.5), + offset=offset, + focus_position=self._scroll_position, + focus_size=(self._scroll_width, + self._scroll_height)) + + self._scrollwidget = ba.scrollwidget(parent=self.root_widget, + size=(self._scroll_width, + self._scroll_height), + color=(0.55, 0.55, 0.55), + highlight=False, + position=self._scroll_position) + ba.containerwidget(edit=self._scrollwidget, claims_left_right=True) + + self._sub_width = self._scroll_width * 0.95 + self._sub_height = 5 + rows * (button_height + + 2 * button_buffer_v) + 100 + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._sub_width, + self._sub_height), + background=False) + index = 0 + for y in range(rows): + for x in range(columns): + pos = (x * (button_width + 2 * button_buffer_h) + + button_buffer_h, self._sub_height - (y + 1) * + (button_height + 2 * button_buffer_v) + 0) + btn = ba.buttonwidget(parent=self._subcontainer, + button_type='square', + size=(button_width, button_height), + autoselect=True, + text_scale=1.2, + label='', + color=(0.65, 0.65, 0.65), + on_activate_call=ba.Call( + self._select_icon, + self._icons[index]), + position=pos) + ba.textwidget(parent=self._subcontainer, + h_align='center', + v_align='center', + size=(0, 0), + position=(pos[0] + 0.5 * button_width - 1, + pos[1] + 15), + draw_controller=btn, + text=self._icons[index], + scale=1.8) + ba.widget(edit=btn, show_buffer_top=60, show_buffer_bottom=60) + if self._icons[index] == selected_icon: + ba.containerwidget(edit=self._subcontainer, + selected_child=btn, + visible_child=btn) + index += 1 + + if index >= count: + break + if index >= count: + break + self._get_more_icons_button = btn = ba.buttonwidget( + parent=self._subcontainer, + size=(self._sub_width * 0.8, 60), + position=(self._sub_width * 0.1, 30), + label=ba.Lstr(resource='editProfileWindow.getMoreIconsText'), + on_activate_call=self._on_store_press, + color=(0.6, 0.6, 0.6), + textcolor=(0.8, 0.8, 0.8), + autoselect=True) + ba.widget(edit=btn, show_buffer_top=30, show_buffer_bottom=30) + + def _on_store_press(self) -> None: + from bastd.ui import account + from bastd.ui.store import browser + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + self._transition_out() + browser.StoreBrowserWindow(modal=True, + show_tab='icons', + origin_widget=self._get_more_icons_button) + + def _select_icon(self, icon: str) -> None: + if self._delegate is not None: + self._delegate.on_icon_picker_pick(icon) + self._transition_out() + + def _transition_out(self) -> None: + if not self._transitioning_out: + self._transitioning_out = True + ba.containerwidget(edit=self.root_widget, transition='out_scale') + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._transition_out() diff --git a/assets/src/data/scripts/bastd/ui/kiosk.py b/assets/src/data/scripts/bastd/ui/kiosk.py new file mode 100644 index 00000000..684962ae --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/kiosk.py @@ -0,0 +1,449 @@ +"""UI functionality for running the game in kiosk mode.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Optional + + +class KioskWindow(ba.OldWindow): + """Kiosk mode window.""" + + def __init__(self, transition: str = 'in_right'): + # pylint: disable=too-many-locals, too-many-statements + from bastd.ui import confirm + self._width = 720.0 + self._height = 340.0 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition=transition, + on_cancel_call=ba.Call(confirm.QuitWindow, swish=True, back=True), + background=False, + stack_offset=(0, -130))) + + self._r = 'kioskWindow' + + self._show_multiplayer = False + + # Let's reset all random player names every time we hit the main menu. + _ba.reset_random_player_names() + + # Reset achievements too (at least locally). + ba.app.config['Achievements'] = {} + + t_delay_base = 0.0 + t_delay_scale = 0.0 + if not ba.app.did_menu_intro: + t_delay_base = 1.0 + t_delay_scale = 1.0 + + model_opaque = ba.getmodel('level_select_button_opaque') + model_transparent = ba.getmodel('level_select_button_transparent') + mask_tex = ba.gettexture('mapPreviewMask') + + y_extra = 130.0 + (0.0 if self._show_multiplayer else -130.0) + b_width = 250.0 + b_height = 200.0 + b_space = 280.0 + b_v = 80.0 + y_extra + label_height = 130.0 + y_extra + img_width = 180.0 + img_v = 158.0 + y_extra + + if self._show_multiplayer: + tdelay = t_delay_base + t_delay_scale * 1.3 + ba.textwidget( + parent=self._root_widget, + size=(0, 0), + position=(self._width * 0.5, self._height + y_extra - 44), + transition_delay=tdelay, + text=ba.Lstr(resource=self._r + '.singlePlayerExamplesText'), + flatness=1.0, + scale=1.2, + h_align='center', + v_align='center', + shadow=1.0) + else: + tdelay = t_delay_base + t_delay_scale * 0.7 + ba.textwidget( + parent=self._root_widget, + size=(0, 0), + position=(self._width * 0.5, self._height + y_extra - 34), + transition_delay=tdelay, + text=ba.Lstr(resource='demoText', + fallback_resource='mainMenu.demoMenuText'), + flatness=1.0, + scale=1.2, + h_align='center', + v_align='center', + shadow=1.0) + h = self._width * 0.5 - b_space + tdelay = t_delay_base + t_delay_scale * 0.7 + self._b1 = btn = ba.buttonwidget(parent=self._root_widget, + autoselect=True, + size=(b_width, b_height), + on_activate_call=ba.Call( + self._do_game, 'easy'), + transition_delay=tdelay, + position=(h - b_width * 0.5, b_v), + label='', + button_type='square') + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + transition_delay=tdelay, + size=(0, 0), + position=(h, label_height), + maxwidth=b_width * 0.7, + text=ba.Lstr(resource=self._r + '.easyText'), + scale=1.3, + h_align='center', + v_align='center') + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + size=(img_width, 0.5 * img_width), + transition_delay=tdelay, + position=(h - img_width * 0.5, img_v), + texture=ba.gettexture('doomShroomPreview'), + model_opaque=model_opaque, + model_transparent=model_transparent, + mask_texture=mask_tex) + h = self._width * 0.5 + tdelay = t_delay_base + t_delay_scale * 0.65 + self._b2 = btn = ba.buttonwidget(parent=self._root_widget, + autoselect=True, + size=(b_width, b_height), + on_activate_call=ba.Call( + self._do_game, 'medium'), + position=(h - b_width * 0.5, b_v), + label='', + button_type='square', + transition_delay=tdelay) + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + transition_delay=tdelay, + size=(0, 0), + position=(h, label_height), + maxwidth=b_width * 0.7, + text=ba.Lstr(resource=self._r + '.mediumText'), + scale=1.3, + h_align='center', + v_align='center') + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + size=(img_width, 0.5 * img_width), + transition_delay=tdelay, + position=(h - img_width * 0.5, img_v), + texture=ba.gettexture('footballStadiumPreview'), + model_opaque=model_opaque, + model_transparent=model_transparent, + mask_texture=mask_tex) + h = self._width * 0.5 + b_space + tdelay = t_delay_base + t_delay_scale * 0.6 + self._b3 = btn = ba.buttonwidget(parent=self._root_widget, + autoselect=True, + size=(b_width, b_height), + on_activate_call=ba.Call( + self._do_game, 'hard'), + transition_delay=tdelay, + position=(h - b_width * 0.5, b_v), + label='', + button_type='square') + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + transition_delay=tdelay, + size=(0, 0), + position=(h, label_height), + maxwidth=b_width * 0.7, + text='Hard', + scale=1.3, + h_align='center', + v_align='center') + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + transition_delay=tdelay, + size=(img_width, 0.5 * img_width), + position=(h - img_width * 0.5, img_v), + texture=ba.gettexture('courtyardPreview'), + model_opaque=model_opaque, + model_transparent=model_transparent, + mask_texture=mask_tex) + if not ba.app.did_menu_intro: + ba.app.did_menu_intro = True + + self._b4: Optional[ba.Widget] + self._b5: Optional[ba.Widget] + self._b6: Optional[ba.Widget] + + # noinspection PyUnreachableCode + if False: # pylint: disable=using-constant-test + ba.textwidget( + parent=self._root_widget, + size=(0, 0), + position=(self._width * 0.5, self._height + y_extra - 44), + transition_delay=tdelay, + text=ba.Lstr(resource=self._r + '.versusExamplesText'), + flatness=1.0, + scale=1.2, + h_align='center', + v_align='center', + shadow=1.0) + h = self._width * 0.5 - b_space + tdelay = t_delay_base + t_delay_scale * 0.7 + self._b4 = btn = ba.buttonwidget(parent=self._root_widget, + autoselect=True, + size=(b_width, b_height), + on_activate_call=ba.Call( + self._do_game, 'ctf'), + transition_delay=tdelay, + position=(h - b_width * 0.5, b_v), + label='', + button_type='square') + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + transition_delay=tdelay, + size=(0, 0), + position=(h, label_height), + maxwidth=b_width * 0.7, + text=ba.Lstr(translate=('gameNames', + 'Capture the Flag')), + scale=1.3, + h_align='center', + v_align='center') + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + size=(img_width, 0.5 * img_width), + transition_delay=tdelay, + position=(h - img_width * 0.5, img_v), + texture=ba.gettexture('bridgitPreview'), + model_opaque=model_opaque, + model_transparent=model_transparent, + mask_texture=mask_tex) + + h = self._width * 0.5 + tdelay = t_delay_base + t_delay_scale * 0.65 + self._b5 = btn = ba.buttonwidget(parent=self._root_widget, + autoselect=True, + size=(b_width, b_height), + on_activate_call=ba.Call( + self._do_game, 'hockey'), + position=(h - b_width * 0.5, b_v), + label='', + button_type='square', + transition_delay=tdelay) + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + transition_delay=tdelay, + size=(0, 0), + position=(h, label_height), + maxwidth=b_width * 0.7, + text=ba.Lstr(translate=('gameNames', 'Hockey')), + scale=1.3, + h_align='center', + v_align='center') + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + size=(img_width, 0.5 * img_width), + transition_delay=tdelay, + position=(h - img_width * 0.5, img_v), + texture=ba.gettexture('hockeyStadiumPreview'), + model_opaque=model_opaque, + model_transparent=model_transparent, + mask_texture=mask_tex) + h = self._width * 0.5 + b_space + tdelay = t_delay_base + t_delay_scale * 0.6 + self._b6 = btn = ba.buttonwidget(parent=self._root_widget, + autoselect=True, + size=(b_width, b_height), + on_activate_call=ba.Call( + self._do_game, 'epic'), + transition_delay=tdelay, + position=(h - b_width * 0.5, b_v), + label='', + button_type='square') + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + transition_delay=tdelay, + size=(0, 0), + position=(h, label_height), + maxwidth=b_width * 0.7, + text=ba.Lstr(resource=self._r + '.epicModeText'), + scale=1.3, + h_align='center', + v_align='center') + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + transition_delay=tdelay, + size=(img_width, 0.5 * img_width), + position=(h - img_width * 0.5, img_v), + texture=ba.gettexture('tipTopPreview'), + model_opaque=model_opaque, + model_transparent=model_transparent, + mask_texture=mask_tex) + else: + self._b4 = self._b5 = self._b6 = None + + self._b7: Optional[ba.Widget] + # noinspection PyUnreachableCode + if False: # pylint: disable=using-constant-test + self._b7 = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + size=(b_width, 50), + color=(0.45, 0.55, 0.45), + textcolor=(0.7, 0.8, 0.7), + scale=0.5, + position=((self._width * 0.5 - 37.5, + y_extra + 120) if not self._show_multiplayer else + (self._width + 100, + y_extra + (140 if ba.app.small_ui else 120))), + transition_delay=tdelay, + label=ba.Lstr(resource=self._r + '.fullMenuText'), + on_activate_call=self._do_full_menu) + else: + self._b7 = None + self._restore_state() + self._update() + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + + def _restore_state(self) -> None: + try: + sel_name = ba.app.window_states[self.__class__.__name__] + except Exception: + sel_name = None + sel: Optional[ba.Widget] + if sel_name == 'b1': + sel = self._b1 + elif sel_name == 'b2': + sel = self._b2 + elif sel_name == 'b3': + sel = self._b3 + elif sel_name == 'b4': + sel = self._b4 + elif sel_name == 'b5': + sel = self._b5 + elif sel_name == 'b6': + sel = self._b6 + elif sel_name == 'b7': + sel = self._b7 + else: + sel = self._b1 + if sel: + ba.containerwidget(edit=self._root_widget, selected_child=sel) + + def _save_state(self) -> None: + sel = self._root_widget.get_selected_child() + if sel == self._b1: + sel_name = 'b1' + elif sel == self._b2: + sel_name = 'b2' + elif sel == self._b3: + sel_name = 'b3' + elif sel == self._b4: + sel_name = 'b4' + elif sel == self._b5: + sel_name = 'b5' + elif sel == self._b6: + sel_name = 'b6' + elif sel == self._b7: + sel_name = 'b7' + else: + sel_name = 'b1' + ba.app.window_states[self.__class__.__name__] = sel_name + + def _update(self) -> None: + # Kiosk-mode is designed to be used signed-out... try for force + # the issue. + if _ba.get_account_state() == 'signed_in': + # _bs.sign_out() + # FIXME: Try to delete player profiles here too. + pass + else: + # Also make sure there's no player profiles. + bs_config = ba.app.config + bs_config['Player Profiles'] = {} + + def _do_game(self, mode: str) -> None: + self._save_state() + if mode in ['epic', 'ctf', 'hockey']: + bs_config = ba.app.config + if 'Team Tournament Playlists' not in bs_config: + bs_config['Team Tournament Playlists'] = {} + if 'Free-for-All Playlists' not in bs_config: + bs_config['Free-for-All Playlists'] = {} + bs_config['Show Tutorial'] = False + if mode == 'epic': + bs_config['Free-for-All Playlists']['Just Epic Elim'] = [{ + 'settings': { + 'Epic Mode': 1, + 'Lives Per Player': 1, + 'Respawn Times': 1.0, + 'Time Limit': 0, + 'map': 'Tip Top' + }, + 'type': 'bs_elimination.EliminationGame' + }] + bs_config['Free-for-All Playlist Selection'] = 'Just Epic Elim' + _ba.fade_screen(False, + endcall=ba.Call( + ba.pushcall, + ba.Call(_ba.new_host_session, + ba.FreeForAllSession))) + else: + if mode == 'ctf': + bs_config['Team Tournament Playlists']['Just CTF'] = [{ + 'settings': { + 'Epic Mode': False, + 'Flag Idle Return Time': 30, + 'Flag Touch Return Time': 0, + 'Respawn Times': 1.0, + 'Score to Win': 3, + 'Time Limit': 0, + 'map': 'Bridgit' + }, + 'type': 'bs_capture_the_flag.CTFGame' + }] + bs_config[ + 'Team Tournament Playlist Selection'] = 'Just CTF' + else: + bs_config['Team Tournament Playlists']['Just Hockey'] = [{ + 'settings': { + 'Respawn Times': 1.0, + 'Score to Win': 1, + 'Time Limit': 0, + 'map': 'Hockey Stadium' + }, + 'type': 'bs_hockey.HockeyGame' + }] + bs_config['Team Tournament Playlist Selection'] = \ + 'Just Hockey' + _ba.fade_screen(False, + endcall=ba.Call( + ba.pushcall, + ba.Call(_ba.new_host_session, + ba.TeamsSession))) + ba.containerwidget(edit=self._root_widget, transition='out_left') + return + + game = ('Easy:Onslaught Training' + if mode == 'easy' else 'Easy:Rookie Football' + if mode == 'medium' else 'Easy:Uber Onslaught') + cfg = ba.app.config + cfg['Selected Coop Game'] = game + cfg.commit() + if ba.app.launch_coop_game(game, force=True): + ba.containerwidget(edit=self._root_widget, transition='out_left') + + def _do_full_menu(self) -> None: + from bastd.ui.mainmenu import MainMenuWindow + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.did_menu_intro = True # prevent delayed transition-in + ba.app.main_menu_window = (MainMenuWindow().get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/league/__init__.py b/assets/src/data/scripts/bastd/ui/league/__init__.py new file mode 100644 index 00000000..6be260bc --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/league/__init__.py @@ -0,0 +1 @@ +"""League related UI functionality.""" diff --git a/assets/src/data/scripts/bastd/ui/league/rankbutton.py b/assets/src/data/scripts/bastd/ui/league/rankbutton.py new file mode 100644 index 00000000..7fb4de10 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/league/rankbutton.py @@ -0,0 +1,370 @@ +"""Provides a button showing league rank.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Tuple, Optional, Callable, Dict, Union + + +class LeagueRankButton: + """Button showing league rank.""" + + def __init__(self, + parent: ba.Widget, + position: Tuple[float, float], + size: Tuple[float, float], + scale: float, + on_activate_call: Callable[[], Any] = None, + transition_delay: float = None, + color: Tuple[float, float, float] = None, + textcolor: Tuple[float, float, float] = None, + smooth_update_delay: float = None): + from ba.internal import get_cached_league_rank_data + if on_activate_call is None: + on_activate_call = ba.WeakCall(self._default_on_activate_call) + self._on_activate_call = on_activate_call + if smooth_update_delay is None: + smooth_update_delay = 1000 + self._smooth_update_delay = smooth_update_delay + self._size = size + self._scale = scale + if color is None: + color = (0.5, 0.6, 0.5) + if textcolor is None: + textcolor = (1, 1, 1) + self._color = color + self._textcolor = textcolor + self._header_color = (0.8, 0.8, 2.0) + self._parent = parent + self._position: Tuple[float, float] = (0.0, 0.0) + + self._button = ba.buttonwidget(parent=parent, + size=size, + label='', + button_type='square', + scale=scale, + autoselect=True, + on_activate_call=self._on_activate, + transition_delay=transition_delay, + color=color) + + self._title_text = ba.textwidget( + parent=parent, + size=(0, 0), + draw_controller=self._button, + h_align='center', + v_align='center', + maxwidth=size[0] * scale * 0.85, + text=ba.Lstr( + resource='league.leagueRankText', + fallback_resource='coopSelectWindow.powerRankingText'), + color=self._header_color, + flatness=1.0, + shadow=1.0, + scale=scale * 0.5, + transition_delay=transition_delay) + + self._value_text = ba.textwidget(parent=parent, + size=(0, 0), + h_align='center', + v_align='center', + maxwidth=size[0] * scale * 0.85, + text='-', + draw_controller=self._button, + big=True, + scale=scale, + transition_delay=transition_delay, + color=textcolor) + + self._smooth_percent: Optional[float] = None + self._percent: Optional[int] = None + self._smooth_rank: Optional[float] = None + self._rank: Optional[int] = None + self._ticking_node: Optional[ba.Node] = None + self._smooth_increase_speed = 1.0 + self._league: Optional[str] = None + self._improvement_text: Optional[str] = None + + self._smooth_update_timer: Optional[ba.Timer] = None + + # take note of our account state; we'll refresh later if this changes + self._account_state_num = _ba.get_account_state_num() + self._last_power_ranking_query_time: Optional[float] = None + self._doing_power_ranking_query = False + self.set_position(position) + self._bg_flash = False + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + self._update() + + # if we've got cached power-ranking data already, apply it.. + data = get_cached_league_rank_data() + if data is not None: + self._update_for_league_rank_data(data) + + def _on_activate(self) -> None: + _ba.increment_analytics_count('League rank button press') + self._on_activate_call() + + def __del__(self) -> None: + if self._ticking_node is not None: + self._ticking_node.delete() + + def _start_smooth_update(self) -> None: + self._smooth_update_timer = ba.Timer(0.05, + ba.WeakCall(self._smooth_update), + repeat=True, + timetype=ba.TimeType.REAL) + + def _smooth_update(self) -> None: + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + try: + if not self._button: + return + if self._ticking_node is None: + with ba.Context('ui'): + self._ticking_node = ba.newnode( + 'sound', + attrs={ + 'sound': ba.getsound('scoreIncrease'), + 'positional': False + }) + self._bg_flash = (not self._bg_flash) + color_used = ((self._color[0] * 2, self._color[1] * 2, + self._color[2] * + 2) if self._bg_flash else self._color) + textcolor_used = ((1, 1, 1) if self._bg_flash else self._textcolor) + header_color_used = ((1, 1, + 1) if self._bg_flash else self._header_color) + + if self._rank is not None: + assert self._smooth_rank is not None + self._smooth_rank -= 1.0 * self._smooth_increase_speed + finished = (int(self._smooth_rank) <= self._rank) + elif self._smooth_percent is not None: + self._smooth_percent += 1.0 * self._smooth_increase_speed + assert self._percent is not None + finished = (int(self._smooth_percent) >= self._percent) + else: + finished = True + if finished: + if self._rank is not None: + self._smooth_rank = float(self._rank) + elif self._percent is not None: + self._smooth_percent = float(self._percent) + color_used = self._color + textcolor_used = self._textcolor + self._smooth_update_timer = None + if self._ticking_node is not None: + self._ticking_node.delete() + self._ticking_node = None + ba.playsound(ba.getsound('cashRegister2')) + assert self._improvement_text is not None + diff_text = ba.textwidget( + parent=self._parent, + size=(0, 0), + h_align='center', + v_align='center', + text='+' + self._improvement_text + "!", + position=(self._position[0] + + self._size[0] * 0.5 * self._scale, + self._position[1] + + self._size[1] * -0.2 * self._scale), + color=(0, 1, 0), + flatness=1.0, + shadow=0.0, + scale=self._scale * 0.7) + + def safe_delete(widget: ba.Widget) -> None: + if widget: + widget.delete() + + ba.timer(2.0, ba.Call(safe_delete, diff_text, timetype='real')) + status_text: Union[str, ba.Lstr] + if self._rank is not None: + assert self._smooth_rank is not None + status_text = ba.Lstr(resource='numberText', + subs=[('${NUMBER}', + str(int(self._smooth_rank)))]) + elif self._smooth_percent is not None: + status_text = str(int(self._smooth_percent)) + '%' + else: + status_text = '-' + ba.textwidget(edit=self._value_text, + text=status_text, + color=textcolor_used) + ba.textwidget(edit=self._title_text, color=header_color_used) + ba.buttonwidget(edit=self._button, color=color_used) + + except Exception: + ba.print_exception('error doing smooth update') + self._smooth_update_timer = None + + def _update_for_league_rank_data(self, + data: Optional[Dict[str, Any]]) -> None: + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + from ba.internal import get_league_rank_points + + # If our button has died, ignore. + if not self._button: + return + + status_text: Union[str, ba.Lstr] + + in_top = data is not None and data['rank'] is not None + do_percent = False + if data is None or _ba.get_account_state() != 'signed_in': + self._percent = self._rank = None + status_text = '-' + elif in_top: + self._percent = None + self._rank = data['rank'] + prev_league = self._league + self._league = data['l'] + + # If this is the first set, league has changed, or rank has gotten + # worse, snap the smooth value immediately. + assert self._rank is not None + if (self._smooth_rank is None or prev_league != self._league + or self._rank > int(self._smooth_rank)): + self._smooth_rank = float(self._rank) + status_text = ba.Lstr(resource='numberText', + subs=[('${NUMBER}', + str(int(self._smooth_rank)))]) + else: + try: + if not data['scores'] or data['scores'][-1][1] <= 0: + self._percent = self._rank = None + status_text = '-' + else: + our_points = get_league_rank_points(data) + progress = float(our_points) / data['scores'][-1][1] + self._percent = int(progress * 100.0) + self._rank = None + do_percent = True + prev_league = self._league + self._league = data['l'] + + # If this is the first set, league has changed, or percent + # has decreased, snap the smooth value immediately. + if (self._smooth_percent is None + or prev_league != self._league + or self._percent < int(self._smooth_percent)): + self._smooth_percent = float(self._percent) + status_text = str(int(self._smooth_percent)) + '%' + + except Exception: + ba.print_exception('error updating power ranking') + self._percent = self._rank = None + status_text = '-' + + # If we're doing a smooth update, set a timer. + if (self._rank is not None and self._smooth_rank is not None + and int(self._smooth_rank) != self._rank): + self._improvement_text = str(-(int(self._rank) - + int(self._smooth_rank))) + diff = abs(self._rank - self._smooth_rank) + if diff > 100: + self._smooth_increase_speed = diff / 80.0 + elif diff > 50: + self._smooth_increase_speed = diff / 70.0 + elif diff > 25: + self._smooth_increase_speed = diff / 55.0 + else: + self._smooth_increase_speed = diff / 40.0 + self._smooth_increase_speed = max(0.4, self._smooth_increase_speed) + ba.timer(self._smooth_update_delay, + ba.WeakCall(self._start_smooth_update), + timetype=ba.TimeType.REAL, + timeformat=ba.TimeFormat.MILLISECONDS) + + assert self._smooth_percent is not None + if (self._percent is not None + and int(self._smooth_percent) != self._percent): + self._improvement_text = str( + (int(self._percent) - int(self._smooth_percent))) + self._smooth_increase_speed = 0.3 + ba.timer(self._smooth_update_delay, + ba.WeakCall(self._start_smooth_update), + timetype=ba.TimeType.REAL, + timeformat=ba.TimeFormat.MILLISECONDS) + + if do_percent: + ba.textwidget( + edit=self._title_text, + text=ba.Lstr(resource='coopSelectWindow.toRankedText')) + else: + try: + assert data is not None + txt = ba.Lstr(resource='league.leagueFullText', + subs=[('${NAME}', + ba.Lstr(translate=('leagueNames', + data['l']['n'])))]) + t_color = data['l']['c'] + except Exception: + txt = ba.Lstr( + resource='league.leagueRankText', + fallback_resource='coopSelectWindow.powerRankingText') + t_color = ba.app.title_color + ba.textwidget(edit=self._title_text, text=txt, color=t_color) + ba.textwidget(edit=self._value_text, text=status_text) + + def _on_power_ranking_query_response(self, data: Optional[Dict[str, Any]] + ) -> None: + from ba.internal import cache_league_rank_data + self._doing_power_ranking_query = False + cache_league_rank_data(data) + self._update_for_league_rank_data(data) + + def _update(self) -> None: + cur_time = ba.time(ba.TimeType.REAL) + + # If our account state has changed, refresh our UI. + account_state_num = _ba.get_account_state_num() + if account_state_num != self._account_state_num: + self._account_state_num = account_state_num + # and power ranking too... + if not self._doing_power_ranking_query: + self._last_power_ranking_query_time = None + + # Send off a new power-ranking query if its been + # long enough or whatnot. + if not self._doing_power_ranking_query and ( + self._last_power_ranking_query_time is None + or cur_time - self._last_power_ranking_query_time > 30.0): + self._last_power_ranking_query_time = cur_time + self._doing_power_ranking_query = True + _ba.power_ranking_query( + callback=ba.WeakCall(self._on_power_ranking_query_response)) + + def _default_on_activate_call(self) -> None: + from bastd.ui.league import rankwindow + rankwindow.LeagueRankWindow(modal=True, origin_widget=self._button) + + def set_position(self, position: Tuple[float, float]) -> None: + """Set the button's position.""" + self._position = position + if not self._button: + return + ba.buttonwidget(edit=self._button, position=self._position) + ba.textwidget( + edit=self._title_text, + position=(self._position[0] + self._size[0] * 0.5 * self._scale, + self._position[1] + self._size[1] * 0.82 * self._scale)) + ba.textwidget( + edit=self._value_text, + position=(self._position[0] + self._size[0] * 0.5 * self._scale, + self._position[1] + self._size[1] * 0.36 * self._scale)) + + def get_button(self) -> ba.Widget: + """Return the underlying button ba.Widget>""" + return self._button diff --git a/assets/src/data/scripts/bastd/ui/league/rankwindow.py b/assets/src/data/scripts/bastd/ui/league/rankwindow.py new file mode 100644 index 00000000..69a43d02 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/league/rankwindow.py @@ -0,0 +1,918 @@ +"""UI related to league rank.""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.ui import popup as popup_ui + +if TYPE_CHECKING: + from typing import Any, Optional, Tuple, List, Dict, Union + + +class LeagueRankWindow(ba.OldWindow): + """Window for showing league rank.""" + + def __init__(self, + transition: str = 'in_right', + modal: bool = False, + origin_widget: ba.Widget = None): + from ba.internal import get_cached_league_rank_data + from ba.deprecated import get_resource + ba.set_analytics_screen('League Rank Window') + + self._league_rank_data: Optional[Dict[str, Any]] = None + self._modal = modal + + # If they provided an origin-widget, scale up from that. + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self._width = 1320 if ba.app.small_ui else 1120 + x_inset = 100 if ba.app.small_ui else 0 + self._height = (657 + if ba.app.small_ui else 710 if ba.app.med_ui else 800) + self._r = 'coopSelectWindow' + self._rdict = get_resource(self._r) + top_extra = 20 if ba.app.small_ui else 0 + + self._league_url_arg = '' + + self._is_current_season = False + self._can_do_more_button = True + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + top_extra), + stack_offset=(0, -15) if ba.app.small_ui else ( + 0, 10) if ba.app.med_ui else (0, 0), + transition=transition, + scale_origin_stack_offset=scale_origin, + scale=( + 1.2 if ba.app.small_ui else 0.93 if ba.app.med_ui else 0.8))) + + self._back_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(75 + x_inset, + self._height - 87 - (4 if ba.app.small_ui else 0)), + size=(120, 60), + scale=1.2, + autoselect=True, + label=ba.Lstr(resource='doneText' if self._modal else 'backText'), + button_type=None if self._modal else 'back', + on_activate_call=self._back) + + self._title_text = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height - 56), + size=(0, 0), + text=ba.Lstr( + resource='league.leagueRankText', + fallback_resource='coopSelectWindow.powerRankingText'), + h_align="center", + color=ba.app.title_color, + scale=1.4, + maxwidth=600, + v_align="center") + + ba.buttonwidget(edit=btn, + button_type='backSmall', + position=(75 + x_inset, self._height - 87 - + (2 if ba.app.small_ui else 0)), + size=(60, 55), + label=ba.charstr(ba.SpecialChar.BACK)) + + self._scroll_width = self._width - (130 + 2 * x_inset) + self._scroll_height = self._height - 160 + self._scrollwidget = ba.scrollwidget(parent=self._root_widget, + highlight=False, + position=(65 + x_inset, 70), + size=(self._scroll_width, + self._scroll_height), + center_small_content=True) + ba.widget(edit=self._scrollwidget, autoselect=True) + ba.containerwidget(edit=self._scrollwidget, claims_left_right=True) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._back_button, + selected_child=self._back_button) + + self._last_power_ranking_query_time: Optional[float] = None + self._doing_power_ranking_query = False + + self._subcontainer: Optional[ba.Widget] = None + self._subcontainerwidth = 800 + self._subcontainerheight = 483 + self._power_ranking_score_widgets: List[ba.Widget] = [] + + self._season_popup_menu: Optional[popup_ui.PopupMenu] = None + self._requested_season: Optional[str] = None + self._season = None + + # take note of our account state; we'll refresh later if this changes + self._account_state = _ba.get_account_state() + + self._refresh() + self._restore_state() + + # if we've got cached power-ranking data already, display it + info = get_cached_league_rank_data() + if info is not None: + self._update_for_league_rank_data(info) + + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + self._update(show=(info is None)) + + def _on_achievements_press(self) -> None: + from bastd.ui import achievements + # only allow this for all-time or the current season + # (we currently don't keep specific achievement data for old seasons) + if self._season == 'a' or self._is_current_season: + achievements.AchievementsWindow( + position=(self._power_ranking_achievements_button. + get_screen_space_center())) + else: + ba.screenmessage(ba.Lstr( + resource='achievementsUnavailableForOldSeasonsText', + fallback_resource='unavailableText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + + def _on_activity_mult_press(self) -> None: + from bastd.ui import confirm + txt = ba.Lstr( + resource='coopSelectWindow.activenessAllTimeInfoText' + if self._season == 'a' else 'coopSelectWindow.activenessInfoText', + subs=[('${MAX}', + str(_ba.get_account_misc_read_val('activenessMax', 1.0)))]) + confirm.ConfirmWindow(txt, + cancel_button=False, + width=460, + height=150, + origin_widget=self._activity_mult_button) + + def _on_pro_mult_press(self) -> None: + from bastd.ui import confirm + txt = ba.Lstr( + resource='coopSelectWindow.proMultInfoText', + subs=[ + ('${PERCENT}', + str(_ba.get_account_misc_read_val('proPowerRankingBoost', + 10))), + ('${PRO}', + ba.Lstr(resource='store.bombSquadProNameText', + subs=[('${APP_NAME}', ba.Lstr(resource='titleText')) + ])) + ]) + confirm.ConfirmWindow(txt, + cancel_button=False, + width=460, + height=130, + origin_widget=self._pro_mult_button) + + def _on_trophies_press(self) -> None: + from bastd.ui.trophies import TrophiesWindow + info = self._league_rank_data + if info is not None: + TrophiesWindow(position=self._power_ranking_trophies_button. + get_screen_space_center(), + data=info) + else: + ba.playsound(ba.getsound('error')) + + def _on_power_ranking_query_response(self, data: Optional[Dict[str, Any]] + ) -> None: + from ba.internal import cache_league_rank_data + self._doing_power_ranking_query = False + # important: *only* cache this if we requested the current season.. + if data is not None and data.get('s', None) is None: + cache_league_rank_data(data) + # always store a copy locally though (even for other seasons) + self._league_rank_data = copy.deepcopy(data) + self._update_for_league_rank_data(data) + + def _restore_state(self) -> None: + pass + + def _update(self, show: bool = False) -> None: + + cur_time = ba.time(ba.TimeType.REAL) + + # if our account state has changed, refresh our UI + account_state = _ba.get_account_state() + if account_state != self._account_state: + self._account_state = account_state + self._save_state() + self._refresh() + + # and power ranking too... + if not self._doing_power_ranking_query: + self._last_power_ranking_query_time = None + + # send off a new power-ranking query if its been long enough or our + # requested season has changed or whatnot.. + if not self._doing_power_ranking_query and ( + self._last_power_ranking_query_time is None + or cur_time - self._last_power_ranking_query_time > 30.0): + try: + if show: + ba.textwidget(edit=self._league_title_text, text='') + ba.textwidget(edit=self._league_text, text='') + ba.textwidget(edit=self._league_number_text, text='') + ba.textwidget( + edit=self._your_power_ranking_text, + text=ba.Lstr(value='${A}...', + subs=[('${A}', + ba.Lstr(resource='loadingText'))])) + ba.textwidget(edit=self._to_ranked_text, text='') + ba.textwidget(edit=self._power_ranking_rank_text, text='') + ba.textwidget(edit=self._season_ends_text, text='') + ba.textwidget(edit=self._trophy_counts_reset_text, text='') + except Exception: + ba.print_exception('error showing updated rank info') + + self._last_power_ranking_query_time = cur_time + self._doing_power_ranking_query = True + _ba.power_ranking_query(season=self._requested_season, + callback=ba.WeakCall( + self._on_power_ranking_query_response)) + + def _refresh(self) -> None: + # pylint: disable=too-many-statements + + # (re)create the sub-container if need be.. + if self._subcontainer is not None: + self._subcontainer.delete() + self._subcontainer = ba.containerwidget( + parent=self._scrollwidget, + size=(self._subcontainerwidth, self._subcontainerheight), + background=False) + + w_parent = self._subcontainer + v = self._subcontainerheight - 20 + + v -= 0 + + h2 = 80 + v2 = v - 60 + worth_color = (0.6, 0.6, 0.65) + tally_color = (0.5, 0.6, 0.8) + spc = 43 + + h_offs_tally = 150 + tally_maxwidth = 120 + v2 -= 70 + + ba.textwidget(parent=w_parent, + position=(h2 - 60, v2 + 106), + size=(0, 0), + flatness=1.0, + shadow=0.0, + text=ba.Lstr(resource='coopSelectWindow.pointsText'), + h_align='left', + v_align='center', + scale=0.8, + color=(1, 1, 1, 0.3), + maxwidth=200) + + self._power_ranking_achievements_button = ba.buttonwidget( + parent=w_parent, + position=(h2 - 60, v2 + 10), + size=(200, 80), + icon=ba.gettexture('achievementsIcon'), + autoselect=True, + on_activate_call=ba.WeakCall(self._on_achievements_press), + up_widget=self._back_button, + left_widget=self._back_button, + color=(0.5, 0.5, 0.6), + textcolor=(0.7, 0.7, 0.8), + label='') + + self._power_ranking_achievement_total_text = ba.textwidget( + parent=w_parent, + position=(h2 + h_offs_tally, v2 + 45), + size=(0, 0), + flatness=1.0, + shadow=0.0, + text='-', + h_align='left', + v_align='center', + scale=0.8, + color=tally_color, + maxwidth=tally_maxwidth) + + v2 -= 80 + + self._power_ranking_trophies_button = ba.buttonwidget( + parent=w_parent, + position=(h2 - 60, v2 + 10), + size=(200, 80), + icon=ba.gettexture('medalSilver'), + autoselect=True, + on_activate_call=ba.WeakCall(self._on_trophies_press), + left_widget=self._back_button, + color=(0.5, 0.5, 0.6), + textcolor=(0.7, 0.7, 0.8), + label='') + self._power_ranking_trophies_total_text = ba.textwidget( + parent=w_parent, + position=(h2 + h_offs_tally, v2 + 45), + size=(0, 0), + flatness=1.0, + shadow=0.0, + text='-', + h_align='left', + v_align='center', + scale=0.8, + color=tally_color, + maxwidth=tally_maxwidth) + + v2 -= 100 + + ba.textwidget( + parent=w_parent, + position=(h2 - 60, v2 + 86), + size=(0, 0), + flatness=1.0, + shadow=0.0, + text=ba.Lstr(resource='coopSelectWindow.multipliersText'), + h_align='left', + v_align='center', + scale=0.8, + color=(1, 1, 1, 0.3), + maxwidth=200) + + self._activity_mult_button: Optional[ba.Widget] + if _ba.get_account_misc_read_val('act', False): + self._activity_mult_button = ba.buttonwidget( + parent=w_parent, + position=(h2 - 60, v2 + 10), + size=(200, 60), + icon=ba.gettexture('heart'), + icon_color=(0.5, 0, 0.5), + label=ba.Lstr(resource='coopSelectWindow.activityText'), + autoselect=True, + on_activate_call=ba.WeakCall(self._on_activity_mult_press), + left_widget=self._back_button, + color=(0.5, 0.5, 0.6), + textcolor=(0.7, 0.7, 0.8)) + + self._activity_mult_text = ba.textwidget( + parent=w_parent, + position=(h2 + h_offs_tally, v2 + 40), + size=(0, 0), + flatness=1.0, + shadow=0.0, + text='-', + h_align='left', + v_align='center', + scale=0.8, + color=tally_color, + maxwidth=tally_maxwidth) + v2 -= 65 + else: + self._activity_mult_button = None + + self._pro_mult_button = ba.buttonwidget( + parent=w_parent, + position=(h2 - 60, v2 + 10), + size=(200, 60), + icon=ba.gettexture('logo'), + icon_color=(0.3, 0, 0.3), + label=ba.Lstr(resource='store.bombSquadProNameText', + subs=[('${APP_NAME}', ba.Lstr(resource='titleText')) + ]), + autoselect=True, + on_activate_call=ba.WeakCall(self._on_pro_mult_press), + left_widget=self._back_button, + color=(0.5, 0.5, 0.6), + textcolor=(0.7, 0.7, 0.8)) + + self._pro_mult_text = ba.textwidget(parent=w_parent, + position=(h2 + h_offs_tally, + v2 + 40), + size=(0, 0), + flatness=1.0, + shadow=0.0, + text='-', + h_align='left', + v_align='center', + scale=0.8, + color=tally_color, + maxwidth=tally_maxwidth) + v2 -= 30 + + v2 -= spc + ba.textwidget(parent=w_parent, + position=(h2 + h_offs_tally - 10 - 40, v2 + 35), + size=(0, 0), + flatness=1.0, + shadow=0.0, + text=ba.Lstr(resource='finalScoreText'), + h_align='right', + v_align='center', + scale=0.9, + color=worth_color, + maxwidth=150) + self._power_ranking_total_text = ba.textwidget( + parent=w_parent, + position=(h2 + h_offs_tally - 40, v2 + 35), + size=(0, 0), + flatness=1.0, + shadow=0.0, + text='-', + h_align='left', + v_align='center', + scale=0.9, + color=tally_color, + maxwidth=tally_maxwidth) + + self._season_show_text = ba.textwidget( + parent=w_parent, + position=(390 - 15, v - 20), + size=(0, 0), + color=(0.6, 0.6, 0.7), + maxwidth=200, + text=ba.Lstr(resource='showText'), + h_align='right', + v_align='center', + scale=0.8, + shadow=0, + flatness=1.0) + + self._league_title_text = ba.textwidget(parent=w_parent, + position=(470, v - 97), + size=(0, 0), + color=(0.6, 0.6, 0.7), + maxwidth=230, + text='', + h_align='center', + v_align='center', + scale=0.9, + shadow=0, + flatness=1.0) + + self._league_text_scale = 1.8 + self._league_text_maxwidth = 210 + self._league_text = ba.textwidget(parent=w_parent, + position=(470, v - 140), + size=(0, 0), + color=(1, 1, 1), + maxwidth=self._league_text_maxwidth, + text='-', + h_align='center', + v_align='center', + scale=self._league_text_scale, + shadow=1.0, + flatness=1.0) + self._league_number_base_pos = (470, v - 140) + self._league_number_text = ba.textwidget(parent=w_parent, + position=(470, v - 140), + size=(0, 0), + color=(1, 1, 1), + maxwidth=100, + text='', + h_align='left', + v_align='center', + scale=0.8, + shadow=1.0, + flatness=1.0) + + self._your_power_ranking_text = ba.textwidget(parent=w_parent, + position=(470, + v - 142 - 70), + size=(0, 0), + color=(0.6, 0.6, 0.7), + maxwidth=230, + text='', + h_align='center', + v_align='center', + scale=0.9, + shadow=0, + flatness=1.0) + + self._to_ranked_text = ba.textwidget(parent=w_parent, + position=(470, v - 250 - 70), + size=(0, 0), + color=(0.6, 0.6, 0.7), + maxwidth=230, + text='', + h_align='center', + v_align='center', + scale=0.8, + shadow=0, + flatness=1.0) + + self._power_ranking_rank_text = ba.textwidget(parent=w_parent, + position=(473, + v - 210 - 70), + size=(0, 0), + big=False, + text='-', + h_align='center', + v_align='center', + scale=1.0) + + self._season_ends_text = ba.textwidget(parent=w_parent, + position=(470, v - 380), + size=(0, 0), + color=(0.6, 0.6, 0.6), + maxwidth=230, + text='', + h_align='center', + v_align='center', + scale=0.9, + shadow=0, + flatness=1.0) + self._trophy_counts_reset_text = ba.textwidget( + parent=w_parent, + position=(470, v - 410), + size=(0, 0), + color=(0.5, 0.5, 0.5), + maxwidth=230, + text='Trophy counts will reset next season.', + h_align='center', + v_align='center', + scale=0.8, + shadow=0, + flatness=1.0) + + self._power_ranking_score_widgets = [] + + self._power_ranking_score_v = v - 56 + + h = 707 + v -= 451 + + self._see_more_button = ba.buttonwidget(parent=w_parent, + label=self._rdict.seeMoreText, + position=(h, v), + color=(0.5, 0.5, 0.6), + textcolor=(0.7, 0.7, 0.8), + size=(230, 60), + autoselect=True, + on_activate_call=ba.WeakCall( + self._on_more_press)) + + def _on_more_press(self) -> None: + our_login_id = _ba.get_public_login_id() + # our_login_id = _bs.get_account_misc_read_val_2( + # 'resolvedAccountID', None) + if not self._can_do_more_button or our_login_id is None: + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource='unavailableText'), + color=(1, 0, 0)) + return + if self._season is None: + season_str = '' + else: + season_str = ( + '&season=' + + ('all_time' if self._season == 'a' else self._season)) + if self._league_url_arg != '': + league_str = '&league=' + self._league_url_arg + else: + league_str = '' + ba.open_url(_ba.get_master_server_address() + + '/highscores?list=powerRankings&v=2' + league_str + + season_str + '&player=' + our_login_id) + + def _update_for_league_rank_data(self, + data: Optional[Dict[str, Any]]) -> None: + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + from ba.internal import get_league_rank_points + if not self._root_widget: + return + in_top = (data is not None and data['rank'] is not None) + eq_text = self._rdict.powerRankingPointsEqualsText + pts_txt = self._rdict.powerRankingPointsText + num_text = ba.Lstr(resource='numberText').evaluate() + do_percent = False + finished_season_unranked = False + self._can_do_more_button = True + extra_text = '' + if _ba.get_account_state() != 'signed_in': + status_text = '(' + ba.Lstr( + resource='notSignedInText').evaluate() + ')' + elif in_top: + assert data is not None + status_text = num_text.replace('${NUMBER}', str(data['rank'])) + elif data is not None: + try: + # handle old seasons where we didn't wind up ranked + # at the end.. + if not data['scores']: + status_text = ( + self._rdict.powerRankingFinishedSeasonUnrankedText) + extra_text = '' + finished_season_unranked = True + self._can_do_more_button = False + else: + our_points = get_league_rank_points(data) + progress = float(our_points) / max(1, + data['scores'][-1][1]) + status_text = str(int(progress * 100.0)) + '%' + extra_text = ( + '\n' + + self._rdict.powerRankingPointsToRankedText.replace( + '${CURRENT}', str(our_points)).replace( + '${REMAINING}', str(data['scores'][-1][1]))) + do_percent = True + except Exception: + ba.print_exception('error updating power ranking') + status_text = self._rdict.powerRankingNotInTopText.replace( + '${NUMBER}', str(data['listSize'])) + extra_text = '' + else: + status_text = '-' + + self._season = data['s'] if data is not None else None + + v = self._subcontainerheight - 20 + popup_was_selected = False + if self._season_popup_menu is not None: + btn = self._season_popup_menu.get_button() + assert self._subcontainer + if self._subcontainer.get_selected_child() == btn: + popup_was_selected = True + btn.delete() + season_choices = [] + season_choices_display = [] + did_first = False + self._is_current_season = False + if data is not None: + # build our list of seasons we have available + for ssn in data['sl']: + season_choices.append(ssn) + if ssn != 'a' and not did_first: + season_choices_display.append( + ba.Lstr(resource='league.currentSeasonText', + subs=[('${NUMBER}', ssn)])) + did_first = True + # if we either did not specify a season or specified the + # first, we're looking at the current.. + if self._season in [ssn, None]: + self._is_current_season = True + elif ssn == 'a': + season_choices_display.append( + ba.Lstr(resource='league.allTimeText')) + else: + season_choices_display.append( + ba.Lstr(resource='league.seasonText', + subs=[('${NUMBER}', ssn)])) + assert self._subcontainer + self._season_popup_menu = popup_ui.PopupMenu( + parent=self._subcontainer, + position=(390, v - 45), + width=150, + button_size=(200, 50), + choices=season_choices, + on_value_change_call=ba.WeakCall(self._on_season_change), + choices_display=season_choices_display, + current_choice=self._season) + if popup_was_selected: + ba.containerwidget( + edit=self._subcontainer, + selected_child=self._season_popup_menu.get_button()) + ba.widget(edit=self._see_more_button, show_buffer_bottom=100) + ba.widget(edit=self._season_popup_menu.get_button(), + up_widget=self._back_button) + ba.widget(edit=self._back_button, + down_widget=self._power_ranking_achievements_button, + right_widget=self._season_popup_menu.get_button()) + + ba.textwidget(edit=self._league_title_text, + text='' if self._season == 'a' else ba.Lstr( + resource='league.leagueText')) + + if data is None: + lname = '' + lnum = '' + lcolor = (1, 1, 1) + self._league_url_arg = '' + elif self._season == 'a': + lname = ba.Lstr(resource='league.allTimeText').evaluate() + lnum = '' + lcolor = (1, 1, 1) + self._league_url_arg = '' + else: + lnum = ('[' + str(data['l']['i']) + ']') if data['l']['i2'] else '' + lname = ba.Lstr(translate=('leagueNames', + data['l']['n'])).evaluate() + lcolor = data['l']['c'] + self._league_url_arg = (data['l']['n'] + '_' + + str(data['l']['i'])).lower() + + to_end_string: Union[ba.Lstr, str] + if data is None or self._season == 'a' or data['se'] is None: + to_end_string = '' + show_season_end = False + else: + show_season_end = True + days_to_end = data['se'][0] + minutes_to_end = data['se'][1] + if days_to_end > 0: + to_end_string = ba.Lstr(resource='league.seasonEndsDaysText', + subs=[('${NUMBER}', str(days_to_end))]) + elif days_to_end == 0 and minutes_to_end >= 60: + to_end_string = ba.Lstr(resource='league.seasonEndsHoursText', + subs=[('${NUMBER}', + str(minutes_to_end // 60))]) + elif days_to_end == 0 and minutes_to_end >= 0: + to_end_string = ba.Lstr( + resource='league.seasonEndsMinutesText', + subs=[('${NUMBER}', str(minutes_to_end))]) + else: + to_end_string = ba.Lstr( + resource='league.seasonEndedDaysAgoText', + subs=[('${NUMBER}', str(-(days_to_end + 1)))]) + + ba.textwidget(edit=self._season_ends_text, text=to_end_string) + ba.textwidget(edit=self._trophy_counts_reset_text, + text=ba.Lstr(resource='league.trophyCountsResetText') + if self._is_current_season and show_season_end else '') + + ba.textwidget(edit=self._league_text, text=lname, color=lcolor) + l_text_width = min( + self._league_text_maxwidth, + _ba.get_string_width(lname, suppress_warning=True) * + self._league_text_scale) + ba.textwidget( + edit=self._league_number_text, + text=lnum, + color=lcolor, + position=(self._league_number_base_pos[0] + l_text_width * 0.5 + 8, + self._league_number_base_pos[1] + 10)) + ba.textwidget( + edit=self._to_ranked_text, + text=ba.Lstr(resource='coopSelectWindow.toRankedText').evaluate() + + '' + extra_text if do_percent else '') + + ba.textwidget( + edit=self._your_power_ranking_text, + text=ba.Lstr( + resource='rankText', + fallback_resource='coopSelectWindow.yourPowerRankingText') if + (not do_percent) else '') + + ba.textwidget(edit=self._power_ranking_rank_text, + position=(473, v - 70 - (170 if do_percent else 220)), + text=status_text, + big=(in_top or do_percent), + scale=3.0 if (in_top or do_percent) else + 0.7 if finished_season_unranked else 1.0) + + if self._activity_mult_button is not None: + if data is None or data['act'] is None: + ba.buttonwidget(edit=self._activity_mult_button, + textcolor=(0.7, 0.7, 0.8, 0.5), + icon_color=(0.5, 0, 0.5, 0.3)) + ba.textwidget(edit=self._activity_mult_text, text=' -') + else: + ba.buttonwidget(edit=self._activity_mult_button, + textcolor=(0.7, 0.7, 0.8, 1.0), + icon_color=(0.5, 0, 0.5, 1.0)) + ba.textwidget(edit=self._activity_mult_text, + text='x ' + ('%.2f' % data['act'])) + + have_pro = False if data is None else data['p'] + pro_mult = 1.0 + float( + _ba.get_account_misc_read_val('proPowerRankingBoost', 0.0)) * 0.01 + ba.textwidget(edit=self._pro_mult_text, + text=' -' if + (data is None or not have_pro) else 'x ' + + ('%.2f' % pro_mult)) + ba.buttonwidget(edit=self._pro_mult_button, + textcolor=(0.7, 0.7, 0.8, (1.0 if have_pro else 0.5)), + icon_color=(0.5, 0, 0.5) if have_pro else + (0.5, 0, 0.5, 0.2)) + ba.buttonwidget(edit=self._power_ranking_achievements_button, + label=('' if data is None else + (str(data['a']) + ' ')) + + ba.Lstr(resource='achievementsText').evaluate()) + + # for the achievement value, use the number they gave us for + # non-current seasons; otherwise calc our own + total_ach_value = 0 + for ach in ba.app.achievements: + if ach.complete: + total_ach_value += ach.power_ranking_value + if self._season != 'a' and not self._is_current_season: + if data is not None and 'at' in data: + total_ach_value = data['at'] + + ba.textwidget(edit=self._power_ranking_achievement_total_text, + text='-' if data is None else + ('+ ' + + pts_txt.replace('${NUMBER}', str(total_ach_value)))) + + total_trophies_count = (get_league_rank_points(data, 'trophyCount')) + total_trophies_value = (get_league_rank_points(data, 'trophies')) + ba.buttonwidget(edit=self._power_ranking_trophies_button, + label=('' if data is None else + (str(total_trophies_count) + ' ')) + + ba.Lstr(resource='trophiesText').evaluate()) + ba.textwidget( + edit=self._power_ranking_trophies_total_text, + text='-' if data is None else + ('+ ' + pts_txt.replace('${NUMBER}', str(total_trophies_value)))) + + ba.textwidget(edit=self._power_ranking_total_text, + text='-' if data is None else eq_text.replace( + '${NUMBER}', str(get_league_rank_points(data)))) + for widget in self._power_ranking_score_widgets: + widget.delete() + self._power_ranking_score_widgets = [] + + scores = data['scores'] if data is not None else [] + tally_color = (0.5, 0.6, 0.8) + w_parent = self._subcontainer + v2 = self._power_ranking_score_v + + for score in scores: + h2 = 680 + is_us = score[3] + self._power_ranking_score_widgets.append( + ba.textwidget(parent=w_parent, + position=(h2 - 20, v2), + size=(0, 0), + color=(1, 1, 1) if is_us else (0.6, 0.6, 0.7), + maxwidth=40, + flatness=1.0, + shadow=0.0, + text=num_text.replace('${NUMBER}', + str(score[0])), + h_align='right', + v_align='center', + scale=0.5)) + self._power_ranking_score_widgets.append( + ba.textwidget(parent=w_parent, + position=(h2 + 20, v2), + size=(0, 0), + color=(1, 1, 1) if is_us else tally_color, + maxwidth=60, + text=str(score[1]), + flatness=1.0, + shadow=0.0, + h_align='center', + v_align='center', + scale=0.7)) + txt = ba.textwidget(parent=w_parent, + position=(h2 + 60, v2 - (28 * 0.5) / 0.9), + size=(210 / 0.9, 28), + color=(1, 1, 1) if is_us else (0.6, 0.6, 0.6), + maxwidth=210, + flatness=1.0, + shadow=0.0, + autoselect=True, + selectable=True, + click_activate=True, + text=score[2], + h_align='left', + v_align='center', + scale=0.9) + self._power_ranking_score_widgets.append(txt) + ba.textwidget(edit=txt, + on_activate_call=ba.Call(self._show_account_info, + score[4], txt)) + assert self._season_popup_menu is not None + ba.widget(edit=txt, + left_widget=self._season_popup_menu.get_button()) + v2 -= 28 + + def _show_account_info(self, account_id: str, + textwidget: ba.Widget) -> None: + from bastd.ui.account import viewer + ba.playsound(ba.getsound('swish')) + viewer.AccountViewerWindow( + account_id=account_id, + position=textwidget.get_screen_space_center()) + + def _on_season_change(self, value: str) -> None: + self._requested_season = value + self._last_power_ranking_query_time = None # make sure we update asap + self._update(show=True) + + def _save_state(self) -> None: + pass + + def _back(self) -> None: + from bastd.ui.coop import browser + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + if not self._modal: + ba.app.main_menu_window = (browser.CoopBrowserWindow( + transition='in_left').get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/mainmenu.py b/assets/src/data/scripts/bastd/ui/mainmenu.py new file mode 100644 index 00000000..ce83b679 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/mainmenu.py @@ -0,0 +1,992 @@ +"""Implements the main menu window.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Callable, List, Dict, Tuple, Optional, Union + + +class MainMenuWindow(ba.OldWindow): + """The main menu window, both in-game and in the main menu.""" + + def __init__(self, transition: str = 'in_right'): + from bastd import mainmenu + self._in_game = not isinstance(_ba.get_foreground_host_session(), + mainmenu.MainMenuSession) + if not self._in_game: + ba.set_analytics_screen('Main Menu') + + self._show_remote_app_info_on_first_launch() + + # Make a vanilla container; we'll modify it to our needs in refresh. + super().__init__(root_widget=ba.containerwidget( + transition=transition, + toolbar_visibility='menu_minimal_no_back' if self. + _in_game else 'menu_minimal_no_back')) + + self._is_kiosk = ba.app.kiosk_mode + self._tdelay = 0.0 + self._t_delay_inc = 0.02 + self._t_delay_play = 1.7 + self._p_index = 0 + self._use_autoselect = True + self._button_width = 200.0 + self._button_height = 45.0 + self._width = 100.0 + self._height = 100.0 + self._demo_menu_button: Optional[ba.Widget] = None + self._gather_button: Optional[ba.Widget] = None + self._start_button: Optional[ba.Widget] = None + self._watch_button: Optional[ba.Widget] = None + self._gc_button: Optional[ba.Widget] = None + self._how_to_play_button: Optional[ba.Widget] = None + self._credits_button: Optional[ba.Widget] = None + + self._store_char_tex = self._get_store_char_tex() + + self._refresh() + self._restore_state() + + # Keep an eye on a few things and refresh if they change. + self._account_state = _ba.get_account_state() + self._account_state_num = _ba.get_account_state_num() + self._account_type = (_ba.get_account_type() + if self._account_state == 'signed_in' else None) + self._refresh_timer = ba.Timer(1.0, + ba.WeakCall(self._check_refresh), + repeat=True, + timetype=ba.TimeType.REAL) + + def _show_remote_app_info_on_first_launch(self) -> None: + # The first time the non-in-game menu pops up, we might wanna show + # a 'get-remote-app' dialog in front of it. + if ba.app.first_main_menu: + ba.app.first_main_menu = False + try: + app = ba.app + force_test = False + _ba.get_local_active_input_devices_count() + if (((app.on_tv or app.platform == 'mac') + and ba.app.config.get('launchCount', 0) <= 1) + or force_test): + + def _check_show_bs_remote_window() -> None: + try: + from bastd.ui import getremote + ba.playsound(ba.getsound('swish')) + getremote.GetBSRemoteWindow() + except Exception: + ba.print_exception( + 'error showing ba-remote window') + + ba.timer(2.5, + _check_show_bs_remote_window, + timetype=ba.TimeType.REAL) + except Exception as exc: + print('EXC bs_remote_show', exc) + + def _get_store_char_tex(self) -> str: + return ('storeCharacterXmas' if _ba.get_account_misc_read_val( + 'xmas', False) else + 'storeCharacterEaster' if _ba.get_account_misc_read_val( + 'easter', False) else 'storeCharacter') + + def _check_refresh(self) -> None: + if not self._root_widget: + return + + # Don't refresh for the first few seconds the game is up so we don't + # interrupt the transition in. + ba.app.main_menu_window_refresh_check_count += 1 + if ba.app.main_menu_window_refresh_check_count < 3: + return + + store_char_tex = self._get_store_char_tex() + account_state_num = _ba.get_account_state_num() + if (account_state_num != self._account_state_num + or store_char_tex != self._store_char_tex): + self._store_char_tex = store_char_tex + self._account_state_num = account_state_num + account_state = self._account_state = (_ba.get_account_state()) + self._account_type = (_ba.get_account_type() + if account_state == 'signed_in' else None) + self._save_state() + self._refresh() + self._restore_state() + + def get_play_button(self) -> Optional[ba.Widget]: + """Return the play button.""" + return self._start_button + + def _refresh(self) -> None: + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + from bastd.ui import confirm + from bastd.ui.store.button import StoreButton + + # Clear everything that was there. + children = self._root_widget.get_children() + for child in children: + child.delete() + + self._tdelay = 0.0 + self._t_delay_inc = 0.0 + self._t_delay_play = 0.0 + self._button_width = 200.0 + self._button_height = 45.0 + + self._r = 'mainMenu' + + app = ba.app + self._have_quit_button = (app.interface_type == 'large' + or (app.platform == 'windows' + and app.subplatform == 'oculus')) + + self._have_store_button = not self._in_game + + self._have_settings_button = ((not self._in_game + or not app.toolbar_test) + and not self._is_kiosk) + + self._input_device = input_device = _ba.get_ui_input_device() + self._input_player = input_device.player if input_device else None + self._connected_to_remote_player = ( + input_device.is_connected_to_remote_player() + if input_device else False) + + positions: List[Tuple[float, float, float]] = [] + self._p_index = 0 + + if self._in_game: + h, v, scale = self._refresh_in_game(positions) + else: + h, v, scale = self._refresh_not_in_game(positions) + + if self._have_settings_button: + h, v, scale = positions[self._p_index] + self._p_index += 1 + self._settings_button = ba.buttonwidget( + parent=self._root_widget, + position=(h - self._button_width * 0.5 * scale, v), + size=(self._button_width, self._button_height), + scale=scale, + autoselect=self._use_autoselect, + label=ba.Lstr(resource=self._r + '.settingsText'), + transition_delay=self._tdelay, + on_activate_call=self._settings) + + # Scattered eggs on easter. + if _ba.get_account_misc_read_val('easter', + False) and not self._in_game: + icon_size = 34 + ba.imagewidget(parent=self._root_widget, + position=(h - icon_size * 0.5 - 15, + v + self._button_height * scale - + icon_size * 0.24 + 1.5), + transition_delay=self._tdelay, + size=(icon_size, icon_size), + texture=ba.gettexture('egg3'), + tilt_scale=0.0) + + self._tdelay += self._t_delay_inc + + if self._in_game: + h, v, scale = positions[self._p_index] + self._p_index += 1 + + # If we're in a replay, we have a 'Leave Replay' button. + if _ba.is_in_replay(): + ba.buttonwidget(parent=self._root_widget, + position=(h - self._button_width * 0.5 * scale, + v), + scale=scale, + size=(self._button_width, self._button_height), + autoselect=self._use_autoselect, + label=ba.Lstr(resource='replayEndText'), + on_activate_call=self._confirm_end_replay) + elif _ba.get_foreground_host_session() is not None: + ba.buttonwidget( + parent=self._root_widget, + position=(h - self._button_width * 0.5 * scale, v), + scale=scale, + size=(self._button_width, self._button_height), + autoselect=self._use_autoselect, + label=ba.Lstr(resource=self._r + '.endGameText'), + on_activate_call=self._confirm_end_game) + # Assume we're in a client-session. + else: + ba.buttonwidget( + parent=self._root_widget, + position=(h - self._button_width * 0.5 * scale, v), + scale=scale, + size=(self._button_width, self._button_height), + autoselect=self._use_autoselect, + label=ba.Lstr(resource=self._r + '.leavePartyText'), + on_activate_call=self._confirm_leave_party) + + self._store_button: Optional[ba.Widget] + if self._have_store_button: + this_b_width = self._button_width + h, v, scale = positions[self._p_index] + self._p_index += 1 + + sbtn = self._store_button_instance = StoreButton( + parent=self._root_widget, + position=(h - this_b_width * 0.5 * scale, v), + size=(this_b_width, self._button_height), + scale=scale, + on_activate_call=ba.WeakCall(self._on_store_pressed), + sale_scale=1.3, + transition_delay=self._tdelay) + self._store_button = store_button = sbtn.get_button() + icon_size = (55 + if ba.app.small_ui else 55 if ba.app.med_ui else 70) + ba.imagewidget( + parent=self._root_widget, + position=(h - icon_size * 0.5, + v + self._button_height * scale - icon_size * 0.23), + transition_delay=self._tdelay, + size=(icon_size, icon_size), + texture=ba.gettexture(self._store_char_tex), + tilt_scale=0.0, + draw_controller=store_button) + + self._tdelay += self._t_delay_inc + else: + self._store_button = None + + self._quit_button: Optional[ba.Widget] + if not self._in_game and self._have_quit_button: + h, v, scale = positions[self._p_index] + self._p_index += 1 + self._quit_button = quit_button = ba.buttonwidget( + parent=self._root_widget, + autoselect=self._use_autoselect, + position=(h - self._button_width * 0.5 * scale, v), + size=(self._button_width, self._button_height), + scale=scale, + label=ba.Lstr(resource=self._r + + ('.quitText' if 'Mac' in + ba.app.user_agent_string else '.exitGameText')), + on_activate_call=self._quit, + transition_delay=self._tdelay) + + # Scattered eggs on easter. + if _ba.get_account_misc_read_val('easter', False): + icon_size = 30 + ba.imagewidget(parent=self._root_widget, + position=(h - icon_size * 0.5 + 25, + v + self._button_height * scale - + icon_size * 0.24 + 1.5), + transition_delay=self._tdelay, + size=(icon_size, icon_size), + texture=ba.gettexture('egg1'), + tilt_scale=0.0) + + ba.containerwidget(edit=self._root_widget, + cancel_button=quit_button) + self._tdelay += self._t_delay_inc + else: + self._quit_button = None + + # If we're not in-game, have no quit button, and this is android, + # we want back presses to quit our activity. + if (not self._in_game and not self._have_quit_button + and ba.app.platform == 'android'): + ba.containerwidget(edit=self._root_widget, + on_cancel_call=ba.Call(confirm.QuitWindow, + swish=True, + back=True)) + + # Add speed-up/slow-down buttons for replays. + # (ideally this should be part of a fading-out playback bar like most + # media players but this works for now). + if _ba.is_in_replay(): + b_size = 50.0 + b_buffer = 10.0 + t_scale = 0.75 + if ba.app.small_ui: + b_size *= 0.6 + b_buffer *= 1.0 + v_offs = -40 + t_scale = 0.5 + elif ba.app.med_ui: + v_offs = -70 + else: + v_offs = -100 + self._replay_speed_text = ba.textwidget( + parent=self._root_widget, + text=ba.Lstr(resource='watchWindow.playbackSpeedText', + subs=[('${SPEED}', str(1.23))]), + position=(h, v + v_offs + 7 * t_scale), + h_align='center', + v_align='center', + size=(0, 0), + scale=t_scale) + + # Update to current value. + self._change_replay_speed(0) + + # Keep updating in a timer in case it gets changed elsewhere. + self._change_replay_speed_timer = ba.Timer( + 0.25, + ba.WeakCall(self._change_replay_speed, 0), + timetype=ba.TimeType.REAL, + repeat=True) + btn = ba.buttonwidget(parent=self._root_widget, + position=(h - b_size - b_buffer, + v - b_size - b_buffer + v_offs), + button_type='square', + size=(b_size, b_size), + label='', + autoselect=True, + on_activate_call=ba.Call( + self._change_replay_speed, -1)) + ba.textwidget( + parent=self._root_widget, + draw_controller=btn, + text='-', + position=(h - b_size * 0.5 - b_buffer, + v - b_size * 0.5 - b_buffer + 5 * t_scale + v_offs), + h_align='center', + v_align='center', + size=(0, 0), + scale=3.0 * t_scale) + btn = ba.buttonwidget( + parent=self._root_widget, + position=(h + b_buffer, v - b_size - b_buffer + v_offs), + button_type='square', + size=(b_size, b_size), + label='', + autoselect=True, + on_activate_call=ba.Call(self._change_replay_speed, 1)) + ba.textwidget( + parent=self._root_widget, + draw_controller=btn, + text='+', + position=(h + b_size * 0.5 + b_buffer, + v - b_size * 0.5 - b_buffer + 5 * t_scale + v_offs), + h_align='center', + v_align='center', + size=(0, 0), + scale=3.0 * t_scale) + + def _refresh_not_in_game(self, positions: List[Tuple[float, float, float]] + ) -> Tuple[float, float, float]: + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + if not ba.app.did_menu_intro: + self._tdelay = 2.0 + self._t_delay_inc = 0.02 + self._t_delay_play = 1.7 + ba.app.did_menu_intro = True + self._width = 400.0 + self._height = 200.0 + enable_account_button = True + account_type_name: Union[str, ba.Lstr] + if _ba.get_account_state() == 'signed_in': + account_type_name = _ba.get_account_display_string() + account_type_icon = None + account_textcolor = (1.0, 1.0, 1.0) + else: + account_type_name = ba.Lstr( + resource='notSignedInText', + fallback_resource='accountSettingsWindow.titleText') + account_type_icon = None + account_textcolor = (1.0, 0.2, 0.2) + account_type_icon_color = (1.0, 1.0, 1.0) + account_type_call = self._show_account_window + account_type_enable_button_sound = True + b_count = 4 # play, help, credits, settings + if enable_account_button: + b_count += 1 + if self._have_quit_button: + b_count += 1 + if self._have_store_button: + b_count += 1 + if ba.app.small_ui: + root_widget_scale = 1.6 + play_button_width = self._button_width * 0.65 + play_button_height = self._button_height * 1.1 + small_button_scale = 0.51 if b_count > 6 else 0.63 + button_y_offs = -20.0 + button_y_offs2 = -60.0 + self._button_height *= 1.3 + button_spacing = 1.04 + elif ba.app.med_ui: + root_widget_scale = 1.3 + play_button_width = self._button_width * 0.65 + play_button_height = self._button_height * 1.1 + small_button_scale = 0.6 + button_y_offs = -55.0 + button_y_offs2 = -75.0 + self._button_height *= 1.25 + button_spacing = 1.1 + else: + root_widget_scale = 1.0 + play_button_width = self._button_width * 0.65 + play_button_height = self._button_height * 1.1 + small_button_scale = 0.75 + button_y_offs = -80.0 + button_y_offs2 = -100.0 + self._button_height *= 1.2 + button_spacing = 1.1 + spc = self._button_width * small_button_scale * button_spacing + ba.containerwidget(edit=self._root_widget, + size=(self._width, self._height), + background=False, + scale=root_widget_scale) + assert not positions + positions.append((self._width * 0.5, button_y_offs, 1.7)) + x_offs = self._width * 0.5 - (spc * (b_count - 1) * 0.5) + (spc * 0.5) + for i in range(b_count - 1): + positions.append( + (x_offs + spc * i - 1.0, button_y_offs + button_y_offs2, + small_button_scale)) + # In kiosk mode, provide a button to get back to the kiosk menu. + if ba.app.kiosk_mode: + h, v, scale = positions[self._p_index] + this_b_width = self._button_width * 0.4 * scale + demo_menu_delay = 0.0 if self._t_delay_play == 0.0 else max( + 0, self._t_delay_play + 0.1) + self._demo_menu_button = ba.buttonwidget( + parent=self._root_widget, + position=(self._width * 0.5 - this_b_width * 0.5, v + 90), + size=(this_b_width, 45), + autoselect=True, + color=(0.45, 0.55, 0.45), + textcolor=(0.7, 0.8, 0.7), + label=ba.Lstr(resource=self._r + '.demoMenuText'), + transition_delay=demo_menu_delay, + on_activate_call=self._demo_menu_press) + else: + self._demo_menu_button = None + foof = (-1 if ba.app.small_ui else 1 if ba.app.med_ui else 3) + h, v, scale = positions[self._p_index] + v = v + foof + gather_delay = 0.0 if self._t_delay_play == 0.0 else max( + 0.0, self._t_delay_play + 0.1) + assert play_button_width is not None + assert play_button_height is not None + this_h = h - play_button_width * 0.5 * scale - 40 * scale + this_b_width = self._button_width * 0.25 * scale + this_b_height = self._button_height * 0.82 * scale + self._gather_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(this_h - this_b_width * 0.5, v), + size=(this_b_width, this_b_height), + autoselect=self._use_autoselect, + button_type='square', + label='', + transition_delay=gather_delay, + on_activate_call=self._gather_press) + ba.textwidget(parent=self._root_widget, + position=(this_h, v + self._button_height * 0.33), + size=(0, 0), + scale=0.75, + transition_delay=gather_delay, + draw_controller=btn, + color=(0.75, 1.0, 0.7), + maxwidth=self._button_width * 0.33, + text=ba.Lstr(resource='gatherWindow.titleText'), + h_align='center', + v_align='center') + icon_size = this_b_width * 0.6 + ba.imagewidget(parent=self._root_widget, + size=(icon_size, icon_size), + draw_controller=btn, + transition_delay=gather_delay, + position=(this_h - 0.5 * icon_size, + v + 0.31 * this_b_height), + texture=ba.gettexture('usersButton')) + + # Play button. + h, v, scale = positions[self._p_index] + self._p_index += 1 + self._start_button = start_button = ba.buttonwidget( + parent=self._root_widget, + position=(h - play_button_width * 0.5 * scale, v), + size=(play_button_width, play_button_height), + autoselect=self._use_autoselect, + scale=scale, + text_res_scale=2.0, + label=ba.Lstr(resource='playText'), + transition_delay=self._t_delay_play, + on_activate_call=self._play_press) + ba.containerwidget(edit=self._root_widget, + start_button=start_button, + selected_child=start_button) + v = v + foof + watch_delay = 0.0 if self._t_delay_play == 0.0 else max( + 0.0, self._t_delay_play - 0.1) + this_h = h + play_button_width * 0.5 * scale + 40 * scale + this_b_width = self._button_width * 0.25 * scale + this_b_height = self._button_height * 0.82 * scale + self._watch_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(this_h - this_b_width * 0.5, v), + size=(this_b_width, this_b_height), + autoselect=self._use_autoselect, + button_type='square', + label='', + transition_delay=watch_delay, + on_activate_call=self._watch_press) + ba.textwidget(parent=self._root_widget, + position=(this_h, v + self._button_height * 0.33), + size=(0, 0), + scale=0.75, + transition_delay=watch_delay, + color=(0.75, 1.0, 0.7), + draw_controller=btn, + maxwidth=self._button_width * 0.33, + text=ba.Lstr(resource='watchWindow.titleText'), + h_align='center', + v_align='center') + icon_size = this_b_width * 0.55 + ba.imagewidget(parent=self._root_widget, + size=(icon_size, icon_size), + draw_controller=btn, + transition_delay=watch_delay, + position=(this_h - 0.5 * icon_size, + v + 0.33 * this_b_height), + texture=ba.gettexture('tv')) + if not self._in_game and enable_account_button: + this_b_width = self._button_width + h, v, scale = positions[self._p_index] + self._p_index += 1 + self._gc_button = ba.buttonwidget( + parent=self._root_widget, + position=(h - this_b_width * 0.5 * scale, v), + size=(this_b_width, self._button_height), + scale=scale, + label=account_type_name, + autoselect=self._use_autoselect, + on_activate_call=account_type_call, + textcolor=account_textcolor, + icon=account_type_icon, + icon_color=account_type_icon_color, + transition_delay=self._tdelay, + enable_sound=account_type_enable_button_sound) + + # Scattered eggs on easter. + if _ba.get_account_misc_read_val('easter', + False) and not self._in_game: + icon_size = 32 + ba.imagewidget(parent=self._root_widget, + position=(h - icon_size * 0.5 + 35, + v + self._button_height * scale - + icon_size * 0.24 + 1.5), + transition_delay=self._tdelay, + size=(icon_size, icon_size), + texture=ba.gettexture('egg2'), + tilt_scale=0.0) + self._tdelay += self._t_delay_inc + else: + self._gc_button = None + # How-to-play button. + h, v, scale = positions[self._p_index] + self._p_index += 1 + btn = ba.buttonwidget( + parent=self._root_widget, + position=(h - self._button_width * 0.5 * scale, v), + scale=scale, + autoselect=self._use_autoselect, + size=(self._button_width, self._button_height), + label=ba.Lstr(resource=self._r + '.howToPlayText'), + transition_delay=self._tdelay, + on_activate_call=self._howtoplay) + self._how_to_play_button = btn + # Scattered eggs on easter. + if _ba.get_account_misc_read_val('easter', + False) and not self._in_game: + icon_size = 28 + ba.imagewidget(parent=self._root_widget, + position=(h - icon_size * 0.5 + 30, + v + self._button_height * scale - + icon_size * 0.24 + 1.5), + transition_delay=self._tdelay, + size=(icon_size, icon_size), + texture=ba.gettexture('egg4'), + tilt_scale=0.0) + # Credits button. + self._tdelay += self._t_delay_inc + h, v, scale = positions[self._p_index] + self._p_index += 1 + self._credits_button = ba.buttonwidget( + parent=self._root_widget, + position=(h - self._button_width * 0.5 * scale, v), + size=(self._button_width, self._button_height), + autoselect=self._use_autoselect, + label=ba.Lstr(resource=self._r + '.creditsText'), + scale=scale, + transition_delay=self._tdelay, + on_activate_call=self._credits) + self._tdelay += self._t_delay_inc + return h, v, scale + + def _refresh_in_game(self, positions: List[Tuple[float, float, float]] + ) -> Tuple[float, float, float]: + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + custom_menu_entries: List[Dict[str, Any]] = [] + session = _ba.get_foreground_host_session() + if session is not None: + try: + custom_menu_entries = session.get_custom_menu_entries() + for cme in custom_menu_entries: + if (not isinstance(cme, dict) or 'label' not in cme + or not isinstance(cme['label'], (str, ba.Lstr)) + or 'call' not in cme or not callable(cme['call'])): + raise Exception("invalid custom menu entry: " + + str(cme)) + except Exception: + custom_menu_entries = [] + ba.print_exception('exception getting custom menu entries for', + session) + self._width = 250.0 + self._height = 250.0 if self._input_player else 180.0 + if self._is_kiosk and self._input_player: + self._height -= 40 + if not self._have_settings_button: + self._height -= 50 + if self._connected_to_remote_player: + # In this case we have a leave *and* a disconnect button. + self._height += 50 + self._height += 50 * (len(custom_menu_entries)) + ba.containerwidget( + edit=self._root_widget, + size=(self._width, self._height), + scale=2.15 if ba.app.small_ui else 1.6 if ba.app.med_ui else 1.0) + h = 125.0 + v = (self._height - 80.0 if self._input_player else self._height - 60) + h_offset = 0 + d_h_offset = 0 + v_offset = -50 + for _i in range(6 + len(custom_menu_entries)): + positions.append((h, v, 1.0)) + v += v_offset + h += h_offset + h_offset += d_h_offset + custom_menu_entries = [] + self._start_button = None + ba.app.pause() + + # Player name if applicable. + if self._input_player: + player_name = self._input_player.get_name() + h, v, scale = positions[self._p_index] + v += 35 + ba.textwidget(parent=self._root_widget, + position=(h - self._button_width / 2, v), + size=(self._button_width, self._button_height), + color=(1, 1, 1, 0.5), + scale=0.7, + h_align='center', + text=ba.Lstr(value=player_name)) + else: + player_name = '' + h, v, scale = positions[self._p_index] + self._p_index += 1 + btn = ba.buttonwidget(parent=self._root_widget, + position=(h - self._button_width / 2, v), + size=(self._button_width, self._button_height), + scale=scale, + label=ba.Lstr(resource=self._r + '.resumeText'), + autoselect=self._use_autoselect, + on_activate_call=self._resume) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + # Add any custom options defined by the current game. + for entry in custom_menu_entries: + h, v, scale = positions[self._p_index] + self._p_index += 1 + + # Ask the entry whether we should resume when we call + # it (defaults to true). + try: + resume = entry['resume_on_call'] + except Exception: + resume = True + + if resume: + call = ba.Call(self._resume_and_call, entry['call']) + else: + call = ba.Call(entry['call'], ba.WeakCall(self._resume)) + + ba.buttonwidget(parent=self._root_widget, + position=(h - self._button_width / 2, v), + size=(self._button_width, self._button_height), + scale=scale, + on_activate_call=call, + label=entry['label'], + autoselect=self._use_autoselect) + # Add a 'leave' button if the menu-owner has a player. + if ((self._input_player or self._connected_to_remote_player) + and not self._is_kiosk): + h, v, scale = positions[self._p_index] + self._p_index += 1 + btn = ba.buttonwidget(parent=self._root_widget, + position=(h - self._button_width / 2, v), + size=(self._button_width, + self._button_height), + scale=scale, + on_activate_call=self._leave, + label='', + autoselect=self._use_autoselect) + + if (player_name != '' and player_name[0] != '<' + and player_name[-1] != '>'): + txt = ba.Lstr(resource=self._r + '.justPlayerText', + subs=[('${NAME}', player_name)]) + else: + txt = ba.Lstr(value=player_name) + ba.textwidget(parent=self._root_widget, + position=(h, v + self._button_height * + (0.64 if player_name != '' else 0.5)), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.leaveGameText'), + scale=(0.83 if player_name != '' else 1.0), + color=(0.75, 1.0, 0.7), + h_align='center', + v_align='center', + draw_controller=btn, + maxwidth=self._button_width * 0.9) + ba.textwidget(parent=self._root_widget, + position=(h, v + self._button_height * 0.27), + size=(0, 0), + text=txt, + color=(0.75, 1.0, 0.7), + h_align='center', + v_align='center', + draw_controller=btn, + scale=0.45, + maxwidth=self._button_width * 0.9) + return h, v, scale + + def _change_replay_speed(self, offs: int) -> None: + _ba.set_replay_speed_exponent(_ba.get_replay_speed_exponent() + offs) + actual_speed = pow(2.0, _ba.get_replay_speed_exponent()) + ba.textwidget(edit=self._replay_speed_text, + text=ba.Lstr(resource='watchWindow.playbackSpeedText', + subs=[('${SPEED}', str(actual_speed))])) + + def _quit(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import confirm + confirm.QuitWindow(origin_widget=self._quit_button) + + def _demo_menu_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import kiosk + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (kiosk.KioskWindow( + transition='in_left').get_root_widget()) + + def _show_account_window(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.account import settings + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (settings.AccountSettingsWindow( + origin_widget=self._gc_button).get_root_widget()) + + def _on_store_pressed(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.store import browser + from bastd.ui import account + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (browser.StoreBrowserWindow( + origin_widget=self._store_button).get_root_widget()) + + def _confirm_end_game(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import confirm + # FIXME: Currently we crash calling this on client-sessions. + + # Select cancel by default; this occasionally gets called by accident + # in a fit of button mashing and this will help reduce damage. + confirm.ConfirmWindow(ba.Lstr(resource=self._r + '.exitToMenuText'), + self._end_game, + cancel_is_selected=True) + + def _confirm_end_replay(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import confirm + + # Select cancel by default; this occasionally gets called by accident + # in a fit of button mashing and this will help reduce damage. + confirm.ConfirmWindow(ba.Lstr(resource=self._r + '.exitToMenuText'), + self._end_game, + cancel_is_selected=True) + + def _confirm_leave_party(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import confirm + + # Select cancel by default; this occasionally gets called by accident + # in a fit of button mashing and this will help reduce damage. + confirm.ConfirmWindow(ba.Lstr(resource=self._r + + '.leavePartyConfirmText'), + self._leave_party, + cancel_is_selected=True) + + def _leave_party(self) -> None: + _ba.disconnect_from_host() + + def _end_game(self) -> None: + if not self._root_widget: + return + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.return_to_main_menu_session_gracefully() + + def _leave(self) -> None: + if self._input_player: + self._input_player.remove_from_game() + elif self._connected_to_remote_player: + if self._input_device: + self._input_device.remove_remote_player_from_game() + self._resume() + + def _credits(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import creditslist + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (creditslist.CreditsListWindow( + origin_widget=self._credits_button).get_root_widget()) + + def _howtoplay(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import helpui + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (helpui.HelpWindow( + main_menu=True, + origin_widget=self._how_to_play_button).get_root_widget()) + + def _settings(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import allsettings + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (allsettings.AllSettingsWindow( + origin_widget=self._settings_button).get_root_widget()) + + def _resume_and_call(self, call: Callable[[], Any]) -> None: + self._resume() + call() + + def _do_game_service_press(self) -> None: + self._save_state() + _ba.show_online_score_ui() + + def _save_state(self) -> None: + + # Don't do this for the in-game menu. + if self._in_game: + return + sel = self._root_widget.get_selected_child() + if sel == self._start_button: + ba.app.main_menu_selection = 'Start' + elif sel == self._gather_button: + ba.app.main_menu_selection = 'Gather' + elif sel == self._watch_button: + ba.app.main_menu_selection = 'Watch' + elif sel == self._how_to_play_button: + ba.app.main_menu_selection = 'HowToPlay' + elif sel == self._credits_button: + ba.app.main_menu_selection = 'Credits' + elif sel == self._settings_button: + ba.app.main_menu_selection = 'Settings' + elif sel == self._gc_button: + ba.app.main_menu_selection = 'GameService' + elif sel == self._store_button: + ba.app.main_menu_selection = 'Store' + elif sel == self._quit_button: + ba.app.main_menu_selection = 'Quit' + elif sel == self._demo_menu_button: + ba.app.main_menu_selection = 'DemoMenu' + else: + print('unknown widget in main menu store selection:', sel) + ba.app.main_menu_selection = 'Start' + + def _restore_state(self) -> None: + # pylint: disable=too-many-branches + + # Don't do this for the in-game menu. + if self._in_game: + return + sel_name = ba.app.main_menu_selection + sel: Optional[ba.Widget] + if sel_name is None: + sel_name = 'Start' + if sel_name == 'HowToPlay': + sel = self._how_to_play_button + elif sel_name == 'Gather': + sel = self._gather_button + elif sel_name == 'Watch': + sel = self._watch_button + elif sel_name == 'Credits': + sel = self._credits_button + elif sel_name == 'Settings': + sel = self._settings_button + elif sel_name == 'GameService': + sel = self._gc_button + elif sel_name == 'Store': + sel = self._store_button + elif sel_name == 'Quit': + sel = self._quit_button + elif sel_name == 'DemoMenu': + sel = self._demo_menu_button + else: + sel = self._start_button + if sel is not None: + ba.containerwidget(edit=self._root_widget, selected_child=sel) + + def _gather_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.gather import GatherWindow + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (GatherWindow( + origin_widget=self._gather_button).get_root_widget()) + + def _watch_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.watch import WatchWindow + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (WatchWindow( + origin_widget=self._watch_button).get_root_widget()) + + def _play_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.play import PlayWindow + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (PlayWindow( + origin_widget=self._start_button).get_root_widget()) + + def _resume(self) -> None: + ba.app.resume() + if self._root_widget: + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = None + + # If there's callbacks waiting for this window to go away, call them. + for call in ba.app.main_menu_resume_callbacks: + call() + del ba.app.main_menu_resume_callbacks[:] diff --git a/assets/src/data/scripts/bastd/ui/onscreenkeyboard.py b/assets/src/data/scripts/bastd/ui/onscreenkeyboard.py new file mode 100644 index 00000000..8a59d13a --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/onscreenkeyboard.py @@ -0,0 +1,268 @@ +"""Provides the built-in on screen keyboard UI.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import _ba +import ba + +if TYPE_CHECKING: + from typing import List, Tuple, Optional + + +class OnScreenKeyboardWindow(ba.OldWindow): + """Simple built-in on-screen keyboard.""" + + def __init__(self, textwidget: ba.Widget, label: str, max_chars: int): + # pylint: disable=too-many-locals + self._target_text = textwidget + self._width = 700 + self._height = 400 + top_extra = 20 if ba.app.small_ui else 0 + super().__init__(root_widget=ba.containerwidget( + parent=_ba.get_special_widget('overlay_stack'), + size=(self._width, self._height + top_extra), + transition='in_scale', + scale_origin_stack_offset=self._target_text. + get_screen_space_center(), + scale=(2.0 if ba.app.small_ui else 1.5 if ba.app.med_ui else 1.0), + stack_offset=(0, 0) if ba.app.small_ui else ( + 0, 0) if ba.app.med_ui else (0, 0))) + self._done_button = ba.buttonwidget(parent=self._root_widget, + position=(self._width - 200, 44), + size=(140, 60), + autoselect=True, + label=ba.Lstr(resource='doneText'), + on_activate_call=self._done) + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._cancel, + start_button=self._done_button) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 41), + size=(0, 0), + scale=0.95, + text=label, + maxwidth=self._width - 140, + color=ba.app.title_color, + h_align='center', + v_align='center') + + self._text_field = ba.textwidget( + parent=self._root_widget, + position=(70, self._height - 116), + max_chars=max_chars, + text=cast(str, ba.textwidget(query=self._target_text)), + on_return_press_call=self._done, + autoselect=True, + size=(self._width - 140, 55), + v_align='center', + editable=True, + maxwidth=self._width - 175, + force_internal_editing=True, + always_show_carat=True) + + self._shift_button = None + self._num_mode_button = None + self._char_keys: List[ba.Widget] = [] + self._mode = 'normal' + + v = self._height - 180 + key_width = 46 + key_height = 46 + self._key_color_lit = (1.4, 1.2, 1.4) + self._key_color = key_color = (0.69, 0.6, 0.74) + self._key_color_dark = key_color_dark = (0.55, 0.55, 0.71) + key_textcolor = (1, 1, 1) + row_starts = (69, 95, 151) + + self._click_sound = ba.getsound('click01') + + # kill prev char keys + for key in self._char_keys: + key.delete() + self._char_keys = [] + + # dummy data just used for row/column lengths... we don't actually + # set things until refresh + chars: List[Tuple[str, ...]] = [ + ('q', 'u', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'), + ('a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'), + ('z', 'x', 'c', 'v', 'b', 'n', 'm') + ] + + for row_num, row in enumerate(chars): + h = row_starts[row_num] + # shift key before row 3 + if row_num == 2: + self._shift_button = ba.buttonwidget( + parent=self._root_widget, + position=(h - key_width * 2.0, v), + size=(key_width * 1.7, key_height), + autoselect=True, + textcolor=key_textcolor, + color=key_color_dark, + label=ba.charstr(ba.SpecialChar.SHIFT), + enable_sound=False, + extra_touch_border_scale=0.3, + button_type='square', + ) + + for _ in row: + btn = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(key_width, key_height), + autoselect=True, + enable_sound=False, + textcolor=key_textcolor, + color=key_color, + label='', + button_type='square', + extra_touch_border_scale=0.1, + ) + self._char_keys.append(btn) + h += key_width + 10 + + # Add delete key at end of third row. + if row_num == 2: + ba.buttonwidget(parent=self._root_widget, + position=(h + 4, v), + size=(key_width * 1.8, key_height), + autoselect=True, + enable_sound=False, + repeat=True, + textcolor=key_textcolor, + color=key_color_dark, + label=ba.charstr(ba.SpecialChar.DELETE), + button_type='square', + on_activate_call=self._del) + v -= (key_height + 9) + # Do space bar and stuff. + if row_num == 2: + if self._num_mode_button is None: + self._num_mode_button = ba.buttonwidget( + parent=self._root_widget, + position=(112, v - 8), + size=(key_width * 2, key_height + 5), + enable_sound=False, + button_type='square', + extra_touch_border_scale=0.3, + autoselect=True, + textcolor=key_textcolor, + color=key_color_dark, + label='', + ) + btn1 = self._num_mode_button + btn2 = ba.buttonwidget(parent=self._root_widget, + position=(210, v - 12), + size=(key_width * 6.1, key_height + 15), + extra_touch_border_scale=0.3, + enable_sound=False, + autoselect=True, + textcolor=key_textcolor, + color=key_color_dark, + label=ba.Lstr(resource='spaceKeyText'), + on_activate_call=ba.Call( + self._type_char, ' ')) + ba.widget(edit=btn1, right_widget=btn2) + ba.widget(edit=btn2, + left_widget=btn1, + right_widget=self._done_button) + ba.widget(edit=self._done_button, left_widget=btn2) + + ba.containerwidget(edit=self._root_widget, + selected_child=self._char_keys[14]) + + self._refresh() + + def _refresh(self) -> None: + chars: Optional[List[str]] = None + if self._mode in ['normal', 'caps']: + chars = [ + 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', 'a', 's', + 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'z', 'x', 'c', 'v', 'b', + 'n', 'm' + ] + if self._mode == 'caps': + chars = [c.upper() for c in chars] + ba.buttonwidget(edit=self._shift_button, + color=self._key_color_lit + if self._mode == 'caps' else self._key_color_dark, + label=ba.charstr(ba.SpecialChar.SHIFT), + on_activate_call=self._shift) + ba.buttonwidget(edit=self._num_mode_button, + label='123#&*', + on_activate_call=self._num_mode) + elif self._mode == 'num': + chars = [ + '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '/', + ':', ';', '(', ')', '$', '&', '@', '"', '.', ',', '?', '!', + '\'', '_' + ] + ba.buttonwidget(edit=self._shift_button, + color=self._key_color_dark, + label='', + on_activate_call=self._null_press) + ba.buttonwidget(edit=self._num_mode_button, + label='abc', + on_activate_call=self._abc_mode) + + for i, btn in enumerate(self._char_keys): + assert chars is not None + ba.buttonwidget(edit=btn, + label=chars[i], + on_activate_call=ba.Call(self._type_char, + chars[i])) + + def _null_press(self) -> None: + ba.playsound(self._click_sound) + + def _abc_mode(self) -> None: + ba.playsound(self._click_sound) + self._mode = 'normal' + self._refresh() + + def _num_mode(self) -> None: + ba.playsound(self._click_sound) + self._mode = 'num' + self._refresh() + + def _shift(self) -> None: + ba.playsound(self._click_sound) + if self._mode == 'normal': + self._mode = 'caps' + elif self._mode == 'caps': + self._mode = 'normal' + self._refresh() + + def _del(self) -> None: + ba.playsound(self._click_sound) + txt = cast(str, ba.textwidget(query=self._text_field)) + # pylint: disable=unsubscriptable-object + txt = txt[:-1] + ba.textwidget(edit=self._text_field, text=txt) + + def _type_char(self, char: str) -> None: + ba.playsound(self._click_sound) + # operate in unicode so we don't do anything funky like chop utf-8 + # chars in half + txt = cast(str, ba.textwidget(query=self._text_field)) + txt += char + ba.textwidget(edit=self._text_field, text=txt) + # if we were caps, go back + if self._mode == 'caps': + self._mode = 'normal' + self._refresh() + + def _cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + ba.containerwidget(edit=self._root_widget, transition='out_scale') + + def _done(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_scale') + if self._target_text: + ba.textwidget(edit=self._target_text, + text=cast(str, + ba.textwidget(query=self._text_field))) diff --git a/assets/src/data/scripts/bastd/ui/party.py b/assets/src/data/scripts/bastd/ui/party.py new file mode 100644 index 00000000..e743fab3 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/party.py @@ -0,0 +1,456 @@ +"""Provides party related UI.""" + +from __future__ import annotations + +import math +import weakref +from typing import TYPE_CHECKING, cast + +import _ba +import ba +from bastd.ui import popup + +if TYPE_CHECKING: + from typing import List, Sequence, Optional, Dict, Any + + +class PartyWindow(ba.OldWindow): + """Party list/chat window.""" + + def __del__(self) -> None: + _ba.set_party_window_open(False) + + def __init__(self, origin: Sequence[float] = (0, 0)): + _ba.set_party_window_open(True) + self._r = 'partyWindow' + self._popup_type: Optional[str] = None + self._popup_party_member_client_id: Optional[int] = None + self._popup_party_member_is_host: Optional[bool] = None + self._width = 500 + self._height = (365 + if ba.app.small_ui else 480 if ba.app.med_ui else 600) + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition='in_scale', + color=(0.40, 0.55, 0.20), + parent=_ba.get_special_widget('overlay_stack'), + on_outside_click_call=self.close_with_sound, + scale_origin_stack_offset=origin, + scale=(2.0 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0), + stack_offset=(0, -10) if ba.app.small_ui else ( + 240, 0) if ba.app.med_ui else (330, 20))) + + self._cancel_button = ba.buttonwidget(parent=self._root_widget, + scale=0.7, + position=(30, self._height - 47), + size=(50, 50), + label='', + on_activate_call=self.close, + autoselect=True, + color=(0.45, 0.63, 0.15), + icon=ba.gettexture('crossOut'), + iconscale=1.2) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + + self._menu_button = ba.buttonwidget( + parent=self._root_widget, + scale=0.7, + position=(self._width - 60, self._height - 47), + size=(50, 50), + label='...', + autoselect=True, + button_type='square', + on_activate_call=ba.WeakCall(self._on_menu_button_press), + color=(0.55, 0.73, 0.25), + iconscale=1.2) + + info = _ba.get_connection_to_host_info() + if info.get('name', '') != '': + title = info['name'] + else: + title = ba.Lstr(resource=self._r + '.titleText') + + self._title_text = ba.textwidget(parent=self._root_widget, + scale=0.9, + color=(0.5, 0.7, 0.5), + text=title, + size=(0, 0), + position=(self._width * 0.5, + self._height - 29), + maxwidth=self._width * 0.7, + h_align='center', + v_align='center') + + self._empty_str = ba.textwidget(parent=self._root_widget, + scale=0.75, + size=(0, 0), + position=(self._width * 0.5, + self._height - 65), + maxwidth=self._width * 0.85, + h_align='center', + v_align='center') + + self._scroll_width = self._width - 50 + self._scrollwidget = ba.scrollwidget(parent=self._root_widget, + size=(self._scroll_width, + self._height - 200), + position=(30, 80), + color=(0.4, 0.6, 0.3)) + self._columnwidget = ba.columnwidget(parent=self._scrollwidget) + ba.widget(edit=self._menu_button, down_widget=self._columnwidget) + + self._muted_text = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.5), + size=(0, 0), + h_align='center', + v_align='center', + text=ba.Lstr(resource='chatMutedText')) + self._chat_texts: List[ba.Widget] = [] + + # add all existing messages if chat is not muted + if not ba.app.config.resolve('Chat Muted'): + msgs = _ba.get_chat_messages() + for msg in msgs: + self._add_msg(msg) + + self._text_field = txt = ba.textwidget( + parent=self._root_widget, + editable=True, + size=(530, 40), + position=(44, 39), + text='', + maxwidth=494, + shadow=0.3, + flatness=1.0, + description=ba.Lstr(resource=self._r + '.chatMessageText'), + autoselect=True, + v_align='center', + corner_scale=0.7) + + ba.widget(edit=self._scrollwidget, + autoselect=True, + left_widget=self._cancel_button, + up_widget=self._cancel_button, + down_widget=self._text_field) + ba.widget(edit=self._columnwidget, + autoselect=True, + up_widget=self._cancel_button, + down_widget=self._text_field) + ba.containerwidget(edit=self._root_widget, selected_child=txt) + btn = ba.buttonwidget(parent=self._root_widget, + size=(50, 35), + label=ba.Lstr(resource=self._r + '.sendText'), + button_type='square', + autoselect=True, + position=(self._width - 70, 35), + on_activate_call=self._send_chat_message) + ba.textwidget(edit=txt, on_return_press_call=btn.activate) + self._name_widgets: List[ba.Widget] = [] + self._roster: Optional[List[Dict[str, Any]]] = None + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + repeat=True, + timetype=ba.TimeType.REAL) + self._update() + + def on_chat_message(self, msg: str) -> None: + """Called when a new chat message comes through.""" + if not ba.app.config.resolve('Chat Muted'): + self._add_msg(msg) + + def _add_msg(self, msg: str) -> None: + txt = ba.textwidget(parent=self._columnwidget, + text=msg, + h_align='left', + v_align='center', + size=(0, 13), + scale=0.55, + maxwidth=self._scroll_width * 0.94, + shadow=0.3, + flatness=1.0) + self._chat_texts.append(txt) + if len(self._chat_texts) > 40: + first = self._chat_texts.pop(0) + first.delete() + ba.containerwidget(edit=self._columnwidget, visible_child=txt) + + def _on_menu_button_press(self) -> None: + is_muted = ba.app.config.resolve('Chat Muted') + popup.PopupMenuWindow( + position=self._menu_button.get_screen_space_center(), + scale=2.3 if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23, + choices=['unmute' if is_muted else 'mute'], + choices_display=[ + ba.Lstr( + resource='chatUnMuteText' if is_muted else 'chatMuteText') + ], + current_choice='unmute' if is_muted else 'mute', + delegate=self) + self._popup_type = 'menu' + + def _update(self) -> None: + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-nested-blocks + + # update muted state + if ba.app.config.resolve('Chat Muted'): + ba.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.3)) + # clear any chat texts we're showing + if self._chat_texts: + while self._chat_texts: + first = self._chat_texts.pop() + first.delete() + else: + ba.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.0)) + + # update roster section + roster = _ba.get_game_roster() + if roster != self._roster: + self._roster = roster + + # clear out old + for widget in self._name_widgets: + widget.delete() + self._name_widgets = [] + if not self._roster: + top_section_height = 60 + ba.textwidget(edit=self._empty_str, + text=ba.Lstr(resource=self._r + '.emptyText')) + ba.scrollwidget(edit=self._scrollwidget, + size=(self._width - 50, + self._height - top_section_height - 110), + position=(30, 80)) + else: + columns = 1 if len( + self._roster) == 1 else 2 if len(self._roster) == 2 else 3 + rows = int(math.ceil(float(len(self._roster)) / columns)) + c_width = (self._width * 0.9) / max(3, columns) + c_width_total = c_width * columns + c_height = 24 + c_height_total = c_height * rows + for y in range(rows): + for x in range(columns): + index = y * columns + x + if index < len(self._roster): + t_scale = 0.65 + pos = (self._width * 0.53 - c_width_total * 0.5 + + c_width * x - 23, + self._height - 65 - c_height * y - 15) + + # if there are players present for this client, use + # their names as a display string instead of the + # client spec-string + try: + if self._roster[index]['players']: + # if there's just one, use the full name; + # otherwise combine short names + if len(self._roster[index] + ['players']) == 1: + p_str = self._roster[index]['players'][ + 0]['name_full'] + else: + p_str = ('/'.join([ + entry['name'] for entry in + self._roster[index]['players'] + ])) + if len(p_str) > 25: + p_str = p_str[:25] + '...' + else: + p_str = self._roster[index][ + 'displayString'] + except Exception: + ba.print_exception( + 'error calcing client name str') + p_str = '???' + + widget = ba.textwidget(parent=self._root_widget, + position=(pos[0], pos[1]), + scale=t_scale, + size=(c_width * 0.85, 30), + maxwidth=c_width * 0.85, + color=(1, 1, + 1) if index == 0 else + (1, 1, 1), + selectable=True, + autoselect=True, + click_activate=True, + text=ba.Lstr(value=p_str), + h_align='left', + v_align='center') + self._name_widgets.append(widget) + + # in newer versions client_id will be present and + # we can use that to determine who the host is. + # in older versions we assume the first client is + # host + if self._roster[index]['client_id'] is not None: + is_host = self._roster[index][ + 'client_id'] == -1 + else: + is_host = (index == 0) + + # FIXME: Should pass client_id to these sort of + # calls; not spec-string (perhaps should wait till + # client_id is more readily available though). + ba.textwidget(edit=widget, + on_activate_call=ba.Call( + self._on_party_member_press, + self._roster[index]['client_id'], + is_host, widget)) + pos = (self._width * 0.53 - c_width_total * 0.5 + + c_width * x, + self._height - 65 - c_height * y) + + # Make the assumption that the first roster + # entry is the server. + # FIXME: Shouldn't do this. + if is_host: + twd = min( + c_width * 0.85, + _ba.get_string_width( + p_str, suppress_warning=True) * + t_scale) + self._name_widgets.append( + ba.textwidget( + parent=self._root_widget, + position=(pos[0] + twd + 1, + pos[1] - 0.5), + size=(0, 0), + h_align='left', + v_align='center', + maxwidth=c_width * 0.96 - twd, + color=(0.1, 1, 0.1, 0.5), + text=ba.Lstr(resource=self._r + + '.hostText'), + scale=0.4, + shadow=0.1, + flatness=1.0)) + ba.textwidget(edit=self._empty_str, text='') + ba.scrollwidget(edit=self._scrollwidget, + size=(self._width - 50, + max(100, self._height - 139 - + c_height_total)), + position=(30, 80)) + + def popup_menu_selected_choice(self, popup_window: popup.PopupMenuWindow, + choice: str) -> None: + """Called when a choice is selected in the popup.""" + del popup_window # unused + if self._popup_type == 'partyMemberPress': + if self._popup_party_member_is_host: + ba.playsound(ba.getsound('error')) + ba.screenmessage( + ba.Lstr(resource='internal.cantKickHostError'), + color=(1, 0, 0)) + else: + assert self._popup_party_member_client_id is not None + + # Ban for 5 minutes. + result = _ba.disconnect_client( + self._popup_party_member_client_id, ban_time=5 * 60) + if not result: + ba.playsound(ba.getsound('error')) + ba.screenmessage( + ba.Lstr(resource='getTicketsWindow.unavailableText'), + color=(1, 0, 0)) + elif self._popup_type == 'menu': + if choice in ('mute', 'unmute'): + cfg = ba.app.config + cfg['Chat Muted'] = (choice == 'mute') + cfg.apply_and_commit() + self._update() + else: + print('unhandled popup type: ' + str(self._popup_type)) + + def popup_menu_closing(self, popup_window: popup.PopupWindow) -> None: + """Called when the popup is closing.""" + + def _on_party_member_press(self, client_id: int, is_host: bool, + widget: ba.Widget) -> None: + # if we're the host, pop up 'kick' options for all non-host members + if _ba.get_foreground_host_session() is not None: + kick_str = ba.Lstr(resource='kickText') + else: + # kick-votes appeared in build 14248 + if (_ba.get_connection_to_host_info().get('build_number', 0) < + 14248): + return + kick_str = ba.Lstr(resource='kickVoteText') + popup.PopupMenuWindow( + position=widget.get_screen_space_center(), + scale=2.3 if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23, + choices=['kick'], + choices_display=[kick_str], + current_choice='kick', + delegate=self) + self._popup_type = 'partyMemberPress' + self._popup_party_member_client_id = client_id + self._popup_party_member_is_host = is_host + + def _send_chat_message(self) -> None: + _ba.chat_message(cast(str, ba.textwidget(query=self._text_field))) + ba.textwidget(edit=self._text_field, text='') + + def close(self) -> None: + """Close the window.""" + ba.containerwidget(edit=self._root_widget, transition='out_scale') + + def close_with_sound(self) -> None: + """Close the window and make a lovely sound.""" + ba.playsound(ba.getsound('swish')) + self.close() + + +def handle_party_invite(name: str, invite_id: str) -> None: + """Handle an incoming party invitation.""" + from bastd import mainmenu + from bastd.ui import confirm + ba.playsound(ba.getsound('fanfare')) + + # if we're not in the main menu, just print the invite + # (don't want to screw up an in-progress game) + in_game = not isinstance(_ba.get_foreground_host_session(), + mainmenu.MainMenuSession) + if in_game: + ba.screenmessage(ba.Lstr( + value='${A}\n${B}', + subs=[('${A}', + ba.Lstr(resource='gatherWindow.partyInviteText', + subs=[('${NAME}', name)])), + ('${B}', + ba.Lstr( + resource='gatherWindow.partyInviteGooglePlayExtraText')) + ]), + color=(0.5, 1, 0)) + else: + + def do_accept(inv_id: str) -> None: + _ba.accept_party_invitation(inv_id) + + conf = confirm.ConfirmWindow( + ba.Lstr(resource='gatherWindow.partyInviteText', + subs=[('${NAME}', name)]), + ba.Call(do_accept, invite_id), + width=500, + height=150, + color=(0.75, 1.0, 0.0), + ok_text=ba.Lstr(resource='gatherWindow.partyInviteAcceptText'), + cancel_text=ba.Lstr(resource='gatherWindow.partyInviteIgnoreText')) + + # FIXME: Ugly. + # Let's store the invite-id away on the confirm window so we know if + # we need to kill it later. + # noinspection PyTypeHints + conf.party_invite_id = invite_id # type: ignore + + # store a weak-ref so we can get at this later + ba.app.invite_confirm_windows.append(weakref.ref(conf)) + + # go ahead and prune our weak refs while we're here. + ba.app.invite_confirm_windows = [ + w for w in ba.app.invite_confirm_windows if w() is not None + ] diff --git a/assets/src/data/scripts/bastd/ui/partyqueue.py b/assets/src/data/scripts/bastd/ui/partyqueue.py new file mode 100644 index 00000000..8c334632 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/partyqueue.py @@ -0,0 +1,521 @@ +"""UI related to waiting in line for a party.""" + +from __future__ import annotations + +import random +import time +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Optional, Sequence, List, Dict + + +class PartyQueueWindow(ba.OldWindow): + """Window showing players waiting to join a server.""" + + # ewww this needs quite a bit of de-linting if/when i revisit it.. + # pylint: disable=invalid-name + # pylint: disable=consider-using-dict-comprehension + class Dude: + """Represents a single dude waiting in a server line.""" + + def __init__(self, parent: PartyQueueWindow, distance: float, + initial_offset: float, is_player: bool, account_id: str, + name: str): + self.claimed = False + self._line_left = parent.get_line_left() + self._line_width = parent.get_line_width() + self._line_bottom = parent.get_line_bottom() + self._target_distance = distance + self._distance = distance + initial_offset + self._boost_brightness = 0.0 + self._debug = False + self._sc = sc = 1.1 if is_player else 0.6 + random.random() * 0.2 + self._y_offs = -30.0 if is_player else -47.0 * sc + self._last_boost_time = 0.0 + self._color = (0.2, 1.0, + 0.2) if is_player else (0.5 + 0.3 * random.random(), + 0.4 + 0.2 * random.random(), + 0.5 + 0.3 * random.random()) + self._eye_color = (0.7 * 1.0 + 0.3 * self._color[0], + 0.7 * 1.0 + 0.3 * self._color[1], + 0.7 * 1.0 + 0.3 * self._color[2]) + self._body_image = ba.buttonwidget( + parent=parent.get_root_widget(), + selectable=True, + label='', + size=(sc * 60, sc * 80), + color=self._color, + texture=parent.lineup_tex, + model_transparent=parent.lineup_1_transparent_model) + ba.buttonwidget(edit=self._body_image, + on_activate_call=ba.WeakCall( + parent.on_account_press, account_id, + self._body_image)) + ba.widget(edit=self._body_image, autoselect=True) + self._eyes_image = ba.imagewidget( + parent=parent.get_root_widget(), + size=(sc * 36, sc * 18), + texture=parent.lineup_tex, + color=self._eye_color, + model_transparent=parent.eyes_model) + self._name_text = ba.textwidget(parent=parent.get_root_widget(), + size=(0, 0), + shadow=0, + flatness=1.0, + text=name, + maxwidth=100, + h_align='center', + v_align='center', + scale=0.75, + color=(1, 1, 1, 0.6)) + self._update_image() + + # DEBUG: vis target pos.. + self._body_image_target: Optional[ba.Widget] + self._eyes_image_target: Optional[ba.Widget] + if self._debug: + self._body_image_target = ba.imagewidget( + parent=parent.get_root_widget(), + size=(sc * 60, sc * 80), + color=self._color, + texture=parent.lineup_tex, + model_transparent=parent.lineup_1_transparent_model) + self._eyes_image_target = ba.imagewidget( + parent=parent.get_root_widget(), + size=(sc * 36, sc * 18), + texture=parent.lineup_tex, + color=self._eye_color, + model_transparent=parent.eyes_model) + # (updates our image positions) + self.set_target_distance(self._target_distance) + else: + self._body_image_target = self._eyes_image_target = None + + def __del__(self) -> None: + + # ew. our destructor here may get called as part of an internal + # widget tear-down. + # running further widget calls here can quietly break stuff, so we + # need to push a deferred call to kill these as necessary instead. + # (should bulletproof internal widget code to give a clean error + # in this case) + def kill_widgets(widgets: Sequence[ba.Widget]) -> None: + for widget in widgets: + if widget: + widget.delete() + + ba.pushcall( + ba.Call(kill_widgets, [ + self._body_image, self._eyes_image, + self._body_image_target, self._eyes_image_target, + self._name_text + ])) + + def set_target_distance(self, dist: float) -> None: + """Set distance for a dude.""" + self._target_distance = dist + if self._debug: + sc = self._sc + position = (self._line_left + self._line_width * + (1.0 - self._target_distance), + self._line_bottom - 30) + ba.imagewidget(edit=self._body_image_target, + position=(position[0] - sc * 30, + position[1] - sc * 25 - 70)) + ba.imagewidget(edit=self._eyes_image_target, + position=(position[0] - sc * 18, + position[1] + sc * 31 - 70)) + + def step(self, smoothing: float) -> None: + """Step this dude.""" + self._distance = (smoothing * self._distance + + (1.0 - smoothing) * self._target_distance) + self._update_image() + self._boost_brightness *= 0.9 + + def _update_image(self) -> None: + sc = self._sc + position = (self._line_left + self._line_width * + (1.0 - self._distance), self._line_bottom + 40) + brightness = 1.0 + self._boost_brightness + ba.buttonwidget(edit=self._body_image, + position=(position[0] - sc * 30, + position[1] - sc * 25 + self._y_offs), + color=(self._color[0] * brightness, + self._color[1] * brightness, + self._color[2] * brightness)) + ba.imagewidget(edit=self._eyes_image, + position=(position[0] - sc * 18, + position[1] + sc * 31 + self._y_offs), + color=(self._eye_color[0] * brightness, + self._eye_color[1] * brightness, + self._eye_color[2] * brightness)) + ba.textwidget(edit=self._name_text, + position=(position[0] - sc * 0, + position[1] + sc * 40.0)) + + def boost(self, amount: float, smoothing: float) -> None: + """Boost this dude.""" + del smoothing # unused arg + self._distance = max(0.0, self._distance - amount) + self._update_image() + self._last_boost_time = time.time() + self._boost_brightness += 0.6 + + def __init__(self, queue_id: str, address: str, port: int): + ba.app.have_party_queue_window = True + self._address = address + self._port = port + self._queue_id = queue_id + self._width = 800 + self._height = 400 + self._last_connect_attempt_time: Optional[float] = None + self._last_transaction_time: Optional[float] = None + self._boost_button: Optional[ba.Widget] = None + self._boost_price: Optional[ba.Widget] = None + self._boost_label: Optional[ba.Widget] = None + self._field_shown = False + self._dudes: List[PartyQueueWindow.Dude] = [] + self._dudes_by_id: Dict[int, PartyQueueWindow.Dude] = {} + self._line_left = 40.0 + self._line_width = self._width - 190 + self._line_bottom = self._height * 0.4 + self.lineup_tex = ba.gettexture('playerLineup') + self._smoothing = 0.0 + self._initial_offset = 0.0 + self._boost_tickets = 0 + self._boost_strength = 0.0 + self._angry_computer_transparent_model = ba.getmodel( + 'angryComputerTransparent') + self._angry_computer_image: Optional[ba.Widget] = None + self.lineup_1_transparent_model = ba.getmodel( + 'playerLineup1Transparent') + self._lineup_2_transparent_model = ba.getmodel( + 'playerLineup2Transparent') + self._lineup_3_transparent_model = ba.getmodel( + 'playerLineup3Transparent') + self._lineup_4_transparent_model = ba.getmodel( + 'playerLineup4Transparent') + self._line_image: Optional[ba.Widget] = None + self.eyes_model = ba.getmodel('plasticEyesTransparent') + self._white_tex = ba.gettexture('white') + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + color=(0.45, 0.63, 0.15), + transition='in_scale', + scale=1.4 if ba.app.small_ui else 1.2 if ba.app.med_ui else 1.0)) + + self._cancel_button = ba.buttonwidget(parent=self._root_widget, + scale=1.0, + position=(60, self._height - 80), + size=(50, 50), + label='', + on_activate_call=self.close, + autoselect=True, + color=(0.45, 0.63, 0.15), + icon=ba.gettexture('crossOut'), + iconscale=1.2) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + + self._title_text = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.55), + size=(0, 0), + color=(1.0, 3.0, 1.0), + scale=1.3, + h_align="center", + v_align="center", + text=ba.Lstr(resource='internal.connectingToPartyText'), + maxwidth=self._width * 0.65) + + self._tickets_text = ba.textwidget(parent=self._root_widget, + position=(self._width - 180, + self._height - 20), + size=(0, 0), + color=(0.2, 1.0, 0.2), + scale=0.7, + h_align="center", + v_align="center", + text='') + + # update at roughly 30fps + self._update_timer = ba.Timer(0.033, + ba.WeakCall(self.update), + repeat=True, + timetype=ba.TimeType.REAL) + self.update() + + def __del__(self) -> None: + try: + ba.app.have_party_queue_window = False + _ba.add_transaction({ + 'type': 'PARTY_QUEUE_REMOVE', + 'q': self._queue_id + }) + _ba.run_transactions() + except Exception: + ba.print_exception('err removing self from party queue') + + def get_line_left(self) -> float: + """(internal)""" + return self._line_left + + def get_line_width(self) -> float: + """(internal)""" + return self._line_width + + def get_line_bottom(self) -> float: + """(internal)""" + return self._line_bottom + + def on_account_press(self, account_id: str, + origin_widget: ba.Widget) -> None: + """A dude was clicked so we should show his account info.""" + from bastd.ui.account import viewer + if account_id is None: + ba.playsound(ba.getsound('error')) + return + viewer.AccountViewerWindow( + account_id=account_id, + position=origin_widget.get_screen_space_center()) + + def close(self) -> None: + """Close the ui.""" + ba.containerwidget(edit=self._root_widget, transition='out_scale') + + def _update_field(self, response: Dict[str, Any]) -> None: + if self._angry_computer_image is None: + self._angry_computer_image = ba.imagewidget( + parent=self._root_widget, + position=(self._width - 180, self._height * 0.5 - 65), + size=(150, 150), + texture=self.lineup_tex, + model_transparent=self._angry_computer_transparent_model) + if self._line_image is None: + self._line_image = ba.imagewidget( + parent=self._root_widget, + color=(0.0, 0.0, 0.0), + opacity=0.2, + position=(self._line_left, self._line_bottom - 2.0), + size=(self._line_width, 4.0), + texture=self._white_tex) + + # now go through the data they sent, creating dudes for us and our + # enemies as needed and updating target positions on all of them.. + + # mark all as unclaimed so we know which ones to kill off.. + for dude in self._dudes: + dude.claimed = False + + # always have a dude for ourself.. + if -1 not in self._dudes_by_id: + dude = self.Dude( + self, response['d'], self._initial_offset, True, + _ba.get_account_misc_read_val_2('resolvedAccountID', None), + _ba.get_account_display_string()) + self._dudes_by_id[-1] = dude + self._dudes.append(dude) + else: + self._dudes_by_id[-1].set_target_distance(response['d']) + self._dudes_by_id[-1].claimed = True + + # now create/destroy enemies + for (enemy_id, enemy_distance, enemy_account_id, + enemy_name) in response['e']: + if enemy_id not in self._dudes_by_id: + dude = self.Dude(self, enemy_distance, self._initial_offset, + False, enemy_account_id, enemy_name) + self._dudes_by_id[enemy_id] = dude + self._dudes.append(dude) + else: + self._dudes_by_id[enemy_id].set_target_distance(enemy_distance) + self._dudes_by_id[enemy_id].claimed = True + + # remove unclaimed dudes from both of our lists + self._dudes_by_id = dict([ + item for item in list(self._dudes_by_id.items()) if item[1].claimed + ]) + self._dudes = [dude for dude in self._dudes if dude.claimed] + + def _hide_field(self) -> None: + if self._angry_computer_image: + self._angry_computer_image.delete() + self._angry_computer_image = None + if self._line_image: + self._line_image.delete() + self._line_image = None + self._dudes = [] + self._dudes_by_id = {} + + def on_update_response(self, response: Optional[Dict[str, Any]]) -> None: + """We've received a response from an update to the server.""" + if not self._root_widget: + return + if response is not None: + should_show_field = (response.get('d') is not None) + self._smoothing = response['s'] + self._initial_offset = response['o'] + + # If they gave us a position, show the field. + if should_show_field: + ba.textwidget(edit=self._title_text, + text=ba.Lstr(resource='waitingInLineText'), + position=(self._width * 0.5, + self._height * 0.85)) + self._update_field(response) + self._field_shown = True + if not should_show_field and self._field_shown: + ba.textwidget( + edit=self._title_text, + text=ba.Lstr(resource='internal.connectingToPartyText'), + position=(self._width * 0.5, self._height * 0.55)) + self._hide_field() + self._field_shown = False + + # if they told us there's a boost button, update.. + if response.get('bt') is not None: + self._boost_tickets = response['bt'] + self._boost_strength = response['ba'] + if self._boost_button is None: + self._boost_button = ba.buttonwidget( + parent=self._root_widget, + scale=1.0, + position=(self._width * 0.5 - 75, 20), + size=(150, 100), + button_type='square', + label='', + on_activate_call=self.on_boost_press, + enable_sound=False, + color=(0, 1, 0), + autoselect=True) + self._boost_label = ba.textwidget( + parent=self._root_widget, + draw_controller=self._boost_button, + position=(self._width * 0.5, 88), + size=(0, 0), + color=(0.8, 1.0, 0.8), + scale=1.5, + h_align="center", + v_align="center", + text=ba.Lstr(resource='boostText'), + maxwidth=150) + self._boost_price = ba.textwidget( + parent=self._root_widget, + draw_controller=self._boost_button, + position=(self._width * 0.5, 50), + size=(0, 0), + color=(0, 1, 0), + scale=0.9, + h_align="center", + v_align="center", + text=ba.charstr(ba.SpecialChar.TICKET) + + str(self._boost_tickets), + maxwidth=150) + else: + if self._boost_button is not None: + self._boost_button.delete() + self._boost_button = None + if self._boost_price is not None: + self._boost_price.delete() + self._boost_price = None + if self._boost_label is not None: + self._boost_label.delete() + self._boost_label = None + + # if they told us to go ahead and try and connect, do so.. + # (note: servers will disconnect us if we try to connect before + # getting this go-ahead, so don't get any bright ideas...) + if response.get('c', False): + # enforce a delay between connection attempts + # (in case they're jamming on the boost button) + now = time.time() + if (self._last_connect_attempt_time is None + or now - self._last_connect_attempt_time > 10.0): + _ba.connect_to_party(address=self._address, + port=self._port, + print_progress=False) + self._last_connect_attempt_time = now + + def on_boost_press(self) -> None: + """Boost was pressed.""" + from bastd.ui import account + from bastd.ui import getcurrency + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + + if _ba.get_account_ticket_count() < self._boost_tickets: + ba.playsound(ba.getsound('error')) + getcurrency.show_get_tickets_prompt() + return + + ba.playsound(ba.getsound('laserReverse')) + _ba.add_transaction( + { + 'type': 'PARTY_QUEUE_BOOST', + 't': self._boost_tickets, + 'q': self._queue_id + }, + callback=ba.WeakCall(self.on_update_response)) + # lets not run these immediately (since they may be rapid-fire, + # just bucket them until the next tick) + + # the transaction handles the local ticket change, but we apply our + # local boost vis manually here.. + # (our visualization isn't really wired up to be transaction-based) + our_dude = self._dudes_by_id.get(-1) + if our_dude is not None: + our_dude.boost(self._boost_strength, self._smoothing) + + def update(self) -> None: + """Update!""" + if not self._root_widget: + return + + # Update boost-price. + if self._boost_price is not None: + ba.textwidget(edit=self._boost_price, + text=ba.charstr(ba.SpecialChar.TICKET) + + str(self._boost_tickets)) + + # Update boost button color based on if we have enough moola. + if self._boost_button is not None: + can_boost = ( + (_ba.get_account_state() == 'signed_in' + and _ba.get_account_ticket_count() >= self._boost_tickets)) + ba.buttonwidget(edit=self._boost_button, + color=(0, 1, 0) if can_boost else (0.7, 0.7, 0.7)) + + # Update ticket-count. + if self._tickets_text is not None: + if self._boost_button is not None: + if _ba.get_account_state() == 'signed_in': + val = ba.charstr(ba.SpecialChar.TICKET) + str( + _ba.get_account_ticket_count()) + else: + val = ba.charstr(ba.SpecialChar.TICKET) + '???' + ba.textwidget(edit=self._tickets_text, text=val) + else: + ba.textwidget(edit=self._tickets_text, text='') + + current_time = ba.time(ba.TimeType.REAL) + if (self._last_transaction_time is None + or current_time - self._last_transaction_time > + 0.001 * _ba.get_account_misc_read_val('pqInt', 5000)): + self._last_transaction_time = current_time + _ba.add_transaction( + { + 'type': 'PARTY_QUEUE_QUERY', + 'q': self._queue_id + }, + callback=ba.WeakCall(self.on_update_response)) + _ba.run_transactions() + + # step our dudes + for dude in self._dudes: + dude.step(self._smoothing) diff --git a/assets/src/data/scripts/bastd/ui/play.py b/assets/src/data/scripts/bastd/ui/play.py new file mode 100644 index 00000000..5c87dae3 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/play.py @@ -0,0 +1,544 @@ +"""Provides the top level play window.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Optional, Tuple + + +class PlayWindow(ba.OldWindow): + """Window for selecting overall play type.""" + + def __init__(self, + transition: str = 'in_right', + origin_widget: ba.Widget = None): + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + new_style = True + width = 1000 if ba.app.small_ui else 800 + x_offs = 100 if ba.app.small_ui else 0 + height = 550 if new_style else 400 + button_width = 400 + + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self._r = 'playWindow' + + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition=transition, + toolbar_visibility='menu_full', + scale_origin_stack_offset=scale_origin, + scale=(1.6 if new_style else 1.52 + ) if ba.app.small_ui else 0.9 if ba.app.med_ui else 0.8, + stack_offset=((0, 0) if new_style else ( + 10, 7)) if ba.app.small_ui else (0, 0))) + self._back_button = back_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(55 + x_offs, height - 132) if new_style else + (55, height - 92), + size=(120, 60), + scale=1.1, + text_res_scale=1.5, + text_scale=1.2, + autoselect=True, + label=ba.Lstr(resource='backText'), + button_type='back') + + txt = ba.textwidget(parent=self._root_widget, + position=(width * 0.5, + height - (101 if new_style else 61)), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.titleText'), + scale=1.7, + res_scale=2.0, + maxwidth=400, + color=ba.app.heading_color, + h_align="center", + v_align="center") + + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + if ba.app.toolbars and ba.app.small_ui: + ba.textwidget(edit=txt, text='') + + v = height - (110 if new_style else 60) + v -= 100 + clr = (0.6, 0.7, 0.6, 1.0) + v -= 280 if new_style else 180 + v += 30 if ba.app.toolbars and ba.app.small_ui else 0 + hoffs = x_offs + 80 if new_style else 0 + scl = 1.13 if new_style else 0.68 + + self._lineup_tex = ba.gettexture('playerLineup') + angry_computer_transparent_model = ba.getmodel( + 'angryComputerTransparent') + self._lineup_1_transparent_model = ba.getmodel( + 'playerLineup1Transparent') + self._lineup_2_transparent_model = ba.getmodel( + 'playerLineup2Transparent') + self._lineup_3_transparent_model = ba.getmodel( + 'playerLineup3Transparent') + self._lineup_4_transparent_model = ba.getmodel( + 'playerLineup4Transparent') + self._eyes_model = ba.getmodel('plasticEyesTransparent') + + self._coop_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(hoffs, v + (scl * 15 if new_style else 0)), + size=(scl * button_width, scl * (300 if new_style else 360)), + extra_touch_border_scale=0.1, + autoselect=True, + label="", + button_type='square', + text_scale=1.13, + on_activate_call=self._coop) + + if ba.app.toolbars and ba.app.small_ui: + ba.widget(edit=btn, + left_widget=_ba.get_special_widget('back_button')) + ba.widget(edit=btn, + up_widget=_ba.get_special_widget('account_button')) + ba.widget(edit=btn, + down_widget=_ba.get_special_widget('settings_button')) + + self._draw_dude(0, + btn, + hoffs, + v, + scl, + position=(140, 30), + color=(0.72, 0.4, 1.0)) + self._draw_dude(1, + btn, + hoffs, + v, + scl, + position=(185, 53), + color=(0.71, 0.5, 1.0)) + self._draw_dude(2, + btn, + hoffs, + v, + scl, + position=(220, 27), + color=(0.67, 0.44, 1.0)) + self._draw_dude(3, + btn, + hoffs, + v, + scl, + position=(255, 57), + color=(0.7, 0.3, 1.0)) + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * 230, v + scl * 153), + size=(scl * 115, scl * 115), + texture=self._lineup_tex, + model_transparent=angry_computer_transparent_model) + + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (-10), v + scl * 95), + size=(scl * button_width, scl * 50), + text=ba.Lstr(resource='playModes.singlePlayerCoopText', + fallback_resource='playModes.coopText'), + maxwidth=scl * button_width * 0.7, + res_scale=1.5, + h_align='center', + v_align='center', + color=(0.7, 0.9, 0.7, 1.0), + scale=scl * 2.3) + + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (-10), v + (scl * 54)), + size=(scl * button_width, scl * 30), + text=ba.Lstr(resource=self._r + '.oneToFourPlayersText'), + h_align='center', + v_align='center', + scale=0.83 * scl, + flatness=1.0, + maxwidth=scl * button_width * 0.7, + color=clr) + + scl = 0.5 if new_style else 0.68 + hoffs += 440 if new_style else 260 + v += 180 if new_style else 0 + + self._teams_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(hoffs, v + (scl * 15 if new_style else 0)), + size=(scl * button_width, scl * (300 if new_style else 360)), + extra_touch_border_scale=0.1, + autoselect=True, + label="", + button_type='square', + text_scale=1.13, + on_activate_call=self._team_tourney) + + if ba.app.toolbars: + ba.widget(edit=btn, + up_widget=_ba.get_special_widget('tickets_plus_button'), + right_widget=_ba.get_special_widget('party_button')) + + xxx = -14 + self._draw_dude(2, + btn, + hoffs, + v, + scl, + position=(xxx + 148, 30), + color=(0.2, 0.4, 1.0)) + self._draw_dude(3, + btn, + hoffs, + v, + scl, + position=(xxx + 181, 53), + color=(0.3, 0.4, 1.0)) + self._draw_dude(1, + btn, + hoffs, + v, + scl, + position=(xxx + 216, 33), + color=(0.3, 0.5, 1.0)) + self._draw_dude(0, + btn, + hoffs, + v, + scl, + position=(xxx + 245, 57), + color=(0.3, 0.5, 1.0)) + + xxx = 155 + self._draw_dude(0, + btn, + hoffs, + v, + scl, + position=(xxx + 151, 30), + color=(1.0, 0.5, 0.4)) + self._draw_dude(1, + btn, + hoffs, + v, + scl, + position=(xxx + 189, 53), + color=(1.0, 0.58, 0.58)) + self._draw_dude(3, + btn, + hoffs, + v, + scl, + position=(xxx + 223, 27), + color=(1.0, 0.5, 0.5)) + self._draw_dude(2, + btn, + hoffs, + v, + scl, + position=(xxx + 257, 57), + color=(1.0, 0.5, 0.5)) + + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (-10), v + scl * 95), + size=(scl * button_width, scl * 50), + text=ba.Lstr(resource='playModes.teamsText', + fallback_resource='teamsText'), + res_scale=1.5, + maxwidth=scl * button_width * 0.7, + h_align='center', + v_align='center', + color=(0.7, 0.9, 0.7, 1.0), + scale=scl * 2.3) + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (-10), v + (scl * 54)), + size=(scl * button_width, scl * 30), + text=ba.Lstr(resource=self._r + + '.twoToEightPlayersText'), + h_align='center', + v_align='center', + res_scale=1.5, + scale=0.9 * scl, + flatness=1.0, + maxwidth=scl * button_width * 0.7, + color=clr) + + hoffs += 0 if new_style else 260 + v -= 155 if new_style else 0 + self._free_for_all_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(hoffs, v + (scl * 15 if new_style else 0)), + size=(scl * button_width, scl * (300 if new_style else 360)), + extra_touch_border_scale=0.1, + autoselect=True, + label="", + button_type='square', + text_scale=1.13, + on_activate_call=self._free_for_all) + + xxx = -5 + self._draw_dude(0, + btn, + hoffs, + v, + scl, + position=(xxx + 140, 30), + color=(0.4, 1.0, 0.4)) + self._draw_dude(3, + btn, + hoffs, + v, + scl, + position=(xxx + 185, 53), + color=(1.0, 0.4, 0.5)) + self._draw_dude(1, + btn, + hoffs, + v, + scl, + position=(xxx + 220, 27), + color=(0.4, 0.5, 1.0)) + self._draw_dude(2, + btn, + hoffs, + v, + scl, + position=(xxx + 255, 57), + color=(0.5, 1.0, 0.4)) + xxx = 140 + self._draw_dude(2, + btn, + hoffs, + v, + scl, + position=(xxx + 148, 30), + color=(1.0, 0.9, 0.4)) + self._draw_dude(0, + btn, + hoffs, + v, + scl, + position=(xxx + 182, 53), + color=(0.7, 1.0, 0.5)) + self._draw_dude(3, + btn, + hoffs, + v, + scl, + position=(xxx + 233, 27), + color=(0.7, 0.5, 0.9)) + self._draw_dude(1, + btn, + hoffs, + v, + scl, + position=(xxx + 266, 53), + color=(0.4, 0.5, 0.8)) + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (-10), v + scl * 95), + size=(scl * button_width, scl * 50), + text=ba.Lstr(resource='playModes.freeForAllText', + fallback_resource='freeForAllText'), + maxwidth=scl * button_width * 0.7, + h_align='center', + v_align='center', + color=(0.7, 0.9, 0.7, 1.0), + scale=scl * 1.9) + ba.textwidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (-10), v + (scl * 54)), + size=(scl * button_width, scl * 30), + text=ba.Lstr(resource=self._r + + '.twoToEightPlayersText'), + h_align='center', + v_align='center', + scale=0.9 * scl, + flatness=1.0, + maxwidth=scl * button_width * 0.7, + color=clr) + + if ba.app.toolbars and ba.app.small_ui: + back_button.delete() + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._back, + selected_child=self._coop_button) + else: + ba.buttonwidget(edit=back_button, on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, + cancel_button=back_button, + selected_child=self._coop_button) + + self._restore_state() + + def _back(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import mainmenu + self._save_state() + ba.app.main_menu_window = (mainmenu.MainMenuWindow( + transition='in_left').get_root_widget()) + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + + def _coop(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import account + from bastd.ui.coop import browser + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (browser.CoopBrowserWindow( + origin_widget=self._coop_button).get_root_widget()) + + def _team_tourney(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.playlist import browser + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (browser.PlaylistBrowserWindow( + origin_widget=self._teams_button, + sessiontype=ba.TeamsSession).get_root_widget()) + + def _free_for_all(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.playlist import browser + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (browser.PlaylistBrowserWindow( + origin_widget=self._free_for_all_button, + sessiontype=ba.FreeForAllSession).get_root_widget()) + + def _draw_dude(self, i: int, btn: ba.Widget, hoffs: float, v: float, + scl: float, position: Tuple[float, float], + color: Tuple[float, float, float]) -> None: + h_extra = -100 + v_extra = 130 + eye_color = (0.7 * 1.0 + 0.3 * color[0], 0.7 * 1.0 + 0.3 * color[1], + 0.7 * 1.0 + 0.3 * color[2]) + if i == 0: + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (h_extra + position[0]), + v + scl * (v_extra + position[1])), + size=(scl * 60, scl * 80), + color=color, + texture=self._lineup_tex, + model_transparent=self._lineup_1_transparent_model) + ba.imagewidget( + parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (h_extra + position[0] + 12), + v + scl * (v_extra + position[1] + 53)), + size=(scl * 36, scl * 18), + texture=self._lineup_tex, + color=eye_color, + model_transparent=self._eyes_model) + elif i == 1: + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (h_extra + position[0]), + v + scl * (v_extra + position[1])), + size=(scl * 45, scl * 90), + color=color, + texture=self._lineup_tex, + model_transparent=self._lineup_2_transparent_model) + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (h_extra + position[0] + 5), + v + scl * (v_extra + position[1] + 67)), + size=(scl * 32, scl * 16), + texture=self._lineup_tex, + color=eye_color, + model_transparent=self._eyes_model) + elif i == 2: + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (h_extra + position[0]), + v + scl * (v_extra + position[1])), + size=(scl * 45, scl * 90), + color=color, + texture=self._lineup_tex, + model_transparent=self._lineup_3_transparent_model) + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (h_extra + position[0] + 5), + v + scl * (v_extra + position[1] + 59)), + size=(scl * 34, scl * 17), + texture=self._lineup_tex, + color=eye_color, + model_transparent=self._eyes_model) + elif i == 3: + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (h_extra + position[0]), + v + scl * (v_extra + position[1])), + size=(scl * 48, scl * 96), + color=color, + texture=self._lineup_tex, + model_transparent=self._lineup_4_transparent_model) + ba.imagewidget(parent=self._root_widget, + draw_controller=btn, + position=(hoffs + scl * (h_extra + position[0] + 2), + v + scl * (v_extra + position[1] + 62)), + size=(scl * 38, scl * 19), + texture=self._lineup_tex, + color=eye_color, + model_transparent=self._eyes_model) + + def _save_state(self) -> None: + try: + sel = self._root_widget.get_selected_child() + if sel == self._teams_button: + sel_name = 'Team Games' + elif sel == self._coop_button: + sel_name = 'Co-op Games' + elif sel == self._free_for_all_button: + sel_name = 'Free-for-All Games' + elif sel == self._back_button: + sel_name = 'Back' + else: + raise Exception("unrecognized selected widget") + ba.app.window_states[self.__class__.__name__] = sel_name + except Exception: + ba.print_exception('error saving state for', self.__class__) + + def _restore_state(self) -> None: + try: + try: + sel_name = ba.app.window_states[self.__class__.__name__] + except Exception: + sel_name = None + if sel_name == 'Team Games': + sel = self._teams_button + elif sel_name == 'Co-op Games': + sel = self._coop_button + elif sel_name == 'Free-for-All Games': + sel = self._free_for_all_button + elif sel_name == 'Back': + sel = self._back_button + else: + sel = self._coop_button + ba.containerwidget(edit=self._root_widget, selected_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) diff --git a/assets/src/data/scripts/bastd/ui/playlist/__init__.py b/assets/src/data/scripts/bastd/ui/playlist/__init__.py new file mode 100644 index 00000000..199936eb --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/playlist/__init__.py @@ -0,0 +1,48 @@ +"""Playlist ui functionality.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Type + + +# FIXME: Could change this to be a classmethod of session types? +class PlaylistTypeVars: + """Defines values for a playlist type (config names to use, etc).""" + + def __init__(self, sessiontype: Type[ba.Session]): + from ba.internal import (get_default_teams_playlist, + get_default_free_for_all_playlist) + self.sessiontype: Type[ba.Session] + if issubclass(sessiontype, ba.TeamsSession): + play_mode_name = ba.Lstr(resource='playModes.teamsText', + fallback_resource='teamsText') + self.get_default_list_call = get_default_teams_playlist + self.session_type_name = 'ba.TeamsSession' + self.config_name = 'Team Tournament' + self.window_title_name = ba.Lstr(resource='playModes.teamsText', + fallback_resource='teamsText') + self.sessiontype = ba.TeamsSession + elif issubclass(sessiontype, ba.FreeForAllSession): + play_mode_name = ba.Lstr(resource='playModes.freeForAllText', + fallback_resource='freeForAllText') + self.get_default_list_call = get_default_free_for_all_playlist + self.session_type_name = 'ba.FreeForAllSession' + self.config_name = 'Free-for-All' + self.window_title_name = ba.Lstr( + resource='playModes.freeForAllText', + fallback_resource='freeForAllText') + self.sessiontype = ba.FreeForAllSession + else: + raise Exception('playlist type vars undefined for session type: ' + + str(sessiontype)) + self.default_list_name = ba.Lstr(resource='defaultGameListNameText', + subs=[('${PLAYMODE}', play_mode_name) + ]) + self.default_new_list_name = ba.Lstr( + resource='defaultNewGameListNameText', + subs=[('${PLAYMODE}', play_mode_name)]) diff --git a/assets/src/data/scripts/bastd/ui/playlist/addgame.py b/assets/src/data/scripts/bastd/ui/playlist/addgame.py new file mode 100644 index 00000000..6a0983db --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/playlist/addgame.py @@ -0,0 +1,196 @@ +"""Provides a window for selecting a game type to add to a playlist.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Type, Optional + from bastd.ui.playlist.editcontroller import PlaylistEditController + + +class PlaylistAddGameWindow(ba.OldWindow): + """Window for selecting a game type to add to a playlist.""" + + def __init__(self, + editcontroller: PlaylistEditController, + transition: str = 'in_right'): + self._editcontroller = editcontroller + self._r = 'addGameWindow' + self._width = 750 if ba.app.small_ui else 650 + x_inset = 50 if ba.app.small_ui else 0 + self._height = (346 + if ba.app.small_ui else 380 if ba.app.med_ui else 440) + top_extra = 30 if ba.app.small_ui else 20 + self._scroll_width = 210 + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + top_extra), + transition=transition, + scale=(2.17 if ba.app.small_ui else 1.5 if ba.app.med_ui else 1.0), + stack_offset=(0, 1) if ba.app.small_ui else (0, 0))) + + self._back_button = ba.buttonwidget(parent=self._root_widget, + position=(58 + x_inset, + self._height - 53), + size=(165, 70), + scale=0.75, + text_scale=1.2, + label=ba.Lstr(resource='backText'), + autoselect=True, + button_type='back', + on_activate_call=self._back) + self._select_button = select_button = ba.buttonwidget( + parent=self._root_widget, + position=(self._width - (172 + x_inset), self._height - 50), + autoselect=True, + size=(160, 60), + scale=0.75, + text_scale=1.2, + label=ba.Lstr(resource='selectText'), + on_activate_call=self._add) + + if ba.app.toolbars: + ba.widget(edit=select_button, + right_widget=_ba.get_special_widget('party_button')) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 28), + size=(0, 0), + scale=1.0, + text=ba.Lstr(resource=self._r + '.titleText'), + h_align='center', + color=ba.app.title_color, + maxwidth=250, + v_align='center') + v = self._height - 64 + + self._selected_title_text = ba.textwidget( + parent=self._root_widget, + position=(x_inset + self._scroll_width + 50 + 30, v - 15), + size=(0, 0), + scale=1.0, + color=(0.7, 1.0, 0.7, 1.0), + maxwidth=self._width - self._scroll_width - 150 - x_inset * 2, + h_align='left', + v_align='center') + v -= 30 + + self._selected_description_text = ba.textwidget( + parent=self._root_widget, + position=(x_inset + self._scroll_width + 50 + 30, v), + size=(0, 0), + scale=0.7, + color=(0.5, 0.8, 0.5, 1.0), + maxwidth=self._width - self._scroll_width - 150 - x_inset * 2, + h_align='left') + + scroll_height = self._height - 100 + + v = self._height - 60 + + self._scrollwidget = ba.scrollwidget(parent=self._root_widget, + position=(x_inset + 61, + v - scroll_height), + size=(self._scroll_width, + scroll_height), + highlight=False) + ba.widget(edit=self._scrollwidget, + up_widget=self._back_button, + left_widget=self._back_button, + right_widget=select_button) + self._column: Optional[ba.Widget] = None + + v -= 35 + ba.containerwidget(edit=self._root_widget, + cancel_button=self._back_button, + start_button=select_button) + self._selected_game_type: Optional[Type[ba.GameActivity]] = None + + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget) + + self._refresh() + + def _refresh(self, select_get_more_games_button: bool = False) -> None: + from ba.internal import get_game_types + + if self._column is not None: + self._column.delete() + + self._column = ba.columnwidget(parent=self._scrollwidget) + + gametypes = [ + gt for gt in get_game_types() if gt.supports_session_type( + self._editcontroller.get_session_type()) + ] + + # Sort in the current language. + gametypes.sort(key=lambda g: g.get_display_string().evaluate()) + + for i, gametype in enumerate(gametypes): + txt = ba.textwidget( + parent=self._column, + position=(0, 0), + size=(self._width - 88, 24), + text=gametype.get_display_string(), + h_align="left", + v_align="center", + color=(0.8, 0.8, 0.8, 1.0), + maxwidth=self._scroll_width * 0.8, + on_select_call=ba.Call(self._set_selected_game_type, gametype), + always_highlight=True, + selectable=True, + on_activate_call=ba.Call(ba.timer, + 0.1, + self._select_button.activate, + timetype='real')) + if i == 0: + ba.widget(edit=txt, up_widget=self._back_button) + + self._get_more_games_button = ba.buttonwidget( + parent=self._column, + autoselect=True, + label=ba.Lstr(resource=self._r + '.getMoreGamesText'), + color=(0.54, 0.52, 0.67), + textcolor=(0.7, 0.65, 0.7), + on_activate_call=self._on_get_more_games_press, + size=(178, 50)) + if select_get_more_games_button: + ba.containerwidget(edit=self._column, + selected_child=self._get_more_games_button, + visible_child=self._get_more_games_button) + + def _on_get_more_games_press(self) -> None: + from bastd.ui import account + from bastd.ui.store import browser + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + browser.StoreBrowserWindow(modal=True, + show_tab='minigames', + on_close_call=self._on_store_close, + origin_widget=self._get_more_games_button) + + def _on_store_close(self) -> None: + self._refresh(select_get_more_games_button=True) + + def _add(self) -> None: + _ba.lock_all_input() # Make sure no more commands happen. + ba.timer(0.1, _ba.unlock_all_input, timetype=ba.TimeType.REAL) + assert self._selected_game_type is not None + self._editcontroller.add_game_type_selected(self._selected_game_type) + + def _set_selected_game_type(self, gametype: Type[ba.GameActivity]) -> None: + self._selected_game_type = gametype + ba.textwidget(edit=self._selected_title_text, + text=gametype.get_display_string()) + ba.textwidget(edit=self._selected_description_text, + text=gametype.get_description_display_string( + self._editcontroller.get_session_type())) + + def _back(self) -> None: + self._editcontroller.add_game_cancelled() diff --git a/assets/src/data/scripts/bastd/ui/playlist/browser.py b/assets/src/data/scripts/bastd/ui/playlist/browser.py new file mode 100644 index 00000000..51c30817 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/playlist/browser.py @@ -0,0 +1,631 @@ +"""Provides a window for browsing and launching game playlists.""" + +from __future__ import annotations + +import copy +import math +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Type, Optional, Tuple, Union + + +class PlaylistBrowserWindow(ba.OldWindow): + """Window for starting teams games.""" + + def __init__(self, + sessiontype: Type[ba.Session], + transition: str = 'in_right', + origin_widget: ba.Widget = None): + # pylint: disable=too-many-statements + # pylint: disable=cyclic-import + from bastd.ui import playlist + + # If they provided an origin-widget, scale up from that. + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + # Store state for when we exit the next game. + if issubclass(sessiontype, ba.TeamsSession): + ba.app.main_window = "Team Game Select" + ba.set_analytics_screen('Teams Window') + elif issubclass(sessiontype, ba.FreeForAllSession): + ba.app.main_window = "Free-for-All Game Select" + ba.set_analytics_screen('FreeForAll Window') + else: + raise Exception(f'invalid sessiontype: {sessiontype}') + self._pvars = playlist.PlaylistTypeVars(sessiontype) + + self._sessiontype = sessiontype + + self._customize_button: Optional[ba.Widget] = None + self._sub_width: Optional[float] = None + self._sub_height: Optional[float] = None + + # On new installations, go ahead and create a few playlists + # besides the hard-coded default one: + if not _ba.get_account_misc_val('madeStandardPlaylists', False): + # yapf: disable + _ba.add_transaction({ + 'type': 'ADD_PLAYLIST', + 'playlistType': 'Free-for-All', + 'playlistName': + ba.Lstr(resource='singleGamePlaylistNameText' + ).evaluate().replace( + '${GAME}', + ba.Lstr( + translate=('gameNames', + 'Death Match')).evaluate()), + 'playlist': [ + {'type': 'bs_death_match.DeathMatchGame', + 'settings': { + 'Epic Mode': False, + 'Kills to Win Per Player': 10, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Doom Shroom'} + }, + {'type': 'bs_death_match.DeathMatchGame', + 'settings': { + 'Epic Mode': False, + 'Kills to Win Per Player': 10, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'map': 'Crag Castle'} + } + ] + }) + _ba.add_transaction({ + 'type': 'ADD_PLAYLIST', + 'playlistType': 'Team Tournament', + 'playlistName': + ba.Lstr( + resource='singleGamePlaylistNameText' + ).evaluate().replace( + '${GAME}', + ba.Lstr(translate=('gameNames', + 'Capture the Flag')).evaluate()), + 'playlist': [ + {'type': 'bs_capture_the_flag.CTFGame', + 'settings': { + 'map': 'Bridgit', + 'Score to Win': 3, + 'Flag Idle Return Time': 30, + 'Flag Touch Return Time': 0, + 'Respawn Times': 1.0, + 'Time Limit': 600, + 'Epic Mode': False} + }, + {'type': 'bs_capture_the_flag.CTFGame', + 'settings': { + 'map': 'Roundabout', + 'Score to Win': 2, + 'Flag Idle Return Time': 30, + 'Flag Touch Return Time': 0, + 'Respawn Times': 1.0, + 'Time Limit': 600, + 'Epic Mode': False} + }, + {'type': 'bs_capture_the_flag.CTFGame', + 'settings': { + 'map': 'Tip Top', + 'Score to Win': 2, + 'Flag Idle Return Time': 30, + 'Flag Touch Return Time': 3, + 'Respawn Times': 1.0, + 'Time Limit': 300, + 'Epic Mode': False} + } + ] + }) + _ba.add_transaction({ + 'type': 'ADD_PLAYLIST', + 'playlistType': 'Team Tournament', + 'playlistName': + ba.Lstr(translate=('playlistNames', + 'Just Sports')).evaluate(), + 'playlist': [ + {'type': 'bs_hockey.HockeyGame', + 'settings': { + 'Time Limit': 0, + 'map': 'Hockey Stadium', + 'Score to Win': 1, + 'Respawn Times': 1.0} + }, + {'type': 'bs_football.FootballTeamGame', + 'settings': { + 'Time Limit': 0, + 'map': 'Football Stadium', + 'Score to Win': 21, + 'Respawn Times': 1.0} + } + ] + }) + _ba.add_transaction({ + 'type': 'ADD_PLAYLIST', + 'playlistType': 'Free-for-All', + 'playlistName': + ba.Lstr(translate=('playlistNames', + 'Just Epic')).evaluate(), + 'playlist': [ + {'type': 'bs_elimination.EliminationGame', + 'settings': { + 'Time Limit': 120, + 'map': 'Tip Top', + 'Respawn Times': 1.0, + 'Lives Per Player': 1, + 'Epic Mode': 1} + } + ] + }) + _ba.add_transaction({ + 'type': 'SET_MISC_VAL', + 'name': 'madeStandardPlaylists', + 'value': True + }) + # yapf: enable + _ba.run_transactions() + + # Get the current selection (if any). + try: + self._selected_playlist = ba.app.config[self._pvars.config_name + + ' Playlist Selection'] + except Exception: + self._selected_playlist = None + + self._width = 900 if ba.app.small_ui else 800 + x_inset = 50 if ba.app.small_ui else 0 + self._height = (480 + if ba.app.small_ui else 510 if ba.app.med_ui else 580) + + top_extra = 20 if ba.app.small_ui else 0 + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + top_extra), + transition=transition, + toolbar_visibility='menu_full', + scale_origin_stack_offset=scale_origin, + scale=( + 1.69 if ba.app.small_ui else 1.05 if ba.app.med_ui else 0.9), + stack_offset=(0, -26) if ba.app.small_ui else (0, 0))) + + self._back_button: Optional[ba.Widget] = ba.buttonwidget( + parent=self._root_widget, + position=(59 + x_inset, self._height - 70), + size=(120, 60), + scale=1.0, + on_activate_call=self._on_back_press, + autoselect=True, + label=ba.Lstr(resource='backText'), + button_type='back') + ba.containerwidget(edit=self._root_widget, + cancel_button=self._back_button) + txt = self._title_text = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height - 41), + size=(0, 0), + text=self._pvars.window_title_name, + scale=1.3, + res_scale=1.5, + color=ba.app.heading_color, + h_align="center", + v_align="center") + if ba.app.small_ui and ba.app.toolbars: + ba.textwidget(edit=txt, text='') + + ba.buttonwidget(edit=self._back_button, + button_type='backSmall', + size=(60, 54), + position=(59 + x_inset, self._height - 67), + label=ba.charstr(ba.SpecialChar.BACK)) + + if ba.app.small_ui and ba.app.toolbars: + self._back_button.delete() + self._back_button = None + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._on_back_press) + scroll_offs = 33 + else: + scroll_offs = 0 + self._scroll_width = self._width - (100 + 2 * x_inset) + self._scroll_height = self._height - (146 if ba.app.small_ui + and ba.app.toolbars else 136) + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + highlight=False, + size=(self._scroll_width, self._scroll_height), + position=((self._width - self._scroll_width) * 0.5, + 65 + scroll_offs)) + ba.containerwidget(edit=self._scrollwidget, claims_left_right=True) + self._subcontainer: Optional[ba.Widget] = None + self._config_name_full = self._pvars.config_name + ' Playlists' + self._last_config = None + + # update now and once per second.. (this should do our initial refresh) + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + self._update() + + def _refresh(self) -> None: + # FIXME: Should tidy this up. + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-nested-blocks + from ba.internal import (get_map_class, + get_default_free_for_all_playlist, + get_default_teams_playlist, filter_playlist) + if not self._root_widget: + return + if self._subcontainer is not None: + self._save_state() + self._subcontainer.delete() + + # make sure config exists + if self._config_name_full not in ba.app.config: + ba.app.config[self._config_name_full] = {} + + items = list(ba.app.config[self._config_name_full].items()) + + # make sure everything is unicode + items = [(i[0].decode(), i[1]) if not isinstance(i[0], str) else i + for i in items] + + items.sort(key=lambda x2: x2[0].lower()) + items = [['__default__', None]] + items # default is always first + + count = len(items) + columns = 3 + rows = int(math.ceil(float(count) / columns)) + button_width = 230 + button_height = 230 + button_buffer_h = -3 + button_buffer_v = 0 + + self._sub_width = self._scroll_width + self._sub_height = 40 + rows * (button_height + + 2 * button_buffer_v) + 90 + assert self._sub_width is not None + assert self._sub_height is not None + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._sub_width, + self._sub_height), + background=False) + + children = self._subcontainer.get_children() + for child in children: + child.delete() + + ba.textwidget(parent=self._subcontainer, + text=ba.Lstr(resource='playlistsText'), + position=(40, self._sub_height - 26), + size=(0, 0), + scale=1.0, + maxwidth=400, + color=ba.app.title_color, + h_align='left', + v_align='center') + + index = 0 + bs_config = ba.app.config + + model_opaque = ba.getmodel('level_select_button_opaque') + model_transparent = ba.getmodel('level_select_button_transparent') + mask_tex = ba.gettexture('mapPreviewMask') + + h_offs = 225 if count == 1 else 115 if count == 2 else 0 + h_offs_bottom = 0 + + for y in range(rows): + for x in range(columns): + name = items[index][0] + pos = (x * (button_width + 2 * button_buffer_h) + + button_buffer_h + 8 + h_offs, self._sub_height - 47 - + (y + 1) * (button_height + 2 * button_buffer_v)) + btn = ba.buttonwidget(parent=self._subcontainer, + button_type='square', + size=(button_width, button_height), + autoselect=True, + label='', + position=pos) + + if x == 0 and ba.app.toolbars and ba.app.small_ui: + ba.widget( + edit=btn, + left_widget=_ba.get_special_widget('back_button')) + if x == columns - 1 and ba.app.toolbars and ba.app.small_ui: + ba.widget( + edit=btn, + right_widget=_ba.get_special_widget('party_button')) + ba.buttonwidget( + edit=btn, + on_activate_call=ba.Call(self._on_playlist_press, btn, + name), + on_select_call=ba.Call(self._on_playlist_select, name)) + ba.widget(edit=btn, show_buffer_top=50, show_buffer_bottom=50) + + if self._selected_playlist == name: + ba.containerwidget(edit=self._subcontainer, + selected_child=btn, + visible_child=btn) + + if self._back_button is not None: + if y == 0: + ba.widget(edit=btn, up_widget=self._back_button) + if x == 0: + ba.widget(edit=btn, left_widget=self._back_button) + + print_name: Optional[Union[str, ba.Lstr]] + if name == '__default__': + print_name = self._pvars.default_list_name + else: + print_name = name + ba.textwidget(parent=self._subcontainer, + text=print_name, + position=(pos[0] + button_width * 0.5, + pos[1] + button_height * 0.79), + size=(0, 0), + scale=button_width * 0.003, + maxwidth=button_width * 0.7, + draw_controller=btn, + h_align='center', + v_align='center') + + # Poke into this playlist and see if we can display some of + # its maps. + map_images = [] + try: + map_textures = [] + map_texture_entries = [] + if name == '__default__': + if self._sessiontype is ba.FreeForAllSession: + playlist = (get_default_free_for_all_playlist()) + elif self._sessiontype is ba.TeamsSession: + playlist = get_default_teams_playlist() + else: + raise Exception("unrecognized session-type: " + + str(self._sessiontype)) + else: + if name not in bs_config[self._pvars.config_name + + ' Playlists']: + print( + 'NOT FOUND ERR', + bs_config[self._pvars.config_name + + ' Playlists']) + playlist = bs_config[self._pvars.config_name + + ' Playlists'][name] + playlist = filter_playlist(playlist, + self._sessiontype, + remove_unowned=False, + mark_unowned=True) + for entry in playlist: + mapname = entry['settings']['map'] + maptype: Optional[Type[ba.Map]] + try: + maptype = get_map_class(mapname) + except Exception: + maptype = None + if maptype is not None: + tex_name = maptype.get_preview_texture_name() + if tex_name is not None: + map_textures.append(tex_name) + map_texture_entries.append(entry) + if len(map_textures) >= 6: + break + + if len(map_textures) > 4: + img_rows = 3 + img_columns = 2 + scl = 0.33 + h_offs_img = 30 + v_offs_img = 126 + elif len(map_textures) > 2: + img_rows = 2 + img_columns = 2 + scl = 0.35 + h_offs_img = 24 + v_offs_img = 110 + elif len(map_textures) > 1: + img_rows = 2 + img_columns = 1 + scl = 0.5 + h_offs_img = 47 + v_offs_img = 105 + else: + img_rows = 1 + img_columns = 1 + scl = 0.75 + h_offs_img = 20 + v_offs_img = 65 + + v = None + for row in range(img_rows): + for col in range(img_columns): + tex_index = row * img_columns + col + if tex_index < len(map_textures): + entry = map_texture_entries[tex_index] + + owned = not (('is_unowned_map' in entry + and entry['is_unowned_map']) or + ('is_unowned_game' in entry + and entry['is_unowned_game'])) + + tex_name = map_textures[tex_index] + h = pos[0] + h_offs_img + scl * 250 * col + v = pos[1] + v_offs_img - scl * 130 * row + map_images.append( + ba.imagewidget( + parent=self._subcontainer, + size=(scl * 250.0, scl * 125.0), + position=(h, v), + texture=ba.gettexture(tex_name), + opacity=1.0 if owned else 0.25, + draw_controller=btn, + model_opaque=model_opaque, + model_transparent=model_transparent, + mask_texture=mask_tex)) + if not owned: + ba.imagewidget( + parent=self._subcontainer, + size=(scl * 100.0, scl * 100.0), + position=(h + scl * 75, v + scl * 10), + texture=ba.gettexture('lock'), + draw_controller=btn) + assert v is not None + v -= scl * 130.0 + + except Exception: + ba.print_exception("error listing playlist maps") + + if not map_images: + ba.textwidget(parent=self._subcontainer, + text='???', + scale=1.5, + size=(0, 0), + color=(1, 1, 1, 0.5), + h_align='center', + v_align='center', + draw_controller=btn, + position=(pos[0] + button_width * 0.5, + pos[1] + button_height * 0.5)) + + index += 1 + + if index >= count: + break + if index >= count: + break + self._customize_button = btn = ba.buttonwidget( + parent=self._subcontainer, + size=(100, 30), + position=(34 + h_offs_bottom, 50), + text_scale=0.6, + label=ba.Lstr(resource='customizeText'), + on_activate_call=self._on_customize_press, + color=(0.54, 0.52, 0.67), + textcolor=(0.7, 0.65, 0.7), + autoselect=True) + ba.widget(edit=btn, show_buffer_top=22, show_buffer_bottom=28) + self._restore_state() + + def on_play_options_window_run_game(self) -> None: + """(internal)""" + if not self._root_widget: + return + ba.containerwidget(edit=self._root_widget, transition='out_left') + + def _on_playlist_select(self, playlist_name: str) -> None: + self._selected_playlist = playlist_name + + def _update(self) -> None: + + # make sure config exists + if self._config_name_full not in ba.app.config: + ba.app.config[self._config_name_full] = {} + + cfg = ba.app.config[self._config_name_full] + if cfg != self._last_config: + self._last_config = copy.deepcopy(cfg) + self._refresh() + + def _on_playlist_press(self, button: ba.Widget, + playlist_name: str) -> None: + # pylint: disable=cyclic-import + from bastd.ui import playoptions + # Make sure the target playlist still exists. + try: + exists = (playlist_name == '__default__' or + playlist_name in ba.app.config[self._config_name_full]) + except Exception: + exists = False + if not exists: + return + + self._save_state() + playoptions.PlayOptionsWindow( + sessiontype=self._sessiontype, + scale_origin=button.get_screen_space_center(), + playlist=playlist_name, + delegate=self) + + def _on_customize_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.playlist import customizebrowser as cb + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (cb.PlaylistCustomizeBrowserWindow( + origin_widget=self._customize_button, + sessiontype=self._sessiontype).get_root_widget()) + + def _on_back_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import play + + # Store our selected playlist if that's changed. + if self._selected_playlist is not None: + try: + prev_sel = ba.app.config[self._pvars.config_name + + ' Playlist Selection'] + except Exception: + prev_sel = None + if self._selected_playlist != prev_sel: + cfg = ba.app.config + cfg[self._pvars.config_name + + ' Playlist Selection'] = self._selected_playlist + cfg.commit() + + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = (play.PlayWindow( + transition='in_left').get_root_widget()) + + def _save_state(self) -> None: + try: + sel = self._root_widget.get_selected_child() + if sel == self._back_button: + sel_name = 'Back' + elif sel == self._scrollwidget: + assert self._subcontainer is not None + subsel = self._subcontainer.get_selected_child() + if subsel == self._customize_button: + sel_name = 'Customize' + else: + sel_name = 'Scroll' + else: + raise Exception("unrecognized selected widget") + ba.app.window_states[self.__class__.__name__] = sel_name + except Exception: + ba.print_exception('error saving state for', self.__class__) + + def _restore_state(self) -> None: + try: + try: + sel_name = ba.app.window_states[self.__class__.__name__] + except Exception: + sel_name = None + if sel_name == 'Back': + sel = self._back_button + elif sel_name == 'Scroll': + sel = self._scrollwidget + elif sel_name == 'Customize': + sel = self._scrollwidget + ba.containerwidget(edit=self._subcontainer, + selected_child=self._customize_button, + visible_child=self._customize_button) + else: + sel = self._scrollwidget + ba.containerwidget(edit=self._root_widget, selected_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) diff --git a/assets/src/data/scripts/bastd/ui/playlist/customizebrowser.py b/assets/src/data/scripts/bastd/ui/playlist/customizebrowser.py new file mode 100644 index 00000000..6ae23574 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/playlist/customizebrowser.py @@ -0,0 +1,596 @@ +"""Provides UI for viewing/creating/editing playlists.""" + +from __future__ import annotations + +import copy +import time +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Type, Optional, Tuple, List + + +class PlaylistCustomizeBrowserWindow(ba.OldWindow): + """Window for viewing a playlist.""" + + def __init__(self, + sessiontype: Type[ba.Session], + transition: str = 'in_right', + select_playlist: str = None, + origin_widget: ba.Widget = None): + # Yes this needs tidying. + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + # pylint: disable=cyclic-import + from bastd.ui import playlist + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self._sessiontype = sessiontype + self._pvars = playlist.PlaylistTypeVars(sessiontype) + self._max_playlists = 30 + self._r = 'gameListWindow' + self._width = 750.0 if ba.app.small_ui else 650.0 + x_inset = 50.0 if ba.app.small_ui else 0.0 + self._height = (380.0 if ba.app.small_ui else + 420.0 if ba.app.med_ui else 500.0) + top_extra = 20.0 if ba.app.small_ui else 0.0 + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + top_extra), + transition=transition, + scale_origin_stack_offset=scale_origin, + scale=(2.05 if ba.app.small_ui else 1.5 if ba.app.med_ui else 1.0), + stack_offset=(0, -10) if ba.app.small_ui else (0, 0))) + + self._back_button = back_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(43 + x_inset, self._height - 60), + size=(160, 68), + scale=0.77, + autoselect=True, + text_scale=1.3, + label=ba.Lstr(resource='backText'), + button_type='back') + + ba.textwidget(parent=self._root_widget, + position=(0, self._height - 47), + size=(self._width, 25), + text=ba.Lstr(resource=self._r + '.titleText', + subs=[('${TYPE}', + self._pvars.window_title_name)]), + color=ba.app.heading_color, + maxwidth=290, + h_align="center", + v_align="center") + + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + v = self._height - 59.0 + h = 41 + x_inset + b_color = (0.6, 0.53, 0.63) + b_textcolor = (0.75, 0.7, 0.8) + self._lock_images: List[ba.Widget] = [] + lock_tex = ba.gettexture('lock') + + scl = (1.1 if ba.app.small_ui else 1.27 if ba.app.med_ui else 1.57) + scl *= 0.63 + v -= 65.0 * scl + new_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(90, 58.0 * scl), + on_activate_call=self._new_playlist, + color=b_color, + autoselect=True, + button_type='square', + textcolor=b_textcolor, + text_scale=0.7, + label=ba.Lstr(resource='newText', + fallback_resource=self._r + '.newText')) + self._lock_images.append( + ba.imagewidget(parent=self._root_widget, + size=(30, 30), + draw_controller=btn, + position=(h - 10, v + 58.0 * scl - 28), + texture=lock_tex)) + + v -= 65.0 * scl + self._edit_button = edit_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(90, 58.0 * scl), + on_activate_call=self._edit_playlist, + color=b_color, + autoselect=True, + textcolor=b_textcolor, + button_type='square', + text_scale=0.7, + label=ba.Lstr(resource='editText', + fallback_resource=self._r + '.editText')) + self._lock_images.append( + ba.imagewidget(parent=self._root_widget, + size=(30, 30), + draw_controller=btn, + position=(h - 10, v + 58.0 * scl - 28), + texture=lock_tex)) + + v -= 65.0 * scl + duplicate_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(90, 58.0 * scl), + on_activate_call=self._duplicate_playlist, + color=b_color, + autoselect=True, + textcolor=b_textcolor, + button_type='square', + text_scale=0.7, + label=ba.Lstr(resource='duplicateText', + fallback_resource=self._r + '.duplicateText')) + self._lock_images.append( + ba.imagewidget(parent=self._root_widget, + size=(30, 30), + draw_controller=btn, + position=(h - 10, v + 58.0 * scl - 28), + texture=lock_tex)) + + v -= 65.0 * scl + delete_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(90, 58.0 * scl), + on_activate_call=self._delete_playlist, + color=b_color, + autoselect=True, + textcolor=b_textcolor, + button_type='square', + text_scale=0.7, + label=ba.Lstr(resource='deleteText', + fallback_resource=self._r + '.deleteText')) + self._lock_images.append( + ba.imagewidget(parent=self._root_widget, + size=(30, 30), + draw_controller=btn, + position=(h - 10, v + 58.0 * scl - 28), + texture=lock_tex)) + v -= 65.0 * scl + self._import_button = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(90, 58.0 * scl), + on_activate_call=self._import_playlist, + color=b_color, + autoselect=True, + textcolor=b_textcolor, + button_type='square', + text_scale=0.7, + label=ba.Lstr(resource='importText')) + v -= 65.0 * scl + btn = ba.buttonwidget(parent=self._root_widget, + position=(h, v), + size=(90, 58.0 * scl), + on_activate_call=self._share_playlist, + color=b_color, + autoselect=True, + textcolor=b_textcolor, + button_type='square', + text_scale=0.7, + label=ba.Lstr(resource='shareText')) + self._lock_images.append( + ba.imagewidget(parent=self._root_widget, + size=(30, 30), + draw_controller=btn, + position=(h - 10, v + 58.0 * scl - 28), + texture=lock_tex)) + + v = self._height - 75 + self._scroll_height = self._height - 119 + scrollwidget = ba.scrollwidget(parent=self._root_widget, + position=(140 + x_inset, + v - self._scroll_height), + size=(self._width - (180 + 2 * x_inset), + self._scroll_height + 10), + highlight=False) + ba.widget(edit=back_button, right_widget=scrollwidget) + self._columnwidget = ba.columnwidget(parent=scrollwidget) + + h = 145 + + try: + self._do_randomize_val = ba.app.config[self._pvars.config_name + + ' Playlist Randomize'] + except Exception: + self._do_randomize_val = 0 + + h += 210 + + for btn in [new_button, delete_button, edit_button, duplicate_button]: + ba.widget(edit=btn, right_widget=scrollwidget) + ba.widget(edit=scrollwidget, + left_widget=new_button, + right_widget=_ba.get_special_widget('party_button') + if ba.app.toolbars else None) + + # make sure config exists + self._config_name_full = self._pvars.config_name + ' Playlists' + + if self._config_name_full not in ba.app.config: + ba.app.config[self._config_name_full] = {} + + self._selected_playlist_name: Optional[str] = None + self._selected_playlist_index: Optional[int] = None + self._playlist_widgets: List[ba.Widget] = [] + + self._refresh(select_playlist=select_playlist) + + ba.buttonwidget(edit=back_button, on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=back_button) + + ba.containerwidget(edit=self._root_widget, selected_child=scrollwidget) + + # Keep our lock images up to date/etc. + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + self._update() + + def _update(self) -> None: + from ba.internal import have_pro_options + have = have_pro_options() + for lock in self._lock_images: + ba.imagewidget(edit=lock, opacity=0.0 if have else 1.0) + + def _back(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.playlist import browser + if self._selected_playlist_name is not None: + cfg = ba.app.config + cfg[self._pvars.config_name + + ' Playlist Selection'] = self._selected_playlist_name + cfg.commit() + + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = (browser.PlaylistBrowserWindow( + transition='in_left', + sessiontype=self._sessiontype).get_root_widget()) + + def _select(self, name: str, index: int) -> None: + self._selected_playlist_name = name + self._selected_playlist_index = index + + def _run_selected_playlist(self) -> None: + # pylint: disable=cyclic-import + _ba.unlock_all_input() + try: + _ba.new_host_session(self._sessiontype) + except Exception: + from bastd import mainmenu + ba.print_exception("exception running session", self._sessiontype) + + # Drop back into a main menu session. + _ba.new_host_session(mainmenu.MainMenuSession) + + def _choose_playlist(self) -> None: + if self._selected_playlist_name is None: + return + self._save_playlist_selection() + ba.containerwidget(edit=self._root_widget, transition='out_left') + _ba.fade_screen(False, endcall=self._run_selected_playlist) + _ba.lock_all_input() + + def _refresh(self, select_playlist: str = None) -> None: + old_selection = self._selected_playlist_name + + # If there was no prev selection, look in prefs. + if old_selection is None: + try: + old_selection = ba.app.config[self._pvars.config_name + + ' Playlist Selection'] + except Exception: + pass + + old_selection_index = self._selected_playlist_index + + # Delete old. + while self._playlist_widgets: + self._playlist_widgets.pop().delete() + + items = list(ba.app.config[self._config_name_full].items()) + + # Make sure everything is unicode now. + items = [(i[0].decode(), i[1]) if not isinstance(i[0], str) else i + for i in items] + + items.sort(key=lambda x: x[0].lower()) + + items = [['__default__', None]] + items # Default is always first. + index = 0 + for pname, _ in items: + assert pname is not None + txtw = ba.textwidget( + parent=self._columnwidget, + size=(self._width - 40, 30), + maxwidth=self._width - 110, + text=self._get_playlist_display_name(pname), + h_align='left', + v_align='center', + color=(0.6, 0.6, 0.7, 1.0) if pname == '__default__' else + (0.85, 0.85, 0.85, 1), + always_highlight=True, + on_select_call=ba.Call(self._select, pname, index), + on_activate_call=ba.Call(self._edit_button.activate), + selectable=True) + ba.widget(edit=txtw, show_buffer_top=50, show_buffer_bottom=50) + + # Hitting up from top widget should jump to 'back' + if index == 0: + ba.widget(edit=txtw, up_widget=self._back_button) + + self._playlist_widgets.append(txtw) + + # Select this one if the user requested it. + if select_playlist is not None: + if pname == select_playlist: + ba.columnwidget(edit=self._columnwidget, + selected_child=txtw, + visible_child=txtw) + else: + # Select this one if it was previously selected. + # Go by index if there's one. + if old_selection_index is not None: + if index == old_selection_index: + ba.columnwidget(edit=self._columnwidget, + selected_child=txtw, + visible_child=txtw) + else: # Otherwise look by name. + if pname == old_selection: + ba.columnwidget(edit=self._columnwidget, + selected_child=txtw, + visible_child=txtw) + + index += 1 + + def _save_playlist_selection(self) -> None: + # Store the selected playlist in prefs. + # This serves dual purposes of letting us re-select it next time + # if we want and also lets us pass it to the game (since we reset + # the whole python environment that's not actually easy). + cfg = ba.app.config + cfg[self._pvars.config_name + + ' Playlist Selection'] = self._selected_playlist_name + cfg[self._pvars.config_name + + ' Playlist Randomize'] = self._do_randomize_val + cfg.commit() + + def _new_playlist(self) -> None: + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui.playlist import editcontroller + from bastd.ui import purchase + if not have_pro_options(): + purchase.PurchaseWindow(items=['pro']) + return + + # Clamp at our max playlist number. + if len(ba.app.config[self._config_name_full]) > self._max_playlists: + ba.screenmessage( + ba.Lstr(translate=('serverResponses', + 'Max number of playlists reached.')), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + # In case they cancel so we can return to this state. + self._save_playlist_selection() + + # Kick off the edit UI. + editcontroller.PlaylistEditController(sessiontype=self._sessiontype) + ba.containerwidget(edit=self._root_widget, transition='out_left') + + def _edit_playlist(self) -> None: + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui.playlist import editcontroller + from bastd.ui import purchase + if not have_pro_options(): + purchase.PurchaseWindow(items=['pro']) + return + if self._selected_playlist_name is None: + return + if self._selected_playlist_name == '__default__': + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource=self._r + + '.cantEditDefaultText')) + return + self._save_playlist_selection() + editcontroller.PlaylistEditController( + existing_playlist_name=self._selected_playlist_name, + sessiontype=self._sessiontype) + ba.containerwidget(edit=self._root_widget, transition='out_left') + + def _do_delete_playlist(self) -> None: + _ba.add_transaction({ + 'type': 'REMOVE_PLAYLIST', + 'playlistType': self._pvars.config_name, + 'playlistName': self._selected_playlist_name + }) + _ba.run_transactions() + ba.playsound(ba.getsound('shieldDown')) + + # (we don't use len()-1 here because the default list adds one) + assert self._selected_playlist_index is not None + if self._selected_playlist_index > len( + ba.app.config[self._pvars.config_name + ' Playlists']): + self._selected_playlist_index = len( + ba.app.config[self._pvars.config_name + ' Playlists']) + self._refresh() + + def _import_playlist(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.playlist import share + + # Gotta be signed in for this to work. + if _ba.get_account_state() != 'signed_in': + ba.screenmessage(ba.Lstr(resource='notSignedInErrorText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + share.SharePlaylistImportWindow(origin_widget=self._import_button, + on_success_callback=ba.WeakCall( + self._on_playlist_import_success)) + + def _on_playlist_import_success(self) -> None: + self._refresh() + + def _on_share_playlist_response(self, name: str, response: Any) -> None: + # pylint: disable=cyclic-import + from bastd.ui.playlist import share + if response is None: + ba.screenmessage( + ba.Lstr(resource='internal.unavailableNoConnectionText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + share.SharePlaylistResultsWindow(name, response) + + def _share_playlist(self) -> None: + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui import purchase + if not have_pro_options(): + purchase.PurchaseWindow(items=['pro']) + return + + # Gotta be signed in for this to work. + if _ba.get_account_state() != 'signed_in': + ba.screenmessage(ba.Lstr(resource='notSignedInErrorText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + if self._selected_playlist_name == '__default__': + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource=self._r + + '.cantShareDefaultText'), + color=(1, 0, 0)) + return + + if self._selected_playlist_name is None: + return + + _ba.add_transaction( + { + 'type': 'SHARE_PLAYLIST', + 'expire_time': time.time() + 5, + 'playlistType': self._pvars.config_name, + 'playlistName': self._selected_playlist_name + }, + callback=ba.WeakCall(self._on_share_playlist_response, + self._selected_playlist_name)) + _ba.run_transactions() + ba.screenmessage(ba.Lstr(resource='sharingText')) + + def _delete_playlist(self) -> None: + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui import purchase + from bastd.ui import confirm + if not have_pro_options(): + purchase.PurchaseWindow(items=['pro']) + return + + if self._selected_playlist_name is None: + return + if self._selected_playlist_name == '__default__': + ba.playsound(ba.getsound('error')) + ba.screenmessage( + ba.Lstr(resource=self._r + '.cantDeleteDefaultText')) + else: + confirm.ConfirmWindow( + ba.Lstr(resource=self._r + '.deleteConfirmText', + subs=[('${LIST}', self._selected_playlist_name)]), + self._do_delete_playlist, 450, 150) + + def _get_playlist_display_name(self, playlist: str) -> ba.Lstr: + if playlist == '__default__': + return self._pvars.default_list_name + return playlist if isinstance(playlist, ba.Lstr) else ba.Lstr( + value=playlist) + + def _duplicate_playlist(self) -> None: + # pylint: disable=too-many-branches + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui import purchase + if not have_pro_options(): + purchase.PurchaseWindow(items=['pro']) + return + if self._selected_playlist_name is None: + return + if self._selected_playlist_name == '__default__': + plst = self._pvars.get_default_list_call() + else: + plst = ba.app.config[self._config_name_full].get( + self._selected_playlist_name) + if plst is None: + ba.playsound(ba.getsound('error')) + return + + # clamp at our max playlist number + if len(ba.app.config[self._config_name_full]) > self._max_playlists: + ba.screenmessage( + ba.Lstr(translate=('serverResponses', + 'Max number of playlists reached.')), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + copy_text = ba.Lstr(resource='copyOfText').evaluate() + # get just 'Copy' or whatnot + copy_word = copy_text.replace('${NAME}', '').strip() + # find a valid dup name that doesn't exist + + test_index = 1 + base_name = self._get_playlist_display_name( + self._selected_playlist_name).evaluate() + + # If it looks like a copy, strip digits and spaces off the end. + if copy_word in base_name: + while base_name[-1].isdigit() or base_name[-1] == ' ': + base_name = base_name[:-1] + while True: + if copy_word in base_name: + test_name = base_name + else: + test_name = copy_text.replace('${NAME}', base_name) + if test_index > 1: + test_name += ' ' + str(test_index) + if test_name not in ba.app.config[self._config_name_full]: + break + test_index += 1 + + _ba.add_transaction({ + 'type': 'ADD_PLAYLIST', + 'playlistType': self._pvars.config_name, + 'playlistName': test_name, + 'playlist': copy.deepcopy(plst) + }) + _ba.run_transactions() + + ba.playsound(ba.getsound('gunCocking')) + self._refresh(select_playlist=test_name) diff --git a/assets/src/data/scripts/bastd/ui/playlist/edit.py b/assets/src/data/scripts/bastd/ui/playlist/edit.py new file mode 100644 index 00000000..494f26c4 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/playlist/edit.py @@ -0,0 +1,381 @@ +"""Provides a window for editing individual game playlists.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Optional, List + from bastd.ui.playlist.editcontroller import PlaylistEditController + + +class PlaylistEditWindow(ba.OldWindow): + """Window for editing an individual game playlist.""" + + def __init__(self, + editcontroller: PlaylistEditController, + transition: str = 'in_right'): + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + prev_selection: Optional[str] + self._editcontroller = editcontroller + self._r = 'editGameListWindow' + prev_selection = self._editcontroller.get_edit_ui_selection() + + self._width = 770 if ba.app.small_ui else 670 + x_inset = 50 if ba.app.small_ui else 0 + self._height = (400 + if ba.app.small_ui else 470 if ba.app.med_ui else 540) + + top_extra = 20 if ba.app.small_ui else 0 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + top_extra), + transition=transition, + scale=(2.0 if ba.app.small_ui else 1.3 if ba.app.med_ui else 1.0), + stack_offset=(0, -16) if ba.app.small_ui else (0, 0))) + cancel_button = ba.buttonwidget(parent=self._root_widget, + position=(35 + x_inset, + self._height - 60), + scale=0.8, + size=(175, 60), + autoselect=True, + label=ba.Lstr(resource='cancelText'), + text_scale=1.2) + save_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(self._width - (195 + x_inset), self._height - 60), + scale=0.8, + size=(190, 60), + autoselect=True, + left_widget=cancel_button, + label=ba.Lstr(resource='saveText'), + text_scale=1.2) + + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + + ba.widget(edit=cancel_button, + left_widget=cancel_button, + right_widget=save_button) + + ba.textwidget(parent=self._root_widget, + position=(-10, self._height - 50), + size=(self._width, 25), + text=ba.Lstr(resource=self._r + '.titleText'), + color=ba.app.title_color, + scale=1.05, + h_align="center", + v_align="center", + maxwidth=270) + + v = self._height - 115.0 + + self._scroll_width = self._width - (205 + 2 * x_inset) + + ba.textwidget(parent=self._root_widget, + text=ba.Lstr(resource=self._r + '.listNameText'), + position=(196 + x_inset, v + 31), + maxwidth=150, + color=(0.8, 0.8, 0.8, 0.5), + size=(0, 0), + scale=0.75, + h_align='right', + v_align='center') + + self._text_field = ba.textwidget( + parent=self._root_widget, + position=(210 + x_inset, v + 7), + size=(self._scroll_width - 53, 43), + text=self._editcontroller.get_name(), + h_align="left", + v_align="center", + max_chars=40, + autoselect=True, + color=(0.9, 0.9, 0.9, 1.0), + description=ba.Lstr(resource=self._r + '.listNameText'), + editable=True, + padding=4, + on_return_press_call=self._save_press_with_sound) + ba.widget(edit=cancel_button, down_widget=self._text_field) + + self._list_widgets: List[ba.Widget] = [] + + h = 40 + x_inset + v = self._height - 172.0 + + b_color = (0.6, 0.53, 0.63) + b_textcolor = (0.75, 0.7, 0.8) + + v -= 2.0 + v += 63 + + scl = (1.03 if ba.app.small_ui else 1.36 if ba.app.med_ui else 1.74) + v -= 63.0 * scl + + add_game_button = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(110, 61.0 * scl), + on_activate_call=self._add, + on_select_call=ba.Call(self._set_ui_selection, 'add_button'), + autoselect=True, + button_type='square', + color=b_color, + textcolor=b_textcolor, + text_scale=0.8, + label=ba.Lstr(resource=self._r + '.addGameText')) + ba.widget(edit=add_game_button, up_widget=self._text_field) + v -= 63.0 * scl + + self._edit_button = edit_game_button = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(110, 61.0 * scl), + on_activate_call=self._edit, + on_select_call=ba.Call(self._set_ui_selection, 'editButton'), + autoselect=True, + button_type='square', + color=b_color, + textcolor=b_textcolor, + text_scale=0.8, + label=ba.Lstr(resource=self._r + '.editGameText')) + v -= 63.0 * scl + + remove_game_button = ba.buttonwidget(parent=self._root_widget, + position=(h, v), + size=(110, 61.0 * scl), + text_scale=0.8, + on_activate_call=self._remove, + autoselect=True, + button_type='square', + color=b_color, + textcolor=b_textcolor, + label=ba.Lstr(resource=self._r + + '.removeGameText')) + v -= 40 + h += 9 + ba.buttonwidget(parent=self._root_widget, + position=(h, v), + size=(42, 35), + on_activate_call=self._move_up, + label=ba.charstr(ba.SpecialChar.UP_ARROW), + button_type='square', + color=b_color, + textcolor=b_textcolor, + autoselect=True, + repeat=True) + h += 52 + ba.buttonwidget(parent=self._root_widget, + position=(h, v), + size=(42, 35), + on_activate_call=self._move_down, + autoselect=True, + button_type='square', + color=b_color, + textcolor=b_textcolor, + label=ba.charstr(ba.SpecialChar.DOWN_ARROW), + repeat=True) + + v = self._height - 100 + scroll_height = self._height - 155 + scrollwidget = ba.scrollwidget( + parent=self._root_widget, + position=(160 + x_inset, v - scroll_height), + highlight=False, + on_select_call=ba.Call(self._set_ui_selection, 'gameList'), + size=(self._scroll_width, (scroll_height - 15))) + ba.widget(edit=scrollwidget, + left_widget=add_game_button, + right_widget=scrollwidget) + self._columnwidget = ba.columnwidget(parent=scrollwidget) + ba.widget(edit=self._columnwidget, up_widget=self._text_field) + + for button in [add_game_button, edit_game_button, remove_game_button]: + ba.widget(edit=button, + left_widget=button, + right_widget=scrollwidget) + + self._refresh() + + ba.buttonwidget(edit=cancel_button, on_activate_call=self._cancel) + ba.containerwidget(edit=self._root_widget, + cancel_button=cancel_button, + selected_child=scrollwidget) + + ba.buttonwidget(edit=save_button, on_activate_call=self._save_press) + ba.containerwidget(edit=self._root_widget, start_button=save_button) + + if prev_selection == 'add_button': + ba.containerwidget(edit=self._root_widget, + selected_child=add_game_button) + elif prev_selection == 'editButton': + ba.containerwidget(edit=self._root_widget, + selected_child=edit_game_button) + elif prev_selection == 'gameList': + ba.containerwidget(edit=self._root_widget, + selected_child=scrollwidget) + + def _set_ui_selection(self, selection: str) -> None: + self._editcontroller.set_edit_ui_selection(selection) + + def _cancel(self) -> None: + from bastd.ui.playlist import customizebrowser as cb + ba.playsound(ba.getsound('powerdown01')) + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (cb.PlaylistCustomizeBrowserWindow( + transition='in_left', + sessiontype=self._editcontroller.get_session_type(), + select_playlist=self._editcontroller.get_existing_playlist_name()). + get_root_widget()) + + def _add(self) -> None: + # store list name then tell the session to perform an add + self._editcontroller.set_name( + cast(str, ba.textwidget(query=self._text_field))) + self._editcontroller.add_game_pressed() + + def _edit(self) -> None: + # store list name then tell the session to perform an add + self._editcontroller.set_name( + cast(str, ba.textwidget(query=self._text_field))) + self._editcontroller.edit_game_pressed() + + def _save_press(self) -> None: + from bastd.ui.playlist import customizebrowser as cb + new_name = cast(str, ba.textwidget(query=self._text_field)) + if (new_name != self._editcontroller.get_existing_playlist_name() + and new_name in ba.app.config[ + self._editcontroller.get_config_name() + ' Playlists']): + ba.screenmessage( + ba.Lstr(resource=self._r + '.cantSaveAlreadyExistsText')) + ba.playsound(ba.getsound('error')) + return + if not new_name: + ba.playsound(ba.getsound('error')) + return + if not self._editcontroller.get_playlist(): + ba.screenmessage( + ba.Lstr(resource=self._r + '.cantSaveEmptyListText')) + ba.playsound(ba.getsound('error')) + return + # We couldn't actually replace the default list anyway, but disallow + # using its exact name to avoid confusion. + if new_name == self._editcontroller.get_default_list_name().evaluate(): + ba.screenmessage( + ba.Lstr(resource=self._r + '.cantOverwriteDefaultText')) + ba.playsound(ba.getsound('error')) + return + + # if we had an old one, delete it + if self._editcontroller.get_existing_playlist_name() is not None: + _ba.add_transaction({ + 'type': + 'REMOVE_PLAYLIST', + 'playlistType': + self._editcontroller.get_config_name(), + 'playlistName': + self._editcontroller.get_existing_playlist_name() + }) + + _ba.add_transaction({ + 'type': 'ADD_PLAYLIST', + 'playlistType': self._editcontroller.get_config_name(), + 'playlistName': new_name, + 'playlist': self._editcontroller.get_playlist() + }) + _ba.run_transactions() + + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.playsound(ba.getsound('gunCocking')) + ba.app.main_menu_window = (cb.PlaylistCustomizeBrowserWindow( + transition='in_left', + sessiontype=self._editcontroller.get_session_type(), + select_playlist=new_name).get_root_widget()) + + def _save_press_with_sound(self) -> None: + ba.playsound(ba.getsound('swish')) + self._save_press() + + def _select(self, index: int) -> None: + self._editcontroller.set_selected_index(index) + + def _refresh(self) -> None: + from ba.internal import getclass + # Need to grab this here as rebuilding the list will + # change it otherwise. + old_selection_index = self._editcontroller.get_selected_index() + + while self._list_widgets: + self._list_widgets.pop().delete() + for index, pentry in enumerate(self._editcontroller.get_playlist()): + + try: + cls = getclass(pentry['type'], subclassof=ba.GameActivity) + desc = cls.get_config_display_string(pentry) + except Exception: + ba.print_exception() + desc = "(invalid: '" + pentry['type'] + "')" + + txtw = ba.textwidget(parent=self._columnwidget, + size=(self._width - 80, 30), + on_select_call=ba.Call(self._select, index), + always_highlight=True, + color=(0.8, 0.8, 0.8, 1.0), + padding=0, + maxwidth=self._scroll_width * 0.93, + text=desc, + on_activate_call=self._edit_button.activate, + v_align='center', + selectable=True) + ba.widget(edit=txtw, show_buffer_top=50, show_buffer_bottom=50) + # wanna be able to jump up to the text field from the top one + if index == 0: + ba.widget(edit=txtw, up_widget=self._text_field) + self._list_widgets.append(txtw) + if old_selection_index == index: + ba.columnwidget(edit=self._columnwidget, + selected_child=txtw, + visible_child=txtw) + + def _move_down(self) -> None: + playlist = self._editcontroller.get_playlist() + index = self._editcontroller.get_selected_index() + if index >= len(playlist) - 1: + return + tmp = playlist[index] + playlist[index] = playlist[index + 1] + playlist[index + 1] = tmp + index += 1 + self._editcontroller.set_playlist(playlist) + self._editcontroller.set_selected_index(index) + self._refresh() + + def _move_up(self) -> None: + playlist = self._editcontroller.get_playlist() + index = self._editcontroller.get_selected_index() + if index < 1: + return + tmp = playlist[index] + playlist[index] = (playlist[index - 1]) + playlist[index - 1] = tmp + index -= 1 + self._editcontroller.set_playlist(playlist) + self._editcontroller.set_selected_index(index) + self._refresh() + + def _remove(self) -> None: + playlist = self._editcontroller.get_playlist() + index = self._editcontroller.get_selected_index() + if not playlist: + return + del playlist[index] + if index >= len(playlist): + index = len(playlist) - 1 + self._editcontroller.set_playlist(playlist) + self._editcontroller.set_selected_index(index) + ba.playsound(ba.getsound('shieldDown')) + self._refresh() diff --git a/assets/src/data/scripts/bastd/ui/playlist/editcontroller.py b/assets/src/data/scripts/bastd/ui/playlist/editcontroller.py new file mode 100644 index 00000000..29f15eb0 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/playlist/editcontroller.py @@ -0,0 +1,206 @@ +"""Defines a controller for wrangling playlist edit UIs.""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Type, List, Dict, Optional + + +class PlaylistEditController: + """Coordinates various UIs involved in playlist editing.""" + + def __init__(self, + sessiontype: Type[ba.Session], + existing_playlist_name: str = None, + transition: str = 'in_right', + playlist: List[Dict[str, Any]] = None, + playlist_name: str = None): + from ba.internal import preload_map_preview_media, filter_playlist + from bastd.ui import playlist as playlistui + from bastd.ui.playlist import edit as peditui + + bs_config = ba.app.config + + # Since we may be showing our map list momentarily, + # lets go ahead and preload all map preview textures. + preload_map_preview_media() + self._sessiontype = sessiontype + + self._editing_game = False + self._editing_game_type: Optional[Type[ba.GameActivity]] = None + self._pvars = playlistui.PlaylistTypeVars(sessiontype) + self._existing_playlist_name = existing_playlist_name + self._config_name_full = self._pvars.config_name + ' Playlists' + + # Make sure config exists. + if self._config_name_full not in bs_config: + bs_config[self._config_name_full] = {} + + self._selected_index = 0 + if existing_playlist_name: + self._name = existing_playlist_name + + # Filter out invalid games. + self._playlist = filter_playlist( + bs_config[self._pvars.config_name + + ' Playlists'][existing_playlist_name], + sessiontype=sessiontype, + remove_unowned=False) + self._edit_ui_selection = None + else: + if playlist is not None: + self._playlist = playlist + else: + self._playlist = [] + if playlist_name is not None: + self._name = playlist_name + else: + + # Find a good unused name. + i = 1 + while True: + self._name = ( + self._pvars.default_new_list_name.evaluate() + + ((' ' + str(i)) if i > 1 else '')) + if self._name not in bs_config[self._pvars.config_name + + ' Playlists']: + break + i += 1 + + # Also we want it to start with 'add' highlighted since its empty + # and that's all they can do. + self._edit_ui_selection = 'add_button' + + ba.app.main_menu_window = (peditui.PlaylistEditWindow( + editcontroller=self, transition=transition).get_root_widget()) + + def get_config_name(self) -> str: + """(internal)""" + return self._pvars.config_name + + def get_existing_playlist_name(self) -> Optional[str]: + """(internal)""" + return self._existing_playlist_name + + def get_edit_ui_selection(self) -> Optional[str]: + """(internal)""" + return self._edit_ui_selection + + def set_edit_ui_selection(self, selection: str) -> None: + """(internal)""" + self._edit_ui_selection = selection + + def get_name(self) -> str: + """(internal)""" + return self._name + + def set_name(self, name: str) -> None: + """(internal)""" + self._name = name + + def get_playlist(self) -> List[Dict[str, Any]]: + """Return the current state of the edited playlist.""" + return copy.deepcopy(self._playlist) + + def set_playlist(self, playlist: List[Dict[str, Any]]) -> None: + """Set the playlist contents.""" + self._playlist = copy.deepcopy(playlist) + + def get_session_type(self) -> Type[ba.Session]: + """Return the ba.Session type for this edit-session.""" + return self._sessiontype + + def get_selected_index(self) -> int: + """Return the index of the selected playlist.""" + return self._selected_index + + def get_default_list_name(self) -> ba.Lstr: + """(internal)""" + return self._pvars.default_list_name + + def set_selected_index(self, index: int) -> None: + """Sets the selected playlist index.""" + self._selected_index = index + + def add_game_pressed(self) -> None: + """(internal)""" + from bastd.ui.playlist import addgame + ba.containerwidget(edit=ba.app.main_menu_window, transition='out_left') + ba.app.main_menu_window = (addgame.PlaylistAddGameWindow( + editcontroller=self).get_root_widget()) + + def edit_game_pressed(self) -> None: + """Should be called by supplemental UIs when a game is to be edited.""" + from ba.internal import getclass + if not self._playlist: + return + self._show_edit_ui(gametype=getclass( + self._playlist[self._selected_index]['type'], + subclassof=ba.GameActivity), + config=self._playlist[self._selected_index]) + + def add_game_cancelled(self) -> None: + """(internal)""" + from bastd.ui.playlist import edit as pedit + ba.containerwidget(edit=ba.app.main_menu_window, + transition='out_right') + ba.app.main_menu_window = (pedit.PlaylistEditWindow( + editcontroller=self, transition='in_left').get_root_widget()) + + def _show_edit_ui(self, gametype: Type[ba.GameActivity], + config: Optional[Dict[str, Any]]) -> None: + self._editing_game = (config is not None) + self._editing_game_type = gametype + assert self._sessiontype is not None + gametype.create_config_ui(self._sessiontype, copy.deepcopy(config), + self._edit_game_done) + + def add_game_type_selected(self, gametype: Type[ba.GameActivity]) -> None: + """(internal)""" + self._show_edit_ui(gametype=gametype, config=None) + + def _edit_game_done(self, config: Optional[Dict[str, Any]]) -> None: + from bastd.ui.playlist import edit as pedit + from bastd.ui.playlist import addgame + from ba.internal import get_type_name + if config is None: + # If we were editing, go back to our list. + if self._editing_game: + ba.playsound(ba.getsound('powerdown01')) + ba.containerwidget(edit=ba.app.main_menu_window, + transition='out_right') + ba.app.main_menu_window = (pedit.PlaylistEditWindow( + editcontroller=self, + transition='in_left').get_root_widget()) + + # Otherwise we were adding; go back to the add type choice list. + else: + ba.containerwidget(edit=ba.app.main_menu_window, + transition='out_right') + ba.app.main_menu_window = (addgame.PlaylistAddGameWindow( + editcontroller=self, + transition='in_left').get_root_widget()) + else: + # Make sure type is in there. + assert self._editing_game_type is not None + config['type'] = get_type_name(self._editing_game_type) + + if self._editing_game: + self._playlist[self._selected_index] = copy.deepcopy(config) + else: + # Add a new entry to the playlist. + insert_index = min(len(self._playlist), + self._selected_index + 1) + self._playlist.insert(insert_index, copy.deepcopy(config)) + self._selected_index = insert_index + + ba.playsound(ba.getsound('gunCocking')) + ba.containerwidget(edit=ba.app.main_menu_window, + transition='out_right') + ba.app.main_menu_window = (pedit.PlaylistEditWindow( + editcontroller=self, transition='in_left').get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/playlist/editgame.py b/assets/src/data/scripts/bastd/ui/playlist/editgame.py new file mode 100644 index 00000000..c9e7159b --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/playlist/editgame.py @@ -0,0 +1,492 @@ +"""Provides UI for editing a game in a playlist.""" + +from __future__ import annotations + +import copy +import random +from typing import TYPE_CHECKING, cast + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Type, Any, Dict, Callable, Optional, Union + + +class PlaylistEditGameWindow(ba.OldWindow): + """Window for editing a game in a playlist.""" + + def __init__(self, + gameclass: Type[ba.GameActivity], + sessiontype: Type[ba.Session], + config: Optional[Dict[str, Any]], + completion_call: Callable[[Optional[Dict[str, Any]]], Any], + default_selection: str = None, + transition: str = 'in_right', + edit_info: Dict[str, Any] = None): + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + from ba.internal import (get_unowned_maps, get_filtered_map_name, + get_map_class, get_map_display_string) + self._gameclass = gameclass + self._sessiontype = sessiontype + + # If we're within an editing session we get passed edit_info + # (returning from map selection window, etc). + if edit_info is not None: + self._edit_info = edit_info + + # ..otherwise determine whether we're adding or editing a game based + # on whether an existing config was passed to us. + else: + if config is None: + self._edit_info = {'editType': 'add'} + else: + self._edit_info = {'editType': 'edit'} + + self._r = 'gameSettingsWindow' + + valid_maps = gameclass.get_supported_maps(sessiontype) + if not valid_maps: + ba.screenmessage(ba.Lstr(resource='noValidMapsErrorText')) + raise Exception("No valid maps") + + self._settings_defs = gameclass.get_settings(sessiontype) + self._completion_call = completion_call + + # To start with, pick a random map out of the ones we own. + unowned_maps = get_unowned_maps() + valid_maps_owned = [m for m in valid_maps if m not in unowned_maps] + if valid_maps_owned: + self._map = valid_maps[random.randrange(len(valid_maps_owned))] + + # Hmmm.. we own none of these maps.. just pick a random un-owned one + # I guess.. should this ever happen? + else: + self._map = valid_maps[random.randrange(len(valid_maps))] + + is_add = (self._edit_info['editType'] == 'add') + + # If there's a valid map name in the existing config, use that. + try: + if (config is not None and 'settings' in config + and 'map' in config['settings']): + filtered_map_name = get_filtered_map_name( + config['settings']['map']) + if filtered_map_name in valid_maps: + self._map = filtered_map_name + except Exception: + ba.print_exception('exception getting map for editor') + + if config is not None and 'settings' in config: + self._settings = config['settings'] + else: + self._settings = {} + + self._choice_selections: Dict[str, int] = {} + + width = 720 if ba.app.small_ui else 620 + x_inset = 50 if ba.app.small_ui else 0 + height = (365 if ba.app.small_ui else 460 if ba.app.med_ui else 550) + spacing = 52 + y_extra = 15 + y_extra2 = 21 + + map_tex_name = (get_map_class(self._map).get_preview_texture_name()) + if map_tex_name is None: + raise Exception("no map preview tex found for" + self._map) + map_tex = ba.gettexture(map_tex_name) + + top_extra = 20 if ba.app.small_ui else 0 + super().__init__(root_widget=ba.containerwidget( + size=(width, height + top_extra), + transition=transition, + scale=( + 2.19 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0), + stack_offset=(0, -17) if ba.app.small_ui else (0, 0))) + + btn = ba.buttonwidget( + parent=self._root_widget, + position=(45 + x_inset, height - 82 + y_extra2), + size=(180, 70) if is_add else (180, 65), + label=ba.Lstr(resource='backText') if is_add else ba.Lstr( + resource='cancelText'), + button_type='back' if is_add else None, + autoselect=True, + scale=0.75, + text_scale=1.3, + on_activate_call=ba.Call(self._cancel)) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + add_button = ba.buttonwidget( + parent=self._root_widget, + position=(width - (193 + x_inset), height - 82 + y_extra2), + size=(200, 65), + scale=0.75, + text_scale=1.3, + label=ba.Lstr(resource=self._r + + '.addGameText') if is_add else ba.Lstr( + resource='doneText')) + + if ba.app.toolbars: + pbtn = _ba.get_special_widget('party_button') + ba.widget(edit=add_button, right_widget=pbtn, up_widget=pbtn) + + ba.textwidget(parent=self._root_widget, + position=(-8, height - 70 + y_extra2), + size=(width, 25), + text=gameclass.get_display_string(), + color=ba.app.title_color, + maxwidth=235, + scale=1.1, + h_align="center", + v_align="center") + + map_height = 100 + + scroll_height = map_height + 10 # map select and margin + + # Calc our total height we'll need + scroll_height += spacing * len(self._settings_defs) + + scroll_width = width - (86 + 2 * x_inset) + self._scrollwidget = ba.scrollwidget(parent=self._root_widget, + position=(44 + x_inset, + 35 + y_extra), + size=(scroll_width, height - 116), + highlight=False) + self._subcontainer = cnt = ba.containerwidget( + parent=self._scrollwidget, + size=(scroll_width, scroll_height), + background=False) + + # So selection loops through everything and doesn't get stuck in + # sub-containers. + ba.containerwidget(edit=self._scrollwidget, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + ba.containerwidget(edit=cnt, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + + v = scroll_height - 5 + h = -40 + + # Keep track of all the selectable widgets we make so we can wire + # them up conveniently. + widget_column = [] + + # Map select button. + ba.textwidget(parent=self._subcontainer, + position=(h + 49, v - 63), + size=(100, 30), + maxwidth=110, + text=ba.Lstr(resource='mapText'), + h_align="left", + color=(0.8, 0.8, 0.8, 1.0), + v_align="center") + + ba.imagewidget( + parent=self._subcontainer, + size=(256 * 0.7, 125 * 0.7), + position=(h + 261 - 128 + 128.0 * 0.56, v - 90), + texture=map_tex, + model_opaque=ba.getmodel('level_select_button_opaque'), + model_transparent=ba.getmodel('level_select_button_transparent'), + mask_texture=ba.gettexture('mapPreviewMask')) + map_button = btn = ba.buttonwidget( + parent=self._subcontainer, + size=(140, 60), + position=(h + 448, v - 72), + on_activate_call=ba.Call(self._select_map), + scale=0.7, + label=ba.Lstr(resource='mapSelectText')) + widget_column.append([btn]) + + ba.textwidget(parent=self._subcontainer, + position=(h + 363 - 123, v - 114), + size=(100, 30), + flatness=1.0, + shadow=1.0, + scale=0.55, + maxwidth=256 * 0.7 * 0.8, + text=get_map_display_string(self._map), + h_align="center", + color=(0.6, 1.0, 0.6, 1.0), + v_align="center") + v -= map_height + + for setting_name, setting in self._settings_defs: + value = setting['default'] + value_type = type(value) + + # Now, if there's an existing value for it in the config, + # override with that. + try: + if (config is not None and 'settings' in config + and setting_name in config['settings']): + value = value_type(config['settings'][setting_name]) + except Exception: + ba.print_exception() + + # Shove the starting value in there to start. + self._settings[setting_name] = value + + name_translated = self._get_localized_setting_name(setting_name) + + mw1 = 280 + mw2 = 70 + + # Handle types with choices specially: + if 'choices' in setting: + for choice in setting['choices']: + if len(choice) != 2: + raise Exception( + "Expected 2-member tuples for 'choices'; got: " + + repr(choice)) + if not isinstance(choice[0], str): + raise Exception( + "First value for choice tuple must be a str; got: " + + repr(choice)) + if not isinstance(choice[1], value_type): + raise Exception( + "Choice type does not match default value; choice:" + + repr(choice) + "; setting:" + repr(setting)) + if value_type not in (int, float): + raise Exception( + "Choice type setting must have int or float default; " + "got: " + repr(setting)) + + # Start at the choice corresponding to the default if possible. + self._choice_selections[setting_name] = 0 + for index, choice in enumerate(setting['choices']): + if choice[1] == value: + self._choice_selections[setting_name] = index + break + + v -= spacing + ba.textwidget(parent=self._subcontainer, + position=(h + 50, v), + size=(100, 30), + maxwidth=mw1, + text=name_translated, + h_align="left", + color=(0.8, 0.8, 0.8, 1.0), + v_align="center") + txt = ba.textwidget( + parent=self._subcontainer, + position=(h + 509 - 95, v), + size=(0, 28), + text=self._get_localized_setting_name(setting['choices'][ + self._choice_selections[setting_name]][0]), + editable=False, + color=(0.6, 1.0, 0.6, 1.0), + maxwidth=mw2, + h_align="right", + v_align="center", + padding=2) + btn1 = ba.buttonwidget(parent=self._subcontainer, + position=(h + 509 - 50 - 1, v), + size=(28, 28), + label="<", + autoselect=True, + on_activate_call=ba.Call( + self._choice_inc, setting_name, txt, + setting, -1), + repeat=True) + btn2 = ba.buttonwidget(parent=self._subcontainer, + position=(h + 509 + 5, v), + size=(28, 28), + label=">", + autoselect=True, + on_activate_call=ba.Call( + self._choice_inc, setting_name, txt, + setting, 1), + repeat=True) + widget_column.append([btn1, btn2]) + + elif value_type in [int, float]: + v -= spacing + try: + min_value = setting['min_value'] + except Exception: + min_value = 0 + try: + max_value = setting['max_value'] + except Exception: + max_value = 9999 + try: + increment = setting['increment'] + except Exception: + increment = 1 + ba.textwidget(parent=self._subcontainer, + position=(h + 50, v), + size=(100, 30), + text=name_translated, + h_align="left", + color=(0.8, 0.8, 0.8, 1.0), + v_align="center", + maxwidth=mw1) + txt = ba.textwidget(parent=self._subcontainer, + position=(h + 509 - 95, v), + size=(0, 28), + text=str(value), + editable=False, + color=(0.6, 1.0, 0.6, 1.0), + maxwidth=mw2, + h_align="right", + v_align="center", + padding=2) + btn1 = ba.buttonwidget(parent=self._subcontainer, + position=(h + 509 - 50 - 1, v), + size=(28, 28), + label="-", + autoselect=True, + on_activate_call=ba.Call( + self._inc, txt, min_value, + max_value, -increment, value_type, + setting_name), + repeat=True) + btn2 = ba.buttonwidget(parent=self._subcontainer, + position=(h + 509 + 5, v), + size=(28, 28), + label="+", + autoselect=True, + on_activate_call=ba.Call( + self._inc, txt, min_value, + max_value, increment, value_type, + setting_name), + repeat=True) + widget_column.append([btn1, btn2]) + + elif value_type == bool: + v -= spacing + ba.textwidget(parent=self._subcontainer, + position=(h + 50, v), + size=(100, 30), + text=name_translated, + h_align="left", + color=(0.8, 0.8, 0.8, 1.0), + v_align="center", + maxwidth=mw1) + txt = ba.textwidget( + parent=self._subcontainer, + position=(h + 509 - 95, v), + size=(0, 28), + text=ba.Lstr(resource='onText') if value else ba.Lstr( + resource='offText'), + editable=False, + color=(0.6, 1.0, 0.6, 1.0), + maxwidth=mw2, + h_align="right", + v_align="center", + padding=2) + cbw = ba.checkboxwidget(parent=self._subcontainer, + text='', + position=(h + 505 - 50 - 5, v - 2), + size=(200, 30), + autoselect=True, + textcolor=(0.8, 0.8, 0.8), + value=value, + on_value_change_call=ba.Call( + self._check_value_change, + setting_name, txt)) + widget_column.append([cbw]) + + else: + raise Exception() + + # ok now wire up the column + try: + # pylint: disable=unsubscriptable-object + prev_widgets = None + for cwdg in widget_column: + if prev_widgets is not None: + # wire our rightmost to their rightmost + ba.widget(edit=prev_widgets[-1], down_widget=cwdg[-1]) + ba.widget(cwdg[-1], up_widget=prev_widgets[-1]) + # wire our leftmost to their leftmost + ba.widget(edit=prev_widgets[0], down_widget=cwdg[0]) + ba.widget(cwdg[0], up_widget=prev_widgets[0]) + prev_widgets = cwdg + except Exception: + ba.print_exception( + 'error wiring up game-settings-select widget column') + + ba.buttonwidget(edit=add_button, on_activate_call=ba.Call(self._add)) + ba.containerwidget(edit=self._root_widget, + selected_child=add_button, + start_button=add_button) + + if default_selection == 'map': + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget) + ba.containerwidget(edit=self._subcontainer, + selected_child=map_button) + + def _get_localized_setting_name(self, name: str) -> ba.Lstr: + return ba.Lstr(translate=('settingNames', name)) + + def _select_map(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.playlist import mapselect + + # Replace ourself with the map-select UI. + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (mapselect.PlaylistMapSelectWindow( + self._gameclass, self._sessiontype, + copy.deepcopy(self._getconfig()), self._edit_info, + self._completion_call).get_root_widget()) + + def _choice_inc(self, setting_name: str, widget: ba.Widget, + setting: Dict[str, Any], increment: int) -> None: + choices = setting['choices'] + if increment > 0: + self._choice_selections[setting_name] = min( + len(choices) - 1, self._choice_selections[setting_name] + 1) + else: + self._choice_selections[setting_name] = max( + 0, self._choice_selections[setting_name] - 1) + ba.textwidget(edit=widget, + text=self._get_localized_setting_name( + choices[self._choice_selections[setting_name]][0])) + self._settings[setting_name] = choices[ + self._choice_selections[setting_name]][1] + + def _cancel(self) -> None: + self._completion_call(None) + + def _check_value_change(self, setting_name: str, widget: ba.Widget, + value: int) -> None: + ba.textwidget(edit=widget, + text=ba.Lstr(resource='onText') if value else ba.Lstr( + resource='offText')) + self._settings[setting_name] = value + + def _getconfig(self) -> Dict[str, Any]: + settings = copy.deepcopy(self._settings) + settings['map'] = self._map + return {'settings': settings} + + def _add(self) -> None: + self._completion_call(copy.deepcopy(self._getconfig())) + + def _inc(self, ctrl: ba.Widget, min_val: Union[int, float], + max_val: Union[int, float], increment: Union[int, float], + setting_type: Type, setting_name: str) -> None: + if setting_type == float: + val = float(cast(str, ba.textwidget(query=ctrl))) + else: + val = int(cast(str, ba.textwidget(query=ctrl))) + val += increment + val = max(min_val, min(val, max_val)) + if setting_type == float: + ba.textwidget(edit=ctrl, text=str(round(val, 2))) + elif setting_type == int: + ba.textwidget(edit=ctrl, text=str(int(val))) + else: + raise Exception('invalid vartype: ' + str(setting_type)) + self._settings[setting_name] = val diff --git a/assets/src/data/scripts/bastd/ui/playlist/mapselect.py b/assets/src/data/scripts/bastd/ui/playlist/mapselect.py new file mode 100644 index 00000000..5c12a362 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/playlist/mapselect.py @@ -0,0 +1,250 @@ +"""Provides UI for selecting maps in playlists.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Type, Any, Callable, Dict, List, Tuple, Optional + + +class PlaylistMapSelectWindow(ba.OldWindow): + """Window to select a map.""" + + def __init__(self, + gameclass: Type[ba.GameActivity], + sessiontype: Type[ba.Session], + config: Dict[str, Any], + edit_info: Dict[str, Any], + completion_call: Callable[[Optional[Dict[str, Any]]], Any], + transition: str = 'in_right'): + from ba.internal import get_filtered_map_name + self._gameclass = gameclass + self._sessiontype = sessiontype + self._config = config + self._completion_call = completion_call + self._edit_info = edit_info + self._maps: List[Tuple[str, ba.Texture]] = [] + try: + self._previous_map = get_filtered_map_name( + config['settings']['map']) + except Exception: + self._previous_map = '' + + width = 715 if ba.app.small_ui else 615 + x_inset = 50 if ba.app.small_ui else 0 + height = (400 if ba.app.small_ui else 480 if ba.app.med_ui else 600) + + top_extra = 20 if ba.app.small_ui else 0 + super().__init__(root_widget=ba.containerwidget( + size=(width, height + top_extra), + transition=transition, + scale=(2.17 if ba.app.small_ui else 1.3 if ba.app.med_ui else 1.0), + stack_offset=(0, -27) if ba.app.small_ui else (0, 0))) + + self._cancel_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(38 + x_inset, height - 67), + size=(140, 50), + scale=0.9, + text_scale=1.0, + autoselect=True, + label=ba.Lstr(resource='cancelText'), + on_activate_call=self._cancel) + + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, height - 46), + size=(0, 0), + maxwidth=260, + scale=1.1, + text=ba.Lstr(resource='mapSelectTitleText', + subs=[('${GAME}', + self._gameclass.get_display_string()) + ]), + color=ba.app.title_color, + h_align="center", + v_align="center") + v = height - 70 + self._scroll_width = width - (80 + 2 * x_inset) + self._scroll_height = height - 140 + + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + position=(40 + x_inset, v - self._scroll_height), + size=(self._scroll_width, self._scroll_height)) + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget) + ba.containerwidget(edit=self._scrollwidget, claims_left_right=True) + + self._subcontainer: Optional[ba.Widget] = None + self._refresh() + + def _refresh(self, select_get_more_maps_button: bool = False) -> None: + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + from ba.internal import (get_unowned_maps, get_map_class, + get_map_display_string) + + # Kill old. + if self._subcontainer is not None: + self._subcontainer.delete() + + model_opaque = ba.getmodel('level_select_button_opaque') + model_transparent = ba.getmodel('level_select_button_transparent') + + self._maps = [] + map_list = self._gameclass.get_supported_maps(self._sessiontype) + map_list_sorted = list(map_list) + map_list_sorted.sort() + unowned_maps = get_unowned_maps() + + for mapname in map_list_sorted: + + # Disallow ones we don't own. + if mapname in unowned_maps: + continue + map_tex_name = (get_map_class(mapname).get_preview_texture_name()) + if map_tex_name is not None: + try: + map_tex = ba.gettexture(map_tex_name) + self._maps.append((mapname, map_tex)) + except Exception: + print('invalid map preview texture: "' + map_tex_name + + '"') + else: + print('Error: no map preview texture for map:', mapname) + + count = len(self._maps) + columns = 2 + rows = int(math.ceil(float(count) / columns)) + button_width = 220 + button_height = button_width * 0.5 + button_buffer_h = 16 + button_buffer_v = 19 + self._sub_width = self._scroll_width * 0.95 + self._sub_height = 5 + rows * (button_height + + 2 * button_buffer_v) + 100 + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._sub_width, + self._sub_height), + background=False) + index = 0 + mask_texture = ba.gettexture('mapPreviewMask') + h_offs = 130 if len(self._maps) == 1 else 0 + for y in range(rows): + for x in range(columns): + pos = (x * (button_width + 2 * button_buffer_h) + + button_buffer_h + h_offs, self._sub_height - (y + 1) * + (button_height + 2 * button_buffer_v) + 12) + btn = ba.buttonwidget(parent=self._subcontainer, + button_type='square', + size=(button_width, button_height), + autoselect=True, + texture=self._maps[index][1], + mask_texture=mask_texture, + model_opaque=model_opaque, + model_transparent=model_transparent, + label='', + color=(1, 1, 1), + on_activate_call=ba.Call( + self._select_with_delay, + self._maps[index][0]), + position=pos) + if x == 0: + ba.widget(edit=btn, left_widget=self._cancel_button) + if y == 0: + ba.widget(edit=btn, up_widget=self._cancel_button) + if x == columns - 1 and ba.app.toolbars: + ba.widget( + edit=btn, + right_widget=_ba.get_special_widget("party_button")) + + ba.widget(edit=btn, show_buffer_top=60, show_buffer_bottom=60) + if self._maps[index][0] == self._previous_map: + ba.containerwidget(edit=self._subcontainer, + selected_child=btn, + visible_child=btn) + name = get_map_display_string(self._maps[index][0]) + ba.textwidget(parent=self._subcontainer, + text=name, + position=(pos[0] + button_width * 0.5, + pos[1] - 12), + size=(0, 0), + scale=0.5, + maxwidth=button_width, + draw_controller=btn, + h_align='center', + v_align='center', + color=(0.8, 0.8, 0.8, 0.8)) + index += 1 + + if index >= count: + break + if index >= count: + break + self._get_more_maps_button = btn = ba.buttonwidget( + parent=self._subcontainer, + size=(self._sub_width * 0.8, 60), + position=(self._sub_width * 0.1, 30), + label=ba.Lstr(resource='mapSelectGetMoreMapsText'), + on_activate_call=self._on_store_press, + color=(0.6, 0.53, 0.63), + textcolor=(0.75, 0.7, 0.8), + autoselect=True) + ba.widget(edit=btn, show_buffer_top=30, show_buffer_bottom=30) + if select_get_more_maps_button: + ba.containerwidget(edit=self._subcontainer, + selected_child=btn, + visible_child=btn) + + def _on_store_press(self) -> None: + from bastd.ui import account + from bastd.ui.store import browser + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + browser.StoreBrowserWindow(modal=True, + show_tab='maps', + on_close_call=self._on_store_close, + origin_widget=self._get_more_maps_button) + + def _on_store_close(self) -> None: + self._refresh(select_get_more_maps_button=True) + + def _select(self, map_name: str) -> None: + from bastd.ui.playlist import editgame + self._config['settings']['map'] = map_name + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (editgame.PlaylistEditGameWindow( + self._gameclass, + self._sessiontype, + self._config, + self._completion_call, + default_selection='map', + transition='in_left', + edit_info=self._edit_info).get_root_widget()) + + def _select_with_delay(self, map_name: str) -> None: + _ba.lock_all_input() + ba.timer(0.1, _ba.unlock_all_input, timetype=ba.TimeType.REAL) + ba.timer(0.1, + ba.WeakCall(self._select, map_name), + timetype=ba.TimeType.REAL) + + def _cancel(self) -> None: + from bastd.ui.playlist import editgame + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (editgame.PlaylistEditGameWindow( + self._gameclass, + self._sessiontype, + self._config, + self._completion_call, + default_selection='map', + transition='in_left', + edit_info=self._edit_info).get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/playlist/share.py b/assets/src/data/scripts/bastd/ui/playlist/share.py new file mode 100644 index 00000000..3dfd5cee --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/playlist/share.py @@ -0,0 +1,129 @@ +"""UI functionality for importing shared playlists.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.ui import promocode + +if TYPE_CHECKING: + from typing import Any, Callable, Dict, Optional, Tuple + + +class SharePlaylistImportWindow(promocode.PromoCodeWindow): + """Window for importing a shared playlist.""" + + def __init__(self, + origin_widget: ba.Widget = None, + on_success_callback: Callable[[], Any] = None): + promocode.PromoCodeWindow.__init__(self, + modal=True, + origin_widget=origin_widget) + self._on_success_callback = on_success_callback + + def _on_import_response(self, response: Optional[Dict[str, Any]]) -> None: + if response is None: + ba.screenmessage(ba.Lstr(resource='errorText'), color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + if response['playlistType'] == 'Team Tournament': + playlist_type_name = ba.Lstr(resource='playModes.teamsText') + elif response['playlistType'] == 'Free-for-All': + playlist_type_name = ba.Lstr(resource='playModes.freeForAllText') + else: + playlist_type_name = ba.Lstr(value=response['playlistType']) + + ba.screenmessage(ba.Lstr(resource='importPlaylistSuccessText', + subs=[('${TYPE}', playlist_type_name), + ('${NAME}', response['playlistName'])]), + color=(0, 1, 0)) + ba.playsound(ba.getsound('gunCocking')) + if self._on_success_callback is not None: + self._on_success_callback() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + + def _do_enter(self) -> None: + _ba.add_transaction( + { + 'type': 'IMPORT_PLAYLIST', + 'expire_time': time.time() + 5, + 'code': ba.textwidget(query=self._text_field) + }, + callback=ba.WeakCall(self._on_import_response)) + _ba.run_transactions() + ba.screenmessage(ba.Lstr(resource='importingText')) + + +class SharePlaylistResultsWindow(ba.OldWindow): + """Window for sharing playlists.""" + + def __init__(self, + name: str, + data: str, + origin: Tuple[float, float] = (0.0, 0.0)): + del origin # unused arg + self._width = 450 + self._height = 300 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + color=(0.45, 0.63, 0.15), + transition='in_scale', + scale=1.8 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0)) + ba.playsound(ba.getsound('cashRegister')) + ba.playsound(ba.getsound('swish')) + + self._cancel_button = ba.buttonwidget(parent=self._root_widget, + scale=0.7, + position=(40, self._height - 40), + size=(50, 50), + label='', + on_activate_call=self.close, + autoselect=True, + color=(0.45, 0.63, 0.15), + icon=ba.gettexture('crossOut'), + iconscale=1.2) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.745), + size=(0, 0), + color=ba.app.infotextcolor, + scale=1.0, + flatness=1.0, + h_align="center", + v_align="center", + text=ba.Lstr(resource='exportSuccessText', + subs=[('${NAME}', name)]), + maxwidth=self._width * 0.85) + + ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.645), + size=(0, 0), + color=ba.app.infotextcolor, + scale=0.6, + flatness=1.0, + h_align="center", + v_align="center", + text=ba.Lstr(resource='importPlaylistCodeInstructionsText'), + maxwidth=self._width * 0.85) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.4), + size=(0, 0), + color=(1.0, 3.0, 1.0), + scale=2.3, + h_align="center", + v_align="center", + text=data, + maxwidth=self._width * 0.85) + + def close(self) -> None: + """Close the window.""" + ba.containerwidget(edit=self._root_widget, transition='out_scale') diff --git a/assets/src/data/scripts/bastd/ui/playoptions.py b/assets/src/data/scripts/bastd/ui/playoptions.py new file mode 100644 index 00000000..b9f18a8a --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/playoptions.py @@ -0,0 +1,426 @@ +"""Provides a window for configuring play options.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.ui import popup + +if TYPE_CHECKING: + from typing import Any, Type, Tuple, Optional, Union + + +class PlayOptionsWindow(popup.PopupWindow): + """A popup window for configuring play options.""" + + def __init__(self, + sessiontype: Type[ba.Session], + playlist: str, + scale_origin: Tuple[float, float], + delegate: Any = None): + # FIXME: Tidy this up. + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + from ba.internal import (getclass, have_pro, + get_default_teams_playlist, + get_default_free_for_all_playlist, + filter_playlist) + from ba.internal import get_map_class + from bastd.ui.playlist import PlaylistTypeVars + + self._r = 'gameListWindow' + self._delegate = delegate + self._pvars = PlaylistTypeVars(sessiontype) + self._transitioning_out = False + + self._do_randomize_val = (ba.app.config.get( + self._pvars.config_name + ' Playlist Randomize', 0)) + + self._sessiontype = sessiontype + self._playlist = playlist + + self._width = 500.0 + self._height = 330.0 - 50.0 + + # In teams games, show the custom names/colors button. + if self._sessiontype is ba.TeamsSession: + self._height += 50.0 + + self._row_height = 45.0 + + # Grab our maps to display. + model_opaque = ba.getmodel('level_select_button_opaque') + model_transparent = ba.getmodel('level_select_button_transparent') + mask_tex = ba.gettexture('mapPreviewMask') + + # Poke into this playlist and see if we can display some of its maps. + map_textures = [] + map_texture_entries = [] + rows = 0 + columns = 0 + game_count = 0 + scl = 0.35 + c_width_total = 0.0 + try: + max_columns = 5 + name = playlist + if name == '__default__': + if self._sessiontype is ba.FreeForAllSession: + plst = get_default_free_for_all_playlist() + elif self._sessiontype is ba.TeamsSession: + plst = get_default_teams_playlist() + else: + raise Exception("unrecognized session-type: " + + str(self._sessiontype)) + else: + try: + plst = ba.app.config[self._pvars.config_name + + ' Playlists'][name] + except Exception: + print('ERROR INFO: self._config_name is:', + self._pvars.config_name) + print( + 'ERROR INFO: playlist names are:', + list(ba.app.config[self._pvars.config_name + + ' Playlists'].keys())) + raise + plst = filter_playlist(plst, + self._sessiontype, + remove_unowned=False, + mark_unowned=True) + game_count = len(plst) + for entry in plst: + mapname = entry['settings']['map'] + maptype: Optional[Type[ba.Map]] + try: + maptype = get_map_class(mapname) + except Exception: + maptype = None + if maptype is not None: + tex_name = maptype.get_preview_texture_name() + if tex_name is not None: + map_textures.append(tex_name) + map_texture_entries.append(entry) + rows = (max(0, len(map_textures) - 1) // max_columns) + 1 + columns = min(max_columns, len(map_textures)) + + if len(map_textures) == 1: + scl = 1.1 + elif len(map_textures) == 2: + scl = 0.7 + elif len(map_textures) == 3: + scl = 0.55 + else: + scl = 0.35 + self._row_height = 128.0 * scl + c_width_total = scl * 250.0 * columns + if map_textures: + self._height += self._row_height * rows + + except Exception: + ba.print_exception("error listing playlist maps") + + show_shuffle_check_box = game_count > 1 + + if show_shuffle_check_box: + self._height += 40 + + # Creates our _root_widget. + scale = (1.69 if ba.app.small_ui else 1.1 if ba.app.med_ui else 0.85) + super().__init__(position=scale_origin, + size=(self._width, self._height), + scale=scale) + + playlist_name: Union[str, ba.Lstr] = (self._pvars.default_list_name + if playlist == '__default__' else + playlist) + self._title_text = ba.textwidget(parent=self.root_widget, + position=(self._width * 0.5, + self._height - 89 + 51), + size=(0, 0), + text=playlist_name, + scale=1.4, + color=(1, 1, 1), + maxwidth=self._width * 0.7, + h_align="center", + v_align="center") + + self._cancel_button = ba.buttonwidget( + parent=self.root_widget, + position=(25, self._height - 53), + size=(50, 50), + scale=0.7, + label='', + color=(0.42, 0.73, 0.2), + on_activate_call=self._on_cancel_press, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + + h_offs_img = self._width * 0.5 - c_width_total * 0.5 + v_offs_img = self._height - 118 - scl * 125.0 + 50 + bottom_row_buttons = [] + self._have_at_least_one_owned = False + + for row in range(rows): + for col in range(columns): + tex_index = row * columns + col + if tex_index < len(map_textures): + tex_name = map_textures[tex_index] + h = h_offs_img + scl * 250 * col + v = v_offs_img - self._row_height * row + entry = map_texture_entries[tex_index] + owned = not (('is_unowned_map' in entry + and entry['is_unowned_map']) or + ('is_unowned_game' in entry + and entry['is_unowned_game'])) + + if owned: + self._have_at_least_one_owned = True + + try: + desc = getclass(entry['type'], + subclassof=ba.GameActivity + ).get_config_display_string(entry) + if not owned: + desc = ba.Lstr( + value='${DESC}\n${UNLOCK}', + subs=[ + ('${DESC}', desc), + ('${UNLOCK}', + ba.Lstr( + resource='unlockThisInTheStoreText')) + ]) + desc_color = (0, 1, 0) if owned else (1, 0, 0) + except Exception: + desc = ba.Lstr(value='(invalid)') + desc_color = (1, 0, 0) + + btn = ba.buttonwidget( + parent=self.root_widget, + size=(scl * 240.0, scl * 120.0), + position=(h, v), + texture=ba.gettexture(tex_name if owned else 'empty'), + model_opaque=model_opaque if owned else None, + on_activate_call=ba.Call(ba.screenmessage, + desc, + color=desc_color), + label='', + color=(1, 1, 1), + autoselect=True, + extra_touch_border_scale=0.0, + model_transparent=model_transparent if owned else None, + mask_texture=mask_tex if owned else None) + if row == 0 and col == 0: + ba.widget(edit=self._cancel_button, down_widget=btn) + if row == rows - 1: + bottom_row_buttons.append(btn) + if not owned: + + # Ewww; buttons don't currently have alpha so in this + # case we draw an image over our button with an empty + # texture on it. + ba.imagewidget(parent=self.root_widget, + size=(scl * 260.0, scl * 130.0), + position=(h - 10.0 * scl, + v - 4.0 * scl), + draw_controller=btn, + color=(1, 1, 1), + texture=ba.gettexture(tex_name), + model_opaque=model_opaque, + opacity=0.25, + model_transparent=model_transparent, + mask_texture=mask_tex) + + ba.imagewidget(parent=self.root_widget, + size=(scl * 100, scl * 100), + draw_controller=btn, + position=(h + scl * 70, v + scl * 10), + texture=ba.gettexture('lock')) + + # Team names/colors. + self._custom_colors_names_button: Optional[ba.Widget] + if self._sessiontype is ba.TeamsSession: + y_offs = 50 if show_shuffle_check_box else 0 + self._custom_colors_names_button = ba.buttonwidget( + parent=self.root_widget, + position=(100, 200 + y_offs), + size=(290, 35), + on_activate_call=ba.WeakCall(self._custom_colors_names_press), + autoselect=True, + textcolor=(0.8, 0.8, 0.8), + label=ba.Lstr(resource='teamNamesColorText')) + if not have_pro(): + ba.imagewidget( + parent=self.root_widget, + size=(30, 30), + position=(95, 202 + y_offs), + texture=ba.gettexture('lock'), + draw_controller=self._custom_colors_names_button) + else: + self._custom_colors_names_button = None + + # Shuffle. + def _cb_callback(val: bool) -> None: + self._do_randomize_val = val + cfg = ba.app.config + cfg[self._pvars.config_name + + ' Playlist Randomize'] = self._do_randomize_val + cfg.commit() + + if show_shuffle_check_box: + self._shuffle_check_box = ba.checkboxwidget( + parent=self.root_widget, + position=(110, 200), + scale=1.0, + size=(250, 30), + autoselect=True, + text=ba.Lstr(resource=self._r + '.shuffleGameOrderText'), + maxwidth=300, + textcolor=(0.8, 0.8, 0.8), + value=self._do_randomize_val, + on_value_change_call=_cb_callback) + + # Show tutorial. + try: + show_tutorial = ba.app.config['Show Tutorial'] + except Exception: + show_tutorial = True + + def _cb_callback_2(val: bool) -> None: + cfg = ba.app.config + cfg['Show Tutorial'] = val + cfg.commit() + + self._show_tutorial_check_box = ba.checkboxwidget( + parent=self.root_widget, + position=(110, 151), + scale=1.0, + size=(250, 30), + autoselect=True, + text=ba.Lstr(resource=self._r + '.showTutorialText'), + maxwidth=300, + textcolor=(0.8, 0.8, 0.8), + value=show_tutorial, + on_value_change_call=_cb_callback_2) + + # Grumble: current autoselect doesn't do a very good job + # with checkboxes. + if self._custom_colors_names_button is not None: + for btn in bottom_row_buttons: + ba.widget(edit=btn, + down_widget=self._custom_colors_names_button) + if show_shuffle_check_box: + ba.widget(edit=self._custom_colors_names_button, + down_widget=self._shuffle_check_box) + ba.widget(edit=self._shuffle_check_box, + up_widget=self._custom_colors_names_button) + else: + ba.widget(edit=self._custom_colors_names_button, + down_widget=self._show_tutorial_check_box) + ba.widget(edit=self._show_tutorial_check_box, + up_widget=self._custom_colors_names_button) + + self._play_button = ba.buttonwidget( + parent=self.root_widget, + position=(70, 44), + size=(200, 45), + scale=1.8, + text_res_scale=1.5, + on_activate_call=self._on_play_press, + autoselect=True, + label=ba.Lstr(resource='playText')) + + ba.widget(edit=self._play_button, + up_widget=self._show_tutorial_check_box) + + ba.containerwidget(edit=self.root_widget, + start_button=self._play_button, + cancel_button=self._cancel_button, + selected_child=self._play_button) + + # Update now and once per second. + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + self._update() + + def _custom_colors_names_press(self) -> None: + from ba.internal import have_pro + from bastd.ui import account as accountui + from bastd.ui import teamnamescolors + from bastd.ui import purchase + if not have_pro(): + if _ba.get_account_state() != 'signed_in': + accountui.show_sign_in_prompt() + else: + purchase.PurchaseWindow(items=['pro']) + self._transition_out() + return + assert self._custom_colors_names_button + teamnamescolors.TeamNamesColorsWindow( + scale_origin=self._custom_colors_names_button. + get_screen_space_center()) + + def _does_target_playlist_exist(self) -> bool: + if self._playlist == '__default__': + return True + val: bool = self._playlist in ba.app.config.get( + self._pvars.config_name + ' Playlists', {}) + assert isinstance(val, bool) + return val + + def _update(self) -> None: + # All we do here is make sure our targeted playlist still exists, + # and close ourself if not. + if not self._does_target_playlist_exist(): + self._transition_out() + + def _transition_out(self, transition: str = 'out_scale') -> None: + if not self._transitioning_out: + self._transitioning_out = True + ba.containerwidget(edit=self.root_widget, transition=transition) + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._transition_out() + + def _on_cancel_press(self) -> None: + self._transition_out() + + def _on_play_press(self) -> None: + + # Disallow if our playlist has disappeared. + if not self._does_target_playlist_exist(): + return + + # Disallow if we have no unlocked games. + if not self._have_at_least_one_owned: + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource='playlistNoValidGamesErrorText'), + color=(1, 0, 0)) + return + + cfg = ba.app.config + cfg[self._pvars.config_name + ' Playlist Selection'] = self._playlist + cfg.commit() + _ba.fade_screen(False, endcall=self._run_selected_playlist) + _ba.lock_all_input() + self._transition_out(transition='out_left') + if self._delegate is not None: + self._delegate.on_play_options_window_run_game() + + def _run_selected_playlist(self) -> None: + _ba.unlock_all_input() + try: + _ba.new_host_session(self._sessiontype) + except Exception: + from bastd import mainmenu + ba.print_exception("exception running session", self._sessiontype) + + # Drop back into a main menu session. + _ba.new_host_session(mainmenu.MainMenuSession) diff --git a/assets/src/data/scripts/bastd/ui/popup.py b/assets/src/data/scripts/bastd/ui/popup.py new file mode 100644 index 00000000..de82c3a0 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/popup.py @@ -0,0 +1,377 @@ +"""Popup window/menu related functionality.""" + +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Tuple, Any, Sequence, Callable, Optional, List, Union + + +class PopupWindow: + """A transient window that positions and scales itself for visibility.""" + + def __init__(self, + position: Tuple[float, float], + size: Tuple[float, float], + scale: float = 1.0, + offset: Tuple[float, float] = (0, 0), + bg_color: Tuple[float, float, float] = (0.35, 0.55, 0.15), + focus_position: Tuple[float, float] = (0, 0), + focus_size: Tuple[float, float] = None, + toolbar_visibility: str = "menu_minimal_no_back"): + # pylint: disable=too-many-locals + if focus_size is None: + focus_size = size + + # In vr mode we can't have windows going outside the screen. + if ba.app.vr_mode: + focus_size = size + focus_position = (0, 0) + + width = focus_size[0] + height = focus_size[1] + + # Ok, we've been given a desired width, height, and scale; + # we now need to ensure that we're all onscreen by scaling down if + # need be and clamping it to the UI bounds. + bounds = ba.app.ui_bounds + edge_buffer = 15 + bounds_width = (bounds[1] - bounds[0] - edge_buffer * 2) + bounds_height = (bounds[3] - bounds[2] - edge_buffer * 2) + + fin_width = width * scale + fin_height = height * scale + if fin_width > bounds_width: + scale /= (fin_width / bounds_width) + fin_width = width * scale + fin_height = height * scale + if fin_height > bounds_height: + scale /= (fin_height / bounds_height) + fin_width = width * scale + fin_height = height * scale + + x_min = bounds[0] + edge_buffer + fin_width * 0.5 + y_min = bounds[2] + edge_buffer + fin_height * 0.5 + x_max = bounds[1] - edge_buffer - fin_width * 0.5 + y_max = bounds[3] - edge_buffer - fin_height * 0.5 + + x_fin = min(max(x_min, position[0] + offset[0]), x_max) + y_fin = min(max(y_min, position[1] + offset[1]), y_max) + + # ok, we've calced a valid x/y position and a scale based on or + # focus area. ..now calc the difference between the center of our + # focus area and the center of our window to come up with the + # offset we'll need to plug in to the window + x_offs = ((focus_position[0] + focus_size[0] * 0.5) - + (size[0] * 0.5)) * scale + y_offs = ((focus_position[1] + focus_size[1] * 0.5) - + (size[1] * 0.5)) * scale + + self.root_widget = ba.containerwidget( + transition='in_scale', + scale=scale, + toolbar_visibility=toolbar_visibility, + size=size, + parent=_ba.get_special_widget('overlay_stack'), + stack_offset=(x_fin - x_offs, y_fin - y_offs), + scale_origin_stack_offset=(position[0], position[1]), + on_outside_click_call=self.on_popup_cancel, + claim_outside_clicks=True, + color=bg_color, + on_cancel_call=self.on_popup_cancel) + # complain if we outlive our root widget + ba.uicleanupcheck(self, self.root_widget) + + def on_popup_cancel(self) -> None: + """Called when the popup is canceled. + + Cancels can occur due to clicking outside the window, + hitting escape, etc. + """ + + +class PopupMenuWindow(PopupWindow): + """A menu built using popup-window functionality.""" + + def __init__(self, + position: Tuple[float, float], + choices: Sequence[str], + current_choice: str, + delegate: Any = None, + width: float = 230.0, + maxwidth: float = None, + scale: float = 1.0, + choices_disabled: Sequence[str] = None, + choices_display: Sequence[ba.Lstr] = None): + # FIXME: Clean up a bit. + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + if choices_disabled is None: + choices_disabled = [] + if choices_display is None: + choices_display = [] + + # FIXME: For the moment we base our width on these strings so + # we need to flatten them. + choices_display_fin: List[str] = [] + for choice_display in choices_display: + if isinstance(choice_display, ba.Lstr): + choices_display_fin.append(choice_display.evaluate()) + else: + ba.print_error( + 'PopupMenuWindow got a raw string in \'choices_display\';' + ' please pass ba.Lstr values only', + once=True) + choices_display_fin.append(choice_display) + + if maxwidth is None: + maxwidth = width * 1.5 + + self._transitioning_out = False + self._choices = list(choices) + self._choices_display = list(choices_display_fin) + self._current_choice = current_choice + self._choices_disabled = list(choices_disabled) + self._done_building = False + if not choices: + raise Exception("Must pass at least one choice") + self._width = width + self._scale = scale + if len(choices) > 8: + self._height = 280 + self._use_scroll = True + else: + self._height = 20 + len(choices) * 33 + self._use_scroll = False + self._delegate = None # don't want this stuff called just yet.. + + # extend width to fit our longest string (or our max-width) + for index, choice in enumerate(choices): + if len(choices_display_fin) == len(choices): + choice_display_name = choices_display_fin[index] + else: + choice_display_name = choice + if self._use_scroll: + self._width = max( + self._width, + min( + maxwidth, + _ba.get_string_width(choice_display_name, + suppress_warning=True)) + 75) + else: + self._width = max( + self._width, + min( + maxwidth, + _ba.get_string_width(choice_display_name, + suppress_warning=True)) + 60) + + # init parent class - this will rescale and reposition things as + # needed and create our root widget + PopupWindow.__init__(self, + position, + size=(self._width, self._height), + scale=self._scale) + + if self._use_scroll: + self._scrollwidget = ba.scrollwidget(parent=self.root_widget, + position=(20, 20), + highlight=False, + color=(0.35, 0.55, 0.15), + size=(self._width - 40, + self._height - 40)) + self._columnwidget = ba.columnwidget(parent=self._scrollwidget) + else: + self._offset_widget = ba.containerwidget(parent=self.root_widget, + position=(30, 15), + size=(self._width - 40, + self._height), + background=False) + self._columnwidget = ba.columnwidget(parent=self._offset_widget) + for index, choice in enumerate(choices): + if len(choices_display_fin) == len(choices): + choice_display_name = choices_display_fin[index] + else: + choice_display_name = choice + inactive = (choice in self._choices_disabled) + wdg = ba.textwidget(parent=self._columnwidget, + size=(self._width - 40, 28), + on_select_call=ba.Call(self._select, index), + click_activate=True, + color=(0.5, 0.5, 0.5, 0.5) if inactive else + ((0.5, 1, 0.5, + 1) if choice == self._current_choice else + (0.8, 0.8, 0.8, 1.0)), + padding=0, + maxwidth=maxwidth, + text=choice_display_name, + on_activate_call=self._activate, + v_align='center', + selectable=(not inactive)) + if choice == self._current_choice: + ba.containerwidget(edit=self._columnwidget, + selected_child=wdg, + visible_child=wdg) + + # ok from now on our delegate can be called + self._delegate = weakref.ref(delegate) + self._done_building = True + + def _select(self, index: int) -> None: + if self._done_building: + self._current_choice = self._choices[index] + + def _activate(self) -> None: + ba.playsound(ba.getsound('swish')) + ba.timer(0.05, self._transition_out, timetype=ba.TimeType.REAL) + delegate = self._getdelegate() + if delegate is not None: + # Call this in a timer so it doesn't interfere with us killing + # our widgets and whatnot. + call = ba.Call(delegate.popup_menu_selected_choice, self, + self._current_choice) + ba.timer(0, call, timetype=ba.TimeType.REAL) + + def _getdelegate(self) -> Any: + return None if self._delegate is None else self._delegate() + + def _transition_out(self) -> None: + if not self.root_widget: + return + if not self._transitioning_out: + self._transitioning_out = True + delegate = self._getdelegate() + if delegate is not None: + delegate.popup_menu_closing(self) + ba.containerwidget(edit=self.root_widget, transition='out_scale') + + def on_popup_cancel(self) -> None: + if not self._transitioning_out: + ba.playsound(ba.getsound('swish')) + self._transition_out() + + +class PopupMenu: + """A complete popup-menu control. + + This creates a button and wrangles its pop-up menu. + """ + + def __init__(self, + parent: ba.Widget, + position: Tuple[float, float], + choices: Sequence[str], + current_choice: str = None, + on_value_change_call: Callable[[str], Any] = None, + opening_call: Callable[[], Any] = None, + closing_call: Callable[[], Any] = None, + width: float = 230.0, + maxwidth: float = None, + scale: float = None, + choices_disabled: Sequence[str] = None, + choices_display: Sequence[ba.Lstr] = None, + button_size: Tuple[float, float] = (160.0, 50.0), + autoselect: bool = True): + if choices_disabled is None: + choices_disabled = [] + if choices_display is None: + choices_display = [] + if scale is None: + scale = (2.3 + if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + if current_choice not in choices: + current_choice = None + self._choices = list(choices) + if not choices: + raise Exception("no choices given") + self._choices_display = list(choices_display) + self._choices_disabled = list(choices_disabled) + self._width = width + self._maxwidth = maxwidth + self._scale = scale + self._current_choice = (current_choice if current_choice is not None + else self._choices[0]) + self._position = position + self._parent = parent + if not choices: + raise Exception("Must pass at least one choice") + self._parent = parent + self._button_size = button_size + + self._button = ba.buttonwidget( + parent=self._parent, + position=(self._position[0], self._position[1]), + autoselect=autoselect, + size=self._button_size, + scale=1.0, + label='', + on_activate_call=lambda: ba.timer( + 0, self._make_popup, timetype=ba.TimeType.REAL)) + self._on_value_change_call = None # Don't wanna call for initial set. + self._opening_call = opening_call + self._autoselect = autoselect + self._closing_call = closing_call + self.set_choice(self._current_choice) + self._on_value_change_call = on_value_change_call + self._window_widget: Optional[ba.Widget] = None + + # Complain if we outlive our button. + ba.uicleanupcheck(self, self._button) + + def _make_popup(self) -> None: + if not self._button: + return + if self._opening_call: + self._opening_call() + self._window_widget = PopupMenuWindow( + position=self._button.get_screen_space_center(), + delegate=self, + width=self._width, + maxwidth=self._maxwidth, + scale=self._scale, + choices=self._choices, + current_choice=self._current_choice, + choices_disabled=self._choices_disabled, + choices_display=self._choices_display).root_widget + + def get_button(self) -> ba.Widget: + """Return the menu's button widget.""" + return self._button + + def get_window_widget(self) -> Optional[ba.Widget]: + """Return the menu's window widget (or None if nonexistent).""" + return self._window_widget + + def popup_menu_selected_choice(self, popup_window: PopupWindow, + choice: str) -> None: + """Called when a choice is selected.""" + del popup_window # Unused here. + self.set_choice(choice) + if self._on_value_change_call: + self._on_value_change_call(choice) + + def popup_menu_closing(self, popup_window: PopupWindow) -> None: + """Called when the menu is closing.""" + del popup_window # Unused here. + if self._button: + ba.containerwidget(edit=self._parent, selected_child=self._button) + self._window_widget = None + if self._closing_call: + self._closing_call() + + def set_choice(self, choice: str) -> None: + """Set the selected choice.""" + self._current_choice = choice + displayname: Union[str, ba.Lstr] + if len(self._choices_display) == len(self._choices): + displayname = self._choices_display[self._choices.index(choice)] + else: + displayname = choice + if self._button: + ba.buttonwidget(edit=self._button, label=displayname) diff --git a/assets/src/data/scripts/bastd/ui/profile/__init__.py b/assets/src/data/scripts/bastd/ui/profile/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assets/src/data/scripts/bastd/ui/profile/browser.py b/assets/src/data/scripts/bastd/ui/profile/browser.py new file mode 100644 index 00000000..b0ccbcf7 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/profile/browser.py @@ -0,0 +1,375 @@ +"""UI functionality related to browsing player profiles.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Optional, Tuple, List, Dict + + +class ProfileBrowserWindow(ba.OldWindow): + """Window for browsing player profiles.""" + + def __init__(self, + transition: str = 'in_right', + in_main_menu: bool = True, + selected_profile: str = None, + origin_widget: ba.Widget = None): + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + from ba.internal import ensure_have_account_player_profile + self._in_main_menu = in_main_menu + if self._in_main_menu: + back_label = ba.Lstr(resource='backText') + else: + back_label = ba.Lstr(resource='doneText') + self._width = 700.0 if ba.app.small_ui else 600.0 + x_inset = 50.0 if ba.app.small_ui else 0.0 + self._height = (360.0 if ba.app.small_ui else + 385.0 if ba.app.med_ui else 410.0) + + # If we're being called up standalone, handle pause/resume ourself. + if not self._in_main_menu: + ba.app.pause() + + # If they provided an origin-widget, scale up from that. + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self._r = 'playerProfilesWindow' + + # Ensure we've got an account-profile in cases where we're signed in. + ensure_have_account_player_profile() + + top_extra = 20 if ba.app.small_ui else 0 + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + top_extra), + transition=transition, + scale_origin_stack_offset=scale_origin, + scale=(2.2 if ba.app.small_ui else 1.6 if ba.app.med_ui else 1.0), + stack_offset=(0, -14) if ba.app.small_ui else (0, 0))) + + self._back_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(40 + x_inset, self._height - 59), + size=(120, 60), + scale=0.8, + label=back_label, + button_type='back' if self._in_main_menu else None, + autoselect=True, + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 36), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.titleText'), + maxwidth=300, + color=ba.app.title_color, + scale=0.9, + h_align="center", + v_align="center") + + if self._in_main_menu: + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + scroll_height = self._height - 140.0 + self._scroll_width = self._width - (188 + x_inset * 2) + v = self._height - 84.0 + h = 50 + x_inset + b_color = (0.6, 0.53, 0.63) + + scl = (1.055 if ba.app.small_ui else 1.18 if ba.app.med_ui else 1.3) + v -= 70.0 * scl + self._new_button = ba.buttonwidget(parent=self._root_widget, + position=(h, v), + size=(80, 66.0 * scl), + on_activate_call=self._new_profile, + color=b_color, + button_type='square', + autoselect=True, + textcolor=(0.75, 0.7, 0.8), + text_scale=0.7, + label=ba.Lstr(resource=self._r + + '.newButtonText')) + v -= 70.0 * scl + self._edit_button = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(80, 66.0 * scl), + on_activate_call=self._edit_profile, + color=b_color, + button_type='square', + autoselect=True, + textcolor=(0.75, 0.7, 0.8), + text_scale=0.7, + label=ba.Lstr(resource=self._r + '.editButtonText')) + v -= 70.0 * scl + self._delete_button = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(80, 66.0 * scl), + on_activate_call=self._delete_profile, + color=b_color, + button_type='square', + autoselect=True, + textcolor=(0.75, 0.7, 0.8), + text_scale=0.7, + label=ba.Lstr(resource=self._r + '.deleteButtonText')) + + v = self._height - 87 + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 71), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.explanationText'), + color=ba.app.infotextcolor, + maxwidth=self._width * 0.83, + scale=0.6, + h_align="center", + v_align="center") + + self._scrollwidget = ba.scrollwidget(parent=self._root_widget, + highlight=False, + position=(140 + x_inset, + v - scroll_height), + size=(self._scroll_width, + scroll_height)) + ba.widget(edit=self._scrollwidget, + autoselect=True, + left_widget=self._new_button) + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget) + self._columnwidget = ba.columnwidget(parent=self._scrollwidget) + v -= 255 + self._profiles: Optional[Dict[str, Dict[str, Any]]] = None + self._selected_profile = selected_profile + self._profile_widgets: List[ba.Widget] = [] + self._refresh() + self._restore_state() + + def _new_profile(self) -> None: + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui.profile import edit as pedit + from bastd.ui import purchase + + # Limit to a handful profiles if they don't have pro-options. + max_non_pro_profiles = _ba.get_account_misc_read_val('mnpp', 5) + assert self._profiles is not None + if (not have_pro_options() + and len(self._profiles) >= max_non_pro_profiles): + purchase.PurchaseWindow(items=['pro'], + header_text=ba.Lstr( + resource='unlockThisProfilesText', + subs=[('${NUM}', + str(max_non_pro_profiles))])) + return + + # Clamp at 100 profiles (otherwise the server will and that's less + # elegant looking). + if len(self._profiles) > 100: + ba.screenmessage( + ba.Lstr(translate=('serverResponses', + 'Max number of profiles reached.')), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (pedit.EditProfileWindow( + existing_profile=None, + in_main_menu=self._in_main_menu).get_root_widget()) + + def _delete_profile(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import confirm + if self._selected_profile is None: + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource='nothingIsSelectedErrorText'), + color=(1, 0, 0)) + return + if self._selected_profile == '__account__': + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource=self._r + + '.cantDeleteAccountProfileText'), + color=(1, 0, 0)) + return + confirm.ConfirmWindow( + ba.Lstr(resource=self._r + '.deleteConfirmText', + subs=[('${PROFILE}', self._selected_profile)]), + self._do_delete_profile, 350) + + def _do_delete_profile(self) -> None: + _ba.add_transaction({ + 'type': 'REMOVE_PLAYER_PROFILE', + 'name': self._selected_profile + }) + _ba.run_transactions() + ba.playsound(ba.getsound('shieldDown')) + self._refresh() + + # Select profile list. + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget) + + def _edit_profile(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.profile import edit as pedit + if self._selected_profile is None: + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource='nothingIsSelectedErrorText'), + color=(1, 0, 0)) + return + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (pedit.EditProfileWindow( + self._selected_profile, + in_main_menu=self._in_main_menu).get_root_widget()) + + def _select(self, name: str, index: int) -> None: + del index # Unused. + self._selected_profile = name + + def _back(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.account import settings + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + if self._in_main_menu: + ba.app.main_menu_window = (settings.AccountSettingsWindow( + transition='in_left').get_root_widget()) + + # If we're being called up standalone, handle pause/resume ourself. + else: + ba.app.resume() + + def _refresh(self) -> None: + # pylint: disable=too-many-locals + from ba.internal import (PlayerProfilesChangedMessage, + get_player_profile_colors, + get_player_profile_icon) + old_selection = self._selected_profile + + # Delete old. + while self._profile_widgets: + self._profile_widgets.pop().delete() + try: + self._profiles = ba.app.config['Player Profiles'] + except Exception: + self._profiles = {} + assert self._profiles is not None + items = list(self._profiles.items()) + items.sort(key=lambda x: x[0].lower()) + index = 0 + account_name: Optional[str] + if _ba.get_account_state() == 'signed_in': + account_name = _ba.get_account_display_string() + else: + account_name = None + widget_to_select = None + for p_name, _ in items: + if p_name == '__account__' and account_name is None: + continue + color, _highlight = get_player_profile_colors(p_name) + scl = 1.1 + txtw = ba.textwidget( + parent=self._columnwidget, + position=(0, 32), + size=((self._width - 40) / scl, 28), + text=ba.Lstr( + value=account_name if p_name == + '__account__' else get_player_profile_icon(p_name) + + p_name), + h_align='left', + v_align='center', + on_select_call=ba.WeakCall(self._select, p_name, index), + maxwidth=self._scroll_width * 0.92, + corner_scale=scl, + color=ba.safecolor(color, 0.4), + always_highlight=True, + on_activate_call=ba.Call(self._edit_button.activate), + selectable=True) + if index == 0: + ba.widget(edit=txtw, up_widget=self._back_button) + ba.widget(edit=txtw, show_buffer_top=40, show_buffer_bottom=40) + self._profile_widgets.append(txtw) + + # Select/show this one if it was previously selected + # (but defer till after this loop since our height is + # still changing). + if p_name == old_selection: + widget_to_select = txtw + + index += 1 + + if widget_to_select is not None: + ba.columnwidget(edit=self._columnwidget, + selected_child=widget_to_select, + visible_child=widget_to_select) + + # If there's a team-chooser in existence, tell it the profile-list + # has probably changed. + session = _ba.get_foreground_host_session() + if session is not None: + session.handlemessage(PlayerProfilesChangedMessage()) + + def _save_state(self) -> None: + try: + sel = self._root_widget.get_selected_child() + if sel == self._new_button: + sel_name = 'New' + elif sel == self._edit_button: + sel_name = 'Edit' + elif sel == self._delete_button: + sel_name = 'Delete' + elif sel == self._scrollwidget: + sel_name = 'Scroll' + else: + sel_name = 'Back' + ba.app.window_states[self.__class__.__name__] = sel_name + except Exception: + ba.print_exception('error saving state for', self.__class__) + + def _restore_state(self) -> None: + try: + try: + sel_name = ba.app.window_states[self.__class__.__name__] + except Exception: + sel_name = None + if sel_name == 'Scroll': + sel = self._scrollwidget + elif sel_name == 'New': + sel = self._new_button + elif sel_name == 'Delete': + sel = self._delete_button + elif sel_name == 'Edit': + sel = self._edit_button + elif sel_name == 'Back': + sel = self._back_button + else: + # By default we select our scroll widget if we have profiles; + # otherwise our new widget. + if not self._profile_widgets: + sel = self._new_button + else: + sel = self._scrollwidget + ba.containerwidget(edit=self._root_widget, selected_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) diff --git a/assets/src/data/scripts/bastd/ui/profile/edit.py b/assets/src/data/scripts/bastd/ui/profile/edit.py new file mode 100644 index 00000000..045359aa --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/profile/edit.py @@ -0,0 +1,666 @@ +"""Provides UI to edit a player profile.""" + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING, cast + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Tuple, Optional, List + from bastd.ui.colorpicker import ColorPicker + + +class EditProfileWindow(ba.OldWindow): + """Window for editing a player profile.""" + + # FIXME: WILL NEED TO CHANGE THIS FOR UILOCATION. + def reload_window(self) -> None: + """Transitions out and recreates ourself.""" + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = EditProfileWindow( + self.get_name(), self._in_main_menu).get_root_widget() + + def __init__(self, + existing_profile: Optional[str], + in_main_menu: bool, + transition: str = 'in_right'): + # FIXME: Tidy this up a bit. + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + from ba.internal import get_player_profile_colors + self._in_main_menu = in_main_menu + self._existing_profile = existing_profile + self._r = 'editProfileWindow' + self._spazzes: List[str] = [] + self._icon_textures: List[ba.Texture] = [] + self._icon_tint_textures: List[ba.Texture] = [] + + # Grab profile colors or pick random ones. + self._color, self._highlight = get_player_profile_colors( + existing_profile) + self._width = width = 780.0 if ba.app.small_ui else 680.0 + self._x_inset = x_inset = 50.0 if ba.app.small_ui else 0.0 + self._height = height = (350.0 if ba.app.small_ui else + 400.0 if ba.app.med_ui else 450.0) + spacing = 40 + self._base_scale = (2.05 if ba.app.small_ui else + 1.5 if ba.app.med_ui else 1.0) + top_extra = 15 if ba.app.small_ui else 15 + super().__init__(root_widget=ba.containerwidget( + size=(width, height + top_extra), + transition=transition, + scale=self._base_scale, + stack_offset=(0, 15) if ba.app.small_ui else (0, 0))) + cancel_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(52 + x_inset, height - 60), + size=(155, 60), + scale=0.8, + autoselect=True, + label=ba.Lstr(resource='cancelText'), + on_activate_call=self._cancel) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + save_button = btn = ba.buttonwidget(parent=self._root_widget, + position=(width - (177 + x_inset), + height - 60), + size=(155, 60), + autoselect=True, + scale=0.8, + label=ba.Lstr(resource='saveText')) + ba.widget(edit=save_button, left_widget=cancel_button) + ba.widget(edit=cancel_button, right_widget=save_button) + ba.containerwidget(edit=self._root_widget, start_button=btn) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, height - 38), + size=(0, 0), + text=(ba.Lstr(resource=self._r + '.titleNewText') + if existing_profile is None else ba.Lstr( + resource=self._r + '.titleEditText')), + color=ba.app.title_color, + maxwidth=290, + scale=1.0, + h_align="center", + v_align="center") + + # Make a list of spaz icons. + self.refresh_characters() + profile = ba.app.config.get('Player Profiles', + {}).get(self._existing_profile, {}) + + if 'global' in profile: + self._global = profile['global'] + else: + self._global = False + + if 'icon' in profile: + self._icon = profile['icon'] + else: + self._icon = ba.charstr(ba.SpecialChar.LOGO) + + assigned_random_char = False + + # Look for existing character choice or pick random one otherwise. + try: + icon_index = self._spazzes.index(profile['character']) + except Exception: + # Let's set the default icon to spaz for our first profile; after + # that we go random. + # (SCRATCH THAT.. we now hard-code account-profiles to start with + # spaz which has a similar effect) + # try: p_len = len(ba.app.config['Player Profiles']) + # except Exception: p_len = 0 + # if p_len == 0: icon_index = self._spazzes.index('Spaz') + # else: + random.seed() + icon_index = random.randrange(len(self._spazzes)) + assigned_random_char = True + self._icon_index = icon_index + ba.buttonwidget(edit=save_button, on_activate_call=self.save) + + v = height - 115.0 + self._name = ('' if self._existing_profile is None else + self._existing_profile) + self._is_account_profile = (self._name == '__account__') + + # If we just picked a random character, see if it has specific + # colors/highlights associated with it and assign them if so. + if assigned_random_char: + clr = ba.app.spaz_appearances[ + self._spazzes[icon_index]].default_color + if clr is not None: + self._color = clr + highlight = ba.app.spaz_appearances[ + self._spazzes[icon_index]].default_highlight + if highlight is not None: + self._highlight = highlight + + # Assign a random name if they had none. + if self._name == '': + names = _ba.get_random_names() + self._name = names[random.randrange(len(names))] + + self._clipped_name_text = ba.textwidget(parent=self._root_widget, + text='', + position=(540 + x_inset, + v - 8), + flatness=1.0, + shadow=0.0, + scale=0.55, + size=(0, 0), + maxwidth=100, + h_align='center', + v_align='center', + color=(1, 1, 0, 0.5)) + + if not self._is_account_profile and not self._global: + ba.textwidget(parent=self._root_widget, + text=ba.Lstr(resource=self._r + '.nameText'), + position=(200 + x_inset, v - 6), + size=(0, 0), + h_align='right', + v_align='center', + color=(1, 1, 1, 0.5), + scale=0.9) + + self._upgrade_button = None + if self._is_account_profile: + if _ba.get_account_state() == 'signed_in': + sval = _ba.get_account_display_string() + else: + sval = '??' + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, v - 7), + size=(0, 0), + scale=1.2, + text=sval, + maxwidth=270, + h_align='center', + v_align='center') + txtl = ba.Lstr( + resource='editProfileWindow.accountProfileText').evaluate() + b_width = min( + 270.0, + _ba.get_string_width(txtl, suppress_warning=True) * 0.6) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, v - 39), + size=(0, 0), + scale=0.6, + color=ba.app.infotextcolor, + text=txtl, + maxwidth=270, + h_align='center', + v_align='center') + self._account_type_info_button = ba.buttonwidget( + parent=self._root_widget, + label='?', + size=(15, 15), + text_scale=0.6, + position=(self._width * 0.5 + b_width * 0.5 + 13, v - 47), + button_type='square', + color=(0.6, 0.5, 0.65), + autoselect=True, + on_activate_call=self.show_account_profile_info) + elif self._global: + + b_size = 60 + self._icon_button = btn = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(self._width * 0.5 - 160 - b_size * 0.5, v - 38 - 15), + size=(b_size, b_size), + color=(0.6, 0.5, 0.6), + label='', + button_type='square', + text_scale=1.2, + on_activate_call=self._on_icon_press) + self._icon_button_label = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5 - 160, v - 35), + draw_controller=btn, + h_align='center', + v_align='center', + size=(0, 0), + color=(1, 1, 1), + text='', + scale=2.0) + + ba.textwidget(parent=self._root_widget, + h_align='center', + v_align='center', + position=(self._width * 0.5 - 160, v - 55 - 15), + size=(0, 0), + draw_controller=btn, + text=ba.Lstr(resource=self._r + '.iconText'), + scale=0.7, + color=ba.app.title_color, + maxwidth=120) + + self._update_icon() + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, v - 7), + size=(0, 0), + scale=1.2, + text=self._name, + maxwidth=240, + h_align='center', + v_align='center') + # FIXME hard coded strings are bad + txtl = ba.Lstr( + resource='editProfileWindow.globalProfileText').evaluate() + b_width = min( + 240.0, + _ba.get_string_width(txtl, suppress_warning=True) * 0.6) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, v - 39), + size=(0, 0), + scale=0.6, + color=ba.app.infotextcolor, + text=txtl, + maxwidth=240, + h_align='center', + v_align='center') + self._account_type_info_button = ba.buttonwidget( + parent=self._root_widget, + label='?', + size=(15, 15), + text_scale=0.6, + position=(self._width * 0.5 + b_width * 0.5 + 13, v - 47), + button_type='square', + color=(0.6, 0.5, 0.65), + autoselect=True, + on_activate_call=self.show_global_profile_info) + else: + self._text_field = ba.textwidget( + parent=self._root_widget, + position=(220 + x_inset, v - 30), + size=(265, 40), + text=self._name, + h_align='left', + v_align='center', + max_chars=16, + description=ba.Lstr(resource=self._r + '.nameDescriptionText'), + autoselect=True, + editable=True, + padding=4, + color=(0.9, 0.9, 0.9, 1.0), + on_return_press_call=ba.Call(save_button.activate)) + + # FIXME hard coded strings are bad + txtl = ba.Lstr( + resource='editProfileWindow.localProfileText').evaluate() + b_width = min( + 270.0, + _ba.get_string_width(txtl, suppress_warning=True) * 0.6) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, v - 43), + size=(0, 0), + scale=0.6, + color=ba.app.infotextcolor, + text=txtl, + maxwidth=270, + h_align='center', + v_align='center') + self._account_type_info_button = ba.buttonwidget( + parent=self._root_widget, + label='?', + size=(15, 15), + text_scale=0.6, + position=(self._width * 0.5 + b_width * 0.5 + 13, v - 50), + button_type='square', + color=(0.6, 0.5, 0.65), + autoselect=True, + on_activate_call=self.show_local_profile_info) + self._upgrade_button = ba.buttonwidget( + parent=self._root_widget, + label=ba.Lstr(resource='upgradeText'), + size=(40, 17), + text_scale=1.0, + button_type='square', + position=(self._width * 0.5 + b_width * 0.5 + 13 + 43, v - 51), + color=(0.6, 0.5, 0.65), + autoselect=True, + on_activate_call=self.upgrade_profile) + + self._update_clipped_name() + self._clipped_name_timer = ba.Timer(0.333, + ba.WeakCall( + self._update_clipped_name), + timetype=ba.TimeType.REAL, + repeat=True) + + v -= spacing * 3.0 + b_size = 80 + b_size_2 = 100 + b_offs = 150 + self._color_button = btn = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(self._width * 0.5 - b_offs - b_size * 0.5, v - 50), + size=(b_size, b_size), + color=self._color, + label='', + button_type='square') + origin = self._color_button.get_screen_space_center() + ba.buttonwidget(edit=self._color_button, + on_activate_call=ba.WeakCall(self._make_picker, + 'color', origin)) + ba.textwidget(parent=self._root_widget, + h_align='center', + v_align='center', + position=(self._width * 0.5 - b_offs, v - 65), + size=(0, 0), + draw_controller=btn, + text=ba.Lstr(resource=self._r + '.colorText'), + scale=0.7, + color=ba.app.title_color, + maxwidth=120) + + self._character_button = btn = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(self._width * 0.5 - b_size_2 * 0.5, v - 60), + up_widget=self._account_type_info_button, + on_activate_call=self._on_character_press, + size=(b_size_2, b_size_2), + label='', + color=(1, 1, 1), + mask_texture=ba.gettexture('characterIconMask')) + if not self._is_account_profile and not self._global: + ba.containerwidget(edit=self._root_widget, + selected_child=self._text_field) + ba.textwidget(parent=self._root_widget, + h_align='center', + v_align='center', + position=(self._width * 0.5, v - 80), + size=(0, 0), + draw_controller=btn, + text=ba.Lstr(resource=self._r + '.characterText'), + scale=0.7, + color=ba.app.title_color, + maxwidth=130) + + self._highlight_button = btn = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(self._width * 0.5 + b_offs - b_size * 0.5, v - 50), + up_widget=self._upgrade_button if self._upgrade_button is not None + else self._account_type_info_button, + size=(b_size, b_size), + color=self._highlight, + label='', + button_type='square') + + if not self._is_account_profile and not self._global: + ba.widget(edit=cancel_button, down_widget=self._text_field) + ba.widget(edit=save_button, down_widget=self._text_field) + ba.widget(edit=self._color_button, up_widget=self._text_field) + ba.widget(edit=self._account_type_info_button, + down_widget=self._character_button) + + origin = self._highlight_button.get_screen_space_center() + ba.buttonwidget(edit=self._highlight_button, + on_activate_call=ba.WeakCall(self._make_picker, + 'highlight', origin)) + ba.textwidget(parent=self._root_widget, + h_align='center', + v_align='center', + position=(self._width * 0.5 + b_offs, v - 65), + size=(0, 0), + draw_controller=btn, + text=ba.Lstr(resource=self._r + '.highlightText'), + scale=0.7, + color=ba.app.title_color, + maxwidth=120) + self._update_character() + + def upgrade_profile(self) -> None: + """Attempt to ugrade the profile to global.""" + from bastd.ui import account + from bastd.ui.profile import upgrade as pupgrade + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + + pupgrade.ProfileUpgradeWindow(self) + + def show_account_profile_info(self) -> None: + """Show an explanation of account profiles.""" + from bastd.ui.confirm import ConfirmWindow + icons_str = ' '.join([ + ba.charstr(n) for n in [ + ba.SpecialChar.GOOGLE_PLAY_GAMES_LOGO, + ba.SpecialChar.GAME_CENTER_LOGO, + ba.SpecialChar.GAME_CIRCLE_LOGO, ba.SpecialChar.OUYA_LOGO, + ba.SpecialChar.LOCAL_ACCOUNT, ba.SpecialChar.ALIBABA_LOGO, + ba.SpecialChar.OCULUS_LOGO, ba.SpecialChar.NVIDIA_LOGO + ] + ]) + txtl = ba.Lstr(resource='editProfileWindow.accountProfileInfoText', + subs=[('${ICONS}', icons_str)]) + ConfirmWindow(txtl, + cancel_button=False, + width=500, + height=300, + origin_widget=self._account_type_info_button) + + def show_local_profile_info(self) -> None: + """Show an explanation of local profiles.""" + from bastd.ui.confirm import ConfirmWindow + txtl = ba.Lstr(resource='editProfileWindow.localProfileInfoText') + ConfirmWindow(txtl, + cancel_button=False, + width=600, + height=250, + origin_widget=self._account_type_info_button) + + def show_global_profile_info(self) -> None: + """Show an explanation of global profiles.""" + from bastd.ui.confirm import ConfirmWindow + txtl = ba.Lstr(resource='editProfileWindow.globalProfileInfoText') + ConfirmWindow(txtl, + cancel_button=False, + width=600, + height=250, + origin_widget=self._account_type_info_button) + + def refresh_characters(self) -> None: + """Refresh available characters/icons.""" + from bastd.actor import spazappearance + self._spazzes = spazappearance.get_appearances() + self._spazzes.sort() + self._icon_textures = [ + ba.gettexture(ba.app.spaz_appearances[s].icon_texture) + for s in self._spazzes + ] + self._icon_tint_textures = [ + ba.gettexture(ba.app.spaz_appearances[s].icon_mask_texture) + for s in self._spazzes + ] + + def on_icon_picker_pick(self, icon: str) -> None: + """An icon has been selected by the picker.""" + self._icon = icon + self._update_icon() + + def on_character_picker_pick(self, character: str) -> None: + """A character has been selected by the picker.""" + if not self._root_widget: + return + + # The player could have bought a new one while the picker was up. + self.refresh_characters() + self._icon_index = self._spazzes.index( + character) if character in self._spazzes else 0 + self._update_character() + + def _on_character_press(self) -> None: + from bastd.ui import characterpicker + characterpicker.CharacterPicker( + parent=self._root_widget, + position=self._character_button.get_screen_space_center(), + selected_character=self._spazzes[self._icon_index], + delegate=self, + tint_color=self._color, + tint2_color=self._highlight) + + def _on_icon_press(self) -> None: + from bastd.ui import iconpicker + iconpicker.IconPicker( + parent=self._root_widget, + position=self._icon_button.get_screen_space_center(), + selected_icon=self._icon, + delegate=self, + tint_color=self._color, + tint2_color=self._highlight) + + def _make_picker(self, picker_type: str, + origin: Tuple[float, float]) -> None: + from bastd.ui import colorpicker + if picker_type == 'color': + initial_color = self._color + elif picker_type == 'highlight': + initial_color = self._highlight + else: + raise Exception("invalid picker_type: " + picker_type) + colorpicker.ColorPicker( + parent=self._root_widget, + position=origin, + offset=(self._base_scale * + (-100 if picker_type == 'color' else 100), 0), + initial_color=initial_color, + delegate=self, + tag=picker_type) + + def _cancel(self) -> None: + from bastd.ui.profile import browser as pbrowser + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = pbrowser.ProfileBrowserWindow( + 'in_left', + selected_profile=self._existing_profile, + in_main_menu=self._in_main_menu).get_root_widget() + + def _set_color(self, color: Tuple[float, float, float]) -> None: + self._color = color + if self._color_button: + ba.buttonwidget(edit=self._color_button, color=color) + + def _set_highlight(self, color: Tuple[float, float, float]) -> None: + self._highlight = color + if self._highlight_button: + ba.buttonwidget(edit=self._highlight_button, color=color) + + def color_picker_closing(self, picker: ColorPicker) -> None: + """Called when a color picker is closing.""" + if not self._root_widget: + return + tag = picker.get_tag() + if tag == 'color': + ba.containerwidget(edit=self._root_widget, + selected_child=self._color_button) + elif tag == 'highlight': + ba.containerwidget(edit=self._root_widget, + selected_child=self._highlight_button) + else: + print('color_picker_closing got unknown tag ' + str(tag)) + + def color_picker_selected_color(self, picker: ColorPicker, + color: Tuple[float, float, float]) -> None: + """Called when a color is selected in a color picker.""" + if not self._root_widget: + return + tag = picker.get_tag() + if tag == 'color': + self._set_color(color) + elif tag == 'highlight': + self._set_highlight(color) + else: + print('color_picker_selected_color got unknown tag ' + str(tag)) + self._update_character() + + def _update_clipped_name(self) -> None: + if not self._clipped_name_text: + return + name = self.get_name() + if name == '__account__': + name = (_ba.get_account_name() + if _ba.get_account_state() == 'signed_in' else '???') + if len(name) > 10 and not (self._global or self._is_account_profile): + ba.textwidget(edit=self._clipped_name_text, + text=ba.Lstr(resource='inGameClippedNameText', + subs=[('${NAME}', name[:10] + '...')])) + else: + ba.textwidget(edit=self._clipped_name_text, text='') + + def _update_character(self, change: int = 0) -> None: + self._icon_index = (self._icon_index + change) % len(self._spazzes) + if self._character_button: + ba.buttonwidget( + edit=self._character_button, + texture=self._icon_textures[self._icon_index], + tint_texture=self._icon_tint_textures[self._icon_index], + tint_color=self._color, + tint2_color=self._highlight) + + def _update_icon(self) -> None: + if self._icon_button_label: + ba.textwidget(edit=self._icon_button_label, text=self._icon) + + def get_name(self) -> str: + """Return the current profile name value.""" + if self._is_account_profile: + new_name = '__account__' + elif self._global: + new_name = self._name + else: + new_name = cast(str, ba.textwidget(query=self._text_field)) + return new_name + + def save(self, transition_out: bool = True) -> bool: + """Save has been selected.""" + from bastd.ui.profile import browser as pbrowser + new_name = self.get_name().strip() + + if not new_name: + ba.screenmessage(ba.Lstr(resource='nameNotEmptyText')) + ba.playsound(ba.getsound('error')) + return False + + if transition_out: + ba.playsound(ba.getsound('gunCocking')) + + # Delete old in case we're renaming. + if self._existing_profile and self._existing_profile != new_name: + _ba.add_transaction({ + 'type': 'REMOVE_PLAYER_PROFILE', + 'name': self._existing_profile + }) + + # Also lets be aware we're no longer global if we're taking a + # new name (will need to re-request it). + self._global = False + + _ba.add_transaction({ + 'type': 'ADD_PLAYER_PROFILE', + 'name': new_name, + 'profile': { + 'character': self._spazzes[self._icon_index], + 'color': self._color, + 'global': self._global, + 'icon': self._icon, + 'highlight': self._highlight + } + }) + + if transition_out: + _ba.run_transactions() + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (pbrowser.ProfileBrowserWindow( + 'in_left', + selected_profile=new_name, + in_main_menu=self._in_main_menu).get_root_widget()) + return True diff --git a/assets/src/data/scripts/bastd/ui/profile/upgrade.py b/assets/src/data/scripts/bastd/ui/profile/upgrade.py new file mode 100644 index 00000000..a12fb996 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/profile/upgrade.py @@ -0,0 +1,236 @@ +"""UI for player profile upgrades.""" + +from __future__ import annotations + +import time +import weakref +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Optional, Dict + from bastd.ui.profile.edit import EditProfileWindow + + +class ProfileUpgradeWindow(ba.OldWindow): + """Window for player profile upgrades to global.""" + + def __init__(self, + edit_profile_window: EditProfileWindow, + transition: str = 'in_right'): + from ba.internal import serverget + self._r = 'editProfileWindow' + + self._width = 680 + self._height = 350 + self._base_scale = (2.05 if ba.app.small_ui else + 1.5 if ba.app.med_ui else 1.2) + self._upgrade_start_time: Optional[float] = None + self._name = edit_profile_window.get_name() + self._edit_profile_window = weakref.ref(edit_profile_window) + + top_extra = 15 if ba.app.small_ui else 15 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + top_extra), + toolbar_visibility='menu_currency', + transition=transition, + scale=self._base_scale, + stack_offset=(0, 15) if ba.app.small_ui else (0, 0))) + cancel_button = ba.buttonwidget(parent=self._root_widget, + position=(52, 30), + size=(155, 60), + scale=0.8, + autoselect=True, + label=ba.Lstr(resource='cancelText'), + on_activate_call=self._cancel) + self._upgrade_button = ba.buttonwidget( + parent=self._root_widget, + position=(self._width - 190, 30), + size=(155, 60), + scale=0.8, + autoselect=True, + label=ba.Lstr(resource='upgradeText'), + on_activate_call=self._on_upgrade_press) + ba.containerwidget(edit=self._root_widget, + cancel_button=cancel_button, + start_button=self._upgrade_button, + selected_child=self._upgrade_button) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 38), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.upgradeToGlobalProfileText'), + color=ba.app.title_color, + maxwidth=self._width * 0.45, + scale=1.0, + h_align="center", + v_align="center") + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 100), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.upgradeProfileInfoText'), + color=ba.app.infotextcolor, + maxwidth=self._width * 0.8, + scale=0.7, + h_align="center", + v_align="center") + + self._status_text = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height - 160), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.checkingAvailabilityText', + subs=[('${NAME}', self._name)]), + color=(0.8, 0.4, 0.0), + maxwidth=self._width * 0.8, + scale=0.65, + h_align="center", + v_align="center") + + self._price_text = ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, + self._height - 230), + size=(0, 0), + text='', + color=(0.2, 1, 0.2), + maxwidth=self._width * 0.8, + scale=1.5, + h_align="center", + v_align="center") + + self._tickets_text: Optional[ba.Widget] + if not ba.app.toolbars: + self._tickets_text = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.9 - 5, self._height - 30), + size=(0, 0), + text=ba.charstr(ba.SpecialChar.TICKET) + '123', + color=(0.2, 1, 0.2), + maxwidth=100, + scale=0.5, + h_align="right", + v_align="center") + else: + self._tickets_text = None + + serverget('bsGlobalProfileCheck', { + 'name': self._name, + 'b': ba.app.build_number + }, + callback=ba.WeakCall(self._profile_check_result)) + self._cost = _ba.get_account_misc_read_val('price.global_profile', 500) + self._status: Optional[str] = 'waiting' + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + self._update() + + def _profile_check_result(self, result: Optional[Dict[str, Any]]) -> None: + if result is None: + ba.textwidget( + edit=self._status_text, + text=ba.Lstr(resource='internal.unavailableNoConnectionText'), + color=(1, 0, 0)) + self._status = 'error' + ba.buttonwidget(edit=self._upgrade_button, + color=(0.4, 0.4, 0.4), + textcolor=(0.5, 0.5, 0.5)) + else: + if result['available']: + ba.textwidget(edit=self._status_text, + text=ba.Lstr(resource=self._r + '.availableText', + subs=[('${NAME}', self._name)]), + color=(0, 1, 0)) + ba.textwidget(edit=self._price_text, + text=ba.charstr(ba.SpecialChar.TICKET) + + str(self._cost)) + self._status = None + else: + ba.textwidget(edit=self._status_text, + text=ba.Lstr(resource=self._r + + '.unavailableText', + subs=[('${NAME}', self._name)]), + color=(1, 0, 0)) + self._status = 'unavailable' + ba.buttonwidget(edit=self._upgrade_button, + color=(0.4, 0.4, 0.4), + textcolor=(0.5, 0.5, 0.5)) + + def _on_upgrade_press(self) -> None: + from bastd.ui import getcurrency + if self._status is None: + # If it appears we don't have enough tickets, offer to buy more. + tickets = _ba.get_account_ticket_count() + if tickets < self._cost: + ba.playsound(ba.getsound('error')) + getcurrency.show_get_tickets_prompt() + return + ba.screenmessage(ba.Lstr(resource='purchasingText'), + color=(0, 1, 0)) + self._status = 'pre_upgrading' + + # Now we tell the original editor to save the profile, add an + # upgrade transaction, and then sit and wait for everything to + # go through. + edit_profile_window = self._edit_profile_window() + if edit_profile_window is None: + print('profile upgrade: original edit window gone') + return + success = edit_profile_window.save(transition_out=False) + if not success: + print('profile upgrade: error occurred saving profile') + ba.screenmessage(ba.Lstr(resource='errorText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + _ba.add_transaction({ + 'type': 'UPGRADE_PROFILE', + 'name': self._name + }) + _ba.run_transactions() + self._status = 'upgrading' + self._upgrade_start_time = time.time() + else: + ba.playsound(ba.getsound('error')) + + def _update(self) -> None: + try: + t_str = str(_ba.get_account_ticket_count()) + except Exception: + t_str = '?' + if self._tickets_text is not None: + ba.textwidget(edit=self._tickets_text, + text=ba.Lstr( + resource='getTicketsWindow.youHaveShortText', + subs=[('${COUNT}', + ba.charstr(ba.SpecialChar.TICKET) + t_str) + ])) + + # Once we've kicked off an upgrade attempt and all transactions go + # through, we're done. + if (self._status == 'upgrading' + and not _ba.have_outstanding_transactions()): + self._status = 'exiting' + ba.containerwidget(edit=self._root_widget, transition='out_right') + edit_profile_window = self._edit_profile_window() + if edit_profile_window is None: + print('profile upgrade transition out:' + ' original edit window gone') + return + ba.playsound(ba.getsound('gunCocking')) + edit_profile_window.reload_window() + + def _cancel(self) -> None: + # If we recently sent out an upgrade request, disallow canceling + # for a bit. + if (self._upgrade_start_time is not None + and time.time() - self._upgrade_start_time < 10.0): + ba.playsound(ba.getsound('error')) + return + ba.containerwidget(edit=self._root_widget, transition='out_right') diff --git a/assets/src/data/scripts/bastd/ui/promocode.py b/assets/src/data/scripts/bastd/ui/promocode.py new file mode 100644 index 00000000..67827dd0 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/promocode.py @@ -0,0 +1,114 @@ +"""UI functionality for entering promo codes.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Optional, Tuple + + +class PromoCodeWindow(ba.OldWindow): + """Window for entering promo codes.""" + + def __init__(self, modal: bool = False, origin_widget: ba.Widget = None): + + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + transition = 'in_right' + + width = 450 + height = 230 + + self._modal = modal + self._r = 'promoCodeWindow' + + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition=transition, + toolbar_visibility='menu_minimal_no_back', + scale_origin_stack_offset=scale_origin, + scale=(2.0 if ba.app.small_ui else 1.5 if ba.app.med_ui else 1.0))) + + btn = ba.buttonwidget(parent=self._root_widget, + scale=0.5, + position=(40, height - 40), + size=(60, 60), + label='', + on_activate_call=self._do_back, + autoselect=True, + color=(0.55, 0.5, 0.6), + icon=ba.gettexture('crossOut'), + iconscale=1.2) + + ba.textwidget(parent=self._root_widget, + text=ba.Lstr(resource=self._r + '.codeText'), + position=(22, height - 113), + color=(0.8, 0.8, 0.8, 1.0), + size=(90, 30), + h_align='right') + self._text_field = ba.textwidget( + parent=self._root_widget, + position=(125, height - 121), + size=(280, 46), + text='', + h_align="left", + v_align="center", + max_chars=64, + color=(0.9, 0.9, 0.9, 1.0), + description=ba.Lstr(resource=self._r + '.codeText'), + editable=True, + padding=4, + on_return_press_call=self._activate_enter_button) + ba.widget(edit=btn, down_widget=self._text_field) + + b_width = 200 + self._enter_button = btn2 = ba.buttonwidget( + parent=self._root_widget, + position=(width * 0.5 - b_width * 0.5, height - 200), + size=(b_width, 60), + scale=1.0, + label=ba.Lstr(resource='submitText', + fallback_resource=self._r + '.enterText'), + on_activate_call=self._do_enter) + ba.containerwidget(edit=self._root_widget, + cancel_button=btn, + start_button=btn2, + selected_child=self._text_field) + + def _do_back(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import advanced + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + if not self._modal: + ba.app.main_menu_window = (advanced.AdvancedSettingsWindow( + transition='in_left').get_root_widget()) + + def _activate_enter_button(self) -> None: + self._enter_button.activate() + + def _do_enter(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import advanced + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + if not self._modal: + ba.app.main_menu_window = (advanced.AdvancedSettingsWindow( + transition='in_left').get_root_widget()) + _ba.add_transaction({ + 'type': 'PROMO_CODE', + 'expire_time': time.time() + 5, + 'code': ba.textwidget(query=self._text_field) + }) + _ba.run_transactions() diff --git a/assets/src/data/scripts/bastd/ui/purchase.py b/assets/src/data/scripts/bastd/ui/purchase.py new file mode 100644 index 00000000..34960ebc --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/purchase.py @@ -0,0 +1,149 @@ +"""UI related to purchasing items.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Dict, List, Optional + + +class PurchaseWindow(ba.OldWindow): + """Window for purchasing one or more items.""" + + def __init__(self, + items: List[str], + transition: str = 'in_right', + header_text: ba.Lstr = None): + from ba.internal import get_store_item_display_size + from bastd.ui.store import item as storeitemui + if header_text is None: + header_text = ba.Lstr(resource='unlockThisText', + fallback_resource='unlockThisInTheStoreText') + if len(items) != 1: + raise Exception('expected exactly 1 item') + self._items = list(items) + self._width = 580 + self._height = 520 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition=transition, + toolbar_visibility='menu_currency', + scale=(1.2 if ba.app.small_ui else 1.1 if ba.app.med_ui else 1.0), + stack_offset=(0, -15) if ba.app.small_ui else (0, 0))) + self._is_double = False + self._title_text = ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, + self._height - 30), + size=(0, 0), + text=header_text, + h_align='center', + v_align='center', + maxwidth=self._width * 0.9 - 120, + scale=1.2, + color=(1, 0.8, 0.3, 1)) + size = get_store_item_display_size(items[0]) + display: Dict[str, Any] = {} + storeitemui.instantiate_store_item_display( + items[0], + display, + parent_widget=self._root_widget, + b_pos=(self._width * 0.5 - size[0] * 0.5 + 10 - + ((size[0] * 0.5 + 30) if self._is_double else 0), + self._height * 0.5 - size[1] * 0.5 + 30 + + (20 if self._is_double else 0)), + b_width=size[0], + b_height=size[1], + button=False) + + # Wire up the parts we need. + if self._is_double: + pass # not working + else: + if self._items == ['pro']: + price_str = _ba.get_price(self._items[0]) + pyoffs = -15 + else: + pyoffs = 0 + price = self._price = _ba.get_account_misc_read_val( + 'price.' + str(items[0]), -1) + price_str = ba.charstr(ba.SpecialChar.TICKET) + str(price) + self._price_text = ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, + 150 + pyoffs), + size=(0, 0), + text=price_str, + h_align='center', + v_align='center', + maxwidth=self._width * 0.9, + scale=1.4, + color=(0.2, 1, 0.2)) + + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + + self._cancel_button = ba.buttonwidget( + parent=self._root_widget, + position=(50, 40), + size=(150, 60), + scale=1.0, + on_activate_call=self._cancel, + autoselect=True, + label=ba.Lstr(resource='cancelText')) + self._purchase_button = ba.buttonwidget( + parent=self._root_widget, + position=(self._width - 200, 40), + size=(150, 60), + scale=1.0, + on_activate_call=self._purchase, + autoselect=True, + label=ba.Lstr(resource='store.purchaseText')) + + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button, + start_button=self._purchase_button, + selected_child=self._purchase_button) + + def _update(self) -> None: + from ba.internal import have_pro + can_die = False + + # We go away if we see that our target item is owned. + if self._items == ['pro']: + if have_pro(): + can_die = True + else: + if _ba.get_purchased(self._items[0]): + can_die = True + + if can_die: + ba.containerwidget(edit=self._root_widget, transition='out_left') + + def _purchase(self) -> None: + from bastd.ui import getcurrency + if self._items == ['pro']: + _ba.purchase('pro') + else: + ticket_count: Optional[int] + try: + ticket_count = _ba.get_account_ticket_count() + except Exception: + ticket_count = None + if ticket_count is not None and ticket_count < self._price: + getcurrency.show_get_tickets_prompt() + ba.playsound(ba.getsound('error')) + return + + def do_it() -> None: + _ba.in_game_purchase(self._items[0], self._price) + + ba.playsound(ba.getsound('swish')) + do_it() + + def _cancel(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_right') diff --git a/assets/src/data/scripts/bastd/ui/qrcode.py b/assets/src/data/scripts/bastd/ui/qrcode.py new file mode 100644 index 00000000..6691fbef --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/qrcode.py @@ -0,0 +1,51 @@ +"""Provides functionality for displaying QR codes.""" +from __future__ import annotations + +import ba +from bastd.ui import popup + + +class QRCodeWindow(popup.PopupWindow): + """Popup window that shows a QR code.""" + + def __init__(self, origin_widget: ba.Widget, qr_tex: ba.Texture): + + position = origin_widget.get_screen_space_center() + scale = (2.3 if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + self._transitioning_out = False + self._width = 450 + self._height = 400 + bg_color = (0.5, 0.4, 0.6) + popup.PopupWindow.__init__(self, + position=position, + size=(self._width, self._height), + scale=scale, + bg_color=bg_color) + self._cancel_button = ba.buttonwidget( + parent=self.root_widget, + position=(50, self._height - 30), + size=(50, 50), + scale=0.5, + label='', + color=bg_color, + on_activate_call=self._on_cancel_press, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + ba.imagewidget(parent=self.root_widget, + position=(self._width * 0.5 - 150, + self._height * 0.5 - 150), + size=(300, 300), + texture=qr_tex) + + def _on_cancel_press(self) -> None: + self._transition_out() + + def _transition_out(self) -> None: + if not self._transitioning_out: + self._transitioning_out = True + ba.containerwidget(edit=self.root_widget, transition='out_scale') + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._transition_out() diff --git a/assets/src/data/scripts/bastd/ui/radiogroup.py b/assets/src/data/scripts/bastd/ui/radiogroup.py new file mode 100644 index 00000000..cab35ac6 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/radiogroup.py @@ -0,0 +1,31 @@ +"""UI functionality for creating radio groups of buttons.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import List, Any, Callable, Sequence + + +def make_radio_group(check_boxes: Sequence[ba.Widget], + value_names: Sequence[str], value: str, + value_change_call: Callable[[str], Any]) -> None: + """Link the provided check_boxes together into a radio group.""" + + def _radio_press(check_string: str, other_check_boxes: List[ba.Widget], + val: int) -> None: + if val == 1: + value_change_call(check_string) + for cbx in other_check_boxes: + ba.checkboxwidget(edit=cbx, value=False) + + for i, check_box in enumerate(check_boxes): + ba.checkboxwidget(edit=check_box, + value=(value == value_names[i]), + is_radio_button=True, + on_value_change_call=ba.Call( + _radio_press, value_names[i], + [c for c in check_boxes if c != check_box])) diff --git a/assets/src/data/scripts/bastd/ui/report.py b/assets/src/data/scripts/bastd/ui/report.py new file mode 100644 index 00000000..c15ef2f0 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/report.py @@ -0,0 +1,89 @@ +"""UI related to reporting bad behavior/etc.""" + +from __future__ import annotations + +import _ba +import ba + + +class ReportPlayerWindow(ba.OldWindow): + """Player for reporting naughty players.""" + + def __init__(self, account_id: str, origin_widget: ba.Widget): + self._width = 550 + self._height = 220 + self._account_id = account_id + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + + overlay_stack = _ba.get_special_widget('overlay_stack') + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + parent=overlay_stack, + transition='in_scale', + scale_origin_stack_offset=scale_origin, + scale=( + 1.8 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0))) + self._cancel_button = ba.buttonwidget(parent=self._root_widget, + scale=0.7, + position=(40, self._height - 50), + size=(50, 50), + label='', + on_activate_call=self.close, + autoselect=True, + color=(0.4, 0.4, 0.5), + icon=ba.gettexture('crossOut'), + iconscale=1.2) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.64), + size=(0, 0), + color=(1, 1, 1, 0.8), + scale=1.2, + h_align="center", + v_align="center", + text=ba.Lstr(resource='reportThisPlayerReasonText'), + maxwidth=self._width * 0.85) + ba.buttonwidget(parent=self._root_widget, + size=(235, 60), + position=(20, 30), + label=ba.Lstr(resource='reportThisPlayerLanguageText'), + on_activate_call=self._on_language_press, + autoselect=True) + ba.buttonwidget(parent=self._root_widget, + size=(235, 60), + position=(self._width - 255, 30), + label=ba.Lstr(resource='reportThisPlayerCheatingText'), + on_activate_call=self._on_cheating_press, + autoselect=True) + + def _on_language_press(self) -> None: + from urllib import parse + _ba.add_transaction({ + 'type': 'REPORT_ACCOUNT', + 'reason': 'language', + 'account': self._account_id + }) + body = ba.Lstr(resource='reportPlayerExplanationText').evaluate() + ba.open_url('mailto:support@froemling.net' + '?subject=BallisticaCore Player Report: ' + + self._account_id + '&body=' + parse.quote(body)) + self.close() + + def _on_cheating_press(self) -> None: + from urllib import parse + _ba.add_transaction({ + 'type': 'REPORT_ACCOUNT', + 'reason': 'cheating', + 'account': self._account_id + }) + body = ba.Lstr(resource='reportPlayerExplanationText').evaluate() + ba.open_url('mailto:support@froemling.net' + '?subject=BallisticaCore Player Report: ' + + self._account_id + '&body=' + parse.quote(body)) + self.close() + + def close(self) -> None: + """Close the window.""" + ba.containerwidget(edit=self._root_widget, transition='out_scale') diff --git a/assets/src/data/scripts/bastd/ui/resourcetypeinfo.py b/assets/src/data/scripts/bastd/ui/resourcetypeinfo.py new file mode 100644 index 00000000..8fc2737e --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/resourcetypeinfo.py @@ -0,0 +1,47 @@ +"""Provides a window which shows info about resource types.""" + +from __future__ import annotations + +import ba +from bastd.ui import popup + + +class ResourceTypeInfoWindow(popup.PopupWindow): + """Popup window providing info about resource types.""" + + def __init__(self, origin_widget: ba.Widget): + scale = (2.3 if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + self._transitioning_out = False + self._width = 570 + self._height = 350 + bg_color = (0.5, 0.4, 0.6) + popup.PopupWindow.__init__( + self, + size=(self._width, self._height), + toolbar_visibility='inherit', + scale=scale, + bg_color=bg_color, + position=origin_widget.get_screen_space_center()) + self._cancel_button = ba.buttonwidget( + parent=self.root_widget, + position=(50, self._height - 30), + size=(50, 50), + scale=0.5, + label='', + color=bg_color, + on_activate_call=self._on_cancel_press, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + + def _on_cancel_press(self) -> None: + self._transition_out() + + def _transition_out(self) -> None: + if not self._transitioning_out: + self._transitioning_out = True + ba.containerwidget(edit=self.root_widget, transition='out_scale') + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._transition_out() diff --git a/assets/src/data/scripts/bastd/ui/serverdialog.py b/assets/src/data/scripts/bastd/ui/serverdialog.py new file mode 100644 index 00000000..c6e9ebe0 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/serverdialog.py @@ -0,0 +1,92 @@ +"""Dialog window controlled by the master server.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Dict, Optional + + +class ServerDialogWindow(ba.OldWindow): + """A dialog window driven by the master-server.""" + + def __init__(self, data: Dict[str, Any]): + self._dialog_id = data['dialogID'] + txt = ba.Lstr(translate=('serverResponses', data['text']), + subs=data.get('subs', [])).evaluate() + txt = txt.strip() + txt_scale = 1.5 + txt_height = (_ba.get_string_height(txt, suppress_warning=True) * + txt_scale) + self._width = 500 + self._height = 130 + min(200, txt_height) + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition='in_scale', + scale=1.8 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0)) + self._starttime = ba.time(ba.TimeType.REAL, ba.TimeFormat.MILLISECONDS) + + ba.playsound(ba.getsound('swish')) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, + 70 + (self._height - 70) * 0.5), + size=(0, 0), + color=(1.0, 3.0, 1.0), + scale=txt_scale, + h_align="center", + v_align="center", + text=txt, + maxwidth=self._width * 0.85, + max_height=(self._height - 110)) + show_cancel = data.get('showCancel', True) + self._cancel_button: Optional[ba.Widget] + if show_cancel: + self._cancel_button = ba.buttonwidget( + parent=self._root_widget, + position=(30, 30), + size=(160, 60), + autoselect=True, + label=ba.Lstr(resource='cancelText'), + on_activate_call=self._cancel_press) + else: + self._cancel_button = None + self._ok_button = ba.buttonwidget( + parent=self._root_widget, + position=((self._width - 182) if show_cancel else + (self._width * 0.5 - 80), 30), + size=(160, 60), + autoselect=True, + label=ba.Lstr(resource='okText'), + on_activate_call=self._ok_press) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button, + start_button=self._ok_button, + selected_child=self._ok_button) + + def _ok_press(self) -> None: + if ba.time(ba.TimeType.REAL, + ba.TimeFormat.MILLISECONDS) - self._starttime < 1000: + ba.playsound(ba.getsound('error')) + return + _ba.add_transaction({ + 'type': 'DIALOG_RESPONSE', + 'dialogID': self._dialog_id, + 'response': 1 + }) + ba.containerwidget(edit=self._root_widget, transition='out_scale') + + def _cancel_press(self) -> None: + if ba.time(ba.TimeType.REAL, + ba.TimeFormat.MILLISECONDS) - self._starttime < 1000: + ba.playsound(ba.getsound('error')) + return + _ba.add_transaction({ + 'type': 'DIALOG_RESPONSE', + 'dialogID': self._dialog_id, + 'response': 0 + }) + ba.containerwidget(edit=self._root_widget, transition='out_scale') diff --git a/assets/src/data/scripts/bastd/ui/settings/__init__.py b/assets/src/data/scripts/bastd/ui/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assets/src/data/scripts/bastd/ui/settings/advanced.py b/assets/src/data/scripts/bastd/ui/settings/advanced.py new file mode 100644 index 00000000..edfac11e --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/advanced.py @@ -0,0 +1,670 @@ +"""UI functionality for advanced settings.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.ui import popup as popup_ui + +if TYPE_CHECKING: + from typing import Tuple, Any, Optional, List, Dict + + +class AdvancedSettingsWindow(ba.OldWindow): + """Window for editing advanced game settings.""" + + def __init__(self, + transition: str = 'in_right', + origin_widget: ba.Widget = None): + # pylint: disable=too-many-statements + from ba.internal import serverget + + app = ba.app + + # If they provided an origin-widget, scale up from that. + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self._width = 870.0 if app.small_ui else 670.0 + x_inset = 100 if app.small_ui else 0 + self._height = (390.0 + if app.small_ui else 450.0 if app.med_ui else 520.0) + self._spacing = 32 + self._menu_open = False + top_extra = 10 if app.small_ui else 0 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + top_extra), + transition=transition, + toolbar_visibility='menu_minimal', + scale_origin_stack_offset=scale_origin, + scale=2.06 if app.small_ui else 1.4 if app.med_ui else 1.0, + stack_offset=(0, -25) if app.small_ui else (0, 0))) + self._prev_lang = "" + self._prev_lang_list: List[str] = [] + self._complete_langs_list = None + self._complete_langs_error = False + self._language_popup: Optional[popup_ui.PopupMenu] = None + + # In vr-mode, the internal keyboard is currently the *only* option, + # so no need to show this. + self._show_always_use_internal_keyboard = (not app.vr_mode) + + self._scroll_width = self._width - (100 + 2 * x_inset) + self._scroll_height = self._height - 115.0 + self._sub_width = self._scroll_width * 0.95 + self._sub_height = 740.0 + + if self._show_always_use_internal_keyboard: + self._sub_height += 62 + + self._do_vr_test_button = app.vr_mode + self._do_net_test_button = True + self._extra_button_spacing = self._spacing * 2.5 + + if self._do_vr_test_button: + self._sub_height += self._extra_button_spacing + if self._do_net_test_button: + self._sub_height += self._extra_button_spacing + + self._r = 'settingsWindowAdvanced' + + if app.toolbars and app.small_ui: + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._do_back) + self._back_button = None + else: + self._back_button = ba.buttonwidget( + parent=self._root_widget, + position=(53 + x_inset, self._height - 60), + size=(140, 60), + scale=0.8, + autoselect=True, + label=ba.Lstr(resource='backText'), + button_type='back', + on_activate_call=self._do_back) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._back_button) + + self._title_text = ba.textwidget(parent=self._root_widget, + position=(0, self._height - 52), + size=(self._width, 25), + text=ba.Lstr(resource=self._r + + '.titleText'), + color=app.title_color, + h_align="center", + v_align="top") + + if self._back_button is not None: + ba.buttonwidget(edit=self._back_button, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + self._scrollwidget = ba.scrollwidget(parent=self._root_widget, + position=(50 + x_inset, 50), + simple_culling_v=20.0, + highlight=False, + size=(self._scroll_width, + self._scroll_height)) + ba.containerwidget(edit=self._scrollwidget, + selection_loop_to_parent=True) + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._sub_width, + self._sub_height), + background=False, + selection_loop_to_parent=True) + + self._rebuild() + + # Rebuild periodically to pick up language changes/additions/etc. + self._rebuild_timer = ba.Timer(1.0, + ba.WeakCall(self._rebuild), + repeat=True, + timetype=ba.TimeType.REAL) + + # Fetch the list of completed languages. + serverget('bsLangGetCompleted', {'b': app.build_number}, + callback=ba.WeakCall(self._completed_langs_cb)) + + def _update_lang_status(self) -> None: + if self._complete_langs_list is not None: + up_to_date = (ba.app.language in self._complete_langs_list) + ba.textwidget( + edit=self._lang_status_text, + text='' if ba.app.language == 'Test' else ba.Lstr( + resource=self._r + '.translationNoUpdateNeededText') + if up_to_date else ba.Lstr(resource=self._r + + '.translationUpdateNeededText'), + color=(0.2, 1.0, 0.2, 0.8) if up_to_date else + (1.0, 0.2, 0.2, 0.8)) + else: + ba.textwidget( + edit=self._lang_status_text, + text=ba.Lstr(resource=self._r + '.translationFetchErrorText') + if self._complete_langs_error else ba.Lstr( + resource=self._r + '.translationFetchingStatusText'), + color=(1.0, 0.5, 0.2) if self._complete_langs_error else + (0.7, 0.7, 0.7)) + + def _rebuild(self) -> None: + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + from bastd.ui import config + from ba.internal import show_user_scripts + + # Don't rebuild if the menu is open or if our language and + # language-list hasn't changed. + # NOTE - although we now support widgets updating their own + # translations, we still change the label formatting on the language + # menu based on the language so still need this. ...however we could + # make this more limited to it only rebuilds that one menu instead + # of everything. + if self._menu_open or ( + self._prev_lang == _ba.app.config.get('Lang', None) + and self._prev_lang_list == ba.get_valid_languages()): + return + self._prev_lang = _ba.app.config.get('Lang', None) + self._prev_lang_list = ba.get_valid_languages() + + # Clear out our sub-container. + children = self._subcontainer.get_children() + for child in children: + child.delete() + + v = self._sub_height - 35 + + v -= self._spacing * 1.2 + + # Update our existing back button and title. + if self._back_button is not None: + ba.buttonwidget(edit=self._back_button, + label=ba.Lstr(resource='backText')) + ba.buttonwidget(edit=self._back_button, + label=ba.charstr(ba.SpecialChar.BACK)) + + ba.textwidget(edit=self._title_text, + text=ba.Lstr(resource=self._r + '.titleText')) + + this_button_width = 410 + + self._promo_code_button = ba.buttonwidget( + parent=self._subcontainer, + position=(self._sub_width / 2 - this_button_width / 2, v - 14), + size=(this_button_width, 60), + autoselect=True, + label=ba.Lstr(resource=self._r + '.enterPromoCodeText'), + text_scale=1.0, + on_activate_call=self._on_promo_code_press) + if self._back_button is not None: + ba.widget(edit=self._promo_code_button, + up_widget=self._back_button, + left_widget=self._back_button) + v -= self._extra_button_spacing * 0.8 + + ba.textwidget(parent=self._subcontainer, + position=(200, v + 10), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.languageText'), + maxwidth=150, + scale=0.95, + color=ba.app.title_color, + h_align='right', + v_align='center') + + languages = ba.get_valid_languages() + cur_lang = _ba.app.config.get('Lang', None) + if cur_lang is None: + cur_lang = 'Auto' + + # We have a special dict of language names in that language + # so we don't have to go digging through each full language. + try: + import json + with open('data/data/langdata.json') as infile: + lang_names_translated = (json.loads( + infile.read())['lang_names_translated']) + except Exception: + ba.print_exception('error reading lang data') + lang_names_translated = {} + + langs_translated = {} + for lang in languages: + langs_translated[lang] = lang_names_translated.get(lang, lang) + + langs_full = {} + for lang in languages: + lang_translated = ba.Lstr(translate=('languages', lang)).evaluate() + if langs_translated[lang] == lang_translated: + langs_full[lang] = lang_translated + else: + langs_full[lang] = (langs_translated[lang] + ' (' + + lang_translated + ')') + + self._language_popup = popup_ui.PopupMenu( + parent=self._subcontainer, + position=(210, v - 19), + width=150, + opening_call=ba.WeakCall(self._on_menu_open), + closing_call=ba.WeakCall(self._on_menu_close), + autoselect=False, + on_value_change_call=ba.WeakCall(self._on_menu_choice), + choices=['Auto'] + languages, + button_size=(250, 60), + choices_display=([ + ba.Lstr(value=(ba.Lstr(resource='autoText').evaluate() + ' (' + + ba.Lstr(translate=( + 'languages', + ba.app.default_language)).evaluate() + ')')) + ] + [ba.Lstr(value=langs_full[l]) for l in languages]), + current_choice=cur_lang) + + v -= self._spacing * 1.8 + + ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, v + 10), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.helpTranslateText', + subs=[('${APP_NAME}', + ba.Lstr(resource='titleText'))]), + maxwidth=self._sub_width * 0.9, + max_height=55, + flatness=1.0, + scale=0.65, + color=(0.4, 0.9, 0.4, 0.8), + h_align='center', + v_align='center') + v -= self._spacing * 1.9 + this_button_width = 410 + self._translation_editor_button = ba.buttonwidget( + parent=self._subcontainer, + position=(self._sub_width / 2 - this_button_width / 2, v - 24), + size=(this_button_width, 60), + label=ba.Lstr(resource=self._r + '.translationEditorButtonText', + subs=[('${APP_NAME}', ba.Lstr(resource='titleText')) + ]), + autoselect=True, + on_activate_call=ba.Call(ba.open_url, + 'http://bombsquadgame.com/translate')) + + self._lang_status_text = ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, + v - 40), + size=(0, 0), + text='', + flatness=1.0, + scale=0.63, + h_align='center', + v_align='center', + maxwidth=400.0) + self._update_lang_status() + v -= 40 + + lang_inform = _ba.get_account_misc_val('langInform', False) + + self._language_inform_checkbox = cbw = ba.checkboxwidget( + parent=self._subcontainer, + position=(50, v - 50), + size=(self._sub_width - 100, 30), + autoselect=True, + maxwidth=430, + textcolor=(0.8, 0.8, 0.8), + value=lang_inform, + text=ba.Lstr(resource=self._r + '.translationInformMe'), + on_value_change_call=ba.WeakCall( + self._on_lang_inform_value_change)) + + ba.widget(edit=self._translation_editor_button, + down_widget=cbw, + up_widget=self._language_popup.get_button()) + + v -= self._spacing * 3.0 + + self._kick_idle_players_check_box = config.ConfigCheckBox( + parent=self._subcontainer, + position=(50, v), + size=(self._sub_width - 100, 30), + configkey="Kick Idle Players", + displayname=ba.Lstr(resource=self._r + '.kickIdlePlayersText'), + scale=1.0, + maxwidth=430) + + self._always_use_internal_keyboard_check_box: Optional[ + config.ConfigCheckBox] + if self._show_always_use_internal_keyboard: + v -= 42 + self._always_use_internal_keyboard_check_box = ( + config.ConfigCheckBox( + parent=self._subcontainer, + position=(50, v), + size=(self._sub_width - 100, 30), + configkey="Always Use Internal Keyboard", + autoselect=True, + displayname=ba.Lstr(resource=self._r + + '.alwaysUseInternalKeyboardText'), + scale=1.0, + maxwidth=430)) + ba.textwidget( + parent=self._subcontainer, + position=(90, v - 10), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.alwaysUseInternalKeyboardDescriptionText'), + maxwidth=400, + flatness=1.0, + scale=0.65, + color=(0.4, 0.9, 0.4, 0.8), + h_align='left', + v_align='center') + v -= 20 + else: + self._always_use_internal_keyboard_check_box = None + + v -= self._spacing * 2.1 + + this_button_width = 410 + self._show_user_mods_button = ba.buttonwidget( + parent=self._subcontainer, + position=(self._sub_width / 2 - this_button_width / 2, v - 10), + size=(this_button_width, 60), + autoselect=True, + label=ba.Lstr(resource=self._r + '.showUserModsText'), + text_scale=1.0, + on_activate_call=show_user_scripts) + if self._show_always_use_internal_keyboard: + assert self._always_use_internal_keyboard_check_box is not None + ba.widget(edit=self._always_use_internal_keyboard_check_box.widget, + down_widget=self._show_user_mods_button) + ba.widget( + edit=self._show_user_mods_button, + up_widget=self._always_use_internal_keyboard_check_box.widget) + else: + ba.widget(edit=self._show_user_mods_button, + up_widget=self._kick_idle_players_check_box.widget) + ba.widget(edit=self._kick_idle_players_check_box.widget, + down_widget=self._show_user_mods_button) + + v -= self._spacing * 2.0 + + btn = self._modding_guide_button = ba.buttonwidget( + parent=self._subcontainer, + position=(self._sub_width / 2 - this_button_width / 2, v - 10), + size=(this_button_width, 60), + autoselect=True, + label=ba.Lstr(resource=self._r + '.moddingGuideText'), + text_scale=1.0, + on_activate_call=ba.Call( + ba.open_url, + 'http://www.froemling.net/docs/bombsquad-modding-guide')) + + v -= self._spacing * 1.8 + + def doit(val: Any) -> None: + del val # Unused. + ba.screenmessage(ba.Lstr(resource=self._r + '.mustRestartText'), + color=(1, 1, 0)) + + self._enable_package_mods_checkbox = config.ConfigCheckBox( + parent=self._subcontainer, + position=(80, v), + size=(self._sub_width - 100, 30), + configkey="Enable Package Mods", + autoselect=True, + value_change_call=doit, + displayname=ba.Lstr(resource=self._r + '.enablePackageModsText'), + scale=1.0, + maxwidth=400) + ccb = self._enable_package_mods_checkbox.widget + ba.widget(edit=btn, down_widget=ccb) + ba.widget(edit=ccb, up_widget=btn) + ba.textwidget(parent=self._subcontainer, + position=(90, v - 10), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.enablePackageModsDescriptionText'), + maxwidth=400, + flatness=1.0, + scale=0.65, + color=(0.4, 0.9, 0.4, 0.8), + h_align='left', + v_align='center') + + v -= self._spacing * 0.6 + + self._vr_test_button: Optional[ba.Widget] + if self._do_vr_test_button: + v -= self._extra_button_spacing + self._vr_test_button = ba.buttonwidget( + parent=self._subcontainer, + position=(self._sub_width / 2 - this_button_width / 2, v - 14), + size=(this_button_width, 60), + autoselect=True, + label=ba.Lstr(resource=self._r + '.vrTestingText'), + text_scale=1.0, + on_activate_call=self._on_vr_test_press) + else: + self._vr_test_button = None + + self._net_test_button: Optional[ba.Widget] + if self._do_net_test_button: + v -= self._extra_button_spacing + self._net_test_button = ba.buttonwidget( + parent=self._subcontainer, + position=(self._sub_width / 2 - this_button_width / 2, v - 14), + size=(this_button_width, 60), + autoselect=True, + label=ba.Lstr(resource=self._r + '.netTestingText'), + text_scale=1.0, + on_activate_call=self._on_net_test_press) + else: + self._net_test_button = None + + v -= 70 + self._benchmarks_button = ba.buttonwidget( + parent=self._subcontainer, + position=(self._sub_width / 2 - this_button_width / 2, v - 14), + size=(this_button_width, 60), + autoselect=True, + label=ba.Lstr(resource=self._r + '.benchmarksText'), + text_scale=1.0, + on_activate_call=self._on_benchmark_press) + + ba.widget( + edit=self._vr_test_button + if self._vr_test_button is not None else self._net_test_button + if self._net_test_button is not None else self._benchmarks_button, + up_widget=cbw) + + for child in self._subcontainer.get_children(): + ba.widget(edit=child, show_buffer_bottom=30, show_buffer_top=20) + + if ba.app.toolbars: + pbtn = _ba.get_special_widget('party_button') + ba.widget(edit=self._scrollwidget, right_widget=pbtn) + if self._back_button is None: + ba.widget(edit=self._scrollwidget, + left_widget=_ba.get_special_widget('back_button')) + + self._restore_state() + + def _on_lang_inform_value_change(self, val: bool) -> None: + _ba.add_transaction({ + 'type': 'SET_MISC_VAL', + 'name': 'langInform', + 'value': val + }) + _ba.run_transactions() + + def _on_vr_test_press(self) -> None: + from bastd.ui.settings import vrtesting + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (vrtesting.VRTestingWindow( + transition='in_right').get_root_widget()) + + def _on_net_test_press(self) -> None: + from bastd.ui.settings import nettesting + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (nettesting.NetTestingWindow( + transition='in_right').get_root_widget()) + + def _on_friend_promo_code_press(self) -> None: + from bastd.ui import appinvite + from bastd.ui import account + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + appinvite.handle_app_invites_press() + + def _on_promo_code_press(self) -> None: + from bastd.ui import promocode + from bastd.ui import account + + # We have to be logged in for promo-codes to work. + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (promocode.PromoCodeWindow( + origin_widget=self._promo_code_button).get_root_widget()) + + def _on_benchmark_press(self) -> None: + from bastd.ui import debug + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (debug.DebugWindow( + transition='in_right').get_root_widget()) + + def _save_state(self) -> None: + # pylint: disable=too-many-branches + try: + sel = self._root_widget.get_selected_child() + if sel == self._scrollwidget: + sel = self._subcontainer.get_selected_child() + if sel == self._vr_test_button: + sel_name = 'VRTest' + elif sel == self._net_test_button: + sel_name = 'NetTest' + elif sel == self._promo_code_button: + sel_name = 'PromoCode' + elif sel == self._benchmarks_button: + sel_name = 'Benchmarks' + elif sel == self._kick_idle_players_check_box.widget: + sel_name = 'KickIdlePlayers' + elif (self._always_use_internal_keyboard_check_box is not None + and sel == + self._always_use_internal_keyboard_check_box.widget): + sel_name = 'AlwaysUseInternalKeyboard' + elif (self._language_popup is not None + and sel == self._language_popup.get_button()): + sel_name = 'Languages' + elif sel == self._translation_editor_button: + sel_name = 'TranslationEditor' + elif sel == self._show_user_mods_button: + sel_name = 'ShowUserMods' + elif sel == self._modding_guide_button: + sel_name = 'ModdingGuide' + elif sel == self._enable_package_mods_checkbox.widget: + sel_name = 'PackageMods' + elif sel == self._language_inform_checkbox: + sel_name = 'LangInform' + else: + raise Exception("unrecognized selection") + elif sel == self._back_button: + sel_name = 'Back' + else: + raise Exception("unrecognized selection") + ba.app.window_states[self.__class__.__name__] = { + 'sel_name': sel_name + } + except Exception: + ba.print_exception('error saving state for', self.__class__) + + def _restore_state(self) -> None: + # pylint: disable=too-many-branches + try: + try: + sel_name = ba.app.window_states[ + self.__class__.__name__]['sel_name'] + except Exception: + sel_name = None + if sel_name == 'Back': + sel = self._back_button + else: + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget) + if sel_name == 'VRTest': + sel = self._vr_test_button + elif sel_name == 'NetTest': + sel = self._net_test_button + elif sel_name == 'PromoCode': + sel = self._promo_code_button + elif sel_name == 'Benchmarks': + sel = self._benchmarks_button + elif sel_name == 'KickIdlePlayers': + sel = self._kick_idle_players_check_box.widget + elif (sel_name == 'AlwaysUseInternalKeyboard' + and self._always_use_internal_keyboard_check_box is + not None): + sel = self._always_use_internal_keyboard_check_box.widget + elif (sel_name == 'Languages' + and self._language_popup is not None): + sel = self._language_popup.get_button() + elif sel_name == 'TranslationEditor': + sel = self._translation_editor_button + elif sel_name == 'ShowUserMods': + sel = self._show_user_mods_button + elif sel_name == 'ModdingGuide': + sel = self._modding_guide_button + elif sel_name == 'PackageMods': + sel = self._enable_package_mods_checkbox.widget + elif sel_name == 'LangInform': + sel = self._language_inform_checkbox + else: + sel = None + if sel is not None: + ba.containerwidget(edit=self._subcontainer, + selected_child=sel, + visible_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) + + def _on_menu_open(self) -> None: + self._menu_open = True + + def _on_menu_close(self) -> None: + self._menu_open = False + + def _on_menu_choice(self, choice: str) -> None: + ba.setlanguage(None if choice == 'Auto' else choice) + self._save_state() + ba.timer(0.1, ba.WeakCall(self._rebuild), timetype=ba.TimeType.REAL) + + def _completed_langs_cb(self, results: Optional[Dict[str, Any]]) -> None: + if results is not None and results['langs'] is not None: + self._complete_langs_list = results['langs'] + self._complete_langs_error = False + else: + self._complete_langs_list = None + self._complete_langs_error = True + ba.timer(0.001, + ba.WeakCall(self._update_lang_status), + timetype=ba.TimeType.REAL) + + def _do_back(self) -> None: + from bastd.ui.settings import allsettings + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = (allsettings.AllSettingsWindow( + transition='in_left').get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/settings/allsettings.py b/assets/src/data/scripts/bastd/ui/settings/allsettings.py new file mode 100644 index 00000000..58b4e06f --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/allsettings.py @@ -0,0 +1,265 @@ +"""UI for top level settings categories.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Tuple, Optional, Union + + +class AllSettingsWindow(ba.OldWindow): + """Window for selecting a settings category.""" + + def __init__(self, + transition: str = 'in_right', + origin_widget: ba.Widget = None): + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + ba.set_analytics_screen('Settings Window') + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + width = 900 if ba.app.small_ui else 580 + x_inset = 75 if ba.app.small_ui else 0 + height = 435 + # button_height = 42 + self._r = 'settingsWindow' + top_extra = 20 if ba.app.small_ui else 0 + + super().__init__(root_widget=ba.containerwidget( + size=(width, height + top_extra), + transition=transition, + toolbar_visibility='menu_minimal', + scale_origin_stack_offset=scale_origin, + scale=( + 1.75 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0), + stack_offset=(0, -8) if ba.app.small_ui else (0, 0))) + + if ba.app.toolbars and ba.app.small_ui: + self._back_button = None + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._do_back) + else: + self._back_button = btn = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(40 + x_inset, height - 55), + size=(130, 60), + scale=0.8, + text_scale=1.2, + label=ba.Lstr(resource='backText'), + button_type='back', + on_activate_call=self._do_back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + ba.textwidget(parent=self._root_widget, + position=(0, height - 44), + size=(width, 25), + text=ba.Lstr(resource=self._r + '.titleText'), + color=ba.app.title_color, + h_align="center", + v_align="center", + maxwidth=130) + + if self._back_button is not None: + ba.buttonwidget(edit=self._back_button, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + v = height - 80 + v -= 145 + + basew = 280 if ba.app.small_ui else 230 + baseh = 170 + x_offs = x_inset + (105 + if ba.app.small_ui else 72) - basew # now unused + x_offs2 = x_offs + basew - 7 + x_offs3 = x_offs + 2 * (basew - 7) + x_offs4 = x_offs2 + x_offs5 = x_offs3 + + def _b_title(x: float, y: float, button: ba.Widget, + text: Union[str, ba.Lstr]) -> None: + ba.textwidget(parent=self._root_widget, + text=text, + position=(x + basew * 0.47, y + baseh * 0.22), + maxwidth=basew * 0.7, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=button, + color=(0.7, 0.9, 0.7, 1.0)) + + ctb = self._controllers_button = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(x_offs2, v), + size=(basew, baseh), + button_type='square', + label='', + on_activate_call=self._do_controllers) + if ba.app.toolbars and self._back_button is None: + bbtn = _ba.get_special_widget('back_button') + ba.widget(edit=ctb, left_widget=bbtn) + _b_title(x_offs2, v, ctb, + ba.Lstr(resource=self._r + '.controllersText')) + imgw = imgh = 130 + ba.imagewidget(parent=self._root_widget, + position=(x_offs2 + basew * 0.49 - imgw * 0.5, v + 35), + size=(imgw, imgh), + texture=ba.gettexture('controllerIcon'), + draw_controller=ctb) + + gfxb = self._graphics_button = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(x_offs3, v), + size=(basew, baseh), + button_type='square', + label='', + on_activate_call=self._do_graphics) + if ba.app.toolbars: + pbtn = _ba.get_special_widget('party_button') + ba.widget(edit=gfxb, up_widget=pbtn, right_widget=pbtn) + _b_title(x_offs3, v, gfxb, ba.Lstr(resource=self._r + '.graphicsText')) + imgw = imgh = 110 + ba.imagewidget(parent=self._root_widget, + position=(x_offs3 + basew * 0.49 - imgw * 0.5, v + 42), + size=(imgw, imgh), + texture=ba.gettexture('graphicsIcon'), + draw_controller=gfxb) + + v -= (baseh - 5) + + abtn = self._audio_button = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(x_offs4, v), + size=(basew, baseh), + button_type='square', + label='', + on_activate_call=self._do_audio) + _b_title(x_offs4, v, abtn, ba.Lstr(resource=self._r + '.audioText')) + imgw = imgh = 120 + ba.imagewidget(parent=self._root_widget, + position=(x_offs4 + basew * 0.49 - imgw * 0.5 + 5, + v + 35), + size=(imgw, imgh), + color=(1, 1, 0), + texture=ba.gettexture('audioIcon'), + draw_controller=abtn) + + avb = self._advanced_button = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(x_offs5, v), + size=(basew, baseh), + button_type='square', + label='', + on_activate_call=self._do_advanced) + _b_title(x_offs5, v, avb, ba.Lstr(resource=self._r + '.advancedText')) + imgw = imgh = 120 + ba.imagewidget(parent=self._root_widget, + position=(x_offs5 + basew * 0.49 - imgw * 0.5 + 5, + v + 35), + size=(imgw, imgh), + color=(0.8, 0.95, 1), + texture=ba.gettexture('advancedIcon'), + draw_controller=avb) + + def _do_back(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import mainmenu + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = (mainmenu.MainMenuWindow( + transition='in_left').get_root_widget()) + + def _do_controllers(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import controls + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (controls.ControlsSettingsWindow( + origin_widget=self._controllers_button).get_root_widget()) + + def _do_graphics(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import graphics + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (graphics.GraphicsSettingsWindow( + origin_widget=self._graphics_button).get_root_widget()) + + def _do_audio(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import audio + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (audio.AudioSettingsWindow( + origin_widget=self._audio_button).get_root_widget()) + + def _do_advanced(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import advanced + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (advanced.AdvancedSettingsWindow( + origin_widget=self._advanced_button).get_root_widget()) + + def _save_state(self) -> None: + try: + sel = self._root_widget.get_selected_child() + if sel == self._controllers_button: + sel_name = 'Controllers' + elif sel == self._graphics_button: + sel_name = 'Graphics' + elif sel == self._audio_button: + sel_name = 'Audio' + elif sel == self._advanced_button: + sel_name = 'Advanced' + elif sel == self._back_button: + sel_name = 'Back' + else: + raise Exception("unrecognized selection") + ba.app.window_states[self.__class__.__name__] = { + 'sel_name': sel_name + } + except Exception: + ba.print_exception('error saving state for', self.__class__) + + def _restore_state(self) -> None: + try: + try: + sel_name = ba.app.window_states[ + self.__class__.__name__]['sel_name'] + except Exception: + sel_name = None + sel: Optional[ba.Widget] + if sel_name == 'Controllers': + sel = self._controllers_button + elif sel_name == 'Graphics': + sel = self._graphics_button + elif sel_name == 'Audio': + sel = self._audio_button + elif sel_name == 'Advanced': + sel = self._advanced_button + elif sel_name == 'Back': + sel = self._back_button + else: + sel = self._controllers_button + if sel is not None: + ba.containerwidget(edit=self._root_widget, selected_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) diff --git a/assets/src/data/scripts/bastd/ui/settings/audio.py b/assets/src/data/scripts/bastd/ui/settings/audio.py new file mode 100644 index 00000000..e2085880 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/audio.py @@ -0,0 +1,279 @@ +"""Provides audio settings UI.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Tuple, Optional + + +class AudioSettingsWindow(ba.OldWindow): + """Window for editing audio settings.""" + + def __init__(self, + transition: str = 'in_right', + origin_widget: ba.Widget = None): + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + from ba.internal import have_music_player, music_volume_changed + from bastd.ui import popup as popup_ui + from bastd.ui import config as cfgui + + # If they provided an origin-widget, scale up from that. + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self._r = 'audioSettingsWindow' + + spacing = 50.0 + width = 460.0 + height = 210.0 + + # Update: hard-coding head-relative audio to true now, so not showing + # options. + # show_vr_head_relative_audio = True if ba.app.vr_mode else False + show_vr_head_relative_audio = False + + if show_vr_head_relative_audio: + height += 70 + + show_soundtracks = False + if have_music_player(): + show_soundtracks = True + height += spacing * 2.0 + + base_scale = (2.05 + if ba.app.small_ui else 1.6 if ba.app.med_ui else 1.0) + popup_menu_scale = base_scale * 1.2 + + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition=transition, + scale=base_scale, + scale_origin_stack_offset=scale_origin, + stack_offset=(0, -20) if ba.app.small_ui else (0, 0))) + + self._back_button = back_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(35, height - 55), + size=(120, 60), + scale=0.8, + text_scale=1.2, + label=ba.Lstr(resource='backText'), + button_type='back', + on_activate_call=self._back, + autoselect=True) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + v = height - 60 + v -= spacing * 1.0 + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, height - 32), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.titleText'), + color=ba.app.title_color, + maxwidth=180, + h_align="center", + v_align="center") + + ba.buttonwidget(edit=self._back_button, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + self._sound_volume_numedit = svne = cfgui.ConfigNumberEdit( + parent=self._root_widget, + position=(40, v), + xoffset=10, + configkey="Sound Volume", + displayname=ba.Lstr(resource=self._r + '.soundVolumeText'), + minval=0.0, + maxval=1.0, + increment=0.1) + if ba.app.toolbars: + ba.widget(edit=svne.plusbutton, + right_widget=_ba.get_special_widget('party_button')) + v -= spacing + self._music_volume_numedit = cfgui.ConfigNumberEdit( + parent=self._root_widget, + position=(40, v), + xoffset=10, + configkey="Music Volume", + displayname=ba.Lstr(resource=self._r + '.musicVolumeText'), + minval=0.0, + maxval=1.0, + increment=0.1, + callback=music_volume_changed, + changesound=False) + + v -= 0.5 * spacing + + self._vr_head_relative_audio_button: Optional[ba.Widget] + if show_vr_head_relative_audio: + v -= 40 + ba.textwidget(parent=self._root_widget, + position=(40, v + 24), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.headRelativeVRAudioText'), + color=(0.8, 0.8, 0.8), + maxwidth=230, + h_align="left", + v_align="center") + + popup = popup_ui.PopupMenu( + parent=self._root_widget, + position=(290, v), + width=120, + button_size=(135, 50), + scale=popup_menu_scale, + choices=['Auto', 'On', 'Off'], + choices_display=[ + ba.Lstr(resource='autoText'), + ba.Lstr(resource='onText'), + ba.Lstr(resource='offText') + ], + current_choice=ba.app.config.resolve('VR Head Relative Audio'), + on_value_change_call=self._set_vr_head_relative_audio) + self._vr_head_relative_audio_button = popup.get_button() + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, v - 11), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.headRelativeVRAudioInfoText'), + scale=0.5, + color=(0.7, 0.8, 0.7), + maxwidth=400, + flatness=1.0, + h_align="center", + v_align="center") + v -= 30 + else: + self._vr_head_relative_audio_button = None + + self._soundtrack_button: Optional[ba.Widget] + if show_soundtracks: + v -= 1.2 * spacing + self._soundtrack_button = ba.buttonwidget( + parent=self._root_widget, + position=((width - 310) / 2, v), + size=(310, 50), + autoselect=True, + label=ba.Lstr(resource=self._r + '.soundtrackButtonText'), + on_activate_call=self._do_soundtracks) + v -= spacing * 0.5 + ba.textwidget(parent=self._root_widget, + position=(0, v), + size=(width, 20), + text=ba.Lstr(resource=self._r + + '.soundtrackDescriptionText'), + flatness=1.0, + h_align='center', + scale=0.5, + color=(0.7, 0.8, 0.7, 1.0), + maxwidth=400) + else: + self._soundtrack_button = None + + # tweak a few navigation bits + try: + ba.widget(edit=back_button, down_widget=svne.minusbutton) + except Exception: + ba.print_exception('error wiring AudioSettingsWindow') + + self._restore_state() + + def _set_vr_head_relative_audio(self, val: str) -> None: + cfg = ba.app.config + cfg['VR Head Relative Audio'] = val + cfg.apply_and_commit() + + def _do_soundtracks(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.soundtrack import browser as stb + + # We require disk access for soundtracks; + # if we don't have it, request it. + if not _ba.have_permission(ba.Permission.STORAGE): + ba.playsound(ba.getsound('ding')) + ba.screenmessage(ba.Lstr(resource='storagePermissionAccessText'), + color=(0.5, 1, 0.5)) + ba.timer(1.0, + ba.Call(_ba.request_permission, ba.Permission.STORAGE), + timetype=ba.TimeType.REAL) + return + + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (stb.SoundtrackBrowserWindow( + origin_widget=self._soundtrack_button).get_root_widget()) + + def _back(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import allsettings + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = (allsettings.AllSettingsWindow( + transition='in_left').get_root_widget()) + + def _save_state(self) -> None: + try: + sel = self._root_widget.get_selected_child() + if sel == self._sound_volume_numedit.minusbutton: + sel_name = 'SoundMinus' + elif sel == self._sound_volume_numedit.plusbutton: + sel_name = 'SoundPlus' + elif sel == self._music_volume_numedit.minusbutton: + sel_name = 'MusicMinus' + elif sel == self._music_volume_numedit.plusbutton: + sel_name = 'MusicPlus' + elif sel == self._soundtrack_button: + sel_name = 'Soundtrack' + elif sel == self._back_button: + sel_name = 'Back' + elif sel == self._vr_head_relative_audio_button: + sel_name = 'VRHeadRelative' + else: + raise Exception("unrecognized selected widget") + ba.app.window_states[self.__class__.__name__] = sel_name + except Exception: + ba.print_exception('error saving state for', self.__class__) + + def _restore_state(self) -> None: + try: + try: + sel_name = ba.app.window_states[self.__class__.__name__] + except Exception: + sel_name = None + sel: Optional[ba.Widget] + if sel_name == 'SoundMinus': + sel = self._sound_volume_numedit.minusbutton + elif sel_name == 'SoundPlus': + sel = self._sound_volume_numedit.plusbutton + elif sel_name == 'MusicMinus': + sel = self._music_volume_numedit.minusbutton + elif sel_name == 'MusicPlus': + sel = self._music_volume_numedit.plusbutton + elif sel_name == 'VRHeadRelative': + sel = self._vr_head_relative_audio_button + elif sel_name == 'Soundtrack': + sel = self._soundtrack_button + elif sel_name == 'Back': + sel = self._back_button + else: + sel = self._back_button + if sel: + ba.containerwidget(edit=self._root_widget, selected_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) diff --git a/assets/src/data/scripts/bastd/ui/settings/controls.py b/assets/src/data/scripts/bastd/ui/settings/controls.py new file mode 100644 index 00000000..69743b29 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/controls.py @@ -0,0 +1,492 @@ +"""Provides a top level control settings window.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Tuple, Optional + + +class ControlsSettingsWindow(ba.OldWindow): + """Top level control settings window.""" + + def __init__(self, + transition: str = 'in_right', + origin_widget: ba.Widget = None): + # FIXME: should tidy up here. + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + from bastd.ui import popup as popup_ui + self._have_selected_child = False + + scale_origin: Optional[Tuple[float, float]] + + # If they provided an origin-widget, scale up from that. + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self._r = 'configControllersWindow' + app = ba.app + + is_fire_tv = _ba.is_running_on_fire_tv() + + spacing = 50.0 + button_width = 350.0 + width = 460.0 + height = 135.0 + + space_height = spacing * 0.3 + + # FIXME: should create vis settings in platform for these, + # not hard code them here.. + + show_gamepads = False + platform = app.platform + subplatform = app.subplatform + non_vr_windows = (platform == 'windows' + and (subplatform != 'oculus' or not app.vr_mode)) + if platform in ('linux', 'android', 'mac') or non_vr_windows: + show_gamepads = True + height += spacing + + show_touch = False + if _ba.have_touchscreen_input(): + show_touch = True + height += spacing + + show_space_1 = False + if show_gamepads or show_touch: + show_space_1 = True + height += space_height + + show_keyboard = False + if _ba.get_input_device('Keyboard', '#1', doraise=False) is not None: + show_keyboard = True + height += spacing * 2 + show_keyboard_p2 = False if app.vr_mode else show_keyboard + if show_keyboard_p2: + height += spacing + + show_space_2 = False + if show_keyboard: + show_space_2 = True + height += space_height + + # noinspection PyUnreachableCode + if True: # pylint: disable=using-constant-test + show_remote = True + height += spacing + else: + show_remote = False + + show_ps3 = False + if platform == 'mac': + show_ps3 = True + height += spacing + + show360 = False + if platform == 'mac' or is_fire_tv: + show360 = True + height += spacing + + show_mac_wiimote = False + if platform == 'mac': + show_mac_wiimote = True + height += spacing + + # on non-oculus-vr windows, show an option to disable xinput + show_xinput_toggle = False + if platform == 'windows' and (subplatform != 'oculus' + or not app.vr_mode): + show_xinput_toggle = True + + # on mac builds, show an option to switch between generic and + # made-for-iOS/Mac systems + # (we can run into problems where devices register as one of each + # type otherwise).. + show_mac_controller_subsystem = False + if platform == 'mac': + show_mac_controller_subsystem = True + + if show_mac_controller_subsystem: + height += spacing + + if show_xinput_toggle: + height += spacing + + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition=transition, + scale_origin_stack_offset=scale_origin, + scale=(1.7 if show_keyboard else 2.2 + ) if ba.app.small_ui else 1.5 if ba.app.med_ui else 1.0)) + self._back_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(35, height - 60), + size=(140, 65), + scale=0.8, + text_scale=1.2, + autoselect=True, + label=ba.Lstr(resource='backText'), + button_type='back', + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + # need these vars to exist even if the buttons don't + self._gamepads_button: Optional[ba.Widget] = None + self._touch_button: Optional[ba.Widget] = None + self._keyboard_button: Optional[ba.Widget] = None + self._keyboard_2_button: Optional[ba.Widget] = None + self._idevices_button: Optional[ba.Widget] = None + self._ps3_button: Optional[ba.Widget] = None + self._xbox_360_button: Optional[ba.Widget] = None + self._wiimotes_button: Optional[ba.Widget] = None + + ba.textwidget(parent=self._root_widget, + position=(0, height - 49), + size=(width, 25), + text=ba.Lstr(resource=self._r + '.titleText'), + color=ba.app.title_color, + h_align="center", + v_align="top") + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + v = height - 75 + v -= spacing + + if show_touch: + self._touch_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=((width - button_width) / 2, v), + size=(button_width, 43), + autoselect=True, + label=ba.Lstr(resource=self._r + '.configureTouchText'), + on_activate_call=self._do_touchscreen) + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + if not self._have_selected_child: + ba.containerwidget(edit=self._root_widget, + selected_child=self._touch_button) + ba.widget(edit=self._back_button, + down_widget=self._touch_button) + self._have_selected_child = True + v -= spacing + + if show_gamepads: + self._gamepads_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=((width - button_width) / 2 - 7, v), + size=(button_width, 43), + autoselect=True, + label=ba.Lstr(resource=self._r + '.configureControllersText'), + on_activate_call=self._do_gamepads) + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + if not self._have_selected_child: + ba.containerwidget(edit=self._root_widget, + selected_child=self._gamepads_button) + ba.widget(edit=self._back_button, + down_widget=self._gamepads_button) + self._have_selected_child = True + v -= spacing + else: + self._gamepads_button = None + + if show_space_1: + v -= space_height + + if show_keyboard: + self._keyboard_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=((width - button_width) / 2 + 5, v), + size=(button_width, 43), + autoselect=True, + label=ba.Lstr(resource=self._r + '.configureKeyboardText'), + on_activate_call=self._config_keyboard) + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + if not self._have_selected_child: + ba.containerwidget(edit=self._root_widget, + selected_child=self._keyboard_button) + ba.widget(edit=self._back_button, + down_widget=self._keyboard_button) + self._have_selected_child = True + v -= spacing + if show_keyboard_p2: + self._keyboard_2_button = ba.buttonwidget( + parent=self._root_widget, + position=((width - button_width) / 2 - 3, v), + size=(button_width, 43), + autoselect=True, + label=ba.Lstr(resource=self._r + '.configureKeyboard2Text'), + on_activate_call=self._config_keyboard2) + v -= spacing + if show_space_2: + v -= space_height + if show_remote: + self._idevices_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=((width - button_width) / 2 - 5, v), + size=(button_width, 43), + autoselect=True, + label=ba.Lstr(resource=self._r + '.configureMobileText'), + on_activate_call=self._do_mobile_devices) + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + if not self._have_selected_child: + ba.containerwidget(edit=self._root_widget, + selected_child=self._idevices_button) + ba.widget(edit=self._back_button, + down_widget=self._idevices_button) + self._have_selected_child = True + v -= spacing + if show_ps3: + self._ps3_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=((width - button_width) / 2 + 5, v), + size=(button_width, 43), + autoselect=True, + label=ba.Lstr(resource=self._r + '.ps3Text'), + on_activate_call=self._do_ps3_controllers) + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + v -= spacing + if show360: + self._xbox_360_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=((width - button_width) / 2 - 1, v), + size=(button_width, 43), + autoselect=True, + label=ba.Lstr(resource=self._r + '.xbox360Text'), + on_activate_call=self._do_360_controllers) + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + v -= spacing + if show_mac_wiimote: + self._wiimotes_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=((width - button_width) / 2 + 5, v), + size=(button_width, 43), + autoselect=True, + label=ba.Lstr(resource=self._r + '.wiimotesText'), + on_activate_call=self._do_wiimotes) + if ba.app.toolbars: + ba.widget(edit=btn, + right_widget=_ba.get_special_widget('party_button')) + v -= spacing + + if show_xinput_toggle: + + def do_toggle(value: bool) -> None: + ba.screenmessage( + ba.Lstr(resource='settingsWindowAdvanced.mustRestartText'), + color=(1, 1, 0)) + ba.playsound(ba.getsound('gunCocking')) + _ba.set_low_level_config_value('enablexinput', not value) + + ba.checkboxwidget( + parent=self._root_widget, + position=(100, v + 3), + size=(120, 30), + value=(not _ba.get_low_level_config_value('enablexinput', 1)), + maxwidth=200, + on_value_change_call=do_toggle, + text=ba.Lstr(resource='disableXInputText'), + autoselect=True) + ba.textwidget( + parent=self._root_widget, + position=(width * 0.5, v - 5), + size=(0, 0), + text=ba.Lstr(resource='disableXInputDescriptionText'), + scale=0.5, + h_align='center', + v_align='center', + color=ba.app.infotextcolor, + maxwidth=width * 0.8) + v -= spacing + if show_mac_controller_subsystem: + popup_ui.PopupMenu( + parent=self._root_widget, + position=(260, v - 10), + width=160, + button_size=(150, 50), + scale=1.5, + choices=['Classic', 'MFi', 'Both'], + choices_display=[ + ba.Lstr(resource='macControllerSubsystemClassicText'), + ba.Lstr(resource='macControllerSubsystemMFiText'), + ba.Lstr(resource='macControllerSubsystemBothText') + ], + current_choice=ba.app.config.resolve( + 'Mac Controller Subsystem'), + on_value_change_call=self._set_mac_controller_subsystem) + ba.textwidget( + parent=self._root_widget, + position=(245, v + 13), + size=(0, 0), + text=ba.Lstr(resource='macControllerSubsystemTitleText'), + scale=1.0, + h_align='right', + v_align='center', + color=ba.app.infotextcolor, + maxwidth=180) + ba.textwidget( + parent=self._root_widget, + position=(width * 0.5, v - 20), + size=(0, 0), + text=ba.Lstr(resource='macControllerSubsystemDescriptionText'), + scale=0.5, + h_align='center', + v_align='center', + color=ba.app.infotextcolor, + maxwidth=width * 0.8) + v -= spacing + self._restore_state() + + def _set_mac_controller_subsystem(self, val: str) -> None: + cfg = ba.app.config + cfg['Mac Controller Subsystem'] = val + cfg.apply_and_commit() + + def _config_keyboard(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import keyboard + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (keyboard.ConfigKeyboardWindow( + _ba.get_input_device('Keyboard', '#1')).get_root_widget()) + + def _config_keyboard2(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import keyboard + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (keyboard.ConfigKeyboardWindow( + _ba.get_input_device('Keyboard', '#2')).get_root_widget()) + + def _do_mobile_devices(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import remoteapp + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = ( + remoteapp.RemoteAppSettingsWindow().get_root_widget()) + + def _do_ps3_controllers(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import ps3controller + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = ( + ps3controller.PS3ControllerSettingsWindow().get_root_widget()) + + def _do_360_controllers(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import xbox360controller as xbox + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = ( + xbox.XBox360ControllerSettingsWindow().get_root_widget()) + + def _do_wiimotes(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import wiimote + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = ( + wiimote.WiimoteSettingsWindow().get_root_widget()) + + def _do_gamepads(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import gamepadselect + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = ( + gamepadselect.GamepadSelectWindow().get_root_widget()) + + def _do_touchscreen(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import touchscreen + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = ( + touchscreen.TouchscreenSettingsWindow().get_root_widget()) + + def _save_state(self) -> None: + sel = self._root_widget.get_selected_child() + if sel == self._gamepads_button: + sel_name = 'GamePads' + elif sel == self._touch_button: + sel_name = 'Touch' + elif sel == self._keyboard_button: + sel_name = 'Keyboard' + elif sel == self._keyboard_2_button: + sel_name = 'Keyboard2' + elif sel == self._idevices_button: + sel_name = 'iDevices' + elif sel == self._ps3_button: + sel_name = 'PS3' + elif sel == self._xbox_360_button: + sel_name = 'xbox360' + elif sel == self._wiimotes_button: + sel_name = 'Wiimotes' + else: + sel_name = 'Back' + ba.app.window_states[self.__class__.__name__] = sel_name + + def _restore_state(self) -> None: + try: + sel_name = ba.app.window_states[self.__class__.__name__] + except Exception: + sel_name = None + if sel_name == 'GamePads': + sel = self._gamepads_button + elif sel_name == 'Touch': + sel = self._touch_button + elif sel_name == 'Keyboard': + sel = self._keyboard_button + elif sel_name == 'Keyboard2': + sel = self._keyboard_2_button + elif sel_name == 'iDevices': + sel = self._idevices_button + elif sel_name == 'PS3': + sel = self._ps3_button + elif sel_name == 'xbox360': + sel = self._xbox_360_button + elif sel_name == 'Wiimotes': + sel = self._wiimotes_button + elif sel_name == 'Back': + sel = self._back_button + else: + sel = (self._gamepads_button + if self._gamepads_button is not None else self._back_button) + ba.containerwidget(edit=self._root_widget, selected_child=sel) + + def _back(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import allsettings + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = allsettings.AllSettingsWindow( + transition='in_left').get_root_widget() diff --git a/assets/src/data/scripts/bastd/ui/settings/gamepad.py b/assets/src/data/scripts/bastd/ui/settings/gamepad.py new file mode 100644 index 00000000..b5e9b4c8 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/gamepad.py @@ -0,0 +1,832 @@ +"""Settings UI functionality related to gamepads.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Dict, Any, Optional, Union, Tuple, Callable + + +class GamepadSettingsWindow(ba.OldWindow): + """Window for configuring a gamepad.""" + + def __init__(self, + gamepad: ba.InputDevice, + is_main_menu: bool = True, + transition: str = 'in_right', + transition_out: str = 'out_right', + settings: Dict[str, Any] = None): + self._input = gamepad + + # If this fails, our input device went away or something; + # just return an empty zombie then. + try: + self._name = self._input.name + except Exception: + return + + self._r = 'configGamepadWindow' + self._settings = settings + self._transition_out = transition_out + + # We're a secondary gamepad if supplied with settings. + self._is_secondary = (settings is not None) + self._ext = '_B' if self._is_secondary else '' + self._is_main_menu = is_main_menu + self._displayname = self._name + self._width = 700 if self._is_secondary else 730 + self._height = 440 if self._is_secondary else 450 + self._spacing = 40 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + scale=( + 1.63 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0), + stack_offset=(-20, -16) if ba.app.small_ui else (0, 0), + transition=transition)) + + # Don't ask to config joysticks while we're in here. + self._rebuild_ui() + + def _rebuild_ui(self) -> None: + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + from ba.internal import get_device_value + + # Clear existing UI. + for widget in self._root_widget.get_children(): + widget.delete() + + self._textwidgets: Dict[str, ba.Widget] = {} + + # If we were supplied with settings, we're a secondary joystick and + # just operate on that. in the other (normal) case we make our own. + if not self._is_secondary: + + # Fill our temp config with present values (for our primary and + # secondary controls). + self._settings = {} + for skey in [ + 'buttonJump', + 'buttonJump_B', + 'buttonPunch', + 'buttonPunch_B', + 'buttonBomb', + 'buttonBomb_B', + 'buttonPickUp', + 'buttonPickUp_B', + 'buttonStart', + 'buttonStart_B', + 'buttonStart2', + 'buttonStart2_B', + 'buttonUp', + 'buttonUp_B', + 'buttonDown', + 'buttonDown_B', + 'buttonLeft', + 'buttonLeft_B', + 'buttonRight', + 'buttonRight_B', + 'buttonRun1', + 'buttonRun1_B', + 'buttonRun2', + 'buttonRun2_B', + 'triggerRun1', + 'triggerRun1_B', + 'triggerRun2', + 'triggerRun2_B', + 'buttonIgnored', + 'buttonIgnored_B', + 'buttonIgnored2', + 'buttonIgnored2_B', + 'buttonIgnored3', + 'buttonIgnored3_B', + 'buttonIgnored4', + 'buttonIgnored4_B', + 'buttonVRReorient', + 'buttonVRReorient_B', + 'analogStickDeadZone', + 'analogStickDeadZone_B', + 'dpad', + 'dpad_B', + 'unassignedButtonsRun', + 'unassignedButtonsRun_B', + 'startButtonActivatesDefaultWidget', + 'startButtonActivatesDefaultWidget_B', + 'uiOnly', + 'uiOnly_B', + 'ignoreCompletely', + 'ignoreCompletely_B', + 'autoRecalibrateAnalogStick', + 'autoRecalibrateAnalogStick_B', + 'analogStickLR', + 'analogStickLR_B', + 'analogStickUD', + 'analogStickUD_B', + 'enableSecondary', + ]: + val = get_device_value(self._input, skey) + if val != -1: + self._settings[skey] = val + + back_button: Optional[ba.Widget] + + if self._is_secondary: + back_button = ba.buttonwidget(parent=self._root_widget, + position=(self._width - 180, + self._height - 65), + autoselect=True, + size=(160, 60), + label=ba.Lstr(resource='doneText'), + scale=0.9, + on_activate_call=self._save) + ba.containerwidget(edit=self._root_widget, + start_button=back_button, + on_cancel_call=back_button.activate) + cancel_button = None + else: + cancel_button = ba.buttonwidget( + parent=self._root_widget, + position=(51, self._height - 65), + autoselect=True, + size=(160, 60), + label=ba.Lstr(resource='cancelText'), + scale=0.9, + on_activate_call=self._cancel) + ba.containerwidget(edit=self._root_widget, + cancel_button=cancel_button) + + save_button: Optional[ba.Widget] + if not self._is_secondary: + save_button = ba.buttonwidget( + parent=self._root_widget, + position=(self._width - (165 if self._is_secondary else 195), + self._height - 65), + size=((160 if self._is_secondary else 180), 60), + autoselect=True, + label=ba.Lstr(resource='doneText') + if self._is_secondary else ba.Lstr(resource='makeItSoText'), + scale=0.9, + on_activate_call=self._save) + ba.containerwidget(edit=self._root_widget, + start_button=save_button) + else: + save_button = None + + if not self._is_secondary: + v = self._height - 59 + ba.textwidget(parent=self._root_widget, + position=(0, v + 5), + size=(self._width, 25), + text=ba.Lstr(resource=self._r + '.titleText'), + color=ba.app.title_color, + maxwidth=310, + h_align="center", + v_align="center") + v -= 48 + + ba.textwidget(parent=self._root_widget, + position=(0, v + 3), + size=(self._width, 25), + text=self._name, + color=ba.app.infotextcolor, + maxwidth=self._width * 0.9, + h_align="center", + v_align="center") + v -= self._spacing * 1 + + ba.textwidget(parent=self._root_widget, + position=(50, v + 10), + size=(self._width - 100, 30), + text=ba.Lstr(resource=self._r + '.appliesToAllText'), + maxwidth=330, + scale=0.65, + color=(0.5, 0.6, 0.5, 1.0), + h_align='center', + v_align='center') + v -= 70 + self._enable_check_box = None + else: + v = self._height - 49 + ba.textwidget(parent=self._root_widget, + position=(0, v + 5), + size=(self._width, 25), + text=ba.Lstr(resource=self._r + '.secondaryText'), + color=ba.app.title_color, + maxwidth=300, + h_align="center", + v_align="center") + v -= self._spacing * 1 + + ba.textwidget(parent=self._root_widget, + position=(50, v + 10), + size=(self._width - 100, 30), + text=ba.Lstr(resource=self._r + '.secondHalfText'), + maxwidth=300, + scale=0.65, + color=(0.6, 0.8, 0.6, 1.0), + h_align='center') + self._enable_check_box = ba.checkboxwidget( + parent=self._root_widget, + position=(self._width * 0.5 - 80, v - 73), + value=self.get_enable_secondary_value(), + autoselect=True, + on_value_change_call=self._enable_check_box_changed, + size=(200, 30), + text=ba.Lstr(resource=self._r + '.secondaryEnableText'), + scale=1.2) + v = self._height - 205 + + h_offs = 160 + dist = 70 + d_color = (0.4, 0.4, 0.8) + sclx = 1.2 + scly = 0.98 + dpm = ba.Lstr(resource=self._r + '.pressAnyButtonOrDpadText') + dpm2 = ba.Lstr(resource=self._r + '.ifNothingHappensTryAnalogText') + self._capture_button(pos=(h_offs, v + scly * dist), + color=d_color, + button='buttonUp' + self._ext, + texture=ba.gettexture('upButton'), + scale=1.0, + message=dpm, + message2=dpm2) + self._capture_button(pos=(h_offs - sclx * dist, v), + color=d_color, + button='buttonLeft' + self._ext, + texture=ba.gettexture('leftButton'), + scale=1.0, + message=dpm, + message2=dpm2) + self._capture_button(pos=(h_offs + sclx * dist, v), + color=d_color, + button='buttonRight' + self._ext, + texture=ba.gettexture('rightButton'), + scale=1.0, + message=dpm, + message2=dpm2) + self._capture_button(pos=(h_offs, v - scly * dist), + color=d_color, + button='buttonDown' + self._ext, + texture=ba.gettexture('downButton'), + scale=1.0, + message=dpm, + message2=dpm2) + + dpm3 = ba.Lstr(resource=self._r + '.ifNothingHappensTryDpadText') + self._capture_button(pos=(h_offs + 130, v - 125), + color=(0.4, 0.4, 0.6), + button='analogStickLR' + self._ext, + maxwidth=140, + texture=ba.gettexture('analogStick'), + scale=1.2, + message=ba.Lstr(resource=self._r + + '.pressLeftRightText'), + message2=dpm3) + + self._capture_button(pos=(self._width * 0.5, v), + color=(0.4, 0.4, 0.6), + button='buttonStart' + self._ext, + texture=ba.gettexture('startButton'), + scale=0.7) + + h_offs = self._width - 160 + + self._capture_button(pos=(h_offs, v + scly * dist), + color=(0.6, 0.4, 0.8), + button='buttonPickUp' + self._ext, + texture=ba.gettexture('buttonPickUp'), + scale=1.0) + self._capture_button(pos=(h_offs - sclx * dist, v), + color=(0.7, 0.5, 0.1), + button='buttonPunch' + self._ext, + texture=ba.gettexture('buttonPunch'), + scale=1.0) + self._capture_button(pos=(h_offs + sclx * dist, v), + color=(0.5, 0.2, 0.1), + button='buttonBomb' + self._ext, + texture=ba.gettexture('buttonBomb'), + scale=1.0) + self._capture_button(pos=(h_offs, v - scly * dist), + color=(0.2, 0.5, 0.2), + button='buttonJump' + self._ext, + texture=ba.gettexture('buttonJump'), + scale=1.0) + + self._advanced_button = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + label=ba.Lstr(resource=self._r + '.advancedText'), + text_scale=0.9, + color=(0.45, 0.4, 0.5), + textcolor=(0.65, 0.6, 0.7), + position=(self._width - 300, 30), + size=(130, 40), + on_activate_call=self._do_advanced) + + try: + if cancel_button is not None and save_button is not None: + ba.widget(edit=cancel_button, right_widget=save_button) + ba.widget(edit=save_button, left_widget=cancel_button) + except Exception: + ba.print_exception('error wiring gamepad config window') + + def get_r(self) -> str: + """(internal)""" + return self._r + + def get_advanced_button(self) -> ba.Widget: + """(internal)""" + return self._advanced_button + + def get_is_secondary(self) -> bool: + """(internal)""" + return self._is_secondary + + def get_settings(self) -> Dict[str, Any]: + """(internal)""" + assert self._settings is not None + return self._settings + + def get_ext(self) -> str: + """(internal)""" + return self._ext + + def get_input(self) -> ba.InputDevice: + """(internal)""" + return self._input + + def _do_advanced(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import gamepadadvanced + gamepadadvanced.GamepadAdvancedSettingsWindow(self) + + def _enable_check_box_changed(self, value: bool) -> None: + assert self._settings is not None + if value: + self._settings['enableSecondary'] = 1 + else: + # Just clear since this is default. + if 'enableSecondary' in self._settings: + del self._settings['enableSecondary'] + + def get_unassigned_buttons_run_value(self) -> bool: + """(internal)""" + assert self._settings is not None + return self._settings.get('unassignedButtonsRun', True) + + def set_unassigned_buttons_run_value(self, value: bool) -> None: + """(internal)""" + assert self._settings is not None + if value: + if 'unassignedButtonsRun' in self._settings: + + # Clear since this is default. + del self._settings['unassignedButtonsRun'] + return + self._settings['unassignedButtonsRun'] = False + + def get_start_button_activates_default_widget_value(self) -> bool: + """(internal)""" + assert self._settings is not None + return self._settings.get('startButtonActivatesDefaultWidget', True) + + def set_start_button_activates_default_widget_value(self, + value: bool) -> None: + """(internal)""" + assert self._settings is not None + if value: + if 'startButtonActivatesDefaultWidget' in self._settings: + + # Clear since this is default. + del self._settings['startButtonActivatesDefaultWidget'] + return + self._settings['startButtonActivatesDefaultWidget'] = False + + def get_ui_only_value(self) -> bool: + """(internal)""" + assert self._settings is not None + return self._settings.get('uiOnly', False) + + def set_ui_only_value(self, value: bool) -> None: + """(internal)""" + assert self._settings is not None + if not value: + if 'uiOnly' in self._settings: + + # Clear since this is default. + del self._settings['uiOnly'] + return + self._settings['uiOnly'] = True + + def get_ignore_completely_value(self) -> bool: + """(internal)""" + assert self._settings is not None + return self._settings.get('ignoreCompletely', False) + + def set_ignore_completely_value(self, value: bool) -> None: + """(internal)""" + assert self._settings is not None + if not value: + if 'ignoreCompletely' in self._settings: + + # Clear since this is default. + del self._settings['ignoreCompletely'] + return + self._settings['ignoreCompletely'] = True + + def get_auto_recalibrate_analog_stick_value(self) -> bool: + """(internal)""" + assert self._settings is not None + return self._settings.get('autoRecalibrateAnalogStick', False) + + def set_auto_recalibrate_analog_stick_value(self, value: bool) -> None: + """(internal)""" + assert self._settings is not None + if not value: + if 'autoRecalibrateAnalogStick' in self._settings: + + # Clear since this is default. + del self._settings['autoRecalibrateAnalogStick'] + else: + self._settings['autoRecalibrateAnalogStick'] = True + + def get_enable_secondary_value(self) -> bool: + """(internal)""" + assert self._settings is not None + if not self._is_secondary: + raise Exception("enable value only applies to secondary editor") + return self._settings.get('enableSecondary', False) + + def show_secondary_editor(self) -> None: + """(internal)""" + GamepadSettingsWindow(self._input, + is_main_menu=False, + settings=self._settings, + transition='in_scale', + transition_out='out_scale') + + def get_control_value_name(self, control: str) -> Union[str, ba.Lstr]: + """(internal)""" + # pylint: disable=too-many-return-statements + assert self._settings is not None + if control == 'analogStickLR' + self._ext: + + # This actually shows both LR and UD. + sval1 = (self._settings['analogStickLR' + + self._ext] if 'analogStickLR' + + self._ext in self._settings else + 5 if self._is_secondary else 1) + sval2 = (self._settings['analogStickUD' + + self._ext] if 'analogStickUD' + + self._ext in self._settings else + 6 if self._is_secondary else 2) + return self._input.get_axis_name( + sval1) + ' / ' + self._input.get_axis_name(sval2) + + # If they're looking for triggers. + if control in ['triggerRun1' + self._ext, 'triggerRun2' + self._ext]: + if control in self._settings: + return self._input.get_axis_name(self._settings[control]) + return ba.Lstr(resource=self._r + '.unsetText') + + # Dead-zone. + if control == 'analogStickDeadZone' + self._ext: + if control in self._settings: + return str(self._settings[control]) + return str(1.0) + + # For dpad buttons: show individual buttons if any are set. + # Otherwise show whichever dpad is set (defaulting to 1). + dpad_buttons = [ + 'buttonLeft' + self._ext, 'buttonRight' + self._ext, + 'buttonUp' + self._ext, 'buttonDown' + self._ext + ] + if control in dpad_buttons: + + # If *any* dpad buttons are assigned, show only button assignments. + if any(b in self._settings for b in dpad_buttons): + if control in self._settings: + return self._input.get_button_name(self._settings[control]) + return ba.Lstr(resource=self._r + '.unsetText') + + # No dpad buttons - show the dpad number for all 4. + return ba.Lstr( + value='${A} ${B}', + subs=[('${A}', ba.Lstr(resource=self._r + '.dpadText')), + ('${B}', + str(self._settings['dpad' + + self._ext] if 'dpad' + self._ext in + self._settings else 2 if self._is_secondary else 1)) + ]) + + # other buttons.. + if control in self._settings: + return self._input.get_button_name(self._settings[control]) + return ba.Lstr(resource=self._r + '.unsetText') + + def _gamepad_event(self, control: str, event: Dict[str, Any], + dialog: AwaitGamepadInputWindow) -> None: + # pylint: disable=too-many-nested-blocks + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + assert self._settings is not None + ext = self._ext + + # For our dpad-buttons we're looking for either a button-press or a + # hat-switch press. + if control in [ + 'buttonUp' + ext, 'buttonLeft' + ext, 'buttonDown' + ext, + 'buttonRight' + ext + ]: + if event['type'] in ['BUTTONDOWN', 'HATMOTION']: + + # If its a button-down. + if event['type'] == 'BUTTONDOWN': + value = event['button'] + self._settings[control] = value + + # If its a dpad. + elif event['type'] == 'HATMOTION': + # clear out any set dir-buttons + for btn in [ + 'buttonUp' + ext, 'buttonLeft' + ext, + 'buttonRight' + ext, 'buttonDown' + ext + ]: + if btn in self._settings: + del self._settings[btn] + if event['hat'] == (2 if self._is_secondary else 1): + + # Exclude value in default case. + if 'dpad' + ext in self._settings: + del self._settings['dpad' + ext] + else: + self._settings['dpad' + ext] = event['hat'] + + # Update the 4 dpad button txt widgets. + ba.textwidget(edit=self._textwidgets['buttonUp' + ext], + text=self.get_control_value_name('buttonUp' + + ext)) + ba.textwidget(edit=self._textwidgets['buttonLeft' + ext], + text=self.get_control_value_name('buttonLeft' + + ext)) + ba.textwidget(edit=self._textwidgets['buttonRight' + ext], + text=self.get_control_value_name('buttonRight' + + ext)) + ba.textwidget(edit=self._textwidgets['buttonDown' + ext], + text=self.get_control_value_name('buttonDown' + + ext)) + ba.playsound(ba.getsound('gunCocking')) + dialog.die() + + elif control == 'analogStickLR' + ext: + if event['type'] == 'AXISMOTION': + + # Ignore small values or else we might get triggered by noise. + if abs(event['value']) > 0.5: + axis = event['axis'] + if axis == (5 if self._is_secondary else 1): + + # Exclude value in default case. + if 'analogStickLR' + ext in self._settings: + del self._settings['analogStickLR' + ext] + else: + self._settings['analogStickLR' + ext] = axis + ba.textwidget( + edit=self._textwidgets['analogStickLR' + ext], + text=self.get_control_value_name('analogStickLR' + + ext)) + ba.playsound(ba.getsound('gunCocking')) + dialog.die() + + # Now launch the up/down listener. + AwaitGamepadInputWindow( + self._input, 'analogStickUD' + ext, + self._gamepad_event, + ba.Lstr(resource=self._r + '.pressUpDownText')) + + elif control == 'analogStickUD' + ext: + if event['type'] == 'AXISMOTION': + + # Ignore small values or else we might get triggered by noise. + if abs(event['value']) > 0.5: + axis = event['axis'] + + # Ignore our LR axis. + if 'analogStickLR' + ext in self._settings: + lr_axis = self._settings['analogStickLR' + ext] + else: + lr_axis = (5 if self._is_secondary else 1) + if axis != lr_axis: + if axis == (6 if self._is_secondary else 2): + + # Exclude value in default case. + if 'analogStickUD' + ext in self._settings: + del self._settings['analogStickUD' + ext] + else: + self._settings['analogStickUD' + ext] = axis + ba.textwidget( + edit=self._textwidgets['analogStickLR' + ext], + text=self.get_control_value_name('analogStickLR' + + ext)) + ba.playsound(ba.getsound('gunCocking')) + dialog.die() + else: + # For other buttons we just want a button-press. + if event['type'] == 'BUTTONDOWN': + value = event['button'] + self._settings[control] = value + + # Update the button's text widget. + ba.textwidget(edit=self._textwidgets[control], + text=self.get_control_value_name(control)) + ba.playsound(ba.getsound('gunCocking')) + dialog.die() + + def _capture_button(self, + pos: Tuple[float, float], + color: Tuple[float, float, float], + texture: ba.Texture, + button: str, + scale: float = 1.0, + message: ba.Lstr = None, + message2: ba.Lstr = None, + maxwidth: float = 80.0) -> ba.Widget: + if message is None: + message = ba.Lstr(resource=self._r + '.pressAnyButtonText') + base_size = 79 + btn = ba.buttonwidget(parent=self._root_widget, + position=(pos[0] - base_size * 0.5 * scale, + pos[1] - base_size * 0.5 * scale), + autoselect=True, + size=(base_size * scale, base_size * scale), + texture=texture, + label='', + color=color) + + # Make this in a timer so that it shows up on top of all other buttons. + + def doit() -> None: + uiscale = 0.9 * scale + txt = ba.textwidget(parent=self._root_widget, + position=(pos[0] + 0.0 * scale, + pos[1] - 58.0 * scale), + color=(1, 1, 1, 0.3), + size=(0, 0), + h_align='center', + v_align='center', + scale=uiscale, + text=self.get_control_value_name(button), + maxwidth=maxwidth) + self._textwidgets[button] = txt + ba.buttonwidget(edit=btn, + on_activate_call=ba.Call(AwaitGamepadInputWindow, + self._input, button, + self._gamepad_event, + message, message2)) + + ba.timer(0, doit, timetype=ba.TimeType.REAL) + return btn + + def _cancel(self) -> None: + from bastd.ui.settings import controls + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + if self._is_main_menu: + ba.app.main_menu_window = (controls.ControlsSettingsWindow( + transition='in_left').get_root_widget()) + + def _save(self) -> None: + from ba.internal import (serverput, get_input_device_config, + get_input_map_hash, should_submit_debug_info) + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + + # If we're a secondary editor we just go away (we were editing our + # parent's settings dict). + if self._is_secondary: + return + + assert self._settings is not None + if self._input: + dst = get_input_device_config(self._input, default=True) + dst2: Dict[str, Any] = dst[0][dst[1]] + dst2.clear() + + # Store any values that aren't -1. + for key, val in list(self._settings.items()): + if val != -1: + dst2[key] = val + + # If we're allowed to phone home, send this config so we can + # generate more defaults in the future. + inputhash = get_input_map_hash(self._input) + if should_submit_debug_info(): + serverput( + 'controllerConfig', { + 'ua': ba.app.user_agent_string, + 'b': ba.app.build_number, + 'name': self._name, + 'inputMapHash': inputhash, + 'config': dst2, + 'v': 2 + }) + ba.app.config.apply_and_commit() + ba.playsound(ba.getsound('gunCocking')) + else: + ba.playsound(ba.getsound('error')) + + if self._is_main_menu: + from bastd.ui.settings import controls + ba.app.main_menu_window = (controls.ControlsSettingsWindow( + transition='in_left').get_root_widget()) + + +class AwaitGamepadInputWindow(ba.OldWindow): + """Window for capturing a gamepad button press.""" + + def __init__( + self, + gamepad: ba.InputDevice, + button: str, + callback: Callable[[str, Dict[str, Any], AwaitGamepadInputWindow], + Any], + message: ba.Lstr = None, + message2: ba.Lstr = None): + if message is None: + print('AwaitGamepadInputWindow message is None!') + message = ba.Lstr( + value='Press any button...') # Shouldn't get here. + self._callback = callback + self._input = gamepad + self._capture_button = button + width = 400 + height = 150 + super().__init__(root_widget=ba.containerwidget( + scale=2.0 if ba.app.small_ui else 1.9 if ba.app.med_ui else 1.0, + size=(width, height), + transition='in_scale')) + ba.textwidget(parent=self._root_widget, + position=(0, (height - 60) if message2 is None else + (height - 50)), + size=(width, 25), + text=message, + maxwidth=width * 0.9, + h_align="center", + v_align="center") + if message2 is not None: + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, height - 60), + size=(0, 0), + text=message2, + maxwidth=width * 0.9, + scale=0.47, + color=(0.7, 1.0, 0.7, 0.6), + h_align="center", + v_align="center") + self._counter = 5 + self._count_down_text = ba.textwidget(parent=self._root_widget, + h_align='center', + position=(0, height - 110), + size=(width, 25), + color=(1, 1, 1, 0.3), + text=str(self._counter)) + self._decrement_timer: Optional[ba.Timer] = ba.Timer( + 1.0, + ba.Call(self._decrement), + repeat=True, + timetype=ba.TimeType.REAL) + _ba.capture_gamepad_input(ba.WeakCall(self._event_callback)) + + def __del__(self) -> None: + pass + + def die(self) -> None: + """Kill the window.""" + + # This strong-refs us; killing it allow us to die now. + self._decrement_timer = None + _ba.release_gamepad_input() + if self._root_widget: + ba.containerwidget(edit=self._root_widget, transition='out_scale') + + def _event_callback(self, event: Dict[str, Any]) -> None: + input_device = event['input_device'] + assert isinstance(input_device, ba.InputDevice) + + # Update - we now allow *any* input device of this type. + if input_device.exists and input_device.name == self._input.name: + self._callback(self._capture_button, event, self) + + def _decrement(self) -> None: + self._counter -= 1 + if self._counter >= 1: + if self._count_down_text: + ba.textwidget(edit=self._count_down_text, + text=str(self._counter)) + else: + ba.playsound(ba.getsound('error')) + self.die() diff --git a/assets/src/data/scripts/bastd/ui/settings/gamepadadvanced.py b/assets/src/data/scripts/bastd/ui/settings/gamepadadvanced.py new file mode 100644 index 00000000..5e8a1e4a --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/gamepadadvanced.py @@ -0,0 +1,492 @@ +"""UI functionality related to advanced gamepad configuring.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Dict, Tuple, Optional, Any + from bastd.ui.settings import gamepad as gpsui + + +class GamepadAdvancedSettingsWindow(ba.OldWindow): + """Window for advanced gamepad configuration.""" + + def __init__(self, parent_window: gpsui.GamepadSettingsWindow): + # pylint: disable=too-many-statements + self._parent_window = parent_window + + app = ba.app + + self._r = parent_window.get_r() + self._width = 900 if ba.app.small_ui else 700 + self._x_inset = x_inset = 100 if ba.app.small_ui else 0 + self._height = 402 if ba.app.small_ui else 512 + self._textwidgets: Dict[str, ba.Widget] = {} + super().__init__(root_widget=ba.containerwidget( + transition='in_scale', + size=(self._width, self._height), + scale=1.06 * + (1.85 if ba.app.small_ui else 1.35 if ba.app.med_ui else 1.0), + stack_offset=(0, -25) if ba.app.small_ui else (0, 0), + scale_origin_stack_offset=(parent_window.get_advanced_button(). + get_screen_space_center()))) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - + (40 if ba.app.small_ui else 34)), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.advancedTitleText'), + maxwidth=320, + color=ba.app.title_color, + h_align="center", + v_align="center") + + back_button = btn = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(self._width - (176 + x_inset), + self._height - (60 if ba.app.small_ui else 55)), + size=(120, 48), + text_scale=0.8, + label=ba.Lstr(resource='doneText'), + on_activate_call=self._done) + ba.containerwidget(edit=self._root_widget, + start_button=btn, + on_cancel_call=btn.activate) + + self._scroll_width = self._width - (100 + 2 * x_inset) + self._scroll_height = self._height - 110 + self._sub_width = self._scroll_width - 20 + self._sub_height = (940 if self._parent_window.get_is_secondary() else + 1040) + if app.vr_mode: + self._sub_height += 50 + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + position=((self._width - self._scroll_width) * 0.5, + self._height - 65 - self._scroll_height), + size=(self._scroll_width, self._scroll_height)) + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._sub_width, + self._sub_height), + background=False) + ba.containerwidget(edit=self._scrollwidget, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + ba.containerwidget(edit=self._subcontainer, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget) + + h = 30 + v = self._sub_height - 10 + + h2 = h + 12 + + # don't allow secondary joysticks to handle unassigned buttons + if not self._parent_window.get_is_secondary(): + v -= 40 + cb1 = ba.checkboxwidget( + parent=self._subcontainer, + position=(h + 70, v), + size=(500, 30), + text=ba.Lstr(resource=self._r + '.unassignedButtonsRunText'), + textcolor=(0.8, 0.8, 0.8), + maxwidth=330, + scale=1.0, + on_value_change_call=self._parent_window. + set_unassigned_buttons_run_value, + autoselect=True, + value=self._parent_window.get_unassigned_buttons_run_value()) + ba.widget(edit=cb1, up_widget=back_button) + v -= 60 + capb = self._capture_button( + pos=(h2, v), + name=ba.Lstr(resource=self._r + '.runButton1Text'), + control='buttonRun1' + self._parent_window.get_ext()) + if self._parent_window.get_is_secondary(): + for widget in capb: + ba.widget(edit=widget, up_widget=back_button) + v -= 42 + self._capture_button( + pos=(h2, v), + name=ba.Lstr(resource=self._r + '.runButton2Text'), + control='buttonRun2' + self._parent_window.get_ext()) + ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, v - 24), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.runTriggerDescriptionText'), + color=(0.7, 1, 0.7, 0.6), + maxwidth=self._sub_width * 0.8, + scale=0.7, + h_align='center', + v_align='center') + + v -= 85 + + self._capture_button( + pos=(h2, v), + name=ba.Lstr(resource=self._r + '.runTrigger1Text'), + control='triggerRun1' + self._parent_window.get_ext(), + message=ba.Lstr(resource=self._r + '.pressAnyAnalogTriggerText')) + v -= 42 + self._capture_button( + pos=(h2, v), + name=ba.Lstr(resource=self._r + '.runTrigger2Text'), + control='triggerRun2' + self._parent_window.get_ext(), + message=ba.Lstr(resource=self._r + '.pressAnyAnalogTriggerText')) + + # in vr mode, allow assigning a reset-view button + if app.vr_mode: + v -= 50 + self._capture_button( + pos=(h2, v), + name=ba.Lstr(resource=self._r + '.vrReorientButtonText'), + control='buttonVRReorient' + self._parent_window.get_ext()) + + v -= 60 + self._capture_button( + pos=(h2, v), + name=ba.Lstr(resource=self._r + '.extraStartButtonText'), + control='buttonStart2' + self._parent_window.get_ext()) + v -= 60 + self._capture_button( + pos=(h2, v), + name=ba.Lstr(resource=self._r + '.ignoredButton1Text'), + control='buttonIgnored' + self._parent_window.get_ext()) + v -= 42 + self._capture_button( + pos=(h2, v), + name=ba.Lstr(resource=self._r + '.ignoredButton2Text'), + control='buttonIgnored2' + self._parent_window.get_ext()) + v -= 42 + self._capture_button( + pos=(h2, v), + name=ba.Lstr(resource=self._r + '.ignoredButton3Text'), + control='buttonIgnored3' + self._parent_window.get_ext()) + v -= 42 + self._capture_button( + pos=(h2, v), + name=ba.Lstr(resource=self._r + '.ignoredButton4Text'), + control='buttonIgnored4' + self._parent_window.get_ext()) + ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, v - 14), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.ignoredButtonDescriptionText'), + color=(0.7, 1, 0.7, 0.6), + scale=0.8, + maxwidth=self._sub_width * 0.8, + h_align='center', + v_align='center') + + v -= 80 + ba.checkboxwidget(parent=self._subcontainer, + autoselect=True, + position=(h + 50, v), + size=(400, 30), + text=ba.Lstr(resource=self._r + + '.startButtonActivatesDefaultText'), + textcolor=(0.8, 0.8, 0.8), + maxwidth=450, + scale=0.9, + on_value_change_call=self._parent_window. + set_start_button_activates_default_widget_value, + value=self._parent_window. + get_start_button_activates_default_widget_value()) + ba.textwidget( + parent=self._subcontainer, + position=(self._sub_width * 0.5, v - 12), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.startButtonActivatesDefaultDescriptionText'), + color=(0.7, 1, 0.7, 0.6), + maxwidth=self._sub_width * 0.8, + scale=0.7, + h_align='center', + v_align='center') + + v -= 80 + ba.checkboxwidget( + parent=self._subcontainer, + autoselect=True, + position=(h + 50, v), + size=(400, 30), + text=ba.Lstr(resource=self._r + '.uiOnlyText'), + textcolor=(0.8, 0.8, 0.8), + maxwidth=450, + scale=0.9, + on_value_change_call=self._parent_window.set_ui_only_value, + value=self._parent_window.get_ui_only_value()) + ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, v - 12), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.uiOnlyDescriptionText'), + color=(0.7, 1, 0.7, 0.6), + maxwidth=self._sub_width * 0.8, + scale=0.7, + h_align='center', + v_align='center') + + v -= 80 + ba.checkboxwidget( + parent=self._subcontainer, + autoselect=True, + position=(h + 50, v), + size=(400, 30), + text=ba.Lstr(resource=self._r + '.ignoreCompletelyText'), + textcolor=(0.8, 0.8, 0.8), + maxwidth=450, + scale=0.9, + on_value_change_call=self._parent_window. + set_ignore_completely_value, + value=self._parent_window.get_ignore_completely_value()) + ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, v - 12), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.ignoreCompletelyDescriptionText'), + color=(0.7, 1, 0.7, 0.6), + maxwidth=self._sub_width * 0.8, + scale=0.7, + h_align='center', + v_align='center') + + v -= 80 + + cb1 = ba.checkboxwidget( + parent=self._subcontainer, + autoselect=True, + position=(h + 50, v), + size=(400, 30), + text=ba.Lstr(resource=self._r + '.autoRecalibrateText'), + textcolor=(0.8, 0.8, 0.8), + maxwidth=450, + scale=0.9, + on_value_change_call=self._parent_window. + set_auto_recalibrate_analog_stick_value, + value=self._parent_window.get_auto_recalibrate_analog_stick_value( + )) + ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, v - 12), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.autoRecalibrateDescriptionText'), + color=(0.7, 1, 0.7, 0.6), + maxwidth=self._sub_width * 0.8, + scale=0.7, + h_align='center', + v_align='center') + v -= 80 + + buttons = self._config_value_editor( + ba.Lstr(resource=self._r + '.analogStickDeadZoneText'), + control=('analogStickDeadZone' + self._parent_window.get_ext()), + position=(h + 40, v), + min_val=0, + max_val=10.0, + increment=0.1, + x_offset=100) + ba.widget(edit=buttons[0], left_widget=cb1, up_widget=cb1) + ba.widget(edit=cb1, right_widget=buttons[0], down_widget=buttons[0]) + + ba.textwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5, v - 12), + size=(0, 0), + text=ba.Lstr(resource=self._r + + '.analogStickDeadZoneDescriptionText'), + color=(0.7, 1, 0.7, 0.6), + maxwidth=self._sub_width * 0.8, + scale=0.7, + h_align='center', + v_align='center') + v -= 100 + + # child joysticks cant have child joysticks.. that's just + # crazy talk + if not self._parent_window.get_is_secondary(): + ba.buttonwidget( + parent=self._subcontainer, + autoselect=True, + label=ba.Lstr(resource=self._r + '.twoInOneSetupText'), + position=(40, v), + size=(self._sub_width - 80, 50), + on_activate_call=self._parent_window.show_secondary_editor, + up_widget=buttons[0]) + + # set a bigger bottom show-buffer for the widgets we just made + # so we can see the text below them when navigating with + # a gamepad + for child in self._subcontainer.get_children(): + ba.widget(edit=child, show_buffer_bottom=30, show_buffer_top=30) + + def _capture_button(self, + pos: Tuple[float, float], + name: ba.Lstr, + control: str, + message: Optional[ba.Lstr] = None + ) -> Tuple[ba.Widget, ba.Widget]: + if message is None: + message = ba.Lstr(resource=self._parent_window.get_r() + + '.pressAnyButtonText') + btn = ba.buttonwidget(parent=self._subcontainer, + autoselect=True, + position=(pos[0], pos[1]), + label=name, + size=(250, 60), + scale=0.7) + btn2 = ba.buttonwidget(parent=self._subcontainer, + autoselect=True, + position=(pos[0] + 400, pos[1] + 2), + left_widget=btn, + color=(0.45, 0.4, 0.5), + textcolor=(0.65, 0.6, 0.7), + label=ba.Lstr(resource=self._r + '.clearText'), + size=(110, 50), + scale=0.7, + on_activate_call=ba.Call( + self._clear_control, control)) + ba.widget(edit=btn, right_widget=btn2) + + # make this in a timer so that it shows up on top of all + # other buttons + + def doit() -> None: + from bastd.ui.settings import gamepad + txt = ba.textwidget( + parent=self._subcontainer, + position=(pos[0] + 285, pos[1] + 20), + color=(1, 1, 1, 0.3), + size=(0, 0), + h_align='center', + v_align='center', + scale=0.7, + text=self._parent_window.get_control_value_name(control), + maxwidth=200) + self._textwidgets[control] = txt + ba.buttonwidget(edit=btn, + on_activate_call=ba.Call( + gamepad.AwaitGamepadInputWindow, + self._parent_window.get_input(), control, + self._gamepad_event, message)) + + ba.timer(0, doit, timetype=ba.TimeType.REAL) + return btn, btn2 + + def _inc(self, control: str, min_val: float, max_val: float, + inc: float) -> None: + try: + val = self._parent_window.get_settings()[control] + except Exception: + val = 1.0 + val = min(max_val, max(min_val, val + inc)) + if abs(1.0 - val) < 0.001: + if control in self._parent_window.get_settings(): + del self._parent_window.get_settings()[control] + else: + self._parent_window.get_settings()[control] = round(val, 1) + ba.textwidget(edit=self._textwidgets[control], + text=self._parent_window.get_control_value_name(control)) + + def _config_value_editor(self, + name: ba.Lstr, + control: str, + position: Tuple[float, float], + min_val: float = 0.0, + max_val: float = 100.0, + increment: float = 1.0, + change_sound: bool = True, + x_offset: float = 0.0, + displayname: ba.Lstr = None + ) -> Tuple[ba.Widget, ba.Widget]: + + if displayname is None: + displayname = name + ba.textwidget(parent=self._subcontainer, + position=position, + size=(100, 30), + text=displayname, + color=(0.8, 0.8, 0.8, 1.0), + h_align="left", + v_align="center", + scale=1.0, + maxwidth=280) + self._textwidgets[control] = ba.textwidget( + parent=self._subcontainer, + position=(246.0 + x_offset, position[1]), + size=(60, 28), + editable=False, + color=(0.3, 1.0, 0.3, 1.0), + h_align="right", + v_align="center", + text=self._parent_window.get_control_value_name(control), + padding=2) + btn = ba.buttonwidget(parent=self._subcontainer, + autoselect=True, + position=(330 + x_offset, position[1] + 4), + size=(28, 28), + label="-", + on_activate_call=ba.Call(self._inc, control, + min_val, max_val, + -increment), + repeat=True, + enable_sound=(change_sound is True)) + btn2 = ba.buttonwidget(parent=self._subcontainer, + autoselect=True, + position=(380 + x_offset, position[1] + 4), + size=(28, 28), + label="+", + on_activate_call=ba.Call( + self._inc, control, min_val, max_val, + increment), + repeat=True, + enable_sound=(change_sound is True)) + return btn, btn2 + + def _clear_control(self, control: str) -> None: + if control in self._parent_window.get_settings(): + del self._parent_window.get_settings()[control] + ba.textwidget(edit=self._textwidgets[control], + text=self._parent_window.get_control_value_name(control)) + + def _gamepad_event(self, control: str, event: Dict[str, Any], + dialog: gpsui.AwaitGamepadInputWindow) -> None: + ext = self._parent_window.get_ext() + if control in ['triggerRun1' + ext, 'triggerRun2' + ext]: + if event['type'] == 'AXISMOTION': + # ignore small values or else we might get triggered + # by noise + if abs(event['value']) > 0.5: + self._parent_window.get_settings()[control] = ( + event['axis']) + # update the button's text widget + if self._textwidgets[control]: + ba.textwidget( + edit=self._textwidgets[control], + text=self._parent_window.get_control_value_name( + control)) + ba.playsound(ba.getsound('gunCocking')) + dialog.die() + else: + if event['type'] == 'BUTTONDOWN': + value = event['button'] + self._parent_window.get_settings()[control] = value + # update the button's text widget + if self._textwidgets[control]: + ba.textwidget( + edit=self._textwidgets[control], + text=self._parent_window.get_control_value_name( + control)) + ba.playsound(ba.getsound('gunCocking')) + dialog.die() + + def _done(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_scale') diff --git a/assets/src/data/scripts/bastd/ui/settings/gamepadselect.py b/assets/src/data/scripts/bastd/ui/settings/gamepadselect.py new file mode 100644 index 00000000..79177af3 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/gamepadselect.py @@ -0,0 +1,138 @@ +"""Settings UI related to gamepad functionality.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Dict, Any + + +def gamepad_configure_callback(event: Dict[str, Any]) -> None: + """Respond to a gamepad button press during config selection.""" + from ba.internal import get_remote_app_name + from bastd.ui.settings import gamepad + # ignore all but button-presses + if event['type'] not in ['BUTTONDOWN', 'HATMOTION']: + return + _ba.release_gamepad_input() + try: + ba.containerwidget(edit=ba.app.main_menu_window, transition='out_left') + except Exception: + ba.print_exception("error transitioning out main_menu_window") + ba.playsound(ba.getsound('activateBeep')) + ba.playsound(ba.getsound('swish')) + if event['input_device'].get_allows_configuring(): + ba.app.main_menu_window = (gamepad.GamepadSettingsWindow( + event["input_device"]).get_root_widget()) + else: + width = 700 + height = 200 + button_width = 100 + ba.app.main_menu_window = dlg = (ba.containerwidget( + scale=1.7 if ba.app.small_ui else 1.4 if ba.app.med_ui else 1.0, + size=(width, height), + transition='in_right')) + device_name = event['input_device'].get_name() + if device_name == 'iDevice': + msg = ba.Lstr(resource='bsRemoteConfigureInAppText', + subs=[('${REMOTE_APP_NAME}', get_remote_app_name())]) + else: + msg = ba.Lstr(resource='cantConfigureDeviceText', + subs=[('${DEVICE}', device_name)]) + ba.textwidget(parent=dlg, + position=(0, height - 80), + size=(width, 25), + text=msg, + scale=0.8, + h_align="center", + v_align="top") + + def _ok() -> None: + from bastd.ui.settings import controls + ba.containerwidget(edit=dlg, transition='out_right') + ba.app.main_menu_window = (controls.ControlsSettingsWindow( + transition='in_left').get_root_widget()) + + ba.buttonwidget(parent=dlg, + position=((width - button_width) / 2, 20), + size=(button_width, 60), + label=ba.Lstr(resource='okText'), + on_activate_call=_ok) + + +class GamepadSelectWindow(ba.OldWindow): + """Window for selecting a gamepad to configure.""" + + def __init__(self) -> None: + from typing import cast + width = 480 + height = 170 + spacing = 40 + self._r = 'configGamepadSelectWindow' + + super().__init__(root_widget=ba.containerwidget( + scale=2.3 if ba.app.small_ui else 1.5 if ba.app.med_ui else 1.0, + size=(width, height), + transition='in_right')) + + btn = ba.buttonwidget(parent=self._root_widget, + position=(20, height - 60), + size=(130, 60), + label=ba.Lstr(resource='backText'), + button_type='back', + scale=0.8, + on_activate_call=self._back) + # Let's not have anything selected by default; its misleading looking + # for the controller getting configured. + ba.containerwidget(edit=self._root_widget, + cancel_button=btn, + selected_child=cast(ba.Widget, 0)) + ba.textwidget(parent=self._root_widget, + position=(20, height - 50), + size=(width, 25), + text=ba.Lstr(resource=self._r + '.titleText'), + maxwidth=250, + color=ba.app.title_color, + h_align="center", + v_align="center") + + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + v: float = height - 60 + v -= spacing + ba.textwidget(parent=self._root_widget, + position=(15, v), + size=(width - 30, 30), + scale=0.8, + text=ba.Lstr(resource=self._r + '.pressAnyButtonText'), + maxwidth=width * 0.95, + color=ba.app.infotextcolor, + h_align="center", + v_align="top") + v -= spacing * 1.24 + if ba.app.platform == 'android': + ba.textwidget(parent=self._root_widget, + position=(15, v), + size=(width - 30, 30), + scale=0.46, + text=ba.Lstr(resource=self._r + '.androidNoteText'), + maxwidth=width * 0.95, + color=(0.7, 0.9, 0.7, 0.5), + h_align="center", + v_align="top") + + _ba.capture_gamepad_input(gamepad_configure_callback) + + def _back(self) -> None: + from bastd.ui.settings import controls + _ba.release_gamepad_input() + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (controls.ControlsSettingsWindow( + transition='in_left').get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/settings/graphics.py b/assets/src/data/scripts/bastd/ui/settings/graphics.py new file mode 100644 index 00000000..e68bddc8 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/graphics.py @@ -0,0 +1,384 @@ +"""Provides UI for graphics settings.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Tuple, Optional + + +class GraphicsSettingsWindow(ba.OldWindow): + """Window for graphics settings.""" + + def __init__(self, + transition: str = 'in_right', + origin_widget: ba.Widget = None): + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + from bastd.ui import popup + from bastd.ui import config as cfgui + # if they provided an origin-widget, scale up from that + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self._r = 'graphicsSettingsWindow' + app = ba.app + + spacing = 32 + self._have_selected_child = False + interface_type = app.interface_type + width = 450.0 + height = 302.0 + + self._show_fullscreen = False + fullscreen_spacing_top = spacing * 0.2 + fullscreen_spacing = spacing * 1.2 + if interface_type == 'large' and app.platform != 'android': + self._show_fullscreen = True + height += fullscreen_spacing + fullscreen_spacing_top + + show_gamma = False + gamma_spacing = spacing * 1.3 + if _ba.has_gamma_control(): + show_gamma = True + height += gamma_spacing + + show_vsync = False + if app.platform == 'mac': + show_vsync = True + + show_resolution = True + if app.vr_mode: + show_resolution = (app.platform == 'android' + and app.subplatform == 'cardboard') + + base_scale = (2.4 + if ba.app.small_ui else 1.5 if ba.app.med_ui else 1.0) + popup_menu_scale = base_scale * 1.2 + v = height - 50 + v -= spacing * 1.15 + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition=transition, + scale_origin_stack_offset=scale_origin, + scale=base_scale, + stack_offset=(0, -30) if ba.app.small_ui else (0, 0))) + + btn = ba.buttonwidget(parent=self._root_widget, + position=(35, height - 50), + size=(120, 60), + scale=0.8, + text_scale=1.2, + autoselect=True, + label=ba.Lstr(resource='backText'), + button_type='back', + on_activate_call=self._back) + + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + ba.textwidget(parent=self._root_widget, + position=(0, height - 44), + size=(width, 25), + text=ba.Lstr(resource=self._r + '.titleText'), + color=ba.app.title_color, + h_align="center", + v_align="top") + + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + self._fullscreen_checkbox: Optional[ba.Widget] + if self._show_fullscreen: + v -= fullscreen_spacing_top + self._fullscreen_checkbox = cfgui.ConfigCheckBox( + parent=self._root_widget, + position=(100, v), + maxwidth=200, + size=(300, 30), + configkey="Fullscreen", + displayname=ba.Lstr(resource=self._r + + ('.fullScreenCmdText' if app.platform == + 'mac' else '.fullScreenCtrlText'))).widget + if not self._have_selected_child: + ba.containerwidget(edit=self._root_widget, + selected_child=self._fullscreen_checkbox) + self._have_selected_child = True + v -= fullscreen_spacing + else: + self._fullscreen_checkbox = None + + self._gamma_controls: Optional[cfgui.ConfigNumberEdit] + if show_gamma: + self._gamma_controls = gmc = cfgui.ConfigNumberEdit( + parent=self._root_widget, + position=(90, v), + configkey="Screen Gamma", + displayname=ba.Lstr(resource=self._r + '.gammaText'), + minval=0.1, + maxval=2.0, + increment=0.1, + xoffset=-70, + textscale=0.85) + if ba.app.toolbars: + ba.widget(edit=gmc.plusbutton, + right_widget=_ba.get_special_widget('party_button')) + if not self._have_selected_child: + ba.containerwidget(edit=self._root_widget, + selected_child=gmc.minusbutton) + self._have_selected_child = True + v -= gamma_spacing + else: + self._gamma_controls = None + + self._selected_color = (0.5, 1, 0.5, 1) + self._unselected_color = (0.7, 0.7, 0.7, 1) + + # quality + ba.textwidget(parent=self._root_widget, + position=(60, v), + size=(160, 25), + text=ba.Lstr(resource=self._r + '.visualsText'), + color=ba.app.heading_color, + scale=0.65, + maxwidth=150, + h_align="center", + v_align="center") + popup.PopupMenu( + parent=self._root_widget, + position=(60, v - 50), + width=150, + scale=popup_menu_scale, + choices=['Auto', 'Higher', 'High', 'Medium', 'Low'], + choices_disabled=['Higher', 'High'] + if _ba.get_max_graphics_quality() == 'Medium' else [], + choices_display=[ + ba.Lstr(resource='autoText'), + ba.Lstr(resource=self._r + '.higherText'), + ba.Lstr(resource=self._r + '.highText'), + ba.Lstr(resource=self._r + '.mediumText'), + ba.Lstr(resource=self._r + '.lowText') + ], + current_choice=ba.app.config.resolve('Graphics Quality'), + on_value_change_call=self._set_quality) + + # texture controls + ba.textwidget(parent=self._root_widget, + position=(230, v), + size=(160, 25), + text=ba.Lstr(resource=self._r + '.texturesText'), + color=ba.app.heading_color, + scale=0.65, + maxwidth=150, + h_align="center", + v_align="center") + textures_popup = popup.PopupMenu( + parent=self._root_widget, + position=(230, v - 50), + width=150, + scale=popup_menu_scale, + choices=['Auto', 'High', 'Medium', 'Low'], + choices_display=[ + ba.Lstr(resource='autoText'), + ba.Lstr(resource=self._r + '.highText'), + ba.Lstr(resource=self._r + '.mediumText'), + ba.Lstr(resource=self._r + '.lowText') + ], + current_choice=ba.app.config.resolve('Texture Quality'), + on_value_change_call=self._set_textures) + if ba.app.toolbars: + ba.widget(edit=textures_popup.get_button(), + right_widget=_ba.get_special_widget('party_button')) + v -= 80 + + h_offs = 0 + + if show_resolution: + # resolution + ba.textwidget(parent=self._root_widget, + position=(h_offs + 60, v), + size=(160, 25), + text=ba.Lstr(resource=self._r + '.resolutionText'), + color=ba.app.heading_color, + scale=0.65, + maxwidth=150, + h_align="center", + v_align="center") + + # on standard android we have 'Auto', 'Native', and a few + # HD standards + if app.platform == 'android': + # on cardboard/daydream android we have a few + # render-target-scale options + if app.subplatform == 'cardboard': + current_res_cardboard = (str(min(100, max(10, int(round( + ba.app.config.resolve('GVR Render Target Scale') + * 100.0))))) + '%') # yapf: disable + popup.PopupMenu( + parent=self._root_widget, + position=(h_offs + 60, v - 50), + width=120, + scale=popup_menu_scale, + choices=['100%', '75%', '50%', '35%'], + current_choice=current_res_cardboard, + on_value_change_call=self._set_gvr_render_target_scale) + else: + native_res = _ba.get_display_resolution() + choices = ['Auto', 'Native'] + choices_display = [ + ba.Lstr(resource='autoText'), + ba.Lstr(resource='nativeText') + ] + for res in [1440, 1080, 960, 720, 480]: + # nav bar is 72px so lets allow for that in what + # choices we show + if native_res[1] >= res - 72: + res_str = str(res) + 'p' + choices.append(res_str) + choices_display.append(ba.Lstr(value=res_str)) + current_res_android = ba.app.config.resolve( + 'Resolution (Android)') + popup.PopupMenu(parent=self._root_widget, + position=(h_offs + 60, v - 50), + width=120, + scale=popup_menu_scale, + choices=choices, + choices_display=choices_display, + current_choice=current_res_android, + on_value_change_call=self._set_android_res) + else: + # if we're on a system that doesn't allow setting resolution, + # set pixel-scale instead + current_res = _ba.get_display_resolution() + if current_res is None: + current_res = (str(min(100, max(10, int(round( + ba.app.config.resolve('Screen Pixel Scale') + * 100.0))))) + '%') # yapf: disable + popup.PopupMenu( + parent=self._root_widget, + position=(h_offs + 60, v - 50), + width=120, + scale=popup_menu_scale, + choices=['100%', '88%', '75%', '63%', '50%'], + current_choice=current_res, + on_value_change_call=self._set_pixel_scale) + else: + raise Exception('obsolete path; discrete resolutions' + ' no longer supported') + + # vsync + if show_vsync: + ba.textwidget(parent=self._root_widget, + position=(230, v), + size=(160, 25), + text=ba.Lstr(resource=self._r + '.verticalSyncText'), + color=ba.app.heading_color, + scale=0.65, + maxwidth=150, + h_align="center", + v_align="center") + + popup.PopupMenu( + parent=self._root_widget, + position=(230, v - 50), + width=150, + scale=popup_menu_scale, + choices=['Auto', 'Always', 'Never'], + choices_display=[ + ba.Lstr(resource='autoText'), + ba.Lstr(resource=self._r + '.alwaysText'), + ba.Lstr(resource=self._r + '.neverText') + ], + current_choice=ba.app.config.resolve('Vertical Sync'), + on_value_change_call=self._set_vsync) + + v -= 90 + fpsc = cfgui.ConfigCheckBox(parent=self._root_widget, + position=(69, v - 6), + size=(210, 30), + scale=0.86, + configkey="Show FPS", + displayname=ba.Lstr(resource=self._r + + '.showFPSText'), + maxwidth=130) + + # (tv mode doesnt apply to vr) + if not ba.app.vr_mode: + tvc = cfgui.ConfigCheckBox(parent=self._root_widget, + position=(240, v - 6), + size=(210, 30), + scale=0.86, + configkey="TV Border", + displayname=ba.Lstr(resource=self._r + + '.tvBorderText'), + maxwidth=130) + # grumble.. + ba.widget(edit=fpsc.widget, right_widget=tvc.widget) + try: + pass + + except Exception: + ba.print_exception('Exception wiring up graphics settings UI:') + + v -= spacing + + # make a timer to update our controls in case the config changes + # under us + self._update_timer = ba.Timer(0.25, + ba.WeakCall(self._update_controls), + repeat=True, + timetype=ba.TimeType.REAL) + + def _back(self) -> None: + from bastd.ui.settings import allsettings + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = (allsettings.AllSettingsWindow( + transition='in_left').get_root_widget()) + + def _set_quality(self, quality: str) -> None: + cfg = ba.app.config + cfg['Graphics Quality'] = quality + cfg.apply_and_commit() + + def _set_textures(self, val: str) -> None: + cfg = ba.app.config + cfg['Texture Quality'] = val + cfg.apply_and_commit() + + def _set_android_res(self, val: str) -> None: + cfg = ba.app.config + cfg['Resolution (Android)'] = val + cfg.apply_and_commit() + + def _set_pixel_scale(self, res: str) -> None: + cfg = ba.app.config + cfg['Screen Pixel Scale'] = float(res[:-1]) / 100.0 + cfg.apply_and_commit() + + def _set_gvr_render_target_scale(self, res: str) -> None: + cfg = ba.app.config + cfg['GVR Render Target Scale'] = float(res[:-1]) / 100.0 + cfg.apply_and_commit() + + def _set_vsync(self, val: str) -> None: + cfg = ba.app.config + cfg['Vertical Sync'] = val + cfg.apply_and_commit() + + def _update_controls(self) -> None: + if self._show_fullscreen: + ba.checkboxwidget(edit=self._fullscreen_checkbox, + value=ba.app.config.resolve('Fullscreen')) diff --git a/assets/src/data/scripts/bastd/ui/settings/keyboard.py b/assets/src/data/scripts/bastd/ui/settings/keyboard.py new file mode 100644 index 00000000..9dc358ac --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/keyboard.py @@ -0,0 +1,299 @@ +"""Keyboard settings related UI functionality.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Dict, Tuple, Any, Optional + + +class ConfigKeyboardWindow(ba.OldWindow): + """Window for configuring keyboards.""" + + def __init__(self, c: ba.InputDevice, transition: str = 'in_right'): + self._r = 'configKeyboardWindow' + self._input = c + self._name = self._input.name + self._unique_id = self._input.unique_identifier + dname_raw = self._name + if self._unique_id != '#1': + dname_raw += ' ' + self._unique_id.replace('#', 'P') + self._displayname = ba.Lstr(translate=('inputDeviceNames', dname_raw)) + self._width = 700 + if self._unique_id != "#1": + self._height = 450 + else: + self._height = 345 + self._spacing = 40 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + scale=(1.6 if ba.app.small_ui else 1.3 if ba.app.med_ui else 1.0), + stack_offset=(0, -10) if ba.app.small_ui else (0, 0), + transition=transition)) + + # don't ask to config joysticks while we're in here.. + self._rebuild_ui() + + def _rebuild_ui(self) -> None: + from ba.internal import get_device_value + for widget in self._root_widget.get_children(): + widget.delete() + + # fill our temp config with present values + self._settings: Dict[str, int] = {} + for button in [ + 'buttonJump', 'buttonPunch', 'buttonBomb', 'buttonPickUp', + 'buttonStart', 'buttonStart2', 'buttonUp', 'buttonDown', + 'buttonLeft', 'buttonRight' + ]: + self._settings[button] = get_device_value(self._input, button) + + cancel_button = ba.buttonwidget(parent=self._root_widget, + autoselect=True, + position=(38, self._height - 65), + size=(170, 60), + label=ba.Lstr(resource='cancelText'), + scale=0.9, + on_activate_call=self._cancel) + save_button = ba.buttonwidget(parent=self._root_widget, + autoselect=True, + position=(self._width - 190, + self._height - 65), + size=(180, 60), + label=ba.Lstr(resource='makeItSoText'), + scale=0.9, + text_scale=0.9, + on_activate_call=self._save) + ba.containerwidget(edit=self._root_widget, + cancel_button=cancel_button, + start_button=save_button) + + ba.widget(edit=cancel_button, right_widget=save_button) + ba.widget(edit=save_button, left_widget=cancel_button) + + v = self._height - 54.0 + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, v + 15), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.configuringText', + subs=[('${DEVICE}', self._displayname)]), + color=ba.app.title_color, + h_align='center', + v_align='center', + maxwidth=270, + scale=0.83) + v -= 20 + + if self._unique_id != "#1": + v -= 20 + v -= self._spacing + ba.textwidget(parent=self._root_widget, + position=(0, v + 19), + size=(self._width, 50), + text=ba.Lstr(resource=self._r + + '.keyboard2NoteText'), + scale=0.7, + maxwidth=self._width * 0.75, + max_height=110, + color=ba.app.infotextcolor, + h_align="center", + v_align="top") + v -= 45 + v -= 10 + v -= self._spacing * 2.2 + v += 25 + v -= 42 + h_offs = 160 + dist = 70 + d_color = (0.4, 0.4, 0.8) + self._capture_button(pos=(h_offs, v + 0.95 * dist), + color=d_color, + button='buttonUp', + texture=ba.gettexture('upButton'), + scale=1.0) + self._capture_button(pos=(h_offs - 1.2 * dist, v), + color=d_color, + button='buttonLeft', + texture=ba.gettexture('leftButton'), + scale=1.0) + self._capture_button(pos=(h_offs + 1.2 * dist, v), + color=d_color, + button='buttonRight', + texture=ba.gettexture('rightButton'), + scale=1.0) + self._capture_button(pos=(h_offs, v - 0.95 * dist), + color=d_color, + button='buttonDown', + texture=ba.gettexture('downButton'), + scale=1.0) + + if self._unique_id == "#2": + self._capture_button(pos=(self._width * 0.5, v + 0.1 * dist), + color=(0.4, 0.4, 0.6), + button='buttonStart', + texture=ba.gettexture('startButton'), + scale=0.8) + + h_offs = self._width - 160 + + self._capture_button(pos=(h_offs, v + 0.95 * dist), + color=(0.6, 0.4, 0.8), + button='buttonPickUp', + texture=ba.gettexture('buttonPickUp'), + scale=1.0) + self._capture_button(pos=(h_offs - 1.2 * dist, v), + color=(0.7, 0.5, 0.1), + button='buttonPunch', + texture=ba.gettexture('buttonPunch'), + scale=1.0) + self._capture_button(pos=(h_offs + 1.2 * dist, v), + color=(0.5, 0.2, 0.1), + button='buttonBomb', + texture=ba.gettexture('buttonBomb'), + scale=1.0) + self._capture_button(pos=(h_offs, v - 0.95 * dist), + color=(0.2, 0.5, 0.2), + button='buttonJump', + texture=ba.gettexture('buttonJump'), + scale=1.0) + + def _capture_button(self, + pos: Tuple[float, float], + color: Tuple[float, float, float], + texture: ba.Texture, + button: str, + scale: float = 1.0) -> None: + base_size = 79 + btn = ba.buttonwidget(parent=self._root_widget, + autoselect=True, + position=(pos[0] - base_size * 0.5 * scale, + pos[1] - base_size * 0.5 * scale), + size=(base_size * scale, base_size * scale), + texture=texture, + label='', + color=color) + + # do this deferred so it shows up on top of other buttons + def doit() -> None: + uiscale = 0.66 * scale * 2.0 + maxwidth = 76.0 * scale + txt = ba.textwidget(parent=self._root_widget, + position=(pos[0] + 0.0 * scale, + pos[1] - (57.0 - 18.0) * scale), + color=(1, 1, 1, 0.3), + size=(0, 0), + h_align='center', + v_align='top', + scale=uiscale, + maxwidth=maxwidth, + text=self._input.get_button_name( + self._settings[button])) + ba.buttonwidget(edit=btn, + autoselect=True, + on_activate_call=ba.Call(AwaitKeyboardInputWindow, + button, txt, + self._settings)) + + ba.pushcall(doit) + + def _cancel(self) -> None: + from bastd.ui.settings.controls import ControlsSettingsWindow + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (ControlsSettingsWindow( + transition='in_left').get_root_widget()) + + def _save(self) -> None: + from bastd.ui.settings.controls import ControlsSettingsWindow + from ba.internal import (get_input_device_config, + should_submit_debug_info, serverput) + + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.playsound(ba.getsound('gunCocking')) + dst = get_input_device_config(self._input, default=False) + dst2: Dict[str, Any] = dst[0][dst[1]] + dst2.clear() + + # Store any values that aren't -1. + for key, val in list(self._settings.items()): + if val != -1: + dst2[key] = val + + # If we're allowed to phone home, send this config so we can generate + # more defaults in the future. + if should_submit_debug_info(): + serverput( + 'controllerConfig', { + 'ua': ba.app.user_agent_string, + 'name': self._name, + 'b': ba.app.build_number, + 'config': dst2, + 'v': 2 + }) + ba.app.config.apply_and_commit() + ba.app.main_menu_window = (ControlsSettingsWindow( + transition='in_left').get_root_widget()) + + +class AwaitKeyboardInputWindow(ba.OldWindow): + """Window for capturing a keypress.""" + + def __init__(self, button: str, ui: ba.Widget, settings: Dict[str, Any]): + + self._capture_button = button + self._capture_key_ui = ui + self._settings = settings + + width = 400 + height = 150 + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition='in_right', + scale=2.0 if ba.app.small_ui else 1.5 if ba.app.med_ui else 1.0)) + ba.textwidget(parent=self._root_widget, + position=(0, height - 60), + size=(width, 25), + text=ba.Lstr(resource='pressAnyKeyText'), + h_align="center", + v_align="top") + + self._counter = 5 + self._count_down_text = ba.textwidget(parent=self._root_widget, + h_align='center', + position=(0, height - 110), + size=(width, 25), + color=(1, 1, 1, 0.3), + text=str(self._counter)) + self._decrement_timer: Optional[ba.Timer] = ba.Timer( + 1.0, + ba.Call(self._decrement), + repeat=True, + timetype=ba.TimeType.REAL) + _ba.capture_keyboard_input(ba.WeakCall(self._button_callback)) + + def __del__(self) -> None: + _ba.release_keyboard_input() + + def _die(self) -> None: + # this strong-refs us; killing it allow us to die now + self._decrement_timer = None + if self._root_widget: + ba.containerwidget(edit=self._root_widget, transition='out_left') + + def _button_callback(self, event: Dict[str, Any]) -> None: + self._settings[self._capture_button] = event["button"] + if event['type'] == 'BUTTONDOWN': + bname = event['input_device'].get_button_name(event["button"]) + ba.textwidget(edit=self._capture_key_ui, text=bname) + ba.playsound(ba.getsound('gunCocking')) + self._die() + + def _decrement(self) -> None: + self._counter -= 1 + if self._counter >= 1: + ba.textwidget(edit=self._count_down_text, text=str(self._counter)) + else: + self._die() diff --git a/assets/src/data/scripts/bastd/ui/settings/nettesting.py b/assets/src/data/scripts/bastd/ui/settings/nettesting.py new file mode 100644 index 00000000..2c9d161c --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/nettesting.py @@ -0,0 +1,38 @@ +"""Provides ui for network related testing.""" + +from __future__ import annotations + +import ba +from bastd.ui.settings import testing + + +class NetTestingWindow(testing.TestingWindow): + """Window to test network related settings.""" + + def __init__(self, transition: str = 'in_right'): + + entries = [ + { + 'name': 'bufferTime', + 'label': 'Buffer Time', + 'increment': 1.0 + }, + { + 'name': 'delaySampling', + 'label': 'Delay Sampling', + 'increment': 1.0 + }, + { + 'name': 'dynamicsSyncTime', + 'label': 'Dynamics Sync Time', + 'increment': 10 + }, + { + 'name': 'showNetInfo', + 'label': 'Show Net Info', + 'increment': 1 + }, + ] + testing.TestingWindow.__init__( + self, ba.Lstr(resource='settingsWindowAdvanced.netTestingText'), + entries, transition) diff --git a/assets/src/data/scripts/bastd/ui/settings/ps3controller.py b/assets/src/data/scripts/bastd/ui/settings/ps3controller.py new file mode 100644 index 00000000..689920b5 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/ps3controller.py @@ -0,0 +1,102 @@ +"""Settings UI related to PS3 controllers.""" + +from __future__ import annotations + +import _ba +import ba + + +class PS3ControllerSettingsWindow(ba.OldWindow): + """UI showing info about using PS3 controllers.""" + + def __init__(self) -> None: + width = 760 + height = 330 if _ba.is_running_on_fire_tv() else 540 + spacing = 40 + self._r = 'ps3ControllersWindow' + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition='in_right', + scale=1.35 if ba.app.small_ui else 1.3 if ba.app.med_ui else 1.0)) + + btn = ba.buttonwidget(parent=self._root_widget, + position=(37, height - 73), + size=(135, 65), + scale=0.85, + label=ba.Lstr(resource='backText'), + button_type='back', + autoselect=True, + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, height - 46), + size=(0, 0), + maxwidth=410, + text=ba.Lstr(resource=self._r + '.titleText', + subs=[('${APP_NAME}', + ba.Lstr(resource='titleText'))]), + color=ba.app.title_color, + h_align="center", + v_align="center") + + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + v = height - 90 + v -= spacing + + if _ba.is_running_on_fire_tv(): + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, height * 0.45), + size=(0, 0), + color=(0.7, 0.9, 0.7, 1.0), + maxwidth=width * 0.95, + max_height=height * 0.8, + scale=1.0, + text=ba.Lstr(resource=self._r + + '.ouyaInstructionsText'), + h_align="center", + v_align="center") + else: + txts = ba.Lstr(resource=self._r + + '.macInstructionsText').evaluate().split('\n\n\n') + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, v - 29), + size=(0, 0), + color=(0.7, 0.9, 0.7, 1.0), + maxwidth=width * 0.95, + max_height=170, + scale=1.0, + text=txts[0].strip(), + h_align="center", + v_align="center") + if txts: + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, v - 280), + size=(0, 0), + color=(0.7, 0.9, 0.7, 1.0), + maxwidth=width * 0.95, + max_height=170, + scale=1.0, + text=txts[1].strip(), + h_align="center", + v_align="center") + + ba.buttonwidget(parent=self._root_widget, + position=(225, v - 176), + size=(300, 40), + label=ba.Lstr(resource=self._r + + '.pairingTutorialText'), + autoselect=True, + on_activate_call=ba.Call( + ba.open_url, 'http://www.youtube.com/watch' + '?v=IlR_HxeOQpI&feature=related')) + + def _back(self) -> None: + from bastd.ui.settings import controls + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (controls.ControlsSettingsWindow( + transition='in_left').get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/settings/remoteapp.py b/assets/src/data/scripts/bastd/ui/settings/remoteapp.py new file mode 100644 index 00000000..b99a4dfc --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/remoteapp.py @@ -0,0 +1,113 @@ +"""Settings UI functionality related to the remote app.""" + +from __future__ import annotations + +import ba + + +class RemoteAppSettingsWindow(ba.OldWindow): + """Window showing info/settings related to the remote app.""" + + def __init__(self) -> None: + from ba.internal import get_remote_app_name + self._r = 'connectMobileDevicesWindow' + width = 700 + height = 390 + spacing = 40 + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition='in_right', + scale=(1.85 if ba.app.small_ui else 1.3 if ba.app.med_ui else 1.0), + stack_offset=(-10, 0) if ba.app.small_ui else (0, 0))) + btn = ba.buttonwidget(parent=self._root_widget, + position=(40, height - 67), + size=(140, 65), + scale=0.8, + label=ba.Lstr(resource='backText'), + button_type='back', + text_scale=1.1, + autoselect=True, + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, height - 42), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.titleText'), + maxwidth=370, + color=ba.app.title_color, + scale=0.8, + h_align="center", + v_align="center") + + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + v = height - 70.0 + v -= spacing * 1.2 + ba.textwidget(parent=self._root_widget, + position=(15, v - 26), + size=(width - 30, 30), + maxwidth=width * 0.95, + color=(0.7, 0.9, 0.7, 1.0), + scale=0.8, + text=ba.Lstr(resource=self._r + '.explanationText', + subs=[('${APP_NAME}', + ba.Lstr(resource='titleText')), + ('${REMOTE_APP_NAME}', + get_remote_app_name())]), + max_height=100, + h_align="center", + v_align="center") + v -= 90 + + # hmm the itms:// version doesnt bounce through safari but is kinda + # apple-specific-ish + + # Update: now we just show link to the remote webpage. + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, v + 5), + size=(0, 0), + color=(0.7, 0.9, 0.7, 1.0), + scale=1.4, + text='bombsquadgame.com/remote', + maxwidth=width * 0.95, + max_height=60, + h_align="center", + v_align="center") + v -= 30 + + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, v - 35), + size=(0, 0), + color=(0.7, 0.9, 0.7, 0.8), + scale=0.65, + text=ba.Lstr(resource=self._r + '.bestResultsText'), + maxwidth=width * 0.95, + max_height=height * 0.19, + h_align="center", + v_align="center") + + ba.checkboxwidget( + parent=self._root_widget, + position=(width * 0.5 - 150, v - 116), + size=(300, 30), + maxwidth=300, + scale=0.8, + value=not ba.app.config.resolve('Enable Remote App'), + autoselect=True, + text=ba.Lstr(resource='disableRemoteAppConnectionsText'), + on_value_change_call=self._on_check_changed) + + def _on_check_changed(self, value: bool) -> None: + cfg = ba.app.config + cfg['Enable Remote App'] = not value + cfg.apply_and_commit() + + def _back(self) -> None: + from bastd.ui.settings import controls + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (controls.ControlsSettingsWindow( + transition='in_left').get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/settings/testing.py b/assets/src/data/scripts/bastd/ui/settings/testing.py new file mode 100644 index 00000000..b63eb9c3 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/testing.py @@ -0,0 +1,177 @@ +"""Provides UI for test settings.""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Dict, List + + +class TestingWindow(ba.OldWindow): + """Window for conveniently testing various settings.""" + + def __init__(self, + title: ba.Lstr, + entries: List[Dict[str, Any]], + transition: str = 'in_right'): + self._width = 600 + self._height = 324 if ba.app.small_ui else 400 + self._entries = copy.deepcopy(entries) + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition=transition, + scale=(2.5 if ba.app.small_ui else 1.2 if ba.app.med_ui else 1.0), + stack_offset=(0, -28) if ba.app.small_ui else (0, 0))) + self._back_button = btn = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(65, self._height - 59), + size=(130, 60), + scale=0.8, + text_scale=1.2, + label=ba.Lstr(resource='backText'), + button_type='back', + on_activate_call=self._do_back) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 35), + size=(0, 0), + color=ba.app.title_color, + h_align='center', + v_align='center', + maxwidth=245, + text=title) + + ba.buttonwidget(edit=self._back_button, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height - 75), + size=(0, 0), + color=ba.app.infotextcolor, + h_align='center', + v_align='center', + maxwidth=self._width * 0.75, + text=ba.Lstr(resource='settingsWindowAdvanced.forTestingText')) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + self._scroll_width = self._width - 130 + self._scroll_height = self._height - 140 + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + size=(self._scroll_width, self._scroll_height), + highlight=False, + position=((self._width - self._scroll_width) * 0.5, 40)) + ba.containerwidget(edit=self._scrollwidget, claims_left_right=True) + + self._spacing = 50 + + self._sub_width = self._scroll_width * 0.95 + self._sub_height = 50 + len(self._entries) * self._spacing + 60 + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._sub_width, + self._sub_height), + background=False) + + h = 230 + v = self._sub_height - 48 + + for i, entry in enumerate(self._entries): + + entry_name = entry['name'] + + # if we haven't yet, record the default value for this name so + # we can reset if we want.. + if entry_name not in ba.app.value_test_defaults: + ba.app.value_test_defaults[entry_name] = ( + _ba.value_test(entry_name)) + + ba.textwidget(parent=self._subcontainer, + position=(h, v), + size=(0, 0), + h_align='right', + v_align='center', + maxwidth=200, + text=entry['label']) + btn = ba.buttonwidget(parent=self._subcontainer, + position=(h + 20, v - 19), + size=(40, 40), + autoselect=True, + repeat=True, + left_widget=self._back_button, + button_type='square', + label='-', + on_activate_call=ba.Call( + self._on_minus_press, entry['name'])) + if i == 0: + ba.widget(edit=btn, up_widget=self._back_button) + ba.widget(edit=btn, show_buffer_top=20, show_buffer_bottom=20) + entry['widget'] = ba.textwidget( + parent=self._subcontainer, + position=(h + 100, v), + size=(0, 0), + h_align='center', + v_align='center', + maxwidth=60, + text=str(round(_ba.value_test(entry_name), 4))) + btn = ba.buttonwidget(parent=self._subcontainer, + position=(h + 140, v - 19), + size=(40, 40), + autoselect=True, + repeat=True, + button_type='square', + label='+', + on_activate_call=ba.Call( + self._on_plus_press, entry['name'])) + if i == 0: + ba.widget(edit=btn, up_widget=self._back_button) + v -= self._spacing + v -= 35 + b_reset = ba.buttonwidget( + parent=self._subcontainer, + autoselect=True, + size=(200, 50), + position=(self._sub_width * 0.5 - 100, v), + label=ba.Lstr(resource='settingsWindowAdvanced.resetText'), + right_widget=btn, + on_activate_call=self._on_reset_press) + ba.widget(edit=b_reset, show_buffer_top=20, show_buffer_bottom=20) + + def _get_entry(self, name: str) -> Dict[str, Any]: + for entry in self._entries: + if entry['name'] == name: + return entry + raise Exception(f'Entry not found: {name}') + + def _on_reset_press(self) -> None: + for entry in self._entries: + _ba.value_test(entry['name'], + absolute=ba.app.value_test_defaults[entry['name']]) + ba.textwidget(edit=entry['widget'], + text=str(round(_ba.value_test(entry['name']), 4))) + + def _on_minus_press(self, entry_name: str) -> None: + entry = self._get_entry(entry_name) + _ba.value_test(entry['name'], change=-entry['increment']) + ba.textwidget(edit=entry['widget'], + text=str(round(_ba.value_test(entry['name']), 4))) + + def _on_plus_press(self, entry_name: str) -> None: + entry = self._get_entry(entry_name) + _ba.value_test(entry['name'], change=entry['increment']) + ba.textwidget(edit=entry['widget'], + text=str(round(_ba.value_test(entry['name']), 4))) + + def _do_back(self) -> None: + # pylint: disable=cyclic-import + import bastd.ui.settings.advanced + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = ( + bastd.ui.settings.advanced.AdvancedSettingsWindow( + transition='in_left').get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/settings/touchscreen.py b/assets/src/data/scripts/bastd/ui/settings/touchscreen.py new file mode 100644 index 00000000..0f95d7f0 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/touchscreen.py @@ -0,0 +1,234 @@ +"""UI settings functionality related to touchscreens.""" +from __future__ import annotations + +import _ba +import ba + + +class TouchscreenSettingsWindow(ba.OldWindow): + """Settings window for touchscreens.""" + + def __del__(self) -> None: + # Note - this happens in 'back' too; + # we just do it here too in case the window is closed by other means. + + # FIXME: Could switch to a UI destroy callback now that those are a + # thing that exists. + _ba.set_touchscreen_editing(False) + + def __init__(self) -> None: + + self._width = 650 + self._height = 380 + self._spacing = 40 + self._r = 'configTouchscreenWindow' + + _ba.set_touchscreen_editing(True) + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition='in_right', + scale=1.9 if ba.app.small_ui else 1.55 if ba.app.med_ui else 1.2)) + + btn = ba.buttonwidget(parent=self._root_widget, + position=(55, self._height - 60), + size=(120, 60), + label=ba.Lstr(resource='backText'), + button_type='back', + scale=0.8, + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + ba.textwidget(parent=self._root_widget, + position=(25, self._height - 50), + size=(self._width, 25), + text=ba.Lstr(resource=self._r + '.titleText'), + color=ba.app.title_color, + maxwidth=280, + h_align="center", + v_align="center") + + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + self._scroll_width = self._width - 100 + self._scroll_height = self._height - 110 + self._sub_width = self._scroll_width - 20 + self._sub_height = 360 + + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + position=((self._width - self._scroll_width) * 0.5, + self._height - 65 - self._scroll_height), + size=(self._scroll_width, self._scroll_height)) + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(self._sub_width, + self._sub_height), + background=False) + ba.containerwidget(edit=self._scrollwidget, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + ba.containerwidget(edit=self._subcontainer, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + + self._build_gui() + + def _build_gui(self) -> None: + from bastd.ui import config as cfgui + from bastd.ui import radiogroup + # clear anything already there + children = self._subcontainer.get_children() + for child in children: + child.delete() + h = 30 + v = self._sub_height - 85 + clr = (0.8, 0.8, 0.8, 1.0) + clr2 = (0.8, 0.8, 0.8) + ba.textwidget(parent=self._subcontainer, + position=(-10, v + 43), + size=(self._sub_width, 25), + text=ba.Lstr(resource=self._r + '.swipeInfoText'), + flatness=1.0, + color=(0, 0.9, 0.1, 0.7), + maxwidth=self._sub_width * 0.9, + scale=0.55, + h_align="center", + v_align="center") + cur_val = ba.app.config.get('Touch Movement Control Type', 'swipe') + ba.textwidget(parent=self._subcontainer, + position=(h, v - 2), + size=(0, 30), + text=ba.Lstr(resource=self._r + '.movementText'), + maxwidth=190, + color=clr, + v_align='center') + cb1 = ba.checkboxwidget(parent=self._subcontainer, + position=(h + 220, v), + size=(170, 30), + text=ba.Lstr(resource=self._r + + '.joystickText'), + maxwidth=100, + textcolor=clr2, + scale=0.9) + cb2 = ba.checkboxwidget(parent=self._subcontainer, + position=(h + 357, v), + size=(170, 30), + text=ba.Lstr(resource=self._r + '.swipeText'), + maxwidth=100, + textcolor=clr2, + value=False, + scale=0.9) + radiogroup.make_radio_group((cb1, cb2), ('joystick', 'swipe'), cur_val, + self._movement_changed) + v -= 50 + cfgui.ConfigNumberEdit( + parent=self._subcontainer, + position=(h, v), + xoffset=65, + configkey="Touch Controls Scale Movement", + displayname=ba.Lstr(resource=self._r + + '.movementControlScaleText'), + changesound=False, + minval=0.1, + maxval=4.0, + increment=0.1) + v -= 50 + cur_val = ba.app.config.get('Touch Action Control Type', 'buttons') + ba.textwidget(parent=self._subcontainer, + position=(h, v - 2), + size=(0, 30), + text=ba.Lstr(resource=self._r + '.actionsText'), + maxwidth=190, + color=clr, + v_align='center') + cb1 = ba.checkboxwidget(parent=self._subcontainer, + position=(h + 220, v), + size=(170, 30), + text=ba.Lstr(resource=self._r + + '.buttonsText'), + maxwidth=100, + textcolor=clr2, + scale=0.9) + cb2 = ba.checkboxwidget(parent=self._subcontainer, + position=(h + 357, v), + size=(170, 30), + text=ba.Lstr(resource=self._r + '.swipeText'), + maxwidth=100, + textcolor=clr2, + scale=0.9) + radiogroup.make_radio_group((cb1, cb2), ('buttons', 'swipe'), cur_val, + self._actions_changed) + v -= 50 + cfgui.ConfigNumberEdit(parent=self._subcontainer, + position=(h, v), + xoffset=65, + configkey="Touch Controls Scale Actions", + displayname=ba.Lstr(resource=self._r + + '.actionControlScaleText'), + changesound=False, + minval=0.1, + maxval=4.0, + increment=0.1) + + v -= 50 + cfgui.ConfigCheckBox(parent=self._subcontainer, + position=(h, v), + size=(400, 30), + maxwidth=400, + configkey="Touch Controls Swipe Hidden", + displayname=ba.Lstr(resource=self._r + + '.swipeControlsHiddenText')) + v -= 65 + + ba.buttonwidget(parent=self._subcontainer, + position=(self._sub_width * 0.5 - 70, v), + size=(170, 60), + label=ba.Lstr(resource=self._r + '.resetText'), + scale=0.75, + on_activate_call=self._reset) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, 38), + size=(0, 0), + h_align='center', + text=ba.Lstr(resource=self._r + '.dragControlsText'), + maxwidth=self._width * 0.8, + scale=0.65, + color=(1, 1, 1, 0.4)) + + def _actions_changed(self, v: str) -> None: + cfg = ba.app.config + cfg['Touch Action Control Type'] = v + cfg.apply_and_commit() + + def _movement_changed(self, v: str) -> None: + cfg = ba.app.config + cfg['Touch Movement Control Type'] = v + cfg.apply_and_commit() + + def _reset(self) -> None: + cfg = ba.app.config + cfgkeys = [ + 'Touch Movement Control Type', 'Touch Action Control Type', + 'Touch Controls Scale', 'Touch Controls Scale Movement', + 'Touch Controls Scale Actions', 'Touch Controls Swipe Hidden', + 'Touch DPad X', 'Touch DPad Y', 'Touch Buttons X', + 'Touch Buttons Y' + ] + for cfgkey in cfgkeys: + if cfgkey in cfg: + del cfg[cfgkey] + cfg.apply_and_commit() + ba.timer(0, self._build_gui, timetype=ba.TimeType.REAL) + + def _back(self) -> None: + from bastd.ui.settings import controls + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (controls.ControlsSettingsWindow( + transition='in_left').get_root_widget()) + _ba.set_touchscreen_editing(False) diff --git a/assets/src/data/scripts/bastd/ui/settings/vrtesting.py b/assets/src/data/scripts/bastd/ui/settings/vrtesting.py new file mode 100644 index 00000000..8418c0cd --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/vrtesting.py @@ -0,0 +1,88 @@ +"""Provides UI for testing vr settings.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.ui.settings import testing + +if TYPE_CHECKING: + from typing import Any, Dict, List + + +class VRTestingWindow(testing.TestingWindow): + """Window for testing vr settings.""" + + def __init__(self, transition: str = 'in_right'): + + entries: List[Dict[str, Any]] = [] + app = ba.app + # these are gear-vr only + if app.platform == 'android' and app.subplatform == 'oculus': + entries += [ + { + 'name': 'timeWarpDebug', + 'label': 'Time Warp Debug', + 'increment': 1.0 + }, + { + 'name': 'chromaticAberrationCorrection', + 'label': 'Chromatic Aberration Correction', + 'increment': 1.0 + }, + { + 'name': 'vrMinimumVSyncs', + 'label': 'Minimum Vsyncs', + 'increment': 1.0 + }, + # {'name':'eyeOffsX','label':'Eye IPD','increment':0.001} + ] + # cardboard/gearvr get eye offset controls.. + # if app.platform == 'android': + # entries += [ + # {'name':'eyeOffsY','label':'Eye Offset Y','increment':0.01}, + # {'name':'eyeOffsZ','label':'Eye Offset Z','increment':0.005}] + # everyone gets head-scale + entries += [{ + 'name': 'headScale', + 'label': 'Head Scale', + 'increment': 1.0 + }] + # and everyone gets all these.. + entries += [ + { + 'name': 'vrCamOffsetY', + 'label': 'In-Game Cam Offset Y', + 'increment': 0.1 + }, + { + 'name': 'vrCamOffsetZ', + 'label': 'In-Game Cam Offset Z', + 'increment': 0.1 + }, + { + 'name': 'vrOverlayScale', + 'label': 'Overlay Scale', + 'increment': 0.025 + }, + { + 'name': 'allowCameraMovement', + 'label': 'Allow Camera Movement', + 'increment': 1.0 + }, + { + 'name': 'cameraPanSpeedScale', + 'label': 'Camera Movement Speed', + 'increment': 0.1 + }, + { + 'name': 'showOverlayBounds', + 'label': 'Show Overlay Bounds', + 'increment': 1 + }, + ] + + super().__init__( + ba.Lstr(resource='settingsWindowAdvanced.vrTestingText'), entries, + transition) diff --git a/assets/src/data/scripts/bastd/ui/settings/wiimote.py b/assets/src/data/scripts/bastd/ui/settings/wiimote.py new file mode 100644 index 00000000..649d12f5 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/wiimote.py @@ -0,0 +1,248 @@ +"""Settings UI functionality related to wiimote support.""" +from __future__ import annotations + +import _ba +import ba + + +class WiimoteSettingsWindow(ba.OldWindow): + """Window for setting up Wiimotes.""" + + def __init__(self) -> None: + self._r = 'wiimoteSetupWindow' + width = 600 + height = 480 + spacing = 40 + super().__init__(root_widget=ba.containerwidget(size=(width, height), + transition='in_right')) + + btn = ba.buttonwidget(parent=self._root_widget, + position=(55, height - 50), + size=(120, 60), + scale=0.8, + autoselect=True, + label=ba.Lstr(resource='backText'), + button_type='back', + on_activate_call=self._back) + + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, height - 28), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.titleText'), + maxwidth=270, + color=ba.app.title_color, + h_align="center", + v_align="center") + + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + v = height - 60.0 + v -= spacing + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, v - 80), + size=(0, 0), + color=(0.7, 0.9, 0.7, 1.0), + scale=0.75, + text=ba.Lstr(resource=self._r + '.macInstructionsText'), + maxwidth=width * 0.95, + max_height=height * 0.5, + h_align="center", + v_align="center") + v -= 230 + button_width = 200 + v -= 30 + btn = ba.buttonwidget(parent=self._root_widget, + position=(width / 2 - button_width / 2, v + 1), + autoselect=True, + size=(button_width, 50), + label=ba.Lstr(resource=self._r + '.listenText'), + on_activate_call=WiimoteListenWindow) + ba.containerwidget(edit=self._root_widget, start_button=btn) + v -= spacing * 1.1 + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, v), + size=(0, 0), + color=(0.7, 0.9, 0.7, 1.0), + scale=0.8, + maxwidth=width * 0.95, + text=ba.Lstr(resource=self._r + '.thanksText'), + h_align="center", + v_align="center") + v -= 30 + this_button_width = 200 + ba.buttonwidget(parent=self._root_widget, + position=(width / 2 - this_button_width / 2, v - 14), + color=(0.45, 0.4, 0.5), + autoselect=True, + size=(this_button_width, 15), + label=ba.Lstr(resource=self._r + '.copyrightText'), + textcolor=(0.55, 0.5, 0.6), + text_scale=0.6, + on_activate_call=WiimoteLicenseWindow) + + def _back(self) -> None: + from bastd.ui.settings import controls + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (controls.ControlsSettingsWindow( + transition='in_left').get_root_widget()) + + +class WiimoteListenWindow(ba.OldWindow): + """Window shown while listening for a wiimote connection.""" + + def __init__(self) -> None: + self._r = 'wiimoteListenWindow' + width = 650 + height = 210 + super().__init__(root_widget=ba.containerwidget(size=(width, height), + transition='in_right')) + btn = ba.buttonwidget(parent=self._root_widget, + position=(35, height - 60), + size=(140, 60), + autoselect=True, + label=ba.Lstr(resource='cancelText'), + scale=0.8, + on_activate_call=self._dismiss) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + _ba.start_listening_for_wii_remotes() + self._wiimote_connect_counter = 15 + ba.app.dismiss_wii_remotes_window_call = ba.WeakCall(self._dismiss) + ba.textwidget(parent=self._root_widget, + position=(15, height - 55), + size=(width - 30, 30), + text=ba.Lstr(resource=self._r + '.listeningText'), + color=ba.app.title_color, + maxwidth=320, + h_align="center", + v_align="center") + ba.textwidget(parent=self._root_widget, + position=(15, height - 110), + size=(width - 30, 30), + scale=1.0, + text=ba.Lstr(resource=self._r + '.pressText'), + maxwidth=width * 0.9, + color=(0.7, 0.9, 0.7, 1.0), + h_align="center", + v_align="center") + ba.textwidget(parent=self._root_widget, + position=(15, height - 140), + size=(width - 30, 30), + color=(0.7, 0.9, 0.7, 1.0), + scale=0.55, + text=ba.Lstr(resource=self._r + '.pressText2'), + maxwidth=width * 0.95, + h_align="center", + v_align="center") + self._counter_text = ba.textwidget(parent=self._root_widget, + position=(15, 23), + size=(width - 30, 30), + scale=1.2, + text="15", + h_align="center", + v_align="top") + for i in range(1, 15): + ba.timer(1.0 * i, + ba.WeakCall(self._decrement), + timetype=ba.TimeType.REAL) + ba.timer(15.0, ba.WeakCall(self._dismiss), timetype=ba.TimeType.REAL) + + def _decrement(self) -> None: + self._wiimote_connect_counter -= 1 + ba.textwidget(edit=self._counter_text, + text=str(self._wiimote_connect_counter)) + + def _dismiss(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_left') + _ba.stop_listening_for_wii_remotes() + + +class WiimoteLicenseWindow(ba.OldWindow): + """Window displaying the Darwiinremote software license.""" + + def __init__(self) -> None: + self._r = 'wiimoteLicenseWindow' + width = 750 + height = 550 + super().__init__(root_widget=ba.containerwidget(size=(width, height), + transition='in_right')) + btn = ba.buttonwidget(parent=self._root_widget, + position=(65, height - 50), + size=(120, 60), + scale=0.8, + autoselect=True, + label=ba.Lstr(resource='backText'), + button_type='back', + on_activate_call=self._close) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + ba.textwidget(parent=self._root_widget, + position=(0, height - 48), + size=(width, 30), + text=ba.Lstr(resource=self._r + '.titleText'), + h_align="center", + color=ba.app.title_color, + v_align="center") + license_text = ( + 'Copyright (c) 2007, DarwiinRemote Team\n' + 'All rights reserved.\n' + '\n' + ' Redistribution and use in source and binary forms, with or ' + 'without modification,\n' + ' are permitted provided that' + ' the following conditions are met:\n' + '\n' + '1. Redistributions of source code must retain the above copyright' + ' notice, this\n' + ' list of conditions and the following disclaimer.\n' + '2. Redistributions in binary form must reproduce the above' + ' copyright notice, this\n' + ' list of conditions and the following disclaimer in the' + ' documentation and/or other\n' + ' materials provided with the distribution.\n' + '3. Neither the name of this project nor the names of its' + ' contributors may be used to\n' + ' endorse or promote products derived from this software' + ' without specific prior\n' + ' written permission.\n' + '\n' + 'THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND' + ' CONTRIBUTORS "AS IS"\n' + 'AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT' + ' LIMITED TO, THE\n' + 'IMPLIED WARRANTIES OF MERCHANTABILITY' + ' AND FITNESS FOR A PARTICULAR' + ' PURPOSE\n' + 'ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR' + ' CONTRIBUTORS BE\n' + 'LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,' + ' OR\n' + 'CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT' + ' OF\n' + ' SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;' + ' OR BUSINESS\n' + 'INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,' + ' WHETHER IN\n' + 'CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR' + ' OTHERWISE)\n' + 'ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF' + ' ADVISED OF THE\n' + 'POSSIBILITY OF SUCH DAMAGE.\n') + license_text_scale = 0.62 + ba.textwidget(parent=self._root_widget, + position=(100, height * 0.45), + size=(0, 0), + h_align="left", + v_align="center", + padding=4, + color=(0.7, 0.9, 0.7, 1.0), + scale=license_text_scale, + maxwidth=width * 0.9 - 100, + max_height=height * 0.85, + text=license_text) + + def _close(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_right') diff --git a/assets/src/data/scripts/bastd/ui/settings/xbox360controller.py b/assets/src/data/scripts/bastd/ui/settings/xbox360controller.py new file mode 100644 index 00000000..085719e5 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/settings/xbox360controller.py @@ -0,0 +1,110 @@ +"""UI functionality related to using xbox360 controllers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + pass + + +class XBox360ControllerSettingsWindow(ba.OldWindow): + """UI showing info about xbox 360 controllers.""" + + def __init__(self) -> None: + self._r = 'xbox360ControllersWindow' + width = 700 + height = 300 if _ba.is_running_on_fire_tv() else 485 + spacing = 40 + super().__init__(root_widget=ba.containerwidget( + size=(width, height), + transition='in_right', + scale=1.4 if ba.app.small_ui else 1.4 if ba.app.med_ui else 1.0)) + + btn = ba.buttonwidget(parent=self._root_widget, + position=(35, height - 65), + size=(120, 60), + scale=0.84, + label=ba.Lstr(resource='backText'), + button_type='back', + autoselect=True, + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, height - 42), + size=(0, 0), + scale=0.85, + text=ba.Lstr(resource=self._r + '.titleText', + subs=[('${APP_NAME}', + ba.Lstr(resource='titleText'))]), + color=ba.app.title_color, + maxwidth=400, + h_align="center", + v_align="center") + + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + v = height - 70 + v -= spacing + + if _ba.is_running_on_fire_tv(): + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, height * 0.47), + size=(0, 0), + color=(0.7, 0.9, 0.7, 1.0), + maxwidth=width * 0.95, + max_height=height * 0.75, + scale=0.7, + text=ba.Lstr(resource=self._r + + '.ouyaInstructionsText'), + h_align="center", + v_align="center") + else: + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, v - 1), + size=(0, 0), + color=(0.7, 0.9, 0.7, 1.0), + maxwidth=width * 0.95, + max_height=height * 0.22, + text=ba.Lstr(resource=self._r + + '.macInstructionsText'), + scale=0.7, + h_align="center", + v_align="center") + v -= 90 + b_width = 300 + btn = ba.buttonwidget( + parent=self._root_widget, + position=((width - b_width) * 0.5, v - 10), + size=(b_width, 50), + label=ba.Lstr(resource=self._r + '.getDriverText'), + autoselect=True, + on_activate_call=ba.Call( + ba.open_url, + 'https://github.com/360Controller/360Controller/releases')) + ba.containerwidget(edit=self._root_widget, start_button=btn) + v -= 60 + ba.textwidget(parent=self._root_widget, + position=(width * 0.5, v - 85), + size=(0, 0), + color=(0.7, 0.9, 0.7, 1.0), + maxwidth=width * 0.95, + max_height=height * 0.46, + scale=0.7, + text=ba.Lstr(resource=self._r + + '.macInstructions2Text'), + h_align="center", + v_align="center") + + def _back(self) -> None: + from bastd.ui.settings import controls + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (controls.ControlsSettingsWindow( + transition='in_left').get_root_widget()) diff --git a/assets/src/data/scripts/bastd/ui/soundtrack/__init__.py b/assets/src/data/scripts/bastd/ui/soundtrack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assets/src/data/scripts/bastd/ui/soundtrack/browser.py b/assets/src/data/scripts/bastd/ui/soundtrack/browser.py new file mode 100644 index 00000000..2b5c1af3 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/soundtrack/browser.py @@ -0,0 +1,495 @@ +"""Provides UI for browsing soundtracks.""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Optional, List, Tuple, Dict + + +class SoundtrackBrowserWindow(ba.OldWindow): + """Window for browsing soundtracks.""" + + def __init__(self, + transition: str = 'in_right', + origin_widget: ba.Widget = None): + # pylint: disable=too-many-statements + + # If they provided an origin-widget, scale up from that. + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self._r = 'editSoundtrackWindow' + self._width = 800 if ba.app.small_ui else 600 + x_inset = 100 if ba.app.small_ui else 0 + self._height = (340 + if ba.app.small_ui else 370 if ba.app.med_ui else 440) + spacing = 40.0 + v = self._height - 40.0 + v -= spacing * 1.0 + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition=transition, + toolbar_visibility='menu_minimal', + scale_origin_stack_offset=scale_origin, + scale=(2.3 if ba.app.small_ui else 1.6 if ba.app.med_ui else 1.0), + stack_offset=(0, -18) if ba.app.small_ui else (0, 0))) + + if ba.app.toolbars and ba.app.small_ui: + self._back_button = None + else: + self._back_button = ba.buttonwidget( + parent=self._root_widget, + position=(45 + x_inset, self._height - 60), + size=(120, 60), + scale=0.8, + label=ba.Lstr(resource='backText'), + button_type='back', + autoselect=True) + ba.buttonwidget(edit=self._back_button, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 35), + size=(0, 0), + maxwidth=300, + text=ba.Lstr(resource=self._r + '.titleText'), + color=ba.app.title_color, + h_align="center", + v_align="center") + + h = 43 + x_inset + v = self._height - 60 + b_color = (0.6, 0.53, 0.63) + b_textcolor = (0.75, 0.7, 0.8) + lock_tex = ba.gettexture('lock') + self._lock_images: List[ba.Widget] = [] + + scl = (1.0 if ba.app.small_ui else 1.13 if ba.app.med_ui else 1.4) + v -= 60.0 * scl + self._new_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(100, 55.0 * scl), + on_activate_call=self._new_soundtrack, + color=b_color, + button_type='square', + autoselect=True, + textcolor=b_textcolor, + text_scale=0.7, + label=ba.Lstr(resource=self._r + '.newText')) + self._lock_images.append( + ba.imagewidget(parent=self._root_widget, + size=(30, 30), + draw_controller=btn, + position=(h - 10, v + 55.0 * scl - 28), + texture=lock_tex)) + + if self._back_button is None: + ba.widget(edit=btn, + left_widget=_ba.get_special_widget('back_button')) + v -= 60.0 * scl + + self._edit_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(100, 55.0 * scl), + on_activate_call=self._edit_soundtrack, + color=b_color, + button_type='square', + autoselect=True, + textcolor=b_textcolor, + text_scale=0.7, + label=ba.Lstr(resource=self._r + '.editText')) + self._lock_images.append( + ba.imagewidget(parent=self._root_widget, + size=(30, 30), + draw_controller=btn, + position=(h - 10, v + 55.0 * scl - 28), + texture=lock_tex)) + if self._back_button is None: + ba.widget(edit=btn, + left_widget=_ba.get_special_widget('back_button')) + v -= 60.0 * scl + + self._duplicate_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(100, 55.0 * scl), + on_activate_call=self._duplicate_soundtrack, + button_type='square', + autoselect=True, + color=b_color, + textcolor=b_textcolor, + text_scale=0.7, + label=ba.Lstr(resource=self._r + '.duplicateText')) + self._lock_images.append( + ba.imagewidget(parent=self._root_widget, + size=(30, 30), + draw_controller=btn, + position=(h - 10, v + 55.0 * scl - 28), + texture=lock_tex)) + if self._back_button is None: + ba.widget(edit=btn, + left_widget=_ba.get_special_widget('back_button')) + v -= 60.0 * scl + + self._delete_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(100, 55.0 * scl), + on_activate_call=self._delete_soundtrack, + color=b_color, + button_type='square', + autoselect=True, + textcolor=b_textcolor, + text_scale=0.7, + label=ba.Lstr(resource=self._r + '.deleteText')) + self._lock_images.append( + ba.imagewidget(parent=self._root_widget, + size=(30, 30), + draw_controller=btn, + position=(h - 10, v + 55.0 * scl - 28), + texture=lock_tex)) + if self._back_button is None: + ba.widget(edit=btn, + left_widget=_ba.get_special_widget('back_button')) + + # keep our lock images up to date/etc. + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + self._update() + + v = self._height - 65 + scroll_height = self._height - 105 + v -= scroll_height + self._scrollwidget = scrollwidget = ba.scrollwidget( + parent=self._root_widget, + position=(152 + x_inset, v), + highlight=False, + size=(self._width - (205 + 2 * x_inset), scroll_height)) + ba.widget(edit=self._scrollwidget, + left_widget=self._new_button, + right_widget=_ba.get_special_widget('party_button') + if ba.app.toolbars else self._scrollwidget) + self._col = ba.columnwidget(parent=scrollwidget) + + self._soundtracks: Optional[Dict[str, Any]] = None + self._selected_soundtrack: Optional[str] = None + self._selected_soundtrack_index: Optional[int] = None + self._soundtrack_widgets: List[ba.Widget] = [] + self._allow_changing_soundtracks = False + self._refresh() + if self._back_button is not None: + ba.buttonwidget(edit=self._back_button, + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._back_button) + else: + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._back) + + def _update(self) -> None: + from ba.internal import have_pro_options + have = have_pro_options() + for lock in self._lock_images: + ba.imagewidget(edit=lock, opacity=0.0 if have else 1.0) + + def _do_delete_soundtrack(self) -> None: + cfg = ba.app.config + soundtracks = cfg.setdefault('Soundtracks', {}) + if self._selected_soundtrack in soundtracks: + del soundtracks[self._selected_soundtrack] + cfg.commit() + ba.playsound(ba.getsound('shieldDown')) + assert self._selected_soundtrack_index is not None + assert self._soundtracks is not None + if self._selected_soundtrack_index >= len(self._soundtracks): + self._selected_soundtrack_index = len(self._soundtracks) + self._refresh() + + def _delete_soundtrack(self) -> None: + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui import purchase + from bastd.ui import confirm + if not have_pro_options(): + purchase.PurchaseWindow(items=['pro']) + return + if self._selected_soundtrack is None: + return + if self._selected_soundtrack == '__default__': + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource=self._r + + '.cantDeleteDefaultText'), + color=(1, 0, 0)) + else: + confirm.ConfirmWindow( + ba.Lstr(resource=self._r + '.deleteConfirmText', + subs=[("${NAME}", self._selected_soundtrack)]), + self._do_delete_soundtrack, 450, 150) + + def _duplicate_soundtrack(self) -> None: + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui import purchase + if not have_pro_options(): + purchase.PurchaseWindow(items=['pro']) + return + cfg = ba.app.config + cfg.setdefault('Soundtracks', {}) + + if self._selected_soundtrack is None: + return + sdtk: Dict[str, Any] + if self._selected_soundtrack == '__default__': + sdtk = {} + else: + sdtk = cfg['Soundtracks'][self._selected_soundtrack] + + # find a valid dup name that doesn't exist + test_index = 1 + copy_text = ba.Lstr(resource='copyOfText').evaluate() + # get just 'Copy' or whatnot + copy_word = copy_text.replace('${NAME}', '').strip() + base_name = self._get_soundtrack_display_name( + self._selected_soundtrack).evaluate() + if not isinstance(base_name, str): + print('expected uni base_name 3fj0') + assert isinstance(base_name, bytes) + base_name = base_name.decode('utf-8') + + # if it looks like a copy, strip digits and spaces off the end + if copy_word in base_name: + while base_name[-1].isdigit() or base_name[-1] == ' ': + base_name = base_name[:-1] + while True: + if copy_word in base_name: + test_name = base_name + else: + test_name = copy_text.replace('${NAME}', base_name) + if test_index > 1: + test_name += ' ' + str(test_index) + if test_name not in cfg['Soundtracks']: + break + test_index += 1 + + cfg['Soundtracks'][test_name] = copy.deepcopy(sdtk) + cfg.commit() + self._refresh(select_soundtrack=test_name) + + def _select(self, name: str, index: int) -> None: + from ba.internal import do_play_music + self._selected_soundtrack_index = index + self._selected_soundtrack = name + cfg = ba.app.config + current_soundtrack = cfg.setdefault('Soundtrack', '__default__') + + # if it varies from current, commit and play + if current_soundtrack != name and self._allow_changing_soundtracks: + ba.playsound(ba.getsound('gunCocking')) + cfg['Soundtrack'] = self._selected_soundtrack + cfg.commit() + # just play whats already playing.. this'll grab it from the + # new soundtrack + do_play_music(ba.app.music_types['regular']) + + def _back(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings import audio + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = (audio.AudioSettingsWindow( + transition='in_left').get_root_widget()) + + def _edit_soundtrack_with_sound(self) -> None: + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui import purchase + if not have_pro_options(): + purchase.PurchaseWindow(items=['pro']) + return + ba.playsound(ba.getsound('swish')) + self._edit_soundtrack() + + def _edit_soundtrack(self) -> None: + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui import purchase + from bastd.ui.soundtrack import edit as stedit + if not have_pro_options(): + purchase.PurchaseWindow(items=['pro']) + return + if self._selected_soundtrack is None: + return + if self._selected_soundtrack == '__default__': + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource=self._r + + '.cantEditDefaultText'), + color=(1, 0, 0)) + return + + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (stedit.SoundtrackEditWindow( + existing_soundtrack=self._selected_soundtrack).get_root_widget()) + + def _get_soundtrack_display_name(self, soundtrack: str) -> ba.Lstr: + if soundtrack == '__default__': + return ba.Lstr(resource=self._r + '.defaultSoundtrackNameText') + return ba.Lstr(value=soundtrack) + + def _refresh(self, select_soundtrack: str = None) -> None: + self._allow_changing_soundtracks = False + old_selection = self._selected_soundtrack + + # If there was no prev selection, look in prefs. + if old_selection is None: + try: + old_selection = ba.app.config['Soundtrack'] + except Exception: + pass + old_selection_index = self._selected_soundtrack_index + + # Delete old. + while self._soundtrack_widgets: + self._soundtrack_widgets.pop().delete() + try: + self._soundtracks = ba.app.config['Soundtracks'] + except Exception: + self._soundtracks = {} + assert self._soundtracks is not None + items = list(self._soundtracks.items()) + items.sort(key=lambda x: x[0].lower()) + items = [('__default__', None)] + items # default is always first + index = 0 + for pname, _pval in items: + assert pname is not None + txtw = ba.textwidget( + parent=self._col, + size=(self._width - 40, 24), + text=self._get_soundtrack_display_name(pname), + h_align='left', + v_align='center', + maxwidth=self._width - 110, + always_highlight=True, + on_select_call=ba.WeakCall(self._select, pname, index), + on_activate_call=self._edit_soundtrack_with_sound, + selectable=True) + if index == 0: + ba.widget(edit=txtw, up_widget=self._back_button) + self._soundtrack_widgets.append(txtw) + + # Select this one if the user requested it + if select_soundtrack is not None: + if pname == select_soundtrack: + ba.columnwidget(edit=self._col, + selected_child=txtw, + visible_child=txtw) + else: + # Select this one if it was previously selected. + # Go by index if there's one. + if old_selection_index is not None: + if index == old_selection_index: + ba.columnwidget(edit=self._col, + selected_child=txtw, + visible_child=txtw) + else: # Otherwise look by name. + if pname == old_selection: + ba.columnwidget(edit=self._col, + selected_child=txtw, + visible_child=txtw) + index += 1 + + # Explicitly run select callback on current one and re-enable + # callbacks. + + # Eww need to run this in a timer so it happens after our select + # callbacks. With a small-enough time sometimes it happens before + # anyway. Ew. need a way to just schedule a callable i guess. + ba.timer(0.1, + ba.WeakCall(self._set_allow_changing), + timetype=ba.TimeType.REAL) + + def _set_allow_changing(self) -> None: + self._allow_changing_soundtracks = True + assert self._selected_soundtrack is not None + assert self._selected_soundtrack_index is not None + self._select(self._selected_soundtrack, + self._selected_soundtrack_index) + + def _new_soundtrack(self) -> None: + # pylint: disable=cyclic-import + from ba.internal import have_pro_options + from bastd.ui import purchase + from bastd.ui.soundtrack import edit as stedit + if not have_pro_options(): + purchase.PurchaseWindow(items=['pro']) + return + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + stedit.SoundtrackEditWindow(existing_soundtrack=None) + + def _create_done(self, new_soundtrack: str) -> None: + if new_soundtrack is not None: + ba.playsound(ba.getsound('gunCocking')) + self._refresh(select_soundtrack=new_soundtrack) + + def _save_state(self) -> None: + try: + sel = self._root_widget.get_selected_child() + if sel == self._scrollwidget: + sel_name = 'Scroll' + elif sel == self._new_button: + sel_name = 'New' + elif sel == self._edit_button: + sel_name = 'Edit' + elif sel == self._duplicate_button: + sel_name = 'Duplicate' + elif sel == self._delete_button: + sel_name = 'Delete' + elif sel == self._back_button: + sel_name = 'Back' + else: + raise Exception("unrecognized selection") + ba.app.window_states[self.__class__.__name__] = sel_name + except Exception: + ba.print_exception('error saving state for', self.__class__) + + def _restore_state(self) -> None: + try: + try: + sel_name = ba.app.window_states[self.__class__.__name__] + except Exception: + sel_name = None + if sel_name == 'Scroll': + sel = self._scrollwidget + elif sel_name == 'New': + sel = self._new_button + elif sel_name == 'Edit': + sel = self._edit_button + elif sel_name == 'Duplicate': + sel = self._duplicate_button + elif sel_name == 'Delete': + sel = self._delete_button + else: + sel = self._scrollwidget + ba.containerwidget(edit=self._root_widget, selected_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) diff --git a/assets/src/data/scripts/bastd/ui/soundtrack/edit.py b/assets/src/data/scripts/bastd/ui/soundtrack/edit.py new file mode 100644 index 00000000..315115bc --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/soundtrack/edit.py @@ -0,0 +1,401 @@ +"""Provides UI for editing a soundtrack.""" + +from __future__ import annotations + +import copy +import os +from typing import TYPE_CHECKING, cast + +import ba + +if TYPE_CHECKING: + from typing import Any, Dict, Union, Optional + + +class SoundtrackEditWindow(ba.OldWindow): + """Window for editing a soundtrack.""" + + def __init__(self, + existing_soundtrack: Optional[Union[str, Dict[str, Any]]], + transition: str = 'in_right'): + # pylint: disable=too-many-statements + bs_config = ba.app.config + self._r = 'editSoundtrackWindow' + self._folder_tex = ba.gettexture('folder') + self._file_tex = ba.gettexture('file') + self._width = 848 if ba.app.small_ui else 648 + x_inset = 100 if ba.app.small_ui else 0 + self._height = (395 + if ba.app.small_ui else 450 if ba.app.med_ui else 560) + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition=transition, + scale=(2.08 if ba.app.small_ui else 1.5 if ba.app.med_ui else 1.0), + stack_offset=(0, -48) if ba.app.small_ui else ( + 0, 15) if ba.app.med_ui else (0, 0))) + cancel_button = ba.buttonwidget(parent=self._root_widget, + position=(38 + x_inset, + self._height - 60), + size=(160, 60), + autoselect=True, + label=ba.Lstr(resource='cancelText'), + scale=0.8) + save_button = ba.buttonwidget(parent=self._root_widget, + position=(self._width - (168 + x_inset), + self._height - 60), + autoselect=True, + size=(160, 60), + label=ba.Lstr(resource='saveText'), + scale=0.8) + ba.widget(edit=save_button, left_widget=cancel_button) + ba.widget(edit=cancel_button, right_widget=save_button) + ba.textwidget( + parent=self._root_widget, + position=(0, self._height - 50), + size=(self._width, 25), + text=ba.Lstr( + resource=self._r + + ('.editSoundtrackText' if existing_soundtrack is not None else + '.newSoundtrackText')), + color=ba.app.title_color, + h_align="center", + v_align="center", + maxwidth=280) + v = self._height - 110 + if 'Soundtracks' not in bs_config: + bs_config['Soundtracks'] = {} + + self._soundtrack_name: Optional[str] + self._existing_soundtrack_name: Optional[str] + if existing_soundtrack is not None: + # if they passed just a name, pull info from that soundtrack + if isinstance(existing_soundtrack, str): + self._soundtrack = copy.deepcopy( + bs_config['Soundtracks'][existing_soundtrack]) + self._soundtrack_name = existing_soundtrack + self._existing_soundtrack_name = existing_soundtrack + self._last_edited_song_type = None + else: + # otherwise they can pass info on an in-progress edit + self._soundtrack = existing_soundtrack['soundtrack'] + self._soundtrack_name = existing_soundtrack['name'] + self._existing_soundtrack_name = ( + existing_soundtrack['existing_name']) + self._last_edited_song_type = ( + existing_soundtrack['last_edited_song_type']) + else: + self._soundtrack_name = None + self._existing_soundtrack_name = None + self._soundtrack = {} + self._last_edited_song_type = None + + ba.textwidget(parent=self._root_widget, + text=ba.Lstr(resource=self._r + '.nameText'), + maxwidth=80, + scale=0.8, + position=(105 + x_inset, v + 19), + color=(0.8, 0.8, 0.8, 0.5), + size=(0, 0), + h_align='right', + v_align='center') + + # if there's no initial value, find a good initial unused name + if existing_soundtrack is None: + i = 1 + st_name_text = ba.Lstr(resource=self._r + + '.newSoundtrackNameText').evaluate() + if '${COUNT}' not in st_name_text: + # make sure we insert number *somewhere* + st_name_text = st_name_text + ' ${COUNT}' + while True: + self._soundtrack_name = st_name_text.replace( + '${COUNT}', str(i)) + if self._soundtrack_name not in bs_config['Soundtracks']: + break + i += 1 + + self._text_field = ba.textwidget( + parent=self._root_widget, + position=(120 + x_inset, v - 5), + size=(self._width - (160 + 2 * x_inset), 43), + text=self._soundtrack_name, + h_align="left", + v_align="center", + max_chars=32, + autoselect=True, + description=ba.Lstr(resource=self._r + '.nameText'), + editable=True, + padding=4, + on_return_press_call=self._do_it_with_sound) + + scroll_height = self._height - 180 + self._scrollwidget = scrollwidget = ba.scrollwidget( + parent=self._root_widget, + highlight=False, + position=(40 + x_inset, v - (scroll_height + 10)), + size=(self._width - (80 + 2 * x_inset), scroll_height), + simple_culling_v=10) + ba.widget(edit=self._text_field, down_widget=self._scrollwidget) + self._col = ba.columnwidget(parent=scrollwidget) + + ba.containerwidget(edit=self._scrollwidget, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + ba.containerwidget(edit=self._col, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + + self._song_type_buttons: Dict[str, ba.Widget] = {} + self._refresh() + ba.buttonwidget(edit=cancel_button, on_activate_call=self._cancel) + ba.containerwidget(edit=self._root_widget, cancel_button=cancel_button) + ba.buttonwidget(edit=save_button, on_activate_call=self._do_it) + ba.containerwidget(edit=self._root_widget, start_button=save_button) + ba.widget(edit=self._text_field, up_widget=cancel_button) + ba.widget(edit=cancel_button, down_widget=self._text_field) + + def _refresh(self) -> None: + from ba.deprecated import get_resource + for widget in self._col.get_children(): + widget.delete() + + types = [ + 'Menu', + 'CharSelect', + 'ToTheDeath', + 'Onslaught', + 'Keep Away', + 'Race', + 'Epic Race', + 'ForwardMarch', + 'FlagCatcher', + 'Survival', + 'Epic', + 'Hockey', + 'Football', + 'Flying', + 'Scary', + 'Marching', + 'GrandRomp', + 'Chosen One', + 'Scores', + 'Victory', + ] + # FIXME: We should probably convert this to use translations. + type_names_translated = get_resource('soundtrackTypeNames') + prev_type_button = None + prev_test_button = None + + for index, song_type in enumerate(types): + row = ba.rowwidget(parent=self._col, size=(self._width - 40, 40)) + ba.containerwidget(edit=row, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + try: + type_name = type_names_translated[song_type] + except Exception: + type_name = song_type + ba.textwidget(parent=row, + size=(230, 25), + always_highlight=True, + text=type_name, + scale=0.7, + h_align='left', + v_align='center', + maxwidth=190) + + if song_type in self._soundtrack: + entry = self._soundtrack[song_type] + else: + entry = None + + if entry is not None: + # make sure they don't muck with this after it gets to us + entry = copy.deepcopy(entry) + + icon_type = self._get_entry_button_display_icon_type(entry) + self._song_type_buttons[song_type] = btn = ba.buttonwidget( + parent=row, + size=(230, 32), + label=self._get_entry_button_display_name(entry), + text_scale=0.6, + on_activate_call=ba.Call(self._get_entry, song_type, entry, + type_name), + icon=(self._file_tex if icon_type == 'file' else + self._folder_tex if icon_type == 'folder' else None), + icon_color=(1.1, 0.8, 0.2) if icon_type == 'folder' else + (1, 1, 1), + left_widget=self._text_field, + iconscale=0.7, + autoselect=True, + up_widget=prev_type_button) + if index == 0: + ba.widget(edit=btn, up_widget=self._text_field) + ba.widget(edit=btn, down_widget=btn) + + if (self._last_edited_song_type is not None + and song_type == self._last_edited_song_type): + ba.containerwidget(edit=row, + selected_child=btn, + visible_child=btn) + ba.containerwidget(edit=self._col, + selected_child=row, + visible_child=row) + ba.containerwidget(edit=self._scrollwidget, + selected_child=self._col, + visible_child=self._col) + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget, + visible_child=self._scrollwidget) + + if prev_type_button is not None: + ba.widget(edit=prev_type_button, down_widget=btn) + prev_type_button = btn + ba.textwidget(parent=row, size=(10, 32), text='') # spacing + btn = ba.buttonwidget( + parent=row, + size=(50, 32), + label=ba.Lstr(resource=self._r + '.testText'), + text_scale=0.6, + on_activate_call=ba.Call(self._test, song_type), + up_widget=prev_test_button + if prev_test_button is not None else self._text_field) + if prev_test_button is not None: + ba.widget(edit=prev_test_button, down_widget=btn) + ba.widget(edit=btn, down_widget=btn, right_widget=btn) + prev_test_button = btn + + @classmethod + def _restore_editor(cls, state: Dict[str, Any], musictype: str, + entry: Any) -> None: + from ba.internal import get_soundtrack_entry_type + # Apply the change and recreate the window. + soundtrack = state['soundtrack'] + existing_entry = (None if musictype not in soundtrack else + soundtrack[musictype]) + if existing_entry != entry: + ba.playsound(ba.getsound('gunCocking')) + + # Make sure this doesn't get mucked with after we get it. + if entry is not None: + entry = copy.deepcopy(entry) + + entry_type = get_soundtrack_entry_type(entry) + if entry_type == 'default': + # For 'default' entries simply exclude them from the list. + if musictype in soundtrack: + del soundtrack[musictype] + else: + soundtrack[musictype] = entry + + ba.app.main_menu_window = (cls(state, + transition='in_left').get_root_widget()) + + def _get_entry(self, song_type: str, entry: Any, + selection_target_name: str) -> None: + from ba.internal import get_music_player + if selection_target_name != '': + selection_target_name = "'" + selection_target_name + "'" + state = { + 'name': self._soundtrack_name, + 'existing_name': self._existing_soundtrack_name, + 'soundtrack': self._soundtrack, + 'last_edited_song_type': song_type + } + ba.containerwidget(edit=self._root_widget, transition='out_left') + ba.app.main_menu_window = (get_music_player().select_entry( + ba.Call(self._restore_editor, state, song_type), entry, + selection_target_name).get_root_widget()) + + def _test(self, song_type: str) -> None: + from ba.internal import set_music_play_mode, do_play_music + + # Warn if volume is zero. + if ba.app.config.resolve("Music Volume") < 0.01: + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource=self._r + + '.musicVolumeZeroWarning'), + color=(1, 0.5, 0)) + set_music_play_mode('test') + do_play_music(song_type, mode='test', testsoundtrack=self._soundtrack) + + def _get_entry_button_display_name(self, + entry: Any) -> Union[str, ba.Lstr]: + from ba.internal import (get_soundtrack_entry_type, + get_soundtrack_entry_name) + etype = get_soundtrack_entry_type(entry) + ename: Union[str, ba.Lstr] + if etype == 'default': + ename = ba.Lstr(resource=self._r + '.defaultGameMusicText') + elif etype in ('musicFile', 'musicFolder'): + ename = os.path.basename(get_soundtrack_entry_name(entry)) + else: + ename = get_soundtrack_entry_name(entry) + return ename + + def _get_entry_button_display_icon_type(self, entry: Any) -> Optional[str]: + from ba.internal import get_soundtrack_entry_type + etype = get_soundtrack_entry_type(entry) + if etype == 'musicFile': + return 'file' + if etype == 'musicFolder': + return 'folder' + return None + + def _cancel(self) -> None: + from ba.internal import set_music_play_mode + from bastd.ui.soundtrack import browser as stb + # Resets music back to normal. + set_music_play_mode('regular') + ba.containerwidget(edit=self._root_widget, transition='out_right') + ba.app.main_menu_window = (stb.SoundtrackBrowserWindow( + transition='in_left').get_root_widget()) + + def _do_it(self) -> None: + from ba.internal import set_music_play_mode + from bastd.ui.soundtrack import browser as stb + cfg = ba.app.config + new_name = cast(str, ba.textwidget(query=self._text_field)) + if (new_name != self._soundtrack_name + and new_name in cfg['Soundtracks']): + ba.screenmessage( + ba.Lstr(resource=self._r + '.cantSaveAlreadyExistsText')) + ba.playsound(ba.getsound('error')) + return + if not new_name: + ba.playsound(ba.getsound('error')) + return + if new_name == ba.Lstr(resource=self._r + + '.defaultSoundtrackNameText').evaluate(): + ba.screenmessage( + ba.Lstr(resource=self._r + '.cantOverwriteDefaultText')) + ba.playsound(ba.getsound('error')) + return + + # Make sure config exists. + if 'Soundtracks' not in cfg: + cfg['Soundtracks'] = {} + + # If we had an old one, delete it. + if (self._existing_soundtrack_name is not None + and self._existing_soundtrack_name in cfg['Soundtracks']): + del cfg['Soundtracks'][self._existing_soundtrack_name] + cfg['Soundtracks'][new_name] = self._soundtrack + cfg['Soundtrack'] = new_name + + cfg.commit() + ba.playsound(ba.getsound('gunCocking')) + ba.containerwidget(edit=self._root_widget, transition='out_right') + + # Resets music back to normal. + set_music_play_mode('regular', force_restart=True) + + ba.app.main_menu_window = (stb.SoundtrackBrowserWindow( + transition='in_left').get_root_widget()) + + def _do_it_with_sound(self) -> None: + ba.playsound(ba.getsound('swish')) + self._do_it() diff --git a/assets/src/data/scripts/bastd/ui/soundtrack/entrytypeselect.py b/assets/src/data/scripts/bastd/ui/soundtrack/entrytypeselect.py new file mode 100644 index 00000000..2a4c0541 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/soundtrack/entrytypeselect.py @@ -0,0 +1,185 @@ +"""Provides UI for selecting soundtrack entry types.""" +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + + +class SoundtrackEntryTypeSelectWindow(ba.OldWindow): + """Window for selecting a soundtrack entry type.""" + + def __init__(self, + callback: Callable[[Any], Any], + current_entry: Any, + selection_target_name: str, + transition: str = 'in_right'): + from ba.internal import (get_soundtrack_entry_type, + supports_soundtrack_entry_type) + self._r = 'editSoundtrackWindow' + + self._callback = callback + self._current_entry = copy.deepcopy(current_entry) + + self._width = 580 + self._height = 220 + spacing = 80 + + do_default = True + do_itunes_playlist = supports_soundtrack_entry_type('iTunesPlaylist') + do_music_file = supports_soundtrack_entry_type('musicFile') + do_music_folder = supports_soundtrack_entry_type('musicFolder') + + if do_itunes_playlist: + self._height += spacing + if do_music_file: + self._height += spacing + if do_music_folder: + self._height += spacing + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition=transition, + scale=1.7 if ba.app.small_ui else 1.4 if ba.app.med_ui else 1.0)) + btn = ba.buttonwidget(parent=self._root_widget, + position=(35, self._height - 65), + size=(160, 60), + scale=0.8, + text_scale=1.2, + label=ba.Lstr(resource='cancelText'), + on_activate_call=self._on_cancel_press) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 32), + size=(0, 0), + text=ba.Lstr(resource=self._r + '.selectASourceText'), + color=ba.app.title_color, + maxwidth=230, + h_align="center", + v_align="center") + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 56), + size=(0, 0), + text=selection_target_name, + color=ba.app.infotextcolor, + scale=0.7, + maxwidth=230, + h_align="center", + v_align="center") + + v = self._height - 155 + + current_entry_type = get_soundtrack_entry_type(current_entry) + + if do_default: + btn = ba.buttonwidget(parent=self._root_widget, + size=(self._width - 100, 60), + position=(50, v), + label=ba.Lstr(resource=self._r + + '.useDefaultGameMusicText'), + on_activate_call=self._on_default_press) + if current_entry_type == 'default': + ba.containerwidget(edit=self._root_widget, selected_child=btn) + v -= spacing + + if do_itunes_playlist: + btn = ba.buttonwidget( + parent=self._root_widget, + size=(self._width - 100, 60), + position=(50, v), + label=ba.Lstr(resource=self._r + '.useITunesPlaylistText'), + on_activate_call=self._on_itunes_playlist_press, + icon=None) + if current_entry_type == 'iTunesPlaylist': + ba.containerwidget(edit=self._root_widget, selected_child=btn) + v -= spacing + + if do_music_file: + btn = ba.buttonwidget(parent=self._root_widget, + size=(self._width - 100, 60), + position=(50, v), + label=ba.Lstr(resource=self._r + + '.useMusicFileText'), + on_activate_call=self._on_music_file_press, + icon=ba.gettexture('file')) + if current_entry_type == 'musicFile': + ba.containerwidget(edit=self._root_widget, selected_child=btn) + v -= spacing + + if do_music_folder: + btn = ba.buttonwidget(parent=self._root_widget, + size=(self._width - 100, 60), + position=(50, v), + label=ba.Lstr(resource=self._r + + '.useMusicFolderText'), + on_activate_call=self._on_music_folder_press, + icon=ba.gettexture('folder'), + icon_color=(1.1, 0.8, 0.2)) + if current_entry_type == 'musicFolder': + ba.containerwidget(edit=self._root_widget, selected_child=btn) + v -= spacing + + def _on_itunes_playlist_press(self) -> None: + from ba.internal import (get_soundtrack_entry_type, + get_soundtrack_entry_name) + from bastd.ui.soundtrack import itunes + ba.containerwidget(edit=self._root_widget, transition='out_left') + + current_playlist_entry: Optional[str] + if get_soundtrack_entry_type(self._current_entry) == 'iTunesPlaylist': + current_playlist_entry = get_soundtrack_entry_name( + self._current_entry) + else: + current_playlist_entry = None + ba.app.main_menu_window = (itunes.ITunesPlaylistSelectWindow( + self._callback, current_playlist_entry, + self._current_entry).get_root_widget()) + + def _on_music_file_press(self) -> None: + from ba.internal import get_valid_music_file_extensions + from bastd.ui import fileselector + ba.containerwidget(edit=self._root_widget, transition='out_left') + base_path = _ba.android_get_external_storage_path() + ba.app.main_menu_window = (fileselector.FileSelectorWindow( + base_path, + callback=self._music_file_selector_cb, + show_base_path=False, + valid_file_extensions=get_valid_music_file_extensions(), + allow_folders=False).get_root_widget()) + + def _on_music_folder_press(self) -> None: + from bastd.ui import fileselector + ba.containerwidget(edit=self._root_widget, transition='out_left') + base_path = _ba.android_get_external_storage_path() + ba.app.main_menu_window = (fileselector.FileSelectorWindow( + base_path, + callback=self._music_folder_selector_cb, + show_base_path=False, + valid_file_extensions=[], + allow_folders=True).get_root_widget()) + + def _music_file_selector_cb(self, result: Optional[str]) -> None: + if result is None: + self._callback(self._current_entry) + else: + self._callback({'type': 'musicFile', 'name': result}) + + def _music_folder_selector_cb(self, result: Optional[str]) -> None: + if result is None: + self._callback(self._current_entry) + else: + self._callback({'type': 'musicFolder', 'name': result}) + + def _on_default_press(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_right') + self._callback(None) + + def _on_cancel_press(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_right') + self._callback(self._current_entry) diff --git a/assets/src/data/scripts/bastd/ui/soundtrack/itunes.py b/assets/src/data/scripts/bastd/ui/soundtrack/itunes.py new file mode 100644 index 00000000..61773d9c --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/soundtrack/itunes.py @@ -0,0 +1,98 @@ +"""UI functionality related to using iTunes for soundtracks.""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, List, Optional, Callable + + +class ITunesPlaylistSelectWindow(ba.OldWindow): + """Window for selecting an iTunes playlist.""" + + def __init__(self, callback: Callable[[Any], Any], + existing_playlist: Optional[str], existing_entry: Any): + from ba.internal import get_music_player, MacITunesMusicPlayer + self._r = 'editSoundtrackWindow' + self._callback = callback + self._existing_playlist = existing_playlist + self._existing_entry = copy.deepcopy(existing_entry) + self._width = 520.0 + self._height = 520.0 + self._spacing = 45.0 + v = self._height - 90.0 + v -= self._spacing * 1.0 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), transition='in_right')) + btn = ba.buttonwidget(parent=self._root_widget, + position=(35, self._height - 65), + size=(130, 50), + label=ba.Lstr(resource='cancelText'), + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + ba.textwidget(parent=self._root_widget, + position=(20, self._height - 54), + size=(self._width, 25), + text=ba.Lstr(resource=self._r + '.selectAPlaylistText'), + color=ba.app.title_color, + h_align="center", + v_align="center", + maxwidth=200) + self._scrollwidget = ba.scrollwidget(parent=self._root_widget, + position=(40, v - 340), + size=(self._width - 80, 400)) + self._column = ba.columnwidget(parent=self._scrollwidget) + + # So selection loops through everything and doesn't get stuck + # in sub-containers. + ba.containerwidget(edit=self._scrollwidget, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + ba.containerwidget(edit=self._column, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + + ba.textwidget(parent=self._column, + size=(self._width - 80, 22), + text=ba.Lstr(resource=self._r + '.fetchingITunesText'), + color=(0.6, 0.9, 0.6, 1.0), + scale=0.8) + musicplayer = get_music_player() + assert isinstance(musicplayer, MacITunesMusicPlayer) + musicplayer.get_playlists(self._playlists_cb) + ba.containerwidget(edit=self._root_widget, + selected_child=self._scrollwidget) + + def _playlists_cb(self, playlists: List[str]) -> None: + if self._column: + for widget in self._column.get_children(): + widget.delete() + for playlist in playlists: + txt = ba.textwidget(parent=self._column, + size=(self._width - 80, 30), + text=playlist, + v_align='center', + maxwidth=self._width - 110, + selectable=True, + on_activate_call=ba.Call( + self._sel, playlist), + click_activate=True) + if playlist == self._existing_playlist: + ba.columnwidget(edit=self._column, + selected_child=txt, + visible_child=txt) + + def _sel(self, selection: str) -> None: + if self._root_widget: + ba.containerwidget(edit=self._root_widget, transition='out_right') + self._callback({'type': 'iTunesPlaylist', 'name': selection}) + + def _back(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_right') + self._callback(self._existing_entry) diff --git a/assets/src/data/scripts/bastd/ui/specialoffer.py b/assets/src/data/scripts/bastd/ui/specialoffer.py new file mode 100644 index 00000000..c2a9cb5c --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/specialoffer.py @@ -0,0 +1,446 @@ +"""UI for presenting sales/etc.""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Dict, Optional, Union + + +class SpecialOfferWindow(ba.OldWindow): + """Window for presenting sales/etc.""" + + def __init__(self, offer: Dict[str, Any], transition: str = 'in_right'): + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + from ba.internal import (get_store_item_display_size, get_clean_price) + from ba import SpecialChar + from bastd.ui.store import item as storeitemui + self._cancel_delay = offer.get('cancelDelay', 0) + + # First thing: if we're offering pro or an IAP, see if we have a + # price for it. + # If not, abort and go into zombie mode (the user should never see + # us that way). + + real_price: Optional[str] + + # Misnomer: 'pro' actually means offer 'pro_sale'. + if offer['item'] in ['pro', 'pro_fullprice']: + real_price = _ba.get_price('pro' if offer['item'] == + 'pro_fullprice' else 'pro_sale') + if real_price is None and ba.app.debug_build: + print('TEMP FAKING REAL PRICE') + real_price = '$1.23' + zombie = real_price is None + elif isinstance(offer['price'], str): # a string price implies IAP id + real_price = _ba.get_price(offer['price']) + zombie = real_price is None + else: + real_price = None + zombie = False + if real_price is None: + real_price = '?' + + if offer['item'] in ['pro', 'pro_fullprice']: + self._offer_item = 'pro' + else: + self._offer_item = offer['item'] + + # If we wanted a real price but didn't find one, go zombie. + if zombie: + return + + # This can pop up suddenly, so lets block input for 1 second. + _ba.lock_all_input() + ba.timer(1.0, _ba.unlock_all_input, timetype=ba.TimeType.REAL) + ba.playsound(ba.getsound('ding')) + ba.timer(0.3, + lambda: ba.playsound(ba.getsound('ooh')), + timetype=ba.TimeType.REAL) + self._offer = copy.deepcopy(offer) + self._width = 580 + self._height = 590 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition=transition, + scale=(1.2 if ba.app.small_ui else 1.15 if ba.app.med_ui else 1.0), + stack_offset=(0, -15) if ba.app.small_ui else (0, 0))) + self._is_bundle_sale = False + try: + if offer['item'] in ['pro', 'pro_fullprice']: + original_price_str = _ba.get_price('pro') + if original_price_str is None: + original_price_str = '?' + new_price_str = _ba.get_price('pro_sale') + if new_price_str is None: + new_price_str = '?' + percent_off_text = '' + else: + # If the offer includes bonus tickets, it's a bundle-sale. + if ('bonusTickets' in offer + and offer['bonusTickets'] is not None): + self._is_bundle_sale = True + original_price = _ba.get_account_misc_read_val( + 'price.' + self._offer_item, 9999) + new_price = offer['price'] + tchar = ba.charstr(SpecialChar.TICKET) + original_price_str = tchar + str(original_price) + new_price_str = tchar + str(new_price) + percent_off = int( + round(100.0 - (float(new_price) / original_price) * 100.0)) + percent_off_text = ' ' + ba.Lstr( + resource='store.salePercentText').evaluate().replace( + '${PERCENT}', str(percent_off)) + except Exception: + original_price_str = new_price_str = '?' + percent_off_text = '' + + # If its a bundle sale, change the title. + if self._is_bundle_sale: + sale_text = ba.Lstr(resource='store.saleBundleText', + fallback_resource='store.saleText').evaluate() + else: + # For full pro we say 'Upgrade?' since its not really a sale. + if offer['item'] == 'pro_fullprice': + sale_text = ba.Lstr( + resource='store.upgradeQuestionText', + fallback_resource='store.saleExclaimText').evaluate() + else: + sale_text = ba.Lstr( + resource='store.saleExclaimText', + fallback_resource='store.saleText').evaluate() + + self._title_text = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height - 40), + size=(0, 0), + text=sale_text + + ((' ' + ba.Lstr(resource='store.oneTimeOnlyText').evaluate()) + if self._offer['oneTimeOnly'] else '') + percent_off_text, + h_align='center', + v_align='center', + maxwidth=self._width * 0.9 - 220, + scale=1.4, + color=(0.3, 1, 0.3)) + + self._flash_on = False + self._flashing_timer: Optional[ba.Timer] = ba.Timer( + 0.05, + ba.WeakCall(self._flash_cycle), + repeat=True, + timetype=ba.TimeType.REAL) + ba.timer(0.6, + ba.WeakCall(self._stop_flashing), + timetype=ba.TimeType.REAL) + + size = get_store_item_display_size(self._offer_item) + display: Dict[str, Any] = {} + storeitemui.instantiate_store_item_display( + self._offer_item, + display, + parent_widget=self._root_widget, + b_pos=(self._width * 0.5 - size[0] * 0.5 + 10 - + ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0), + self._height * 0.5 - size[1] * 0.5 + 20 + + (20 if self._is_bundle_sale else 0)), + b_width=size[0], + b_height=size[1], + button=not self._is_bundle_sale) + + # Wire up the parts we need. + if self._is_bundle_sale: + self._plus_text = ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, + self._height * 0.5 + 50), + size=(0, 0), + text='+', + h_align='center', + v_align='center', + maxwidth=self._width * 0.9, + scale=1.4, + color=(0.5, 0.5, 0.5)) + self._plus_tickets = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5 + 120, self._height * 0.5 + 50), + size=(0, 0), + text=ba.charstr(SpecialChar.TICKET_BACKING) + + str(offer['bonusTickets']), + h_align='center', + v_align='center', + maxwidth=self._width * 0.9, + scale=2.5, + color=(0.2, 1, 0.2)) + self._price_text = ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, 150), + size=(0, 0), + text=real_price, + h_align='center', + v_align='center', + maxwidth=self._width * 0.9, + scale=1.4, + color=(0.2, 1, 0.2)) + # Total-value if they supplied it. + total_worth_item = offer.get('valueItem', None) + if total_worth_item is not None: + total_worth_price = get_clean_price( + _ba.get_price(total_worth_item)) + if total_worth_price is not None: + total_worth_text = ba.Lstr(resource='store.totalWorthText', + subs=[('${TOTAL_WORTH}', + total_worth_price)]) + self._total_worth_text = ba.textwidget( + parent=self._root_widget, + text=total_worth_text, + position=(self._width * 0.5, 210), + scale=0.9, + maxwidth=self._width * 0.7, + size=(0, 0), + h_align='center', + v_align='center', + shadow=1.0, + flatness=1.0, + color=(0.3, 1, 1)) + + elif offer['item'] == 'pro_fullprice': + # for full-price pro we simply show full price + ba.textwidget(edit=display['price_widget'], text=real_price) + ba.buttonwidget(edit=display['button'], + on_activate_call=self._purchase) + else: + # Show old/new prices otherwise (for pro sale). + ba.buttonwidget(edit=display['button'], + on_activate_call=self._purchase) + ba.imagewidget(edit=display['price_slash_widget'], opacity=1.0) + ba.textwidget(edit=display['price_widget_left'], + text=original_price_str) + ba.textwidget(edit=display['price_widget_right'], + text=new_price_str) + + # Add ticket button only if this is ticket-purchasable. + if offer['price'] is not None and isinstance(offer['price'], int): + self._get_tickets_button = ba.buttonwidget( + parent=self._root_widget, + position=(self._width - 125, self._height - 68), + size=(90, 55), + scale=1.0, + button_type='square', + color=(0.7, 0.5, 0.85), + textcolor=(0.2, 1, 0.2), + autoselect=True, + label=ba.Lstr(resource='getTicketsWindow.titleText'), + on_activate_call=self._on_get_more_tickets_press) + + self._ticket_text_update_timer = ba.Timer( + 1.0, + ba.WeakCall(self._update_tickets_text), + timetype=ba.TimeType.REAL, + repeat=True) + self._update_tickets_text() + + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + timetype=ba.TimeType.REAL, + repeat=True) + + self._cancel_button = ba.buttonwidget( + parent=self._root_widget, + position=(50, 40) if self._is_bundle_sale else + (self._width * 0.5 - 75, 40), + size=(150, 60), + scale=1.0, + on_activate_call=self._cancel, + autoselect=True, + label=ba.Lstr(resource='noThanksText')) + self._cancel_countdown_text = ba.textwidget( + parent=self._root_widget, + text='', + position=(50 + 150 + 20, 40 + 27) if self._is_bundle_sale else + (self._width * 0.5 - 75 + 150 + 20, 40 + 27), + scale=1.1, + size=(0, 0), + h_align='left', + v_align='center', + shadow=1.0, + flatness=1.0, + color=(0.6, 0.5, 0.5)) + self._update_cancel_button_graphics() + + if self._is_bundle_sale: + self._purchase_button = ba.buttonwidget( + parent=self._root_widget, + position=(self._width - 200, 40), + size=(150, 60), + scale=1.0, + on_activate_call=self._purchase, + autoselect=True, + label=ba.Lstr(resource='store.purchaseText')) + + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button, + start_button=self._purchase_button + if self._is_bundle_sale else None, + selected_child=self._purchase_button + if self._is_bundle_sale else display['button']) + + def _stop_flashing(self) -> None: + self._flashing_timer = None + ba.textwidget(edit=self._title_text, color=(0.3, 1, 0.3)) + + def _flash_cycle(self) -> None: + if not self._root_widget: + return + self._flash_on = not self._flash_on + ba.textwidget(edit=self._title_text, + color=(0.3, 1, 0.3) if self._flash_on else (1, 0.5, 0)) + + def _update_cancel_button_graphics(self) -> None: + ba.buttonwidget(edit=self._cancel_button, + color=(0.5, 0.5, 0.5) if self._cancel_delay > 0 else + (0.7, 0.4, 0.34), + textcolor=(0.5, 0.5, + 0.5) if self._cancel_delay > 0 else + (0.9, 0.9, 1.0)) + ba.textwidget( + edit=self._cancel_countdown_text, + text=str(self._cancel_delay) if self._cancel_delay > 0 else '') + + def _update(self) -> None: + from ba.internal import have_pro + + # If we've got seconds left on our countdown, update it. + if self._cancel_delay > 0: + self._cancel_delay = max(0, self._cancel_delay - 1) + self._update_cancel_button_graphics() + + can_die = False + + # We go away if we see that our target item is owned. + if self._offer_item == 'pro': + if have_pro(): + can_die = True + else: + if _ba.get_purchased(self._offer_item): + can_die = True + + if can_die: + self._transition_out('out_left') + + def _transition_out(self, transition: str = 'out_left') -> None: + # Also clear any pending-special-offer we've stored at this point. + cfg = ba.app.config + if 'pendingSpecialOffer' in cfg: + del cfg['pendingSpecialOffer'] + cfg.commit() + + ba.containerwidget(edit=self._root_widget, transition=transition) + + def _update_tickets_text(self) -> None: + from ba import SpecialChar + if not self._root_widget: + return + sval: Union[str, ba.Lstr] + if _ba.get_account_state() == 'signed_in': + sval = (ba.charstr(SpecialChar.TICKET) + + str(_ba.get_account_ticket_count())) + else: + sval = ba.Lstr(resource='getTicketsWindow.titleText') + ba.buttonwidget(edit=self._get_tickets_button, label=sval) + + def _on_get_more_tickets_press(self) -> None: + from bastd.ui import account + from bastd.ui import getcurrency + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + getcurrency.GetCurrencyWindow(modal=True).get_root_widget() + + def _purchase(self) -> None: + from ba.internal import get_store_item_name_translated + from bastd.ui import getcurrency + from bastd.ui import confirm + if self._offer['item'] == 'pro': + _ba.purchase('pro_sale') + elif self._offer['item'] == 'pro_fullprice': + _ba.purchase('pro') + elif self._is_bundle_sale: + # With bundle sales, the price is the name of the IAP. + _ba.purchase(self._offer['price']) + else: + ticket_count: Optional[int] + try: + ticket_count = _ba.get_account_ticket_count() + except Exception: + ticket_count = None + if (ticket_count is not None + and ticket_count < self._offer['price']): + getcurrency.show_get_tickets_prompt() + ba.playsound(ba.getsound('error')) + return + + def do_it() -> None: + _ba.in_game_purchase('offer:' + str(self._offer['id']), + self._offer['price']) + + ba.playsound(ba.getsound('swish')) + confirm.ConfirmWindow(ba.Lstr( + resource='store.purchaseConfirmText', + subs=[('${ITEM}', + get_store_item_name_translated(self._offer['item']))]), + width=400, + height=120, + action=do_it, + ok_text=ba.Lstr( + resource='store.purchaseText', + fallback_resource='okText')) + + def _cancel(self) -> None: + if self._cancel_delay > 0: + ba.playsound(ba.getsound('error')) + return + self._transition_out('out_right') + + +def show_offer() -> bool: + """(internal)""" + try: + from bastd.ui import feedback + app = ba.app + + # Space things out a bit so we don't hit the poor user with an ad and + # then an in-game offer. + has_been_long_enough_since_ad = True + if (app.last_ad_completion_time is not None and + (ba.time(ba.TimeType.REAL) - app.last_ad_completion_time < 30.0)): + has_been_long_enough_since_ad = False + + if app.special_offer is not None and has_been_long_enough_since_ad: + + # Special case: for pro offers, store this in our prefs so we + # can re-show it if the user kills us (set phasers to 'NAG'!!!). + if app.special_offer.get('item') == 'pro_fullprice': + cfg = app.config + cfg['pendingSpecialOffer'] = { + 'a': _ba.get_public_login_id(), + 'o': app.special_offer + } + cfg.commit() + + with ba.Context('ui'): + if app.special_offer['item'] == 'rating': + feedback.ask_for_rating() + else: + SpecialOfferWindow(app.special_offer) + + app.special_offer = None + return True + except Exception: + ba.print_exception('Error showing offer') + + return False diff --git a/assets/src/data/scripts/bastd/ui/store/__init__.py b/assets/src/data/scripts/bastd/ui/store/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assets/src/data/scripts/bastd/ui/store/browser.py b/assets/src/data/scripts/bastd/ui/store/browser.py new file mode 100644 index 00000000..c2db848f --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/store/browser.py @@ -0,0 +1,1053 @@ +"""UI for browsing the store.""" +# pylint: disable=too-many-lines +from __future__ import annotations + +import copy +import math +import weakref +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Callable, Optional, Tuple, Dict, Union, Sequence + + +class StoreBrowserWindow(ba.OldWindow): + """Window for browsing the store.""" + + def _update_get_tickets_button_pos(self) -> None: + if self._get_tickets_button: + pos = (self._width - 252 - + (self._x_inset + (47 if ba.app.small_ui + and _ba.is_party_icon_visible() else 0)), + self._height - 70) + ba.buttonwidget(edit=self._get_tickets_button, position=pos) + + def __init__(self, + transition: str = 'in_right', + modal: bool = False, + show_tab: str = None, + on_close_call: Callable[[], Any] = None, + back_location: str = None, + origin_widget: ba.Widget = None): + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + from bastd.ui import tabs + from ba import SpecialChar + + app = ba.app + + ba.set_analytics_screen('Store Window') + + scale_origin: Optional[Tuple[float, float]] + + # If they provided an origin-widget, scale up from that. + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + + self.button_infos: Optional[Dict[str, Dict[str, Any]]] = None + self.update_buttons_timer: Optional[ba.Timer] = None + self._status_textwidget_update_timer = None + + self._back_location = back_location + self._on_close_call = on_close_call + self._show_tab = show_tab + self._modal = modal + self._width = 1240 if app.small_ui else 1040 + self._x_inset = x_inset = 100 if app.small_ui else 0 + self._height = (578 if app.small_ui else 645 if app.med_ui else 800) + self._current_tab: Optional[str] = None + extra_top = 30 if app.small_ui else 0 + + self._request: Any = None + self._r = 'store' + self._last_buy_time = None + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + extra_top), + transition=transition, + toolbar_visibility='menu_full', + scale=1.3 if app.small_ui else 0.9 if app.med_ui else 0.8, + scale_origin_stack_offset=scale_origin, + stack_offset=(0, + -5) if app.small_ui else (0, + 0) if app.med_ui else (0, + 0))) + + self._back_button = btn = ba.buttonwidget( + parent=self._root_widget, + position=(70 + x_inset, self._height - 74), + size=(140, 60), + scale=1.1, + autoselect=True, + label=ba.Lstr(resource='doneText' if self._modal else 'backText'), + button_type=None if self._modal else 'back', + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + self._get_tickets_button = ba.buttonwidget( + parent=self._root_widget, + size=(210, 65), + on_activate_call=self._on_get_more_tickets_press, + autoselect=True, + scale=0.9, + text_scale=1.4, + left_widget=self._back_button, + color=(0.7, 0.5, 0.85), + textcolor=(0.2, 1.0, 0.2), + label=ba.Lstr(resource='getTicketsWindow.titleText')) + + # Move this dynamically to keep it out of the way of the party icon. + self._update_get_tickets_button_pos() + self._get_ticket_pos_update_timer = ba.Timer( + 1.0, + ba.WeakCall(self._update_get_tickets_button_pos), + repeat=True, + timetype=ba.TimeType.REAL) + ba.widget(edit=self._back_button, + right_widget=self._get_tickets_button) + self._ticket_text_update_timer = ba.Timer( + 1.0, + ba.WeakCall(self._update_tickets_text), + timetype=ba.TimeType.REAL, + repeat=True) + self._update_tickets_text() + + app = ba.app + if app.platform in ['mac', 'ios'] and app.subplatform == 'appstore': + ba.buttonwidget( + parent=self._root_widget, + position=(self._width * 0.5 - 70, 16), + size=(230, 50), + scale=0.65, + on_activate_call=ba.WeakCall(self._restore_purchases), + color=(0.35, 0.3, 0.4), + selectable=False, + textcolor=(0.55, 0.5, 0.6), + label=ba.Lstr( + resource='getTicketsWindow.restorePurchasesText')) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 44), + size=(0, 0), + color=app.title_color, + scale=1.5, + h_align="center", + v_align="center", + text=ba.Lstr(resource='storeText'), + maxwidth=420) + + if not self._modal: + ba.buttonwidget(edit=self._back_button, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(SpecialChar.BACK)) + + scroll_buffer_h = 130 + 2 * x_inset + tab_buffer_h = 250 + 2 * x_inset + + tabs_def = [ + ('extras', ba.Lstr(resource=self._r + '.extrasText')), + ('maps', ba.Lstr(resource=self._r + '.mapsText')), + ('minigames', ba.Lstr(resource=self._r + '.miniGamesText')), + ('characters', ba.Lstr(resource=self._r + '.charactersText')), + ('icons', ba.Lstr(resource=self._r + '.iconsText')), + ] # yapf : disable + + tab_results = tabs.create_tab_buttons(self._root_widget, + tabs_def, + pos=(tab_buffer_h * 0.5, + self._height - 130), + size=(self._width - tab_buffer_h, + 50), + on_select_call=self._set_tab, + return_extra_info=True) + + self._purchasable_count_widgets: Dict[str, Dict[str, Any]] = {} + + # Create our purchasable-items tags and have them update over time. + for i, tab in enumerate(tabs_def): + pos = tab_results['positions'][i] + size = tab_results['sizes'][i] + button = tab_results['buttons_indexed'][i] + rad = 10 + center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1]) + img = ba.imagewidget(parent=self._root_widget, + position=(center[0] - rad * 1.04, + center[1] - rad * 1.15), + size=(rad * 2.2, rad * 2.2), + texture=ba.gettexture('circleShadow'), + color=(1, 0, 0)) + txt = ba.textwidget(parent=self._root_widget, + position=center, + size=(0, 0), + h_align='center', + v_align='center', + maxwidth=1.4 * rad, + scale=0.6, + shadow=1.0, + flatness=1.0) + rad = 20 + sale_img = ba.imagewidget(parent=self._root_widget, + position=(center[0] - rad, + center[1] - rad), + size=(rad * 2, rad * 2), + draw_controller=button, + texture=ba.gettexture('circleZigZag'), + color=(0.5, 0, 1.0)) + sale_title_text = ba.textwidget(parent=self._root_widget, + position=(center[0], + center[1] + 0.24 * rad), + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=button, + maxwidth=1.4 * rad, + scale=0.6, + shadow=0.0, + flatness=1.0, + color=(0, 1, 0)) + sale_time_text = ba.textwidget(parent=self._root_widget, + position=(center[0], + center[1] - 0.29 * rad), + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=button, + maxwidth=1.4 * rad, + scale=0.4, + shadow=0.0, + flatness=1.0, + color=(0, 1, 0)) + self._purchasable_count_widgets[tab[0]] = { + 'img': img, + 'text': txt, + 'sale_img': sale_img, + 'sale_title_text': sale_title_text, + 'sale_time_text': sale_time_text + } + self._tab_update_timer = ba.Timer(1.0, + ba.WeakCall(self._update_tabs), + timetype=ba.TimeType.REAL, + repeat=True) + self._update_tabs() + + self._tab_buttons = tab_results['buttons'] + + if self._get_tickets_button is not None: + last_tab_button = self._tab_buttons[tabs_def[-1][0]] + ba.widget(edit=self._get_tickets_button, + down_widget=last_tab_button) + ba.widget(edit=last_tab_button, + up_widget=self._get_tickets_button, + right_widget=self._get_tickets_button) + + self._scroll_width = self._width - scroll_buffer_h + self._scroll_height = self._height - 180 + + self._scrollwidget: Optional[ba.Widget] = None + self._status_textwidget: Optional[ba.Widget] = None + self._restore_state() + + def _restore_purchases(self) -> None: + from bastd.ui import account + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + else: + _ba.restore_purchases() + + def _update_tabs(self) -> None: + from ba.internal import (get_available_sale_time, + get_available_purchase_count) + if not self._root_widget: + return + for tab_name, tab_data in list( + self._purchasable_count_widgets.items()): + sale_time = get_available_sale_time(tab_name) + + if sale_time is not None: + ba.textwidget(edit=tab_data['sale_title_text'], + text=ba.Lstr(resource='store.saleText')) + ba.textwidget(edit=tab_data['sale_time_text'], + text=ba.timestring( + sale_time, + centi=False, + timeformat=ba.TimeFormat.MILLISECONDS)) + ba.imagewidget(edit=tab_data['sale_img'], opacity=1.0) + count = 0 + else: + ba.textwidget(edit=tab_data['sale_title_text'], text='') + ba.textwidget(edit=tab_data['sale_time_text'], text='') + ba.imagewidget(edit=tab_data['sale_img'], opacity=0.0) + count = get_available_purchase_count(tab_name) + + if count > 0: + ba.textwidget(edit=tab_data['text'], text=str(count)) + ba.imagewidget(edit=tab_data['img'], opacity=1.0) + else: + ba.textwidget(edit=tab_data['text'], text='') + ba.imagewidget(edit=tab_data['img'], opacity=0.0) + + def _update_tickets_text(self) -> None: + from ba import SpecialChar + if not self._root_widget: + return + sval: Union[str, ba.Lstr] + if _ba.get_account_state() == 'signed_in': + sval = ba.charstr(SpecialChar.TICKET) + str( + _ba.get_account_ticket_count()) + else: + sval = ba.Lstr(resource='getTicketsWindow.titleText') + ba.buttonwidget(edit=self._get_tickets_button, label=sval) + + def _set_tab(self, tab: str) -> None: + from bastd.ui import tabs + if self._current_tab == tab: + return + self._current_tab = tab + + # We wanna preserve our current tab between runs. + cfg = ba.app.config + cfg['Store Tab'] = tab + cfg.commit() + + # Update tab colors based on which is selected. + tabs.update_tab_button_colors(self._tab_buttons, tab) + + # (Re)create scroll widget. + if self._scrollwidget: + self._scrollwidget.delete() + + self._scrollwidget = ba.scrollwidget( + parent=self._root_widget, + highlight=False, + position=((self._width - self._scroll_width) * 0.5, + self._height - self._scroll_height - 79 - 48), + size=(self._scroll_width, self._scroll_height)) + + # NOTE: this stuff is modified by the _Store class. + # Should maybe clean that up. + self.button_infos = {} + self.update_buttons_timer = None + + # So we can still select root level widgets with controllers. + ba.containerwidget(edit=self._scrollwidget, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + + # Show status over top. + if self._status_textwidget: + self._status_textwidget.delete() + self._status_textwidget = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.5), + size=(0, 0), + color=(1, 0.7, 1, 0.5), + h_align="center", + v_align="center", + text=ba.Lstr(resource=self._r + '.loadingText'), + maxwidth=self._scroll_width * 0.9) + + class _Request: + + def __init__(self, window: StoreBrowserWindow): + self._window = weakref.ref(window) + data = {'tab': tab} + ba.timer(0.1, + ba.WeakCall(self._on_response, data), + timetype=ba.TimeType.REAL) + + def _on_response(self, data: Optional[Dict[str, Any]]) -> None: + # FIXME: clean this up. + # pylint: disable=protected-access + window = self._window() + if window is not None and (window._request is self): + window._request = None + # noinspection PyProtectedMember + window._on_response(data) + + # Kick off a server request. + self._request = _Request(self) + + # Actually start the purchase locally. + def _purchase_check_result(self, item: str, is_ticket_purchase: bool, + result: Optional[Dict[str, Any]]) -> None: + if result is None: + ba.playsound(ba.getsound('error')) + ba.screenmessage( + ba.Lstr(resource='internal.unavailableNoConnectionText'), + color=(1, 0, 0)) + else: + if is_ticket_purchase: + if result['allow']: + price = _ba.get_account_misc_read_val( + 'price.' + item, None) + if (price is None or not isinstance(price, int) + or price <= 0): + print('Error; got invalid local price of', price, + 'for item', item) + ba.playsound(ba.getsound('error')) + else: + ba.playsound(ba.getsound('click01')) + _ba.in_game_purchase(item, price) + else: + if result['reason'] == 'versionTooOld': + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr( + resource='getTicketsWindow.versionTooOldText'), + color=(1, 0, 0)) + else: + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr( + resource='getTicketsWindow.unavailableText'), + color=(1, 0, 0)) + # Real in-app purchase. + else: + if result['allow']: + _ba.purchase(item) + else: + if result['reason'] == 'versionTooOld': + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr( + resource='getTicketsWindow.versionTooOldText'), + color=(1, 0, 0)) + else: + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr( + resource='getTicketsWindow.unavailableText'), + color=(1, 0, 0)) + + def _do_purchase_check(self, item: str, + is_ticket_purchase: bool = False) -> None: + from ba.internal import serverget + + # Here we ping the server to ask if it's valid for us to + # purchase this. Better to fail now than after we've + # paid locally. + app = ba.app + serverget('bsAccountPurchaseCheck', { + 'item': item, + 'platform': app.platform, + 'subplatform': app.subplatform, + 'version': app.version, + 'buildNumber': app.build_number, + 'purchaseType': 'ticket' if is_ticket_purchase else 'real' + }, + callback=ba.WeakCall(self._purchase_check_result, item, + is_ticket_purchase)) + + def buy(self, item: str) -> None: + """Attempt to purchase the provided item.""" + from ba.internal import (get_available_sale_time, + get_store_item_name_translated) + from bastd.ui import account + from bastd.ui.confirm import ConfirmWindow + from bastd.ui import getcurrency + + # Prevent pressing buy within a few seconds of the last press + # (gives the buttons time to disable themselves and whatnot). + curtime = ba.time(ba.TimeType.REAL) + if self._last_buy_time is None or curtime - self._last_buy_time < 2.0: + ba.playsound(ba.getsound('error')) + else: + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + else: + self._last_buy_time = curtime + + # Pro is an actual IAP; the rest are ticket purchases. + if item == 'pro': + ba.playsound(ba.getsound('click01')) + + # Purchase either pro or pro_sale depending on whether + # there is a sale going on. + self._do_purchase_check('pro' if get_available_sale_time( + 'extras') is None else 'pro_sale') + else: + price = _ba.get_account_misc_read_val( + 'price.' + item, None) + our_tickets = _ba.get_account_ticket_count() + if price is not None and our_tickets < price: + ba.playsound(ba.getsound('error')) + getcurrency.show_get_tickets_prompt() + else: + + def do_it() -> None: + self._do_purchase_check(item, + is_ticket_purchase=True) + + ba.playsound(ba.getsound('swish')) + ConfirmWindow( + ba.Lstr(resource='store.purchaseConfirmText', + subs=[ + ('${ITEM}', + get_store_item_name_translated(item)) + ]), + width=400, + height=120, + action=do_it, + ok_text=ba.Lstr(resource='store.purchaseText', + fallback_resource='okText')) + + def _print_already_own(self, charname: str) -> None: + ba.screenmessage(ba.Lstr(resource=self._r + '.alreadyOwnText', + subs=[('${NAME}', charname)]), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + + def update_buttons(self) -> None: + """Update our buttons.""" + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + from ba.internal import have_pro, get_available_sale_time + from ba import SpecialChar + if not self._root_widget: + return + import datetime + sales_raw = _ba.get_account_misc_read_val('sales', {}) + sales = {} + try: + # look at the current set of sales; filter any with + # time remaining.. + for sale_item, sale_info in list(sales_raw.items()): + to_end = (datetime.datetime.utcfromtimestamp(sale_info['e']) - + datetime.datetime.utcnow()).total_seconds() + if to_end > 0: + sales[sale_item] = { + 'to_end': to_end, + 'original_price': sale_info['op'] + } + except Exception: + ba.print_exception("Error parsing sales") + + assert self.button_infos is not None + for b_type, b_info in self.button_infos.items(): + + if b_type in ['upgrades.pro', 'pro']: + purchased = have_pro() + else: + purchased = _ba.get_purchased(b_type) + + sale_opacity = 0.0 + sale_title_text: Union[str, ba.Lstr] = '' + sale_time_text: Union[str, ba.Lstr] = '' + + if purchased: + title_color = (0.8, 0.7, 0.9, 1.0) + color = (0.63, 0.55, 0.78) + extra_image_opacity = 0.5 + call = ba.WeakCall(self._print_already_own, b_info['name']) + price_text = '' + price_text_left = '' + price_text_right = '' + show_purchase_check = True + description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4) + description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0) + price_color = (0.5, 1, 0.5, 0.3) + else: + title_color = (0.7, 0.9, 0.7, 1.0) + color = (0.4, 0.8, 0.1) + extra_image_opacity = 1.0 + call = b_info['call'] if 'call' in b_info else None + if b_type in ['upgrades.pro', 'pro']: + sale_time = get_available_sale_time('extras') + if sale_time is not None: + priceraw = _ba.get_price('pro') + price_text_left = (priceraw + if priceraw is not None else '?') + priceraw = _ba.get_price('pro_sale') + price_text_right = (priceraw + if priceraw is not None else '?') + sale_opacity = 1.0 + price_text = '' + sale_title_text = ba.Lstr(resource='store.saleText') + sale_time_text = ba.timestring( + sale_time, + centi=False, + timeformat=ba.TimeFormat.MILLISECONDS) + else: + priceraw = _ba.get_price('pro') + price_text = priceraw if priceraw is not None else '?' + price_text_left = '' + price_text_right = '' + else: + price = _ba.get_account_misc_read_val('price.' + b_type, 0) + # color button differently if we cant afford this + if _ba.get_account_state() == 'signed_in': + if _ba.get_account_ticket_count() < price: + color = (0.6, 0.61, 0.6) + price_text = ba.charstr(ba.SpecialChar.TICKET) + str( + _ba.get_account_misc_read_val('price.' + b_type, '?')) + price_text_left = '' + price_text_right = '' + + # TESTING: + if b_type in sales: + sale_opacity = 1.0 + price_text_left = ba.charstr(SpecialChar.TICKET) + str( + sales[b_type]['original_price']) + price_text_right = price_text + price_text = '' + sale_title_text = ba.Lstr(resource='store.saleText') + sale_time_text = ba.timestring( + int(sales[b_type]['to_end'] * 1000), + centi=False, + timeformat=ba.TimeFormat.MILLISECONDS) + + description_color = (0.5, 1.0, 0.5) + description_color2 = (0.3, 1.0, 1.0) + price_color = (0.2, 1, 0.2, 1.0) + show_purchase_check = False + + if 'title_text' in b_info: + ba.textwidget(edit=b_info['title_text'], color=title_color) + if 'purchase_check' in b_info: + ba.imagewidget(edit=b_info['purchase_check'], + opacity=1.0 if show_purchase_check else 0.0) + if 'price_widget' in b_info: + ba.textwidget(edit=b_info['price_widget'], + text=price_text, + color=price_color) + if 'price_widget_left' in b_info: + ba.textwidget(edit=b_info['price_widget_left'], + text=price_text_left) + if 'price_widget_right' in b_info: + ba.textwidget(edit=b_info['price_widget_right'], + text=price_text_right) + if 'price_slash_widget' in b_info: + ba.imagewidget(edit=b_info['price_slash_widget'], + opacity=sale_opacity) + if 'sale_bg_widget' in b_info: + ba.imagewidget(edit=b_info['sale_bg_widget'], + opacity=sale_opacity) + if 'sale_title_widget' in b_info: + ba.textwidget(edit=b_info['sale_title_widget'], + text=sale_title_text) + if 'sale_time_widget' in b_info: + ba.textwidget(edit=b_info['sale_time_widget'], + text=sale_time_text) + if 'button' in b_info: + ba.buttonwidget(edit=b_info['button'], + color=color, + on_activate_call=call) + if 'extra_backings' in b_info: + for bck in b_info['extra_backings']: + ba.imagewidget(edit=bck, + color=color, + opacity=extra_image_opacity) + if 'extra_images' in b_info: + for img in b_info['extra_images']: + ba.imagewidget(edit=img, opacity=extra_image_opacity) + if 'extra_texts' in b_info: + for etxt in b_info['extra_texts']: + ba.textwidget(edit=etxt, color=description_color) + if 'extra_texts_2' in b_info: + for etxt in b_info['extra_texts_2']: + ba.textwidget(edit=etxt, color=description_color2) + if 'descriptionText' in b_info: + ba.textwidget(edit=b_info['descriptionText'], + color=description_color) + + def _on_response(self, data: Optional[Dict[str, Any]]) -> None: + # pylint: disable=too-many-statements + + # clear status text.. + if self._status_textwidget: + self._status_textwidget.delete() + self._status_textwidget_update_timer = None + + if data is None: + self._status_textwidget = ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.5), + size=(0, 0), + scale=1.3, + transition_delay=0.1, + color=(1, 0.3, 0.3, 1.0), + h_align="center", + v_align="center", + text=ba.Lstr(resource=self._r + '.loadErrorText'), + maxwidth=self._scroll_width * 0.9) + else: + + class _Store: + + def __init__(self, store_window: StoreBrowserWindow, + sdata: Dict[str, Any], width: float): + from ba.internal import (get_store_item_display_size, + get_store_layout) + self._store_window = store_window + self._width = width + store_data = get_store_layout() + self._tab = sdata['tab'] + self._sections = copy.deepcopy(store_data[sdata['tab']]) + self._height: Optional[float] = None + + # Pre-calc a few things and add them to store-data. + for section in self._sections: + if self._tab == 'characters': + dummy_name = 'characters.foo' + elif self._tab == 'extras': + dummy_name = 'pro' + elif self._tab == 'maps': + dummy_name = 'maps.foo' + elif self._tab == 'icons': + dummy_name = 'icons.foo' + else: + dummy_name = '' + section['button_size'] = get_store_item_display_size( + dummy_name) + section['v_spacing'] = ( + -17 if self._tab == 'characters' else 0) + if 'title' not in section: + section['title'] = '' + section['x_offs'] = (130 if self._tab == 'extras' else + 270 if self._tab == 'maps' else 0) + section['y_offs'] = ( + 55 if (self._tab == 'extras' and ba.app.small_ui) + else -20 if self._tab == 'icons' else 0) + + def instantiate(self, scrollwidget: ba.Widget, + tab_button: ba.Widget) -> None: + """Create the store.""" + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-nested-blocks + from bastd.ui.store import item as storeitemui + title_spacing = 40 + button_border = 20 + button_spacing = 4 + boffs_h = 40 + self._height = 80.0 + + # Calc total height. + for i, section in enumerate(self._sections): + if section['title'] != '': + assert self._height is not None + self._height += title_spacing + b_width, b_height = section['button_size'] + b_column_count = int( + math.floor((self._width - boffs_h - 20) / + (b_width + button_spacing))) + b_row_count = int( + math.ceil( + float(len(section['items'])) / b_column_count)) + b_height_total = ( + 2 * button_border + b_row_count * b_height + + (b_row_count - 1) * section['v_spacing']) + self._height += b_height_total + + assert self._height is not None + cnt2 = ba.containerwidget(parent=scrollwidget, + scale=1.0, + size=(self._width, self._height), + background=False) + ba.containerwidget(edit=cnt2, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + v = self._height - 20 + + if self._tab == 'characters': + txt = ba.Lstr( + resource='store.howToSwitchCharactersText', + subs=[ + ('${SETTINGS}', + ba.Lstr( + resource='accountSettingsWindow.titleText' + )), + ('${PLAYER_PROFILES}', + ba.Lstr( + resource='playerProfilesWindow.titleText') + ) + ]) + ba.textwidget(parent=cnt2, + text=txt, + size=(0, 0), + position=(self._width * 0.5, + self._height - 28), + h_align='center', + v_align='center', + color=(0.7, 1, 0.7, 0.4), + scale=0.7, + shadow=0, + flatness=1.0, + maxwidth=700, + transition_delay=0.4) + elif self._tab == 'icons': + txt = ba.Lstr( + resource='store.howToUseIconsText', + subs=[ + ('${SETTINGS}', + ba.Lstr(resource='mainMenu.settingsText')), + ('${PLAYER_PROFILES}', + ba.Lstr( + resource='playerProfilesWindow.titleText') + ) + ]) + ba.textwidget(parent=cnt2, + text=txt, + size=(0, 0), + position=(self._width * 0.5, + self._height - 28), + h_align='center', + v_align='center', + color=(0.7, 1, 0.7, 0.4), + scale=0.7, + shadow=0, + flatness=1.0, + maxwidth=700, + transition_delay=0.4) + elif self._tab == 'maps': + assert self._width is not None + assert self._height is not None + txt = ba.Lstr(resource='store.howToUseMapsText') + ba.textwidget(parent=cnt2, + text=txt, + size=(0, 0), + position=(self._width * 0.5, + self._height - 28), + h_align='center', + v_align='center', + color=(0.7, 1, 0.7, 0.4), + scale=0.7, + shadow=0, + flatness=1.0, + maxwidth=700, + transition_delay=0.4) + + prev_row_buttons = None + this_row_buttons = [] + + delay = 0.3 + for section in self._sections: + if section['title'] != '': + ba.textwidget( + parent=cnt2, + position=(60, v - title_spacing * 0.8), + size=(0, 0), + scale=1.0, + transition_delay=delay, + color=(0.7, 0.9, 0.7, 1), + h_align="left", + v_align="center", + text=ba.Lstr(resource=section['title']), + maxwidth=self._width * 0.7) + v -= title_spacing + delay = max(0.100, delay - 0.100) + v -= button_border + b_width, b_height = section['button_size'] + b_count = len(section['items']) + b_column_count = int( + math.floor((self._width - boffs_h - 20) / + (b_width + button_spacing))) + col = 0 + item: Dict[str, Any] + assert self._store_window.button_infos is not None + for i, item_name in enumerate(section['items']): + item = self._store_window.button_infos[ + item_name] = {} + item['call'] = ba.WeakCall(self._store_window.buy, + item_name) + if 'x_offs' in section: + boffs_h2 = section['x_offs'] + else: + boffs_h2 = 0 + + if 'y_offs' in section: + boffs_v2 = section['y_offs'] + else: + boffs_v2 = 0 + b_pos = (boffs_h + boffs_h2 + + (b_width + button_spacing) * col, + v - b_height + boffs_v2) + storeitemui.instantiate_store_item_display( + item_name, + item, + parent_widget=cnt2, + b_pos=b_pos, + boffs_h=boffs_h, + b_width=b_width, + b_height=b_height, + boffs_h2=boffs_h2, + boffs_v2=boffs_v2, + delay=delay) + btn = item['button'] + delay = max(0.1, delay - 0.1) + this_row_buttons.append(btn) + + # Wire this button to the equivalent in the + # previous row. + if prev_row_buttons is not None: + # pylint: disable=unsubscriptable-object + if len(prev_row_buttons) > col: + ba.widget(edit=btn, + up_widget=prev_row_buttons[col]) + ba.widget(edit=prev_row_buttons[col], + down_widget=btn) + + # If we're the last button in our row, + # wire any in the previous row past + # our position to go to us if down is + # pressed. + if (col + 1 == b_column_count + or i == b_count - 1): + for b_prev in prev_row_buttons[col + + 1:]: + ba.widget(edit=b_prev, + down_widget=btn) + else: + ba.widget(edit=btn, + up_widget=prev_row_buttons[-1]) + else: + ba.widget(edit=btn, up_widget=tab_button) + + col += 1 + if col == b_column_count or i == b_count - 1: + prev_row_buttons = this_row_buttons + this_row_buttons = [] + col = 0 + v -= b_height + if i < b_count - 1: + v -= section['v_spacing'] + + v -= button_border + + # Set a timer to update these buttons periodically as long + # as we're alive (so if we buy one it will grey out, etc). + self._store_window.update_buttons_timer = ba.Timer( + 0.5, + ba.WeakCall(self._store_window.update_buttons), + repeat=True, + timetype=ba.TimeType.REAL) + + # Also update them immediately. + self._store_window.update_buttons() + + if self._current_tab in ('extras', 'minigames', 'characters', + 'maps', 'icons'): + store = _Store(self, data, self._scroll_width) + assert self._scrollwidget is not None + store.instantiate( + scrollwidget=self._scrollwidget, + tab_button=self._tab_buttons[self._current_tab]) + else: + cnt = ba.containerwidget(parent=self._scrollwidget, + scale=1.0, + size=(self._scroll_width, + self._scroll_height * 0.95), + background=False) + ba.containerwidget(edit=cnt, + claims_left_right=True, + claims_tab=True, + selection_loop_to_parent=True) + self._status_textwidget = ba.textwidget( + parent=cnt, + position=(self._scroll_width * 0.5, + self._scroll_height * 0.5), + size=(0, 0), + scale=1.3, + transition_delay=0.1, + color=(1, 1, 0.3, 1.0), + h_align="center", + v_align="center", + text=ba.Lstr(resource=self._r + '.comingSoonText'), + maxwidth=self._scroll_width * 0.9) + + def _save_state(self) -> None: + try: + sel = self._root_widget.get_selected_child() + if sel == self._get_tickets_button: + sel_name = 'GetTickets' + elif sel == self._scrollwidget: + sel_name = 'Scroll' + elif sel == self._back_button: + sel_name = 'Back' + elif sel in list(self._tab_buttons.values()): + sel_name = 'Tab:' + list(self._tab_buttons.keys())[list( + self._tab_buttons.values()).index(sel)] + else: + raise Exception("unrecognized selection") + ba.app.window_states[self.__class__.__name__] = { + 'sel_name': sel_name, + 'tab': self._current_tab + } + except Exception: + ba.print_exception('error saving state for', self.__class__) + + def _restore_state(self) -> None: + try: + sel: Optional[ba.Widget] + try: + sel_name = ( + ba.app.window_states[self.__class__.__name__]['sel_name']) + except Exception: + sel_name = None + try: + current_tab = ba.app.config['Store Tab'] + except Exception: + current_tab = None + if self._show_tab is not None: + current_tab = self._show_tab + if current_tab is None or current_tab not in self._tab_buttons: + current_tab = 'characters' + if sel_name == 'GetTickets': + sel = self._get_tickets_button + elif sel_name == 'Back': + sel = self._back_button + elif sel_name == 'Scroll': + sel = self._scrollwidget + elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): + sel = self._tab_buttons[sel_name.split(':')[-1]] + else: + sel = self._tab_buttons[current_tab] + # if we were requested to show a tab, select it too.. + if (self._show_tab is not None + and self._show_tab in self._tab_buttons): + sel = self._tab_buttons[self._show_tab] + self._set_tab(current_tab) + if sel is not None: + ba.containerwidget(edit=self._root_widget, selected_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) + + def _on_get_more_tickets_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui import account + from bastd.ui import getcurrency + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + self._save_state() + ba.containerwidget(edit=self._root_widget, transition='out_left') + window = getcurrency.GetCurrencyWindow( + from_modal_store=self._modal, + store_back_location=self._back_location).get_root_widget() + if not self._modal: + ba.app.main_menu_window = window + + def _back(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.coop import browser + from bastd.ui import mainmenu + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + if not self._modal: + if self._back_location == 'CoopBrowserWindow': + ba.app.main_menu_window = (browser.CoopBrowserWindow( + transition='in_left').get_root_widget()) + else: + ba.app.main_menu_window = (mainmenu.MainMenuWindow( + transition='in_left').get_root_widget()) + if self._on_close_call is not None: + self._on_close_call() diff --git a/assets/src/data/scripts/bastd/ui/store/button.py b/assets/src/data/scripts/bastd/ui/store/button.py new file mode 100644 index 00000000..3054b7f4 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/store/button.py @@ -0,0 +1,270 @@ +"""UI functionality for a button leading to the store.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Sequence, Callable, Optional + + +class StoreButton: + """A button leading to the store.""" + + def __init__(self, + parent: ba.Widget, + position: Sequence[float], + size: Sequence[float], + scale: float, + on_activate_call: Callable[[], Any] = None, + transition_delay: float = None, + color: Sequence[float] = None, + textcolor: Sequence[float] = None, + show_tickets: bool = False, + button_type: str = None, + sale_scale: float = 1.0): + self._position = position + self._size = size + self._scale = scale + + if on_activate_call is None: + on_activate_call = ba.WeakCall(self._default_on_activate_call) + self._on_activate_call = on_activate_call + + self._button = ba.buttonwidget( + parent=parent, + size=size, + label='' if show_tickets else ba.Lstr(resource='storeText'), + scale=scale, + autoselect=True, + on_activate_call=self._on_activate, + transition_delay=transition_delay, + color=color, + button_type=button_type) + + self._title_text: Optional[ba.Widget] + self._ticket_text: Optional[ba.Widget] + + if show_tickets: + self._title_text = ba.textwidget( + parent=parent, + position=(position[0] + size[0] * 0.5 * scale, + position[1] + size[1] * 0.65 * scale), + size=(0, 0), + h_align='center', + v_align='center', + maxwidth=size[0] * scale * 0.65, + text=ba.Lstr(resource='storeText'), + draw_controller=self._button, + scale=scale, + transition_delay=transition_delay, + color=textcolor) + self._ticket_text = ba.textwidget( + parent=parent, + size=(0, 0), + h_align='center', + v_align='center', + maxwidth=size[0] * scale * 0.85, + text='', + color=(0.2, 1.0, 0.2), + flatness=1.0, + shadow=0.0, + scale=scale * 0.6, + transition_delay=transition_delay) + else: + self._title_text = None + self._ticket_text = None + + self._circle_rad = 12 * scale + self._circle_center = (0.0, 0.0) + self._sale_circle_center = (0.0, 0.0) + + self._available_purchase_backing = ba.imagewidget( + parent=parent, + color=(1, 0, 0), + draw_controller=self._button, + size=(2.2 * self._circle_rad, 2.2 * self._circle_rad), + texture=ba.gettexture('circleShadow'), + transition_delay=transition_delay) + self._available_purchase_text = ba.textwidget( + parent=parent, + size=(0, 0), + h_align='center', + v_align='center', + text='', + draw_controller=self._button, + color=(1, 1, 1), + flatness=1.0, + shadow=1.0, + scale=0.6 * scale, + maxwidth=self._circle_rad * 1.4, + transition_delay=transition_delay) + + self._sale_circle_rad = 18 * scale * sale_scale + self._sale_backing = ba.imagewidget( + parent=parent, + color=(0.5, 0, 1.0), + draw_controller=self._button, + size=(2 * self._sale_circle_rad, 2 * self._sale_circle_rad), + texture=ba.gettexture('circleZigZag'), + transition_delay=transition_delay) + self._sale_title_text = ba.textwidget( + parent=parent, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=self._button, + color=(0, 1, 0), + flatness=1.0, + shadow=0.0, + scale=0.5 * scale * sale_scale, + maxwidth=self._sale_circle_rad * 1.5, + transition_delay=transition_delay) + self._sale_time_text = ba.textwidget(parent=parent, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=self._button, + color=(0, 1, 0), + flatness=1.0, + shadow=0.0, + scale=0.4 * scale * sale_scale, + maxwidth=self._sale_circle_rad * + 1.5, + transition_delay=transition_delay) + + self.set_position(position) + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + repeat=True, + timetype=ba.TimeType.REAL) + self._update() + + def _on_activate(self) -> None: + _ba.increment_analytics_count('Store button press') + self._on_activate_call() + + def set_position(self, position: Sequence[float]) -> None: + """Set the button position.""" + self._position = position + self._circle_center = (position[0] + 0.1 * self._size[0] * self._scale, + position[1] + self._size[1] * self._scale * 0.8) + self._sale_circle_center = (position[0] + + 0.07 * self._size[0] * self._scale, + position[1] + + self._size[1] * self._scale * 0.8) + + if not self._button: + return + ba.buttonwidget(edit=self._button, position=self._position) + if self._title_text is not None: + ba.textwidget(edit=self._title_text, + position=(self._position[0] + + self._size[0] * 0.5 * self._scale, + self._position[1] + + self._size[1] * 0.65 * self._scale)) + if self._ticket_text is not None: + ba.textwidget( + edit=self._ticket_text, + position=(position[0] + self._size[0] * 0.5 * self._scale, + position[1] + self._size[1] * 0.28 * self._scale), + size=(0, 0)) + ba.imagewidget( + edit=self._available_purchase_backing, + position=(self._circle_center[0] - self._circle_rad * 1.02, + self._circle_center[1] - self._circle_rad * 1.13)) + ba.textwidget(edit=self._available_purchase_text, + position=self._circle_center) + + ba.imagewidget( + edit=self._sale_backing, + position=(self._sale_circle_center[0] - self._sale_circle_rad, + self._sale_circle_center[1] - self._sale_circle_rad)) + ba.textwidget(edit=self._sale_title_text, + position=(self._sale_circle_center[0], + self._sale_circle_center[1] + + self._sale_circle_rad * 0.3)) + ba.textwidget(edit=self._sale_time_text, + position=(self._sale_circle_center[0], + self._sale_circle_center[1] - + self._sale_circle_rad * 0.3)) + + def _default_on_activate_call(self) -> None: + from bastd.ui import account + from bastd.ui.store import browser + if _ba.get_account_state() != 'signed_in': + account.show_sign_in_prompt() + return + browser.StoreBrowserWindow(modal=True, origin_widget=self._button) + + def get_button(self) -> ba.Widget: + """Return the underlying button widget.""" + return self._button + + def _update(self) -> None: + # pylint: disable=too-many-branches + from ba import SpecialChar, TimeFormat + from ba.internal import (get_available_sale_time, + get_available_purchase_count) + if not self._button: + return # Our instance may outlive our UI objects. + + if self._ticket_text is not None: + if _ba.get_account_state() == 'signed_in': + sval = ba.charstr(SpecialChar.TICKET) + str( + _ba.get_account_ticket_count()) + else: + sval = '-' + ba.textwidget(edit=self._ticket_text, text=sval) + available_purchases = get_available_purchase_count() + + # Old pro sale stuff.. + sale_time = get_available_sale_time('extras') + + # ..also look for new style sales. + if sale_time is None: + import datetime + sales_raw = _ba.get_account_misc_read_val('sales', {}) + sale_times = [] + try: + # Look at the current set of sales; filter any with time + # remaining that we don't own. + for sale_item, sale_info in list(sales_raw.items()): + if not _ba.get_purchased(sale_item): + to_end = (datetime.datetime.utcfromtimestamp( + sale_info['e']) - + datetime.datetime.utcnow()).total_seconds() + if to_end > 0: + sale_times.append(to_end) + except Exception: + ba.print_exception("Error parsing sales") + if sale_times: + sale_time = int(min(sale_times) * 1000) + + if sale_time is not None: + ba.textwidget(edit=self._sale_title_text, + text=ba.Lstr(resource='store.saleText')) + ba.textwidget(edit=self._sale_time_text, + text=ba.timestring( + sale_time, + centi=False, + timeformat=TimeFormat.MILLISECONDS)) + ba.imagewidget(edit=self._sale_backing, opacity=1.0) + ba.imagewidget(edit=self._available_purchase_backing, opacity=1.0) + ba.textwidget(edit=self._available_purchase_text, text='') + ba.imagewidget(edit=self._available_purchase_backing, opacity=0.0) + else: + ba.imagewidget(edit=self._sale_backing, opacity=0.0) + ba.textwidget(edit=self._sale_time_text, text='') + ba.textwidget(edit=self._sale_title_text, text='') + if available_purchases > 0: + ba.textwidget(edit=self._available_purchase_text, + text=str(available_purchases)) + ba.imagewidget(edit=self._available_purchase_backing, + opacity=1.0) + else: + ba.textwidget(edit=self._available_purchase_text, text='') + ba.imagewidget(edit=self._available_purchase_backing, + opacity=0.0) diff --git a/assets/src/data/scripts/bastd/ui/store/item.py b/assets/src/data/scripts/bastd/ui/store/item.py new file mode 100644 index 00000000..dcfa2633 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/store/item.py @@ -0,0 +1,543 @@ +"""UI functionality related to UI items.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Tuple, Dict, Optional + + +def instantiate_store_item_display(item_name: str, + item: Dict[str, Any], + parent_widget: ba.Widget, + b_pos: Tuple[float, float], + b_width: float, + b_height: float, + boffs_h: float = 0.0, + boffs_h2: float = 0.0, + boffs_v2: float = 0, + delay: float = 0.0, + button: bool = True) -> None: + """(internal)""" + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + from ba.internal import (get_store_item, get_store_item_name_translated, + get_clean_price) + del boffs_h # unused arg + del boffs_h2 # unused arg + del boffs_v2 # unused arg + item_info = get_store_item(item_name) + title_v = 0.24 + price_v = 0.145 + base_text_scale = 1.0 + + item['name'] = title = get_store_item_name_translated(item_name) + + btn: Optional[ba.Widget] + if button: + item['button'] = btn = ba.buttonwidget(parent=parent_widget, + position=b_pos, + transition_delay=delay, + show_buffer_top=76.0, + enable_sound=False, + button_type='square', + size=(b_width, b_height), + autoselect=True, + label='') + ba.widget(edit=btn, show_buffer_bottom=76.0) + else: + btn = None + + b_offs_x = -0.015 * b_width + check_pos = 0.76 + + icon_tex = None + tint_tex = None + tint_color = None + tint2_color = None + tex_name: Optional[str] = None + desc: Optional[str] = None + modes: Optional[ba.Lstr] = None + + if item_name.startswith('characters.'): + character = ba.app.spaz_appearances[item_info['character']] + tint_color = ( + item_info['color'] if 'color' in item_info else + character.default_color if character.default_color is not None else + (1, 1, 1)) + tint2_color = (item_info['highlight'] if 'highlight' in item_info else + character.default_highlight if + character.default_highlight is not None else (1, 1, 1)) + icon_tex = character.icon_texture + tint_tex = character.icon_mask_texture + title_v = 0.255 + price_v = 0.145 + elif item_name in ['upgrades.pro', 'pro']: + base_text_scale = 0.6 + title_v = 0.85 + price_v = 0.15 + elif item_name.startswith('maps.'): + map_type = item_info['map_type'] + tex_name = map_type.get_preview_texture_name() + title_v = 0.312 + price_v = 0.17 + + elif item_name.startswith('games.'): + gametype = item_info['gametype'] + modes_l = [] + if gametype.supports_session_type(ba.CoopSession): + modes_l.append(ba.Lstr(resource='playModes.coopText')) + if gametype.supports_session_type(ba.TeamsSession): + modes_l.append(ba.Lstr(resource='playModes.teamsText')) + if gametype.supports_session_type(ba.FreeForAllSession): + modes_l.append(ba.Lstr(resource='playModes.freeForAllText')) + + if len(modes_l) == 3: + modes = ba.Lstr(value='${A}, ${B}, ${C}', + subs=[('${A}', modes_l[0]), ('${B}', modes_l[1]), + ('${C}', modes_l[2])]) + elif len(modes_l) == 2: + modes = ba.Lstr(value='${A}, ${B}', + subs=[('${A}', modes_l[0]), ('${B}', modes_l[1])]) + elif len(modes_l) == 1: + modes = modes_l[0] + else: + raise Exception() + desc = gametype.get_description_display_string(ba.CoopSession) + tex_name = item_info['previewTex'] + base_text_scale = 0.8 + title_v = 0.48 + price_v = 0.17 + + elif item_name.startswith('icons.'): + base_text_scale = 1.5 + price_v = 0.2 + check_pos = 0.6 + + if item_name.startswith('characters.'): + frame_size = b_width * 0.7 + im_dim = frame_size * (100.0 / 113.0) + im_pos = (b_pos[0] + b_width * 0.5 - im_dim * 0.5 + b_offs_x, + b_pos[1] + b_height * 0.57 - im_dim * 0.5) + mask_texture = ba.gettexture('characterIconMask') + assert icon_tex is not None + assert tint_tex is not None + ba.imagewidget(parent=parent_widget, + position=im_pos, + size=(im_dim, im_dim), + color=(1, 1, 1), + transition_delay=delay, + mask_texture=mask_texture, + draw_controller=btn, + texture=ba.gettexture(icon_tex), + tint_texture=ba.gettexture(tint_tex), + tint_color=tint_color, + tint2_color=tint2_color) + + if item_name in ['pro', 'upgrades.pro']: + frame_size = b_width * 0.5 + im_dim = frame_size * (100.0 / 113.0) + im_pos = (b_pos[0] + b_width * 0.5 - im_dim * 0.5 + b_offs_x, + b_pos[1] + b_height * 0.5 - im_dim * 0.5) + ba.imagewidget(parent=parent_widget, + position=im_pos, + size=(im_dim, im_dim), + transition_delay=delay, + draw_controller=btn, + color=(0.3, 0.0, 0.3), + opacity=0.3, + texture=ba.gettexture('logo')) + txt = ba.Lstr(resource='store.bombSquadProNewDescriptionText') + + # t = 'foo\nfoo\nfoo\nfoo\nfoo\nfoo' + item['descriptionText'] = ba.textwidget( + parent=parent_widget, + text=txt, + position=(b_pos[0] + b_width * 0.5, b_pos[1] + b_height * 0.69), + transition_delay=delay, + scale=b_width * (1.0 / 230.0) * base_text_scale * 0.75, + maxwidth=b_width * 0.75, + max_height=b_height * 0.2, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn, + color=(0.3, 1, 0.3)) + + extra_backings = item['extra_backings'] = [] + extra_images = item['extra_images'] = [] + extra_texts = item['extra_texts'] = [] + extra_texts_2 = item['extra_texts_2'] = [] + + backing_color = (0.5, 0.8, 0.3) if button else (0.6, 0.5, 0.65) + b_square_texture = ba.gettexture('buttonSquare') + char_mask_texture = ba.gettexture('characterIconMask') + + pos = (0.17, 0.43) + tile_size = (b_width * 0.16 * 1.2, b_width * 0.2 * 1.2) + tile_pos = (b_pos[0] + b_width * pos[0], b_pos[1] + b_height * pos[1]) + extra_backings.append( + ba.imagewidget(parent=parent_widget, + position=(tile_pos[0] - tile_size[0] * 0.5, + tile_pos[1] - tile_size[1] * 0.5), + size=tile_size, + transition_delay=delay, + draw_controller=btn, + color=backing_color, + texture=b_square_texture)) + im_size = tile_size[0] * 0.8 + extra_images.append( + ba.imagewidget(parent=parent_widget, + position=(tile_pos[0] - im_size * 0.5, + tile_pos[1] - im_size * 0.4), + size=(im_size, im_size), + transition_delay=delay, + draw_controller=btn, + color=(1, 1, 1), + texture=ba.gettexture('ticketsMore'))) + bonus_tickets = str( + _ba.get_account_misc_read_val('proBonusTickets', 100)) + extra_texts.append( + ba.textwidget(parent=parent_widget, + draw_controller=btn, + position=(tile_pos[0] - tile_size[0] * 0.03, + tile_pos[1] - tile_size[1] * 0.25), + size=(0, 0), + color=(0.6, 1, 0.6), + transition_delay=delay, + h_align='center', + v_align='center', + maxwidth=tile_size[0] * 0.7, + scale=0.55, + text=ba.Lstr(resource='getTicketsWindow.ticketsText', + subs=[('${COUNT}', bonus_tickets)]), + flatness=1.0, + shadow=0.0)) + + for charname, pos in [('Kronk', (0.32, 0.45)), ('Zoe', (0.425, 0.4)), + ('Jack Morgan', (0.555, 0.45)), + ('Mel', (0.645, 0.4))]: + tile_size = (b_width * 0.16 * 0.9, b_width * 0.2 * 0.9) + tile_pos = (b_pos[0] + b_width * pos[0], + b_pos[1] + b_height * pos[1]) + character = ba.app.spaz_appearances[charname] + extra_backings.append( + ba.imagewidget(parent=parent_widget, + position=(tile_pos[0] - tile_size[0] * 0.5, + tile_pos[1] - tile_size[1] * 0.5), + size=tile_size, + transition_delay=delay, + draw_controller=btn, + color=backing_color, + texture=b_square_texture)) + im_size = tile_size[0] * 0.7 + extra_images.append( + ba.imagewidget(parent=parent_widget, + position=(tile_pos[0] - im_size * 0.53, + tile_pos[1] - im_size * 0.35), + size=(im_size, im_size), + transition_delay=delay, + draw_controller=btn, + color=(1, 1, 1), + texture=ba.gettexture(character.icon_texture), + tint_texture=ba.gettexture( + character.icon_mask_texture), + tint_color=character.default_color, + tint2_color=character.default_highlight, + mask_texture=char_mask_texture)) + extra_texts.append( + ba.textwidget(parent=parent_widget, + draw_controller=btn, + position=(tile_pos[0] - im_size * 0.03, + tile_pos[1] - im_size * 0.51), + size=(0, 0), + color=(0.6, 1, 0.6), + transition_delay=delay, + h_align='center', + v_align='center', + maxwidth=tile_size[0] * 0.7, + scale=0.55, + text=ba.Lstr(translate=('characterNames', + charname)), + flatness=1.0, + shadow=0.0)) + + # If we have a 'total-worth' item-id for this id, show that price so + # the user knows how much this is worth. + total_worth_item = _ba.get_account_misc_read_val('twrths', + {}).get(item_name) + total_worth_price: Optional[str] + if total_worth_item is not None: + total_worth_price = get_clean_price( + _ba.get_price(total_worth_item)) + else: + total_worth_price = None + + if total_worth_price is not None: + total_worth_text = ba.Lstr(resource='store.totalWorthText', + subs=[('${TOTAL_WORTH}', + total_worth_price)]) + extra_texts_2.append( + ba.textwidget(parent=parent_widget, + text=total_worth_text, + position=(b_pos[0] + b_width * 0.5 + b_offs_x, + b_pos[1] + b_height * 0.25), + transition_delay=delay, + scale=b_width * (1.0 / 230.0) * base_text_scale * + 0.45, + maxwidth=b_width * 0.5, + size=(0, 0), + h_align='center', + v_align='center', + shadow=1.0, + flatness=1.0, + draw_controller=btn, + color=(0.3, 1, 1))) + + model_opaque = ba.getmodel('level_select_button_opaque') + model_transparent = ba.getmodel('level_select_button_transparent') + mask_tex = ba.gettexture('mapPreviewMask') + for levelname, preview_tex_name, pos in [ + ('Infinite Onslaught', 'doomShroomPreview', (0.80, 0.48)), + ('Infinite Runaround', 'towerDPreview', (0.80, 0.32)) + ]: + tile_size = (b_width * 0.2, b_width * 0.13) + tile_pos = (b_pos[0] + b_width * pos[0], + b_pos[1] + b_height * pos[1]) + im_size = tile_size[0] * 0.8 + extra_backings.append( + ba.imagewidget(parent=parent_widget, + position=(tile_pos[0] - tile_size[0] * 0.5, + tile_pos[1] - tile_size[1] * 0.5), + size=tile_size, + transition_delay=delay, + draw_controller=btn, + color=backing_color, + texture=b_square_texture)) + # hack - gotta draw two transparent versions to avoid z issues + for mod in model_opaque, model_transparent: + extra_images.append( + ba.imagewidget(parent=parent_widget, + position=(tile_pos[0] - im_size * 0.52, + tile_pos[1] - im_size * 0.2), + size=(im_size, im_size * 0.5), + transition_delay=delay, + model_transparent=mod, + mask_texture=mask_tex, + draw_controller=btn, + texture=ba.gettexture(preview_tex_name))) + + extra_texts.append( + ba.textwidget(parent=parent_widget, + draw_controller=btn, + position=(tile_pos[0] - im_size * 0.03, + tile_pos[1] - im_size * 0.2), + size=(0, 0), + color=(0.6, 1, 0.6), + transition_delay=delay, + h_align='center', + v_align='center', + maxwidth=tile_size[0] * 0.7, + scale=0.55, + text=ba.Lstr(translate=('coopLevelNames', + levelname)), + flatness=1.0, + shadow=0.0)) + + if item_name.startswith('icons.'): + item['icon_text'] = ba.textwidget( + parent=parent_widget, + text=item_info['icon'], + position=(b_pos[0] + b_width * 0.5, b_pos[1] + b_height * 0.5), + transition_delay=delay, + scale=b_width * (1.0 / 230.0) * base_text_scale * 2.0, + maxwidth=b_width * 0.9, + max_height=b_height * 0.9, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn) + + if item_name.startswith('maps.'): + frame_size = b_width * 0.9 + im_dim = frame_size * (100.0 / 113.0) + im_pos = (b_pos[0] + b_width * 0.5 - im_dim * 0.5 + b_offs_x, + b_pos[1] + b_height * 0.62 - im_dim * 0.25) + model_opaque = ba.getmodel('level_select_button_opaque') + model_transparent = ba.getmodel('level_select_button_transparent') + mask_tex = ba.gettexture('mapPreviewMask') + assert tex_name is not None + ba.imagewidget(parent=parent_widget, + position=im_pos, + size=(im_dim, im_dim * 0.5), + transition_delay=delay, + model_opaque=model_opaque, + model_transparent=model_transparent, + mask_texture=mask_tex, + draw_controller=btn, + texture=ba.gettexture(tex_name)) + + if item_name.startswith('games.'): + frame_size = b_width * 0.8 + im_dim = frame_size * (100.0 / 113.0) + im_pos = (b_pos[0] + b_width * 0.5 - im_dim * 0.5 + b_offs_x, + b_pos[1] + b_height * 0.72 - im_dim * 0.25) + model_opaque = ba.getmodel('level_select_button_opaque') + model_transparent = ba.getmodel('level_select_button_transparent') + mask_tex = ba.gettexture('mapPreviewMask') + assert tex_name is not None + ba.imagewidget(parent=parent_widget, + position=im_pos, + size=(im_dim, im_dim * 0.5), + transition_delay=delay, + model_opaque=model_opaque, + model_transparent=model_transparent, + mask_texture=mask_tex, + draw_controller=btn, + texture=ba.gettexture(tex_name)) + item['descriptionText'] = ba.textwidget( + parent=parent_widget, + text=desc, + position=(b_pos[0] + b_width * 0.5, b_pos[1] + b_height * 0.36), + transition_delay=delay, + scale=b_width * (1.0 / 230.0) * base_text_scale * 0.78, + maxwidth=b_width * 0.8, + max_height=b_height * 0.14, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn, + flatness=1.0, + shadow=0.0, + color=(0.6, 1, 0.6)) + item['gameModesText'] = ba.textwidget( + parent=parent_widget, + text=modes, + position=(b_pos[0] + b_width * 0.5, b_pos[1] + b_height * 0.26), + transition_delay=delay, + scale=b_width * (1.0 / 230.0) * base_text_scale * 0.65, + maxwidth=b_width * 0.8, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn, + shadow=0, + flatness=1.0, + color=(0.6, 0.8, 0.6)) + + if not item_name.startswith('icons.'): + item['title_text'] = ba.textwidget( + parent=parent_widget, + text=title, + position=(b_pos[0] + b_width * 0.5 + b_offs_x, + b_pos[1] + b_height * title_v), + transition_delay=delay, + scale=b_width * (1.0 / 230.0) * base_text_scale, + maxwidth=b_width * 0.8, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn, + color=(0.7, 0.9, 0.7, 1.0)) + + item['purchase_check'] = ba.imagewidget( + parent=parent_widget, + position=(b_pos[0] + b_width * check_pos, b_pos[1] + b_height * 0.05), + transition_delay=delay, + model_transparent=ba.getmodel('checkTransparent'), + opacity=0.0, + size=(60, 60), + color=(0.6, 0.5, 0.8), + draw_controller=btn, + texture=ba.gettexture('uiAtlas')) + item['price_widget'] = ba.textwidget( + parent=parent_widget, + text='', + position=(b_pos[0] + b_width * 0.5 + b_offs_x, + b_pos[1] + b_height * price_v), + transition_delay=delay, + scale=b_width * (1.0 / 300.0) * base_text_scale, + maxwidth=b_width * 0.9, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn, + color=(0.2, 1, 0.2, 1.0)) + item['price_widget_left'] = ba.textwidget( + parent=parent_widget, + text='', + position=(b_pos[0] + b_width * 0.33 + b_offs_x, + b_pos[1] + b_height * price_v), + transition_delay=delay, + scale=b_width * (1.0 / 300.0) * base_text_scale, + maxwidth=b_width * 0.3, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn, + color=(0.2, 1, 0.2, 0.5)) + item['price_widget_right'] = ba.textwidget( + parent=parent_widget, + text='', + position=(b_pos[0] + b_width * 0.66 + b_offs_x, + b_pos[1] + b_height * price_v), + transition_delay=delay, + scale=1.1 * b_width * (1.0 / 300.0) * base_text_scale, + maxwidth=b_width * 0.3, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn, + color=(0.2, 1, 0.2, 1.0)) + item['price_slash_widget'] = ba.imagewidget( + parent=parent_widget, + position=(b_pos[0] + b_width * 0.33 + b_offs_x - 36, + b_pos[1] + b_height * price_v - 35), + transition_delay=delay, + texture=ba.gettexture('slash'), + opacity=0.0, + size=(70, 70), + draw_controller=btn, + color=(1, 0, 0)) + badge_rad = 44 + badge_center = (b_pos[0] + b_width * 0.1 + b_offs_x, + b_pos[1] + b_height * 0.87) + item['sale_bg_widget'] = ba.imagewidget( + parent=parent_widget, + position=(badge_center[0] - badge_rad, badge_center[1] - badge_rad), + opacity=0.0, + transition_delay=delay, + texture=ba.gettexture('circleZigZag'), + draw_controller=btn, + size=(badge_rad * 2, badge_rad * 2), + color=(0.5, 0, 1)) + item['sale_title_widget'] = ba.textwidget(parent=parent_widget, + position=(badge_center[0], + badge_center[1] + 12), + transition_delay=delay, + scale=1.0, + maxwidth=badge_rad * 1.6, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn, + shadow=0.0, + flatness=1.0, + color=(0, 1, 0)) + item['sale_time_widget'] = ba.textwidget(parent=parent_widget, + position=(badge_center[0], + badge_center[1] - 12), + transition_delay=delay, + scale=0.7, + maxwidth=badge_rad * 1.6, + size=(0, 0), + h_align='center', + v_align='center', + draw_controller=btn, + shadow=0.0, + flatness=1.0, + color=(0.0, 1, 0.0, 1)) diff --git a/assets/src/data/scripts/bastd/ui/tabs.py b/assets/src/data/scripts/bastd/ui/tabs.py new file mode 100644 index 00000000..172fc721 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/tabs.py @@ -0,0 +1,74 @@ +"""UI functionality for creating tab style buttons.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba + +if TYPE_CHECKING: + from typing import Any, Callable, Dict, Tuple, List, Sequence + + +def create_tab_buttons(parent_widget: ba.Widget, + tabs: List[Tuple[str, ba.Lstr]], + pos: Sequence[float], + size: Sequence[float], + on_select_call: Callable[[Any], Any] = None, + return_extra_info: bool = False) -> Dict[str, Any]: + """(internal)""" + # pylint: disable=too-many-locals + tab_pos_v = pos[1] + tab_buttons = {} + tab_buttons_indexed = [] + tab_button_width = float(size[0]) / len(tabs) + + # add a bit more visual spacing as our buttons get narrower + tab_spacing = (250.0 - tab_button_width) * 0.06 + positions = [] + sizes = [] + h = pos[0] + for _i, tab in enumerate(tabs): + + def _tick_and_call(call: Callable[[Any], Any], arg: Any) -> None: + ba.playsound(ba.getsound('click01')) + call(arg) + + pos = (h + tab_spacing * 0.5, tab_pos_v) + size = (tab_button_width - tab_spacing, 50.0) + positions.append(pos) + sizes.append(size) + btn = ba.buttonwidget(parent=parent_widget, + position=pos, + autoselect=True, + button_type='tab', + size=size, + label=tab[1], + enable_sound=False, + on_activate_call=ba.Call(_tick_and_call, + on_select_call, tab[0])) + h += tab_button_width + tab_buttons[tab[0]] = btn + tab_buttons_indexed.append(btn) + if return_extra_info: + return { + 'buttons': tab_buttons, + 'buttons_indexed': tab_buttons_indexed, + 'positions': positions, + 'sizes': sizes + } + return tab_buttons + + +def update_tab_button_colors(tabs: Dict[str, ba.Widget], + selected_tab: str) -> None: + """(internal)""" + for t_id, tbutton in list(tabs.items()): + if t_id == selected_tab: + ba.buttonwidget(edit=tbutton, + color=(0.5, 0.4, 0.93), + textcolor=(0.85, 0.75, 0.95)) # lit + else: + ba.buttonwidget(edit=tbutton, + color=(0.52, 0.48, 0.63), + textcolor=(0.65, 0.6, 0.7)) # unlit diff --git a/assets/src/data/scripts/bastd/ui/teamnamescolors.py b/assets/src/data/scripts/bastd/ui/teamnamescolors.py new file mode 100644 index 00000000..f5f98b45 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/teamnamescolors.py @@ -0,0 +1,179 @@ +"""Provides a window to customize team names and colors.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import ba +from bastd.ui import popup + +if TYPE_CHECKING: + from typing import Tuple, List, Sequence + from bastd.ui.colorpicker import ColorPicker + + +class TeamNamesColorsWindow(popup.PopupWindow): + """A popup window for customizing team names and colors.""" + + def __init__(self, scale_origin: Tuple[float, float]): + from ba.internal import DEFAULT_TEAM_COLORS, DEFAULT_TEAM_NAMES + self._width = 500 + self._height = 330 + self._transitioning_out = False + self._max_name_length = 16 + + # Creates our _root_widget. + scale = (1.69 if ba.app.small_ui else 1.1 if ba.app.med_ui else 0.85) + super().__init__(position=scale_origin, + size=(self._width, self._height), + scale=scale) + + bs_config = ba.app.config + self._names = list( + bs_config.get('Custom Team Names', DEFAULT_TEAM_NAMES)) + # We need to flatten the translation since it will be an + # editable string. + self._names = [ + ba.Lstr(translate=('teamNames', n)).evaluate() for n in self._names + ] + self._colors = list( + bs_config.get('Custom Team Colors', DEFAULT_TEAM_COLORS)) + + self._color_buttons: List[ba.Widget] = [] + self._color_text_fields: List[ba.Widget] = [] + + ba.buttonwidget( + parent=self.root_widget, + label=ba.Lstr(resource='settingsWindowAdvanced.resetText'), + autoselect=True, + scale=0.7, + on_activate_call=self._reset, + size=(120, 50), + position=(self._width * 0.5 - 60 * 0.7, self._height - 60)) + + for i in range(2): + self._color_buttons.append( + ba.buttonwidget(parent=self.root_widget, + autoselect=True, + position=(50, 0 + 195 - 90 * i), + on_activate_call=ba.Call(self._color_click, i), + size=(70, 70), + color=self._colors[i], + label='', + button_type='square')) + self._color_text_fields.append( + ba.textwidget(parent=self.root_widget, + position=(135, 0 + 201 - 90 * i), + size=(280, 46), + text=self._names[i], + h_align="left", + v_align="center", + max_chars=self._max_name_length, + color=self._colors[i], + description=ba.Lstr(resource='nameText'), + editable=True, + padding=4)) + ba.buttonwidget(parent=self.root_widget, + label=ba.Lstr(resource='cancelText'), + autoselect=True, + on_activate_call=self._on_cancel_press, + size=(150, 50), + position=(self._width * 0.5 - 200, 20)) + ba.buttonwidget(parent=self.root_widget, + label=ba.Lstr(resource='saveText'), + autoselect=True, + on_activate_call=self._save, + size=(150, 50), + position=(self._width * 0.5 + 50, 20)) + ba.containerwidget(edit=self.root_widget, + selected_child=self._color_buttons[0]) + self._update() + + def _color_click(self, i: int) -> None: + from bastd.ui.colorpicker import ColorPicker + ColorPicker(parent=self.root_widget, + position=self._color_buttons[i].get_screen_space_center(), + offset=(270.0, 0), + initial_color=self._colors[i], + delegate=self, + tag=i) + + def color_picker_closing(self, picker: ColorPicker) -> None: + """Called when the color picker is closing.""" + + def color_picker_selected_color(self, picker: ColorPicker, + color: Sequence[float]) -> None: + """Called when a color is selected in the color picker.""" + self._colors[picker.get_tag()] = color + self._update() + + def _reset(self) -> None: + from ba.internal import DEFAULT_TEAM_NAMES, DEFAULT_TEAM_COLORS + for i in range(2): + self._colors[i] = DEFAULT_TEAM_COLORS[i] + name = ba.Lstr(translate=('teamNames', + DEFAULT_TEAM_NAMES[i])).evaluate() + if len(name) > self._max_name_length: + print('GOT DEFAULT TEAM NAME LONGER THAN MAX LENGTH') + ba.textwidget(edit=self._color_text_fields[i], text=name) + self._update() + + def _update(self) -> None: + for i in range(2): + ba.buttonwidget(edit=self._color_buttons[i], color=self._colors[i]) + ba.textwidget(edit=self._color_text_fields[i], + color=self._colors[i]) + + def _save(self) -> None: + from ba.internal import DEFAULT_TEAM_COLORS, DEFAULT_TEAM_NAMES + cfg = ba.app.config + + # First, determine whether the values here are defaults, in which case + # we can clear any values from prefs. Currently if the string matches + # either the default raw value or its translation we consider it + # default. (the fact that team names get translated makes this + # situation a bit sloppy) + new_names: List[str] = [] + is_default = True + for i in range(2): + name = cast(str, ba.textwidget(query=self._color_text_fields[i])) + if not name: + ba.screenmessage(ba.Lstr(resource='nameNotEmptyText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + new_names.append(name) + + for i in range(2): + if self._colors[i] != DEFAULT_TEAM_COLORS[i]: + is_default = False + default_team_name = DEFAULT_TEAM_NAMES[i] + default_team_name_translated = ba.Lstr( + translate=('teamNames', default_team_name)).evaluate() + if ((new_names[i] != default_team_name + and new_names[i] != default_team_name_translated)): + is_default = False + + if is_default: + for key in ('Custom Team Names', 'Custom Team Colors'): + if key in cfg: + del cfg[key] + else: + cfg['Custom Team Names'] = tuple(new_names) + cfg['Custom Team Colors'] = tuple(self._colors) + + cfg.commit() + ba.playsound(ba.getsound('gunCocking')) + self._transition_out() + + def _transition_out(self, transition: str = 'out_scale') -> None: + if not self._transitioning_out: + self._transitioning_out = True + ba.containerwidget(edit=self.root_widget, transition=transition) + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._transition_out() + + def _on_cancel_press(self) -> None: + self._transition_out() diff --git a/assets/src/data/scripts/bastd/ui/telnet.py b/assets/src/data/scripts/bastd/ui/telnet.py new file mode 100644 index 00000000..efb70d0e --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/telnet.py @@ -0,0 +1,49 @@ +"""UI functionality for telnet access.""" + +from __future__ import annotations + +import _ba +import ba + + +class TelnetAccessRequestWindow(ba.OldWindow): + """Window asking the user whether to allow a telnet connection.""" + + def __init__(self) -> None: + width = 400 + height = 100 + text = ba.Lstr(resource='telnetAccessText') + + super().__init__(root_widget=ba.containerwidget( + size=(width, height + 40), + transition='in_right', + scale=1.7 if ba.app.small_ui else 1.3 if ba.app.med_ui else 1.0)) + padding = 20 + ba.textwidget(parent=self._root_widget, + position=(padding, padding + 33), + size=(width - 2 * padding, height - 2 * padding), + h_align="center", + v_align="top", + text=text) + btn = ba.buttonwidget(parent=self._root_widget, + position=(20, 20), + size=(140, 50), + label=ba.Lstr(resource='denyText'), + on_activate_call=self._cancel) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + ba.containerwidget(edit=self._root_widget, selected_child=btn) + + ba.buttonwidget(parent=self._root_widget, + position=(width - 155, 20), + size=(140, 50), + label=ba.Lstr(resource='allowText'), + on_activate_call=self._ok) + + def _cancel(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_right') + _ba.set_telnet_access_enabled(False) + + def _ok(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_left') + _ba.set_telnet_access_enabled(True) + ba.screenmessage(ba.Lstr(resource='telnetAccessGrantedText')) diff --git a/assets/src/data/scripts/bastd/ui/tournamententry.py b/assets/src/data/scripts/bastd/ui/tournamententry.py new file mode 100644 index 00000000..79d6c9d2 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/tournamententry.py @@ -0,0 +1,619 @@ +"""Defines a popup window for entering tournaments.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.ui import popup + +if TYPE_CHECKING: + from typing import Any, Tuple, Callable, Optional, Dict + + +class TournamentEntryWindow(popup.PopupWindow): + """Popup window for entering tournaments.""" + + def __init__(self, + tournament_id: str, + tournament_activity: ba.Activity = None, + position: Tuple[float, float] = (0.0, 0.0), + delegate: Any = None, + scale: float = None, + offset: Tuple[float, float] = (0.0, 0.0), + on_close_call: Callable[[], Any] = None): + # needs some tidying + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + + ba.set_analytics_screen('Tournament Entry Window') + + self._tournament_id = tournament_id + self._tournament_info = (ba.app.tournament_info[self._tournament_id]) + + # Set a few vars depending on the tourney fee. + self._fee = self._tournament_info['fee'] + self._allow_ads = self._tournament_info['allowAds'] + if self._fee == 4: + self._purchase_name = 'tournament_entry_4' + self._purchase_price_name = 'price.tournament_entry_4' + elif self._fee == 3: + self._purchase_name = 'tournament_entry_3' + self._purchase_price_name = 'price.tournament_entry_3' + elif self._fee == 2: + self._purchase_name = 'tournament_entry_2' + self._purchase_price_name = 'price.tournament_entry_2' + elif self._fee == 1: + self._purchase_name = 'tournament_entry_1' + self._purchase_price_name = 'price.tournament_entry_1' + else: + if self._fee != 0: + raise Exception("invalid fee: " + str(self._fee)) + self._purchase_name = 'tournament_entry_0' + self._purchase_price_name = 'price.tournament_entry_0' + + self._purchase_price: Optional[int] = None + + self._on_close_call = on_close_call + if scale is None: + scale = (2.3 + if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + self._delegate = delegate + self._transitioning_out = False + + self._tournament_activity = tournament_activity + + self._width = 340 + self._height = 220 + + bg_color = (0.5, 0.4, 0.6) + + # Creates our root_widget. + popup.PopupWindow.__init__(self, + position=position, + size=(self._width, self._height), + scale=scale, + bg_color=bg_color, + offset=offset, + toolbar_visibility='menu_currency') + + self._last_ad_press_time = -9999.0 + self._last_ticket_press_time = -9999.0 + self._entering = False + self._launched = False + + # Show the ad button only if we support ads *and* it has a level 1 fee. + self._do_ad_btn = (_ba.has_video_ads() and self._allow_ads) + + x_offs = 0 if self._do_ad_btn else 85 + + self._cancel_button = ba.buttonwidget(parent=self.root_widget, + position=(20, self._height - 30), + size=(50, 50), + scale=0.5, + label='', + color=bg_color, + on_activate_call=self._on_cancel, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + + self._title_text = ba.textwidget( + parent=self.root_widget, + position=(self._width * 0.5, self._height - 20), + size=(0, 0), + h_align='center', + v_align='center', + scale=0.6, + text=ba.Lstr(resource='tournamentEntryText'), + maxwidth=200, + color=(1, 1, 1, 0.4)) + + btn = self._pay_with_tickets_button = ba.buttonwidget( + parent=self.root_widget, + position=(30 + x_offs, 60), + autoselect=True, + button_type='square', + size=(120, 120), + label='', + on_activate_call=self._on_pay_with_tickets_press) + self._ticket_img_pos = (50 + x_offs, 94) + self._ticket_img_pos_free = (50 + x_offs, 80) + self._ticket_img = ba.imagewidget(parent=self.root_widget, + draw_controller=btn, + size=(80, 80), + position=self._ticket_img_pos, + texture=ba.gettexture('tickets')) + self._ticket_cost_text_position = (87 + x_offs, 88) + self._ticket_cost_text_position_free = (87 + x_offs, 120) + self._ticket_cost_text = ba.textwidget( + parent=self.root_widget, + draw_controller=btn, + position=self._ticket_cost_text_position, + size=(0, 0), + h_align='center', + v_align='center', + scale=0.6, + text='', + maxwidth=95, + color=(0, 1, 0)) + self._free_plays_remaining_text = ba.textwidget( + parent=self.root_widget, + draw_controller=btn, + position=(87 + x_offs, 78), + size=(0, 0), + h_align='center', + v_align='center', + scale=0.33, + text='', + maxwidth=95, + color=(0, 0.8, 0)) + self._pay_with_ad_btn: Optional[ba.Widget] + if self._do_ad_btn: + btn = self._pay_with_ad_btn = ba.buttonwidget( + parent=self.root_widget, + position=(190, 60), + autoselect=True, + button_type='square', + size=(120, 120), + label='', + on_activate_call=self._on_pay_with_ad_press) + self._pay_with_ad_img = ba.imagewidget(parent=self.root_widget, + draw_controller=btn, + size=(80, 80), + position=(210, 94), + texture=ba.gettexture('tv')) + + self._ad_text_position = (251, 88) + self._ad_text_position_remaining = (251, 92) + have_ad_tries_remaining = ( + self._tournament_info['adTriesRemaining'] is not None) + self._ad_text = ba.textwidget( + parent=self.root_widget, + draw_controller=btn, + position=self._ad_text_position_remaining + if have_ad_tries_remaining else self._ad_text_position, + size=(0, 0), + h_align='center', + v_align='center', + scale=0.6, + text=ba.Lstr(resource='watchAVideoText', + fallback_resource='watchAnAdText'), + maxwidth=95, + color=(0, 1, 0)) + ad_plays_remaining_text = ( + '' if not have_ad_tries_remaining else '' + + str(self._tournament_info['adTriesRemaining'])) + self._ad_plays_remaining_text = ba.textwidget( + parent=self.root_widget, + draw_controller=btn, + position=(251, 78), + size=(0, 0), + h_align='center', + v_align='center', + scale=0.33, + text=ad_plays_remaining_text, + maxwidth=95, + color=(0, 0.8, 0)) + + ba.textwidget(parent=self.root_widget, + position=(self._width * 0.5, 120), + size=(0, 0), + h_align='center', + v_align='center', + scale=0.6, + text=ba.Lstr(resource='orText', + subs=[('${A}', ''), ('${B}', '')]), + maxwidth=35, + color=(1, 1, 1, 0.5)) + else: + self._pay_with_ad_btn = None + + self._get_tickets_button: Optional[ba.Widget] + if not ba.app.toolbars: + self._get_tickets_button = ba.buttonwidget( + parent=self.root_widget, + position=(self._width - 190 + 110, 15), + autoselect=True, + scale=0.6, + size=(120, 60), + textcolor=(0.2, 1, 0.2), + label=ba.charstr(ba.SpecialChar.TICKET), + color=(0.6, 0.4, 0.7), + on_activate_call=self._on_get_tickets_press) + else: + self._get_tickets_button = None + + self._seconds_remaining = None + + ba.containerwidget(edit=self.root_widget, + cancel_button=self._cancel_button) + + # Let's also ask the server for info about this tournament + # (time remaining, etc) so we can show the user time remaining, + # disallow entry if time has run out, etc. + xoffs = 104 if ba.app.toolbars else 0 + self._time_remaining_text = ba.textwidget(parent=self.root_widget, + position=(70 + xoffs, 23), + size=(0, 0), + h_align='center', + v_align='center', + text='-', + scale=0.65, + maxwidth=100, + flatness=1.0, + color=(0.7, 0.7, 0.7)) + self._time_remaining_label_text = ba.textwidget( + parent=self.root_widget, + position=(70 + xoffs, 40), + size=(0, 0), + h_align='center', + v_align='center', + text=ba.Lstr(resource='coopSelectWindow.timeRemainingText'), + scale=0.45, + flatness=1.0, + maxwidth=100, + color=(0.7, 0.7, 0.7)) + + self._last_query_time: Optional[float] = None + + # If there seems to be a relatively-recent valid cached info for this + # tournament, use it. Otherwise we'll kick off a query ourselves. + if (self._tournament_id in ba.app.tournament_info + and ba.app.tournament_info[self._tournament_id]['valid'] and + (ba.time(ba.TimeType.REAL, ba.TimeFormat.MILLISECONDS) - + ba.app.tournament_info[self._tournament_id]['timeReceived'] < + 1000 * 60 * 5)): + try: + info = ba.app.tournament_info[self._tournament_id] + self._seconds_remaining = max( + 0, info['timeRemaining'] - int( + (ba.time(ba.TimeType.REAL, ba.TimeFormat.MILLISECONDS) + - info['timeReceived']) / 1000)) + self._have_valid_data = True + self._last_query_time = ba.time(ba.TimeType.REAL) + except Exception: + ba.print_exception("error using valid tourney data") + self._have_valid_data = False + else: + self._have_valid_data = False + + self._fg_state = ba.app.fg_state + self._running_query = False + self._update_timer = ba.Timer(1.0, + ba.WeakCall(self._update), + repeat=True, + timetype=ba.TimeType.REAL) + self._update() + self._restore_state() + + def _on_tournament_query_response(self, + data: Optional[Dict[str, Any]]) -> None: + from ba.internal import cache_tournament_info + self._running_query = False + if data is not None: + data = data['t'] # This used to be the whole payload. + cache_tournament_info(data) + self._seconds_remaining = ba.app.tournament_info[ + self._tournament_id]['timeRemaining'] + self._have_valid_data = True + + def _save_state(self) -> None: + if not self.root_widget: + return + sel = self.root_widget.get_selected_child() + if sel == self._pay_with_ad_btn: + sel_name = 'Ad' + else: + sel_name = 'Tickets' + cfg = ba.app.config + cfg['Tournament Pay Selection'] = sel_name + cfg.commit() + + def _restore_state(self) -> None: + try: + sel_name = ba.app.config['Tournament Pay Selection'] + except Exception: + sel_name = 'Tickets' + if sel_name == 'Ad' and self._pay_with_ad_btn is not None: + sel = self._pay_with_ad_btn + else: + sel = self._pay_with_tickets_button + ba.containerwidget(edit=self.root_widget, selected_child=sel) + + def _update(self) -> None: + # We may outlive our widgets. + if not self.root_widget: + return + + # If we've been foregrounded/backgrounded we need to re-grab data. + if self._fg_state != ba.app.fg_state: + self._fg_state = ba.app.fg_state + self._have_valid_data = False + + # If we need to run another tournament query, do so. + if not self._running_query and ( + (self._last_query_time is None) or (not self._have_valid_data) or + (ba.time(ba.TimeType.REAL) - self._last_query_time > 30.0)): + _ba.tournament_query(args={ + 'source': + 'entry window' if self._tournament_activity is None else + 'retry entry window' + }, + callback=ba.WeakCall( + self._on_tournament_query_response)) + self._last_query_time = ba.time(ba.TimeType.REAL) + self._running_query = True + + # Grab the latest info on our tourney. + self._tournament_info = ba.app.tournament_info[self._tournament_id] + + # If we don't have valid data always show a '-' for time. + if not self._have_valid_data: + ba.textwidget(edit=self._time_remaining_text, text='-') + else: + if self._seconds_remaining is not None: + self._seconds_remaining = max(0, self._seconds_remaining - 1) + ba.textwidget(edit=self._time_remaining_text, + text=ba.timestring( + self._seconds_remaining * 1000, + centi=False, + timeformat=ba.TimeFormat.MILLISECONDS)) + + # Keep price up-to-date and update the button with it. + self._purchase_price = _ba.get_account_misc_read_val( + self._purchase_price_name, None) + + ba.textwidget( + edit=self._ticket_cost_text, + text=(ba.Lstr(resource='getTicketsWindow.freeText') + if self._purchase_price == 0 else ba.Lstr( + resource='getTicketsWindow.ticketsText', + subs=[('${COUNT}', str(self._purchase_price) + if self._purchase_price is not None else '?')])), + position=self._ticket_cost_text_position_free + if self._purchase_price == 0 else self._ticket_cost_text_position, + scale=1.0 if self._purchase_price == 0 else 0.6) + + ba.textwidget( + edit=self._free_plays_remaining_text, + text='' if + (self._tournament_info['freeTriesRemaining'] in [None, 0] + or self._purchase_price != 0) else '' + + str(self._tournament_info['freeTriesRemaining'])) + + ba.imagewidget(edit=self._ticket_img, + opacity=0.2 if self._purchase_price == 0 else 1.0, + position=self._ticket_img_pos_free + if self._purchase_price == 0 else self._ticket_img_pos) + + if self._do_ad_btn: + enabled = _ba.have_incentivized_ad() + have_ad_tries_remaining = ( + self._tournament_info['adTriesRemaining'] is not None + and self._tournament_info['adTriesRemaining'] > 0) + ba.textwidget(edit=self._ad_text, + position=self._ad_text_position_remaining if + have_ad_tries_remaining else self._ad_text_position, + color=(0, 1, 0) if enabled else (0.5, 0.5, 0.5)) + ba.imagewidget(edit=self._pay_with_ad_img, + opacity=1.0 if enabled else 0.2) + ba.buttonwidget(edit=self._pay_with_ad_btn, + color=(0.5, 0.7, 0.2) if enabled else + (0.5, 0.5, 0.5)) + ad_plays_remaining_text = ( + '' if not have_ad_tries_remaining else '' + + str(self._tournament_info['adTriesRemaining'])) + ba.textwidget(edit=self._ad_plays_remaining_text, + text=ad_plays_remaining_text, + color=(0, 0.8, 0) if enabled else (0.4, 0.4, 0.4)) + + try: + t_str = str(_ba.get_account_ticket_count()) + except Exception: + t_str = '?' + if self._get_tickets_button is not None: + ba.buttonwidget(edit=self._get_tickets_button, + label=ba.charstr(ba.SpecialChar.TICKET) + t_str) + + def _launch(self) -> None: + if self._launched: + return + self._launched = True + launched = False + + # If they gave us an existing activity, just restart it. + if self._tournament_activity is not None: + try: + ba.timer(0.1, + lambda: ba.playsound(ba.getsound('cashRegister')), + timetype=ba.TimeType.REAL) + with ba.Context(self._tournament_activity): + self._tournament_activity.end({'outcome': 'restart'}, + force=True) + ba.timer(0.3, self._transition_out, timetype=ba.TimeType.REAL) + launched = True + ba.screenmessage(ba.Lstr(translate=('serverResponses', + 'Entering tournament...')), + color=(0, 1, 0)) + # We can hit exceptions here if _tournament_activity ends before + # our restart attempt happens. + # In this case we'll fall back to launching a new session. + # This is not ideal since players will have to rejoin, etc., + # but it works for now. + except Exception: + pass + + # If we had no existing activity (or were unable to restart it) + # launch a new session. + if not launched: + ba.timer(0.1, + lambda: ba.playsound(ba.getsound('cashRegister')), + timetype=ba.TimeType.REAL) + ba.timer( + 1.0, + lambda: ba.app.launch_coop_game( + self._tournament_info['game'], + args={ + 'min_players': self._tournament_info['minPlayers'], + 'max_players': self._tournament_info['maxPlayers'], + 'tournament_id': self._tournament_id + }), + timetype=ba.TimeType.REAL) + ba.timer(0.7, self._transition_out, timetype=ba.TimeType.REAL) + ba.screenmessage(ba.Lstr(translate=('serverResponses', + 'Entering tournament...')), + color=(0, 1, 0)) + + def _on_pay_with_tickets_press(self) -> None: + from bastd.ui import getcurrency + + # If we're already entering, ignore. + if self._entering: + return + + if not self._have_valid_data: + ba.screenmessage(ba.Lstr(resource='tournamentCheckingStateText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + # If we don't have a price. + if self._purchase_price is None: + ba.screenmessage(ba.Lstr(resource='tournamentCheckingStateText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + # Deny if it looks like the tourney has ended. + if self._seconds_remaining == 0: + ba.screenmessage(ba.Lstr(resource='tournamentEndedText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + # Deny if we don't have enough tickets. + ticket_count: Optional[int] + try: + ticket_count = _ba.get_account_ticket_count() + except Exception: + ticket_count = None + ticket_cost = self._purchase_price + if (ticket_count is not None and ticket_cost is not None + and ticket_count < ticket_cost): + getcurrency.show_get_tickets_prompt() + ba.playsound(ba.getsound('error')) + return + + cur_time = ba.time(ba.TimeType.REAL, ba.TimeFormat.MILLISECONDS) + self._last_ticket_press_time = cur_time + assert isinstance(ticket_cost, int) + _ba.in_game_purchase(self._purchase_name, ticket_cost) + + self._entering = True + _ba.add_transaction({ + 'type': 'ENTER_TOURNAMENT', + 'fee': self._fee, + 'tournamentID': self._tournament_id + }) + _ba.run_transactions() + self._launch() + + def _on_pay_with_ad_press(self) -> None: + from ba.internal import show_ad + + # If we're already entering, ignore. + if self._entering: + return + + if not self._have_valid_data: + ba.screenmessage(ba.Lstr(resource='tournamentCheckingStateText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + # Deny if it looks like the tourney has ended. + if self._seconds_remaining == 0: + ba.screenmessage(ba.Lstr(resource='tournamentEndedText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + cur_time = ba.time(ba.TimeType.REAL) + if cur_time - self._last_ad_press_time > 5.0: + self._last_ad_press_time = cur_time + show_ad('tournament_entry', + on_completion_call=ba.WeakCall(self._on_ad_complete), + pass_actually_showed=True) + + def _on_ad_complete(self, actually_showed: bool) -> None: + + # Make sure any transactions the ad added got locally applied + # (rewards added, etc.). + _ba.run_transactions() + + # If we're already entering the tourney, ignore. + if self._entering: + return + + if not actually_showed: + return + + # This should have awarded us the tournament_entry_ad purchase; + # make sure that's present. + # (otherwise the server will ignore our tournament entry anyway) + if not _ba.get_purchased('tournament_entry_ad'): + print('no tournament_entry_ad purchase present in _on_ad_complete') + ba.screenmessage(ba.Lstr(resource='errorText'), color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + self._entering = True + _ba.add_transaction({ + 'type': 'ENTER_TOURNAMENT', + 'fee': 'ad', + 'tournamentID': self._tournament_id + }) + _ba.run_transactions() + self._launch() + + def _on_get_tickets_press(self) -> None: + from bastd.ui import getcurrency + + # If we're already entering, ignore presses. + if self._entering: + return + + # Bring up get-tickets window and then kill ourself (we're on the + # overlay layer so we'd show up above it). + getcurrency.GetCurrencyWindow(modal=True, + origin_widget=self._get_tickets_button) + self._transition_out() + + def _on_cancel(self) -> None: + + # Don't allow canceling for several seconds after poking an enter + # button if it looks like we're waiting on a purchase or entering + # the tournament. + if ((ba.time(ba.TimeType.REAL, ba.TimeFormat.MILLISECONDS) - + self._last_ticket_press_time < 6000) and + (_ba.have_outstanding_transactions() + or _ba.get_purchased(self._purchase_name) or self._entering)): + ba.playsound(ba.getsound('error')) + return + self._transition_out() + + def _transition_out(self) -> None: + if not self.root_widget: + return + if not self._transitioning_out: + self._transitioning_out = True + self._save_state() + ba.containerwidget(edit=self.root_widget, transition='out_scale') + if self._on_close_call is not None: + self._on_close_call() + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._on_cancel() diff --git a/assets/src/data/scripts/bastd/ui/tournamentscores.py b/assets/src/data/scripts/bastd/ui/tournamentscores.py new file mode 100644 index 00000000..81ad9a24 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/tournamentscores.py @@ -0,0 +1,206 @@ +"""Provides a popup for viewing tournament scores.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _ba +import ba +from bastd.ui import popup as popup_ui + +if TYPE_CHECKING: + from typing import Any, Tuple, Sequence, Callable, Dict, Optional, List + + +class TournamentScoresWindow(popup_ui.PopupWindow): + """Window for viewing tournament scores.""" + + def __init__(self, + tournament_id: str, + tournament_activity: ba.GameActivity = None, + position: Tuple[float, float] = (0.0, 0.0), + scale: float = None, + offset: Tuple[float, float] = (0.0, 0.0), + tint_color: Sequence[float] = (1.0, 1.0, 1.0), + tint2_color: Sequence[float] = (1.0, 1.0, 1.0), + selected_character: str = None, + on_close_call: Callable[[], Any] = None): + + del tournament_activity # unused arg + del tint_color # unused arg + del tint2_color # unused arg + del selected_character # unused arg + self._tournament_id = tournament_id + self._subcontainer: Optional[ba.Widget] = None + self._on_close_call = on_close_call + if scale is None: + scale = (2.3 + if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + self._transitioning_out = False + + self._width = 400 + self._height = (300 + if ba.app.small_ui else 370 if ba.app.med_ui else 450) + + bg_color = (0.5, 0.4, 0.6) + + # creates our _root_widget + super().__init__(position=position, + size=(self._width, self._height), + scale=scale, + bg_color=bg_color, + offset=offset) + + # app = ba.app + + self._cancel_button = ba.buttonwidget( + parent=self.root_widget, + position=(50, self._height - 30), + size=(50, 50), + scale=0.5, + label='', + color=bg_color, + on_activate_call=self._on_cancel_press, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + + self._title_text = ba.textwidget( + parent=self.root_widget, + position=(self._width * 0.5, self._height - 20), + size=(0, 0), + h_align='center', + v_align='center', + scale=0.6, + text=ba.Lstr(resource='tournamentStandingsText'), + maxwidth=200, + color=(1, 1, 1, 0.4)) + + self._scrollwidget = ba.scrollwidget(parent=self.root_widget, + size=(self._width - 60, + self._height - 70), + position=(30, 30), + highlight=False, + simple_culling_v=10) + ba.widget(edit=self._scrollwidget, autoselect=True) + + self._loading_text = ba.textwidget( + parent=self._scrollwidget, + scale=0.5, + text=ba.Lstr(value='${A}...', + subs=[('${A}', ba.Lstr(resource='loadingText'))]), + size=(self._width - 60, 100), + h_align='center', + v_align='center') + + ba.containerwidget(edit=self.root_widget, + cancel_button=self._cancel_button) + + _ba.tournament_query(args={ + 'tournamentIDs': [tournament_id], + 'numScores': 50, + 'source': 'scores window' + }, + callback=ba.WeakCall( + self._on_tournament_query_response)) + + def _on_tournament_query_response(self, + data: Optional[Dict[str, Any]]) -> None: + if data is not None: + # this used to be the whole payload + data_t: List[Dict[str, Any]] = data['t'] + # kill our loading text if we've got scores.. otherwise just + # replace it with 'no scores yet' + if data_t[0]['scores']: + self._loading_text.delete() + else: + ba.textwidget(edit=self._loading_text, + text=ba.Lstr(resource='noScoresYetText')) + incr = 30 + sub_width = self._width - 90 + sub_height = 30 + len(data_t[0]['scores']) * incr + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(sub_width, + sub_height), + background=False) + for i, entry in enumerate(data_t[0]['scores']): + + ba.textwidget(parent=self._subcontainer, + position=(sub_width * 0.1 - 5, + sub_height - 20 - incr * i), + maxwidth=20, + scale=0.5, + color=(0.6, 0.6, 0.7), + flatness=1.0, + shadow=0.0, + text=str(i + 1), + size=(0, 0), + h_align='right', + v_align='center') + + ba.textwidget( + parent=self._subcontainer, + position=(sub_width * 0.25 - 2, + sub_height - 20 - incr * i), + maxwidth=sub_width * 0.24, + color=(0.9, 1.0, 0.9), + flatness=1.0, + shadow=0.0, + scale=0.6, + text=(ba.timestring(entry[0] * 10, + centi=True, + timeformat=ba.TimeFormat.MILLISECONDS) + if data_t[0]['scoreType'] == 'time' else str( + entry[0])), + size=(0, 0), + h_align='center', + v_align='center') + + txt = ba.textwidget( + parent=self._subcontainer, + position=(sub_width * 0.25, + sub_height - 20 - incr * i - (0.5 / 0.7) * incr), + maxwidth=sub_width * 0.6, + scale=0.7, + flatness=1.0, + shadow=0.0, + text=ba.Lstr(value=entry[1]), + selectable=True, + click_activate=True, + autoselect=True, + extra_touch_border_scale=0.0, + size=((sub_width * 0.6) / 0.7, incr / 0.7), + h_align='left', + v_align='center') + + ba.textwidget(edit=txt, + on_activate_call=ba.Call(self._show_player_info, + entry, txt)) + if i == 0: + ba.widget(edit=txt, up_widget=self._cancel_button) + + def _show_player_info(self, entry: Any, textwidget: ba.Widget) -> None: + from bastd.ui.account.viewer import AccountViewerWindow + # for the moment we only work if a single player-info is present.. + if len(entry[2]) != 1: + ba.playsound(ba.getsound('error')) + return + ba.playsound(ba.getsound('swish')) + AccountViewerWindow(account_id=entry[2][0].get('a', None), + profile_id=entry[2][0].get('p', None), + position=textwidget.get_screen_space_center()) + self._transition_out() + + def _on_cancel_press(self) -> None: + self._transition_out() + + def _transition_out(self) -> None: + if not self._transitioning_out: + self._transitioning_out = True + ba.containerwidget(edit=self.root_widget, transition='out_scale') + if self._on_close_call is not None: + self._on_close_call() + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._transition_out() diff --git a/assets/src/data/scripts/bastd/ui/trophies.py b/assets/src/data/scripts/bastd/ui/trophies.py new file mode 100644 index 00000000..d2e4c7e7 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/trophies.py @@ -0,0 +1,181 @@ +"""Provides a popup window for viewing trophies.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +from bastd.ui import popup + +if TYPE_CHECKING: + from typing import Any, Tuple, Dict, List + + +class TrophiesWindow(popup.PopupWindow): + """Popup window for viewing trophies.""" + + def __init__(self, + position: Tuple[float, float], + data: Dict[str, Any], + scale: float = None): + from ba.deprecated import get_resource + self._data = data + if scale is None: + scale = (2.3 + if ba.app.small_ui else 1.65 if ba.app.med_ui else 1.23) + self._transitioning_out = False + self._width = 300 + self._height = 300 + bg_color = (0.5, 0.4, 0.6) + + popup.PopupWindow.__init__(self, + position=position, + size=(self._width, self._height), + scale=scale, + bg_color=bg_color) + + self._cancel_button = ba.buttonwidget( + parent=self.root_widget, + position=(50, self._height - 30), + size=(50, 50), + scale=0.5, + label='', + color=bg_color, + on_activate_call=self._on_cancel_press, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + + self._title_text = ba.textwidget(parent=self.root_widget, + position=(self._width * 0.5, + self._height - 20), + size=(0, 0), + h_align='center', + v_align='center', + scale=0.6, + text=ba.Lstr(resource='trophiesText'), + maxwidth=200, + color=(1, 1, 1, 0.4)) + + self._scrollwidget = ba.scrollwidget(parent=self.root_widget, + size=(self._width - 60, + self._height - 70), + position=(30, 30), + capture_arrows=True) + ba.widget(edit=self._scrollwidget, autoselect=True) + + ba.containerwidget(edit=self.root_widget, + cancel_button=self._cancel_button) + + incr = 31 + sub_width = self._width - 90 + + trophy_types = [['0a'], ['0b'], ['1'], ['2'], ['3'], ['4']] + sub_height = 40 + len(trophy_types) * incr + + eq_text = get_resource('coopSelectWindow.powerRankingPointsEqualsText') + + self._subcontainer = ba.containerwidget(parent=self._scrollwidget, + size=(sub_width, sub_height), + background=False) + + total_pts = 0 + + multi_txt = get_resource('coopSelectWindow.powerRankingPointsMultText') + + total_pts += self._create_trophy_type_widgets(eq_text, incr, multi_txt, + sub_height, sub_width, + trophy_types) + + ba.textwidget(parent=self._subcontainer, + position=(sub_width * 1.0, + sub_height - 20 - incr * len(trophy_types)), + maxwidth=sub_width * 0.5, + scale=0.7, + color=(0.7, 0.8, 1.0), + flatness=1.0, + shadow=0.0, + text=get_resource('coopSelectWindow.totalText') + ' ' + + eq_text.replace('${NUMBER}', str(total_pts)), + size=(0, 0), + h_align='right', + v_align='center') + + def _create_trophy_type_widgets(self, eq_text: str, incr: int, + multi_txt: str, sub_height: int, + sub_width: int, + trophy_types: List[List[str]]) -> int: + from ba.internal import get_trophy_string + pts = 0 + for i, trophy_type in enumerate(trophy_types): + t_count = self._data['t' + trophy_type[0]] + t_mult = self._data['t' + trophy_type[0] + 'm'] + ba.textwidget(parent=self._subcontainer, + position=(sub_width * 0.15, + sub_height - 20 - incr * i), + scale=0.7, + flatness=1.0, + shadow=0.7, + color=(1, 1, 1), + text=get_trophy_string(trophy_type[0]), + size=(0, 0), + h_align='center', + v_align='center') + + ba.textwidget(parent=self._subcontainer, + position=(sub_width * 0.31, + sub_height - 20 - incr * i), + maxwidth=sub_width * 0.2, + scale=0.8, + flatness=1.0, + shadow=0.0, + color=(0, 1, 0) if (t_count > 0) else + (0.6, 0.6, 0.6, 0.5), + text=str(t_count), + size=(0, 0), + h_align='center', + v_align='center') + + txt = multi_txt.replace('${NUMBER}', str(t_mult)) + ba.textwidget(parent=self._subcontainer, + position=(sub_width * 0.57, + sub_height - 20 - incr * i), + maxwidth=sub_width * 0.3, + scale=0.4, + flatness=1.0, + shadow=0.0, + color=(0.63, 0.6, 0.75) if (t_count > 0) else + (0.6, 0.6, 0.6, 0.4), + text=txt, + size=(0, 0), + h_align='center', + v_align='center') + + pts = t_count * t_mult + ba.textwidget(parent=self._subcontainer, + position=(sub_width * 0.88, + sub_height - 20 - incr * i), + maxwidth=sub_width * 0.3, + color=(0.7, 0.8, 1.0) if (t_count > 0) else + (0.9, 0.9, 1.0, 0.3), + flatness=1.0, + shadow=0.0, + scale=0.5, + text=eq_text.replace('${NUMBER}', str(pts)), + size=(0, 0), + h_align='center', + v_align='center') + pts += pts + return pts + + def _on_cancel_press(self) -> None: + self._transition_out() + + def _transition_out(self) -> None: + if not self._transitioning_out: + self._transitioning_out = True + ba.containerwidget(edit=self.root_widget, transition='out_scale') + + def on_popup_cancel(self) -> None: + ba.playsound(ba.getsound('swish')) + self._transition_out() diff --git a/assets/src/data/scripts/bastd/ui/url.py b/assets/src/data/scripts/bastd/ui/url.py new file mode 100644 index 00000000..797b3459 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/url.py @@ -0,0 +1,86 @@ +"""UI functionality related to URLs.""" + +from __future__ import annotations + +import _ba +import ba + + +class ShowURLWindow(ba.OldWindow): + """A window presenting a URL to the user visually.""" + + def __init__(self, address: str): + + # in some cases we might want to show it as a qr code + # (for long URLs especially) + app = ba.app + if app.platform == 'android' and app.subplatform == 'alibaba': + self._width = 500 + self._height = 500 + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition='in_right', + scale=(1.25 if ba.app.small_ui else 1.25 if ba.app. + med_ui else 1.25))) + self._cancel_button = ba.buttonwidget( + parent=self._root_widget, + position=(50, self._height - 30), + size=(50, 50), + scale=0.6, + label='', + color=(0.6, 0.5, 0.6), + on_activate_call=self._done, + autoselect=True, + icon=ba.gettexture('crossOut'), + iconscale=1.2) + qr_size = 400 + ba.imagewidget(parent=self._root_widget, + position=(self._width * 0.5 - qr_size * 0.5, + self._height * 0.5 - qr_size * 0.5), + size=(qr_size, qr_size), + texture=_ba.get_qrcode_texture(address)) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + else: + # show it as a simple string... + self._width = 800 + self._height = 200 + self._root_widget = ba.containerwidget( + size=(self._width, self._height + 40), + transition='in_right', + scale=1.25 + if ba.app.small_ui else 1.25 if ba.app.med_ui else 1.25) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 10), + size=(0, 0), + color=ba.app.title_color, + h_align="center", + v_align="center", + text=ba.Lstr(resource='directBrowserToURLText'), + maxwidth=self._width * 0.95) + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, + self._height * 0.5 + 29), + size=(0, 0), + scale=1.3, + color=ba.app.infotextcolor, + h_align="center", + v_align="center", + text=address, + maxwidth=self._width * 0.95) + button_width = 200 + btn = ba.buttonwidget(parent=self._root_widget, + position=(self._width * 0.5 - + button_width * 0.5, 20), + size=(button_width, 65), + label=ba.Lstr(resource='doneText'), + on_activate_call=self._done) + # we have no 'cancel' button but still want to be able to + # hit back/escape/etc to leave.. + ba.containerwidget(edit=self._root_widget, + selected_child=btn, + start_button=btn, + on_cancel_call=btn.activate) + + def _done(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_left') diff --git a/assets/src/data/scripts/bastd/ui/watch.py b/assets/src/data/scripts/bastd/ui/watch.py new file mode 100644 index 00000000..7caad3d2 --- /dev/null +++ b/assets/src/data/scripts/bastd/ui/watch.py @@ -0,0 +1,508 @@ +"""Provides UI functionality for watching replays.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, cast + +import _ba +import ba + +if TYPE_CHECKING: + from typing import Any, Optional, Tuple, Dict + + +class WatchWindow(ba.OldWindow): + """Window for watching replays.""" + + def __init__(self, + transition: str = 'in_right', + origin_widget: ba.Widget = None): + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + from bastd.ui import tabs + ba.set_analytics_screen('Watch Window') + scale_origin: Optional[Tuple[float, float]] + if origin_widget is not None: + self._transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._transition_out = 'out_right' + scale_origin = None + ba.app.main_window = "Watch" + self._tab_data: Dict[str, Any] = {} + self._my_replays_scroll_width: Optional[float] = None + self._my_replays_watch_replay_button: Optional[ba.Widget] = None + self._scrollwidget: Optional[ba.Widget] = None + self._columnwidget: Optional[ba.Widget] = None + self._my_replay_selected: Optional[str] = None + self._my_replays_rename_window: Optional[ba.Widget] = None + self._my_replay_rename_text: Optional[ba.Widget] = None + self._r = 'watchWindow' + self._width = 1240 if ba.app.small_ui else 1040 + x_inset = 100 if ba.app.small_ui else 0 + self._height = (578 + if ba.app.small_ui else 670 if ba.app.med_ui else 800) + self._current_tab: Optional[str] = None + extra_top = 20 if ba.app.small_ui else 0 + + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height + extra_top), + transition=transition, + toolbar_visibility='menu_minimal', + scale_origin_stack_offset=scale_origin, + scale=(1.3 if ba.app.small_ui else 0.97 if ba.app.med_ui else 0.8), + stack_offset=(0, -10) if ba.app.small_ui else ( + 0, 15) if ba.app.med_ui else (0, 0))) + + if ba.app.small_ui and ba.app.toolbars: + ba.containerwidget(edit=self._root_widget, + on_cancel_call=self._back) + self._back_button = None + else: + self._back_button = btn = ba.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(70 + x_inset, self._height - 74), + size=(140, 60), + scale=1.1, + label=ba.Lstr(resource='backText'), + button_type='back', + on_activate_call=self._back) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + ba.buttonwidget(edit=btn, + button_type='backSmall', + size=(60, 60), + label=ba.charstr(ba.SpecialChar.BACK)) + + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 38), + size=(0, 0), + color=ba.app.title_color, + scale=1.5, + h_align="center", + v_align="center", + text=ba.Lstr(resource=self._r + '.titleText'), + maxwidth=400) + + tabs_def = [('my_replays', + ba.Lstr(resource=self._r + '.myReplaysText'))] + + scroll_buffer_h = 130 + 2 * x_inset + tab_buffer_h = 750 + 2 * x_inset + + self._tab_buttons = tabs.create_tab_buttons( + self._root_widget, + tabs_def, + pos=(tab_buffer_h * 0.5, self._height - 130), + size=(self._width - tab_buffer_h, 50), + on_select_call=self._set_tab) + + if ba.app.toolbars: + ba.widget(edit=self._tab_buttons[tabs_def[-1][0]], + right_widget=_ba.get_special_widget('party_button')) + if ba.app.small_ui: + bbtn = _ba.get_special_widget('back_button') + ba.widget(edit=self._tab_buttons[tabs_def[0][0]], + up_widget=bbtn, + left_widget=bbtn) + + self._scroll_width = self._width - scroll_buffer_h + self._scroll_height = self._height - 180 + + # not actually using a scroll widget anymore; just an image + scroll_left = (self._width - self._scroll_width) * 0.5 + scroll_bottom = self._height - self._scroll_height - 79 - 48 + buffer_h = 10 + buffer_v = 4 + ba.imagewidget(parent=self._root_widget, + position=(scroll_left - buffer_h, + scroll_bottom - buffer_v), + size=(self._scroll_width + 2 * buffer_h, + self._scroll_height + 2 * buffer_v), + texture=ba.gettexture('scrollWidget'), + model_transparent=ba.getmodel('softEdgeOutside')) + self._tab_container: Optional[ba.Widget] = None + + self._restore_state() + + def _set_tab(self, tab: str) -> None: + # pylint: disable=too-many-locals + from bastd.ui import tabs + + if self._current_tab == tab: + return + self._current_tab = tab + + # We wanna preserve our current tab between runs. + cfg = ba.app.config + cfg['Watch Tab'] = tab + cfg.commit() + + # Update tab colors based on which is selected. + tabs.update_tab_button_colors(self._tab_buttons, tab) + + if self._tab_container: + self._tab_container.delete() + scroll_left = (self._width - self._scroll_width) * 0.5 + scroll_bottom = self._height - self._scroll_height - 79 - 48 + + # A place where tabs can store data to get cleared when + # switching to a different tab + self._tab_data = {} + + if tab == 'my_replays': + c_width = self._scroll_width + c_height = self._scroll_height - 20 + sub_scroll_height = c_height - 63 + self._my_replays_scroll_width = sub_scroll_width = ( + 680 if ba.app.small_ui else 640) + + self._tab_container = cnt = ba.containerwidget( + parent=self._root_widget, + position=(scroll_left, scroll_bottom + + (self._scroll_height - c_height) * 0.5), + size=(c_width, c_height), + background=False, + selection_loop_to_parent=True) + + v = c_height - 30 + ba.textwidget(parent=cnt, + position=(c_width * 0.5, v), + color=(0.6, 1.0, 0.6), + scale=0.7, + size=(0, 0), + maxwidth=c_width * 0.9, + h_align='center', + v_align='center', + text=ba.Lstr( + resource='replayRenameWarningText', + subs=[('${REPLAY}', + ba.Lstr(resource='replayNameDefaultText')) + ])) + + b_width = 140 if ba.app.small_ui else 178 + b_height = (107 + if ba.app.small_ui else 142 if ba.app.med_ui else 190) + b_space_extra = (0 if ba.app.small_ui else + -2 if ba.app.med_ui else -5) + + b_color = (0.6, 0.53, 0.63) + b_textcolor = (0.75, 0.7, 0.8) + btnv = c_height - (48 if ba.app.small_ui else + 45 if ba.app.med_ui else 40) - b_height + btnh = 40 if ba.app.small_ui else 40 + smlh = 190 if ba.app.small_ui else 225 + tscl = 1.0 if ba.app.small_ui else 1.2 + self._my_replays_watch_replay_button = btn1 = ba.buttonwidget( + parent=cnt, + size=(b_width, b_height), + position=(btnh, btnv), + button_type='square', + color=b_color, + textcolor=b_textcolor, + on_activate_call=self._on_my_replay_play_press, + text_scale=tscl, + label=ba.Lstr(resource=self._r + '.watchReplayButtonText'), + autoselect=True) + ba.widget(edit=btn1, up_widget=self._tab_buttons[tab]) + if ba.app.small_ui and ba.app.toolbars: + ba.widget(edit=btn1, + left_widget=_ba.get_special_widget('back_button')) + btnv -= b_height + b_space_extra + ba.buttonwidget(parent=cnt, + size=(b_width, b_height), + position=(btnh, btnv), + button_type='square', + color=b_color, + textcolor=b_textcolor, + on_activate_call=self._on_my_replay_rename_press, + text_scale=tscl, + label=ba.Lstr(resource=self._r + + '.renameReplayButtonText'), + autoselect=True) + btnv -= b_height + b_space_extra + ba.buttonwidget(parent=cnt, + size=(b_width, b_height), + position=(btnh, btnv), + button_type='square', + color=b_color, + textcolor=b_textcolor, + on_activate_call=self._on_my_replay_delete_press, + text_scale=tscl, + label=ba.Lstr(resource=self._r + + '.deleteReplayButtonText'), + autoselect=True) + + v -= sub_scroll_height + 23 + self._scrollwidget = scrlw = ba.scrollwidget( + parent=cnt, + position=(smlh, v), + size=(sub_scroll_width, sub_scroll_height)) + ba.containerwidget(edit=cnt, selected_child=scrlw) + self._columnwidget = ba.columnwidget(parent=scrlw, left_border=10) + + ba.widget(edit=scrlw, + autoselect=True, + left_widget=btn1, + up_widget=self._tab_buttons[tab]) + ba.widget(edit=self._tab_buttons[tab], down_widget=scrlw) + + self._my_replay_selected = None + self._refresh_my_replays() + + def _no_replay_selected_error(self) -> None: + ba.screenmessage(ba.Lstr(resource=self._r + + '.noReplaySelectedErrorText'), + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + + def _on_my_replay_play_press(self) -> None: + if self._my_replay_selected is None: + self._no_replay_selected_error() + return + _ba.increment_analytics_count('Replay watch') + + def do_it() -> None: + try: + # reset to normal speed + _ba.set_replay_speed_exponent(0) + _ba.fade_screen(True) + assert self._my_replay_selected is not None + _ba.new_replay_session(_ba.get_replays_dir() + '/' + + self._my_replay_selected) + except Exception: + ba.print_exception("exception running replay session") + # drop back into a fresh main menu session + # in case we half-launched or something.. + from bastd import mainmenu + _ba.new_host_session(mainmenu.MainMenuSession) + + _ba.fade_screen(False, endcall=ba.Call(ba.pushcall, do_it)) + ba.containerwidget(edit=self._root_widget, transition='out_left') + + def _on_my_replay_rename_press(self) -> None: + if self._my_replay_selected is None: + self._no_replay_selected_error() + return + c_width = 600 + c_height = 250 + self._my_replays_rename_window = cnt = ba.containerwidget( + scale=1.8 if ba.app.small_ui else 1.55 if ba.app.med_ui else 1.0, + size=(c_width, c_height), + transition='in_scale') + dname = self._get_replay_display_name(self._my_replay_selected) + ba.textwidget(parent=cnt, + size=(0, 0), + h_align='center', + v_align='center', + text=ba.Lstr(resource=self._r + '.renameReplayText', + subs=[('${REPLAY}', dname)]), + maxwidth=c_width * 0.8, + position=(c_width * 0.5, c_height - 60)) + self._my_replay_rename_text = txt = ba.textwidget( + parent=cnt, + size=(c_width * 0.8, 40), + h_align='left', + v_align='center', + text=dname, + editable=True, + description=ba.Lstr(resource=self._r + '.replayNameText'), + position=(c_width * 0.1, c_height - 140), + autoselect=True, + maxwidth=c_width * 0.7, + max_chars=200) + cbtn = ba.buttonwidget(parent=cnt, + label=ba.Lstr(resource='cancelText'), + on_activate_call=ba.Call( + ba.containerwidget, + edit=cnt, + transition='out_scale'), + size=(180, 60), + position=(30, 30), + autoselect=True) + okb = ba.buttonwidget(parent=cnt, + label=ba.Lstr(resource=self._r + '.renameText'), + size=(180, 60), + position=(c_width - 230, 30), + on_activate_call=ba.Call( + self._rename_my_replay, + self._my_replay_selected), + autoselect=True) + ba.widget(edit=cbtn, right_widget=okb) + ba.widget(edit=okb, left_widget=cbtn) + ba.textwidget(edit=txt, on_return_press_call=okb.activate) + ba.containerwidget(edit=cnt, cancel_button=cbtn, start_button=okb) + + def _rename_my_replay(self, replay: str) -> None: + new_name = None + try: + if not self._my_replay_rename_text: + return + new_name_raw = cast( + str, ba.textwidget(query=self._my_replay_rename_text)) + new_name = new_name_raw + '.brp' + # ignore attempts to change it to what it already is + # (or what it looks like to the user) + if (replay != new_name + and self._get_replay_display_name(replay) != new_name_raw): + old_name_full = (_ba.get_replays_dir() + '/' + + replay).encode('utf-8') + new_name_full = (_ba.get_replays_dir() + '/' + + new_name).encode('utf-8') + # false alarm; ba.textwidget can return non-None val + # pylint: disable=unsupported-membership-test + if os.path.exists(new_name_full): + ba.playsound(ba.getsound('error')) + ba.screenmessage( + ba.Lstr(resource=self._r + + '.replayRenameErrorAlreadyExistsText'), + color=(1, 0, 0)) + elif any(char in new_name_raw for char in ['/', '\\', ':']): + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource=self._r + + '.replayRenameErrorInvalidName'), + color=(1, 0, 0)) + else: + _ba.increment_analytics_count('Replay rename') + os.rename(old_name_full, new_name_full) + self._refresh_my_replays() + ba.playsound(ba.getsound('gunCocking')) + except Exception: + ba.print_exception( + f"error renaming replay '{replay}' to '{new_name}'") + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource=self._r + + '.replayRenameErrorText'), + color=(1, 0, 0)) + + ba.containerwidget(edit=self._my_replays_rename_window, + transition='out_scale') + + def _on_my_replay_delete_press(self) -> None: + from bastd.ui import confirm + if self._my_replay_selected is None: + self._no_replay_selected_error() + return + confirm.ConfirmWindow( + ba.Lstr(resource=self._r + '.deleteConfirmText', + subs=[('${REPLAY}', + self._get_replay_display_name( + self._my_replay_selected))]), + ba.Call(self._delete_replay, self._my_replay_selected), 450, 150) + + def _get_replay_display_name(self, replay: str) -> str: + if replay.endswith('.brp'): + replay = replay[:-4] + if replay == '__lastReplay': + return ba.Lstr(resource='replayNameDefaultText').evaluate() + return replay + + def _delete_replay(self, replay: str) -> None: + try: + _ba.increment_analytics_count('Replay delete') + os.remove((_ba.get_replays_dir() + '/' + replay).encode('utf-8')) + self._refresh_my_replays() + ba.playsound(ba.getsound('shieldDown')) + if replay == self._my_replay_selected: + self._my_replay_selected = None + except Exception: + ba.print_exception("exception deleting replay '" + replay + "'") + ba.playsound(ba.getsound('error')) + ba.screenmessage(ba.Lstr(resource=self._r + + '.replayDeleteErrorText'), + color=(1, 0, 0)) + + def _on_my_replay_select(self, replay: str) -> None: + self._my_replay_selected = replay + + def _refresh_my_replays(self) -> None: + assert self._columnwidget is not None + for child in self._columnwidget.get_children(): + child.delete() + t_scale = 1.6 + try: + names = os.listdir(_ba.get_replays_dir()) + # ignore random other files in there.. + names = [n for n in names if n.endswith('.brp')] + names.sort(key=lambda x: x.lower()) + except Exception: + ba.print_exception("error listing replays dir") + names = [] + + assert self._my_replays_scroll_width is not None + assert self._my_replays_watch_replay_button is not None + for i, name in enumerate(names): + txt = ba.textwidget( + parent=self._columnwidget, + size=(self._my_replays_scroll_width / t_scale, 30), + selectable=True, + color=(1.0, 1, 0.4) if name == '__lastReplay.brp' else + (1, 1, 1), + always_highlight=True, + on_select_call=ba.Call(self._on_my_replay_select, name), + on_activate_call=self._my_replays_watch_replay_button.activate, + text=self._get_replay_display_name(name), + h_align='left', + v_align='center', + corner_scale=t_scale, + maxwidth=(self._my_replays_scroll_width / t_scale) * 0.93) + if i == 0: + ba.widget(edit=txt, up_widget=self._tab_buttons['my_replays']) + + def _save_state(self) -> None: + try: + sel = self._root_widget.get_selected_child() + if sel == self._back_button: + sel_name = 'Back' + elif sel in list(self._tab_buttons.values()): + sel_name = 'Tab:' + list(self._tab_buttons.keys())[list( + self._tab_buttons.values()).index(sel)] + elif sel == self._tab_container: + sel_name = 'TabContainer' + else: + raise Exception("unrecognized selection") + ba.app.window_states[self.__class__.__name__] = { + 'sel_name': sel_name, + 'tab': self._current_tab + } + except Exception: + ba.print_exception('error saving state for', self.__class__) + + def _restore_state(self) -> None: + try: + try: + sel_name = ba.app.window_states[ + self.__class__.__name__]['sel_name'] + except Exception: + sel_name = None + try: + current_tab = ba.app.config['Watch Tab'] + except Exception: + current_tab = None + if current_tab is None or current_tab not in self._tab_buttons: + current_tab = 'my_replays' + self._set_tab(current_tab) + if sel_name == 'Back': + sel = self._back_button + elif sel_name == 'TabContainer': + sel = self._tab_container + elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): + sel = self._tab_buttons[sel_name.split(':')[-1]] + else: + if self._tab_container is not None: + sel = self._tab_container + else: + sel = self._tab_buttons[current_tab] + ba.containerwidget(edit=self._root_widget, selected_child=sel) + except Exception: + ba.print_exception('error restoring state for', self.__class__) + + def _back(self) -> None: + from bastd.ui import mainmenu + self._save_state() + ba.containerwidget(edit=self._root_widget, + transition=self._transition_out) + ba.app.main_menu_window = (mainmenu.MainMenuWindow( + transition='in_left').get_root_widget()) diff --git a/assets/src/server/README.txt b/assets/src/server/README.txt new file mode 100644 index 00000000..7bfd4ee6 --- /dev/null +++ b/assets/src/server/README.txt @@ -0,0 +1,25 @@ +To run this, simply cd into this directory and run ./ballisticacore_server (on mac or linux) or launch_ballisticacore_server.bat (on windows) +You'll need to open a UDP port (43210 by default) so that the world can communicate with your server. +You can edit some server params in the ballisticacore_server script, or for more fancy changes you can modify the game scripts in data/scripts. + +platform-specific notes: + +mac: +- The server should run on the most recent macOS (and possibly older versions, though I have not checked) +- It now requires homebrew python 3, so you'll need that installed (brew install python3). + +linux 32/64 bit: +- Server binaries are currently compiled against ubuntu 16.04 LTS. They depend on Python 3.5, so you may need to install that. + This should just be something like "sudo apt install python3" + +raspberry pi: +- The server binary was compiled on a raspberry pi 3 running raspbian stretch. + As with the standard linux build you'll need to make sure you've got Python 3 installed. + +windows: +- You may need to run vc_redist.x86 to install support libraries if the app quits with complaints of missing DLLs + +Please give me a holler at support@froemling.net if you run into any problems. + +Enjoy! +-Eric diff --git a/assets/src/server/config.py b/assets/src/server/config.py new file mode 100644 index 00000000..6990d564 --- /dev/null +++ b/assets/src/server/config.py @@ -0,0 +1,8 @@ +# place any of your own overrides here. +# see ballisticacore_server for details on what you can override +# examples (uncomment to use): +# config['party_name'] = 'My Awesome Party' +# config['session_type'] = 'teams' +# config['max_party_size'] = 6 +# config['port'] = 43209 +# config['playlist_code'] = 1242 diff --git a/assets/src/server/server.bat b/assets/src/server/server.bat new file mode 100644 index 00000000..845b0f97 --- /dev/null +++ b/assets/src/server/server.bat @@ -0,0 +1,2 @@ +:: All this does is run the ballisticacore_server script with the included python interpreter +python.exe ballisticacore_server.py diff --git a/assets/src/server/server.py b/assets/src/server/server.py new file mode 100755 index 00000000..d7564848 --- /dev/null +++ b/assets/src/server/server.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3.7 +"""Functionality for running a BallisticaCore server.""" +from __future__ import annotations + +import copy +import json +import os +import subprocess +import sys +import tempfile +import threading +import time +import traceback +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Dict, Any, Sequence + + +def _get_default_config() -> Dict[str, Any]: + # Config values are initialized with defaults here. + # You an add your own overrides in config.py. + # noinspection PyDictCreation + config: Dict[str, Any] = {} + + # Name of our server in the public parties list. + config['party_name'] = 'FFA' + + # If True, your party will show up in the global public party list + # Otherwise it will still be joinable via LAN or connecting by IP address. + config['party_is_public'] = True + + # UDP port to host on. Change this to work around firewalls or run multiple + # servers on one machine. + # 43210 is the default and the only port that will show up in the LAN + # browser tab. + config['port'] = 43210 + + # Max devices in the party. Note that this does *NOT* mean max players. + # Any device in the party can have more than one player on it if they have + # multiple controllers. Also, this number currently includes the server so + # generally make it 1 bigger than you need. Max-players is not currently + # exposed but I'll try to add that soon. + config['max_party_size'] = 6 + + # Options here are 'ffa' (free-for-all) and 'teams' + # This value is only used if you do not supply a playlist_code (see below). + # In that case the default teams or free-for-all playlist gets used. + config['session_type'] = 'ffa' + + # To host your own custom playlists, use the 'share' functionality in the + # playlist editor in the regular version of the game. + # This will give you a numeric code you can enter here to host that + # playlist. + config['playlist_code'] = None + + # Whether to shuffle the playlist or play its games in designated order. + config['playlist_shuffle'] = True + + # If True, keeps team sizes equal by disallowing joining the largest team + # (teams mode only). + config['auto_balance_teams'] = True + + # Whether to enable telnet access on port 43250 + # This allows you to run python commands on the server as it is running. + # Note: you can now also run live commands via stdin so telnet is generally + # unnecessary. BallisticaCore's telnet server is very simple so you may + # have to turn off any fancy features in your telnet client to get it to + # work. There is no password protection so make sure to only enable this + # if access to this port is fully trusted (behind a firewall, etc). + # IMPORTANT: Telnet is not encrypted at all, so you really should not + # expose it's port to the world. If you need remote access, consider + # connecting to your machine via ssh and running telnet to localhost + # from there. + config['enable_telnet'] = False + + # Port used for telnet. + config['telnet_port'] = 43250 + + # This can be None for no password but PLEASE do not expose that to the + # world or your machine will likely get owned. + config['telnet_password'] = 'changeme' + + # Series length in teams mode (7 == 'best-of-7' series; a team must + # get 4 wins) + config['teams_series_length'] = 7 + + # Points to win in free-for-all mode (Points are awarded per game based on + # performance) + config['ffa_series_length'] = 24 + + # If you provide a custom stats webpage for your server, you can use + # this to provide a convenient in-game link to it in the server-browser + # beside the server name. + # if ${ACCOUNT} is present in the string, it will be replaced by the + # currently-signed-in account's id. To get info about an account, + # you can use the following url: + # http://bombsquadgame.com/accountquery?id=ACCOUNT_ID_HERE + config['stats_url'] = '' + + return config + + +def _run_process_until_exit(process: subprocess.Popen, + input_commands: Sequence[str], + restart_minutes: int, + config: Dict[str, Any]) -> None: + # So we pass our initial config. + config_dirty = True + + launch_time = time.time() + + # Now just sleep and run commands until the server exits. + while True: + + # Run any commands that came in through stdin. + for cmd in input_commands: + print("GOT INPUT COMMAND", cmd) + old_config = copy.deepcopy(config) + try: + print('FIXME: input commands need updating for python 3') + # exec(cmd) + except Exception: + traceback.print_exc() + if config != old_config: + config_dirty = True + input_commands = [] + + # Request a restart after a while. + if (time.time() - launch_time > 60 * restart_minutes + and not config['quit']): + print('restart_minutes (' + str(restart_minutes) + + 'm) elapsed; requesting server restart ' + 'at next clean opportunity...') + config['quit'] = True + config['quit_reason'] = 'restarting' + config_dirty = True + + # Whenever the config changes, dump it to a json file and feed + # it to the running server. + # FIXME: We can probably just pass the new config directly + # instead of dumping it to a file and passing the path. + if config_dirty: + # Note: The game handles deleting this file for us once its + # done with it. + ftmp = tempfile.NamedTemporaryFile(mode='w', delete=False) + fname = ftmp.name + ftmp.write(json.dumps(config)) + ftmp.close() + + # Note to self: Is there a type-safe way we could do this? + process.stdin.write(('from ba import _server; ' + '_server.config_server(config_file=' + + repr(fname) + ')\n').encode('utf-8')) + process.stdin.flush() + config_dirty = False + + code = process.poll() + if code is not None: + print('BallisticaCore exited with code ' + str(code)) + break + + time.sleep(1) + + +def _run_server_cycle(binary_path: str, config: Dict[str, Any], + input_commands: Sequence[str], + restart_minutes: int) -> None: + """Bring up the server binary and run it until exit.""" + + # Most of our config values we can feed to ballisticacore as it is running + # (see below). However certain things such as network-port need to be + # present in the config file at launch, so let's write that out first. + if not os.path.exists('bacfg'): + os.mkdir('bacfg') + if os.path.exists('bacfg/config.json'): + with open('bacfg/config.json') as infile: + bacfg = json.loads(infile.read()) + else: + bacfg = {} + bacfg['Port'] = config['port'] + bacfg['Enable Telnet'] = config['enable_telnet'] + bacfg['Telnet Port'] = config['telnet_port'] + bacfg['Telnet Password'] = config['telnet_password'] + with open('bacfg/config.json', 'w') as outfile: + outfile.write(json.dumps(bacfg)) + + # Launch our binary and grab its stdin; we'll use this to feed + # it commands. + process = subprocess.Popen([binary_path, '-cfgdir', 'bacfg'], + stdin=subprocess.PIPE) + + # Set quit to True any time after launching the server to gracefully + # quit it at the next clean opportunity (end of the current series, + # etc). + config['quit'] = False + config['quit_reason'] = None + + try: + _run_process_until_exit(process, input_commands, restart_minutes, + config) + + # If we hit ANY Exceptions (including KeyboardInterrupt) we want to kill + # the server binary, so we need to catch BaseException. + except BaseException: + print("Killing server binary...") + + # First, ask it nicely to die and give it a moment. + # If that doesn't work, bring down the hammer. + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + print("Server binary's dead, Jim.") + raise + + +def main() -> None: + """Runs a BallisticaCore server. + + Handles passing config values to the game and periodically restarting + the game binary to keep things fresh. + """ + + # We expect to be running from the dir where this script lives. + script_dir = os.path.dirname(sys.argv[0]) + if script_dir != '': + os.chdir(script_dir) + + config_path = './config.py' + binary_path = None + if os.name == 'nt': + test_paths = 'bs_headless.exe', 'BallisticaCore.exe' + else: + test_paths = './bs_headless', './ballisticacore' + for path in test_paths: + if os.path.exists(path): + binary_path = path + break + if binary_path is None: + raise Exception('Unable to locate bs_headless binary.') + + config = _get_default_config() + + # If config.py exists, run it to apply any overrides it wants. + if os.path.isfile(config_path): + # pylint: disable=exec-used + exec(compile(open(config_path).read(), config_path, 'exec'), globals(), + config) + + # Launch a thread to read our stdin for commands; this lets us modify the + # server as it runs. + input_commands = [] + + # Print a little spiel in interactive mode (make sure we do this before our + # thread reads stdin). + if sys.stdin.isatty(): + print("ballisticacore server wrapper starting up...\n" + "tip: enter python commands via stdin to " + "reconfigure the server on the fly:\n" + "example: config['party_name'] = 'New Party Name'") + + class InputThread(threading.Thread): + """A thread that just sits around waiting for input from stdin.""" + + def run(self) -> None: + while True: + line = sys.stdin.readline() + print('GOT LINE', line) + input_commands.append(line.strip()) + + thread = InputThread() + + # Set daemon mode so this thread's existence won't stop us from dying. + thread.daemon = True + thread.start() + + restart_server = True + + # The server-binary will get relaunched after this amount of time + # (combats memory leaks or other cruft that has built up). + restart_minutes = 360 + + # The standard python exit/quit help messages don't apply here + # so let's get rid of them. + del __builtins__.exit + del __builtins__.quit + + # Sleep for a moment to allow initial stdin data to get through. + time.sleep(0.25) + + # Restart indefinitely until we're told not to. + while restart_server: + _run_server_cycle(binary_path, config, input_commands, restart_minutes) + + +if __name__ == '__main__': + main() diff --git a/config/README.md b/config/README.md new file mode 100644 index 00000000..3c6f0f4b --- /dev/null +++ b/config/README.md @@ -0,0 +1 @@ +This directory is for high level project configuration. diff --git a/config/config.json b/config/config.json new file mode 100644 index 00000000..54f95ec8 --- /dev/null +++ b/config/config.json @@ -0,0 +1,72 @@ +{ + "code_source_dirs": [ + "src/ballistica" + ], + "cpplint_blacklist": [ + "src/ballistica/generic/json.cc", + "src/ballistica/generic/json.h", + "src/ballistica/generic/utf8.cc", + "src/ballistica/graphics/texture/dds.h", + "src/ballistica/graphics/texture/ktx.cc", + "src/ballistica/platform/android/android_gl3.h", + "src/ballistica/platform/apple/app_delegate.h", + "src/ballistica/platform/apple/scripting_bridge_itunes.h", + "src/ballistica/platform/android/utf8/checked.h", + "src/ballistica/platform/android/utf8/unchecked.h", + "src/ballistica/platform/android/utf8/core.h", + "src/ballistica/platform/apple/sdl_main_mac.h", + "src/ballistica/platform/oculus/main_rift.cc" + ], + "name": "BallisticaCore", + "push_ipa_config": { + "app_bundle_name": "BallisticaCore.app", + "archive_name": "bs", + "projectpath": "ballisticacore-ios.xcodeproj", + "scheme": "BallisticaCore iOS Legacy" + }, + "pubsync_public_commit": "16d87a4a67ef69c6b2d08f2dea183a4a0f219ac6", + "pylint_ignored_untracked_deps": [ + "bs_mapdefs_tip_top", + "bs_mapdefs_lake_frigid", + "bs_mapdefs_monkey_face", + "astroid.modutils", + "bs_mapdefs_doom_shroom", + "bs_mapdefs_rampage", + "bs_mapdefs_big_g", + "bs_mapdefs_courtyard", + "bs_mapdefs_crag_castle", + "bs_mapdefs_happy_thoughts", + "astroid", + "bs_mapdefs_bridgit", + "pylint.lint", + "bs_mapdefs_zig_zag", + "bs_mapdefs_the_pad", + "bs_mapdefs_step_right_up", + "pytz", + "bs_mapdefs_football_stadium", + "bs_mapdefs_tower_d", + "bs_mapdefs_hockey_stadium", + "bs_mapdefs_roundabout" + ], + "python_paths": [ + "assets/src/data/scripts", + "tools" + ], + "python_source_dirs": [ + "assets/src/data/scripts", + "assets/src/server", + "src/generated_src", + "tools" + ], + "sync_items": [ + { + "src_path": ".editorconfig", + "src_project_id": "bamaster" + }, + { + "dst_path": "assets/src/data/scripts/bafoundation", + "src_path": "src/bafoundation", + "src_project_id": "bamaster" + } + ] +} \ No newline at end of file diff --git a/config/toolconfigsrc/README b/config/toolconfigsrc/README new file mode 100644 index 00000000..e196d74f --- /dev/null +++ b/config/toolconfigsrc/README @@ -0,0 +1,5 @@ +These configs are installed via 'make toolconfigs' in the project root. +(and should be automatically built for other targets such as 'make check') + +Some of these are filtered to include an abs path to the project root +or other varying data, so they can not simply be included as flat files. \ No newline at end of file diff --git a/config/toolconfigsrc/clang-format b/config/toolconfigsrc/clang-format new file mode 100644 index 00000000..7f292443 --- /dev/null +++ b/config/toolconfigsrc/clang-format @@ -0,0 +1,9 @@ +BasedOnStyle: Google + +# ericf note: ensuring pointers/refs are consistent +# (google style default is to derive from file and fall back to left) +PointerAlignment: Left +DerivePointerAlignment: false + +# want +, -, etc at beginning of split lines +BreakBeforeBinaryOperators: NonAssignment diff --git a/config/toolconfigsrc/dir-locals.el b/config/toolconfigsrc/dir-locals.el new file mode 100644 index 00000000..7443398c --- /dev/null +++ b/config/toolconfigsrc/dir-locals.el @@ -0,0 +1,7 @@ +;;; Directory Local Variables for emacs clang flycheck +;;; For more information see (info "(emacs) Directory Variables") + +;;; Turn flycheck mode on for our c++ stuff and tell jedi where to look for our python stuff. +((c++-mode (eval . (flycheck-mode))) + (python-mode (jedi:server-args . ("--sys-path" "__EFRO_PROJECT_ROOT__/tools" + "--sys-path" "__EFRO_PROJECT_ROOT__/assets/src/data/scripts")))) diff --git a/config/toolconfigsrc/mypy.ini b/config/toolconfigsrc/mypy.ini new file mode 100644 index 00000000..d8dbd07c --- /dev/null +++ b/config/toolconfigsrc/mypy.ini @@ -0,0 +1,26 @@ +[mypy] +mypy_path = __EFRO_PROJECT_ROOT__/tools:__EFRO_PROJECT_ROOT__/assets/src/data/scripts + +__EFRO_MYPY_STANDARD_SETTINGS__ + +[mypy-pylint.*] +ignore_missing_imports = True + +[mypy-xml.*] +ignore_missing_imports = True + +[mypy-vis_cleanup] +ignore_errors = True + +[mypy-bastd.mapdata.*] +ignore_errors = True + +[mypy-astroid.*] +ignore_missing_imports = True + +[mypy-efrotools.pylintplugins] +disallow_any_unimported = False + +[mypy-devtool] +ignore_errors = True + diff --git a/config/toolconfigsrc/pycheckers b/config/toolconfigsrc/pycheckers new file mode 100644 index 00000000..a5f89bb5 --- /dev/null +++ b/config/toolconfigsrc/pycheckers @@ -0,0 +1,5 @@ +[DEFAULT] +checkers= pylint, mypy3 +mypy_config_file=.mypy.ini +mypy_use_daemon=true +mypy_daemon_files_command=tools/snippets scriptfiles -lines diff --git a/config/toolconfigsrc/pylintrc b/config/toolconfigsrc/pylintrc new file mode 100644 index 00000000..8662f2fa --- /dev/null +++ b/config/toolconfigsrc/pylintrc @@ -0,0 +1,98 @@ +[MASTER] +jobs=1 + +load-plugins=efrotools.pylintplugins + +persistent=no + +__EFRO_PYLINT_INIT__ + +[REPORTS] + +# Don't want a score; aiming for perfection. +score=no + +[VARIABLES] +# By default pylint ignores unused imports in __init__.py, but +# when flycheck checks it as flycheck___init__.py that doesn't apply. +# Turning this off to keep things consistent. +init-import=yes + +[FORMAT] +# PEP-8 specifies 79 chars (that's right, not 80) +max-line-length=79 + +# We're using yapf to handle formatting and pylint doesn't always agree with it. +disable=bad-continuation + +[MESSAGES CONTROL] +# broad-except: +# I tend to catch Exception (*not* BaseException) as a broad safety net. +# This is not disallowed in PEP-8 and I feel its +# not a bad practice as long as they are not silently ignored. +# too-few-public-methods: +# Yes I often use little classes just to carry around one or two named attrs +# or as simple messages to send to each other. +# Can look into Data Classes perhaps once 3.7 is well distributed, +# but for now I'm gonna say this is ok. +# no-self-use +# I find a lot of things still make sense organizationally as methods +# even if they do not technically use self at the current time +# too-many-instance-attributes +# Honestly just don't feel this is bad. If anything, the limit encourages +# me to stuff things in dicts or whatnot which loses the bit of +# type safety that pylint provides with attrs +# too-many-arguments +# This one is more understandable, but I still don't see the problem +# with having a bunch of optional args in some cases. +# similarities +# Not gonna touch this for now; maybe later. Bigger fish to fry. +disable=broad-except, + too-few-public-methods, + no-self-use, + too-many-instance-attributes, + too-many-arguments, + similarities + +# We want to know whenever we can get rid of suppression statements. +enable=useless-suppression + +[BASIC] +# I use x, y, h, and v for graphical purposes often, where I feel like they are +# meaningful, so adding them to the allowed list. +# Also t, r, s for translate/rotate/scale +# (i found myself just changing them to xval and yval which doesnt help) + +good-names=i, + j, + k, + x, + y, + h, + v, + s, + h2, + v2, + ex, + Run, + T, + S, + U, + _ + +[MISCELLANEOUS] +# We've got various TODO and FIXME notes in scripts, but don't want +# lint to trigger over that fact. +notes= + +[DESIGN] +# We're going a bit over the recommended max of 7 ancestors when +# in some bafoundation mixin classes; I don't feel like that's +# unreasonable. +max-parents=10 + +[SIMILARITIES] + +[IMPORTS] +# We do quite a bit of this. Perhaps can reconsider if its a good idea later. +disable=import-outside-toplevel diff --git a/config/toolconfigsrc/style.yapf b/config/toolconfigsrc/style.yapf new file mode 100644 index 00000000..f1bfe843 --- /dev/null +++ b/config/toolconfigsrc/style.yapf @@ -0,0 +1,8 @@ +[style] +based_on_style = pep8 +allow_multiline_lambdas = true +allow_multiline_dictionary_keys = true +coalesce_brackets = true +join_multiple_lines = false +indent_dictionary_value = true +blank_line_before_nested_class_or_def = true diff --git a/tools/efrotools/__init__.py b/tools/efrotools/__init__.py new file mode 100644 index 00000000..d4010289 --- /dev/null +++ b/tools/efrotools/__init__.py @@ -0,0 +1,184 @@ +"""EfroTools: Various build related functionality for use in my projects.""" + +from __future__ import annotations + +import os +import json +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Dict, Union, Sequence, Optional, Any + + +def get_proc_count() -> int: + """Return the number of logical processors available.""" + + # Note: this is mac specific currently. + return int( + subprocess.check_output(['sysctl', '-n', 'hw.ncpu']).decode().strip()) + + +def explicit_bool(value: bool) -> bool: + """Simply return input value; can avoid unreachable-code type warnings.""" + return value + + +def get_localconfig(projroot: Path) -> Dict[str, Any]: + """Return a project's localconfig contents (or default if missing).""" + localconfig: Dict[str, Any] + try: + with open(Path(projroot, 'config/localconfig.json')) as infile: + localconfig = json.loads(infile.read()) + except FileNotFoundError: + localconfig = {} + return localconfig + + +def get_config(projroot: Path) -> Dict[str, Any]: + """Return a project's config contents (or default if missing).""" + config: Dict[str, Any] + try: + with open(Path(projroot, 'config/config.json')) as infile: + config = json.loads(infile.read()) + except FileNotFoundError: + config = {} + return config + + +def set_config(projroot: Path, config: Dict[str, Any]) -> None: + """Set the project config contents.""" + os.makedirs(Path(projroot, 'config'), exist_ok=True) + with Path(projroot, 'config/config.json').open('w') as outfile: + outfile.write(json.dumps(config, indent=2)) + + +def readfile(path: Union[str, Path]) -> str: + """Read a text file and return a str.""" + with open(path) as infile: + return infile.read() + + +def writefile(path: Union[str, Path], txt: str) -> None: + """Write a string to a file.""" + with open(path, 'w') as outfile: + outfile.write(txt) + + +def replace_one(opstr: str, old: str, new: str) -> str: + """Replace text ensuring that exactly one occurrence is replaced.""" + count = opstr.count(old) + if count != 1: + raise Exception( + f'expected 1 string occurrence; found {count}. String = {old}') + return opstr.replace(old, new) + + +def run(cmd: str) -> None: + """Run a shell command, checking errors.""" + subprocess.run(cmd, shell=True, check=True) + + +def get_files_hash(filenames: Sequence[Union[str, Path]], + extrahash: str = '', + int_only: bool = False) -> str: + """Return a md5 hash for the given files.""" + import hashlib + if not isinstance(filenames, list): + raise Exception('expected a list') + md5 = hashlib.md5() + for fname in filenames: + with open(fname, 'rb') as infile: + while True: + data = infile.read(2**20) + if not data: + break + md5.update(data) + md5.update(extrahash.encode()) + + if int_only: + return str(int.from_bytes(md5.digest(), byteorder='big')) + + return md5.hexdigest() + + +def _py_symbol_at_column(line: str, col: int) -> str: + start = col + while start > 0 and line[start - 1] != ' ': + start -= 1 + end = col + while end < len(line) and line[end] != ' ': + end += 1 + return line[start:end] + + +def py_examine(filename: Path, line: int, column: int, + selection: Optional[str], operation: str) -> None: + """Given file position info, performs some code inspection.""" + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + import astroid + import re + from efrotools import code + + # Pull in our pylint plugin which really just adds astroid filters. + # That way our introspection here will see the same thing as pylint's does. + with open(filename) as infile: + fcontents = infile.read() + if '#@' in fcontents: + raise Exception('#@ marker found in file; this breaks examinations.') + flines = fcontents.splitlines() + + if operation == 'pylint_infer': + + # See what asteroid can infer about the target symbol. + symbol = (selection if selection is not None else _py_symbol_at_column( + flines[line - 1], column)) + + # Insert a line after the provided one which is just the symbol so we + # can ask for its value alone. + match = re.match(r"\s*", flines[line - 1]) + whitespace = match.group() if match is not None else '' + sline = whitespace + symbol + ' #@' + flines = flines[:line] + [sline] + flines[line:] + node = astroid.extract_node('\n'.join(flines)) + inferred = list(node.infer()) + print(symbol + ':', ', '.join([str(i) for i in inferred])) + elif operation in ('mypy_infer', 'mypy_locals'): + + # Ask mypy for the type of the target symbol. + symbol = (selection if selection is not None else _py_symbol_at_column( + flines[line - 1], column)) + + # Insert a line after the provided one which is just the symbol so we + # can ask for its value alone. + match = re.match(r"\s*", flines[line - 1]) + whitespace = match.group() if match is not None else '' + if operation == 'mypy_infer': + sline = whitespace + 'reveal_type(' + symbol + ')' + else: + sline = whitespace + 'reveal_locals()' + flines = flines[:line] + [sline] + flines[line:] + + # Write a temp file and run the check on it. + # Let's use ' flycheck_*' for the name since pipeline scripts + # are already set to ignore those files. + tmppath = Path(filename.parent, 'flycheck_mp_' + filename.name) + with tmppath.open('w') as outfile: + outfile.write('\n'.join(flines)) + try: + code.runmypy([str(tmppath)], check=False) + except Exception as exc: + print('error running mypy:', exc) + tmppath.unlink() + elif operation == 'pylint_node': + flines[line - 1] += ' #@' + node = astroid.extract_node('\n'.join(flines)) + print(node) + elif operation == 'pylint_tree': + flines[line - 1] += ' #@' + node = astroid.extract_node('\n'.join(flines)) + print(node.repr_tree()) + else: + print('unknown operation: ' + operation) diff --git a/tools/efrotools/code.py b/tools/efrotools/code.py new file mode 100644 index 00000000..94f236a9 --- /dev/null +++ b/tools/efrotools/code.py @@ -0,0 +1,809 @@ +"""Functionality for formatting, linting, etc. code.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from efrotools.filecache import FileCache + +if TYPE_CHECKING: + from typing import Set, List, Dict, Any, Union, Optional + + +def formatcode(projroot: Path, full: bool) -> None: + """Run clang-format on all of our source code (multithreaded).""" + import time + import concurrent.futures + from efrotools import get_files_hash, get_proc_count + os.chdir(projroot) + cachepath = Path(projroot, 'config/.cache-formatcode') + if full and cachepath.exists(): + cachepath.unlink() + cache = FileCache(cachepath) + cfconfig = Path(projroot, '.clang-format') + + filenames = get_code_filenames(projroot) + confighash = get_files_hash([cfconfig]) + cache.update(filenames, confighash) + + dirtyfiles = cache.get_dirty_files() + + def format_file(filename: str) -> Dict[str, Any]: + start_time = time.time() + + # Note: seems os.system does not unlock the gil; + # make sure to use subprocess. + result = subprocess.call(['clang-format', '-i', filename]) + if result != 0: + raise Exception(f'Formatting failed for {filename}') + duration = time.time() - start_time + print(f'Formatted {filename} in {duration:.2f} seconds') + sys.stdout.flush() + return {'f': filename, 't': duration} + + # NOTE: using fewer workers than we have logical procs for now; + # we're bottlenecked by one or two long running instances + # so it actually helps to lighten the load around them. + # may want to revisit later when we have everything chopped up + # better + with concurrent.futures.ThreadPoolExecutor(max_workers=get_proc_count() // + 2) as executor: + # Converting this to a list will propagate any errors. + list(executor.map(format_file, dirtyfiles)) + + if dirtyfiles: + # Since we changed files, need to update hashes again. + cache.update(filenames, confighash) + cache.mark_clean(filenames) + cache.write() + print(f'Formatting up to date for {len(filenames)} code files.') + + +def cpplintcode(projroot: Path, full: bool) -> None: + """Run lint-checking on all code deemed lint-able.""" + from concurrent.futures import ThreadPoolExecutor + from efrotools import get_config, get_proc_count + os.chdir(projroot) + filenames = get_code_filenames(projroot) + if any(' ' in name for name in filenames): + raise Exception('found space in path; unexpected') + + # Check the config for a list of ones to ignore. + code_blacklist: List[str] = get_config(projroot).get( + 'cpplint_blacklist', []) + + # Just pretend blacklisted ones don't exist. + filenames = [f for f in filenames if f not in code_blacklist] + filenames = [f for f in filenames if not f.endswith('.mm')] + + cachepath = Path(projroot, 'config/.cache-lintcode') + if full and cachepath.exists(): + cachepath.unlink() + + cache = FileCache(cachepath) + + # Clear out entries and hashes for files that have changed/etc. + cache.update(filenames, '') + dirtyfiles = cache.get_dirty_files() + + if dirtyfiles: + print(f'CppLint checking {len(dirtyfiles)} file(s)...') + + def lint_file(filename: str) -> None: + result = subprocess.call(['cpplint', '--root=src', filename]) + if result != 0: + raise Exception(f'Linting failed for {filename}') + + with ThreadPoolExecutor(max_workers=get_proc_count() // 2) as executor: + # Converting this to a list will propagate any errors. + list(executor.map(lint_file, dirtyfiles)) + + if dirtyfiles: + cache.mark_clean(filenames) + cache.write() + print(f'CppLint: {len(filenames)} files up to date.') + + +def get_code_filenames(projroot: Path) -> List[str]: + """Return the list of files to lint-check or auto-formatting.""" + from efrotools import get_config + exts = ('.h', '.cc', '.m', '.mm') + places = get_config(projroot).get('code_source_dirs', None) + if places is None: + raise RuntimeError('code_source_dirs not declared in config') + codefilenames = [] + for place in places: + for root, _dirs, files in os.walk(place): + for fname in files: + if any(fname.endswith(ext) for ext in exts): + codefilenames.append(os.path.join(root, fname)) + codefilenames.sort() + return codefilenames + + +def formatscripts(projroot: Path, full: bool) -> None: + """Runs yapf on all our scripts (multithreaded).""" + import time + from concurrent.futures import ThreadPoolExecutor + from efrotools import get_proc_count, get_files_hash + os.chdir(projroot) + cachepath = Path(projroot, 'config/.cache-formatscripts') + if full and cachepath.exists(): + cachepath.unlink() + + cache = FileCache(cachepath) + yapfconfig = Path(projroot, '.style.yapf') + + filenames = get_script_filenames(projroot) + confighash = get_files_hash([yapfconfig]) + cache.update(filenames, confighash) + + dirtyfiles = cache.get_dirty_files() + + def format_file(filename: str) -> None: + start_time = time.time() + result = subprocess.call(['yapf', '--in-place', filename]) + if result != 0: + raise Exception(f'Formatting failed for {filename}') + duration = time.time() - start_time + print(f'Formatted {filename} in {duration:.2f} seconds') + sys.stdout.flush() + + # NOTE: using fewer workers than we have logical procs for now; + # we're bottlenecked by one or two long running instances + # so it actually helps to lighten the load around them. + # may want to revisit later when we have everything chopped up + # better + with ThreadPoolExecutor(max_workers=get_proc_count() // 2) as executor: + # Converting this to a list will propagate any errors + list(executor.map(format_file, dirtyfiles)) + + if dirtyfiles: + # Since we changed files, need to update hashes again. + cache.update(filenames, confighash) + cache.mark_clean(filenames) + cache.write() + print(f'Formatting up to date for {len(filenames)} script files.') + + +def _should_include_script(fnamefull: str) -> bool: + fname = os.path.basename(fnamefull) + + if fname.endswith('.py'): + return True + + # Look for 'binary' scripts with no extensions too. + if not fname.startswith('.') and '.' not in fname: + try: + with open(fnamefull) as infile: + line = infile.readline() + if '/usr/bin/env python' in line or '/usr/bin/python' in line: + return True + except UnicodeDecodeError: + # actual binary files will probably kick back this error.. + pass + return False + + +def get_script_filenames(projroot: Path) -> List[str]: + """Return the Python filenames to lint-check or auto-format.""" + from efrotools import get_config + filenames = set() + places = get_config(projroot).get('python_source_dirs', None) + if places is None: + raise RuntimeError('python_source_dirs not declared in config') + for place in places: + for root, _dirs, files in os.walk(place): + for fname in files: + fnamefull = os.path.join(root, fname) + if _should_include_script(fnamefull): + filenames.add(fnamefull) + return sorted(list(f for f in filenames if 'flycheck_' not in f)) + + +def pylintscripts(projroot: Path, full: bool, fast: bool) -> None: + """Run lint-checking on all scripts deemed lint-able.""" + from efrotools import get_files_hash + pylintrc = Path(projroot, '.pylintrc') + if not os.path.isfile(pylintrc): + raise Exception('pylintrc not found where expected') + filenames = get_script_filenames(projroot) + + if any(' ' in name for name in filenames): + raise Exception('found space in path; unexpected') + script_blacklist: List[str] = [] + filenames = [f for f in filenames if f not in script_blacklist] + + cachebasename = '.cache-lintscriptsfast' if fast else '.cache-lintscripts' + cachepath = Path(projroot, 'config', cachebasename) + if full and cachepath.exists(): + cachepath.unlink() + cache = FileCache(cachepath) + + # Clear out entries and hashes for files that have changed/etc. + cache.update(filenames, get_files_hash([pylintrc])) + + # Do a recursive dependency check and mark all files who are + # either dirty or have a dependency that is dirty. + filestates: Dict[str, bool] = {} + for fname in filenames: + _dirty_dep_check(fname, filestates, cache, fast, 0) + + dirtyfiles = [k for k, v in filestates.items() if v] + + # Let's sort by modification time, so ones we're actively trying + # to fix get linted first and we see remaining errors faster. + dirtyfiles.sort(reverse=True, key=lambda f: os.stat(f).st_mtime) + + if dirtyfiles: + print(f'Pylint checking {len(dirtyfiles)} file(s)...', flush=True) + try: + _run_script_lint(projroot, pylintrc, cache, dirtyfiles, filenames) + except Exception: + # Note: even if we fail here, we still want to + # update our disk cache (since some lints may have passed). + print('Pylint failed.', flush=True) + import traceback + traceback.print_exc() + cache.write() + sys.exit(255) + print(f'Pylint: {len(filenames)} files up to date.', flush=True) + + cache.write() + + +def _dirty_dep_check(fname: str, filestates: Dict[str, bool], cache: FileCache, + fast: bool, recursion: int) -> bool: + """Recursively check a file's deps and return whether it is dirty.""" + # pylint: disable=too-many-branches + + if not fast: + # Check for existing dirty state (only applies in non-fast where + # we recurse infinitely). + curstate = filestates.get(fname) + if curstate is not None: + return curstate + + # Ok; there's no current state for this file. + # First lets immediately mark it as clean so if a dependency of ours + # queries it we won't loop infinitely. (If we're actually dirty that + # will be reflected properly once we're done). + if not fast: + filestates[fname] = False + + # If this dependency has disappeared, consider that dirty. + if fname not in cache.entries: + dirty = True + else: + cacheentry = cache.entries[fname] + + # See if we ourself are dirty + if 'hash' not in cacheentry: + dirty = True + else: + # Ok we're clean; now check our dependencies.. + dirty = False + + # Only increment recursion in fast mode, and + # skip dependencies if we're pass the recursion limit. + recursion2 = recursion + if fast: + # Our one exception is top level ba which basically aggregates. + if not fname.endswith('/ba/__init__.py'): + recursion2 += 1 + if recursion2 <= 1: + deps = cacheentry.get('deps', []) + for dep in deps: + # If we have a dep that no longer exists, WE are dirty. + if not os.path.exists(dep): + dirty = True + break + if _dirty_dep_check(dep, filestates, cache, fast, + recursion2): + dirty = True + break + + # Cache and return our dirty state.. + # Note: for fast mode we limit to recursion==0 so we only write when + # the file itself is being directly visited. + if recursion == 0: + filestates[fname] = dirty + return dirty + + +def _run_script_lint(projroot: Path, pylintrc: Union[Path, str], + cache: FileCache, dirtyfiles: List[str], + allfiles: List[str]) -> Dict[str, Any]: + import time + from pylint import lint + start_time = time.time() + args = ['--rcfile', str(pylintrc)] + + args += dirtyfiles + name = f'{len(dirtyfiles)} file(s)' + run = lint.Run(args, do_exit=False) + result = _apply_pylint_run_to_cache(projroot, run, dirtyfiles, allfiles, + cache) + if result != 0: + raise Exception(f'Linting failed for {result} file(s).') + + # Sanity check: when the linter fails we should always be failing too. + # If not, it means we're probably missing something and incorrectly + # marking a failed file as clean. + if run.linter.msg_status != 0 and result == 0: + raise Exception('linter returned non-zero result but we did not;' + ' this is probably a bug.') + # result = run.linter.msg_status + # we can run + duration = time.time() - start_time + print(f'Pylint passed for {name} in {duration:.1f} seconds.') + sys.stdout.flush() + return {'f': dirtyfiles, 't': duration} + + +def _apply_pylint_run_to_cache(projroot: Path, run: Any, dirtyfiles: List[str], + allfiles: List[str], cache: FileCache) -> int: + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + from astroid import modutils + from efrotools import get_config + + # First off, build a map of dirtyfiles to module names + # (and the corresponding reverse map). + paths_to_names: Dict[str, str] = {} + names_to_paths: Dict[str, str] = {} + for fname in allfiles: + try: + mpath = modutils.modpath_from_file(fname) + mpath = _filter_module_name('.'.join(mpath)) + paths_to_names[fname] = mpath + except ImportError: + # This probably means its a tool or something not in our + # standard path. In this case just use its base name. + # (seems to be what pylint does) + dummyname = os.path.splitext(os.path.basename(fname))[0] + paths_to_names[fname] = dummyname + for key, val in paths_to_names.items(): + names_to_paths[val] = key + + # If there's any cyclic-import errors, just mark all deps as dirty; + # don't want to add the logic to figure out which ones the cycles cover + # since they all seems to appear as errors for the last file in the list. + cycles: int = run.linter.stats.get('by_msg', {}).get('cyclic-import', 0) + have_dep_cycles: bool = cycles > 0 + if have_dep_cycles: + print(f'Found {cycles} cycle-errors; keeping all dirty files dirty.') + + # Update dependencies for what we just ran. + # A run leaves us with a map of modules to a list of the modules that + # imports them. We want the opposite though: for each of our modules + # we want a list of the modules it imports. + reversedeps = {} + + # Make sure these are all proper module names; no foo.bar.__init__ stuff. + for key, val in run.linter.stats['dependencies'].items(): + sval = [_filter_module_name(m) for m in val] + reversedeps[_filter_module_name(key)] = sval + deps: Dict[str, Set[str]] = {} + untracked_deps = set() + for mname, mallimportedby in reversedeps.items(): + for mimportedby in mallimportedby: + if mname in names_to_paths: + deps.setdefault(mimportedby, set()).add(mname) + else: + untracked_deps.add(mname) + + ignored_untracked_deps: List[str] = get_config(projroot).get( + 'pylint_ignored_untracked_deps', []) + + # Add a few that this package itself triggers. + ignored_untracked_deps += ['pylint.lint', 'astroid.modutils', 'astroid'] + + # Ignore some specific untracked deps; complain about any others. + untracked_deps = set(dep for dep in untracked_deps + if dep not in ignored_untracked_deps) + if untracked_deps: + raise Exception( + f'Found untracked dependencies: {untracked_deps}.' + ' If these are external to your project, add them to' + ' "pylint_ignored_untracked_deps" in the project config.') + + # Finally add the dependency lists to our entries (operate on + # everything in the run; it may not be mentioned in deps). + no_deps_modules = set() + for fname in dirtyfiles: + fmod = paths_to_names[fname] + if fmod not in deps: + + # Since this code is a bit flaky, lets always announce when + # we come up empty and keep a whitelist of expected values to + # ignore. + no_deps_modules.add(fmod) + depsval: List[str] = [] + else: + # Our deps here are module names; store paths. + depsval = [names_to_paths[dep] for dep in deps[fmod]] + cache.entries[fname]['deps'] = depsval + + # Let's print a list of modules with no detected deps so we can make sure + # this is behaving. + if no_deps_modules: + # noinspection PyUnreachableCode + if False: # pylint: disable=using-constant-test + print('NOTE: no dependencies found for:', + ', '.join(no_deps_modules)) + + # Ok, now go through all dirtyfiles involved in this run. + # Mark them as either errored or clean depending on whether there's + # error info for them in the run stats. + + # Once again need to convert any foo.bar.__init__ to foo.bar. + stats_by_module: Dict[str, Any] = { + _filter_module_name(key): val + for key, val in run.linter.stats['by_module'].items() + } + errcount = 0 + + for fname in dirtyfiles: + mname2 = paths_to_names.get(fname) + if mname2 is None: + raise Exception('unable to get module name for "' + fname + '"') + counts = (None if mname2 is None else stats_by_module.get(mname2)) + + # 'statement' count seems to be new and always non-zero; ignore it + if counts is not None: + counts = {c: v for c, v in counts.items() if c != 'statement'} + if (counts is not None and any(counts.values())) or have_dep_cycles: + # print('GOT FAIL FOR', fname, counts) + if 'hash' in cache.entries[fname]: + del cache.entries[fname]['hash'] + errcount += 1 + else: + # print('MARKING FILE CLEAN', mname2, fname) + cache.entries[fname]['hash'] = (cache.curhashes[fname]) + + return errcount + + +def _filter_module_name(mpath: str) -> str: + """Filter weird module paths such as 'foo.bar.__init__' to 'foo.bar'.""" + + # Seems Pylint returns module paths with __init__ on the end in some cases + # and not in others. Could dig into it, but for now just filtering them + # out... + return mpath[:-9] if mpath.endswith('.__init__') else mpath + + +def runmypy(filenames: List[str], full: bool = False, + check: bool = True) -> None: + """Run MyPy on provided filenames.""" + args = ['mypy', '--pretty', '--config-file', '.mypy.ini'] + filenames + if full: + args.insert(1, '--no-incremental') + subprocess.run(args, check=check) + + +def mypyscripts(projroot: Path, full: bool) -> None: + """Run mypy on all of our scripts.""" + import time + filenames = get_script_filenames(projroot) + print('Running Mypy ' + ('(full)' if full else '(incremental)') + '...') + starttime = time.time() + try: + runmypy(filenames, full) + except Exception: + print('Mypy: fail.') + sys.exit(255) + duration = time.time() - starttime + print(f'Mypy passed in {duration:.1f} seconds.') + + +def _parse_idea_results(path: Path) -> int: + """Print errors found in an idea inspection xml file. + + Returns the number of errors found. + """ + import xml.etree.ElementTree as Et + error_count = 0 + root = Et.parse(str(path)).getroot() + for child in root: + line: Optional[str] = None + description: Optional[str] = None + fname: Optional[str] = None + if child.tag == 'problem': + is_error = True + for pchild in child: + if pchild.tag == 'problem_class': + # We still report typos but we don't fail the + # check due to them (that just gets tedious). + if pchild.text == 'Typo': + is_error = False + if pchild.tag == 'line': + line = pchild.text + if pchild.tag == 'description': + description = pchild.text + if pchild.tag == 'file': + fname = pchild.text + if isinstance(fname, str): + fname = fname.replace('file://$PROJECT_DIR$/', '') + print(f'{fname}:{line}: {description}') + if is_error: + error_count += 1 + return error_count + + +def _run_idea_inspections(projroot: Path, + scripts: List[str], + displayname: str, + inspect: Path, + verbose: bool, + inspectdir: Path = None) -> None: + """Actually run idea inspections. + + Throw an Exception if anything is found or goes wrong. + """ + # pylint: disable=too-many-locals + import tempfile + import time + import datetime + start_time = time.time() + print(f'{displayname} checking', len(scripts), 'file(s)...', flush=True) + tmpdir = tempfile.TemporaryDirectory() + iprof = Path(projroot, '.idea/inspectionProfiles/Default.xml') + if not iprof.exists(): + iprof = Path(projroot, '.idea/inspectionProfiles/Project_Default.xml') + if not iprof.exists(): + raise Exception("No default inspection profile found.") + cmd = [str(inspect), str(projroot), str(iprof), tmpdir.name, '-v2'] + if inspectdir is not None: + cmd += ['-d', str(inspectdir)] + running = True + + def heartbeat() -> None: + """Print the time occasionally to make the log more informative.""" + while running: + time.sleep(60) + print("Heartbeat", datetime.datetime.now(), flush=True) + + if verbose: + import threading + print(cmd, flush=True) + threading.Thread(target=heartbeat, daemon=True).start() + + result = subprocess.run(cmd, capture_output=not verbose, check=False) + running = False + if result.returncode != 0: + # In verbose mode this stuff got printed already. + if not verbose: + stdout = (result.stdout.decode() if isinstance( + result.stdout, bytes) else str(result.stdout)) + stderr = (result.stderr.decode() if isinstance( + result.stdout, bytes) else str(result.stdout)) + print(f'{displayname} inspection failure stdout:\n{stdout}' + + f'{displayname} inspection failure stderr:\n{stderr}') + raise RuntimeError(f"{displayname} inspection failed.") + files = [f for f in os.listdir(tmpdir.name) if not f.startswith('.')] + total_errors = 0 + if files: + for fname in files: + total_errors += _parse_idea_results(Path(tmpdir.name, fname)) + if total_errors > 0: + raise RuntimeError( + f"{displayname} inspection found {total_errors} error(s).") + duration = time.time() - start_time + + print( + f'{displayname} passed for {len(scripts)} files' + f' in {duration:.1f} seconds.', + flush=True) + + +def _run_idea_inspections_cached(cachepath: Path, + filenames: List[str], + full: bool, + projroot: Path, + displayname: str, + inspect: Path, + verbose: bool, + inspectdir: Path = None) -> None: + # pylint: disable=too-many-locals + import hashlib + import json + md5 = hashlib.md5() + + # Let's calc a single hash from the contents of all script files and only + # run checks when that changes. Sadly there's not much else optimization + # wise that we can easily do, but this will at least prevent re-checks when + # nothing at all has changed. + for filename in filenames: + with open(filename, 'rb') as infile: + md5.update(infile.read()) + + # Also hash a few .idea files so we re-run inspections when they change. + extra_hash_paths = [ + Path(projroot, '.idea/inspectionProfiles/Default.xml'), + Path(projroot, '.idea/inspectionProfiles/Project_Default.xml'), + Path(projroot, '.idea/dictionaries/ericf.xml') + ] + for epath in extra_hash_paths: + if os.path.exists(epath): + with open(epath, 'rb') as infile: + md5.update(infile.read()) + + current_hash = md5.hexdigest() + existing_hash: Optional[str] + try: + with open(cachepath) as infile2: + existing_hash = json.loads(infile2.read())['hash'] + except Exception: + existing_hash = None + if full or current_hash != existing_hash: + _run_idea_inspections(projroot, + filenames, + displayname, + inspect=inspect, + verbose=verbose, + inspectdir=inspectdir) + with open(cachepath, 'w') as outfile: + outfile.write(json.dumps({'hash': current_hash})) + print(f'{displayname}: {len(filenames)} files up to date.') + + +def pycharmscripts(projroot: Path, full: bool, verbose: bool) -> None: + """Run pycharm inspections on all our scripts.""" + + import time + + cachepath = Path('config/.cache-pycharmscripts') + filenames = get_script_filenames(projroot) + pycharmroot = Path('/Applications/PyCharm CE.app') + pycharmbin = Path(pycharmroot, 'Contents/MacOS/pycharm') + inspect = Path(pycharmroot, 'Contents/bin/inspect.sh') + + # In full mode, clear out pycharm's caches first. + # It seems we need to spin up the GUI and give it a bit to + # re-cache system python for this to work... + # UPDATE: This really slows things down, so we now only do it in + # very specific cases where time isn't important. + # (such as our daily full-test-runs) + if full and os.environ.get('EFROTOOLS_FULL_PYCHARM_RECACHE') == '1': + print('Clearing PyCharm caches...', flush=True) + subprocess.run('rm -rf ~/Library/Caches/PyCharmCE*', + shell=True, + check=True) + print('Launching GUI PyCharm to rebuild caches...', flush=True) + process = subprocess.Popen(str(pycharmbin)) + + # Wait a bit and ask it nicely to die. + # We need to make sure it has enough time to do its cache updating + # thing even if the system is fully under load. + time.sleep(10 * 60) + + # Seems killing it via applescript is more likely to leave it + # in a working state for offline inspections than TERM signal.. + subprocess.run( + "osascript -e 'tell application \"PyCharm CE\" to quit'", + shell=True, + check=False) + # process.terminate() + print('Waiting for GUI PyCharm to quit...', flush=True) + process.wait() + + _run_idea_inspections_cached(cachepath=cachepath, + filenames=filenames, + full=full, + projroot=projroot, + displayname='PyCharm', + inspect=inspect, + verbose=verbose) + + +def clioncode(projroot: Path, full: bool, verbose: bool) -> None: + """Run clion inspections on all our code.""" + import time + + cachepath = Path('config/.cache-clioncode') + filenames = get_code_filenames(projroot) + clionroot = Path('/Applications/CLion.app') + clionbin = Path(clionroot, 'Contents/MacOS/clion') + inspect = Path(clionroot, 'Contents/bin/inspect.sh') + + # At the moment offline clion inspections seem a bit flaky. + # They don't seem to run at all if we haven't opened the project + # in the GUI, and it seems recent changes can get ignored for that + # reason too. + # So for now let's try blowing away caches, launching the gui + # temporarily, and then kicking off inspections after that. Sigh. + print('Clearing clion caches...', flush=True) + subprocess.run('rm -rf ~/Library/Caches/CLion*', shell=True, check=True) + + # Note: I'm assuming this project needs to be open when the GUI + # comes up. Currently just have one project so can rely on auto-open + # but may need to get fancier later if that changes. + print('Launching GUI CLion to rebuild caches...', flush=True) + process = subprocess.Popen(str(clionbin)) + + # Wait a moment and ask it nicely to die. + time.sleep(120) + + # Seems killing it via applescript is more likely to leave it + # in a working state for offline inspections than TERM signal.. + subprocess.run("osascript -e 'tell application \"CLion\" to quit'", + shell=True, + check=False) + + # process.terminate() + print('Waiting for GUI CLion to quit...', flush=True) + process.wait(timeout=60) + + print('Launching Offline CLion to run inspections...', flush=True) + _run_idea_inspections_cached( + cachepath=cachepath, + filenames=filenames, + full=full, + projroot=Path(projroot, 'ballisticacore-cmake'), + inspectdir=Path(projroot, 'ballisticacore-cmake/src/ballistica'), + displayname='CLion', + inspect=inspect, + verbose=verbose) + + +def androidstudiocode(projroot: Path, full: bool, verbose: bool) -> None: + """Run Android Studio inspections on all our code.""" + # import time + + cachepath = Path('config/.cache-androidstudiocode') + filenames = get_code_filenames(projroot) + clionroot = Path('/Applications/Android Studio.app') + # clionbin = Path(clionroot, 'Contents/MacOS/studio') + inspect = Path(clionroot, 'Contents/bin/inspect.sh') + + # At the moment offline clion inspections seem a bit flaky. + # They don't seem to run at all if we haven't opened the project + # in the GUI, and it seems recent changes can get ignored for that + # reason too. + # So for now let's try blowing away caches, launching the gui + # temporarily, and then kicking off inspections after that. Sigh. + # print('Clearing Android Studio caches...', flush=True) + # subprocess.run('rm -rf ~/Library/Caches/AndroidStudio*', + # shell=True, + # check=True) + + # Note: I'm assuming this project needs to be open when the GUI + # comes up. Currently just have one project so can rely on auto-open + # but may need to get fancier later if that changes. + # print('Launching GUI CLion to rebuild caches...', flush=True) + # process = subprocess.Popen(str(clionbin)) + + # Wait a moment and ask it nicely to die. + # time.sleep(120) + + # Seems killing it via applescript is more likely to leave it + # in a working state for offline inspections than TERM signal.. + # subprocess.run( + # "osascript -e 'tell application \"Android Studio\" to quit'", + # shell=True) + + # process.terminate() + # print('Waiting for GUI CLion to quit...', flush=True) + # process.wait(timeout=60) + + print('Launching Offline Android Studio to run inspections...', flush=True) + _run_idea_inspections_cached( + cachepath=cachepath, + filenames=filenames, + full=full, + projroot=Path(projroot, 'ballisticacore-android'), + inspectdir=Path( + projroot, + 'ballisticacore-android/BallisticaCore/src/main/cpp/src/ballistica' + ), + # inspectdir=None, + displayname='Android Studio', + inspect=inspect, + verbose=verbose) diff --git a/tools/efrotools/filecache.py b/tools/efrotools/filecache.py new file mode 100644 index 00000000..f1cf6aa1 --- /dev/null +++ b/tools/efrotools/filecache.py @@ -0,0 +1,92 @@ +"""Provides a system for caching linting/formatting operations.""" + +from __future__ import annotations + +import json +import os +from typing import TYPE_CHECKING + +from efrotools import get_files_hash + +if TYPE_CHECKING: + from typing import Dict, Optional, Sequence, Any + from pathlib import Path + + +class FileCache: + """A cache of file hashes/etc. used in linting/formatting/etc.""" + + def __init__(self, path: Path): + self._path = path + self.curhashes: Dict[str, Optional[str]] = {} + self.mtimes: Dict[str, float] = {} + self.entries: Dict[str, Any] + if not os.path.exists(path): + self.entries = {} + else: + with open(path, 'r') as infile: + self.entries = json.loads(infile.read()) + + def update(self, filenames: Sequence[str], extrahash: str) -> None: + """Update the cache for the provided files and hash type. + + Hashes will be checked for all files (incorporating extrahash) + and mismatched hash values cleared. Entries for no-longer-existing + files will be cleared as well. + """ + + # First, completely prune entries for nonexistent files. + self.entries = { + path: val + for path, val in self.entries.items() if os.path.isfile(path) + } + + # Also remove any not in our passed list. + self.entries = { + path: val + for path, val in self.entries.items() if path in filenames + } + + # Add empty entries for files that lack them. + # Also check and store current hashes for all files and clear + # any entry hashes that differ so we know they're dirty. + for filename in filenames: + if filename not in self.entries: + self.entries[filename] = {} + self.curhashes[filename] = curhash = (get_files_hash([filename], + extrahash)) + # Also store modtimes; we'll abort cache writes if + # anything changed. + self.mtimes[filename] = os.path.getmtime(filename) + entry = self.entries[filename] + if 'hash' in entry and entry['hash'] != curhash: + del entry['hash'] + + def get_dirty_files(self) -> Sequence[str]: + """Return paths for all entries with no hash value.""" + + return [ + key for key, value in self.entries.items() if 'hash' not in value + ] + + def mark_clean(self, files: Sequence[str]) -> None: + """Marks provided files as up to date.""" + for fname in files: + self.entries[fname]['hash'] = self.curhashes[fname] + + # Also update their registered mtimes. + self.mtimes[fname] = os.path.getmtime(fname) + + def write(self) -> None: + """Writes the state back to its file.""" + + # Check all file mtimes against the ones we started with; + # if anything has been modified, don't write. + for fname, mtime in self.mtimes.items(): + if os.path.getmtime(fname) != mtime: + print('File changed during run: "' + fname + '";' + + ' cache not updated.') + return + out = json.dumps(self.entries) + with open(self._path, 'w') as outfile: + outfile.write(out) diff --git a/tools/efrotools/ios.py b/tools/efrotools/ios.py new file mode 100644 index 00000000..bc6c2fe7 --- /dev/null +++ b/tools/efrotools/ios.py @@ -0,0 +1,185 @@ +"""Tools related to ios development.""" + +from __future__ import annotations + +import pathlib +import subprocess +import sys +from dataclasses import dataclass + +from efrotools import get_localconfig, get_config + +MODES = { + 'debug': { + 'configuration': 'Debug' + }, + 'release': { + 'configuration': 'Release' + } +} + + +@dataclass +class Config: + """Configuration values for this project.""" + + # Project relative xcodeproj path ('MyAppName/MyAppName.xcodeproj'). + projectpath: str + + # App bundle name ('MyAppName.app'). + app_bundle_name: str + + # Base name of the ipa archive to be pushed ('myappname'). + archive_name: str + + # Scheme to build ('MyAppName iOS'). + scheme: str + + +@dataclass +class LocalConfig: + """Configuration values specific to the machine.""" + + # Sftp host ('myuserid@myserver.com'). + sftp_host: str + + # Path to push ipa to ('/home/myhome/dir/where/i/want/this/). + sftp_dir: str + + +def push_ipa(root: pathlib.Path, modename: str) -> None: + """Construct ios IPA and push it to staging server for device testing. + + This takes some shortcuts to minimize turnaround time; + It doesn't recreate the ipa completely each run, uses rsync + for speedy pushes to the staging server, etc. + The use case for this is quick build iteration on a device + that is not physically near the build machine. + """ + + # Load both the local and project config data. + cfg = Config(**get_config(root)['push_ipa_config']) + lcfg = LocalConfig(**get_localconfig(root)['push_ipa_local_config']) + + if modename not in MODES: + raise Exception('invalid mode: "' + str(modename) + '"') + mode = MODES[modename] + + xc_build_path = pathlib.Path(root, 'tools/xc_build_path') + xcprojpath = pathlib.Path(root, cfg.projectpath) + app_dir = subprocess.run( + [xc_build_path, xcprojpath, mode['configuration']], + check=True, + capture_output=True).stdout.decode().strip() + built_app_path = pathlib.Path(app_dir, cfg.app_bundle_name) + + workdir = pathlib.Path(root, 'build', "push_ipa") + workdir.mkdir(parents=True, exist_ok=True) + + pathlib.Path(root, 'build').mkdir(parents=True, exist_ok=True) + exportoptionspath = pathlib.Path(root, workdir, 'exportoptions.plist') + ipa_dir_path = pathlib.Path(root, workdir, 'ipa') + ipa_dir_path.mkdir(parents=True, exist_ok=True) + + # Inject our latest build into an existing xcarchive (creating if needed). + archivepath = _add_build_to_xcarchive(workdir, xcprojpath, built_app_path, + cfg) + + # Export an IPA from said xcarchive. + ipa_path = _export_ipa_from_xcarchive(archivepath, exportoptionspath, + ipa_dir_path, cfg) + + # And lastly sync said IPA up to our staging server. + print('Pushing to staging server...') + sys.stdout.flush() + subprocess.run( + [ + 'rsync', '--verbose', ipa_path, '-e', + 'ssh -oBatchMode=yes -oStrictHostKeyChecking=yes', + f'{lcfg.sftp_host}:{lcfg.sftp_dir}' + ], + check=True, + ) + + print('iOS Package Updated Successfully!') + + +def _add_build_to_xcarchive(workdir: pathlib.Path, xcprojpath: pathlib.Path, + built_app_path: pathlib.Path, + cfg: Config) -> pathlib.Path: + archivepathbase = pathlib.Path(workdir, cfg.archive_name) + archivepath = pathlib.Path(workdir, cfg.archive_name + '.xcarchive') + + # Rebuild a full archive if one doesn't exist. + if not archivepath.exists(): + print('Base archive not found; doing full build (can take a while)...') + sys.stdout.flush() + args = [ + 'xcodebuild', 'archive', '-project', + str(xcprojpath), '-scheme', cfg.scheme, '-configuration', + MODES['debug']['configuration'], '-archivePath', + str(archivepathbase) + ] + subprocess.run(args, check=True, capture_output=True) + + # Now copy our just-built app into the archive. + print('Copying build to archive...') + sys.stdout.flush() + archive_app_path = pathlib.Path( + archivepath, 'Products/Applications/' + cfg.app_bundle_name) + subprocess.run(['rm', '-rf', archive_app_path], check=True) + subprocess.run(['cp', '-r', built_app_path, archive_app_path], check=True) + return archivepath + + +def _export_ipa_from_xcarchive(archivepath: pathlib.Path, + exportoptionspath: pathlib.Path, + ipa_dir_path: pathlib.Path, + cfg: Config) -> pathlib.Path: + import textwrap + print('Exporting IPA...') + exportoptions = textwrap.dedent(""" + + + + + compileBitcode + + destination + export + method + development + signingStyle + automatic + stripSwiftSymbols + + teamID + G7TQB7SM63 + thinning + <none> + + + """).strip() + with exportoptionspath.open('w') as outfile: + outfile.write(exportoptions) + + sys.stdout.flush() + args = [ + 'xcodebuild', '-allowProvisioningUpdates', '-exportArchive', + '-archivePath', + str(archivepath), '-exportOptionsPlist', + str(exportoptionspath), '-exportPath', + str(ipa_dir_path) + ] + try: + subprocess.run(args, check=True, capture_output=True) + except Exception: + print('Error exporting code-signed archive; ' + ' perhaps try running "security unlock-keychain login.keychain"') + raise + + ipa_path_exported = pathlib.Path(ipa_dir_path, cfg.scheme + '.ipa') + ipa_path = pathlib.Path(ipa_dir_path, cfg.archive_name + '.ipa') + subprocess.run(['mv', ipa_path_exported, ipa_path], check=True) + return ipa_path diff --git a/tools/efrotools/jsontools.py b/tools/efrotools/jsontools.py new file mode 100644 index 00000000..f645b9dd --- /dev/null +++ b/tools/efrotools/jsontools.py @@ -0,0 +1,44 @@ +"""Json related tools functionality.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Dict, Any + + +class NoIndent: + """Used to prevent indenting in our custom json encoder. + + Wrap values in this before passing to encoder and all child + values will be a single line in the json output.""" + + def __init__(self, value: Any) -> None: + self.value = value + + +class NoIndentEncoder(json.JSONEncoder): + """Our custom encoder implementing selective indentation.""" + + def __init__(self, *args: Any, **kwargs: Any): + super(NoIndentEncoder, self).__init__(*args, **kwargs) + self.kwargs = dict(kwargs) + del self.kwargs['indent'] + self._replacement_map: Dict = {} + + def default(self, o: Any) -> Any: # pylint: disable=method-hidden + import uuid + + if isinstance(o, NoIndent): + key = uuid.uuid4().hex + self._replacement_map[key] = json.dumps(o.value, **self.kwargs) + return "@@%s@@" % (key, ) + return super(NoIndentEncoder, self).default(o) + + def encode(self, o: Any) -> Any: + result = super(NoIndentEncoder, self).encode(o) + for k, v in self._replacement_map.items(): + result = result.replace('"@@%s@@"' % (k, ), v) + return result diff --git a/tools/efrotools/pybuild.py b/tools/efrotools/pybuild.py new file mode 100644 index 00000000..1e851cf1 --- /dev/null +++ b/tools/efrotools/pybuild.py @@ -0,0 +1,632 @@ +"""Functionality related to building python for ios, android, etc.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import efrotools + +if TYPE_CHECKING: + from typing import List, Dict, Any + +# Overall version we're using for the game currently. +PYTHON_VERSION_MAJOR = "3.7" + +# Specific version we're using on apple builds. +PYTHON_VERSION_APPLE = "3.7.0" + +# Specific version we're using on android builds. +PYTHON_VERSION_ANDROID = "3.7.2" + +ENABLE_OPENSSL = True + + +def build_apple(arch: str, debug: bool = False) -> None: + """Run a build for the provided apple arch (mac, ios, or tvos).""" + builddir = 'build/python_apple_' + arch + ('_debug' if debug else '') + efrotools.run('rm -rf "' + builddir + '"') + efrotools.run('mkdir -p build') + efrotools.run('git clone ' + 'git@github.com:pybee/Python-Apple-support.git "' + + builddir + '"') + os.chdir(builddir) + efrotools.run('git checkout 3.7') + + # On mac we currently have to add the _scproxy module or urllib will + # fail. + txt = efrotools.readfile('patch/Python/Setup.embedded') + if arch == 'mac': + txt += ('\n' + '# ericf added - mac urllib needs this\n' + '_scproxy _scproxy.c ' + '-framework SystemConfiguration ' + '-framework CoreFoundation') + + # Turn off sqlite module. (scratch that; leaving it in.) + # txt = efrotools.replace_one(txt, '_sqlite3 -I$(', '#_sqlite3 -I$(') + # txt = txt.replace(' _sqlite/', '# _sqlite/') + + # Turn off xz compression module. (scratch that; leaving it in.) + # txt = efrotools.replace_one(txt, '_lzma _', '#_lzma _') + + # Turn off bzip2 module. + txt = efrotools.replace_one(txt, '_bz2 _b', '#_bz2 _b') + + # Turn off openssl module (only if not doing openssl). + if not ENABLE_OPENSSL: + txt = efrotools.replace_one(txt, '_hashlib _hashopenssl.c', + '#_hashlib _hashopenssl.c') + + # Turn off various other stuff we don't use. + for line in [ + '_codecs _codecsmodule.c', + '_codecs_cn cjkcodecs/_codecs_cn.c', + '_codecs_hk cjkcodecs/_codecs_hk.c', + '_codecs_iso2022 cjkcodecs/', + '_codecs_jp cjkcodecs/_codecs_jp.c', + '_codecs_jp cjkcodecs/_codecs_jp.c', + '_codecs_kr cjkcodecs/_codecs_kr.c', + '_codecs_tw cjkcodecs/_codecs_tw.c', + '_lsprof _lsprof.o rotatingtree.c', + '_multibytecodec cjkcodecs/multibytecodec.c', + '_multiprocessing _multiprocessing/multiprocessing.c', + '_opcode _opcode.c', + 'audioop audioop.c', + 'grp grpmodule.c', + 'mmap mmapmodule.c', + 'parser parsermodule.c', + 'pyexpat expat/xmlparse.c', + ' expat/xmlrole.c ', + ' expat/xmltok.c ', + ' pyexpat.c ', + ' -I$(srcdir)/Modules/expat ', + ' -DHAVE_EXPAT_CONFIG_H -DUSE_PYEXPAT_CAPI' + ' -DXML_DEV_URANDOM', + 'resource resource.c', + 'syslog syslogmodule.c', + 'termios termios.c', + '_ctypes_test _ctypes/_ctypes_test.c', + '_testbuffer _testbuffer.c', + '_testimportmultiple _testimportmultiple.c', + '_crypt _cryptmodule.c', # not on android so disabling here too + ]: + txt = efrotools.replace_one(txt, line, '#' + line) + + if ENABLE_OPENSSL: + + # _md5 and _sha modules are normally only built if the + # system does not have the OpenSSL libs containing an optimized + # version. + # Note: seems we still need sha3 or we get errors + for line in [ + '_md5 md5module.c', + '_sha1 sha1module.c', + # '_sha3 _sha3/sha3module.c', + '_sha256 sha256module.c', + '_sha512 sha512module.c', + ]: + txt = efrotools.replace_one(txt, line, '#' + line) + else: + txt = efrotools.replace_one(txt, '_ssl _ssl.c', '#_ssl _ssl.c') + efrotools.writefile('patch/Python/Setup.embedded', txt) + + txt = efrotools.readfile('Makefile') + + # Fix a bug where spaces in PATH cause errors (darn you vmware fusion!) + txt = efrotools.replace_one( + txt, '&& PATH=$(PROJECT_DIR)/$(PYTHON_DIR-macOS)/dist/bin:$(PATH) .', + '&& PATH="$(PROJECT_DIR)/$(PYTHON_DIR-macOS)/dist/bin:$(PATH)" .') + txt = efrotools.replace_one( + txt, '&& PATH=$(PROJECT_DIR)/$(PYTHON_DIR-macOS)/dist/bin:$(PATH) m', + '&& PATH="$(PROJECT_DIR)/$(PYTHON_DIR-macOS)/dist/bin:$(PATH)" m') + + # Remove makefile dependencies so we don't build the + # libs we're not using. + srctxt = '$$(PYTHON_DIR-$1)/dist/lib/libpython$(PYTHON_VER)m.a: ' + txt = efrotools.replace_one( + txt, srctxt, '$$(PYTHON_DIR-$1)/dist/lib/libpython$(PYTHON_VER)m.a: ' + + ('build/$2/Support/OpenSSL ' if ENABLE_OPENSSL else '') + + 'build/$2/Support/XZ $$(PYTHON_DIR-$1)/Makefile\n#' + srctxt) + srctxt = ('dist/Python-$(PYTHON_VER)-$1-support.' + 'b$(BUILD_NUMBER).tar.gz: ') + txt = efrotools.replace_one( + txt, srctxt, + 'dist/Python-$(PYTHON_VER)-$1-support.b$(BUILD_NUMBER).tar.gz:' + ' $$(PYTHON_FRAMEWORK-$1)\n#' + srctxt) + + # Turn doc strings on; looks like it only adds a few hundred k. + txt = txt.replace('--without-doc-strings', '--with-doc-strings') + + # We're currently aiming at 10.13+ on mac + # (see issue with utimensat and futimens). + txt = efrotools.replace_one(txt, 'MACOSX_DEPLOYMENT_TARGET=10.8', + 'MACOSX_DEPLOYMENT_TARGET=10.13') + # And equivalent iOS (11+). + txt = efrotools.replace_one(txt, 'CFLAGS-iOS=-mios-version-min=7.0', + 'CFLAGS-iOS=-mios-version-min=11.0') + # Ditto for tvOS. + txt = efrotools.replace_one(txt, 'CFLAGS-tvOS=-mtvos-version-min=9.0', + 'CFLAGS-tvOS=-mtvos-version-min=11.0') + + if debug: + + # Add debug build flag + # (Currently expect to find 2 instances of this). + dline = '--with-doc-strings --enable-ipv6 --without-ensurepip' + splitlen = len(txt.split(dline)) + if splitlen != 3: + raise Exception("unexpected configure lines") + txt = txt.replace(dline, '--with-pydebug ' + dline) + + # Debug has a different name. + # (Currently expect to replace 13 instances of this). + dline = 'python$(PYTHON_VER)m' + splitlen = len(txt.split(dline)) + if splitlen != 14: + raise Exception("unexpected configure lines") + txt = txt.replace(dline, 'python$(PYTHON_VER)dm') + + efrotools.writefile('Makefile', txt) + + # Ok; let 'er rip. + # (we run these in parallel so limit to 1 job a piece; + # otherwise they inherit the -j12 or whatever from the top level) + # (also this build seems to fail with multiple threads) + efrotools.run('make -j1 ' + { + 'mac': 'Python-macOS', + 'ios': 'Python-iOS', + 'tvos': 'Python-tvOS' + }[arch]) + print('python build complete! (apple/' + arch + ')') + + +def build_android(rootdir: str, arch: str, debug: bool = False) -> None: + """Run a build for android with the given architecture. + + (can be arm, arm64, x86, or x86_64) + """ + import subprocess + builddir = 'build/python_android_' + arch + ('_debug' if debug else '') + efrotools.run('rm -rf "' + builddir + '"') + efrotools.run('mkdir -p build') + efrotools.run('git clone ' + 'git@github.com:yan12125/python3-android.git "' + builddir + + '"') + os.chdir(builddir) + + # Commit from Dec 6th, 2018. Looks like right after this one the repo + # switched to ndk r19 beta 2 and now seems to require r19, so we can + # try switching back to master one r19 comes down the pipe. + # noinspection PyUnreachableCode + if False: # pylint: disable=using-constant-test + efrotools.run('git checkout eb587c52db349fecfc4666c6bf7e077352513035') + + # Commit from ~March 14 2019. Looks like right after this the project + # switched to compiling python as a shared library which would be a pretty + # big change. + # noinspection PyUnreachableCode + if False: # pylint: disable=using-constant-test + efrotools.run('git checkout b3024bf350fd5134542ee974a9a28921a687a8a0') + ftxt = efrotools.readfile('pybuild/env.py') + + # Set the packages we build. + ftxt = efrotools.replace_one( + ftxt, 'packages = (', "packages = ('zlib', 'sqlite', 'xz'," + + (" 'openssl'" if ENABLE_OPENSSL else "") + ")\n# packages = (") + + # Don't wanna bother with gpg signing stuff. + ftxt = efrotools.replace_one(ftxt, 'verify_source = True', + 'verify_source = False') + + # Sub in the min api level we're targeting. + ftxt = efrotools.replace_one(ftxt, 'android_api_level = 21', + 'android_api_level = 21') + ftxt = efrotools.replace_one(ftxt, "target_arch = 'arm'", + "target_arch = '" + arch + "'") + efrotools.writefile('pybuild/env.py', ftxt) + ftxt = efrotools.readfile('Makefile') + + # This needs to be python3 for us. + ftxt = efrotools.replace_one(ftxt, 'PYTHON?=python\n', 'PYTHON?=python3\n') + efrotools.writefile('Makefile', ftxt) + ftxt = efrotools.readfile('pybuild/packages/python.py') + + # We currently build as a static lib. + ftxt = efrotools.replace_one(ftxt, " '--enable-shared',\n", "") + ftxt = efrotools.replace_one( + ftxt, "super().__init__('https://github.com/python/cpython/')", + "super().__init__('https://github.com/python/cpython/', branch='3.7')") + + # Turn ipv6 on (curious why its turned off here?...) + ftxt = efrotools.replace_one(ftxt, "'--disable-ipv6',", "'--enable-ipv6',") + if debug: + ftxt = efrotools.replace_one(ftxt, "'./configure',", + "'./configure', '--with-pydebug',") + + # We don't use this stuff so lets strip it out to simplify. + ftxt = efrotools.replace_one(ftxt, "'--with-system-ffi',", "") + ftxt = efrotools.replace_one(ftxt, "'--with-system-expat',", "") + ftxt = efrotools.replace_one(ftxt, "'--without-ensurepip',", "") + + # This builds all modules as dynamic libs, but we want to be consistent + # with our other embedded builds and just static-build the ones we + # need... so to change that we'll need to add a hook for ourself after + # python is downloaded but before it is built so we can muck with it. + ftxt = efrotools.replace_one( + ftxt, ' def prepare(self):', + ' def prepare(self):\n import os\n' + ' if os.system(\'"' + rootdir + + '/tools/snippets" python_android_patch "' + os.getcwd() + + '"\') != 0: raise Exception("patch apply failed")') + + efrotools.writefile('pybuild/packages/python.py', ftxt) + + # Set this to a particular cpython commit to target exact releases from git + commit = 'e09359112e250268eca209355abeb17abf822486' # 3.7.4 release + if commit is not None: + ftxt = efrotools.readfile('pybuild/source.py') + + # Check out a particular commit right after the clone. + ftxt = efrotools.replace_one( + ftxt, + "'git', 'clone', '-b', self.branch, self.source_url, self.dest])", + "'git', 'clone', '-b', self.branch, self.source_url, self.dest])\n" + " run_in_dir(['git', 'checkout', '" + commit + + "'], self.source_dir)") + efrotools.writefile('pybuild/source.py', ftxt) + ftxt = efrotools.readfile('pybuild/util.py') + + # Still don't wanna bother with gpg signing stuff. + ftxt = efrotools.replace_one( + ftxt, 'def gpg_verify_file(sig_filename, filename, validpgpkeys):\n', + 'def gpg_verify_file(sig_filename, filename, validpgpkeys):\n' + ' print("gpg-verify disabled by ericf")\n' + ' return\n') + efrotools.writefile('pybuild/util.py', ftxt) + + # These builds require ANDROID_NDK to be set, so make sure that's + # the case. + os.environ['ANDROID_NDK'] = subprocess.check_output( + [rootdir + '/tools/android_sdk_utils', + 'get-ndk-path']).decode().strip() + + # Ok, let 'er rip + # (we often run these builds in parallel so limit to 1 job a piece; + # otherwise they each inherit the -j12 or whatever from the top level). + efrotools.run('make -j1') + print('python build complete! (android/' + arch + ')') + + +def android_patch() -> None: + """Run necessary patches on an android archive before building.""" + fname = 'src/cpython/Modules/Setup.dist' + txt = efrotools.readfile(fname) + + # Need to switch some flags on this one. + txt = efrotools.replace_one(txt, '#zlib zlibmodule.c', + 'zlib zlibmodule.c -lz\n#zlib zlibmodule.c') + # Just turn all these on. + for enable in [ + '#array arraymodule.c', '#cmath cmathmodule.c _math.c', + '#math mathmodule.c', '#_contextvars _contextvarsmodule.c', + '#_struct _struct.c', '#_weakref _weakref.c', + '#_testcapi _testcapimodule.c', '#_random _randommodule.c', + '#_elementtree -I', '#_pickle _pickle.c', + '#_datetime _datetimemodule.c', '#_bisect _bisectmodule.c', + '#_heapq _heapqmodule.c', '#_asyncio _asynciomodule.c', + '#unicodedata unicodedata.c', '#fcntl fcntlmodule.c', + '#select selectmodule.c', '#_csv _csv.c', + '#_socket socketmodule.c', '#_blake2 _blake2/blake2module.c', + '#binascii binascii.c', '#_posixsubprocess _posixsubprocess.c', + '#_sha3 _sha3/sha3module.c' + ]: + txt = efrotools.replace_one(txt, enable, enable[1:]) + if ENABLE_OPENSSL: + txt = efrotools.replace_one(txt, '#_ssl _ssl.c \\', + '_ssl _ssl.c -DUSE_SSL -lssl -lcrypto') + else: + # Note that the _md5 and _sha modules are normally only built if the + # system does not have the OpenSSL libs containing an optimized + # version. + for enable in [ + '#_md5 md5module.c', '#_sha1 sha1module.c', + '#_sha256 sha256module.c', '#_sha512 sha512module.c' + ]: + txt = efrotools.replace_one(txt, enable, enable[1:]) + + # Turn this off (its just an example module). + txt = efrotools.replace_one(txt, 'xxsubtype xxsubtype.c', + '#xxsubtype xxsubtype.c') + + # For whatever reason this stuff isn't in there at all; add it. + txt += '\n_json _json.c\n' + + txt += '\n_lzma _lzmamodule.c -llzma\n' + + txt += ('\n_sqlite3 -I$(srcdir)/Modules/_sqlite' + ' -DMODULE_NAME=\'\\"sqlite3\\"\' -DSQLITE_OMIT_LOAD_EXTENSION' + ' -lsqlite3 \\\n' + ' _sqlite/cache.c \\\n' + ' _sqlite/connection.c \\\n' + ' _sqlite/cursor.c \\\n' + ' _sqlite/microprotocols.c \\\n' + ' _sqlite/module.c \\\n' + ' _sqlite/prepare_protocol.c \\\n' + ' _sqlite/row.c \\\n' + ' _sqlite/statement.c \\\n' + ' _sqlite/util.c\n') + + if ENABLE_OPENSSL: + txt += '\n\n_hashlib _hashopenssl.c -DUSE_SSL -lssl -lcrypto\n' + + txt += '\n\n*disabled*\n_ctypes _crypt grp' + + efrotools.writefile(fname, txt) + + # Ok, this is weird. + # When applying the module Setup, python looks for any line containing *=* + # and interprets the whole thing a a global define?... + # This breaks things for our static sqlite compile above. + # The check used to look for [A-Z]*=* which didn't break, so let' just + # change it back to that for now. + fname = 'src/cpython/Modules/makesetup' + txt = efrotools.readfile(fname) + txt = efrotools.replace_one( + txt, ' *=*) DEFS="$line$NL$DEFS"; continue;;', + ' [A-Z]*=*) DEFS="$line$NL$DEFS"; continue;;') + efrotools.writefile(fname, txt) + + print("APPLIED EFROTOOLS ANDROID BUILD PATCHES.") + + +def gather() -> None: + """Gather per-platform python headers, libs, and modules together. + + This assumes all embeddable py builds have been run successfully, + and that PROJROOT is the cwd. + """ + # pylint: disable=too-many-locals + + # First off, clear out any existing output. + existing_dirs = [ + os.path.join('src/external', d) for d in os.listdir('src/external') + if d.startswith('python-') and d != 'python-notes.txt' + ] + existing_dirs += [ + os.path.join('assets/src', d) for d in os.listdir('assets/src') + if d.startswith('pylib-') + ] + for existing_dir in existing_dirs: + efrotools.run('rm -rf "' + existing_dir + '"') + + # Build our set of site-packages that we'll bundle in addition + # to the base system. + # FIXME: Should we perhaps make this part more explicit?.. + # we might get unexpected changes sneaking if we're just + # pulling from installed python. But then again, anytime we're doing + # a new python build/gather we should expect *some* changes even if + # only at the build-system level since we pull some of that directly + # from latest git stuff. + efrotools.run('mkdir -p "assets/src/pylib-site-packages"') + efrotools.run('cp "/usr/local/lib/python' + PYTHON_VERSION_MAJOR + + '/site-packages/typing_extensions.py"' + ' "assets/src/pylib-site-packages/"') + + for buildtype in ['debug', 'release']: + debug = buildtype == 'debug' + bsuffix = '_debug' if buildtype == 'debug' else '' + bsuffix2 = '-debug' if buildtype == 'debug' else '' + + libname = 'python' + PYTHON_VERSION_MAJOR + ('dm' if debug else 'm') + + bases = { + 'mac': + f'build/python_apple_mac{bsuffix}/build/macOS', + 'ios': + f'build/python_apple_ios{bsuffix}/build/iOS', + 'tvos': + f'build/python_apple_tvos{bsuffix}/build/tvOS', + 'android_arm': + f'build/python_android_arm{bsuffix}/build/sysroot', + 'android_arm64': + f'build/python_android_arm64{bsuffix}/build/sysroot', + 'android_x86': + f'build/python_android_x86{bsuffix}/build/sysroot', + 'android_x86_64': + f'build/python_android_x86_64{bsuffix}/build/sysroot' + } + + # Note: only need pylib for the first in each group. + builds: List[Dict[str, Any]] = [{ + 'name': + 'macos', + 'group': + 'apple', + 'headers': + bases['mac'] + '/Support/Python/Headers', + 'libs': [ + bases['mac'] + '/Support/Python/libPython.a', + bases['mac'] + '/Support/OpenSSL/libOpenSSL.a', + bases['mac'] + '/Support/XZ/libxz.a' + ], + 'pylib': + (bases['mac'] + '/python/lib/python' + PYTHON_VERSION_MAJOR), + }, { + 'name': + 'ios', + 'group': + 'apple', + 'headers': + bases['ios'] + '/Support/Python/Headers', + 'libs': [ + bases['ios'] + '/Support/Python/libPython.a', + bases['ios'] + '/Support/OpenSSL/libOpenSSL.a', + bases['ios'] + '/Support/XZ/libxz.a' + ], + }, { + 'name': + 'tvos', + 'group': + 'apple', + 'headers': + bases['tvos'] + '/Support/Python/Headers', + 'libs': [ + bases['tvos'] + '/Support/Python/libPython.a', + bases['tvos'] + '/Support/OpenSSL/libOpenSSL.a', + bases['tvos'] + '/Support/XZ/libxz.a' + ], + }, { + 'name': + 'android_arm', + 'group': + 'android', + 'headers': + bases['android_arm'] + f'/usr/include/{libname}', + 'libs': [ + bases['android_arm'] + f'/usr/lib/lib{libname}.a', + bases['android_arm'] + '/usr/lib/libssl.a', + bases['android_arm'] + '/usr/lib/libcrypto.a', + bases['android_arm'] + '/usr/lib/liblzma.a', + bases['android_arm'] + '/usr/lib/libsqlite3.a' + ], + 'libinst': + 'android_armeabi-v7a', + 'pylib': (bases['android_arm'] + '/usr/lib/python' + + PYTHON_VERSION_MAJOR), + }, { + 'name': 'android_arm64', + 'group': 'android', + 'headers': bases['android_arm64'] + f'/usr/include/{libname}', + 'libs': [ + bases['android_arm64'] + f'/usr/lib/lib{libname}.a', + bases['android_arm64'] + '/usr/lib/libssl.a', + bases['android_arm64'] + '/usr/lib/libcrypto.a', + bases['android_arm64'] + '/usr/lib/liblzma.a', + bases['android_arm64'] + '/usr/lib/libsqlite3.a' + ], + 'libinst': 'android_arm64-v8a', + }, { + 'name': 'android_x86', + 'group': 'android', + 'headers': bases['android_x86'] + f'/usr/include/{libname}', + 'libs': [ + bases['android_x86'] + f'/usr/lib/lib{libname}.a', + bases['android_x86'] + '/usr/lib/libssl.a', + bases['android_x86'] + '/usr/lib/libcrypto.a', + bases['android_x86'] + '/usr/lib/liblzma.a', + bases['android_x86'] + '/usr/lib/libsqlite3.a' + ], + 'libinst': 'android_x86', + }, { + 'name': 'android_x86_64', + 'group': 'android', + 'headers': bases['android_x86_64'] + f'/usr/include/{libname}', + 'libs': [ + bases['android_x86_64'] + f'/usr/lib/lib{libname}.a', + bases['android_x86_64'] + '/usr/lib/libssl.a', + bases['android_x86_64'] + '/usr/lib/libcrypto.a', + bases['android_x86_64'] + '/usr/lib/liblzma.a', + bases['android_x86_64'] + '/usr/lib/libsqlite3.a' + ], + 'libinst': 'android_x86_64', + }] + + for build in builds: + + grp = build['group'] + builddir = f'src/external/python-{grp}{bsuffix2}' + header_dst = os.path.join(builddir, 'include') + lib_dst = os.path.join(builddir, 'lib') + assets_src_dst = f'assets/src/pylib-{grp}' + + # Do some setup only once per group. + if not os.path.exists(builddir): + efrotools.run('mkdir -p "' + builddir + '"') + efrotools.run('mkdir -p "' + lib_dst + '"') + + # Only pull modules into game assets on release pass + if not debug: + # Copy system modules into the src assets + # dir for this group + efrotools.run('mkdir -p "' + assets_src_dst + '"') + efrotools.run( + 'rsync --recursive --include "*.py"' + ' --exclude __pycache__ --include "*/" --exclude "*" "' + + build['pylib'] + '/" "' + assets_src_dst + '"') + + # Prune a bunch of modules we don't need to cut + # down on size. + prune = [ + 'config-*', 'idlelib', 'lib-dynload', 'lib2to3', + 'multiprocessing', 'pydoc_data', 'site-packages', + 'ensurepip', 'tkinter', 'wsgiref', 'distutils', + 'turtle.py', 'turtledemo', 'test', 'sqlite3/test', + 'unittest', 'dbm', 'venv', 'ctypes/test', 'imaplib.py' + ] + efrotools.run('cd "' + assets_src_dst + '" && rm -rf ' + + ' '.join(prune)) + + # Copy in a base set of headers (everything in a group should + # be using the same headers) + efrotools.run(f'cp -r "{build["headers"]}" "{header_dst}"') + + # Clear whatever pyconfigs came across; we'll build our own + # universal one below. + efrotools.run('rm ' + header_dst + '/pyconfig*') + + # Write a master pyconfig header that reroutes to each + # platform's actual header. + with open(header_dst + '/pyconfig.h', 'w') as hfile: + hfile.write( + '#if BA_OSTYPE_MACOS\n' + '#include "pyconfig-macos.h"\n\n' + '#elif BA_OSTYPE_IOS\n' + '#include "pyconfig-ios.h"\n\n' + '#elif BA_OSTYPE_TVOS\n' + '#include "pyconfig-tvos.h"\n\n' + '#elif BA_OSTYPE_ANDROID and defined(__arm__)\n' + '#include "pyconfig-android_arm.h"\n\n' + '#elif BA_OSTYPE_ANDROID and defined(__aarch64__)\n' + '#include "pyconfig-android_arm64.h"\n\n' + '#elif BA_OSTYPE_ANDROID and defined(__i386__)\n' + '#include "pyconfig-android_x86.h"\n\n' + '#elif BA_OSTYPE_ANDROID and defined(__x86_64__)\n' + '#include "pyconfig-android_x86_64.h"\n\n' + '#else\n' + '#error unknown platform\n\n' + '#endif\n') + + # Now copy each build's config headers in with unique names. + cfgs = [ + f for f in os.listdir(build['headers']) + if f.startswith('pyconfig') + ] + + # Copy config headers to their filtered names. + for cfg in cfgs: + out = cfg.replace('pyconfig', 'pyconfig-' + build['name']) + if cfg == 'pyconfig.h': + + # For platform's root pyconfig.h we need to filter + # contents too (those headers can themselves include + # others; ios for instance points to a arm64 and a + # x86_64 variant). + contents = efrotools.readfile(build['headers'] + '/' + cfg) + contents = contents.replace('pyconfig', + 'pyconfig-' + build['name']) + efrotools.writefile(header_dst + '/' + out, contents) + else: + # other configs we just rename + efrotools.run('cp "' + build['headers'] + '/' + cfg + + '" "' + header_dst + '/' + out + '"') + + # Copy in libs. If the lib gave a specific install name, + # use that; otherwise use name. + targetdir = lib_dst + '/' + build.get('libinst', build['name']) + efrotools.run('rm -rf "' + targetdir + '"') + efrotools.run('mkdir -p "' + targetdir + '"') + for lib in build['libs']: + efrotools.run('cp "' + lib + '" "' + targetdir + '"') + + print('Great success!') diff --git a/tools/efrotools/pylintplugins.py b/tools/efrotools/pylintplugins.py new file mode 100644 index 00000000..87240494 --- /dev/null +++ b/tools/efrotools/pylintplugins.py @@ -0,0 +1,187 @@ +"""Plugins for pylint""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import astroid + +if TYPE_CHECKING: + from astroid import node_classes as nc + from typing import Set, Dict, Any + +VERBOSE = False + + +def register(linter: Any) -> None: + """Unused here; we're modifying the ast; not linters.""" + del linter # Unused. + + +failed_imports: Set[str] = set() + + +def failed_import_hook(modname: str) -> None: + """Custom failed import callback.""" + + # We don't actually do anything here except note in our log that + # something couldn't be imported. (may help sanity-check our filtering) + if VERBOSE: + if modname not in failed_imports: + failed_imports.add(modname) + print('GOT FAILED IMPORT OF', modname) + raise astroid.AstroidBuildingError(modname=modname) + + +def ignore_type_check_filter(node: nc.NodeNG) -> nc.NodeNG: + """Ignore stuff under 'if TYPE_CHECKING:' block at module level.""" + + # Look for a non-nested 'if TYPE_CHECKING:' + if (isinstance(node.test, astroid.Name) + and node.test.name == 'TYPE_CHECKING' + and isinstance(node.parent, astroid.Module)): + + # Find the module node. + mnode = node + while mnode.parent is not None: + mnode = mnode.parent + + # First off, remove any names that are getting defined + # in this block from the module locals. + for cnode in node.body: + _strip_import(cnode, mnode) + + # Now replace the body with a simple 'pass'. This will + # keep pylint from complaining about grouped imports/etc. + passnode = astroid.Pass(parent=node, + lineno=node.lineno + 1, + col_offset=node.col_offset + 1) + node.body = [passnode] + return node + + +def _strip_import(cnode: nc.NodeNG, mnode: nc.NodeNG) -> None: + if isinstance(cnode, (astroid.Import, astroid.ImportFrom)): + for name, val in list(mnode.locals.items()): + if cnode in val: + + # Pull us out of the list. + valnew = [v for v in val if v is not cnode] + if valnew: + mnode.locals[name] = valnew + else: + del mnode.locals[name] + + +def using_future_annotations(node: nc.NodeNG) -> nc.NodeNG: + """Return whether postponed annotation evaluation is enabled (PEP 563).""" + + # Find the module. + mnode = node + while mnode.parent is not None: + mnode = mnode.parent + + # Look for 'from __future__ import annotations' to decide + # if we should assume all annotations are defer-eval'ed. + # NOTE: this will become default at some point within a few years.. + annotations_set = mnode.locals.get('annotations') + if (annotations_set and isinstance(annotations_set[0], astroid.ImportFrom) + and annotations_set[0].modname == '__future__'): + return True + return False + + +def func_annotations_filter(node: nc.NodeNG) -> nc.NodeNG: + """Filter annotated function args/retvals. + + This accounts for deferred evaluation available in in Python 3.7+ + via 'from __future__ import annotations'. In this case we don't + want Pylint to complain about missing symbols in annotations when + they aren't actually needed at runtime. + """ + # Only do this if deferred annotations are on. + if not using_future_annotations(node): + return node + + # Wipe out argument annotations. + + # Special-case: functools.singledispatch and ba.dispatchmethod *do* + # evaluate annotations at runtime so we want to leave theirs intact. + # Lets just look for a @XXX.register decorator used by both I guess. + if node.decorators is not None: + for dnode in node.decorators.nodes: + if (isinstance(dnode, astroid.nodes.Name) + and dnode.name in ('dispatchmethod', 'singledispatch')): + return node # Leave annotations intact. + + if (isinstance(dnode, astroid.nodes.Attribute) + and dnode.attrname == 'register'): + return node # Leave annotations intact. + + node.args.annotations = [None for _ in node.args.args] + node.args.varargannotation = None + node.args.kwargannotation = None + node.args.kwonlyargs_annotations = [None for _ in node.args.kwonlyargs] + + # Wipe out return-value annotation. + if node.returns is not None: + node.returns = None + + return node + + +def var_annotations_filter(node: nc.NodeNG) -> nc.NodeNG: + """Filter annotated function variable assigns. + + This accounts for deferred evaluation. + """ + if using_future_annotations(node): + # Future behavior: + # Annotations are never evaluated. + willeval = False + else: + # Legacy behavior: + # Annotated assigns under functions are not evaluated, + # but class or module vars are. + fnode = node + willeval = True + while fnode is not None: + if isinstance(fnode, + (astroid.FunctionDef, astroid.AsyncFunctionDef)): + willeval = False + break + if isinstance(fnode, astroid.ClassDef): + willeval = True + break + fnode = fnode.parent + + # If this annotation won't be eval'ed, replace it with a dummy string. + if not willeval: + dummyval = astroid.Const(parent=node, value='dummyval') + node.annotation = dummyval + + return node + + +def register_plugins(manager: astroid.Manager) -> None: + """Apply our transforms to a given astroid manager object.""" + + if VERBOSE: + manager.register_failed_import_hook(failed_import_hook) + + # Completely ignore everything under an 'if TYPE_CHECKING' conditional. + # That stuff only gets run for mypy, and in general we want to + # check code as if it doesn't exist at all. + manager.register_transform(astroid.If, ignore_type_check_filter) + + # Annotations on variables within a function are defer-eval'ed + # in some cases, so lets replace them with simple strings in those + # cases to avoid type complaints. + # (mypy will still properly alert us to type errors for them) + manager.register_transform(astroid.AnnAssign, var_annotations_filter) + manager.register_transform(astroid.FunctionDef, func_annotations_filter) + manager.register_transform(astroid.AsyncFunctionDef, + func_annotations_filter) + + +register_plugins(astroid.MANAGER) diff --git a/tools/efrotools/snippets.py b/tools/efrotools/snippets.py new file mode 100644 index 00000000..f491eec0 --- /dev/null +++ b/tools/efrotools/snippets.py @@ -0,0 +1,379 @@ +"""Standard snippets that can be pulled into project snippets scripts. + +A snippet is a mini-program that directly takes input from stdin and does +some focused task. This module is a repository of common snippets that can +be imported into projects' snippets script for easy reuse. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Dict, Any, List + +# Absolute path of the project root. +PROJROOT = Path(__file__).resolve().parents[2] + +CLRHDR = '\033[95m' +CLRBLU = '\033[94m' +CLREND = '\033[0m' + + +def snippets_main(globs: Dict[str, Any]) -> None: + """Run a snippet contained in the snippets script. + + We simply look for all public functions and call + the one corresponding to the first passed arg. + """ + import types + funcs = dict(((name, obj) for name, obj in globs.items() + if not name.startswith('_') and name != 'snippets_main' + and isinstance(obj, types.FunctionType))) + show_help = False + retval = 0 + if len(sys.argv) < 2: + print("ERROR: command expected.") + show_help = True + retval = 255 + else: + if sys.argv[1] == 'help': + if len(sys.argv) == 2: + show_help = True + elif sys.argv[2] not in funcs: + print('Invalid help command.') + retval = 255 + else: + docs = _trim_docstring( + getattr(funcs[sys.argv[2]], '__doc__', '')) + print('\nsnippets ' + sys.argv[2] + ':\n' + docs + '\n') + elif sys.argv[1] in funcs: + funcs[sys.argv[1]]() + else: + print('Unknown snippets command: "' + sys.argv[1] + '"', + file=sys.stderr) + retval = 255 + + if show_help: + print("Snippets contains project related commands too small" + " to warrant full scripts.") + print("Run 'snippets help ' for full command documentation.") + print('Available commands:') + for func, obj in sorted(funcs.items()): + doc = getattr(obj, '__doc__', '').splitlines()[0].strip() + print(f'{CLRHDR}{func}{CLRBLU} - {doc}{CLREND}') + sys.exit(retval) + + +def _trim_docstring(docstring: str) -> str: + """Trim raw doc-strings for pretty printing. + + Taken straight from PEP 257. + """ + if not docstring: + return '' + + # Convert tabs to spaces (following the normal Python rules) + # and split into a list of lines. + lines = docstring.expandtabs().splitlines() + + # Determine minimum indentation (first line doesn't count). + indent = sys.maxsize + for line in lines[1:]: + stripped = line.lstrip() + if stripped: + indent = min(indent, len(line) - len(stripped)) + + # Remove indentation (first line is special). + trimmed = [lines[0].strip()] + if indent < sys.maxsize: + for line in lines[1:]: + trimmed.append(line[indent:].rstrip()) + + # Strip off trailing and leading blank lines. + while trimmed and not trimmed[-1]: + trimmed.pop() + while trimmed and not trimmed[0]: + trimmed.pop(0) + + # Return a single string. + return '\n'.join(trimmed) + + +def spelling() -> None: + """Add words to the PyCharm dictionary.""" + fname = '.idea/dictionaries/ericf.xml' + with open(fname) as infile: + lines = infile.read().splitlines() + if lines[2] != ' ': + raise RuntimeError('Unexpected dictionary format.') + words = sys.argv[2:] + for word in words: + lines.insert(3, f' {word.lower()}') + with open(fname, 'w') as outfile: + outfile.write('\n'.join(lines)) + print('Added', len(words), 'words to the dictionary.') + + +def check_clean_safety() -> None: + """Ensure all files are are added to git or in gitignore. + + Use to avoid losing work if we accidentally do a clean without + adding something. + """ + if len(sys.argv) != 2: + raise Exception('invalid arguments') + + # Make sure we wouldn't be deleting anything not tracked by git + # or ignored. + output = subprocess.check_output(['git', 'status', + '--porcelain=v2']).decode() + if any(line.startswith('?') for line in output.splitlines()): + print('ERROR: untracked file(s) found; aborting.' + ' (see "git status" from "' + os.getcwd() + + '") Either \'git add\' them, add them to .gitignore,' + ' or remove them and try again.', + file=sys.stderr) + sys.exit(255) + + +def formatcode() -> None: + """Run clang-format on all of our source code (multithreaded).""" + from efrotools import code + full = '-full' in sys.argv + code.formatcode(PROJROOT, full) + + +def formatscripts() -> None: + """Run yapf on all our scripts (multithreaded).""" + from efrotools import code + full = '-full' in sys.argv + code.formatscripts(PROJROOT, full) + + +def cpplintcode() -> None: + """Run lint-checking on all code deemed lint-able.""" + from efrotools import code + full = '-full' in sys.argv + code.cpplintcode(PROJROOT, full) + + +def scriptfiles() -> None: + """List project script files. + + Pass -lines to use newlines as separators. The default is spaces. + """ + from efrotools import code + paths = code.get_script_filenames(projroot=PROJROOT) + assert not any(' ' in path for path in paths) + if '-lines' in sys.argv: + print('\n'.join(paths)) + else: + print(' '.join(paths)) + + +def pylintscripts() -> None: + """Run pylint checks on our scripts.""" + from efrotools import code + full = ('-full' in sys.argv) + fast = ('-fast' in sys.argv) + code.pylintscripts(PROJROOT, full, fast) + + +def mypyscripts() -> None: + """Run mypy checks on our scripts.""" + from efrotools import code + full = ('-full' in sys.argv) + code.mypyscripts(PROJROOT, full) + + +def pycharmscripts() -> None: + """Run PyCharm checks on our scripts.""" + from efrotools import code + full = '-full' in sys.argv + verbose = '-v' in sys.argv + code.pycharmscripts(PROJROOT, full, verbose) + + +def clioncode() -> None: + """Run CLion checks on our code.""" + from efrotools import code + full = '-full' in sys.argv + verbose = '-v' in sys.argv + code.clioncode(PROJROOT, full, verbose) + + +def androidstudiocode() -> None: + """Run Android Studio checks on our code.""" + from efrotools import code + full = '-full' in sys.argv + verbose = '-v' in sys.argv + code.androidstudiocode(PROJROOT, full, verbose) + + +def tool_config_install() -> None: + """Install a tool config file (with some filtering).""" + from efrotools import get_config + import textwrap + if len(sys.argv) != 4: + raise Exception("expected 2 args") + src = Path(sys.argv[2]) + dst = Path(sys.argv[3]) + with src.open() as infile: + cfg = infile.read() + + # Do a bit of filtering. + + # Stick project-root wherever they want. + cfg = cfg.replace('__EFRO_PROJECT_ROOT__', str(PROJROOT)) + + stdsettings = textwrap.dedent(""" + # We don't want all of our plain scripts complaining + # about __main__ being redefined. + scripts_are_modules = True + + # Try to be as strict as we can about using types everywhere. + warn_unused_ignores = True + warn_return_any = True + warn_redundant_casts = True + disallow_incomplete_defs = True + disallow_untyped_defs = True + disallow_untyped_decorators = True + disallow_untyped_calls = True + disallow_any_unimported = True + strict_equality = True + """).strip() + + cfg = cfg.replace('__EFRO_MYPY_STANDARD_SETTINGS__', stdsettings) + + # Gen a pylint init to set up our python paths: + pylint_init_tag = '__EFRO_PYLINT_INIT__' + if pylint_init_tag in cfg: + pypaths = get_config(PROJROOT).get('python_paths') + if pypaths is None: + raise RuntimeError('python_paths not set in project config') + cstr = "init-hook='import sys;" + for path in pypaths: + cstr += f" sys.path.append('{PROJROOT}/{path}');" + cstr += "'" + cfg = cfg.replace(pylint_init_tag, cstr) + + # Add an auto-generated notice. + comment = None + if dst.name in ['.dir-locals.el']: + comment = ';;' + elif dst.name in [ + '.mypy.ini', '.pycheckers', '.pylintrc', '.style.yapf', + '.clang-format' + ]: + comment = '#' + if comment is not None: + cfg = (f'{comment} THIS FILE WAS AUTOGENERATED; DO NOT EDIT.\n' + f'{comment} Source: {src}.\n\n' + cfg) + + with dst.open('w') as outfile: + outfile.write(cfg) + + +def sync_all() -> None: + """Runs full syncs between all efrotools projects. + + This list is defined in the EFROTOOLS_SYNC_PROJECTS env var. + This assumes that there is a 'syncfull' and 'synclist' Makefile target + under each project. + """ + projects_str = os.environ.get('EFROTOOLS_SYNC_PROJECTS') + if projects_str is None: + print('EFROTOOL_SYNC_PROJECTS is not defined.') + sys.exit(255) + if len(sys.argv) > 2 and sys.argv[2] == 'list': + # List mode + for project in projects_str.split(':'): + cmd = f'cd "{project}" && make synclist' + print(cmd) + subprocess.run(cmd, shell=True, check=True) + + else: + # Real mode + for i in range(2): + if i == 0: + print(CLRBLU + "Running sync pass 1:" + " (ensures all changes at dsts are pushed to src)" + + CLREND) + else: + print(CLRBLU + "Running sync pass 2:" + " (ensures latest src is pulled to all dsts)" + CLREND) + for project in projects_str.split(':'): + cmd = f'cd "{project}" && make syncfull' + print(cmd) + subprocess.run(cmd, shell=True, check=True) + print(CLRBLU + 'Sync-all successful!' + CLREND) + + +def sync() -> None: + """Runs standard syncs between this project and others.""" + from efrotools import get_config + from efrotools.sync import Mode, SyncItem, run_standard_syncs + mode = Mode(sys.argv[2]) if len(sys.argv) > 2 else Mode.PULL + + # Load sync-items from project config and run them + sync_items = [ + SyncItem(**i) for i in get_config(PROJROOT).get('sync_items', []) + ] + run_standard_syncs(PROJROOT, mode, sync_items) + + +def makefile_target_list() -> None: + """Prints targets in a makefile. + + Takes a single argument: a path to a Makefile. + """ + from dataclasses import dataclass + + @dataclass + class _Entry: + kind: str + line: int + title: str + + if len(sys.argv) != 3: + raise RuntimeError("Expected exactly one filename arg.") + + with open(sys.argv[2]) as infile: + lines = infile.readlines() + + def _docstr(lines2: List[str], linenum: int) -> str: + doc = '' + j = linenum - 1 + while j >= 0 and lines2[j].startswith('#'): + doc = lines2[j][1:].strip() + j -= 1 + if doc != '': + return ' - ' + doc + return doc + + entries: List[_Entry] = [] + for i, line in enumerate(lines): + + # Targets. + if ':' in line and line.split(':')[0].replace('-', '').replace( + '_', '').isalnum() and not line.startswith('_'): + entries.append( + _Entry(kind='target', line=i, title=line.split(':')[0])) + + # Section titles. + if (line.startswith('# ') and line.endswith(' #\n') + and len(line.split()) > 2): + entries.append( + _Entry(kind='section', line=i, title=line[1:-2].strip())) + + for entry in entries: + if entry.kind == 'section': + print('\n' + entry.title + '\n' + '-' * len(entry.title)) + elif entry.kind == 'target': + print(CLRHDR + entry.title + CLRBLU + _docstr(lines, entry.line) + + CLREND) diff --git a/tools/efrotools/sync.py b/tools/efrotools/sync.py new file mode 100644 index 00000000..97015304 --- /dev/null +++ b/tools/efrotools/sync.py @@ -0,0 +1,295 @@ +"""Functionality for syncing specific directories between different projects. + +This can be preferable vs using shared git subrepos for certain use cases. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import List, Tuple, Optional, Sequence + +CLRHDR = '\033[95m' +CLRGRN = '\033[92m' +CLRBLU = '\033[94m' +CLRRED = '\033[91m' +CLREND = '\033[0m' + + +class Mode(Enum): + """Modes for sync operations.""" + PULL = 'pull' # Pull updates from theirs to ours; errors if ours changed. + FULL = 'full' # Like pull but also push changes back to src if possible. + LIST = 'list' # Simply list all sync operations that would occur. + FORCE = 'force' # Pull all from src without checking for dst changes. + CHECK = 'check' # Make no changes; errors if dst has changed since sync. + + +def _valid_filename(fname: str) -> bool: + """Is this a file we're ok with syncing? + + (we need to be able to append a comment without breaking it) + """ + if os.path.basename(fname) != fname: + raise ValueError(f'{fname} is not a simple filename.') + if fname in [ + 'requirements.txt', 'pylintrc', 'clang-format', 'pycheckers', + 'style.yapf', 'test_task_bin', '.editorconfig' + ]: + return True + return (any(fname.endswith(ext) for ext in ('.py', '.pyi')) + and 'flycheck_' not in fname) + + +@dataclass +class SyncItem: + """Defines a file or directory to be synced from another project.""" + src_project_id: str + src_path: str + dst_path: Optional[str] = None + + +def run_standard_syncs(projectroot: Path, mode: Mode, + syncitems: Sequence[SyncItem]) -> None: + """Run a standard set of syncs. + + Syncitems should be a list of tuples consisting of a src project name, + a src subpath, and optionally a dst subpath (src will be used by default). + """ + from efrotools import get_localconfig + localconfig = get_localconfig(projectroot) + for syncitem in syncitems: + assert isinstance(syncitem, SyncItem) + src_project = syncitem.src_project_id + src_subpath = syncitem.src_path + dst_subpath = (syncitem.dst_path + if syncitem.dst_path is not None else syncitem.src_path) + dstname = os.path.basename(dst_subpath) + if mode == Mode.CHECK: + print(f'Checking sync target {dstname}...') + count = check_path(Path(dst_subpath)) + print(f'Sync check passed for {count} items.') + else: + link_entry = f'linked_{src_project}' + + # Actual syncs require localconfig entries. + if link_entry not in localconfig: + print(f'No link entry for {src_project}; skipping sync entry.') + continue + src = Path(localconfig[link_entry], src_subpath) + print(f'Processing {dstname} in {mode.name} mode...') + count = sync_paths(src_project, src, Path(dst_subpath), mode) + if mode in [Mode.LIST, Mode.CHECK]: + print(f'Scanned {count} items.') + else: + print(f'Sync successful for {count} items.') + + +def sync_paths(src_proj: str, src: Path, dst: Path, mode: Mode) -> int: + """Sync src and dst paths.""" + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + if mode == Mode.CHECK: + raise ValueError('sync_paths cannot be called in CHECK mode') + if not (src.is_dir() or src.is_file()): + raise ValueError(f'src path is not a dir or file: {src}') + + changed_error_dst_files: List[Path] = [] + + # Build a list of all valid source files and their equivalent paths in dst. + allpaths: List[Tuple[Path, Path]] = [] + + if src.is_file(): + if not _valid_filename(src.name): + raise ValueError(f'provided sync-path {src} is not syncable') + allpaths.append((src, dst)) + else: + for root, _dirs, fnames in os.walk(src): + for fname in fnames: + if _valid_filename(fname): + srcpathfull = Path(root, fname) + relpath = srcpathfull.relative_to(src) + dstpathfull = Path(dst, relpath) + allpaths.append((srcpathfull, dstpathfull)) + for srcfile, dstfile in allpaths: + if not srcfile.is_file(): + raise RuntimeError(f'Invalid src file: {srcfile}.') + dstfile.parent.mkdir(parents=True, exist_ok=True) + with srcfile.open() as infile: + srcdata = infile.read() + src_hash = string_hash(srcdata) + + if not dstfile.is_file() or mode == Mode.FORCE: + if mode == Mode.LIST: + print(f'Would pull from {src_proj}:' + f' {CLRGRN}{dstfile}{CLREND}') + else: + print(f'Pulling from {src_proj}: {CLRGRN}{dstfile}{CLREND}') + + # No dst file; pull src across. + with dstfile.open('w') as outfile: + outfile.write(add_marker(src_proj, srcdata)) + continue + + marker_hash, dst_hash, dstdata = get_dst_file_info(dstfile) + + # Ok, we've now got hashes for src and dst as well as a 'last-known' + # hash. If only one of the two files differs from it we can + # do a directional sync. If they both differ then we're out of luck. + if src_hash != marker_hash and dst_hash == marker_hash: + if mode == Mode.LIST: + print(f'Would pull from {src_proj}:' + f' {CLRGRN}{dstfile}{CLREND}') + else: + print(f'Pulling from {src_proj}: {CLRGRN}{dstfile}{CLREND}') + + # Src has changed; simply pull across to dst. + with dstfile.open('w') as outfile: + outfile.write(add_marker(src_proj, srcdata)) + continue + if src_hash == marker_hash and dst_hash != marker_hash: + + # Dst has changed; we only copy backwards to src + # if we're in full mode. + if mode == Mode.LIST: + print(f'Would push to {src_proj}: {CLRBLU}{dstfile}{CLREND}') + elif mode == Mode.FULL: + print(f'Pushing to {src_proj}: {CLRBLU}{dstfile}{CLREND}') + with srcfile.open('w') as outfile: + outfile.write(dstdata) + + # We ALSO need to rewrite dst to update its embedded hash + with dstfile.open('w') as outfile: + outfile.write(add_marker(src_proj, dstdata)) + else: + # Just make note here; we'll error after forward-syncs run. + changed_error_dst_files.append(dstfile) + continue + + if marker_hash not in (src_hash, dst_hash): + + # One more option: source and dst could have been changed in + # identical ways (common when doing global search/replaces). + # In this case the calced hash from src and dst will match + # but the stored hash in dst won't. + if src_hash == dst_hash: + if mode == Mode.LIST: + print(f'Would update dst hash (both files changed' + f' identically) from {src_proj}:' + f' {CLRGRN}{dstfile}{CLREND}') + else: + print(f'Updating hash (both files changed)' + f' from {src_proj}: {CLRGRN}{dstfile}{CLREND}') + with dstfile.open('w') as outfile: + outfile.write(add_marker(src_proj, srcdata)) + continue + # Src/dst hashes don't match and marker doesn't match either. + # We give up. + raise RuntimeError( + f'both src and dst sync files changed: {srcfile} {dstfile}' + '; this must be resolved manually.') + + # (if we got here this file should be healthy..) + assert src_hash == marker_hash and dst_hash == marker_hash + + # Now, if dst is a dir, iterate through and kill anything not in src. + if dst.is_dir(): + killpaths: List[Path] = [] + for root, dirnames, fnames in os.walk(dst): + for name in dirnames + fnames: + if (name.startswith('.') or '__pycache__' in root + or '__pycache__' in name): + continue + dstpathfull = Path(root, name) + relpath = dstpathfull.relative_to(dst) + srcpathfull = Path(src, relpath) + if not os.path.exists(srcpathfull): + killpaths.append(dstpathfull) + + # This is sloppy in that we'll probably recursively kill dirs and then + # files under them, so make sure we look before we leap. + for killpath in killpaths: + if os.path.exists(killpath): + if mode == Mode.LIST: + print(f'Would remove orphaned sync path:' + f' {CLRRED}{killpath}{CLREND}') + else: + print(f'Removing orphaned sync path:' + f' {CLRRED}{killpath}{CLREND}') + os.system('rm -rf "' + str(killpath) + '"') + + # Lastly throw an error if we found any changed dst files and aren't + # allowed to reverse-sync them back. + if changed_error_dst_files: + raise RuntimeError(f'sync dst file(s) changed since last sync:' + f' {changed_error_dst_files}; run a FULL mode' + ' sync to push changes back to src') + + return len(allpaths) + + +def check_path(dst: Path) -> int: + """Verify files under dst have not changed from their last sync.""" + allpaths: List[Path] = [] + for root, _dirs, fnames in os.walk(dst): + for fname in fnames: + if _valid_filename(fname): + allpaths.append(Path(root, fname)) + for dstfile in allpaths: + marker_hash, dst_hash, _dstdata = get_dst_file_info(dstfile) + + # All we can really check here is that the current hash hasn't + # changed since the last sync. + if marker_hash != dst_hash: + raise RuntimeError( + f'sync dst file changed since last sync: {dstfile}') + return len(allpaths) + + +def add_marker(src_proj: str, srcdata: str) -> str: + """Given the contents of a file, adds a 'synced from' notice and hash.""" + + lines = srcdata.splitlines() + + # Make sure we're not operating on an already-synced file; that's just + # asking for trouble. + if len(lines) > 1 and 'EFRO_SYNC_HASH=' in lines[1]: + raise RuntimeError('Attempting to sync a file that is itself synced.') + + hashstr = string_hash(srcdata) + lines.insert(0, + f'# Synced from {src_proj}.\n# EFRO_SYNC_HASH={hashstr}\n#') + return '\n'.join(lines) + '\n' + + +def string_hash(data: str) -> str: + """Given a string, return a hash.""" + import hashlib + md5 = hashlib.md5() + md5.update(data.encode()) + + # Note: returning plain integers instead of hex so linters + # don't see words and give spelling errors. + return str(int.from_bytes(md5.digest(), byteorder='big')) + + +def get_dst_file_info(dstfile: Path) -> Tuple[str, str, str]: + """Given a path, returns embedded marker hash and its actual hash.""" + with dstfile.open() as infile: + dstdata = infile.read() + dstlines = dstdata.splitlines() + if not dstlines: + raise ValueError(f'no lines found in {dstfile}') + if 'EFRO_SYNC_HASH' not in dstlines[1]: + raise ValueError(f'no EFRO_SYNC_HASH found in {dstfile}') + marker_hash = dstlines[1].split('EFRO_SYNC_HASH=')[1] + + # Return data minus the hash line. + dstdata = '\n'.join(dstlines[3:]) + '\n' + dst_hash = string_hash(dstdata) + return marker_hash, dst_hash, dstdata diff --git a/tools/snippets b/tools/snippets new file mode 100755 index 00000000..6e5b5c67 --- /dev/null +++ b/tools/snippets @@ -0,0 +1,580 @@ +#!/usr/bin/env python3 +"""Wee little snippets of functionality specific to this project. + +All top level functions here can be run by passing them as the first +argument on the command line. (or pass no arguments to get a list of them). + +Functions can be placed here when they're not complex enough to warrant +their own files. Often these functions act as user-facing entry points +to functionality contained in efrotools or other standalone tool modules. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +import efrotools +# Pull in some standard snippets we want to expose. +# noinspection PyUnresolvedReferences +from efrotools.snippets import ( # pylint: disable=unused-import + PROJROOT, snippets_main, formatcode, formatscripts, cpplintcode, + pylintscripts, mypyscripts, tool_config_install, sync, sync_all, + scriptfiles, pycharmscripts, clioncode, androidstudiocode, + makefile_target_list, spelling) + +if TYPE_CHECKING: + from typing import Optional, List + +# Parts of full-tests suite we only run on particular days. +# (This runs in listed order so should be randomized by hand to avoid +# clustering similar tests too much) +SPARSE_TESTS: List[List[str]] = [ + ['ios.pylibs.debug', 'android.pylibs.arm'], + ['linux.package.64', 'android.pylibs.arm64'], + ['windows.package', 'mac.pylibs'], + ['tvos.pylibs', 'android.pylibs.x86'], + ['linux.package.server.64', 'android.pylibs.arm.debug'], + ['windows.package.server'], + ['ios.pylibs', 'android.pylibs.arm64.debug'], + ['linux.package.server.32'], + ['android.pylibs.x86.debug', 'mac.package'], + ['mac.package.server', 'android.pylibs.x86_64'], + ['windows.package.oculus'], + ['linux.package.32', 'android.pylibs.x86_64.debug'], + ['mac.pylibs.debug', 'android.package'], +] + +# Currently only doing sparse-tests in core; not spinoffs. +# (whole word will get subbed out in spinoffs so this will be false) +DO_SPARSE_TESTS = 'ballistica' + 'core' == 'ballisticacore' + + +def archive_old_builds() -> None: + """Stuff our old public builds into the 'old' dir. + + (called after we push newer ones) + """ + if len(sys.argv) < 3: + raise Exception('invalid arguments') + ssh_server = sys.argv[2] + builds_dir = sys.argv[3] + ssh_args = sys.argv[4:] + + def ssh_run(cmd: str) -> str: + val: str = subprocess.check_output(['ssh'] + ssh_args + + [ssh_server, cmd]).decode() + return val + + files = ssh_run('ls -1t "' + builds_dir + '"').splitlines() + + # For every file we find, gather all the ones with the same prefix; + # we'll want to archive all but the first one. + files_to_archive = set() + for fname in files: + if '_' not in fname: + continue + prefix = '_'.join(fname.split('_')[:-1]) + for old_file in [f for f in files if f.startswith(prefix)][1:]: + files_to_archive.add(old_file) + + # Would be faster to package this into a single command but + # this works. + for fname in sorted(files_to_archive): + print('Archiving ' + fname, file=sys.stderr) + ssh_run('mv "' + builds_dir + '/' + fname + '" "' + builds_dir + + '/old/"') + + +def gen_fulltest_buildfile_android() -> None: + """Generate fulltest command list for jenkins. + + (so we see nice pretty split-up build trees) + """ + # pylint: disable=too-many-branches + import datetime + + # Its a pretty big time-suck building all architectures for + # all of our subplatforms, so lets usually just build a single one. + # We'll rotate it though and occasionally do all 4 at once just to + # be safe. + dayoffset = datetime.datetime.now().timetuple().tm_yday + + # Let's only do a full 'prod' once every two times through the loop. + # (it really should never catch anything that individual platforms don't) + modes = ['arm', 'arm64', 'x86', 'x86_64'] + modes += modes + modes.append('prod') + + lines = [] + for i, flavor in enumerate( + sorted(os.listdir('ballisticacore-android/BallisticaCore/src'))): + if flavor == 'main' or flavor.startswith('.'): + continue + mode = modes[(dayoffset + i) % len(modes)] + lines.append('ANDROID_PLATFORM=' + flavor + ' ANDROID_MODE=' + mode + + ' nice -n 15 make android-build') + + # Now add sparse tests that land on today. + if DO_SPARSE_TESTS: + extras = SPARSE_TESTS[dayoffset % len(SPARSE_TESTS)] + extras = [e for e in extras if e.startswith('android.')] + for extra in extras: + if extra == 'android.pylibs.arm': + lines.append('tools/snippets python_build_android arm') + elif extra == 'android.pylibs.arm.debug': + lines.append('tools/snippets python_build_android_debug arm') + elif extra == 'android.pylibs.arm64': + lines.append('tools/snippets python_build_android arm64') + elif extra == 'android.pylibs.arm64.debug': + lines.append('tools/snippets python_build_android_debug arm64') + elif extra == 'android.pylibs.x86': + lines.append('tools/snippets python_build_android x86') + elif extra == 'android.pylibs.x86.debug': + lines.append('tools/snippets python_build_android_debug x86') + elif extra == 'android.pylibs.x86_64': + lines.append('tools/snippets python_build_android x86_64') + elif extra == 'android.pylibs.x86_64.debug': + lines.append( + 'tools/snippets python_build_android_debug x86_64') + elif extra == 'android.package': + lines.append('make android-package') + else: + raise RuntimeError(f'Unknown extra: {extra}') + + with open('_fulltest_buildfile_android', 'w') as outfile: + outfile.write('\n'.join(lines)) + + +def gen_fulltest_buildfile_windows() -> None: + """Generate fulltest command list for jenkins. + + (so we see nice pretty split-up build trees) + """ + import datetime + + dayoffset = datetime.datetime.now().timetuple().tm_yday + + lines: List[str] = [] + + # We want to do one regular, one headless, and one oculus build, + # but let's switch up 32 or 64 bit based on the day. + # Also occasionally throw a release build in but stick to + # mostly debug builds to keep build times speedier. + pval1 = 'Win32' if dayoffset % 2 == 0 else 'x64' + pval2 = 'Win32' if (dayoffset + 1) % 2 == 0 else 'x64' + pval3 = 'Win32' if (dayoffset + 2) % 2 == 0 else 'x64' + cfg1 = 'Release' if dayoffset % 7 == 0 else 'Debug' + cfg2 = 'Release' if (dayoffset + 1) % 7 == 0 else 'Debug' + cfg3 = 'Release' if (dayoffset + 2) % 7 == 0 else 'Debug' + + lines.append(f'WINDOWS_PROJECT= WINDOWS_PLATFORM={pval1} ' + f'WINDOWS_CONFIGURATION={cfg1} make windows-build') + lines.append(f'WINDOWS_PROJECT=Headless WINDOWS_PLATFORM={pval2} ' + f'WINDOWS_CONFIGURATION={cfg2} make windows-build') + lines.append(f'WINDOWS_PROJECT=Oculus WINDOWS_PLATFORM={pval3} ' + f'WINDOWS_CONFIGURATION={cfg3} make windows-build') + + # Now add sparse tests that land on today. + if DO_SPARSE_TESTS: + extras = SPARSE_TESTS[dayoffset % len(SPARSE_TESTS)] + extras = [e for e in extras if e.startswith('windows.')] + for extra in extras: + if extra == 'windows.package': + lines.append('make windows-package') + elif extra == 'windows.package.server': + lines.append('make windows-server-package') + elif extra == 'windows.package.oculus': + lines.append('make windows-oculus-package') + else: + raise RuntimeError(f'Unknown extra: {extra}') + + with open('_fulltest_buildfile_windows', 'w') as outfile: + outfile.write('\n'.join(lines)) + + +def gen_fulltest_buildfile_apple() -> None: + """Generate fulltest command list for jenkins. + + (so we see nice pretty split-up build trees) + """ + # pylint: disable=too-many-branches + import datetime + + dayoffset = datetime.datetime.now().timetuple().tm_yday + + # noinspection PyListCreation + lines = [] + + # iOS stuff + lines.append('nice -n 18 make ios-build') + lines.append('nice -n 18 make ios-new-build') + if DO_SPARSE_TESTS: + extras = SPARSE_TESTS[dayoffset % len(SPARSE_TESTS)] + extras = [e for e in extras if e.startswith('ios.')] + for extra in extras: + if extra == 'ios.pylibs': + lines.append('tools/snippets python_build_apple ios') + elif extra == 'ios.pylibs.debug': + lines.append('tools/snippets python_build_apple_debug ios') + else: + raise RuntimeError(f'Unknown extra: {extra}') + + # tvOS stuff + lines.append('nice -n 18 make tvos-build') + if DO_SPARSE_TESTS: + extras = SPARSE_TESTS[dayoffset % len(SPARSE_TESTS)] + extras = [e for e in extras if e.startswith('tvos.')] + for extra in extras: + if extra == 'tvos.pylibs': + lines.append('tools/snippets python_build_apple tvos') + elif extra == 'tvos.pylibs.debug': + lines.append('tools/snippets python_build_apple_debug tvos') + else: + raise RuntimeError(f'Unknown extra: {extra}') + + # macOS stuff + lines.append('nice -n 18 make mac-build') + # (throw release build in the mix to hopefully catch opt-mode-only errors). + lines.append('nice -n 18 make mac-appstore-release-build') + lines.append('nice -n 18 make mac-new-build') + lines.append('nice -n 18 make mac-server-build') + lines.append('nice -n 18 make cmake-build') + if DO_SPARSE_TESTS: + extras = SPARSE_TESTS[dayoffset % len(SPARSE_TESTS)] + extras = [e for e in extras if e.startswith('mac.')] + for extra in extras: + if extra == 'mac.package': + lines.append('make mac-package') + elif extra == 'mac.package.server': + lines.append('make mac-server-package') + elif extra == 'mac.pylibs': + lines.append('tools/snippets python_build_apple mac') + elif extra == 'mac.pylibs.debug': + lines.append('tools/snippets python_build_apple_debug mac') + else: + raise RuntimeError(f'Unknown extra: {extra}') + + with open('_fulltest_buildfile_apple', 'w') as outfile: + outfile.write('\n'.join(lines)) + + +def gen_fulltest_buildfile_linux() -> None: + """Generate fulltest command list for jenkins. + + (so we see nice pretty split-up build trees) + """ + import datetime + + # Its a bit of a waste of time doing both 32 and 64 bit builds + # for everything, so let's do one of each and alternate the architecture. + dayoffset = datetime.datetime.now().timetuple().tm_yday + + targets = ['build', 'server-build'] + lin32flav = 'LINUX32_FLAVOR=linux32-u16s' + lin64flav = 'LINUX64_FLAVOR=linux64-u18s' + lines = [] + for i, target in enumerate(targets): + if (i + dayoffset) % 2 == 0: + lines.append(f'{lin32flav} make linux32-{target}') + else: + lines.append(f'{lin64flav} make linux64-{target}') + + if DO_SPARSE_TESTS: + extras = SPARSE_TESTS[dayoffset % len(SPARSE_TESTS)] + extras = [e for e in extras if e.startswith('linux.')] + for extra in extras: + if extra == 'linux.package.32': + lines.append(f'{lin32flav} make linux32-package') + elif extra == 'linux.package.server.32': + lines.append(f'{lin32flav} make linux32-server-package') + elif extra == 'linux.package.64': + lines.append(f'{lin64flav} make linux64-package') + elif extra == 'linux.package.server.64': + lines.append(f'{lin64flav} make linux64-server-package') + else: + raise RuntimeError(f'Unknown extra: {extra}') + + with open('_fulltest_buildfile_linux', 'w') as outfile: + outfile.write('\n'.join(lines)) + + +def resize_image() -> None: + """Resize an image and saves it to a new location. + + args: xres, yres, src, dst + """ + if len(sys.argv) != 6: + raise Exception("expected 5 args") + width = int(sys.argv[2]) + height = int(sys.argv[3]) + src = sys.argv[4] + dst = sys.argv[5] + if not dst.endswith('.png'): + raise Exception("dst must be a png") + if not src.endswith('.png'): + raise Exception("src must be a png") + print('Creating: ' + os.path.basename(dst), file=sys.stderr) + + # Switching to imagemagick from sips - hopefully this goes well + # so we'll be nice and cross-platform... + efrotools.run('convert "' + src + '" -resize ' + str(width) + 'x' + + str(height) + ' "' + dst + '"') + + +def check_clean_safety() -> None: + """Ensure all files are are added to git or in gitignore. + + Use to avoid losing work if we accidentally do a clean without + adding something. + """ + from efrotools.snippets import check_clean_safety as std_snippet + + # First do standard checks. + std_snippet() + + # Then also make sure there are no untracked changes to core files + # (since we may be blowing core away here). + status = os.system( + os.path.join(str(PROJROOT), 'tools', 'spinoff') + ' cleancheck') + if status != 0: + sys.exit(255) + + +def get_master_asset_src_dir() -> None: + """Print master-asset-source dir for this repo.""" + + # Ok, for now lets simply use our hard-coded master-src + # path if we're on master in and not otherwise. Should + # probably make this configurable. + output = subprocess.check_output( + ['git', 'status', '--branch', '--porcelain']).decode() + + # Also compare repo name to split version of itself to + # see if we're outside of core (filtering will cause mismatch if so). + if ('origin/master' in output.splitlines()[0] + and 'ballistica' + 'core' == 'ballisticacore'): + + # We seem to be in master in core repo.. lets do it. + print('/Users/ericf/Dropbox/ballisticacore_master_assets') + else: + # Still need to supply dummy path for makefile if not.. + print('/__DUMMY_MASTER_SRC_DISABLED_PATH__') + + +def androidaddr() -> None: + """Return the source file location for an android program-counter. + + command line args: archive_dir architecture addr + """ + if len(sys.argv) != 5: + print('ERROR: expected 4 args; got ' + str(len(sys.argv) - 1) + '.\n' + + 'Usage: "tools/snippets android_addr' + ' "') + sys.exit(255) + archive_dir = sys.argv[2] + if not os.path.isdir(archive_dir): + print('ERROR: invalid archive dir: "' + archive_dir + '"') + sys.exit(255) + arch = sys.argv[3] + archs = { + 'x86': {'prefix': 'x86-', 'libmain': 'libmain_x86.so'}, + 'arm': {'prefix': 'arm-', 'libmain': 'libmain_arm.so'}, + 'arm64': {'prefix': 'aarch64-', 'libmain': 'libmain_arm64.so'}, + 'x86-64': {'prefix': 'x86_64-', 'libmain': 'libmain_x86-64.so'} + } # yapf: disable + if arch not in archs: + print('ERROR: invalid arch "' + arch + '"; (choices are ' + + ', '.join(archs.keys()) + ')') + sys.exit(255) + addr = sys.argv[4] + sdkutils = os.path.abspath( + os.path.join(os.path.dirname(sys.argv[0]), 'android_sdk_utils')) + rootdir = os.path.abspath(os.path.join(os.path.dirname(sys.argv[0]), '..')) + ndkpath = subprocess.check_output([sdkutils, 'get-ndk-path' + ]).decode('utf-8').strip() + if not os.path.isdir(ndkpath): + print("ERROR: ndk-path '" + ndkpath + '" does not exist') + sys.exit(255) + lines = subprocess.check_output( + ['find', + os.path.join(ndkpath, 'toolchains'), '-name', + '*addr2line']).decode('utf-8').strip().splitlines() + lines = [line for line in lines if archs[arch]['prefix'] in line] + if len(lines) != 1: + print("ERROR: couldn't find addr2line binary") + sys.exit(255) + addr2line = lines[0] + efrotools.run('mkdir -p "' + os.path.join(rootdir, 'android_addr_tmp') + + '"') + try: + efrotools.run('cd "' + os.path.join(rootdir, 'android_addr_tmp') + + '" && tar -xf "' + + os.path.join(archive_dir, 'unstripped_libs', + archs[arch]['libmain'] + '.tgz') + '"') + efrotools.run( + addr2line + ' -e "' + + os.path.join(rootdir, 'android_addr_tmp', archs[arch]['libmain']) + + '" ' + addr) + finally: + os.system('rm -rf "' + os.path.join(rootdir, 'android_addr_tmp') + '"') + + +def python_build_apple() -> None: + """Build an embeddable python for mac/ios/tvos.""" + _python_build_apple(debug=False) + + +def python_build_apple_debug() -> None: + """Build embeddable python for mac/ios/tvos (dbg ver).""" + _python_build_apple(debug=True) + + +def _python_build_apple(debug: bool) -> None: + """Build an embeddable python for macOS/iOS/tvOS.""" + from efrotools import pybuild + os.chdir(PROJROOT) + archs = ('mac', 'ios', 'tvos') + if len(sys.argv) != 3: + print("ERROR: expected one arg: " + ', '.join(archs)) + sys.exit(255) + arch = sys.argv[2] + if arch not in archs: + print('ERROR: invalid arch. valid values are: ' + ', '.join(archs)) + sys.exit(255) + pybuild.build_apple(arch, debug=debug) + + +def python_build_android() -> None: + """Build an embeddable Python lib for Android.""" + _python_build_android(debug=False) + + +def python_build_android_debug() -> None: + """Build embeddable Android Python lib (debug ver).""" + _python_build_android(debug=True) + + +def _python_build_android(debug: bool) -> None: + from efrotools import pybuild + os.chdir(PROJROOT) + archs = ('arm', 'arm64', 'x86', 'x86_64') + if len(sys.argv) != 3: + print("ERROR: expected one arg: " + ', '.join(archs)) + sys.exit(255) + arch = sys.argv[2] + if arch not in archs: + print('ERROR: invalid arch. valid values are: ' + ', '.join(archs)) + sys.exit(255) + pybuild.build_android(str(PROJROOT), arch, debug=debug) + + +def python_android_patch() -> None: + """Patches Python to prep for building for Android.""" + from efrotools import pybuild + os.chdir(sys.argv[2]) + pybuild.android_patch() + + +def python_gather() -> None: + """Gather build python components into the project. + + This assumes all embeddable py builds have been run successfully. + """ + from efrotools import pybuild + os.chdir(PROJROOT) + pybuild.gather() + + +def clean_orphaned_assets() -> None: + """Remove assets that are no longer part of the build.""" + import json + + # Operate from dist root.. + os.chdir(PROJROOT) + with open('assets/manifest.json') as infile: + manifest = set(json.loads(infile.read())) + for root, _dirs, fnames in os.walk('assets/build'): + for fname in fnames: + fpath = os.path.join(root, fname) + fpathrel = fpath[13:] # paths are relative to assets/build + if fpathrel not in manifest: + print(f"Removing orphaned asset: {fpath}") + os.unlink(fpath) + + # Lastly, clear empty dirs. + efrotools.run('find assets/build -depth -empty -type d -delete') + + +def py_examine() -> None: + """Run a python examination at a given point in a given file.""" + if len(sys.argv) != 7: + print('ERROR: expected 7 args') + sys.exit(255) + filename = Path(sys.argv[2]) + line = int(sys.argv[3]) + column = int(sys.argv[4]) + selection: Optional[str] = (None if sys.argv[5] == '' else sys.argv[5]) + operation = sys.argv[6] + + # This stuff assumes it is being run from project root. + os.chdir(PROJROOT) + + # Set up pypaths so our main distro stuff works. + scriptsdir = os.path.abspath( + os.path.join(os.path.dirname(sys.argv[0]), + '../assets/src/data/scripts')) + toolsdir = os.path.abspath( + os.path.join(os.path.dirname(sys.argv[0]), '../tools')) + if scriptsdir not in sys.path: + sys.path.append(scriptsdir) + if toolsdir not in sys.path: + sys.path.append(toolsdir) + efrotools.py_examine(filename, line, column, selection, operation) + + +def push_ipa() -> None: + """Construct and push ios IPA for testing.""" + from efrotools import ios + root = Path(sys.argv[0], '../..').resolve() + if len(sys.argv) != 3: + raise Exception('expected 1 arg (debug or release)') + modename = sys.argv[2] + ios.push_ipa(root, modename) + + +def check_mac_ssh() -> None: + """Make sure ssh password access is turned off. + + (This totally doesn't belong here, but I use it it to remind myself to + fix mac ssh after system updates which blow away ssh customizations). + """ + with open('/etc/ssh/sshd_config') as infile: + lines = infile.read().splitlines() + if ('UsePAM yes' in lines or '#PasswordAuthentication yes' in lines + or '#ChallengeResponseAuthentication yes' in lines): + print('ERROR: ssh config is allowing password access\n' + 'To fix: sudo emacs -nw /etc/ssh/sshd_config\n' + '"#PasswordAuthentication yes" -> "PasswordAuthentication no"\n' + '"#ChallengeResponseAuthentication yes" -> ' + '"ChallengeResponseAuthentication no"\n' + '"UsePam yes" -> "UsePam no"\n') + sys.exit(255) + print('password ssh auth seems disabled; hooray!') + + +def megalint() -> None: + """Run really long jetbrains lints.""" + print('would do megalint') + + +def capitalize() -> None: + """Print args capitalized.""" + print(' '.join(w.capitalize() for w in sys.argv[2:])) + + +if __name__ == '__main__': + snippets_main(globals())