diff --git a/.github/workflows/ci-apply.yml b/.github/workflows/ci-apply.yml new file mode 100644 index 0000000..046e97c --- /dev/null +++ b/.github/workflows/ci-apply.yml @@ -0,0 +1,294 @@ +name: PR Apply + +# Trusted counterpart to ci-check.yml (PR Check). Runs with this repo's +# write-scoped GITHUB_TOKEN via `workflow_run`, AFTER PR Check has finished +# safely (no secrets, fork content fully executed there instead of here). +# +# TWO HARD RULES, both load-bearing. Breaking either one reintroduces the +# pwn-request hole this split exists to close: +# +# 1. NEVER execute, import, or `unittest discover` anything from the fork's +# checkout. This job only applies a text patch (`git apply`), commits, +# pushes, and runs auto_apply_version_metadata.py from a SEPARATE checkout +# of this repo's own main branch - never the fork's copy of it. +# +# 2. NEVER interpolate a `${{ }}` expression into a `run:` block unless the +# value is fixed and trusted. `${{ }}` is substituted textually BEFORE the +# shell parses the script, so surrounding quotes do NOT contain it - a PR +# branch named `a";id;"` becomes live shell. Pass values via `env:` and +# reference them as "$VAR", which the shell treats as data. +# +# 3. NEVER identify the target PR by commit sha alone. A sha is a value, not an +# identity: forks share object storage, so anyone can push ANOTHER PR's head +# commit onto a branch of their own, open a PR at it, and then close that PR +# mid-run so the commit -> PR lookup below resolves to the victim's PR - at +# which point this job would push the attacker's artifact to the victim's +# branch. The resolved PR must be pinned to workflow_run.head_repository AND +# head_branch AND head_sha, so a run can only ever write to its own branch. +# +# On trust: a fork PR fully controls ci-check.yml itself (GitHub runs the +# workflow file from the PR's own merge ref for `pull_request` events - that +# is why that job gets a read-only, secret-less token). So EVERYTHING in the +# pr-fixups artifact is attacker-authored. PR identity is therefore resolved +# from the workflow_run payload + the API, never from the artifact; the +# artifact supplies only the patch, which is allowlist-validated below and +# only ever lands on the fork's own branch. + +on: + workflow_run: + workflows: ["PR Check"] + types: [completed] + +permissions: + contents: write + pull-requests: write + actions: read # required to download an artifact from another workflow run + +concurrency: + group: pr-apply-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +jobs: + apply: + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + # Resolves which PR this run belongs to using ONLY trusted inputs: the + # workflow_run payload (set by GitHub, not forgeable by the PR author) + # and the REST API. workflow_run.pull_requests is empty for fork PRs, + # hence the commit -> PR association lookup. That lookup ANSWERS with a + # PR but does not PROVE it is this run's PR - a sha can be adopted by any + # fork even though it cannot be forged - so the result is pinned to the + # payload's head repo/branch/sha below. See HARD RULE 3 in the header. + - name: Resolve and validate PR (trusted sources only) + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + RUN_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} + RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + set -euo pipefail + + skip() { echo "$1"; echo "proceed=0" >> "$GITHUB_OUTPUT"; exit 0; } + + [[ "$RUN_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "Unexpected workflow_run head sha shape; refusing." >&2 + exit 1 + } + + gh api "repos/${REPO}/commits/${RUN_HEAD_SHA}/pulls" > pulls.json + [ "$(jq 'length' pulls.json)" = "1" ] \ + || skip "Not exactly one PR associated with ${RUN_HEAD_SHA}; skipping." + + PR_NUMBER="$(jq -r '.[0].number' pulls.json)" + [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || skip "Bad PR number; skipping." + + gh pr view "$PR_NUMBER" --repo "$REPO" \ + --json state,baseRefName,headRefName,headRefOid,maintainerCanModify,headRepository,headRepositoryOwner \ + > pr.json + + STATE="$(jq -r .state pr.json)" + BASE_REF="$(jq -r .baseRefName pr.json)" + HEAD_REF="$(jq -r .headRefName pr.json)" + HEAD_SHA="$(jq -r .headRefOid pr.json)" + CAN_MODIFY="$(jq -r .maintainerCanModify pr.json)" + FORK="$(jq -r '.headRepositoryOwner.login + "/" + .headRepository.name' pr.json)" + + # Branch/repo names are attacker-chosen strings, and git happily + # accepts refnames containing ` $( ) ; | ' " - so anything outside + # this conservative set is refused rather than carried forward. + # Bash =~ anchors to the whole string, so embedded newlines (which + # would otherwise inject extra $GITHUB_OUTPUT keys) are rejected too. + [[ "$HEAD_REF" =~ ^[A-Za-z0-9._/-]{1,255}$ ]] || skip "Unsafe branch name; skipping." + [[ "$FORK" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || skip "Unsafe repo name; skipping." + [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || skip "Unsafe head sha; skipping." + + [ "$STATE" = "OPEN" ] || skip "PR not open; skipping." + [ "$BASE_REF" = "main" ] || skip "PR not targeting main; skipping." + + # HARD RULE 3: pin the resolved PR to the run that produced the + # artifact. The sha check alone is not enough - a sha is adoptable by + # any fork, so on its own it would let a run push to somebody else's + # PR branch. These are compared only against FORK/HEAD_REF, which the + # regexes above already validated, so no shape check is needed here: + # a null head_repository yields "" and simply fails to match, which is + # the fail-closed direction. Do not "simplify" these away. + [ "$FORK" = "$RUN_HEAD_REPO" ] \ + || skip "Resolved PR head repo != the run's head repo; skipping." + [ "$HEAD_REF" = "$RUN_HEAD_BRANCH" ] \ + || skip "Resolved PR head branch != the run's head branch; skipping." + [ "$HEAD_SHA" = "$RUN_HEAD_SHA" ] \ + || skip "PR head moved since PR Check ran; skipping." + + # maintainerCanModify only has meaning for cross-fork PRs; GitHub + # reports false for a PR opened from a branch in this same repo, + # where we can always push because we own the branch. + if [ "$FORK" != "$REPO" ] && [ "$CAN_MODIFY" != "true" ]; then + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "needs_comment=1" >> "$GITHUB_OUTPUT" + skip "Maintainer edits disabled; cannot push." + fi + + { + echo "pr_number=$PR_NUMBER" + echo "head_ref=$HEAD_REF" + echo "head_sha=$HEAD_SHA" + echo "fork=$FORK" + echo "proceed=1" + } >> "$GITHUB_OUTPUT" + + - name: Comment if maintainer edits are disabled (actionable, not self-resolving) + if: steps.pr.outputs.needs_comment == '1' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + run: | + set -euo pipefail + gh pr comment "$PR_NUMBER" --repo "$REPO" \ + --body "I can't push the automatic formatting/metadata commits to this PR because \"Allow edits from maintainers\" is disabled. Please enable it, or apply \`autopep8\` and \`test/auto_apply_*_metadata.py\` locally." + + - name: Download PR fixups artifact (untrusted data) + if: steps.pr.outputs.proceed == '1' + uses: actions/download-artifact@v8 + with: + name: pr-fixups + path: pr-data + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + + # Checking out fork PR content is what actions/checkout >=v6 refuses by + # default; the opt-in is reviewed and intentional here because nothing + # below ever EXECUTES this checkout - only git plumbing touches it. + # persist-credentials:false keeps the write token out of the untrusted + # working tree's .git/config; the push below authenticates explicitly. + - name: Checkout PR branch (fork) - allow-unsafe-pr-checkout is reviewed & intentional + if: steps.pr.outputs.proceed == '1' + uses: actions/checkout@v7 + with: + repository: ${{ steps.pr.outputs.fork }} + ref: ${{ steps.pr.outputs.head_sha }} + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + persist-credentials: false + allow-unsafe-pr-checkout: true + path: pr + + - name: Checkout TRUSTED scripts from our own main branch + if: steps.pr.outputs.proceed == '1' + uses: actions/checkout@v7 + with: + ref: main + sparse-checkout: | + test/auto_apply_version_metadata.py + sparse-checkout-cone-mode: false + persist-credentials: false + path: trusted + + - name: Re-verify checked-out head matches the resolved head + if: steps.pr.outputs.proceed == '1' + working-directory: pr + env: + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$HEAD_SHA" + + - name: Validate patch (allowlisted paths only, no symlinks/binaries) + if: steps.pr.outputs.proceed == '1' + working-directory: pr + run: | + set -euo pipefail + PATCH="${GITHUB_WORKSPACE}/pr-data/fixups.patch" + if [ ! -s "$PATCH" ]; then + echo "Empty patch, nothing to validate" + exit 0 + fi + if grep -qE '^(deleted file mode 120000|new mode 120000|new file mode 120000|Binary files)' "$PATCH"; then + echo "Patch contains symlinks or binary content - refusing" >&2 + exit 1 + fi + # Deliberately excludes .github/** and test/** - a "[ci]"-authored + # commit touching CI config or the test suite is exactly what a + # reviewer would wave through, so those fail closed and are left to + # the contributor to format locally. + ALLOW='^(plugins/(minigames|utilities|maps)/[^/]+\.py|plugin_manager\.py|index\.json|plugins/(minigames|utilities|maps)\.json|CHANGELOG\.md)$' + # Redirect from a file rather than piping into `while`: a pipeline + # would run the loop in a subshell, where `exit 1` would not reliably + # fail the step. + git apply --numstat "$PATCH" | cut -f3 > "${RUNNER_TEMP}/patch_paths.txt" + while IFS= read -r f; do + [ -n "$f" ] || continue + if [[ ! "$f" =~ $ALLOW ]]; then + echo "Patch touches disallowed path: $f" >&2 + exit 1 + fi + done < "${RUNNER_TEMP}/patch_paths.txt" + git apply --check "$PATCH" + + - name: Apply fixups patch and commit + if: steps.pr.outputs.proceed == '1' + working-directory: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + FORK: ${{ steps.pr.outputs.fork }} + run: | + set -euo pipefail + PATCH="${GITHUB_WORKSPACE}/pr-data/fixups.patch" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if [ ! -s "$PATCH" ]; then + echo "Empty patch, nothing to apply" + exit 0 + fi + git apply "$PATCH" + # Stage exactly the paths the validation step allow-listed, so the + # commit scope can never exceed the scope that was validated. A + # broader pathspec (e.g. all of plugins/) would silently widen the + # blast radius if the allowlist ever regressed. + PATHS="${RUNNER_TEMP}/patch_paths.txt" + test -s "$PATHS" || { + echo "Validated path list missing or empty - refusing to stage" >&2 + exit 1 + } + git add -A --pathspec-from-file="$PATHS" + if ! git diff --cached --quiet; then + git commit -m "[ci] apply-plugin-metadata-and-formatting" + git push "https://x-access-token:${GH_TOKEN}@github.com/${FORK}.git" "HEAD:${HEAD_REF}" + fi + + - name: Apply Version Metadata using the TRUSTED script only (never the fork's copy) + if: steps.pr.outputs.proceed == '1' + working-directory: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + FORK: ${{ steps.pr.outputs.fork }} + run: | + set -euo pipefail + python "${GITHUB_WORKSPACE}/trusted/test/auto_apply_version_metadata.py" "$(git rev-parse HEAD)" + # That script only ever writes index.json and the category manifests + # (it opens every .py read-only), so stage precisely those rather + # than the whole plugins/ tree. + git add -A -- index.json \ + plugins/minigames.json plugins/utilities.json plugins/maps.json + if ! git diff --cached --quiet; then + git commit -m "[ci] apply-version-metadata" + git push "https://x-access-token:${GH_TOKEN}@github.com/${FORK}.git" "HEAD:${HEAD_REF}" + fi + + - name: On mechanical failure, notify the contributor + if: failure() && steps.pr.outputs.pr_number != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + run: | + set -euo pipefail + gh pr comment "$PR_NUMBER" --repo "$REPO" \ + --body "Automatic formatting/metadata could not be applied to this PR (patch touched a disallowed path, or a conflict occurred). A maintainer will need to look at this manually." diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml new file mode 100644 index 0000000..ea4b8d8 --- /dev/null +++ b/.github/workflows/ci-check.yml @@ -0,0 +1,119 @@ +name: PR Check + +# Runs entirely inside GitHub's hardcoded read-only, no-secrets sandbox for +# `pull_request` events from forks. Fully executes fork-supplied code +# (autopep8, metadata scripts, unittest discover) - that's safe here ONLY +# because this token can't push anywhere and has no secrets. +# +# This workflow NEVER pushes anywhere. It only uploads a plain-text patch as +# a build artifact for ci-apply.yml (a separate, trusted workflow) to apply. +# +# Note that a fork PR can modify THIS FILE and have its version run (GitHub +# uses the workflow from the PR's own merge ref for `pull_request`). So +# nothing produced here is trustworthy, and ci-apply.yml is written on that +# assumption: it resolves PR identity from the workflow_run payload and the +# API, and treats the uploaded patch as untrusted input to be validated. +# +# DO NOT switch this to `pull_request_target` and do not add `permissions: +# contents: write` here "to save a round trip" - that reintroduces the +# pwn-request hole this split exists to close. + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: pr-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + # Credentials are deliberately persisted here (unlike ci-apply.yml): the + # `git fetch` below needs them on a private repo, and the workflow-level + # `permissions: contents: read` caps this token to read-only in every + # case - fork PRs get a read-only token from GitHub regardless. + - name: Checkout PR head + uses: actions/checkout@v7 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/head + fetch-depth: 0 + + - name: Fetch PR base commit (for diffing) + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: git fetch --no-tags --depth=1 origin "$BASE_SHA" + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Install Dependencies + run: | + python -m pip install -U pip + python -m pip install -U pycodestyle==2.12.1 autopep8 + python -m pip install -U -r test/pip_reqs.txt + + # Filenames are attacker-controlled, so they are kept in files and + # passed as shell variables - never interpolated into a script or into + # $GITHUB_OUTPUT (where a file named after the heredoc delimiter, or one + # containing a newline, could inject extra output keys). + - name: Compute changed files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + git diff --name-only "$BASE_SHA" HEAD > "${RUNNER_TEMP}/changed_files.txt" + git diff --name-only -z "$BASE_SHA" HEAD -- '*.py' > "${RUNNER_TEMP}/changed_py.z" + cat "${RUNNER_TEMP}/changed_files.txt" + + - name: Apply AutoPEP8 (changed .py files only) + run: | + set -euo pipefail + if [ -s "${RUNNER_TEMP}/changed_py.z" ]; then + xargs -0 -r autopep8 --in-place --max-line-length=100 \ + < "${RUNNER_TEMP}/changed_py.z" + fi + + # PLUGMAN_BASE_REF tells the script which tree counts as "already + # published". Without it the script compares against the PR's own + # manifest, which ci-apply.yml has already written the new version into - + # so every re-run of this workflow (including the one ci-apply.yml's push + # triggers) would fail with "Version cant be lower or equal". + - name: Apply Plugin Metadata (writes null version placeholders) + env: + PLUGMAN_BASE_REF: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + python test/auto_apply_plugin_metadata.py "$(cat "${RUNNER_TEMP}/changed_files.txt")" + + # IMPORTANT: capture the patch BEFORE the version-metadata preview step + # below. commit_sha values computed there are fictitious (no real + # commit exists yet) and must never be part of what ci-apply.yml applies. + - name: Snapshot fixups patch + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/pr-data" + git diff > "${RUNNER_TEMP}/pr-data/fixups.patch" + wc -l "${RUNNER_TEMP}/pr-data/fixups.patch" + + - name: Apply Version Metadata (local preview only - not uploaded) + run: | + python test/auto_apply_version_metadata.py "$(git rev-parse HEAD)" + + - name: "Execute Tests (lenient: new-entry history checks deferred to ci-apply/push-to-main)" + env: + PLUGMAN_CI_LENIENT_HISTORY: "1" + run: | + python -m unittest discover -v + + - name: Upload patch for ci-apply.yml + uses: actions/upload-artifact@v7 + with: + name: pr-fixups + path: ${{ runner.temp }}/pr-data + retention-days: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15fe835..a5dc8d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,34 +1,31 @@ name: CI -# WORD OF CAUTION: -# TO anyone modifying this -# Things will break if you modify this -# without understanding how it works - -# A simple flow of this file: -# Apply AutoPEP8 → Apply Plugin Metadata → CRITICAL COMMIT (format + plugin meta) -# ← ← ← ← ← ↵ -# ↪ Apply Version Metadata → Commit (version meta) → Tests +# Runs only on pushes to main - i.e. after a PR has been merged, or on a +# direct maintainer push. This is fully trusted, same-repo content, so it's +# safe for it to execute the tree and push directly. This is also the +# AUTHORITATIVE integrity check: test/test_checks.py's test_versions runs +# here unmodified/strict against real, permanent git history (unlike +# ci-check.yml, which can't yet resolve a commit sha for a brand-new plugin +# version and runs leniently instead). on: push: branches: - main - pull_request_target: + +permissions: + contents: write jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: - token: ${{ secrets.GITHUB_TOKEN }} - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.head_ref }} fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.12" @@ -42,21 +39,10 @@ jobs: run: | autopep8 --in-place --recursive --max-line-length=100 . - - name: Apply Plugin Metadata - if: github.event_name == 'pull_request_target' - env: - GH_TOKEN: ${{ github.token }} - run: | - CHANGED_FILES=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --jq '.[].filename') - python test/auto_apply_plugin_metadata.py "$CHANGED_FILES" - - # This is a CRITICAL COMMIT for the next step - # which bases this as the commit to get the sha to store in index.json or plugin.json - - name: Commit Plugin Metadata and AutoPEP8 + - name: Commit AutoPEP8 formatting uses: stefanzweifel/git-auto-commit-action@v7 with: - commit_message: "[ci] apply-plugin-metadata-and-formatting" - branch: ${{ github.head_ref }} + commit_message: "[ci] apply-formatting" - name: Apply Version Metadata run: | @@ -66,7 +52,6 @@ jobs: uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: "[ci] apply-version-metadata" - branch: ${{ github.head_ref }} - name: Execute Tests run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00eba47..6d7c429 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,14 +12,14 @@ jobs: name: Create Release runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.12' - name: 'Get Previous tag' - uses: oprypin/find-latest-tag@v1.1.2 + uses: oprypin/find-latest-tag@v1.1.3 with: repository: ${{ github.repository }} releases-only: true # We know that all relevant tags have a GitHub release for them. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3603c84..f1e16ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ ## Plugin Manager (dd-mm-yyyy) +### 1.1.13 (23-08-2026) + +- Changelog window hard-wraps its bullets across several source lines + +### 1.1.12 (23-08-2026) + +- Fixed request timeouts being reported as an unhandled error instead of + a connection problem, which left the plugin manager stuck on a spinner +- Response bodies are now read on the network threadpool rather than on + the main thread, so a slow server no longer freezes the game +- Index and changelog setup no longer deadlock after a failed fetch +- Fixed the plugin manager failing to load or refresh on networks where + one of GitHub's edge servers is unreachable; all of the published + addresses are now tried instead of a single one +- The DNS block workaround now corrects name resolution rather than + replacing the HTTP and TLS stack, so ordinary requests take the + standard code path +- Fixed the changelog window erroring out after the settings window had + been opened + +### 1.1.11 (09-08-2026) + +- Switched to babase.app.asyncio_loop and babase.app.threadpool + for concurrent execution +- A new threadpool to avoid blocking the babase.app.threadpool + which is for short parallel tasks +- Added a shutdown task to shutdown our threadpool + +### 1.1.10 (12-06-2026) + +- Fix for older bs versions using `EXPORT_CLASS_NAME_SHORTCUTS` import + +### 1.1.9 (11-06-2026) + +- Fix for bs 1.7.63 + +### 1.1.8 (23-02-2026) + +- Fix for bs 1.7.61 build no 22714 + ### 1.1.7 (15-02-2026) - Added function to fill form details for plugin bug report button diff --git a/LICENSE b/LICENSE index c1cc192..46ade36 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Members of bombsquad-community organization, and contributors +Copyright (c) 2022-2026 Members of bombsquad-community organization, and contributors All source code in this repository is licensed under MIT, unless otherwise explicitly stated in source code. diff --git a/README.md b/README.md index 963ab1b..b78af51 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,13 @@ which makes further modding of your game more convenient by providing easier acc ## Installation -There are two different ways the plugin manager can be installed: +There are three different ways the plugin manager can be installed: 1. From dev console - Enable "Show Dev Console Button" from advance BombSquad settings + - Make sure you're connected to the internet - Paste the following code in dev console ```py import urllib.request;import _babase;import os;url="https://github.com/bombsquad-community/plugin-manager/releases/latest/download/plugin_manager.py";plugin_path=os.path.join(_babase.env()["python_directory_user"],"plugin_manager.py");file=urllib.request.urlretrieve(url)[0];fl = open(file,'r');f=open(plugin_path, 'w+');f.write(fl.read());fl.close();f.close();print("SUCCESS") @@ -57,7 +58,7 @@ There are two different ways the plugin manager can be installed: manually apply updates by copying the latest plugin manager's source code again to your workspace when using this method. 3. [Download plugin_manager.py][DownloadLink] to your mods directory (check it out by going into your game's - Settings -> Advanced -> Show Mods Folder). This is the recommended way (read next method to know why). + Settings -> Advanced -> Show Mods Folder). If you're on a newer version of Android (11 or above) and not rooted, it probably won't be possible to copy mods to game's mods folder. In this case, you can connect your Android phone to a computer and push `plugin_manager.py` [using `adb`](https://www.xda-developers.com/install-adb-windows-macos-linux/): diff --git a/index.json b/index.json index 3ee912d..d879458 100644 --- a/index.json +++ b/index.json @@ -1,6 +1,42 @@ { "plugin_manager_url": "https://github.com/bombsquad-community/plugin-manager/{content_type}/{tag}/plugin_manager.py", "versions": { + "1.1.13": { + "api_version": 9, + "commit_sha": "0bd5647", + "released_on": "23-08-2026", + "md5sum": "6f4f6e7003180491c35d6cfd5ce2f713" + }, + "1.1.12": { + "api_version": 9, + "commit_sha": "b03ab3a", + "released_on": "23-08-2026", + "md5sum": "ae9724579bcf54c29d36b6c65cbccb06" + }, + "1.1.11": { + "api_version": 9, + "commit_sha": "a74246d", + "released_on": "09-08-2026", + "md5sum": "1dbf57b11602a22196711bdd3ad18fdc" + }, + "1.1.10": { + "api_version": 9, + "commit_sha": "a1baa5f", + "released_on": "12-06-2026", + "md5sum": "ee4f7e3e88c2982bc8290e204b10b050" + }, + "1.1.9": { + "api_version": 9, + "commit_sha": "3adfe38", + "released_on": "11-06-2026", + "md5sum": "d7e990ea4e5c530f780e617c9bdafb42" + }, + "1.1.8": { + "api_version": 9, + "commit_sha": "8222081", + "released_on": "23-02-2026", + "md5sum": "8e8e9fc5b818883102c6081619404318" + }, "1.1.7": { "api_version": 9, "commit_sha": "af881af", diff --git a/plugin_manager.py b/plugin_manager.py index 7e5b8a2..53f46aa 100644 --- a/plugin_manager.py +++ b/plugin_manager.py @@ -2,23 +2,24 @@ import babase import bauiv1 as bui from bauiv1lib import popup, confirm -from babase._meta import EXPORT_CLASS_NAME_SHORTCUTS from bauiv1lib.settings.allsettings import AllSettingsWindow +import urllib.error import urllib.request import http.client import socket import json -import ssl import re import os -import sys import copy import asyncio import pathlib import hashlib +import weakref +import threading import contextlib +import concurrent.futures from typing import override from datetime import datetime @@ -26,7 +27,7 @@ from datetime import datetime # Modules used for overriding AllSettingsWindow import logging -PLUGIN_MANAGER_VERSION = "1.1.7" +PLUGIN_MANAGER_VERSION = "1.1.13" REPOSITORY_URL = "https://github.com/bombsquad-community/plugin-manager" # Current tag can be changed to "staging" or any other branch in # plugin manager repo for testing purpose. @@ -43,7 +44,40 @@ HEADERS = { "User-Agent": _env["legacy_user_agent_string"], } PLUGIN_DIRECTORY = _env["python_directory_user"] -loop = babase._asyncio._asyncio_event_loop + +NETWORK_REQUEST_TIMEOUT = 10 # seconds + +loop = babase.app.asyncio_loop +pool = babase.app.threadpool + +# babase.app.threadpool (aka `pool`, also wired up as the asyncio loop's +# default executor) is reserved for short parallel work and warns/starves +# on long-running tasks. Network requests routinely run past that, so they +# get their own small dedicated pool instead. +_network_pool = concurrent.futures.ThreadPoolExecutor( + max_workers=4, + thread_name_prefix="PluginManagerNetwork", +) + + +async def _shutdown_network_pool() -> None: + """Drain _network_pool's workers so none outlive app shutdown. + + ThreadPoolExecutor.shutdown(wait=True) blocks, so it's run on a + throwaway thread and awaited from here rather than blocking the + event loop (other shutdown tasks run concurrently with this one). + In-flight requests are bounded by NETWORK_REQUEST_TIMEOUT, so this + settles quickly. + """ + done = asyncio.Event() + + def _join() -> None: + _network_pool.shutdown(wait=True, cancel_futures=True) + loop.call_soon_threadsafe(done.set) + + threading.Thread(target=_join, daemon=True).start() + await done.wait() + open_popups = [] @@ -72,12 +106,48 @@ def _by_scale(a, b, c): ) +def _wrap_markdown_bullets(source_lines, max_width, scale): + """Reflow markdown bullets so every rendered line fits `max_width`. + + CHANGELOG.md hard-wraps its bullets across several source lines, so the + lines are first stitched back into whole bullets and then re-wrapped to + the width we actually have. Drawing the source lines as-is left each one + a different length, and since a text widget shrinks itself to its own + maxwidth, long lines rendered visibly smaller than short ones. + """ + bullets = [] + for line in source_lines: + stripped = line.strip() + if not stripped: + continue + if stripped.startswith(("-", "*")) or not bullets: + bullets.append(stripped.lstrip("-*").strip()) + else: + # A continuation of the bullet above it. + bullets[-1] += " " + stripped + + lines = [] + for bullet in bullets: + prefix, current = "- ", "" + for word in bullet.split(): + candidate = f"{current} {word}" if current else word + width = bui.get_string_width(prefix + candidate, suppress_warning=True) + if current and width * scale > max_width: + lines.append(prefix + current) + # Indent wrapped remainders under the bullet's text. + prefix, current = " ", word + else: + current = candidate + lines.append(prefix + current) + return lines + + REGEXP = { "plugin_api_version": re.compile(b"(?<=ba_meta require api )(.*)"), "plugin_entry_points": re.compile( bytes( "(ba_meta export (plugin|{})\n+class )(.*)\\(".format( - _regexp_friendly_class_name_shortcut(EXPORT_CLASS_NAME_SHORTCUTS["plugin"]), + _regexp_friendly_class_name_shortcut("babase.Plugin"), ), "utf-8" ), @@ -121,26 +191,57 @@ class CategoryMetadataParseError(Exception): pass +@contextlib.contextmanager +def network_errors_as_urlerror(): + """Normalize network failures into urllib.error.URLError. + + Every caller in here reports connectivity problems by catching + URLError, but urllib only guarantees that shape while it is building + the request. Once urlopen() has returned, a socket timeout surfaces + as a bare TimeoutError and a truncated/dropped response as an + http.client.HTTPException, both of which sail past those handlers. + Since NETWORK_REQUEST_TIMEOUT made timeouts reachable at all, wrap + the whole fetch so callers only ever have one exception to catch. + """ + try: + yield + except urllib.error.URLError: + # Includes HTTPError; already the shape callers expect. + raise + except (TimeoutError, http.client.HTTPException) as e: + raise urllib.error.URLError(e) from e + + def send_network_request(request): - return urllib.request.urlopen(request) + """Fetch `request` and return its full body as bytes. + + The body is read here rather than handed back unread, because reading + it is itself a blocking call that can time out, and callers await this + from the event loop thread, where doing so would both stall the game + and raise outside network_errors_as_urlerror()'s reach. + """ + with network_errors_as_urlerror(): + with urllib.request.urlopen(request, timeout=NETWORK_REQUEST_TIMEOUT) as response: + return response.read() async def async_send_network_request(request): - response = await loop.run_in_executor(None, send_network_request, request) - return response + content = await loop.run_in_executor(_network_pool, send_network_request, request) + return content def stream_network_response_to_file(request, file, md5sum=None, retries=3): - response = urllib.request.urlopen(request) chunk_size = 16 * 1024 content = b"" - with open(file, "wb") as fout: - while True: - chunk = response.read(chunk_size) - if not chunk: - break - fout.write(chunk) - content += chunk + with network_errors_as_urlerror(): + with urllib.request.urlopen(request, timeout=NETWORK_REQUEST_TIMEOUT) as response: + with open(file, "wb") as fout: + while True: + chunk = response.read(chunk_size) + if not chunk: + break + fout.write(chunk) + content += chunk if md5sum and hashlib.md5(content).hexdigest() != md5sum: if retries <= 0: raise MD5CheckSumFailed("MD5 checksum match failed.") @@ -156,7 +257,7 @@ def stream_network_response_to_file(request, file, md5sum=None, retries=3): async def async_stream_network_response_to_file(request, file, md5sum=None, retries=3): content = await loop.run_in_executor( - None, + _network_pool, stream_network_response_to_file, request, file, @@ -187,50 +288,108 @@ class DNSBlockWorkaround: Usage: ----- >>> import urllib.request - >>> import http.client >>> import socket - >>> import ssl >>> import json >>> DNSBlockWorkaround.apply() >>> response = urllib.request.urlopen("https://dnsblockeddomain.com/path/to/resource/") """ - _google_dns_cache = {} + # Hostnames worth second-guessing the system resolver on. Keeping this + # explicit is what lets apply() patch a process-global function safely: + # every other name the game looks up takes a set membership test and is + # then handed straight to the original resolver. + _blockable_hosts = frozenset(("raw.githubusercontent.com",)) - def apply(): - opener = urllib.request.build_opener( - DNSBlockWorkaround._HTTPHandler, - DNSBlockWorkaround._HTTPSHandler, + # Maps a hostname to the addresses to dial for it, or to None when the + # host resolves normally and needs no workaround at all. Only populated + # for hosts we've already checked, so the check happens once per session. + _resolution_cache = {} + + _original_getaddrinfo = None + + @classmethod + def apply(cls): + """Correct socket.getaddrinfo() instead of rebuilding the HTTP stack. + + The block only ever corrupts one thing, the answer the resolver + hands back, so that is the only thing worth replacing. Fixing it + here leaves urllib, http.client and ssl completely stock: + socket.create_connection() still does its own address walking, + IPv6 handling and error aggregation, and TLS still verifies + against the hostname rather than whatever address we dialed. + """ + if cls._original_getaddrinfo is not None: + # Already applied. Patching again would nest the wrapper. + return + cls._original_getaddrinfo = staticmethod(socket.getaddrinfo) + socket.getaddrinfo = cls._getaddrinfo + + @classmethod + def _getaddrinfo(cls, host, port, family=0, type=0, proto=0, flags=0): + # Signature and argument names mirror socket.getaddrinfo(), which + # callers pass both positionally and by keyword. + if host in cls._blockable_hosts: + addresses = cls._addresses_to_dial(host) + if addresses is not None: + # Re-resolving each literal address is just parsing; it + # builds the 5-tuples callers expect without a lookup. + return [ + addrinfo + for address in addresses + for addrinfo in cls._original_getaddrinfo( + address, port, family, type, proto, flags) + ] + return cls._original_getaddrinfo(host, port, family, type, proto, flags) + + @classmethod + def _resolve_using_google_dns(cls, hostname): + response = urllib.request.urlopen( + f"https://dns.google/resolve?name={hostname}", + timeout=NETWORK_REQUEST_TIMEOUT, ) - urllib.request.install_opener(opener) - - def _resolve_using_google_dns(hostname): - response = urllib.request.urlopen(f"https://dns.google/resolve?name={hostname}") response = response.read() response = json.loads(response) - resolved_host = response["Answer"][0]["data"] - return resolved_host + # Answers can include CNAME records (type 5) alongside the A (1) and + # AAAA (28) records; only the latter are dialable. + return [answer["data"] for answer in response.get("Answer", ()) + if answer.get("type") in (1, 28)] - def _resolve_using_system_dns(hostname): + @classmethod + def _resolve_using_system_dns(cls, hostname): resolved_host = socket.gethostbyname(hostname) return resolved_host - def _resolve_with_workaround(hostname): - resolved_host_from_cache = DNSBlockWorkaround._google_dns_cache.get(hostname) - if resolved_host_from_cache: - return resolved_host_from_cache + @classmethod + def _addresses_to_dial(cls, hostname): + """Addresses to substitute for `hostname`, or None to leave it alone. - resolved_host_by_system_dns = DNSBlockWorkaround._resolve_using_system_dns(hostname) + Returning None is the common case and means the system resolver's + answer stands. Note that both branches yield *every* usable address + rather than one: socket.create_connection() walks the list until + something answers, and raw.githubusercontent.com publishes four A + records whose edge nodes are not all reachable from every network, + so collapsing to a single address would fail a share of requests + outright. + """ + if hostname in cls._resolution_cache: + return cls._resolution_cache[hostname] - if DNSBlockWorkaround._is_blocked(hostname, resolved_host_by_system_dns): - resolved_host = DNSBlockWorkaround._resolve_using_google_dns(hostname) - DNSBlockWorkaround._google_dns_cache[hostname] = resolved_host + try: + resolved_host_by_system_dns = cls._resolve_using_system_dns(hostname) + except socket.gaierror: + # A block that answers with NXDOMAIN rather than a bogus address. + addresses = cls._resolve_using_google_dns(hostname) or None else: - resolved_host = resolved_host_by_system_dns + if cls._is_blocked(hostname, resolved_host_by_system_dns): + addresses = cls._resolve_using_google_dns(hostname) or None + else: + addresses = None - return resolved_host + cls._resolution_cache[hostname] = addresses + return addresses - def _is_blocked(hostname, address): + @classmethod + def _is_blocked(cls, hostname, address): is_blocked = False if hostname == "raw.githubusercontent.com": # Jio's DNS server may be blocking it. @@ -238,36 +397,6 @@ class DNSBlockWorkaround: return is_blocked - class _HTTPConnection(http.client.HTTPConnection): - def connect(self): - host = DNSBlockWorkaround._resolve_with_workaround(self.host) - self.sock = socket.create_connection( - (host, self.port), - self.timeout, - ) - - class _HTTPSConnection(http.client.HTTPSConnection): - def connect(self): - host = DNSBlockWorkaround._resolve_with_workaround(self.host) - sock = socket.create_connection( - (host, self.port), - self.timeout, - ) - context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - context.verify_mode = ssl.CERT_REQUIRED - context.check_hostname = True - context.load_default_certs() - sock = context.wrap_socket(sock, server_hostname=self.host) - self.sock = sock - - class _HTTPHandler(urllib.request.HTTPHandler): - def http_open(self, req): - return self.do_open(DNSBlockWorkaround._HTTPConnection, req) - - class _HTTPSHandler(urllib.request.HTTPSHandler): - def https_open(self, req): - return self.do_open(DNSBlockWorkaround._HTTPSConnection, req) - class StartupTasks: def __init__(self): @@ -414,8 +543,8 @@ class Category: self.meta_url.format(content_type="raw", tag=self.tag), headers=self.request_headers, ) - response = await async_send_network_request(request) - self._metadata = json.loads(response.read()) + content = await async_send_network_request(request) + self._metadata = json.loads(content) self.set_category_global_cache("metadata", self._metadata) return self @@ -574,7 +703,7 @@ class PluginLocal: if not self.is_installed: raise PluginNotInstalled("Plugin is not available locally.") - self._content = await loop.run_in_executor(None, self._get_content) + self._content = await loop.run_in_executor(pool, self._get_content) return self._content async def get_api_version(self): @@ -655,7 +784,7 @@ class PluginLocal: self.save() def load_plugin(self, entry_point): - plugin_class = babase._general.getclass(entry_point, babase.Plugin) + plugin_class = babase.getclass(entry_point, babase.Plugin) loaded_plugin_instance = plugin_class() loaded_plugin_instance.on_app_running() @@ -679,7 +808,7 @@ class PluginLocal: async def set_content(self, content): if not self._content: - await loop.run_in_executor(None, self._set_content, content) + await loop.run_in_executor(pool, self._set_content, content) self._content = content return self @@ -701,14 +830,23 @@ class PluginLocal: class PluginVersion: def __init__(self, plugin, version, tag=CURRENT_TAG): self.number, info = version - self.plugin = plugin + # Plugin already owns its PluginVersions (via `versions`, + # `latest_version`, `latest_compatible_version`); holding a strong + # back-reference here would form a Plugin<->PluginVersion cycle + # that only the cyclic GC can free. A weakref avoids that so + # they're freed by refcounting alone. + self._plugin_ref = weakref.ref(plugin) self.api_version = info["api_version"] self.released_on = info["released_on"] self.commit_sha = info["commit_sha"] self.md5sum = info["md5sum"] - self.download_url = self.plugin.url.format(content_type="raw", tag=tag) - self.view_url = self.plugin.url.format(content_type="blob", tag=tag) + self.download_url = plugin.url.format(content_type="raw", tag=tag) + self.view_url = plugin.url.format(content_type="blob", tag=tag) + + @property + def plugin(self): + return self._plugin_ref() def __eq__(self, plugin_version): return (self.number, self.plugin.name) == (plugin_version.number, @@ -863,9 +1001,11 @@ class PluginManager: def __init__(self): self.request_headers = HEADERS self._index = _CACHE.get("index", {}) - self._changelog = _CACHE.get("changelog", {}) + # The raw changelog text, kept separate from the parsed entry in + # _CACHE["changelog"]; see setup_changelog(). + self._changelog = _CACHE.get("changelog_source") self.categories = {} - self.module_path = sys.modules[__name__].__file__ + self.module_path = __file__ self._index_setup_in_progress = False self._changelog_setup_in_progress = False @@ -879,8 +1019,8 @@ class PluginManager: ), headers=self.request_headers, ) - response = await async_send_network_request(request) - index = json.loads(response.read()) + content = await async_send_network_request(request) + index = json.loads(content) self.set_index_global_cache(index) self._index = index return self._index @@ -891,12 +1031,17 @@ class PluginManager: # Rather wait for the previous network call to complete. await asyncio.sleep(0.1) self._index_setup_in_progress = not bool(self._index) - index = await self.get_index() - await self.setup_plugin_categories(index) - self._index_setup_in_progress = False + try: + index = await self.get_index() + await self.setup_plugin_categories(index) + finally: + # Must clear even when the setup raised, or every later call + # spins in the loop above forever waiting on a call that has + # already given up. + self._index_setup_in_progress = False - async def get_changelog(self) -> list[str, bool]: - requested = False + async def get_changelog(self) -> str: + """The full CHANGELOG.md text, fetched once per session.""" if not self._changelog: request = urllib.request.Request(CHANGELOG_META.format( repository_url=REPOSITORY_URL, @@ -904,10 +1049,10 @@ class PluginManager: tag=CURRENT_TAG ), headers=self.request_headers) - response = await async_send_network_request(request) - self._changelog = response.read().decode() - requested = True - return [self._changelog, requested] + content = await async_send_network_request(request) + self._changelog = content.decode() + self.set_changelog_source_global_cache(self._changelog) + return self._changelog async def setup_changelog(self, version=None) -> None: if version is None: @@ -918,14 +1063,18 @@ class PluginManager: await asyncio.sleep(0.1) self._changelog_setup_in_progress = not bool(self._changelog) try: - full_changelog = await self.get_changelog() - # check if the changelog was requested - if full_changelog[1]: + try: + # Parsing is pure string work on text we already hold, so it + # runs every time rather than only on the call that fetched. + # Skipping it was what let the raw text reach the cache in + # place of the parsed entry ChangelogWindow reads. + full_changelog = await self.get_changelog() pattern = rf"### {version} \(\d\d-\d\d-\d{{4}}\)\n(.*?)(?=### \d+\.\d+\.\d+|\Z)" - if (len(full_changelog[0].split(version)) > 1): - released_on = full_changelog[0].split(version)[1].split('\n')[0] - matches = re.findall(pattern, full_changelog[0], re.DOTALL) + if (len(full_changelog.split(version)) > 1): + released_on = full_changelog.split(version)[1].split('\n')[0] + matches = re.findall(pattern, full_changelog, re.DOTALL) else: + released_on = ' (Not Provided)' matches = None if matches: @@ -934,15 +1083,17 @@ class PluginManager: 'info': matches[0].strip() } else: - changelog = {'released_on': ' (Not Provided)', + changelog = {'released_on': released_on, 'info': f"Changelog entry for version {version} not found."} - else: - changelog = full_changelog[0] - except urllib.error.URLError: - changelog = {'released_on': ' (Not Provided)', - 'info': 'Could not get ChangeLog due to Internet Issues.'} - self.set_changelog_global_cache(changelog) - self._changelog_setup_in_progress = False + except urllib.error.URLError: + changelog = {'released_on': ' (Not Provided)', + 'info': 'Could not get ChangeLog due to Internet Issues.'} + self.set_changelog_global_cache(changelog) + finally: + # Must clear even when the setup raised, or every later call + # spins in the loop above forever waiting on a call that has + # already given up. + self._changelog_setup_in_progress = False async def setup_plugin_categories(self, plugin_index): # A hack to have the "All" category show at the top. @@ -994,12 +1145,12 @@ class PluginManager: def set_changelog_global_cache(self, changelog): _CACHE["changelog"] = changelog + def set_changelog_source_global_cache(self, changelog_source): + _CACHE["changelog_source"] = changelog_source + def unset_index_global_cache(self): - try: - del _CACHE["index"] - del _CACHE["changelog"] - except KeyError: - pass + for key in ("index", "changelog", "changelog_source"): + _CACHE.pop(key, None) async def get_update_details(self): index = await self.get_index() @@ -1029,8 +1180,7 @@ class PluginManager: content_type="raw", tag=tag, ) - response = await async_send_network_request(download_url) - content = response.read() + content = await async_send_network_request(download_url) if hashlib.md5(content).hexdigest() != to_version_info["md5sum"]: raise MD5CheckSumFailed("MD5 checksum failed during plugin manager update.") with open(self.module_path, "wb") as fout: @@ -1046,7 +1196,7 @@ class ChangelogWindow(popup.PopupWindow): self.scale_origin = origin_widget.get_screen_space_center() s = 1.65 if _uiscale() is babase.UIScale.SMALL else 1.39 if _uiscale() is babase.UIScale.MEDIUM else 1.67 width = 400 * s - height = width * 0.5 + height = width * 0.6 color = (1, 1, 1) text_scale = 0.7 * s self._transition_out = 'out_scale' @@ -1092,12 +1242,10 @@ class ChangelogWindow(popup.PopupWindow): released_on = _CACHE['changelog']['released_on'] logs = _CACHE['changelog']['info'].split('\n') h_align = 'left' - extra = 0.1 except KeyError: released_on = '' logs = ["Could not load ChangeLog"] h_align = 'center' - extra = 1 bui.textwidget( parent=self._root_widget, @@ -1125,20 +1273,48 @@ class ChangelogWindow(popup.PopupWindow): ) ) - loop_height = height * 0.62 + # The entry can be arbitrarily long, so it scrolls rather than + # spilling out of the bottom of the window. + scroll_width = width * 0.88 + scroll_height = height * 0.62 + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + size=(scroll_width, scroll_height), + position=(width * 0.06, height * 0.04), + capture_arrows=True + ) + + body_scale = text_scale * 0.7 + line_height = 36 * body_scale + text_width = scroll_width - 30 + if h_align == 'left': + # Leave headroom below `maxwidth` so no line ends up shrunk + # (and thus smaller than its neighbours) by a rounding hair. + logs = _wrap_markdown_bullets(logs, text_width * 0.95, body_scale) + + content_height = max(scroll_height, line_height * len(logs) + 20) + content = bui.containerwidget( + parent=self._scrollwidget, + size=(text_width, content_height), + background=False, + claims_left_right=False + ) + + loop_height = content_height - line_height * 0.5 - 10 for log in logs: bui.textwidget( - parent=self._root_widget, - position=(width * 0.5 * extra, loop_height), + parent=content, + position=(text_width * 0.5 if h_align == 'center' else 0, + loop_height), size=(0, 0), h_align=h_align, v_align='center', text=log, - scale=text_scale, + scale=body_scale, color=color, - maxwidth=width * 0.9 + maxwidth=text_width ) - loop_height -= 30 + loop_height -= line_height def _back(self) -> None: bui.getsound('swish').play() @@ -1731,6 +1907,7 @@ class PluginWindow(popup.PopupWindow): _remove_popup(self) bui.containerwidget(edit=self._root_widget, transition='out_scale') + @staticmethod def button(fn): async def asyncio_handler(fn, self, *args, **kwargs): await fn(self, *args, **kwargs) @@ -2256,8 +2433,8 @@ class PluginManagerWindow(bui.MainWindow): def __init__( self, - transition: str = "in_right", - origin_widget: bui.Widget = None + transition: str | None = "in_right", + origin_widget: bui.Widget | None = None ): self.plugin_manager = PluginManager() self.category_selection_button = None @@ -3415,7 +3592,7 @@ class EntryPoint(babase.Plugin): from bauiv1lib.settings import allsettings allsettings.AllSettingsWindow = NewAllSettingsWindow DNSBlockWorkaround.apply() - asyncio.set_event_loop(babase._asyncio._asyncio_event_loop) + babase.app.add_shutdown_task(_shutdown_network_pool()) startup_tasks = StartupTasks() loop.create_task(startup_tasks.execute()) diff --git a/plugins/minigames.json b/plugins/minigames.json index 969670f..932471c 100644 --- a/plugins/minigames.json +++ b/plugins/minigames.json @@ -1663,6 +1663,61 @@ "md5sum": "33e6b93420cf6115e992aca534d76bfd" } } + }, + "volley_punch": { + "description": "Have fun with your friends in this new volley game !", + "external_url": "https://github.com/Scriptz1/Learn.py-Installer/blob/main/volleypunch.py", + "authors": [ + { + "name": "Learn.py", + "email": "phillipians36@gmail.com", + "discord": "Learning .py" + } + ], + "versions": { + "1.1.0": { + "api_version": 9, + "commit_sha": "a6d11fd", + "released_on": "02-06-2026", + "md5sum": "a2d73ebe4cb84b723242c187ad5b5fbb" + }, + "1.0.0": { + "api_version": 9, + "commit_sha": "576a9c0", + "released_on": "01-06-2026", + "md5sum": "be998b599896ed0e8dd5f2545fae6173" + } + } + }, + "vanishing_tiles": { + "description": "Tiles disappear gradually. Survive each round to continue. Last player standing wins!", + "external_url": "https://discord.gg/sQGDsztQcy", + "authors": [ + { + "name": "Mr.Paradox", + "email": "", + "discord": "gaurangbroyo" + }, + { + "name": "senchx", + "email": "", + "discord": "senchx0" + } + ], + "versions": { + "2.1.1": { + "api_version": 9, + "commit_sha": "5d7d222", + "released_on": "11-06-2026", + "md5sum": "d71b9615872ba3655a83166dd211ecd2" + }, + "2.1.0": { + "api_version": 9, + "commit_sha": "0749053", + "released_on": "10-06-2026", + "md5sum": "152e2e149b2686619c44aa7880a7e460" + } + } } } } \ No newline at end of file diff --git a/plugins/minigames/vanishing_tiles.py b/plugins/minigames/vanishing_tiles.py new file mode 100644 index 0000000..5a75efe --- /dev/null +++ b/plugins/minigames/vanishing_tiles.py @@ -0,0 +1,414 @@ +# ba_meta require api 9 +from __future__ import annotations +from bascenev1lib.gameutils import SharedObjects +import bascenev1 as bs +import babase +import random +from typing import Optional, List, Dict, Any + +# Vanishing Tiles - BombSquad Minigame +# Tiles disappear one by one. Survive all rounds to win. +# Last player/team standing takes the crown. + +plugman = dict( + plugin_name="vanishing_tiles", + description="Tiles disappear gradually. Survive each round to continue. Last player standing wins!", + external_url="https://discord.gg/sQGDsztQcy", + authors=[ + {"name": "Mr.Paradox", "email": "", "discord": "gaurangbroyo"}, + {"name": "senchx", "email": "", "discord": "senchx0"}, + ], + version="2.1.1", +) + + +class Player(bs.Player['Team']): + def __init__(self) -> None: + super().__init__() + self.death_time: Optional[float] = None + + +class Team(bs.Team[Player]): + pass + + +# ba_meta export bascenev1.GameActivity +class VanishingTilesGame(bs.TeamGameActivity[Player, Team]): + + name = 'Vanishing Tiles' + description = 'Tiles disappear one by one. Be the last one standing!' + scoreconfig = bs.ScoreConfig(label='Survived', scoretype=bs.ScoreType.MILLISECONDS) + announce_player_deaths = True + + @classmethod + def get_available_settings(cls, sessiontype: type[bs.Session]) -> list[babase.Setting]: + return [ + bs.BoolSetting('Epic Mode', default=False), + bs.BoolSetting('Show Credits', default=True), + ] + + @classmethod + def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: + return issubclass(sessiontype, (bs.FreeForAllSession, bs.DualTeamSession)) + + @classmethod + def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: + return ['Vanishing Tiles Arena'] + + def __init__(self, settings: dict) -> None: + super().__init__(settings) + self._epic_mode: bool = bool(settings.get('Epic Mode', False)) + self._show_credits: bool = bool(settings.get('Show Credits', True)) + self.default_music = bs.MusicType.EPIC if self._epic_mode else bs.MusicType.SURVIVAL + + self._round: int = 1 + self._removing: bool = False + self._game_start_time: Optional[float] = None + + self._tile_nodes: Dict[int, bs.Node] = {} + self._region_nodes: Dict[int, bs.Node] = {} + self._present_tile_ids: set[int] = set() + + self._remove_speed: float = 1.8 + + self._collide_mat = bs.Material() + self._collide_mat.add_actions(actions=(('modify_part_collision', 'collide', True),)) + self._no_collide_mat = bs.Material() + self._no_collide_mat.add_actions(actions=(('modify_part_collision', 'collide', False),)) + + self._default_tex = bs.gettexture('powerupHealth') + self._warn_tex = bs.gettexture('powerupCurse') + self._final_tex = bs.gettexture('powerupPunch') + + self._hud_round: Optional[bs.Node] = None + self._hud_players: Optional[bs.Node] = None + self._hud_tiles: Optional[bs.Node] = None + self._credit_node: Optional[bs.Node] = None + + if self._epic_mode: + self.slow_motion = True + + def spawn_player(self, player: Player) -> Any: + if isinstance(self.session, bs.FreeForAllSession): + spaz = self.spawn_player_spaz(player, position=VanishingTilesMapDefs.points['spawn1']) + else: + spaz = self.spawn_player_spaz(player) + spaz.connect_controls_to_player(enable_punch=False, enable_pickup=False, enable_bomb=False) + spaz.set_bomb_count(0) + return spaz + + def on_begin(self) -> None: + super().on_begin() + self._game_start_time = bs.time() + + if self._show_credits: + self._credit_node = bs.newnode('text', attrs={ + 'text': 'Made by Mr.Paradox', + 'scale': 0.7, 'position': (0, 8), 'shadow': 0.8, 'flatness': 1.0, + 'color': (1.0, 0.3, 0.8, 1.0), 'h_align': 'center', 'v_attach': 'bottom', + }) + bs.animate_array(self._credit_node, 'color', 4, { + 0.0: [1.0, 0.3, 0.8, 1.0], 1.0: [0.3, 0.8, 1.0, 1.0], + 2.0: [0.4, 1.0, 0.4, 1.0], 3.0: [1.0, 1.0, 0.2, 1.0], + 4.0: [1.0, 0.3, 0.8, 1.0], + }, loop=True) + + self._build_hud() + self._cleanup_tiles() + self._spawn_all_tiles() + self._show_round_banner() + + if not isinstance(self.session, bs.FreeForAllSession): + if not all(len(t.players) >= 1 for t in self.teams): + bs.broadcastmessage('Not enough players - draw!', color=(1, 1, 0)) + bs.timer(1.0, lambda: self.end(bs.GameResults())) + return + + bs.timer(1.0, self._check_initial_state) + bs.timer(3.0, self._start_removal) + + def _build_hud(self) -> None: + for attr in ('_hud_round', '_hud_players', '_hud_tiles'): + node = getattr(self, attr, None) + if node is not None and node.exists(): + node.delete() + setattr(self, attr, None) + + self._hud_round = bs.newnode('text', attrs={ + 'text': '', 'scale': 0.85, 'position': (0, -40), 'maxwidth': 300, + 'h_align': 'center', 'v_align': 'center', 'v_attach': 'top', 'h_attach': 'center', + 'shadow': 1.0, 'flatness': 1.0, 'color': (1, 1, 1, 1), 'in_world': False, + }) + self._hud_players = bs.newnode('text', attrs={ + 'text': '', 'scale': 0.8, 'position': (0, -58), 'maxwidth': 300, + 'h_align': 'center', 'v_align': 'center', 'v_attach': 'top', 'h_attach': 'center', + 'shadow': 1.0, 'flatness': 1.0, 'color': (1, 0.8, 0.2, 1), 'in_world': False, + }) + self._hud_tiles = bs.newnode('text', attrs={ + 'text': '', 'scale': 0.8, 'position': (0, -74), 'maxwidth': 300, + 'h_align': 'center', 'v_align': 'center', 'v_attach': 'top', 'h_attach': 'center', + 'shadow': 1.0, 'flatness': 1.0, 'color': (0.5, 1.0, 0.5, 1), 'in_world': False, + }) + self._refresh_hud() + + def _refresh_hud(self) -> None: + alive = len([p for p in self.players if p.is_alive()]) + tiles = len(self._present_tile_ids) + urgent = tiles <= 4 + + if self._hud_round: + self._hud_round.text = f'Round {self._round}' + if self._hud_players: + self._hud_players.text = f'Players alive: {alive}' + if self._hud_tiles: + self._hud_tiles.color = (1.0, 0.3, 0.3, 1) if urgent else (0.5, 1.0, 0.5, 1) + self._hud_tiles.text = f'Tiles left: {tiles}' + + def _show_round_banner(self) -> None: + node = bs.newnode('text', attrs={ + 'text': f'Round {self._round}', 'scale': 1.3, 'position': (0, 60), + 'shadow': 1.2, 'flatness': 0.7, 'color': (1, 1, 0, 1), + 'h_align': 'center', 'v_attach': 'center', 'in_world': False, + }) + bs.animate(node, 'scale', {0: 0.0, 0.15: 1.3, 2.0: 1.3, 2.5: 0.0}) + bs.timer(2.6, node.delete) + + def on_player_join(self, player: Player) -> None: + if self.has_begun(): + player.death_time = bs.time() + bs.broadcastmessage( + f'{player.getname()} joined! Round {self._round} - {len(self._present_tile_ids)} tiles left.', + color=(0.5, 1.0, 1.0), transient=True, + ) + return + self.spawn_player(player) + + def _check_initial_state(self) -> None: + if not [p for p in self.players if p.is_alive()]: + bs.timer(1.0, self._check_end_game) + + def _spawn_all_tiles(self) -> None: + positions = [ + (4.5, 2, -9), (4.5, 2, -6), (4.5, 2, -3), (4.5, 2, 0), + (1.5, 2, -9), (1.5, 2, -6), (1.5, 2, -3), (1.5, 2, 0), + (-1.5, 2, -9), (-1.5, 2, -6), (-1.5, 2, -3), (-1.5, 2, 0), + (-4.5, 2, -9), (-4.5, 2, -6), (-4.5, 2, -3), (-4.5, 2, 0), + ] + model = bs.getmesh('buttonSquareOpaque') + shared = SharedObjects.get() + for i, pos in enumerate(positions): + tile = bs.newnode('prop', attrs={ + 'body': 'puck', 'position': pos, 'mesh': model, + 'mesh_scale': 3.73, 'body_scale': 3.73, 'gravity_scale': 0.0, + 'color_texture': self._default_tex, 'reflection': 'soft', + 'materials': [self._no_collide_mat], + }) + region = bs.newnode('region', attrs={ + 'position': pos, 'scale': (3.5, 0.1, 3.5), 'type': 'box', + 'materials': [self._collide_mat, shared.footing_material], + }) + self._tile_nodes[i] = tile + self._region_nodes[i] = region + self._present_tile_ids.add(i) + self._refresh_hud() + + def _start_removal(self) -> None: + if not self._removing: + self._removing = True + self._remove_next_tile() + + def _make_final_tile(self) -> None: + try: + tile_id = list(self._present_tile_ids)[0] + tile = self._tile_nodes.get(tile_id) + if tile and tile.exists(): + tile.color_texture = self._final_tex + except Exception: + pass + + self.slow_motion = True + bs.broadcastmessage('LAST TILE!', color=(1, 0.4, 0.1)) + for p in self.players: + if p.is_alive() and p.actor and p.actor.exists(): + p.actor.node.handlemessage(bs.CelebrateMessage(3.0)) + self._refresh_hud() + bs.timer(3.0, self._round_end_check) + + def _remove_next_tile(self) -> None: + if len(self._present_tile_ids) <= 1: + self._make_final_tile() + return + + if len(self._present_tile_ids) <= 4: + bs.getsound('shieldDown').play(0.4) + + tile_id = random.choice(list(self._present_tile_ids)) + self._remove_tile(tile_id) + bs.timer(self._remove_speed, self._remove_next_tile) + + def _remove_tile(self, tile_id: int) -> None: + tile = self._tile_nodes.get(tile_id) + region = self._region_nodes.get(tile_id) + if tile is None or not tile.exists(): + self._present_tile_ids.discard(tile_id) + return + + tile.color_texture = self._warn_tex + + def vanish() -> None: + if tile.exists(): + bs.emitfx(position=tile.position, count=20, scale=0.9, + spread=0.5, chunk_type='spark') + tile.delete() + if region and region.exists(): + region.delete() + self._present_tile_ids.discard(tile_id) + self._refresh_hud() + + bs.timer(1.0, vanish) + + def _round_end_check(self) -> None: + self.slow_motion = self._epic_mode + + if not isinstance(self.session, bs.FreeForAllSession): + living_teams = [t for t in self.teams if any(p.is_alive() for p in t.players)] + if len(living_teams) <= 1: + self._removing = False + self.end_game() + return + else: + if len([p for p in self.players if p.is_alive()]) <= 1: + self._removing = False + self.end_game() + return + + self._remove_speed = max(0.3, self._remove_speed * 0.75) + self._round += 1 + survivors = [p for p in self.players if p.is_alive()] + bs.broadcastmessage(f'Round {self._round}! Speed increasing!', color=(0.5, 1.0, 1.0)) + bs.timer(1.5, lambda s=survivors: self._next_round(s)) + + def _next_round(self, survivors: List[Player]) -> None: + safety = bs.newnode('region', attrs={ + 'position': (0, 1.5, -5), 'scale': (20, 0.2, 20), 'type': 'box', + 'materials': [SharedObjects.get().footing_material], + }) + self._cleanup_tiles() + self._spawn_all_tiles() + self._show_round_banner() + self._refresh_hud() + + for p in survivors: + if p.actor and p.actor.exists(): + p.actor.handlemessage(bs.StandMessage(VanishingTilesMapDefs.points['spawn1'])) + + bs.timer(2.5, safety.delete) + self._removing = False + bs.timer(3.0, self._start_removal) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, bs.PlayerDiedMessage): + super().handlemessage(msg) + msg.getplayer(Player).death_time = bs.time() + self._refresh_hud() + bs.timer(0.5, self._check_end_game) + return None + return super().handlemessage(msg) + + def on_player_leave(self, player: Player) -> None: + super().on_player_leave(player) + bs.timer(0.2, self._check_team_empty) + + def _check_team_empty(self) -> None: + if self.has_ended(): + return + if isinstance(self.session, bs.FreeForAllSession): + return + for team in self.teams: + if len(team.players) == 0: + bs.broadcastmessage('A team has no players - game over!', color=(1, 1, 0)) + bs.timer(1.0, self.end_game) + return + + def _check_end_game(self) -> None: + if not self.has_begun(): + return + if not isinstance(self.session, bs.FreeForAllSession): + if len([t for t in self.teams if any(p.is_alive() for p in t.players)]) <= 1: + self.end_game() + else: + if len([p for p in self.players if p.is_alive()]) <= 1: + self.end_game() + + def end_game(self) -> None: + if self.has_ended(): + return + cur_time = bs.time() + start = self._game_start_time or cur_time + results = bs.GameResults() + for team in self.teams: + longest = 0.0 + for p in team.players: + death = p.death_time or (cur_time + 1) + longest = max(longest, death - start) + results.set_team_score(team, int(longest * 1000)) + + for attr in ('_hud_round', '_hud_players', '_hud_tiles'): + node = getattr(self, attr, None) + if node is not None and node.exists(): + node.delete() + + if self._credit_node and self._credit_node.exists(): + self._credit_node.delete() + + self.end(results=results) + + def _cleanup_tiles(self) -> None: + for n in list(self._tile_nodes.values()): + if n.exists(): + n.delete() + for n in list(self._region_nodes.values()): + if n.exists(): + n.delete() + self._tile_nodes.clear() + self._region_nodes.clear() + self._present_tile_ids.clear() + + +class VanishingTilesMapDefs: + points = {'spawn1': (0, 3, -5)} + boxes = { + 'area_of_interest_bounds': (0, 4, -5, 0, 0, 0, 16, 8, 16), + 'map_bounds': (0, 4, -5, 0, 0, 0, 30, 14, 30), + } + + +class VanishingTilesMap(bs.Map): + defs = VanishingTilesMapDefs() + name = 'Vanishing Tiles Arena' + + @classmethod + def get_preview_texture_name(cls) -> str: + return 'powerupHealth' + + @classmethod + def on_preload(cls) -> Any: + return {'bgtex': bs.gettexture('menuBG'), 'bgmesh': bs.getmesh('thePadBG')} + + def __init__(self) -> None: + super().__init__() + self.node = bs.newnode('terrain', attrs={ + 'mesh': self.preloaddata['bgmesh'], 'lighting': False, + 'background': True, 'color_texture': self.preloaddata['bgtex'], + }) + + +try: + bs._map.register_map(VanishingTilesMap) +except Exception: + pass + + +# ba_meta export babase.Plugin +class Main(babase.Plugin): + pass diff --git a/plugins/minigames/volley_punch.py b/plugins/minigames/volley_punch.py new file mode 100644 index 0000000..f5260e4 --- /dev/null +++ b/plugins/minigames/volley_punch.py @@ -0,0 +1,1293 @@ +# VolleyPunch ? ig i'll name it like that + +# Made by Learn.py +# but this was def inspired by some freaky guy + + +# ba_meta require api 9 + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import babase +import _babase +import math +import random +import bascenev1 as bs +import bauiv1 as bui +import math +import time +import babase +from bascenev1lib.actor.playerspaz import PlayerSpaz +from bascenev1lib.actor.scoreboard import Scoreboard +from bascenev1lib.actor.powerupbox import PowerupBoxFactory +from bascenev1lib.actor.bomb import BombFactory +from bascenev1lib.gameutils import SharedObjects + +if TYPE_CHECKING: + from typing import Any, Sequence, Dict, Type, List, Optional, Union + + +plugman = dict( + plugin_name="volley_punch", + description="Have fun with your friends in this new volley game !", + external_url="https://github.com/Scriptz1/Learn.py-Installer/blob/main/volleypunch.py", + authors=[ + {"name": "Learn.py", "email": "phillipians36@gmail.com", "discord": "Learning .py"}, + ], + version="1.1.0", +) + + +class PuckDiedMessage: + """Inform something that a puck has died.""" + + def __init__(self, puck: Puck): + self.puck = puck + + +class Puck(bs.Actor): + def __init__(self, position: Sequence[float] = (0.0, 1.0, 0.0)): + super().__init__() + shared = SharedObjects.get() + activity = self.getactivity() + + # Spawn just above the provided point. + self._spawn_pos = (position[0], position[1] + 1.05, position[2]) + self.last_players_to_touch: Dict[int, Player] = {} + self.scored = False + + self.touches = 0 + assert activity is not None + assert isinstance(activity, VolleyBallGame) + pmats = [shared.object_material, activity.puck_material] + + # so this is the volleyball we gonna mesh with today + self.node = bs.newnode('prop', + delegate=self, + attrs={ + 'mesh': activity.puck_mesh, + 'color_texture': activity.puck_tex, + 'body': 'sphere', + 'reflection': 'soft', + 'reflection_scale': [0.2], + 'shadow_size': 0.3, + 'mesh_scale': activity.ball_size, + 'body_scale': 1.07, + 'gravity_scale': 1, + 'is_area_of_interest': True, + 'position': self._spawn_pos, + 'materials': pmats + }) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, bs.DieMessage): + assert self.node + self.node.delete() + activity = self._activity() + if activity and not msg.immediate: + activity.handlemessage(PuckDiedMessage(self)) + + # If we go out of bounds, move back to where we started. + # copied that stuff from hockey game ngl + elif isinstance(msg, bs.OutOfBoundsMessage): + assert self.node + self.node.position = self._spawn_pos + + elif isinstance(msg, bs.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. + s_player = msg.get_source_player(Player) + if s_player is not None: + activity = self._activity() + if activity: + if s_player in activity.players: + self.last_players_to_touch[s_player.team.id] = s_player + else: + super().handlemessage(msg) + + +class Player(bs.Player['Team']): + """Our player type for this game.""" + + +class Team(bs.Team[Player]): + """Our team type for this game.""" + + def __init__(self) -> None: + self.score = 0 + + +# ba_meta export bascenev1.GameActivity +class VolleyBallGame(bs.TeamGameActivity[Player, Team]): + name = 'VolleyPunch' + description = 'Punch the Volleyball !' + available_settings = [ + bs.IntSetting( + 'Score to Win', + min_value=1, + default=5, + increment=1, + ), + bs.IntChoiceSetting( + 'Time Limit', + choices=[ + ('None', 0), + ('1 Minute', 60), + ('2 Minutes', 120), + ('5 Minutes', 300), + ('10 Minutes', 600), + ('20 Minutes', 1200), + ], + default=0, + ), + bs.FloatChoiceSetting( + 'Respawn Times', + choices=[ + ('Shorter', 0.25), + ('Short', 0.5), + ('Normal', 1.0), + ('Long', 2.0), + ('Longer', 4.0), + ], + default=1.0, + ), + bs.FloatSetting( + 'Ball size (m)', + min_value=0.15, + default=0.25, + max_value=0.35, + increment=0.05, + ), + bs.FloatChoiceSetting( + 'Ball Average Speed', + choices=[ + ('Slower', 0.9899), + ('Normal', 1.0), + ], + default=1.0, + ), + bs.BoolSetting('Epic Mode', False), + bs.BoolSetting('Enable Credits', True), + ] + default_music = bs.MusicType.HOCKEY + + @classmethod + def supports_session_type(cls, sessiontype: Type[bs.Session]) -> bool: + return issubclass(sessiontype, bs.DualTeamSession) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[bs.Session]) -> List[str]: + return ['Football Stadium'] + + def __init__(self, settings: dict): + super().__init__(settings) + shared = SharedObjects.get() + self._scoreboard = Scoreboard() + self._cheer_sound = bs.getsound('cheer') + self._chant_sound = bs.getsound('crowdChant') + self.one_sound = bs.getsound('announceOne') + self.two_sound = bs.getsound('announceTwo') + self.three_sound = bs.getsound('announceThree') # unused.. yet + self._swipsound = bs.getsound('swip') + self._whistle_sound = bs.getsound('refWhistle') + self.puck_mesh = bs.getmesh('shield') + self.puck_tex = bs.gettexture('ouyaUButton') + self._puck_sound = bs.getsound('metalHit') + self.puck_material = bs.Material() + self.puck_material.add_actions(actions=(('modify_part_collision', + 'friction', 4))) + self.puck_material.add_actions(conditions=('they_have_material', + shared.pickup_material), + actions=('modify_part_collision', + 'collide', True)) + self.puck_material.add_actions( + conditions=( + ('we_are_younger_than', 100), + 'and', + ('they_have_material', shared.object_material), + ), + actions=('modify_node_collision', 'collide', False), + ) + self.puck_material.add_actions(conditions=('they_have_material', + shared.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', shared.player_material), + actions=(('call', 'at_connect', + self._handle_puck_player_collide), + ('modify_part_collision', 'physical', False))) + + # We want the puck to kill powerups; not get stopped by them + self.puck_material.add_actions( + conditions=('they_have_material', + PowerupBoxFactory.get().powerup_material), + actions=(('modify_part_collision', 'physical', False), + ('message', 'their_node', 'at_connect', bs.DieMessage()))) + self._score_region_material = bs.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._fake_wall_material = bs.Material() # i'll fix this. trust + + self._net_wall_material = bs.Material() + self._net_material = bs.Material() + self._net_wall_material.add_actions( + conditions=('they_have_material', shared.player_material), + actions=( + ('modify_part_collision', 'collide', True), + ('modify_part_collision', 'physical', True) + + )) + self._net_material.add_actions( + conditions=('they_have_material', shared.object_material), + actions=( + ('modify_part_collision', 'collide', True), + ('modify_part_collision', 'physical', True), + ('modify_part_collision', + 'friction', -10), + ('call', 'at_connect', self.slow_down))) + + self._puck_spawn_pos: Optional[Sequence[float]] = None + self._score_regions: Optional[List[bs.NodeActor]] = None + self._puck: Optional[Puck] = None + self._score_to_win = int(settings['Score to Win']) + self._time_limit = float(settings['Time Limit']) + self.credit_text = bool(settings['Enable Credits']) # i did the freaku credit thing + self._epic_mode = bool(settings['Epic Mode']) + + # the ball is too fast for the players and the map is soo huge its a good poc - brostos + self.ball_size = float(settings['Ball size (m)']) + self.ball_speed = float(settings['Ball Average Speed']) + + # Base class overrides. + self.slow_motion = self._epic_mode + self.default_music = (bs.MusicType.EPIC if self._epic_mode else + bs.MusicType.TO_THE_DEATH) + self.s: list = [] + self.w: list = [] + self.n: list = [] + self.last_interaction_time = -9999 + self.smashed = -9999 + self.interactions_timer = None + self.curve = True + self.difficulty = 0.5 + self.smasher = None + self.commentary_text = None + self._text_task = None # Référence pour annuler l'animation en cours + self._clear_timer = None # Référence pour le timer de disparition + self.touches_indicator = None + self.guide = None + self.curving = False + + def get_instance_description(self) -> Union[str, Sequence]: + if self._score_to_win == 1: + return 'Score a goal.' + return 'Score ${ARG1} goals.', self._score_to_win + + def get_instance_description_short(self) -> Union[str, Sequence]: + if self._score_to_win == 1: + return 'score a goal' + return 'score ${ARG1} goals', self._score_to_win + + def on_begin(self) -> None: + super().on_begin() + + self.setup_standard_time_limit(self._time_limit) + self._puck_spawn_pos = (-4, 1, 0) if random.random() < 0.5 else (4, 1, 0) + self._spawn_puck() + + # Set up the two score regions. + self._score_regions = [] + self.commentary_text = bs.newnode( + 'text', + attrs=dict( + text='', + scale=0.012, + position=(0, 3.1, -5.8), + in_world=True, + flatness=1, + shadow=0, + color=(1, 1, 1, 0.5), + h_align='center', + v_align='top', + h_attach='center', + v_attach='top', + )) + self.touches_indicator = bs.newnode( + 'text', + attrs=dict( + text='', + scale=0.025, + position=(0, 3, -5.8), + in_world=True, + flatness=1, + shadow=0, + color=(1, 1, 1, 0.5), + h_align='center', + v_align='top', + h_attach='center', + v_attach='top', + )) + self.guide = bs.newnode( + 'text', + attrs=dict( + text='PUNCH TO TAP THE BALL \nPICKUP TO CUFF \nBOMB TO GLIDE / DIVE \nJUMP UNDER A MID AIR BALL WITH THE BAR FILLED TO SMASH \nTHE ULT BAR FILLS AS YOU PLAY', + scale=0.005, + position=(0, 2.7, -5.8), + in_world=True, + flatness=1, + shadow=0, + color=(1, 1, 1, 0.5), + h_align='center', + v_align='top', + h_attach='center', + v_attach='top', + )) + bs.animate_array(self.guide, 'color', 4, {0: (1, 1, 1, 0.5), 1: ( + 1, 1, 1, 1), 2: (1, 1, 1, 0.5)}, loop=True) + self.interactions_timer = bs.Timer( + 1 / 120, self.interactions, repeat=True) # time, function, repeat + self._score_regions.append( + bs.NodeActor( + bs.newnode('region', + attrs={ + 'position': (9, 0, 0), + 'scale': (17.3, 0.002, 12), + 'type': 'box', + 'materials': [self._score_region_material] + }))) + self._score_regions.append( + bs.NodeActor( + bs.newnode('region', + attrs={ + 'position': (-9, 0, 0), + 'scale': (17.3, 0.002, 20), + 'type': 'box', + 'materials': [self._score_region_material] + }))) + self._update_scoreboard() + self._chant_sound.play() + if self.credit_text: + t = bs.newnode('text', + attrs={'text': "Created by Learn.py", + 'scale': 0.7, + 'position': (0, 0), + 'shadow': 0.5, + 'flatness': 1.2, + 'color': (1, 1, 1), + 'h_align': 'center', + 'v_attach': 'bottom'}) + + # Create the net and regions. + mat = bs.Material() # spam this whenever u want a material THEN add some properties + mat.add_actions(actions=('modify_part_collision', + 'collide', False)) + net = bs.newnode( + 'prop', + attrs={ + 'body': 'puck', + 'body_scale': 0, + 'position': (0, 0, 0), + 'mesh_scale': 1, + 'mesh': bs.getmesh('volleyball_net'), + 'color_texture': bs.gettexture('black'), + 'reflection': 'soft', + 'reflection_scale': [1.5], + 'sticky': False, + 'shadow_size': 0.2, + 'gravity_scale': 0, + 'materials': [mat], + }, + ) + + self.s.append(bs.NodeActor(bs.newnode('region', attrs={'position': (0, 2.4, 0), 'scale': ( + 0.8, 6, 20), 'type': 'box', 'materials': (self._fake_wall_material, )}))) + + self.w.append(bs.NodeActor(bs.newnode('region', attrs={'position': (0, 0, 0), 'scale': ( + 0.6, 10, 20), 'type': 'box', 'materials': (self._net_wall_material, )}))) + self.n.append(bs.NodeActor(bs.newnode('region', attrs={'position': (0, 0, 0), 'scale': ( + 0.6, 2.75, 20), 'type': 'box', 'materials': (self._net_material, )}))) + self.n.append(bs.NodeActor(bs.newnode('region', attrs={'position': (0, 0, -5.9), 'scale': ( + 20, 20, 0.8), 'type': 'box', 'materials': (self._net_wall_material, self._net_material)}))) + self.n.append(bs.NodeActor(bs.newnode('region', attrs={'position': (0, 0, 5.9), 'scale': ( + 20, 20, 0.8), 'type': 'box', 'materials': (self._net_wall_material, self._net_material)}))) + + def slow_down(self): + if not self._puck: + return + puck_node = self._puck.node + mult = 0.88 + v = puck_node.velocity + puck_node.velocity = (v[0] * mult, v[1] * mult, v[2] * mult) + + def write(self, text, color): + """types""" + if hasattr(self, '_timers'): + for t in self._timers: + t = None + self._timers = [] + + if not text: + return + + self.commentary_text.color = color + total_chars = len(text) + + interval = 0.9 / max(total_chars, 1) + + for i in range(1, total_chars + 1): + def show(index=i): + if self.commentary_text.exists(): + self.commentary_text.text = text[:index] + + self._timers.append(bs.Timer(interval * i, show, repeat=False)) + + def start_clear(): + for i in range(total_chars, -1, -1): + def hide(index=i): + if self.commentary_text.exists(): + self.commentary_text.text = text[:index] + + self._timers.append( + bs.Timer(0.9 + 3.0 + (0.015 * (total_chars - i)), hide, repeat=False)) + + start_clear() + + def vignette(self, final_color, longer): + """Aesthetic reasons.""" + glb = bs.getactivity().globalsnode + base_color = (0.57, 0.57, 0.57) + bs.animate_array(glb, 'vignette_outer', 3, {0: base_color, 0.1: ( + final_color[0] / 1.2, final_color[1] / 1.2, final_color[2] / 1.2), (1 if longer else 0.5): base_color}) + + def interactions(self): + """Some personalized stuff and my deepest secrets. + I don't know why, but I feel SoK may like this.""" + if self._puck is None: + return + + vel = self._puck.node.velocity + # apply the mult + self._puck.node.velocity = ( + vel[0], + vel[1] * self.ball_speed, + vel[2] + ) + + green = bs.gettexture('ouyaOButton') + yellow = bs.gettexture('ouyaYButton') + red = bs.gettexture('ouyaAButton') + blue = bs.gettexture('ouyaUButton') + + # If it passes the middle.. green. + puck_node = self._puck.node + pos = puck_node.position + x_diff = abs(pos[0]) + + if x_diff < 0.25 or self.curve: + puck_node.color_texture = blue + self._puck.touches = 0 + + if pos[1] < 2.6 and (bs.time() - self.last_interaction_time > 0.5): + mult = 0.935 + v = puck_node.velocity + puck_node.velocity = (v[0] * mult, v[1] * mult, v[2] * mult) + + self.save_text = random.choice([ + "dived to the rescue!", + "kept the ball alive!", + "flew in to save the day!", + "is too fast for the floor!", + "bounced it back just in time!", + "kept the dream alive!", + "barely missed the ground!", + "saved the rally!", + "made a clutch save!", + "refused to let it drop!", + "pulled a superman save!", + "made a desperate save!", + "proved the floor is not an option!", + "landed a miracle touch!", + "saved the point!", + "pulled off a heroic dive!", + "stretched to the limit!", + "barely kept it up!", + "showed reflexes of a cat!", + "denied the floor!", + "made a clutch save!", + "is staying in the game!", + "pulled an epic recovery!", + "reacted in the nick of time!", + "won't let the ball drop!", + "is pure hustle!", + "made an impossible save!", + "is defying gravity!", + "made sure the ground stays empty!", + "saved it!", + "pulled off last-second magic!", + "is floating to save it!", + "is too clutch to fail!", + "got the ball back in the air!", + "is denying the dirt!", + "saved it by a hair!", + "is fighting for every ball!", + "is pure energy!", + "never gives up!", + "keeps it in play!", + "saved it barely!", + "kept the floor clean!", + "is pure determination!", + "pulled a miraculous save!", + "denied the game over!", + "is still alive!", + "is keeping the rally going!", + "made an out-of-reach save!", + "made a ninja-like save!", + "bounced back from trouble!"]) + self.hit_text = random.choice([ + "sent it over!", + "cleared the net!", + "powered it across!", + "nailed a deep shot!", + "bounced it into their zone!", + "launched an attack!", + "sent a rocket over!", + "is dominating the net!", + "found the open spot!", + "placed it perfectly!", + "smashed it back!", + "is applying the pressure!", + "sent them scrambling!", + "over the net it goes!", + "hit a beauty!", + "is controlling the court!", + "forced a tough return!", + "keeps the pressure high!", + "landed a sweet strike!", + "sent it deep!", + "is playing aggressively!", + "hit a laser beam!", + "made a solid connection!", + "is taking charge!", + "sent a tricky shot over!", + "is firing back!", + "put it right on target!", + "sent the ball flying!", + "beat the defense!", + "is crushing it!", + "nailed that serve return!", + "showed off some power!", + "sent a high arc over!", + "is making moves!", + "kept them on their toes!", + "delivered a powerful hit!", + "is pushing the pace!", + "found the gap!", + "sent a curveball over!", + "is looking sharp!", + "hit it with precision!", + "made them work for it!", + "is owning the net!", + "sent a heavy strike!", + "is dictating the play!", + "put it out of reach!", + "landed the perfect hit!", + "is on fire!", + "sent a swift strike!", + "is forcing errors!" + ]) + self.control_text = random.choice([ + "is showing off sweet control!", + "has the ball on a string!", + "handles it with grace!", + "shows off some nice touch!", + "keeps it close and tight!", + "is demonstrating perfect poise!", + "has ice in their veins!", + "is keeping it under control!", + "shows a velvet touch!", + "is masterfully handling the ball!", + "keeps the ball glued to them!", + "is displaying total composure!", + "makes it look so easy!", + "is controlling the pace!", + "shows off pure finesse!", + "has the ball perfectly placed!", + "is staying cool under pressure!", + "has incredible ball handling!", + "is maneuvering with ease!", + "keeps the ball within reach!", + "shows off some fancy footwork!", + "is keeping the ball calm!", + "has the softest touch!", + "is dictating the rhythm!", + "keeps it balanced and steady!", + "is showing high-level control!", + "has the situation handled!", + "is playing with surgical precision!", + "keeps the ball in their zone!", + "shows off expert handling!", + "is keeping it smooth!", + "has the ball right where they want it!", + "demonstrates a gentle touch!", + "is keeping the game slow!", + "shows off calm concentration!", + "has everything under control!", + "is moving with grace!", + "keeps the ball softly hovering!", + "shows off true talent!", + "is maintaining perfect flow!", + "has a magical touch!", + "is playing it smart!", + "keeps the ball very close!", + "shows off professional control!", + "is handling the pressure well!", + "has a very steady hand!", + "is keeping it precise!", + "shows off masterful technique!", + "is keeping the ball in check!", + "has the perfect feel for it!" + ]) + if self.touches_indicator is not None and self.touches_indicator.exists(): + self.touches_indicator.position = (self._puck.node.position[0], self._puck.node.position[1] + 0.35, + self._puck.node.position[2]) + else: + self.touches_indicator = bs.newnode( + 'text', + attrs=dict( + text='', + scale=0.025, + position=(0, 3, -5.8), + in_world=True, + flatness=1, + shadow=0, + color=(1, 1, 1, 0.5), + h_align='center', + v_align='top', + h_attach='center', + v_attach='top', + )) + + everything = bs.getnodes() + everyone = [] + closest_distance = 9999 + closest_player = None + + # i won't explain what "curve" is... look by yourself + if not self.curve and not self._puck.scored: + if bs.time() < self.smashed: + random_pos = (random.uniform( + 0.1, 0.7) + self._puck.node.position[0], self._puck.node.position[1], random.uniform(0.1, 0.7) + self._puck.node.position[2]) + e = bs.newnode( + 'explosion', + attrs={ + 'position': random_pos, + 'color': self.smasher.color, + 'radius': random.uniform(0.1, 0.7), + 'big': False, + } + ) + bs.timer(1, e.delete) + self.smasher.getdelegate(object).percentage /= 1.1 + else: + ball_tex = self._puck.node.color_texture + + texture_to_name = { + green: 'green', + yellow: 'yellow', + red: 'red', + blue: 'blue' + } + + colors = { + 'green': (0.6, 1, 0.6), + 'yellow': (1, 0.3, 0), + 'red': (1, 0, 0), + 'blue': (0.6, 0.6, 1) + } + + color_name = texture_to_name.get(ball_tex) + color = colors.get(color_name) + + e = bs.newnode( + 'explosion', + attrs={ + 'position': self._puck.node.position, + 'color': color, + 'radius': 0.1, + 'big': False, + } + ) + indicator_color = (*color, 0.5) + bs.timer(1, e.delete) + self.touches_indicator.color = indicator_color + self.touches_indicator.text = str(3 - self._puck.touches) + + for human in everything: # doesn't make much sense, but I hope u understand I want players nodes. + if human.getnodetype() == "spaz": + everyone.append(human) + + for player_node in everyone: + if not player_node.exists(): # why wouldn't u exist at first + continue + + # not wanting to do that Euclidean thingy + distance = math.dist(player_node.position, self._puck.node.position) + player_node.hold_node = None + player_node.getdelegate(object).impact_scale = 0 + if distance < closest_distance: + closest_player = player_node # him, the closest one + closest_distance = distance # his distance with the volleyball + + good: bool = (self._puck.touches < 3 and + bs.time() - self.last_interaction_time > 0.4 and + not self._puck.scored) + # bs.time() is the current bombsquad time :) bs for bombsquad. + + delegate = closest_player.getdelegate(object) + now = bs.time() + can_jump = (delegate.last_jump_time_ms > + delegate._jump_cooldown and closest_player.position[1] < 1) + + if closest_player.jump_pressed and can_jump: + ppos = closest_player.position + delegate.last_jump_time_ms = int(bs.time() * 1000) + closest_player.handlemessage( + 'kick_back', + ppos[0], + ppos[1], + ppos[2], + 0, + 1, + 0, + 50, # small force + ) + + punched = now < delegate.punched + pickup = now < delegate.pickup + bombed = now < delegate.bombed + curved = now < delegate.curved + + # SO if broski is the nearest AND broski is near enough... + if closest_player and closest_distance < 4 and good: + player = spaz = delegate + ball = self._puck.node # ball node + you = closest_player.position # already a node + in_air = (you[1] > 0.8) + # Ball goes where you look but always tries to go to the other side. (tell me if u guys want it to go totally where you're looking at) + if closest_distance < (1.35 if not in_air else 1.6) and (closest_player.punch_pressed or punched): + self.difficulty += 0.6 / 75 + e = bs.newnode( + 'explosion', + attrs={ + 'position': self._puck.node.position, + 'color': closest_player.color, + 'radius': 0.8, + 'big': False, + } + ) + bs.timer(1, e.delete) + self.last_interaction_time = bs.time() + self._puck.touches += 1 + self.curve = False + if self._puck.touches == 2: + spaz.percentage = min(spaz.percentage + 8, 100) + ball.color_texture = yellow + self.two_sound.play() + elif self._puck.touches == 3: + spaz.percentage = min(spaz.percentage + 12, 100) + ball.color_texture = red + self.one_sound.play() + else: + ball.color_texture = green + # This is for the sound, you probably didn't know that! + bs.getsound("impactHard").play() + + sight = (bs.Vec3(you) - bs.Vec3(closest_player.position_forward) + ).normalized() * (12 if delegate.percentage < 95 else 30) # Sight factor + speed = 6 if delegate.percentage < 95 else 20 # bar full + + if curved: + start_pos = ball.position + end_pos = (sight[0] * 2, -1, -sight[2]) + duration = 1.1 + fps = 144 + total_steps = int(duration * fps) + curved_time = bs.time() + 0.15 + self.curving = True + + z_offset = -4.0 if sight[0] < 0 else 4.0 + + def move_to_point(step): + if not ball.exists(): + return + if ball.position[1] < 1 and bs.time() > curved_time: + return + if not self.curving: + return + + t = step / total_steps + + x = start_pos[0] + (end_pos[0] - start_pos[0]) * t + y = start_pos[1] + (end_pos[1] - start_pos[1]) * t + z = start_pos[2] + (end_pos[2] - start_pos[2]) * t + + y += math.sin(t * math.pi) * 3.0 + + # Ajout de la courbe latérale + z += math.sin(t * math.pi) * z_offset + + # --- LIMITE SUR L'AXE Z --- + # On force z à rester entre -5 et 5 + z = max(-5.0, min(5.0, z)) + # -------------------------- + + ball.position = (x, y, z) + + if step < total_steps: + bs.timer(1 / fps, lambda: move_to_point(step + 1)) + + move_to_point(0) + else: + # normal + self.curving = False + if in_air and delegate.percentage > 95: + if closest_player.position[0] < 0: + vel = (12 * 1.6, 0, sight[2]) * speed + else: + vel = (-12 * 1.6, 0, sight[2]) * speed + else: + if closest_player.position[0] < 0: + vel = (4.8 * 1.6, 0, sight[2]) * speed + else: + vel = (-4.8 * 1.6, 0, sight[2]) * speed + + # smth + dist_from_middle = math.dist(you, (0, 2, 0)) + height = (max(dist_from_middle * 2.75, 7) * 0.8) + if in_air and delegate.percentage > 95: + height = -4 * 2 / dist_from_middle + + ball.velocity = (vel[0], height, vel[2]) + if in_air and delegate.percentage > 95: + audio = random.choice(['explosion01', 'explosion02', + 'explosion03', 'explosion04', 'explosion05']) + bs.getsound(audio).play() + self.smashed = bs.time() + 1 + self.smasher = closest_player + self.vignette(closest_player.color, False) + delegate.on_punch_press() + delegate.on_punch_release() + self.write( + f'{player.name.upper()} SMASHED THE BALL !' if random.random() < 0.5 else f'{player.name} hit the ball with all his might !', closest_player.color) + else: + self.write( + f'{player.name} {self.hit_text}', closest_player.color) + + # prettiness here + ppos = you + f = (bs.Vec3(ball.position) - bs.Vec3(you)).normalized() + closest_player.handlemessage( + 'kick_back', + ppos[0], + ppos[1], + ppos[2], + f[0], + f[1], + f[2], + 400, # small force + ) + # Ball goes straight up in the air. idk what it is called. + if closest_distance < 1.35 and (closest_player.pickup_pressed or pickup): + self.write( + f'{player.name} {self.control_text}', closest_player.color) + y_diff = abs(self._puck.node.position[1] - closest_player.position[1]) + + if y_diff < 1: + self.difficulty += 0.6 / 75 + self.last_interaction_time = bs.time() + self.curve = False + + self._puck.touches += 1 + + ball = self._puck.node + if self._puck.touches == 2: + spaz.percentage = min(spaz.percentage + 4, 100) + ball.color_texture = yellow + self.two_sound.play() + elif self._puck.touches == 3: + spaz.percentage = min(spaz.percentage + 6, 100) + ball.color_texture = red + self.one_sound.play() + else: + ball.color_texture = green + + closest_player.handlemessage('celebrate', 400) + bs.getsound("impactMedium").play() + e = bs.newnode( + 'explosion', + attrs={ + 'position': self._puck.node.position, + 'color': closest_player.color, + 'radius': 0.5, + 'big': True, + } + ) + bs.timer(1, e.delete) + + direction = (bs.Vec3(you) - + bs.Vec3(closest_player.position_forward)).normalized() + ball.velocity = (-you[0] * 0.04, 12.8, direction[2] * 0.1) + if closest_player.bomb_pressed and not delegate.gliding or bombed and not delegate.gliding: # gliding.. variant coming soon + self.write( + f'{player.name} {self.save_text}', closest_player.color) + delegate.gliding = True + ppos = you + f = (bs.Vec3(ball.position) - bs.Vec3(you)).normalized() + distance = math.dist(ball.position, you) + closest_player.handlemessage( + 'kick_back', + ppos[0], + ppos[1], + ppos[2], + f[0], + 0, + f[2], + 1000 - (500/distance), # tiny force + ) + closest_player.handlemessage('knockout', 100) + print(1) + spaz.pickup = now + 0.8 + + bs.getsound("impactHard").play(volume=0.5) + e = bs.newnode( + 'explosion', + attrs={ + 'position': you, + 'color': (0, 0.3, 0.5), + 'radius': 1.1, + 'big': False, + } + ) + bs.timer(1, e.delete) + bs.timer(0.6, lambda: setattr(delegate, 'gliding', False)) + + # variant soon + + def on_team_join(self, team: Team) -> None: + self._update_scoreboard() + + def _handle_puck_player_collide(self) -> None: + collision = bs.getcollision() + try: + puck = collision.sourcenode.getdelegate(Puck, True) + player = collision.opposingnode.getdelegate(PlayerSpaz, + True).getplayer( + Player, True) + except bs.NotFoundError: + return + + puck.last_players_to_touch[player.team.id] = player + + def _kill_puck(self) -> None: + self._puck = None + self.curve = True + + def _handle_score(self) -> None: + 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 + # to not be getting a corrupted score. + if self._puck.scored: + return + E = bs.newnode( + 'explosion', + attrs={ + 'position': self._puck.node.position, + 'color': (0, 0, 0), + 'radius': .1, + 'big': True, + } + ) + bs.timer(1, E.delete) + bs.animate(self.touches_indicator, 'scale', {0: 0, 2.65: 0, 3: 0.025}) + self.touches_indicator.text = '0' + if self.smashed > 0: + self._puck.node.sticky = True + self.smashed = -9999 + + region = bs.getcollision().sourcenode + 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.id == index: + team.score += 1 + + # Puck Spawn + if team.id == 0: # left side scored + self._puck_spawn_pos = (5, 0.7, 0) + elif team.id == 1: # right side scored + self._puck_spawn_pos = (-5, 0.7, 0) + else: # what the heck + self._puck_spawn_pos = (0, 0.7, 0) + + for player in team.players: + if player.actor: + player.actor.handlemessage(bs.CelebrateMessage(2.0)) # yay we scored + player.actor.percentage = min(player.actor.percentage + 15, 100) + # dont mind this ;( + scorch = bs.newnode( + 'scorch', + attrs={ + 'position': self._puck.node.position, + 'size': 1.4, + 'color': player.actor.node.color, + 'big': True, + }, + ) + bs.animate(scorch, 'presence', {3.000: 1, 7.000: 0}) + bs.timer(7, scorch.delete) + self.vignette(player.actor.node.color, longer=True) + + # End game if we won. + if team.score >= self._score_to_win: + self.end_game() + + self._cheer_sound.play() + + self._puck.scored = True + + # Kill the puck (it'll respawn itself shortly). + # Animation ! 0 sec = 0.25 of size in our case + bs.animate(self._puck.node, 'mesh_scale', {0: 0.25, 0.4: 0}) + bs.timer(0.7, self._kill_puck) + + bs.cameraflash(duration=7.0) + self._update_scoreboard() + + def end_game(self) -> None: + results = bs.GameResults() + for team in self.teams: + results.set_team_score(team, team.score) + self.interactions_timer = None + self.end(results=results) + + def on_transition_in(self) -> None: + super().on_transition_in() + + def _update_scoreboard(self) -> None: + winscore = self._score_to_win + for team in self.teams: + self._scoreboard.set_team_value(team, team.score, winscore) + + # overriding the default character spawning.. + def spawn_player(self, player: Player) -> bs.Actor: + spaz = self.spawn_player_spaz(player) + spaz.bomb_count = 0 # No bombs for yall bahaha + # Now we want to make that your punches are useless too + # But we will need the button to work so let's put the damage to 0 and some custom stuff + spaz._punch_power_scale = 0 + spaz.impact_scale = 0 + spaz.check_timer = 0 + + spaz.pickup = 0 + spaz.punched = 0 + spaz.bombed = 0 + spaz.curved = 0 + + spaz.can_curve = False + + spaz.last_angle = 0 + spaz.total_rotation = 0.0 + spaz.rotation_direction = 0 # 0: neutre, 1: clockwise, -1: not clockwise + + spaz.gliding = False + spaz.smashed = False + spaz.percentage = 0 + spaz._jump_cooldown = 2000 + spaz.name = spaz.node.name + spaz.bar = bs.newnode( + 'shield', + owner=spaz.node, + attrs={ + 'position': spaz.node.position, + 'radius': .01, + 'color': (0, 0, 0), + } + ) + spaz.node.connectattr('position', spaz.bar, 'position') + spaz.bar.always_show_health_bar = True + spaz.node.name = '' # bahaha but you'll sspazuseful + + def check(): + if spaz.node.exists(): + # Controls part + now = bs.time() + + if spaz.node.punch_pressed: + spaz.punched = now + 0.25 + elif spaz.node.pickup_pressed: + spaz.pickup = now + 0.40 + elif spaz.node.bomb_pressed: + spaz.bombed = now + 0.20 + elif spaz.node.jump_pressed and spaz.percentage > 95: + spaz.punched = now + 0.50 + + if spaz.percentage > 95: + spaz.node.name = 'SMASH' + else: + spaz.node.name = '' + + spaz.bar.hurt = min(1.0 - float(spaz.percentage) / 100, 1) + + # Curved ball part + + pos = spaz.node.position + fwd = spaz.node.position_forward + + dx = fwd[0] - pos[0] + dz = fwd[2] - pos[2] + + length = math.sqrt(dx * dx + dz * dz) + if length > 0.0001: + dx /= length + dz /= length + + spaz.angle = math.degrees(math.atan2(-dx, -dz)) # trust + + # checks if u turn ! Not in the game yet ! + def check_270_rotation(): + now = bs.time() + current_angle = spaz.angle + + delta = current_angle - spaz.last_angle + if delta > 180: + delta -= 360 + if delta < -180: + delta += 360 + + current_dir = 1 if delta > 0 else -1 + + if spaz.rotation_direction != 0 and current_dir != spaz.rotation_direction: + spaz.total_rotation = 0 + + spaz.rotation_direction = current_dir + + spaz.total_rotation += abs(delta) + + if spaz.total_rotation >= 250: + spaz.curved = now + 0.40 + spaz.total_rotation = 0 + + spaz.last_angle = current_angle + + spaz.check_timer1 = bs.Timer(1/30, check, repeat=True) + # spaz.check_timer2 = bs.Timer(1/30, check_270_rotation, repeat=True) + + # spaz.node.hold_node = spaz.node ... nevermind + + # By the way when you have a node but want his player just do + # player = node.getdelegate(object) + # i dont know why you'll need it but here it is + + return spaz + + def handlemessage(self, msg: Any) -> Any: + + # Respawn dead players if they're still in the game. + # Because if we don't.. they won't like our game ;( + if isinstance(msg, bs.PlayerDiedMessage): + # Augment standard behavior... + super().handlemessage(msg) + self.respawn_player(msg.getplayer(Player)) + + # Respawn dead pucks. + elif isinstance(msg, PuckDiedMessage): + if not self.has_ended(): + bs.timer(2, self._spawn_puck) # 2 secs to breathe bahahaha + else: + super().handlemessage(msg) + + def _spawn_puck(self) -> None: + self._swipsound.play() + self._whistle_sound.play() + assert self._puck_spawn_pos is not None + self._puck = Puck(position=self._puck_spawn_pos) + self.timer = bs.Timer(1 / 144, self.lock_it, repeat=True) # 144 ?.. yes + + def lock_it(self): + if self._puck is None: + return + if self.curve: + self._puck.node.position = self._puck_spawn_pos + else: + self._puck.node.shadow_size = min(self._puck.node.position[1] * 0.3, 2) + + +# ba_meta export babase.Plugin +class justlearn(babase.Plugin): + def __init__(self): + self.installer = ModInstaller() + self.installer.run_full_install() + + +class ModInstaller: + def __init__(self) -> None: + self.python_user = _babase.env()["python_directory_user"] + self.files = self.python_user + '/VolleyBallMap/' + self.app_dir = _babase.env()["python_directory_app"] + '/' + self.data_dir = self.app_dir + '../' + self.meshes_dir = self.data_dir + 'meshes/' + self.platform = _babase.app.classic.platform + + def run_full_install(self) -> None: + import os + import urllib.request + import shutil + import babase + + target_path = os.path.join(self.files, 'volleyball_net.bob') + destination_path = os.path.join(self.meshes_dir, 'volleyball_net.bob') + + if os.path.exists(destination_path): + return + + def _do_install(): + try: + if not os.path.exists(self.files): + os.makedirs(self.files) + + url = "https://raw.githubusercontent.com/Scriptz1/Learn.py-Installer/main/volleyball_net.bob" + urllib.request.urlretrieve(url, target_path) + shutil.copy(target_path, destination_path) + + bui.screenmessage("Installation worked !", color=(0, 1, 0.3)) + bui.getsound('ding').play() + + except Exception as e: + print(f"DEBUG ERROR: {e}") + bui.screenmessage("Installation Failed!", color=(1, 0, 0)) + bui.getsound('kronk2').play() + + bs.apptimer(2.5, _do_install) diff --git a/plugins/utilities.json b/plugins/utilities.json index 0c62bac..d9e3cd5 100644 --- a/plugins/utilities.json +++ b/plugins/utilities.json @@ -1054,6 +1054,18 @@ } ], "versions": { + "2.1.7": { + "api_version": 9, + "commit_sha": "c0527f3", + "released_on": "05-04-2026", + "md5sum": "442889926c17a0ffc70b924a808d8132" + }, + "2.1.6": { + "api_version": 9, + "commit_sha": "3aa666f", + "released_on": "05-04-2026", + "md5sum": "d2b66f98b5a24aa14eb6b1ed9de2d5dc" + }, "2.1.5": { "api_version": 9, "commit_sha": "a36e3c2", @@ -2375,6 +2387,12 @@ } ], "versions": { + "1.0.1": { + "api_version": 9, + "commit_sha": "023b733", + "released_on": "01-05-2026", + "md5sum": "045eb589b2969398f7fcb171216303d1" + }, "1.0.0": { "api_version": 9, "commit_sha": "f38a7ac", @@ -2384,7 +2402,7 @@ } }, "custom_hits": { - "description": "when someone hited rainbow text whit effect will appear", + "description": "when someone hited text whit random effect will appear", "external_url": "", "authors": [ { @@ -2394,6 +2412,12 @@ } ], "versions": { + "1.1.0": { + "api_version": 9, + "commit_sha": "023b733", + "released_on": "01-05-2026", + "md5sum": "bc39a552ab7f40a71ce2813f7860a62f" + }, "1.0.1": { "api_version": 9, "commit_sha": "b48a4bd", @@ -2426,6 +2450,139 @@ "md5sum": "d8002dfc93be74d71f8e6e4aacfe6fd1" } } + }, + "better_camera_shake": { + "description": "This plugin makes the camera only shake when an explosion hits a player character, begone excessive camera shaking!", + "external_url": "", + "authors": [ + { + "name": "DinoWattz", + "email": "", + "discord": "" + } + ], + "versions": { + "1.0.0": { + "api_version": 9, + "commit_sha": "e67d891", + "released_on": "23-02-2026", + "md5sum": "552b0ff0492bdab6cf69c1bc2d679e0a" + } + } + }, + "chat_bubbles": { + "description": "Adds whatever the players say above their character, includes a few other features too!", + "external_url": "", + "authors": [ + { + "name": "DinoWattz", + "email": "", + "discord": "" + } + ], + "versions": { + "1.0.0": { + "api_version": 9, + "commit_sha": "e67d891", + "released_on": "23-02-2026", + "md5sum": "307175b18b80671a7984862bcf09f1ce" + } + } + }, + "enhanced_effects": { + "description": "Explosions affect character colors, slow-mo tnt, fair colored shields, a trippy background screen and maybe more in the future!", + "external_url": "", + "authors": [ + { + "name": "DinoWattz", + "email": "", + "discord": "" + } + ], + "versions": { + "1.0.0": { + "api_version": 9, + "commit_sha": "e67d891", + "released_on": "23-02-2026", + "md5sum": "63c665905dd4619d3f99ca8977f8fbf3" + } + } + }, + "sleep_on_afk": { + "description": "Staying idle for 40 seconds will make your character fall asleep, they need rest too..", + "external_url": "", + "authors": [ + { + "name": "DinoWattz", + "email": "", + "discord": "" + } + ], + "versions": { + "1.0.0": { + "api_version": 9, + "commit_sha": "e67d891", + "released_on": "23-02-2026", + "md5sum": "5de7a474c3c6b21e8ea1e1abff3c07a1" + } + } + }, + "rank_system": { + "description": "ranks system for servers or for local players", + "external_url": "", + "authors": [ + { + "name": "ATD", + "email": "anasdhaoidi001@gmail.com", + "discord": "" + } + ], + "versions": { + "1.0.0": { + "api_version": 9, + "commit_sha": "023b733", + "released_on": "01-05-2026", + "md5sum": "487c0520dc833f15942941e0dbc82f1e" + } + } + }, + "glowing_profiles": { + "description": "This plugin gives your profile glowlight, just like on some servers, but only offline", + "external_url": "https://m.youtube.com/watch?v=Jb_dKz99rhY", + "authors": [ + { + "name": "andrejkuroglo8", + "email": "andrejkuroglo8@gmail.com", + "discord": "andrewku" + } + ], + "versions": { + "1.0.0": { + "api_version": 9, + "commit_sha": "6a494a4", + "released_on": "10-05-2026", + "md5sum": "6346cd7146baf41eef8926dced23fdfe" + } + } + }, + "fluffyplaylisteditor": { + "description": "A simple not-so advanced Playlist Editor", + "external_url": "https://discord.com/channels/1001896771347304639/1483463979450896445", + "authors": [ + { + "name": "FluffyPal", + "email": "", + "discord": "fluffypal" + } + ], + "versions": { + "1.1.0": { + "api_version": 9, + "commit_sha": "99cf837", + "released_on": "08-06-2026", + "md5sum": "3ead338c768fbdffe022aadcbdc8c1d8" + } + } } } } \ No newline at end of file diff --git a/plugins/utilities/advanced_party_window.py b/plugins/utilities/advanced_party_window.py index 6bdfd2e..aca17e7 100644 --- a/plugins/utilities/advanced_party_window.py +++ b/plugins/utilities/advanced_party_window.py @@ -19,41 +19,51 @@ https://bombsquad-community.web.app/mods ''' +import threading +from _thread import start_new_thread +import urllib.parse +import urllib.request +from typing import TYPE_CHECKING, cast +import _babase +import bascenev1 as bs +import bauiv1 as bui +import babase +import time +import math +from dataclasses import dataclass +from bauiv1lib.confirm import ConfirmWindow +from bauiv1lib.colorpicker import ColorPickerExact +from typing import List, Sequence, Optional, Dict, Any, Union +import bauiv1lib.party as bascenev1lib_party +import ssl +import datetime +import base64 +from babase._general import Call +from bauiv1lib.popup import PopupMenuWindow, PopupWindow +from bauiv1lib.account import viewer +import os +import urllib +import copy +import shutil +import sys +import re +import json +import codecs +import traceback +plugman = dict( + plugin_name="advanced_party_window", + description="Advanced your party window with lots of feature", + external_url="https://www.youtube.com/watch?v=QrES1jQGXF0", + authors=[ + {"name": "Mr.Smoothy", "email": "", "discord": "mr.smoothy"}, + ], + version="2.1.7", +) + # added advanced ID revealer # live ping # Made by Mr.Smoothy - Plasma Boson -import traceback -import codecs -import json -import re -import sys -import shutil -import copy -import urllib -import os -from bauiv1lib.account import viewer -from bauiv1lib.popup import PopupMenuWindow, PopupWindow -from babase._general import Call -import base64 -import datetime -import ssl -import bauiv1lib.party as bascenev1lib_party -from typing import List, Sequence, Optional, Dict, Any, Union -from bauiv1lib.colorpicker import ColorPickerExact -from bauiv1lib.confirm import ConfirmWindow -from dataclasses import dataclass -import math -import time -import babase -import bauiv1 as bui -import bascenev1 as bs -import _babase -from typing import TYPE_CHECKING, cast -import urllib.request -import urllib.parse -from _thread import start_new_thread -import threading version_str = "7" BCSSERVER = 'mods.ballistica.workers.dev' @@ -461,6 +471,7 @@ class ModifiedPartyWindow(bascenev1lib_party.PartyWindow): color=(0.55, 0.73, 0.25), icon=bui.gettexture('menuButton'), iconscale=1.2) + self._menu_popup: PopupMenuWindow | None = None info = bs.get_connection_to_host_info_2() if info != None: @@ -864,7 +875,7 @@ class ModifiedPartyWindow(bascenev1lib_party.PartyWindow): choices.append("hostInfo_Debug") DisChoices.append(_getTransText("Debug_for_Host_Info", isBaLstr=True)) - PopupMenuWindow( + self._menu_popup = PopupMenuWindow( position=self._menu_button.get_screen_space_center(), scale=_get_popup_window_scale(), choices=choices, @@ -891,12 +902,12 @@ class ModifiedPartyWindow(bascenev1lib_party.PartyWindow): except: babase.print_exception() - PopupMenuWindow(position=widget.get_screen_space_center(), - scale=_get_popup_window_scale(), - choices=choices, - choices_display=choices_display, - current_choice="@ this guy", - delegate=self) + self._menu_popup = PopupMenuWindow(position=widget.get_screen_space_center(), + scale=_get_popup_window_scale(), + choices=choices, + choices_display=choices_display, + current_choice="@ this guy", + delegate=self) self._popup_party_member_client_id = client_id self._popup_party_member_is_host = is_host self._popup_type = "partyMemberPress" diff --git a/plugins/utilities/better_camera_shake.py b/plugins/utilities/better_camera_shake.py new file mode 100644 index 0000000..d176f46 --- /dev/null +++ b/plugins/utilities/better_camera_shake.py @@ -0,0 +1,388 @@ +# ba_meta require api 9 +from __future__ import annotations + +import babase +import bascenev1 as bs +import bascenev1lib.actor.bomb as bomb + +import random +from typing import TYPE_CHECKING + +from bascenev1lib.gameutils import SharedObjects +from bascenev1lib.actor.bomb import BombFactory, ExplodeHitMessage +from bascenev1lib.actor.playerspaz import PlayerSpaz + +if TYPE_CHECKING: + from typing import Any, Sequence + +plugman = dict( + plugin_name="better_camera_shake", + description="This plugin makes the camera only shake when an explosion hits a player character, begone excessive camera shaking!", + external_url="", + authors=[ + {"name": "DinoWattz", "email": "", "discord": ""} + ], + version="1.0.0", +) + +# Camera shake only on player impact + + +def new__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: bs.Player | None = 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(type(self), self).__init__() + + shared = SharedObjects.get() + factory = BombFactory.get() + + 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, shared.attack_material) + self.node = bs.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, + }, + ) + + bs.timer(0.05, self.node.delete) + + # Throw in an explosion and flash. + evel = (velocity[0], max(-1.0, velocity[1]), velocity[2]) + explosion = bs.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) + + bs.timer(1.0, explosion.delete) + + if self.blast_type != 'ice': + bs.emitfx( + position=position, + velocity=velocity, + count=int(1.0 + random.random() * 4), + emit_type='tendrils', + tendril_type='thin_smoke', + ) + bs.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', + ) + bs.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: + bs.emitfx( + position=position, + velocity=velocity, + count=30, + spread=2.0, + scale=0.4, + chunk_type='ice', + emit_type='stickers', + ) + + # It looks better if we delay a bit. + bs.timer(0.05, emit) + + elif self.blast_type == 'sticky': + + def emit() -> None: + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + spread=0.7, + chunk_type='slime', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.5, + spread=0.7, + chunk_type='slime', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=15, + scale=0.6, + chunk_type='slime', + emit_type='stickers', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=20, + scale=0.7, + chunk_type='spark', + emit_type='stickers', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(6.0 + random.random() * 12), + scale=0.8, + spread=1.5, + chunk_type='spark', + ) + + # It looks better if we delay a bit. + bs.timer(0.05, emit) + + elif self.blast_type == 'impact': + + def emit() -> None: + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.8, + chunk_type='metal', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.4, + chunk_type='metal', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=20, + scale=0.7, + chunk_type='spark', + emit_type='stickers', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(8.0 + random.random() * 15), + scale=0.8, + spread=1.5, + chunk_type='spark', + ) + + # It looks better if we delay a bit. + bs.timer(0.05, emit) + + else: # Regular or land mine bomb shrapnel. + + def emit() -> None: + if self.blast_type != 'tnt': + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + chunk_type='rock', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.5, + chunk_type='rock', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=30, + scale=1.0 if self.blast_type == 'tnt' else 0.7, + chunk_type='spark', + emit_type='stickers', + ) + bs.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: + bs.emitfx( + position=position, + velocity=velocity, + count=int(20.0 + random.random() * 25), + scale=0.8, + spread=1.0, + chunk_type='splinter', + ) + + bs.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: + bs.emitfx( + position=position, + velocity=velocity, + count=int(10.0 + random.random() * 20), + scale=0.8, + spread=1.5, + chunk_type='spark', + ) + + bs.timer(0.02, emit_extra_sparks) + + # It looks better if we delay a bit. + bs.timer(0.05, emit) + + lcolor = (0.6, 0.6, 1.0) if self.blast_type == 'ice' else (1, 0.3, 0.1) + light = bs.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 + bs.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, + }, + ) + bs.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, + }, + ) + bs.timer(scl * 3.0, light.delete) + + # Make a scorch that fades over time. + scorch = bs.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) + + bs.animate(scorch, 'presence', {3.000: 1, 13.000: 0}) + bs.timer(13.0, scorch.delete) + + if self.blast_type == 'ice': + factory.hiss_sound.play(position=light.position) + + lpos = light.position + factory.random_explode_sound().play(position=lpos) + factory.debris_fall_sound.play(position=lpos) + + # ↓ We don't need any of this, we're fully trained professionals. + # bs.camerashake(intensity=5.0 if self.blast_type == 'tnt' else 1.0) + + # TNT is more epic. + if self.blast_type == 'tnt': + factory.random_explode_sound().play(position=lpos) + + def _extra_boom() -> None: + factory.random_explode_sound().play(position=lpos) + + bs.timer(0.25, _extra_boom) + + def _extra_debris_sound() -> None: + factory.debris_fall_sound.play(position=lpos) + factory.wood_debris_fall_sound.play(position=lpos) + + bs.timer(0.4, _extra_debris_sound) + + +bomb.Blast.__init__ = new__init__ + +org_handlemessage = bomb.Blast.handlemessage + + +def new_handlemessage(self, msg: Any, *args, **kwargs) -> Any: + org_handlemessage(self, msg, *args, **kwargs) + + if isinstance(msg, ExplodeHitMessage): + node = bs.getcollision().opposingnode + delegate = node.getdelegate(PlayerSpaz) + has_used_camerashake = getattr(self, 'has_used_camerashake', False) + if not has_used_camerashake and (delegate and not delegate.shield) and not node.invincible: + self.has_used_camerashake = True + bs.camerashake(intensity=5.0 if self.blast_type == 'tnt' else 1.0) + + +bomb.Blast.handlemessage = new_handlemessage + +# ba_meta export babase.Plugin + + +class Plugin(babase.Plugin): + pass diff --git a/plugins/utilities/chat_bubbles.py b/plugins/utilities/chat_bubbles.py new file mode 100644 index 0000000..af7eb31 --- /dev/null +++ b/plugins/utilities/chat_bubbles.py @@ -0,0 +1,599 @@ +# ba_meta require api 9 +# This plugin only works with BombSquad 1.7.49+ +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +import babase +import bascenev1 as bs +import random +import string +import unicodedata + +if TYPE_CHECKING: + from typing import Any + +plugman = dict( + plugin_name="chat_bubbles", + description="Adds whatever the players say above their character, includes a few other features too!", + external_url="", + authors=[ + {"name": "DinoWattz", "email": "", "discord": ""} + ], + version="1.0.0", +) + +# Prints chat messages to the console as "[Character's Name] Player 1/Player 2: Hello World!" +PRINT_CHAT_MESSAGES = True +BUBBLE_DURATION: float = 4.7 # 4.7 matches the default onscreen message duration +SPEAK_VOLUME = 1.5 +SHOUT_VOLUME = 3.0 + +CELEBRATE_MESSAGE = [(bs.CelebrateMessage, {"duration": 1.0})] + +# Easter egg triggers, sounds, and messages flag +# If adding new ones, make sure to test them in-game +EASTER_EGGS = [ + { + "triggers": { + "hi", "hello", "hey", "hiya", "yo", "sup", "heya", "howdy", + "greetings", "salutations", "ahoy", "ahoyhoy", "bonjour", + "hola", "ciao", "ola", "oi", "eae", "salve", + "bye", "goodbye", "see ya", "see you", "cya", + "adios", "chau", "chao", "tchau", + "adeus", "ate mais", "falou", "flw" + }, + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"aaa"}, + "sound": "spazFall01", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"aaaa"}, + "sound": "zoeFall01", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"aaaaa"}, + "sound": "kronkFall", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"potato"}, + "sound": "kronk2", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"dau", "tau", "d'oh", "doh"}, + "sound": "kronk3", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"drau", "trau", "d'roh", "droh"}, + "sound": "bunny2", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"hahaha", "hahahaha", + "hahahah", "hahahahah", "lol", + "nahahah", "kakaka", "kakakaka", "kkk", "kkkk", + "jajaja", "jajajaja", "jajajajaa"}, + "sound": "mel05", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"ha", "hah", "ja", "ka"}, + "sound": "mel06", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"merry christmas", "merry krismas", "merry xmas", "feliz natal", "feliz navidad"}, + "sound": "santa02", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"hohoho", "ho ho ho", "ho-ho-ho"}, + "sound": "santa05", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"gg", "good game", "que pro", "q pro"}, + "sound": "achievement", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"boo", "bad game", "que noob", "q noob"}, + "sound": "boo", + "messages": CELEBRATE_MESSAGE, + }, + # ↓ Node messages are like this ↓ + { + "triggers": {"0", "zero"}, + "sound": "boxingBell", + "node_messages": [("celebrate_l", 1000)], + }, + { + "triggers": {"1", "one"}, + "sound": "announceOne", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"2", "two"}, + "sound": "announceTwo", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"3", "three"}, + "sound": "announceThree", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"4", "four"}, + "sound": "announceFour", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"5", "five"}, + "sound": "announceFive", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"6", "six"}, + "sound": "announceSix", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"7", "seven"}, + "sound": "announceSeven", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"8", "eight"}, + "sound": "announceEight", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"9", "nine"}, + "sound": "announceNine", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"10", "ten"}, + "sound": "announceTen", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"xd"}, + "sound": "santaDeath", + "node_messages": [("knockout", 100)], + }, + # ↓ Easter eggs without actor/node messages ↓ + { + "triggers": {"huh", "huhh", "huhhh", "huhhhh", "humph", "hmph", "hum", "humm"}, + "sound": "agent1", + }, + { + "triggers": {"hoh", "hohh", "hohhh", "hohhhh", "ohh"}, + "sound": "agent3", + }, + { + "triggers": {"eff"}, + "sound": "zoeEff", + }, + { + "triggers": {"ow", "oww", "oow"}, + "sound": "zoeOw", + }, + { + "triggers": {"pipipi", "bibibi"}, + "sound": "mel07", + }, + { + "triggers": {"oh yeah", "oh yea", "oohh yeah", "oohh yea"}, + "sound": "yeah", + }, + { + "triggers": {"woo"}, + "sound": "woo2", + }, + { + "triggers": {"wooo"}, + "sound": "woo", + }, + { + "triggers": {"woo yeah", "woo yea"}, + "sound": "woo3", + }, + { + "triggers": {"ooh"}, + "sound": "ooh", + }, + { + "triggers": {"wow"}, + "sound": "wow", + }, + { + "triggers": {"gasp"}, + "sound": "gasp", + }, + { + "triggers": {"ahh", "aah"}, + "sound": "aww", + }, + { + "triggers": {"nice"}, + "sound": "nice", + }, + # ↑ Default easter egg list ends here ↑ +] + +# Chat bubble functionality + + +def create_bubble(player: bs.SessionPlayer, text: str, name: str): + msg = normalize_message(text) + is_caps = len(text) > 1 and text.isupper() + handled = False + bubble_created = False + + activityplayer: bs.Player | None = getattr(player, 'activityplayer', None) + + def _fallback_logic(): + nonlocal handled, activityplayer + color = (1, 1, 0.4) + image_icon = {'texture': bs.gettexture("cuteSpaz"), 'tint_texture': bs.gettexture( + "cuteSpaz"), 'tint_color': (1.0, 1.0, 1.0), 'tint2_color': (1.0, 1.0, 1.0)} + + if activityplayer: + normal_highlight = bs.normalized_color( + bs.safecolor(player.highlight, target_intensity=1.15)) + saturated_highlight = bs.normalized_color( + bs.safecolor(player.highlight, target_intensity=0.75)) + color = saturated_highlight if is_caps else normal_highlight + image_icon = activityplayer.get_icon() + + bs.broadcastmessage(f"{name}: {text}", + color=color, + top=True, + image=image_icon) + + for egg in EASTER_EGGS: + if msg in egg["triggers"]: + if egg.get("sound"): + sound = bs.getsound(egg["sound"]) + sound.play(SHOUT_VOLUME if is_caps else SPEAK_VOLUME) + handled = True + break + if not handled and activityplayer: + assert bs.app.classic is not None + handled = True + character = activityplayer.character + char = bs.app.classic.spaz_appearances[character] + if is_caps: + attack_sounds = [bs.getsound(s) for s in char.attack_sounds] + if attack_sounds and isinstance(attack_sounds, (list, tuple)) and len(attack_sounds) > 0: + sound = random.choice(attack_sounds) + sound.play(SHOUT_VOLUME) + else: + pickup_sounds = [bs.getsound(s) for s in char.pickup_sounds] + if pickup_sounds and isinstance(pickup_sounds, (list, tuple)) and len(pickup_sounds) > 0: + sound = random.choice(pickup_sounds) + sound.play(SPEAK_VOLUME) + + if activityplayer is None: + # No valid activityplayer: play sound if possible (lobby/non-in-game) + _fallback_logic() + return + + bubbles = activityplayer.customdata.setdefault('chat_bubbles', []) + actor_list = [] + + # Support for both actor and ghost_actor (from the Ghost Players plugin) + if getattr(activityplayer, 'actor', None): + actor_list.append(activityplayer.actor) + if hasattr(activityplayer, 'customdata') and activityplayer.customdata.get('ghost_actor'): + actor_list.append(activityplayer.customdata['ghost_actor']) + + if actor_list: + for actor in actor_list: + if actor and actor.is_alive() and actor.node: + assert actor is not None + char_node = actor.node + activity = actor.getactivity() + if activity is None or getattr(activity, 'expired', False): + continue + + with activity.context: + # Calculate scale based on text length + base_scale = 0.015 + min_scale = 0.009 + scale = max(base_scale - 0.0003 * max(len(text) - 20, 0), min_scale) + y_offset = 1.2 + 0.25 * len(bubbles) + + mnode = bs.newnode('math', owner=char_node, attrs={ + 'input1': (0, y_offset, 0), + 'operation': 'add' + }) + char_node.connectattr('torso_position', mnode, 'input2') + + normal_highlight = bs.normalized_color( + bs.safecolor(player.highlight, target_intensity=1.15)) + saturated_highlight = bs.normalized_color( + bs.safecolor(player.highlight, target_intensity=0.75)) + bubble_color = saturated_highlight if is_caps else normal_highlight + + textnode = bs.newnode('text', + owner=char_node, + attrs={ + 'text': text, + 'color': bubble_color, + 'shadow': 0.5, + 'flatness': 0.5, + 'scale': scale, + 'h_align': 'center', + 'v_align': 'bottom', + 'in_world': True, + 'opacity': 1.0, + }) + + mnode.connectattr('output', textnode, 'position') + + focus = Plugin.Focus(owner=textnode).autoretain() + textnode.connectattr('position', focus.node, 'position') + + # Adjust bubble duration for slow-motion activities + bubble_duration = BUBBLE_DURATION + if hasattr(activity, 'slow_motion') and activity.slow_motion: + bubble_duration = max(0.5, bubble_duration / 3) + + # Play sound for all triggers (easter eggs and normal) + def _play_chat_sound(actor, sound, volume): + if actor.is_alive(): + actor._safe_play_sound(sound, volume) + else: + sound.play(volume) + + # Easter egg sound/message + for egg in EASTER_EGGS: + if msg in egg['triggers']: + # Sound selection logic + sound = None + if egg.get('sound'): + sound = bs.getsound(egg['sound']) + else: + # Default to character sounds if no specific sound is set + if is_caps: + attack_sounds = getattr(char_node, 'attack_sounds', None) + if attack_sounds and isinstance(attack_sounds, (list, tuple)) and len(attack_sounds) > 0: + sound = random.choice(attack_sounds) + else: + pickup_sounds = getattr(char_node, 'pickup_sounds', None) + if pickup_sounds and isinstance(pickup_sounds, (list, tuple)) and len(pickup_sounds) > 0: + sound = random.choice(pickup_sounds) + + if sound: + _play_chat_sound( + actor, sound, SHOUT_VOLUME if is_caps else SPEAK_VOLUME) + + # Animation logic + if egg.get('messages') and actor.is_alive() and hasattr(actor, 'handlemessage'): + for msg_class, msg_kwargs in egg.get('messages', []): + msg_kwargs = msg_kwargs or {} + actor.handlemessage(msg_class(**msg_kwargs)) + + if egg.get('node_messages') and actor.is_alive() and actor.node.exists(): + for node_args in egg.get('node_messages', []): + actor.node.handlemessage(*node_args) + + handled = True + break + + if not handled: + if is_caps: + attack_sounds = getattr(char_node, 'attack_sounds', None) + if attack_sounds and isinstance(attack_sounds, (list, tuple)) and len(attack_sounds) > 0: + sound = random.choice(attack_sounds) + _play_chat_sound(actor, sound, SHOUT_VOLUME) + if actor.is_alive() and hasattr(actor, 'handlemessage'): + actor.handlemessage(bs.CelebrateMessage(duration=1.0)) + else: + pickup_sounds = getattr(char_node, 'pickup_sounds', None) + if pickup_sounds and isinstance(pickup_sounds, (list, tuple)) and len(pickup_sounds) > 0: + sound = random.choice(pickup_sounds) + _play_chat_sound(actor, sound, SPEAK_VOLUME) + + bubbles.append((textnode, mnode)) + bubble_created = True + while len(bubbles) > 3: + old_textnode, old_mnode = bubbles.pop(0) + if old_textnode is not None and hasattr(old_textnode, 'exists') and old_textnode.exists(): + old_textnode.delete() + if old_mnode is not None and hasattr(old_mnode, 'exists') and old_mnode.exists(): + old_mnode.delete() + update_bubble_offsets(bubbles) + + fade_time = max(0.1, bubble_duration - 0.5) + bs.animate(textnode, 'opacity', {fade_time: 1.0, bubble_duration: 0.0}) + bs.timer(bubble_duration, textnode.delete) + bs.timer(bubble_duration, mnode.delete) + + def cleanup(): + if hasattr(activityplayer, 'customdata'): + if 'chat_bubbles' in activityplayer.customdata: + activityplayer.customdata['chat_bubbles'] = [ + b for b in activityplayer.customdata['chat_bubbles'] if b[0] != textnode + ] + bs.timer(bubble_duration, cleanup) + + if not bubble_created: + # No valid actor found: play sound if possible + _fallback_logic() + return + + +def update_bubble_offsets(bubbles): + for i, (tnode, mnode) in enumerate(reversed(bubbles)): + y_offset = 1.2 + 0.25 * i + if mnode is not None and hasattr(mnode, 'exists') and mnode.exists(): + mnode.input1 = (0, y_offset, 0) + +# Tools + + +def normalize_message(text): + msg = text.strip().lower() + msg = msg.translate(str.maketrans('', '', string.punctuation)) + msg = ''.join( + c for c in unicodedata.normalize('NFD', msg) + if unicodedata.category(c) != 'Mn' + ) + + return msg + +# ba_meta export babase.Plugin + + +class Plugin(babase.Plugin): + def on_app_running(self) -> None: + # Hook into chat message filtering + # We're delaying until the app runs and using a timer so it's more likely to work with other chat plugins (please don't make a chat plugin/filter that is delayed like this). + bs.apptimer(0.001, bs.WeakCallStrict(self.apply_patch)) + + def apply_patch(self): + import bascenev1._hooks as _hooks + + _org_filter_chat_message = _hooks.filter_chat_message + + def _chat_bubbles_hook(msg: str, client_id: int, *args, **kwargs) -> str | None: + org_msg = _org_filter_chat_message(msg, client_id, *args, **kwargs) + + if org_msg is None: + # The original message has been ignored, so we should ignore it as well + return org_msg + + # Chat bubble sorcery + try: + roster = bs.get_game_roster() + session = bs.get_foreground_host_session() + if session is None: + # Probably couldn't find a session because we are a client + return org_msg + sessionplayers = session.sessionplayers + + player_list = [] + matched = False + dead_bubble = False + character: str | None = None + + with session.context: + for player in sessionplayers: + if player.inputdevice.client_id == client_id and player.in_game: + player_list.append(player) + continue + if player_list: + # Get combined name for multiple players on same client + name = '' + created_bubble = False + + for player in player_list: + if name == '': + name = player.getname() + else: + name = name + '/' + player.getname() + + # Create chat bubble + for player in player_list: + activityplayer: bs.Player = player.activityplayer + # We have a session player but not an activity player + if not activityplayer: + if not dead_bubble: + create_bubble(player, org_msg, name) + created_bubble = dead_bubble = True + continue + # We have a dead activity player + if not activityplayer.is_alive() and not dead_bubble: + create_bubble(player, org_msg, name) + created_bubble = dead_bubble = True + character = activityplayer.character if character is None else character + # elif we have an alive activity player + elif activityplayer.is_alive(): + create_bubble(player, org_msg, name) + created_bubble = True + character = activityplayer.character if character is None else character + + if created_bubble: + matched = True + if PRINT_CHAT_MESSAGES: + print(f"[{character}] {name}: {org_msg}") + else: + bs.logging.warning( + "Failed to create a chat bubble, using non-session player method now.") + # Non-session players (in lobby or just spectating, this only works for servers) + if not matched and not dead_bubble: + for client in roster: + if client['client_id'] == client_id: + name = client.get('display_string') + create_bubble(None, org_msg, name) + + matched = True + if PRINT_CHAT_MESSAGES: + print(f"[{character}] {name}: {org_msg}") + break + except Exception as e: + bs.logging.exception(f"Error in chat bubble hook: {e}") + + return org_msg + + _hooks.filter_chat_message = _chat_bubbles_hook + bs.reload_hooks() + + print("[ChatBubbles] Plugin initialized.") + + class Focus(bs.Actor): + def __init__( + self, + *, + owner: bs.Node | None = None, + ): + super().__init__() + + self._focusnode_material = bs.Material() + self._focusnode_material.add_actions( + actions=('modify_node_collision', 'collide', False), + ) + + if getattr(owner, 'owner', None): + assert owner is not None + area_of_interest = False if getattr( + owner.owner, 'is_area_of_interest', False) else True + else: + area_of_interest = False if getattr(owner, 'is_area_of_interest', False) else True + + # I'm tired of this area_of_interest nonsense, it never works right + # area_of_interest = False + # actually it might work now that we don't spawn bubbles for dead players. + + self.node = bs.newnode( + 'prop', + delegate=self, + owner=owner, + attrs={ + 'body': 'sphere', + 'body_scale': 0.0, + 'shadow_size': 0.0, + 'gravity_scale': 0.0, + 'is_area_of_interest': area_of_interest, + 'materials': (self._focusnode_material,), + }, + ) + + @override + def handlemessage(self, msg: Any) -> Any: + assert not self.expired + if isinstance(msg, bs.DieMessage): + if self.node: + self.node.delete() + else: + super().handlemessage(msg) diff --git a/plugins/utilities/custom_hits.py b/plugins/utilities/custom_hits.py index afa7730..e6d3e78 100644 --- a/plugins/utilities/custom_hits.py +++ b/plugins/utilities/custom_hits.py @@ -1,7 +1,6 @@ # ba_meta require api 9 from __future__ import annotations - from typing import TYPE_CHECKING import babase @@ -14,136 +13,81 @@ if TYPE_CHECKING: plugman = dict( plugin_name="custom_hits", - description="when someone hited rainbow text whit effect will appear", + description="when someone hited text whit random effect will appear", external_url="", authors=[ {"name": "ATD", "email": "anasdhaoidi001@gmail.com", "discord": ""}, ], - version="1.0.1", + version="1.1.0", ) +# ___________________________________________________ -#################################### - -first_damage = (u'\ue042TRY AGAIN?\ue042', (0, 1, 0), '6') -second_damage = (u'\ue048GOOD!\ue048', (0, 1, 1), '2') -third_damage = (u'\ue041NICE!\ue041', (0.8, 0.4, 1), '3') -fourth_damage = (u'\ue049UPPS\ue049', (1, 1, 0), '4') -five_damage = (u'\ue043DEATH!\ue043', (1, 0, 0), '5') +first_damage = (u'NAH NOOB?', (1, 1, 1), '6') +second_damage = (u'GOOD!', (0, 1, 0), '2') +third_damage = (u'DAMN!', (0, 0, 0), '3') +fourth_damage = (u'SRY NOOB', (1, 1, 0), '4') +five_damage = (u'FAH!!!', (1, 0, 0), '5') def custom_effects(pos: float, effect: str = None) -> None: if effect == '1': - bs.emitfx( - position=pos, - count=3, - scale=0.1, - spread=0.1, - chunk_type='rock') + bs.emitfx(position=pos, count=3, scale=0.1, spread=0.1, chunk_type='rock') elif effect == '2': - bs.emitfx( - position=pos, - count=70, - scale=2, - spread=0.8, - chunk_type='spark') + bs.emitfx(position=pos, count=70, scale=2, spread=0.8, chunk_type='spark') elif effect == '3': - bs.emitfx( - position=pos, - count=6, - scale=0.3, - spread=0.4, - chunk_type='splinter') + bs.emitfx(position=pos, count=6, scale=0.3, spread=0.4, chunk_type='splinter') elif effect == '4': - bs.emitfx( - position=pos, - count=50, - scale=3.0, - spread=0.9, - chunk_type='ice') + bs.emitfx(position=pos, count=50, scale=3.0, spread=0.9, chunk_type='ice') elif effect == '5': - bs.emitfx( - position=pos, - count=80, - scale=3.0, - spread=1.5, - chunk_type='metal') + bs.emitfx(position=pos, count=80, scale=3.0, spread=1.5, chunk_type='metal') elif effect == '6': - bs.emitfx( - position=pos, - count=30, - scale=1.2, - spread=0.8, - chunk_type='slime') + bs.emitfx(position=pos, count=30, scale=1.2, spread=0.8, chunk_type='slime') + + +def on_punched(self, damage: int) -> None: + pos = self.node.position + + def custom_text(msg: str, color: float) -> None: + text = bs.newnode('text', attrs={ + 'text': msg, + 'color': color, + 'in_world': True, + 'h_align': 'center', + 'shadow': 0.5, + 'flatness': 1.0, + }) + bs.animate_array(text, 'position', 3, { + 0.0: (pos[0], pos[1] + 1.2, pos[2]), + 2.0: (pos[0], pos[1] + 1.7, pos[2]), + }) + bs.animate(text, 'opacity', {0.8: 1.0, 2.0: 0.0}) + bs.animate(text, 'scale', {0: 0, 0.1: 0.017, 0.15: 0.014, 2.0: 0.016}) + bs.timer(2.0, text.delete) + + if damage < 200: + custom_text(first_damage[0], first_damage[1]) + custom_effects(pos, first_damage[2]) + elif damage < 500: + custom_text(second_damage[0], second_damage[1]) + custom_effects(pos, second_damage[2]) + elif damage < 800: + custom_text(third_damage[0], third_damage[1]) + custom_effects(pos, third_damage[2]) + elif damage < 1000: + custom_text(fourth_damage[0], fourth_damage[1]) + custom_effects(pos, fourth_damage[2]) + else: + custom_text(five_damage[0], five_damage[1]) + custom_effects(pos, five_damage[2]) -#################################### # ba_meta export babase.Plugin - - -class CustomHitsPlugin(babase.Plugin): - - def on_punched(self, damage: int) -> None: - pos = self.node.position - - def custom_text(msg: str, color: float) -> None: - text = bs.newnode( - 'text', - attrs={ - 'text': msg, - 'color': color, - 'in_world': True, - 'h_align': 'center', - 'shadow': 0.5, - 'flatness': 1.0}) - bs.animate_array(text, 'position', 3, { - 0.0: (pos[0], pos[1] + 1.2, pos[2]), - 2.0: (pos[0], pos[1] + 1.7, pos[2]) - }) - bs.animate(text, 'opacity', { - 0.8: 1.0, - 2.0: 0.0 - }) - bs.animate(text, 'scale', { - 0: 0, - 0.1: 0.017, - 0.15: 0.014, - 2.0: 0.016 - }) - bs.animate_array(text, 'color', 3, { - 0.0: (0, 0, 0), # Black - 0.2: (2.55, 2.55, 2.55), # White - 0.4: (2, 0, 0), # Red - 0.6: (0, 2.55, 0), # Lime - 0.8: (0, 0, 2.55), # Blue - 1.0: (2.55, 2.55, 0), # Yellow - 1.2: (0, 2.55, 2.55), # Cyan / Aqua - 1.4: (2.55, 0, 2.55), # Magenta / Fuchsia - 1.6: (1.92, 1.92, 1.92), # Silver - 1.8: (1.28, 1.28, 1.28), # Gray - 2.0: (1.28, 0, 0), # Maroon - 2.2: (1.28, 1.28, 0), # Olive - 2.4: (0, 1.28, 0), # Green - 2.6: (1.28, 0, 1.28), # Purple - 2.8: (0, 1.28, 1.28), # Teal - 3.0: (0, 0, 1.28), # Navy - 3.2: (1.5, 0.5, 0), # Orange - 3.4: (1.8, 0.5, 1.6), # Pink - 3.6: (0.5, 0.5, 0.5), # Gray - }, loop=True) - bs.timer(2.0, text.delete) - if damage < 200: - custom_text(first_damage[0], first_damage[1]) - custom_effects(pos, first_damage[2]) - elif damage < 500: - custom_text(second_damage[0], second_damage[1]) - custom_effects(pos, second_damage[2]) - elif damage < 800: - custom_text(third_damage[0], third_damage[1]) - custom_effects(pos, third_damage[2]) - elif damage < 1000: - custom_text(fourth_damage[0], fourth_damage[1]) - custom_effects(pos, fourth_damage[2]) - else: - custom_text(five_damage[0], five_damage[1]) - custom_effects(pos, five_damage[2]) +class byATD(babase.Plugin): + """تفعيل ATD Hits — يُستدعى من bootstraping().""" Spaz.on_punched = on_punched + try: + from bascenev1lib.actor.spazbot import SpazBot + SpazBot.on_punched = on_punched + except Exception: + pass + bs.broadcastmessage(u' || WELCOME || ', color=(0, 1, 0)) diff --git a/plugins/utilities/enhanced_effects.py b/plugins/utilities/enhanced_effects.py new file mode 100644 index 0000000..d78f561 --- /dev/null +++ b/plugins/utilities/enhanced_effects.py @@ -0,0 +1,305 @@ +# ba_meta require api 9 +from __future__ import annotations + +from typing import TYPE_CHECKING + +import random +import weakref +import babase +import bascenev1 as bs + +from bascenev1lib.actor.background import Background + +if TYPE_CHECKING: + from typing import Any + +plugman = dict( + plugin_name="enhanced_effects", + description="Explosions affect character colors, slow-mo tnt, fair colored shields, a trippy background screen and maybe more in the future!", + external_url="", + authors=[ + {"name": "DinoWattz", "email": "", "discord": ""} + ], + version="1.0.0", +) + +# Transparent Background (kinda hacky, works best on pc with high/higher quality 'visuals' setting) +BACKGROUND_OPACITY = 0.5 + + +def __modified_background_init( + self, + fade_time: float = 0.5, + start_faded: bool = False, + show_logo: bool = False, +): + super(type(self), self).__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 = bs.getsession() + self._session = weakref.ref(session) + with session.context: + self.node = bs.newnode( + 'image', + delegate=self, + attrs={ + 'fill_screen': True, + 'texture': bs.gettexture('bg'), + 'tilt_translate': -0.3, + 'has_alpha_channel': False, + 'color': (1, 1, 1), + 'opacity': BACKGROUND_OPACITY, + }, + ) + if not start_faded: + bs.animate( + self.node, + 'opacity', + {0.0: 0.0, self.fade_time: BACKGROUND_OPACITY}, + loop=False, + ) + if show_logo: + logo_texture = bs.gettexture('logo') + logo_mesh = bs.getmesh('logo') + logo_mesh_transparent = bs.getmesh('logoTransparent') + self.logo = bs.newnode( + 'image', + owner=self.node, + attrs={ + 'texture': logo_texture, + 'mesh_opaque': logo_mesh, + 'mesh_transparent': logo_mesh_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 bs.app.env.vr: + self.cmb = bs.newnode( + 'combine', owner=self.node, attrs={'size': 2} + ) + for attr in ['input0', 'input1']: + bs.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 = bs.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 + bs.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 + bs.animate(cmb, 'input1', keys, loop=True) + + +def _modified_background_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) + bs.logging.exception( + 'got None session on Background _die' + ' (and node still exists!)' + ) + elif session is not None: + with session.context: + if not self._dying and self.node: + self._dying = True + if immediate: + self.node.delete() + else: + bs.animate( + self.node, + 'opacity', + {0.0: self.node.opacity, self.fade_time: 0.0}, + loop=False, + ) + bs.timer(self.fade_time + 0.1, self.node.delete) + + +Background.__init__ = __modified_background_init +Background._die = _modified_background_die + +# Tools + + +def blend_toward( + rgb: tuple[float, float, float], + target: tuple[float, float, float], + amount: float = 1.0, + perceptual: bool = False +) -> tuple[float, ...]: + """ + Blend 'rgb' toward 'target' by 'amount' (0..1). + + If perceptual=True, blend in linear-light space for smoother, more + natural-looking fades. + """ + def clamp01(x: float) -> float: + return max(0.0, min(1.0, x)) + + def srgb_to_linear(c: float) -> float: + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + def linear_to_srgb(c: float) -> float: + return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055 + + a = clamp01(amount) + + if not perceptual: + return tuple(clamp01(c + (t - c) * a) for c, t in zip(rgb, target)) + + # Gamma-aware blend + rgb_lin = [srgb_to_linear(clamp01(c)) for c in rgb] + target_lin = [srgb_to_linear(clamp01(c)) for c in target] + blended_lin = [ + c + (t - c) * a for c, t in zip(rgb_lin, target_lin) + ] + return tuple(clamp01(linear_to_srgb(c)) for c in blended_lin) + +# ba_meta export babase.Plugin + + +class Plugin(babase.Plugin): + def on_app_running(self) -> None: + from bascenev1lib.actor.bomb import Blast, ExplodeHitMessage + from bascenev1lib.actor.spaz import Spaz + from bascenev1lib.actor.spazbot import SpazBot + from bascenev1lib.actor.playerspaz import PlayerSpaz + + # Colored Shields + org_equip_shields = Spaz.equip_shields + + def equip_shields(self, decay: bool = False) -> None: + org_equip_shields(self, decay) + if self.shield is not None: + safe_highlight = bs.safecolor( + getattr(self, '_original_highlight', self.node.highlight), target_intensity=0.75) + self.shield.color = bs.normalized_color(safe_highlight) + + Spaz.equip_shields = equip_shields + + # Slow Motion TNT Explosion + org_blast_init = Blast.__init__ + + def new_init(self: Blast, blast_type='normal', hit_type='explosion', *args, **kwargs) -> None: + org_blast_init(self, blast_type=blast_type, hit_type=hit_type, *args, **kwargs) + + if blast_type == 'tnt' and hit_type == 'explosion': + bs.camerashake(3.0) + activity = bs.getactivity() + gnode = activity.globalsnode + + # Pause + gnode.paused = True + self._pause_timer = bs.DisplayTimer( + 0.01, bs.CallPartial(setattr, gnode, 'paused', True), True) + + delay = 0.06 + + bs.displaytimer(delay, bs.CallPartial(setattr, self, '_pause_timer', None)) + bs.displaytimer(delay, bs.CallPartial(setattr, gnode, 'paused', False)) + + # Slow Motion + gnode.slow_motion = True + self._slow_motion_timer = bs.DisplayTimer( + 0.01, bs.CallPartial(setattr, gnode, 'slow_motion', True), True) + + delay += 0.14 + + bs.displaytimer(delay, bs.CallPartial(setattr, self, '_slow_motion_timer', None)) + bs.displaytimer(delay, bs.CallPartial( + setattr, gnode, 'slow_motion', activity.slow_motion)) + + Blast.__init__ = new_init + + # Explosion Spaz Coloring + org_handlemessage = Blast.handlemessage + + def new_handlemessage(self: Blast, msg: Any, *args, **kwargs) -> Any: + org_handlemessage(self, msg, *args, **kwargs) + + if isinstance(msg, ExplodeHitMessage): + node = bs.getcollision().opposingnode + delegate = node.getdelegate(PlayerSpaz) or node.getdelegate(SpazBot) + if (delegate and not delegate.shield) and not node.invincible: + if not hasattr(delegate, '_original_color'): + delegate._original_color = node.color + delegate._original_highlight = node.highlight + + original_color = delegate._original_color + original_highlight = delegate._original_highlight + + blend_time = 1.0 + start_time = 6.0 + end_time = 7.0 + + bs.emitfx( + position=node.position, + velocity=node.velocity, + count=int(4.0 + random.random() * 4), + emit_type='tendrils', + tendril_type='ice' if self.blast_type == 'ice' else 'smoke', + ) + + explosion_intensity = 0.70 if not self.blast_type == 'tnt' else 0.96 + + if self.blast_type == 'ice': + explosion_intensity = 0.91 + explosion_color = (0.07, 0.6, 1.5) + elif self.blast_type == 'sticky': + explosion_intensity = 0.91 + explosion_color = (0.07, 0.9, 0.07) + blend_time = 1.0 + start_time = 1.2 + end_time = 3.0 + else: + explosion_color = (0.07, 0.03, 0.0) + + blend_color = blend_toward(node.color, explosion_color, + explosion_intensity, True) + blend_highlight = blend_toward( + node.highlight, explosion_color, explosion_intensity-0.03, True) + + bs.animate_array(node, 'color', 3, { + blend_time: blend_color, + start_time: blend_color, + end_time: original_color + }) + bs.animate_array(node, 'highlight', 3, { + blend_time: blend_highlight, + start_time: blend_highlight, + end_time: original_highlight + }) + + delegate._color_timer = bs.Timer( + end_time, bs.CallPartial(delattr, delegate, '_original_color')) + delegate._highlight_timer = bs.Timer( + end_time, bs.CallPartial(delattr, delegate, '_original_highlight')) + + Blast.handlemessage = new_handlemessage diff --git a/plugins/utilities/fluffyplaylisteditor.py b/plugins/utilities/fluffyplaylisteditor.py new file mode 100644 index 0000000..6f81fc8 --- /dev/null +++ b/plugins/utilities/fluffyplaylisteditor.py @@ -0,0 +1,2122 @@ +# ba_meta require api 9 + +from bascenev1._playlist import filter_playlist # , PlaylistType +from random import choice # , randint, randrange +from bascenev1 import ( + get_filtered_map_name, + get_map_class, + get_map_display_string, +) +from typing import Callable, Sequence, Literal, TypedDict, Any, cast +from bauiv1lib.confirm import ConfirmWindow +from bauiv1 import ( + # Window utils + Window, MainWindowState, + MainWindow, + UIScale, + + # Widget utils + Widget, widget, + containerwidget, + scrollwidget, + columnwidget, + buttonwidget as original_buttonwidget, + textwidget, + imagewidget, + checkboxwidget, + get_special_widget, + # Other + gettexture, + Mesh, Texture, Lstr, +) +from babase import ( + get_virtual_screen_size +) +from bauiv1lib.playlist.editcontroller import PlaylistEditController +import bauiv1lib.playlist.editcontroller +from bauiv1lib.playlist.editgame import PlaylistEditGameWindow +import bauiv1lib.playlist.editgame +from bauiv1lib.playlist.edit import PlaylistEditWindow +import bauiv1lib.playlist.edit +from bauiv1lib.playlist import PlaylistTypeVars +from babase import app, Plugin +import babase +import bascenev1 as bs +import bauiv1 as bui +from copy import deepcopy +import logging +plugman = dict( + plugin_name="fluffyplaylisteditor", + description="A simple not-so advanced Playlist Editor", + external_url="https://discord.com/channels/1001896771347304639/1483463979450896445", + authors=[ + {"name": "FluffyPal", "email": "", "discord": "fluffypal"} + ], + version="1.1.0", +) + +# Started: 12 Mar 2026 at night from scratch +# - Learning workflow +# - configuring packages +# - PlaylistEditWindow UI Polishing (I HATE UI POLISHING) + +# Continued: 13 Mar 2026 +# - PlaylistEditGameWindow UI Polishing (AAAA.. oh, found a bug) +# - Implemented GameEdit configs reset and restore +# - Implemented GameEdit map quick navigation buttons +# - Lazy TypedDict PlaylistType + +# Continued: 17 Mar 2026 +# - PlaylistEditController logics overwrite +# - Implemented PlaylistEdit duplicate game +# - Refactor filter_playlist +# - Implemented logics to show invalid games/maps in PlaylistEdit +# - Refine GameEdit reset and restore + +# Continued: 7 June 2026 +# - Fixed map select not recorded on MapSelect +# - Added batch game adding within supported maps + + +# ======= PLAYLIST PARENT =======# + +# ======= PLAYLIST BORWSER =======# +# Main Playlist Browser Window For Starting The Playlist +# Then goes to PlaylistCustomizeBrowserWindow if pressing "customize..." +# from bauiv1lib.playlist.browser import PlaylistBrowserWindow + +# import bauiv1lib.playlist.customizebrowser +# from bauiv1lib.playlist.customizebrowser import PlaylistCustomizeBrowserWindow + +# ======= PLAYLIST ADDING/EDITING =======# +# Main packages to modify +# - Game edit window + +# from bauiv1lib.playlist.addgame import PlaylistAddGameWindow + +# - Playlist games edit window + +# Window for adding registered games to the playlist and then goes to PlaylistEditController for editing +# The UI that shows games GameActivities names on the left and desc on the right +# And "Get More Games..." on the bottom +# After selecting the game, we goes to PlaylistEditGameWindow for configuring the game Settings and Map + +# - Game map select window +# import bauiv1lib.playlist.mapselect +# from bauiv1lib.playlist.mapselect import PlaylistMapSelectWindow + + +# ======= PLAYLIST PLAYING =======# +# This is used when we wanna "play" the playlist +# from bauiv1lib.play import PlayWindow, PlaylistSelectContext + +# from bauiv1lib.playoptions import PlayOptionsWindow + + +# ======= UI Packages =======# + +# ======= Type Hints =======# +# from enum import Enum + + +class UntotalOldTypedPlaylistDict(TypedDict, total=False): + map: str + level: str + resolved_type: type[bs.GameActivity] | None + is_unowned_map: bool + is_unowned_game: bool + is_map_invalid: bool + is_game_invalid: bool + + +class OldTypedPlaylistDict(TypedDict): + map: str + level: str + + +class TypedPlaylistDict(UntotalOldTypedPlaylistDict): + settings: OldTypedPlaylistDict + type: str + + +TypedPlaylistType = list[TypedPlaylistDict] + +# -------------------------- UI TOOLS --------------------------# + + +def buttonwidget( + *, + edit: Widget | None = None, + parent: Widget | None = None, + id: str | None = None, + size: Sequence[float] | None = None, + position: Sequence[float] | None = None, + on_activate_call: Callable | None = None, + label: str | Lstr | None = None, + color: Sequence[float] | None = None, + down_widget: Widget | None = None, + up_widget: Widget | None = None, + left_widget: Widget | None = None, + right_widget: Widget | None = None, + # texture: Texture | None = None, + text_scale: float | None = None, + textcolor: Sequence[float] | None = None, + enable_sound: bool | None = None, + mesh_transparent: Mesh | None = None, + mesh_opaque: Mesh | None = None, + repeat: bool | None = None, + scale: float | None = None, + transition_delay: float | None = None, + on_select_call: Callable | None = None, + button_type: str | None = None, + extra_touch_border_scale: float | None = None, + selectable: bool | None = None, + show_buffer_top: float | None = None, + icon: Texture | None = None, + iconscale: float | None = None, + icon_tint: float | None = None, + icon_color: Sequence[float] | None = None, + autoselect: bool | None = None, + mask_texture: Texture | None = None, + tint_texture: Texture | None = None, + tint_color: Sequence[float] | None = None, + tint2_color: Sequence[float] | None = None, + text_flatness: float | None = None, + text_res_scale: float | None = None, + text_literal: bool | None = None, + opacity: float | None = None, + better_bg_fit: bool | None = None, +) -> Widget: + return original_buttonwidget( + edit=edit, + parent=parent, + id=id, + size=size, + position=position, + on_activate_call=on_activate_call, + label=label, + color=color, + down_widget=down_widget, + up_widget=up_widget, + left_widget=left_widget, + right_widget=right_widget, + texture=gettexture('white'), + text_scale=text_scale, + textcolor=textcolor, + enable_sound=enable_sound, + mesh_transparent=mesh_transparent, + mesh_opaque=mesh_opaque, + repeat=repeat, + scale=scale, + transition_delay=transition_delay, + on_select_call=on_select_call, + button_type=button_type, + extra_touch_border_scale=extra_touch_border_scale, + selectable=selectable, + show_buffer_top=show_buffer_top, + icon=icon, + iconscale=iconscale, + icon_tint=icon_tint, + icon_color=icon_color, + autoselect=autoselect, + mask_texture=mask_texture, + tint_texture=tint_texture, + tint_color=tint_color, + tint2_color=tint2_color, + text_flatness=text_flatness, + text_res_scale=text_res_scale, + text_literal=text_literal, + opacity=opacity, + better_bg_fit=better_bg_fit + ) + + +def create_container(size: tuple[float, float], **kwargs) -> Widget: + """Create the main window with overlay.""" + # Container + p = containerwidget( + # parent=get_special_widget('overlay_stack'), + background=False, + # transition='in_scale', + size=size, + **kwargs + ) + x, y = get_virtual_screen_size() + + # Background + imagewidget( + parent=p, + texture=gettexture('white'), + size=(x*2, y*2), + position=(-x*0.5, 0-475), + opacity=0.55, + color=(0, 0, 0) + ) + + # Main Container View + border_size_range = 1.0075 + b = imagewidget( + texture=gettexture('white'), + parent=p, + size=(size[0]*border_size_range, size[1]*border_size_range), + tilt_scale=6, + color=tuple(col*1.5 for col in color_theme.get_color('primary')) + ) + f = imagewidget( # Foreground + texture=gettexture('white'), + parent=p, + size=size, + color=color_theme.get_color('bg') + ) + + border_size_offset = border_size_range-(border_size_range//border_size_range.real) + imagewidget( + edit=b, + position=tuple(pos*-border_size_offset for pos in f.center) + ) + + return p + + +def create_confirm(callback: Callable, text: str) -> Callable: + return lambda: ConfirmWindow( + text=f"{text}?", + color=color_theme.get_color('primary'), # pyright: ignore[reportArgumentType] + action=callback + ) + + +_SOUND_VOL = 1.2 + + +def play_error_sound(): + bui.getsound('error').play(_SOUND_VOL) + + +def play_guncocking_sound(): + bui.getsound('gunCocking').play(_SOUND_VOL) + + +def play_ding_sound(): + bui.getsound('ding').play(_SOUND_VOL) + + +def play_powerdown_sound(): + bui.getsound('powerdown01').play(_SOUND_VOL) + + +def play_shield_down_sound(): + bui.getsound('shieldDown').play(_SOUND_VOL) + + +def play_shield_up_sound(): + bui.getsound('shieldUp').play(_SOUND_VOL) + + +def play_deek_sound(): + bui.getsound(choice(['deek', 'deek2'])).play(_SOUND_VOL) + + +def play_click_sound(): + bui.getsound('click01').play(_SOUND_VOL) + + +class ScreenmessageColors: + RED = (1, 0, 0) + GREEN = (0, 1, 0) + BS_GREEN = (0.6, 1, 0.6) + BLUE = (0, 0, 1) + ORANGE = (1, 0.5, 0) + YELLOW = (1, 1, 0) + PURPLE = (0.6, 0, 1) + CYAN = (0, 1, 1) + MAGENTA = (1, 0, 1) + WHITE = (1, 1, 1) + BLACK = (0, 0, 0) + GRAY = (0.5, 0.5, 0.5) + PINK = (1, 0.4, 0.7) + + +_COLOR_TYPE = Literal[ + 'bg', 'primary', 'secondary', 'tertiary', 'unknown' +] + + +class ColorTheme: + main_color: Sequence[float] = (0.8, 0.8, 0.8) + colors: dict[_COLOR_TYPE, Sequence[float]] = { + 'bg': tuple(col*0.1 for col in main_color), + 'primary': main_color, + 'secondary': tuple(col*0.2 for col in main_color), + 'tertiary': tuple(col*0.4 for col in main_color), + 'unknown': (main_color[0]*1.2, main_color[1], main_color[2]) + } + + def get_color(self, color: _COLOR_TYPE) -> Sequence[float]: + return self.colors[color] + + +color_theme = ColorTheme() + + +# -------------------------- MAIN --------------------------# +class FluffyPlaylistEditController(PlaylistEditController): + def __init__( + self, + sessiontype: type[bs.Session], + from_window: MainWindow, + *, + existing_playlist_name: str | None = None, + playlist: TypedPlaylistType | None = None, + playlist_name: str | None = None, + ): + appconfig = bui.app.config + + # Since we may be showing our map list momentarily, + # lets go ahead and preload all map preview textures. + if app.classic is not None: + app.classic.preload_map_preview_media() + + self._sessiontype = sessiontype + + self._editing_game = False + self._editing_game_type: type[bs.GameActivity] | None = None + self._pvars = PlaylistTypeVars(sessiontype) + self._existing_playlist_name = existing_playlist_name + self._config_name_full = self._pvars.config_name + ' Playlists' + + self._is_batch_add = False + self._pre_game_add_state: MainWindowState | None = None + self._pre_game_edit_state: MainWindowState | None = None + + # Make sure config exists. + if self._config_name_full not in appconfig: + appconfig[self._config_name_full] = {} + + self._selected_index = 0 + if existing_playlist_name: + self._name = existing_playlist_name + + # Filter out invalid games. + self._playlist = new_filter_playlist( + appconfig[self._pvars.config_name + ' Playlists'][ + existing_playlist_name + ], + sessiontype=sessiontype, + remove_unowned=False, + # add_resolved_type=True, # HACK: This would cause json error as we save a Python class which aren't serializable + name=existing_playlist_name, + print_exc=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 appconfig[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' + + editwindow = from_window.main_window_replace( + lambda: FluffyPlaylistEditWindow(editcontroller=self) + ) + assert editwindow is not None + + # Once we've set our start window, store the back state. We'll + # skip back to there once we're fully done. + self._back_state = editwindow.main_window_back_state + + def duplicate_game_pressed(self) -> bool: + """Duplicate the currently selected game in the playlist and insert it just after.""" + if not self._playlist or self._selected_index is None: + bs.screenmessage("No game selected to duplicate", ScreenmessageColors.ORANGE) + play_error_sound() + return False + + base_entry = self._playlist[self._selected_index] + new_entry = deepcopy(base_entry) + + ins_index = self._selected_index + 1 + self._playlist.insert(ins_index, new_entry) + self._selected_index = ins_index + self._edit_ui_selection = None + + try: + cls = babase.getclass(new_entry['type'], bs.GameActivity) + name = cls.getname() + except (ImportError, AttributeError): + name = new_entry['type'] + + bs.screenmessage(f"Game '{name}' duplicated", ScreenmessageColors.BS_GREEN) + return True + + def toggle_epic_mode(self) -> bool: + if not self._playlist or self._selected_index is None: + bs.screenmessage("No game selected to toggle epic mode", ScreenmessageColors.ORANGE) + play_error_sound() + return False + + entry = self._playlist[self._selected_index] + if (epic_setting := 'Epic Mode') in entry['settings'] and isinstance(entry['settings'][epic_setting], bool): + # pyright: ignore[reportGeneralTypeIssues] + self._playlist[self._selected_index]['settings'][epic_setting] = not entry['settings'][epic_setting] + else: + play_error_sound() + return False + return True + + def toggle_solo_mode(self) -> bool: + if not self._playlist or self._selected_index is None: + bs.screenmessage("No game selected to toggle solo mode", ScreenmessageColors.ORANGE) + play_error_sound() + return False + + entry = self._playlist[self._selected_index] + if (solo_setting := 'Solo Mode') in entry['settings'] and isinstance(entry['settings'][solo_setting], bool): + # pyright: ignore[reportGeneralTypeIssues] + self._playlist[self._selected_index]['settings'][solo_setting] = not entry['settings'][solo_setting] + else: + play_error_sound() + return False + return True + + def reslove_default_settings_defs(self, settings: OldTypedPlaylistDict) -> list[bs.Setting]: + default_settings = list[bs.Setting]() + if (epic_mode := 'Epic Mode') in settings: + default_settings.append( + bs.BoolSetting(epic_mode, default=settings[epic_mode]) + ) + if (solo_mode := 'Solo Mode') in settings: + default_settings.append( + bs.BoolSetting(solo_mode, default=settings[solo_mode]) + ) + if (time_limit := 'Time Limit') in settings: + default_settings.append( + bs.IntChoiceSetting( + time_limit, + choices=[ + ('None', 0), + ('1 Minute', 60), + ('2 Minutes', 120), + ('5 Minutes', 300), + ('10 Minutes', 600), + ('20 Minutes', 1200), + ], + default=settings[time_limit], + ) + ) + if (respawn_times := 'Respawn Times') in settings: + default_settings.append( + bs.FloatChoiceSetting( + respawn_times, + choices=[ + ('Shorter', 0.25), + ('Short', 0.5), + ('Normal', 1.0), + ('Long', 2.0), + ('Longer', 4.0), + ], + default=settings[respawn_times], + ) + ) + return default_settings + + def edit_game_pressed(self, from_window: MainWindow) -> None: + if not self._playlist: + return + + playlist = self._playlist[self._selected_index] + try: + cls = babase.getclass( + playlist['type'], + subclassof=bs.GameActivity + ) + except AttributeError as e: + bs.screenmessage( + f'Can\'t edit game: {e}. Maybe try fix it?', ScreenmessageColors.YELLOW) + play_error_sound() + return + + except ModuleNotFoundError as e: + class FakeGameActivity(bs.GameActivity): + name = playlist['type'] + available_settings = self.reslove_default_settings_defs(playlist['settings']) + cls = FakeGameActivity + + self._show_edit_ui( + gametype=cls, + settings=playlist['settings'], # pyright: ignore[reportArgumentType] + from_window=from_window, + ) + + def _show_edit_ui( # pyright: ignore[reportIncompatibleMethodOverride] + self, + gametype: type[bs.GameActivity], + settings: TypedPlaylistDict | None, + from_window: bui.MainWindow, + ) -> None: + # pylint: disable=cyclic-import + if not from_window.main_window_has_control(): + return + + self._editing_game = settings is not None + self._editing_game_type = gametype + assert self._sessiontype is not None + + # Jump into an edit window. + editwindow = from_window.main_window_replace( + lambda: FluffyPlaylistEditGameWindow( + gametype, + self._sessiontype, + settings, + completion_call=self._edit_game_done, + ) + ) + assert editwindow is not None + + # Once we're there, store the back state. We'll use that to jump + # back out to our current location once the edit is done. + assert self._pre_game_edit_state is None + self._pre_game_edit_state = editwindow.main_window_back_state + + def _edit_game_done( # pyright: ignore[reportIncompatibleMethodOverride] + self, config: TypedPlaylistDict | None, from_window: bui.MainWindow + ) -> None: + """Called after finished editing/adding a game""" + # No-op if provided window isn't in charge. + if not from_window.main_window_has_control(): + return + + assert bui.app.classic is not None + if config is None: + play_powerdown_sound() + else: + # Make sure type is in there. + assert self._editing_game_type is not None + if self._editing_game: + playlist = self._playlist[self._selected_index] + config['type'] = playlist['type'] + else: + config['type'] = bui.get_type_name(self._editing_game_type) + + if self._editing_game: + self._playlist[self._selected_index] = deepcopy(config) + else: + # Add a new entry to the playlist. + insert_index = min( + len(self._playlist), self._selected_index + 1 + ) + + self._selected_index = insert_index + self._playlist.insert(insert_index, deepcopy(config)) + + if self._is_batch_add: + for _map in self._get_supported_maps_for_game(config, self._editing_game_type): + new_config = deepcopy(config) + new_config['map'] = _map + new_config['settings']['map'] = _map + self._playlist.insert(insert_index, new_config) + insert_index += 1 + + play_guncocking_sound() + + # If we're adding, jump to before the add started. + # Otherwise jump to before the edit started. + assert ( + self._pre_game_edit_state is not None + or self._pre_game_add_state is not None + ) + if self._pre_game_add_state is not None: + from_window.main_window_back_state = self._pre_game_add_state + elif self._pre_game_edit_state is not None: + from_window.main_window_back_state = self._pre_game_edit_state + + from_window.main_window_back() + self._pre_game_edit_state = None + self._pre_game_add_state = None + self._is_batch_add = False + + def _get_supported_maps_for_game(self, config: TypedPlaylistDict, gametype: type[bs.GameActivity]): + assert app.classic is not None + store = app.classic.store + + valid_maps = set(gametype.get_supported_maps(self._sessiontype)) + to_remove = set(store.get_unowned_maps()) + + if config_map := config.get('map') or (config.get('settings', {}) or {}).get('map'): + to_remove.add(config_map) + + valid_maps -= to_remove + + return sorted(valid_maps) + + def batch_add_game_pressed(self, from_window: MainWindow): + self._is_batch_add = True + self.add_game_pressed(from_window) + + +class FluffyPlaylistEditWindow(PlaylistEditWindow): + def __init__( + self, + editcontroller: FluffyPlaylistEditController, + transition: str | None = 'in_right', + origin_widget: Widget | None = None, + ): + self._list_widgets: list[Widget] = [] + self._editcontroller = editcontroller + editcontroller._is_batch_add = False # HACK: Couldn't think of any clean idea :p + + self._r = 'editGameListWindow' + prev_selection: str | None = self._editcontroller.get_edit_ui_selection() + + # Container Setup + uiscale = app.ui_v1.uiscale + is_small_ui = uiscale is UIScale.SMALL + # Keep a constant aspect ratio for all UIScale values. + # Let's use width:height (classic UI aspect) as a reference. + # We'll use a base width and scale it for each UI scale while maintaining the ratio. + base_width = 800 + aspect_ratio = 16 / 9 + self._width = base_width * 1.65 + + self._height = self._width / aspect_ratio + + # Adjust x_inset proportionally to width + self.x_inset = x_inset = int(self._width * (0.09 if is_small_ui else 0.02)) + self.yoffs = yoffs = -68 if is_small_ui else -15 + + # Round to integers for pixel positions + self._width = int(self._width) + self._height = int(self._height) + + MainWindow.__init__(self, # pyright: ignore[reportArgumentType] + root_widget=create_container( + size=(self._width, self._height), + scale=( + 1.20 if is_small_ui else + 0.80 if uiscale is UIScale.MEDIUM else + 0.60 + ), + toolbar_visibility=( + 'menu_minimal_no_back' + if is_small_ui + else 'menu_full' + ), + ), + transition=transition, + origin_widget=origin_widget, + ) + + # Widgets/children + # Buttons + self.b_color = b_color = color_theme.get_color('primary') + self.b_textcolor = b_textcolor = color_theme.get_color('secondary') + + cancel_button = buttonwidget( + parent=self._root_widget, + position=(35 + x_inset, self._height - 50 + yoffs), + scale=0.8, + size=(175, 60), + autoselect=True, + color=(b_color[0]*1.2, *b_color[1:]), + textcolor=b_textcolor, + label=bui.Lstr(resource='cancelText'), + text_scale=1.2, + ) + save_button = buttonwidget( + parent=self._root_widget, + position=( + self._width - ((225 if is_small_ui else 255) + x_inset), + self._height - 50 + yoffs + ), + scale=0.8, + size=(190, 60), + autoselect=True, + color=(b_color[0], b_color[1]*1.2, b_color[2]), + textcolor=b_textcolor, + left_widget=cancel_button, + label=bui.Lstr(resource='saveText'), + text_scale=1.2, + ) + + widget( + edit=save_button, + right_widget=bui.get_special_widget('squad_button'), + ) + + widget( + edit=cancel_button, + left_widget=cancel_button, + right_widget=save_button, + ) + + v_gap = 60 + h = 40 + x_inset + r_h = x_inset * (9.45 if is_small_ui else 45.25) + + left_button_v_size = 45 + right_button_v_size = 45 + + v = self._height - 172.0 + yoffs + v *= 1.1 + v -= 2.0 + v -= v_gap + buttonwidget( + parent=self._root_widget, + position=(r_h, v), + size=(110, right_button_v_size), + on_activate_call=self._toggle_epic_mode, + enable_sound=False, + autoselect=True, + button_type='square', + color=b_color, + textcolor=b_textcolor, + text_scale=0.8, + label="Toggle\nEpic Mode", + ) + v -= v_gap + + buttonwidget( + parent=self._root_widget, + position=(r_h, v), + size=(110, right_button_v_size), + on_activate_call=self._toggle_solo_mode, + enable_sound=False, + autoselect=True, + button_type='square', + color=b_color, + textcolor=b_textcolor, + text_scale=0.8, + label="Toggle\nSolo Mode", + ) + v -= v_gap + + v = self._height - 172.0 + yoffs + v *= 1.1 + v -= 2.0 + v -= v_gap + + add_game_button = buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(110, left_button_v_size), + on_activate_call=self._add, + on_select_call=bui.CallPartial(self._set_ui_selection, 'add_button'), + autoselect=True, + button_type='square', + color=b_color, + textcolor=b_textcolor, + text_scale=0.8, + label=bui.Lstr(resource=f'{self._r}.addGameText'), + ) + v -= v_gap + + batch_add_game_button = buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(110, left_button_v_size), + on_activate_call=self._batch_add, + on_select_call=bui.CallPartial(self._set_ui_selection, 'batch_add_button'), + autoselect=True, + button_type='square', + color=b_color, + textcolor=b_textcolor, + text_scale=0.8, + label=f"Batch {bui.Lstr(resource=f'{self._r}.addGameText').evaluate()}", + ) + v -= v_gap + + self._edit_button = edit_game_button = buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(110, left_button_v_size), + on_activate_call=self._edit, + on_select_call=bui.CallPartial(self._set_ui_selection, 'editButton'), + autoselect=True, + button_type='square', + color=b_color, + textcolor=b_textcolor, + text_scale=0.8, + label=bui.Lstr(resource=f'{self._r}.editGameText'), + ) + v -= v_gap + + remove_game_button = buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(110, left_button_v_size), + text_scale=0.8, + on_activate_call=self._remove, + autoselect=True, + button_type='square', + color=b_color, + textcolor=b_textcolor, + label=bui.Lstr(resource=f'{self._r}.removeGameText'), + ) + v -= v_gap + + # Customs + duplicate_button = buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(110, left_button_v_size), + on_activate_call=self._duplicate_selected_game, + autoselect=True, + enable_sound=False, + button_type='square', + color=b_color, + textcolor=b_textcolor, + text_scale=0.8, + label="Duplicate\nGame", + ) + v -= v_gap - 10 + + h += 9 + buttonwidget( + parent=self._root_widget, + position=(h, v), + size=(42, 35), + on_activate_call=self._move_up, + label=bui.charstr(bui.SpecialChar.UP_ARROW), + button_type='square', + color=b_color, + textcolor=b_textcolor, + autoselect=True, + repeat=True, + ) + h += 52 + 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=bui.charstr(bui.SpecialChar.DOWN_ARROW), + repeat=True, + ) + + # Scroller + v = self._height - 100 + yoffs + scroll_height = self._height - ( + 250 if is_small_ui else 155 + ) + self._scroll_width = self._width - (205 + (2.5 if is_small_ui else 5.5) * x_inset) + + scrollwidget = bui.scrollwidget( + parent=self._root_widget, + position=(160 + x_inset, v - scroll_height), + highlight=False, + on_select_call=bui.CallStrict(self._set_ui_selection, 'gameList'), + size=(self._scroll_width, (scroll_height - 15)), + border_opacity=0.4, + ) + widget( + edit=scrollwidget, + left_widget=add_game_button, + right_widget=scrollwidget, + ) + self._columnwidget = columnwidget( + parent=scrollwidget, border=2, margin=0 + ) + + for button in [ + add_game_button, batch_add_game_button, edit_game_button, remove_game_button, duplicate_button + ]: + widget( + edit=button, left_widget=button, right_widget=scrollwidget + ) + + buttonwidget(edit=cancel_button, on_activate_call=self._cancel) + containerwidget( + edit=self._root_widget, + cancel_button=cancel_button, + selected_child=scrollwidget, + ) + + buttonwidget(edit=save_button, on_activate_call=self._save_press) + containerwidget(edit=self._root_widget, start_button=save_button) + + # Texts + textwidget( + parent=self._root_widget, + position=(-10, self._height - 50 + yoffs), + size=(self._width, 25), + text=bui.Lstr(resource=f'{self._r}.titleText'), + color=app.ui_v1.title_color, + scale=1.05, + h_align='center', + v_align='center', + maxwidth=270, + ) + + v = self._height - 115.0 + yoffs + + textwidget( + parent=self._root_widget, + text=bui.Lstr(resource=f'{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 = text_field = textwidget( + parent=self._root_widget, + position=(210 + x_inset, v + 7), + size=(self._scroll_width - 53, 43), + text=self._editcontroller.getname(), + h_align='left', + v_align='center', + max_chars=40, + maxwidth=380, + autoselect=True, + color=(0.9, 0.9, 0.9, 1.0), + description=bui.Lstr(resource=f'{self._r}.listNameText'), + editable=True, + padding=4, + on_return_press_call=self._save_press_with_sound, + ) + widget(edit=self._columnwidget, up_widget=text_field) + widget(edit=cancel_button, down_widget=text_field) + widget(edit=add_game_button, up_widget=text_field) + widget(edit=batch_add_game_button, up_widget=add_game_button) + + if prev_selection == 'add_button': + containerwidget( + edit=self._root_widget, selected_child=add_game_button + ) + elif prev_selection == 'batch_add_button': + containerwidget( + edit=self._root_widget, selected_child=batch_add_game_button + ) + elif prev_selection == 'editButton': + containerwidget( + edit=self._root_widget, selected_child=edit_game_button + ) + elif prev_selection == 'gameList': + containerwidget( + edit=self._root_widget, selected_child=scrollwidget + ) + + self._refresh() + + def _get_invalid_game_name(self, pentry: TypedPlaylistDict): + name = pentry['type'] + # A few substitutions for 'Epic', 'Solo' etc. modes. + # FIXME: Should provide a way for game types to define filters of + # their own and should not rely on hard-coded settings names. + if (solo_mode := 'Solo Mode') in pentry['settings'] and pentry['settings'][solo_mode]: + name = babase.Lstr( + resource='soloNameFilterText', subs=[('${NAME}', name)] + ) + if (epic_mode := 'Epic Mode') in pentry['settings'] and pentry['settings'][epic_mode]: + name = babase.Lstr( + resource='epicNameFilterText', subs=[('${NAME}', name)] + ) + + # Resolve map name + if 'map' in pentry['settings']: + sval = babase.Lstr( + value='${NAME} @ ${MAP}', + subs=[ + ('${NAME}', name), + ('${MAP}', bs.get_map_display_string( + bs.get_filtered_map_name(pentry['settings']['map'])), + ), + ], + ) + elif 'map' in pentry: + sval = babase.Lstr( + value='${NAME} @ ${MAP}', + subs=[ + ('${NAME}', name), + ('${MAP}', bs.get_map_display_string( + bs.get_filtered_map_name(pentry['map'])), + ), + ], + ) + else: + print('invalid game config - expected map entry under settings') + sval = babase.Lstr(value='???') + + return sval + + def _duplicate_selected_game(self) -> None: + if self._editcontroller.duplicate_game_pressed(): + play_guncocking_sound() + self._refresh() + + def _move_down(self) -> None: + if len(self._editcontroller.get_playlist()) > 1: + super()._move_down() + + def _move_up(self) -> None: + if len(self._editcontroller.get_playlist()) > 1: + super()._move_up() + + def _toggle_epic_mode(self): + if self._editcontroller.toggle_epic_mode(): + play_guncocking_sound() + self._refresh() + + def _toggle_solo_mode(self): + if self._editcontroller.toggle_solo_mode(): + play_guncocking_sound() + self._refresh() + + def _refresh(self) -> None: + # 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() + # pyright: ignore[reportAssignmentType] + for index, pentry in enumerate(self._editcontroller.get_playlist()): + pentry: TypedPlaylistDict + try: + cls = babase.getclass(pentry['type'], subclassof=bs.GameActivity) + # pyright: ignore[reportArgumentType] + desc = cls.get_settings_display_string(pentry) + color = (0.8, 0.8, 0.8, 1.0) + except Exception: + # logging.exception('Error in playlist refresh.') + # desc = "(invalid: '" + pentry['type'] + "')" + desc = self._get_invalid_game_name(pentry) + color = color_theme.get_color('unknown') + + txtw = textwidget( + parent=self._columnwidget, + size=(self._width - 80, 30), + on_select_call=bui.CallStrict(self._select, index), + always_highlight=True, + color=color, + padding=0, + maxwidth=self._scroll_width * 0.93, + text=desc, + on_activate_call=self._edit_button.activate, + v_align='center', + selectable=True, + ) + 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: + widget(edit=txtw, up_widget=self._text_field) + self._list_widgets.append(txtw) + if old_selection_index == index: + columnwidget( + edit=self._columnwidget, + selected_child=txtw, + visible_child=txtw, + ) + + def _batch_add(self) -> None: + # Store list name then tell the session to perform an add. + self._editcontroller.setname( + cast(str, bui.textwidget(query=self._text_field)) + ) + self._editcontroller.batch_add_game_pressed(from_window=self) + + +class FluffyPlaylistEditGameWindow(PlaylistEditGameWindow): + + def __init__( + self, + gametype: type[bs.GameActivity], + sessiontype: type[bs.Session], + config: TypedPlaylistDict | None, + completion_call: Callable[[TypedPlaylistDict | None, bui.MainWindow], Any], + default_selection: str | None = None, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + edit_info: dict[str, Any] | None = None + ): + + assert app.classic is not None + store = app.classic.store + + self._scrollwidget: Widget | None = None + self._subcontainer: Widget | None = None + + self._gametype = gametype + 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' + + self._valid_maps = valid_maps = gametype.get_supported_maps(sessiontype) + if not valid_maps: + bui.screenmessage(bui.Lstr(resource='noValidMapsErrorText')) + raise RuntimeError('No valid maps found.') + self._valid_maps_owned = [m for m in self._valid_maps if m not in store.get_unowned_maps()] + + self._config = config + + self._settings_defs = gametype.get_available_settings(sessiontype) + self._completion_call = completion_call + + # If there's a valid map name in the existing config, use that. + self._map: str | None = None + # To start with, pick a random map out of the ones we own. + unowned_maps = store.get_unowned_maps() + try: + if ( + config is not None + and 'map' in config + ): + filtered_map_name = get_filtered_map_name( + config['map'] + ) + if filtered_map_name not in unowned_maps: + self._map = filtered_map_name + elif ( + config is not None + and (settings := config.get('settings')) + and (raw_map := settings.get('map')) + ): + filtered_map_name = get_filtered_map_name(raw_map) + if filtered_map_name not in unowned_maps: + self._map = filtered_map_name + # else: + # raise Exception() + except Exception: + logging.exception('Error getting map for editor.') + + if not self._map: + if valid_maps_owned := [m for m in valid_maps if m not in unowned_maps]: + self._map = choice(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 = choice(valid_maps) + + if config is not None: + if 'settings' in config: + self._settings = config['settings'] + else: + self._settings = config + else: + self._settings: OldTypedPlaylistDict = {} # pyright: ignore[reportAttributeAccessIssue] + self._settings['map'] = self._map + + self._default_settings = deepcopy(self._settings) + + try: + self.map_tex_name = get_map_class(self._map).get_preview_texture_name() + except babase.NotFoundError: + self.map_tex_name = 'null' + + if self.map_tex_name is None: + raise RuntimeError(f'No map preview tex found for {self._map}.') + self._choice_selections: dict[str, int] = {} + + # Container Setup + uiscale = app.ui_v1.uiscale + is_small_ui = uiscale is UIScale.SMALL + # Keep a constant aspect ratio for all UIScale values. + # Let's use width:height (classic UI aspect) as a reference. + # We'll use a base width and scale it for each UI scale while maintaining the ratio. + base_width = 900 + aspect_ratio = 5 / 4 + self._width = width = int(base_width * 1.65) + self._height = height = int(base_width / aspect_ratio) + + y_extra2 = 50 # For topper widget elements + + # Adjust x_inset proportionally to width + self.x_inset = int(width * (0.09 if is_small_ui else 0.0225)) + self.yoffs = yoffs = -68 if is_small_ui else -30 + + MainWindow.__init__(self, + root_widget=containerwidget( + size=(width, height), + color=color_theme.get_color('bg'), + scale=( + 1.10 if is_small_ui else + 0.80 if uiscale is UIScale.MEDIUM else + 0.70 + ), + toolbar_visibility=( + 'menu_minimal_no_back' + if uiscale is UIScale.SMALL + else 'menu_full' + ), + ), + transition=transition, + origin_widget=origin_widget, + ) + + # Widgets/children + b_color = color_theme.get_color('primary') + b_textcolor = color_theme.get_color('secondary') + + is_add = self._edit_info['editType'] == 'add' + cancel_button = original_buttonwidget( + parent=self._root_widget, + position=(45 + self.x_inset, height - 82 + y_extra2 + yoffs), + size=(60, 48) if is_add else (180, 65), + label=( + bui.charstr(bui.SpecialChar.BACK) if is_add else + bui.Lstr(resource='cancelText') + ), + button_type='backSmall' if is_add else None, + autoselect=True, + scale=1.0 if is_add else 0.75, + text_scale=1.3, + color=(b_color[0]*1.2, *b_color[1:]), + textcolor=b_textcolor, + on_activate_call=bui.CallStrict(self._cancel), + ) + containerwidget(edit=self._root_widget, cancel_button=cancel_button) + + # Title + textwidget( + parent=self._root_widget, + position=((-20 if is_small_ui else -18), height - 70 + y_extra2 + yoffs), + size=(width, 25), + text=gametype.get_display_string(), + color=bui.app.ui_v1.title_color, + maxwidth=width*0.35, + scale=1.1, + h_align='center', + v_align='center', + ) + + self.add_button = add_button = original_buttonwidget( + parent=self._root_widget, + position=(width - ((255 if is_small_ui else 235) + self.x_inset), + height - 82 + y_extra2 + yoffs), + size=(200, 65), + scale=0.75, + text_scale=1.3, + color=(b_color[0], b_color[1]*1.2, b_color[2]), + textcolor=b_textcolor, + label=( + bui.Lstr(resource=f'{self._r}.addGameText') if is_add else + bui.Lstr(resource='applyText') + ), + ) + + base_h_pos = width - ((155 if is_small_ui else 135) + self.x_inset) + base_v_pos = height * 0.75 + right_buttons_gap = 75 + + reset_config_text = "Reset\nSettings" + original_buttonwidget( + parent=self._root_widget, + position=(base_h_pos, base_v_pos - right_buttons_gap), + size=(200, 100), + scale=0.75, + text_scale=1.3, + color=b_color, + textcolor=b_textcolor, + label=reset_config_text, + on_activate_call=create_confirm( + self._reset_settings, reset_config_text.replace('\n', ' ')), + icon=gettexture('replayIcon'), + iconscale=1.5 + ) + right_buttons_gap += right_buttons_gap + + restore_config_text = "Restore\nSettings" + original_buttonwidget( + parent=self._root_widget, + position=(base_h_pos, base_v_pos - right_buttons_gap), + size=(200, 100), + scale=0.75, + text_scale=1.3, + color=b_color, + textcolor=b_textcolor, + label=restore_config_text, + on_activate_call=( + create_confirm(self._restore_settings, restore_config_text.replace( + '\n', ' ')) if not is_add else play_error_sound + ), + icon=gettexture('leftButton'), + iconscale=1.5 + ) + + self._refresh_settings_items() + + original_buttonwidget( + edit=add_button, on_activate_call=bui.CallStrict(self._add) + ) + containerwidget( + edit=self._root_widget, + selected_child=add_button, + start_button=add_button, + ) + + if default_selection == 'map': + containerwidget( + edit=self._root_widget, selected_child=self._scrollwidget + ) + containerwidget( + edit=self._subcontainer, selected_child=self._map_buttonwidget + ) + + # Tools + def _reset_settings(self): + updated = False + if self._settings: + for setting in self._settings_defs: + if (value := setting.default) != (data := self._settings)[setting.name]: + data[setting.name] = value + updated = True + + if (def_map := self._default_settings['map']) != self._map: + self._map = def_map + try: + self.map_tex_name = get_map_class(def_map).get_preview_texture_name() + except babase.NotFoundError: + self.map_tex_name = 'null' + updated = True + + if updated: + self._refresh_settings_items() + play_shield_down_sound() + else: + play_error_sound() + + def _restore_settings(self): + updated = False + # Only update keys which are different, and track if anything actually changed + for key, value in self._default_settings.items(): + if key not in self._settings or self._settings[key] != value: + # print(f'key restored: {self._settings[key]} -> {key}') + self._settings[key] = value + updated = True + + # Check if map changed, update relevant attrs + restored_map = get_filtered_map_name(self._settings['map']) + if self._map != restored_map: + # print(f'map restored: {self._map} -> {restored_map}') + self._map = restored_map + try: + self.map_tex_name = get_map_class(self._map).get_preview_texture_name() + except babase.NotFoundError: + self.map_tex_name = 'null' + updated = True + + if updated: + self._refresh_settings_items() + self._update_map_widget() + play_ding_sound() + else: + play_error_sound() + + def _randomize_map(self): + valid_maps = self._valid_maps_owned + + # Hmmm.. we own none of these maps.. just pick a random un-owned one + # I guess.. should this ever happen? + cur_map = choice(valid_maps) + self._map = cur_map + self.map_tex_name = get_map_class(cur_map).get_preview_texture_name() + self._update_map_widget() + + play_deek_sound() + + def _update_map_widget(self): + assert self.map_tex_name and self._map + imagewidget( + edit=self._map_imagewidget, + texture=gettexture(self.map_tex_name) + ) + textwidget( + edit=self._map_textwidget, + text=get_map_display_string(self._map) + ) + + def _shift_selected_map(self, index: int): + """Shift selection in the valid maps list by target (wrap around)""" + valid_maps = self._valid_maps_owned + + cur_map = self._map + assert cur_map + try: + cur_map_index = valid_maps.index(cur_map) + except ValueError: + cur_map_index = 0 + new_index = (cur_map_index + index) % len(valid_maps) + + self._map = valid_maps[new_index] + self.map_tex_name = get_map_class(self._map).get_preview_texture_name() + + self._update_map_widget() + play_click_sound() + + def _refresh_settings_items(self): + uiscale = app.ui_v1.uiscale + is_small_ui = uiscale is UIScale.SMALL + + pbtn = get_special_widget('squad_button') + widget(edit=self.add_button, right_widget=pbtn, up_widget=pbtn) + + map_height = 100 + + scroll_width = self._width - (86 + (3 if is_small_ui else 5.5) * self.x_inset) + + spacing = 47 # Scroller playlist config items spacing + y_extra = 15 # For scroller widget + + # Calc our total height we'll need + scroll_height = map_height + 10 # map select and margin + scroll_height += spacing * len(self._settings_defs) + + if not self._scrollwidget: + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + position=( + 44 + self.x_inset, + (95 if uiscale is UIScale.SMALL else 55) + y_extra + self.yoffs, + ), + size=( + scroll_width, + self._height - (166 if uiscale is UIScale.SMALL else 116), + ), + highlight=False, + claims_left_right=True, + selection_loops_to_parent=True, + border_opacity=0.4, + ) + if self._subcontainer: + for child in self._subcontainer.get_children(): + child.delete() + self._subcontainer.delete() + + self._subcontainer = containerwidget( + parent=self._scrollwidget, + size=(scroll_width, scroll_height), + background=False, + claims_left_right=True, + selection_loops_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: list[list[bui.Widget]] = [] + + b_color = color_theme.get_color('primary') + b_textcolor = color_theme.get_color('secondary') + + textwidget( + parent=self._subcontainer, + position=(h + 49, v - 63), + size=(100, 30), + maxwidth=110, + text=bui.Lstr(resource='mapText'), + h_align='left', + color=(0.8, 0.8, 0.8, 1.0), + v_align='center', + ) + assert self.map_tex_name + map_tex = gettexture(self.map_tex_name) + + self._map_imagewidget = imagewidget( + parent=self._subcontainer, + size=(256 * 0.7, 125 * 0.7), + position=(h + scroll_width * 0.46, v - 90), + texture=map_tex, + mesh_opaque=bui.getmesh('level_select_button_opaque'), + mesh_transparent=bui.getmesh('level_select_button_transparent'), + mask_texture=gettexture('mapPreviewMask'), + ) + + self._map_buttonwidget = None + if len(self._valid_maps_owned) > 1: + original_buttonwidget( # map_prev + parent=self._subcontainer, + position=(h + scroll_width * 0.46 - 50 - 1, v - 63), + size=(35, 35), + label='<', + color=b_color, + textcolor=b_textcolor, + autoselect=True, + on_activate_call=bui.CallPartial(self._shift_selected_map, -1), + enable_sound=False, + repeat=True, + ) + original_buttonwidget( # map_next + parent=self._subcontainer, + position=(h + scroll_width * (0.655 if is_small_ui else 0.6225) + 5, v - 63), + size=(35, 35), + label='>', + color=b_color, + textcolor=b_textcolor, + autoselect=True, + on_activate_call=bui.CallPartial(self._shift_selected_map, 1), + enable_sound=False, + repeat=True, + ) + original_buttonwidget( + parent=self._subcontainer, + size=(140, 60), + position=(h + scroll_width * (0.775 if is_small_ui else 0.8), v - 72), + on_activate_call=bui.CallStrict(self._randomize_map), + enable_sound=False, + color=b_color, + textcolor=b_textcolor, + scale=0.7, + label="Randomize", + ) + # Map select button. + self._map_buttonwidget = original_buttonwidget( + parent=self._subcontainer, + size=(140, 60), + position=(h + scroll_width * 0.9, v - 72), + on_activate_call=bui.CallStrict(self._select_map), + scale=0.7, + color=b_color, + textcolor=b_textcolor, + label=bui.Lstr(resource='mapSelectText'), + ) + widget_column.append([self._map_buttonwidget]) + + assert self._map + self._map_textwidget = textwidget( + parent=self._subcontainer, + position=(h + scroll_width * 0.496, 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 + + config = self._settings + assert config + for 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: + if ( + 'settings' in config + and (seeting_name := setting.name) in config['settings'] + ): + value = value_type(config['settings'][seeting_name]) + elif (seeting_name := setting.name) in config: + value = value_type(config[seeting_name]) + except Exception: + logging.exception('Error getting game setting.') + + # 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: + item_h_pos = h + scroll_width * 0.96 + if isinstance(setting, bs.ChoiceSetting): + invalid = False + for choice in setting.choices: + if len(choice) != 2: + raise ValueError( + "Expected 2-member tuples for 'choices'; got: " + + repr(choice) + ) + if not isinstance(choice[0], str): + raise TypeError( + 'First value for choice tuple must be a str; got: ' + + repr(choice) + ) + if not isinstance(choice[1], value_type): + invalid = True + # raise TypeError( + # 'Choice type does not match default value; choice:' + # + repr(choice) + # + '; setting:' + # + repr(setting) + # ) + if value_type not in (int, float): + raise TypeError( + '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 + 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 = textwidget( + parent=self._subcontainer, + position=(item_h_pos - 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 = original_buttonwidget( + parent=self._subcontainer, + position=(item_h_pos - 50 - 1, v), + size=(28, 28), + label='<', + color=b_color, + textcolor=b_textcolor, + autoselect=True, + on_activate_call=bui.CallStrict( + self._choice_inc, setting.name, txt, setting, -1 + ) if not invalid else lambda: play_error_sound(), + repeat=True, + ) + btn2 = original_buttonwidget( + parent=self._subcontainer, + position=(item_h_pos + 5, v), + size=(28, 28), + label='>', + color=b_color, + textcolor=b_textcolor, + autoselect=True, + on_activate_call=bui.CallStrict( + self._choice_inc, setting.name, txt, setting, 1 + ) if not invalid else lambda: play_error_sound(), + repeat=True, + ) + widget_column.append([btn1, btn2]) + + elif isinstance(setting, (bs.IntSetting, bs.FloatSetting)): + v -= spacing + min_value = setting.min_value + max_value = setting.max_value + increment = setting.increment + 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 = textwidget( + parent=self._subcontainer, + position=(item_h_pos - 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 = original_buttonwidget( + parent=self._subcontainer, + position=(item_h_pos - 50 - 1, v), + size=(28, 28), + label='-', + color=b_color, + textcolor=b_textcolor, + autoselect=True, + on_activate_call=bui.CallStrict( + self._inc, + txt, + min_value, + max_value, + -increment, + value_type, + setting.name, + ), + repeat=True, + ) + btn2 = original_buttonwidget( + parent=self._subcontainer, + position=(item_h_pos + 5, v), + size=(28, 28), + label='+', + color=b_color, + textcolor=b_textcolor, + autoselect=True, + on_activate_call=bui.CallStrict( + 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 + 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 = textwidget( + parent=self._subcontainer, + position=(item_h_pos - 95, v), + size=(0, 28), + text=( + bui.Lstr(resource='onText') + if value + else bui.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 = checkboxwidget( + parent=self._subcontainer, + text='', + position=(item_h_pos - 50 - 5, v - 2), + size=(200, 30), + autoselect=True, + color=b_color, + textcolor=b_textcolor, + value=value, + on_value_change_call=bui.CallPartial( + self._check_value_change, setting.name, txt + ), + ) + widget_column.append([cbw]) + + else: + raise TypeError(f'Invalid value type: {value_type}.') + + # Ok now wire up the column. + try: + prev_widgets: list[Widget] | None = None + for cwdg in widget_column: + if prev_widgets is not None: + # Wire our rightmost to their rightmost. + widget(edit=prev_widgets[-1], down_widget=cwdg[-1]) + widget(edit=cwdg[-1], up_widget=prev_widgets[-1]) + + # Wire our leftmost to their leftmost. + widget(edit=prev_widgets[0], down_widget=cwdg[0]) + widget(edit=cwdg[0], up_widget=prev_widgets[0]) + prev_widgets = cwdg + except Exception: + logging.exception( + 'Error wiring up game-settings-select widget column.' + ) + + +original_filter_playlist = filter_playlist + + +def new_filter_playlist( + playlist: TypedPlaylistDict, + sessiontype: type[bs.Session], + *, + add_resolved_type: bool = False, + remove_unowned: bool = True, + mark_unowned: bool = False, + name: str = '?', + print_exc: bool = True +) -> TypedPlaylistType: + """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 bascenev1 import get_filtered_map_name, GameActivity + + assert app.classic is not None + + goodlist = TypedPlaylistType() + available_maps: list[str] = list(app.classic.maps.keys()) + if (remove_unowned or mark_unowned) and app.classic is not None: + unowned_maps = app.classic.store.get_unowned_maps() + unowned_game_types = app.classic.store.get_unowned_game_types() + else: + unowned_maps = [] + unowned_game_types = set() + + for entry in deepcopy(playlist): # pyright: ignore[reportAssignmentType] + entry: TypedPlaylistDict + # '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'] = 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 TypeError('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', + 'bastd.game.assault.AssaultGame', + ): + entry['type'] = 'bascenev1lib.game.assault.AssaultGame' + if entry['type'] in ( + 'King_of_the_Hill.KingOfTheHillGame', + 'bsKingOfTheHill.KingOfTheHillGame', + 'bs_king_of_the_hill.KingOfTheHillGame', + 'bastd.game.kingofthehill.KingOfTheHillGame', + ): + entry['type'] = ( + 'bascenev1lib.game.kingofthehill.KingOfTheHillGame' + ) + if entry['type'] in ( + 'Capture_the_Flag.CTFGame', + 'bsCaptureTheFlag.CTFGame', + 'bs_capture_the_flag.CTFGame', + 'bastd.game.capturetheflag.CaptureTheFlagGame', + ): + entry['type'] = ( + 'bascenev1lib.game.capturetheflag.CaptureTheFlagGame' + ) + if entry['type'] in ( + 'Death_Match.DeathMatchGame', + 'bsDeathMatch.DeathMatchGame', + 'bs_death_match.DeathMatchGame', + 'bastd.game.deathmatch.DeathMatchGame', + ): + entry['type'] = 'bascenev1lib.game.deathmatch.DeathMatchGame' + if entry['type'] in ( + 'ChosenOne.ChosenOneGame', + 'bsChosenOne.ChosenOneGame', + 'bs_chosen_one.ChosenOneGame', + 'bastd.game.chosenone.ChosenOneGame', + ): + entry['type'] = 'bascenev1lib.game.chosenone.ChosenOneGame' + if entry['type'] in ( + 'Conquest.Conquest', + 'Conquest.ConquestGame', + 'bsConquest.ConquestGame', + 'bs_conquest.ConquestGame', + 'bastd.game.conquest.ConquestGame', + ): + entry['type'] = 'bascenev1lib.game.conquest.ConquestGame' + if entry['type'] in ( + 'Elimination.EliminationGame', + 'bsElimination.EliminationGame', + 'bs_elimination.EliminationGame', + 'bastd.game.elimination.EliminationGame', + ): + entry['type'] = 'bascenev1lib.game.elimination.EliminationGame' + if entry['type'] in ( + 'Football.FootballGame', + 'bsFootball.FootballTeamGame', + 'bs_football.FootballTeamGame', + 'bastd.game.football.FootballTeamGame', + ): + entry['type'] = 'bascenev1lib.game.football.FootballTeamGame' + if entry['type'] in ( + 'Hockey.HockeyGame', + 'bsHockey.HockeyGame', + 'bs_hockey.HockeyGame', + 'bastd.game.hockey.HockeyGame', + ): + entry['type'] = 'bascenev1lib.game.hockey.HockeyGame' + if entry['type'] in ( + 'Keep_Away.KeepAwayGame', + 'bsKeepAway.KeepAwayGame', + 'bs_keep_away.KeepAwayGame', + 'bastd.game.keepaway.KeepAwayGame', + ): + entry['type'] = 'bascenev1lib.game.keepaway.KeepAwayGame' + if entry['type'] in ( + 'Race.RaceGame', + 'bsRace.RaceGame', + 'bs_race.RaceGame', + 'bastd.game.race.RaceGame', + ): + entry['type'] = 'bascenev1lib.game.race.RaceGame' + if entry['type'] in ( + 'bsEasterEggHunt.EasterEggHuntGame', + 'bs_easter_egg_hunt.EasterEggHuntGame', + 'bastd.game.easteregghunt.EasterEggHuntGame', + ): + entry['type'] = ( + 'bascenev1lib.game.easteregghunt.EasterEggHuntGame' + ) + if entry['type'] in ( + 'bsMeteorShower.MeteorShowerGame', + 'bs_meteor_shower.MeteorShowerGame', + 'bastd.game.meteorshower.MeteorShowerGame', + ): + entry['type'] = ( + 'bascenev1lib.game.meteorshower.MeteorShowerGame' + ) + if entry['type'] in ( + 'bsTargetPractice.TargetPracticeGame', + 'bs_target_practice.TargetPracticeGame', + 'bastd.game.targetpractice.TargetPracticeGame', + ): + entry['type'] = ( + 'bascenev1lib.game.targetpractice.TargetPracticeGame' + ) + except Exception: + if print_exc: + logging.exception('Error in new_filter_playlist.') + + neededsettings = list[bs.Setting]() + gameclass = None + try: + gameclass = babase.getclass(entry['type'], GameActivity) + + if remove_unowned and gameclass in unowned_game_types: + continue + if add_resolved_type: + entry['resolved_type'] = gameclass + if mark_unowned and gameclass in unowned_game_types: + entry['is_unowned_game'] = True + neededsettings = gameclass.get_available_settings(sessiontype) + + except babase.MapNotFoundError: + if print_exc: + logging.warning( + 'Map \'%s\' not found while scanning playlist \'%s\'.', + entry['settings']['map'], + name, + ) + except ImportError as e: + if print_exc: + logging.warning( + 'Import failed while scanning playlist \'%s\': %s', name, e + ) + entry['is_game_invalid'] = True + # This exception usually happens when we could get the game 'module' + # but, we couldn't get game's GameActivity class name from `entry['type']` + except AttributeError as e: + logging.warning( + 'Get class failed while scanning playlist \'%s\': %s', name, e + ) + entry['is_game_invalid'] = True + + # We 'manually' add some of basic ba*.setting(s) to the filter + # if it exists in raw settings + if entry['settings']['map'] not in available_maps: + entry['is_map_invalid'] = True + + if mark_unowned and entry['settings']['map'] in unowned_maps: + entry['is_unowned_map'] = True + + # Make sure all settings the game defines are present. + for setting in neededsettings: + if setting.name not in entry['settings']: + entry['settings'][setting.name] = setting.default + + goodlist.append(entry) + + return goodlist + + +def apply_packages(): + # Playlist editor controller for PlaylistEditWindow + bauiv1lib.playlist.editcontroller.PlaylistEditController = FluffyPlaylistEditController + + bauiv1lib.playlist.editgame.PlaylistEditGameWindow = FluffyPlaylistEditGameWindow # Playlist Game Editer Window + bauiv1lib.playlist.edit.PlaylistEditWindow = FluffyPlaylistEditWindow # Playlist Editor Window + + bs.filter_playlist = new_filter_playlist + # pyright: ignore[reportAttributeAccessIssue] + bs._playlist.filter_playlist = new_filter_playlist + + +# ba_meta export babase.Plugin +class by_FluffyPal(Plugin): + def on_app_running(self) -> None: + apply_packages() diff --git a/plugins/utilities/glowing_profiles.py b/plugins/utilities/glowing_profiles.py new file mode 100644 index 0000000..799e7c8 --- /dev/null +++ b/plugins/utilities/glowing_profiles.py @@ -0,0 +1,222 @@ +# ba_meta require api 9 +from __future__ import annotations + +from typing import TYPE_CHECKING +import babase +import _babase +import bascenev1 as bs +from types import FunctionType +import enum + + +if TYPE_CHECKING: + from typing import Type, Callable, Any, Tuple + +plugman = dict( + plugin_name="glowing_profiles", + description="This plugin gives your profile glowlight, just like on some servers, but only offline", + external_url="https://m.youtube.com/watch?v=Jb_dKz99rhY", + authors=[ + {"name": "andrejkuroglo8", "email": "andrejkuroglo8@gmail.com", "discord": "andrewku"}, + ], + version="1.0.0", +) + + +def redefine_method(dst: Tuple[Any, str], src: Tuple[Any, str]) -> None: + if hasattr(getattr(*src), '__redefine_type') and getattr(*src).__redefine_type in ( + RedefineFlag.DECORATE_PRE, RedefineFlag.DECORATE_AFTER, RedefineFlag.DECORATE_ADVANCED): + new = getattr(*src) + old = getattr(*dst) + func: Callable + if getattr(*src).__redefine_type == RedefineFlag.DECORATE_AFTER: + def func(*args, **kwargs): + returned = old(*args, **kwargs) + return new(*args, **kwargs, returned=returned) + elif getattr(*src).__redefine_type == RedefineFlag.DECORATE_PRE: + def func(*args, **kwargs): + new(*args, **kwargs) + return old(*args, **kwargs) + else: + def func(*args, **kwargs): + return new(*args, **kwargs, old_function=old) + + setattr(*dst, func) + else: + setattr(*dst, getattr(*src)) + + # Fucking super()! + # dst.__code__ = CodeType( + # src.__code__.co_argcount, + # src.__code__.co_posonlyargcount, + # src.__code__.co_kwonlyargcount, + # src.__code__.co_nlocals, + # src.__code__.co_stacksize, + # src.__code__.co_flags, + # src.__code__.co_code, + # src.__code__.co_consts, + # src.__code__.co_names, + # src.__code__.co_varnames, + # src.__code__.co_filename, + # dst.__code__.co_name, + # src.__code__.co_firstlineno, + # src.__code__.co_lnotab, + # dst.__code__.co_freevars, + # dst.__code__.co_cellvars) + + +def redefine_class_methods(orig_cls: Type[object]) -> Callable[[Any], None]: + """Returns decorator that redefines all class methods + + Parameters: + orig_cls (Type[object]): class that will redefined""" + + def decorator(cls) -> None: + # for method in filter(lambda x: isinstance(getattr(cls, x), FunctionType), dir(cls)): + for method in cls._redefine_methods: + if hasattr(orig_cls, method): + redefine_method((orig_cls, method), (cls, method)) + else: + setattr(orig_cls, method, getattr(cls, method)) + # setattr(getattr(orig_cls, 'self'), '__class__', getattr(cls, method)) # Fucking super()!!!! + + return decorator + + +class RedefineFlag(enum.Enum): + REDEFINE = 0 + DECORATE_AFTER = 1 + DECORATE_PRE = 2 + DECORATE_ADVANCED = 3 + DECORATE = DECORATE_AFTER + + +def redefine_flag(*flags: RedefineFlag) -> Callable[[Callable], Callable]: + def decorator(func: Callable) -> Callable: + for flag in flags: + if flag in (RedefineFlag.DECORATE_AFTER, RedefineFlag.REDEFINE, RedefineFlag.DECORATE_PRE, + RedefineFlag.DECORATE_ADVANCED): + func.__redefine_type = flag + return func + + return decorator + + +def get_locale(*args): + return "Error" + + +@redefine_class_methods(bs.Chooser) +class Chooser: + _redefine_methods = ('_gcinit', '_get_glowing_colors', 'update_from_profile', + '_getname') + + def _gcinit(self): + if hasattr(self, '_gcinit_done'): + return + self.glow_dict = {} + self._markers = ('"', "'", '^', '%', ';', '`') + self._get_glowing_colors() + self._gcinit_done = True + + @redefine_flag(RedefineFlag.REDEFINE) + def _get_glowing_colors(self): + """Search glowing code among profiles.""" + try: + should_del = [] + for i in self._profilenames: + for m in self._markers: + if i.startswith(m + ','): + code = i.split(',') + self.glow_dict[code[0]] = ( + float(code[1]), + float(code[2]), + int(code[3]), + int(code[4])) + # should_del.append(i) + for i in should_del: + self._profilenames.remove(i) + except Exception as err: + print(err) + ba.screenmessage( + get_locale('init_glowing_code_error'), + color=(1, 0, 0), + clients=[self._player.get_input_device().client_id], + transient=True) + + @redefine_flag(RedefineFlag.DECORATE_ADVANCED) + def _getname(self, full=True, old_function=None): + name = old_function(self, full) + for m in self._markers: + name = name.replace(m, '') + return name + + @redefine_flag(RedefineFlag.DECORATE_ADVANCED) + def update_from_profile(self, old_function): + self._gcinit() + from bascenev1 import _profile + try: + self._profilename = self._profilenames[self._profileindex] + character = self._profiles[self._profilename]['character'] + + if self._profilename[0] in self.glow_dict: + if (character not in self._character_names + and character in _ba.app.spaz_appearances): + self._character_names.append(character) + self._character_index = self._character_names.index(character) + + player_glowing_dict = self.glow_dict[self._profilename[0]] + color_marker = player_glowing_dict[0] + color_marker = max(-999.0, min(color_marker, 50.0)) + + highlight_marker = float(player_glowing_dict[1]) + highlight_marker = max(-999.0, min(highlight_marker, 50.0)) + + stabilize_color = int(player_glowing_dict[2]) > 0 + stabilize_highlight = int(player_glowing_dict[3]) > 0 + self._color, self._highlight = \ + _profile.get_player_profile_colors( + self._profilename, + profiles=self._profiles) + + if stabilize_color: + m = max(self._color) + self._color = list(self._color) + for i in (0, 1, 2): + if self._color[i] == m: + self._color[i] = self._color[i] * color_marker + self._color = tuple(self._color) + else: + self._color = ( + self._color[0] * color_marker, + self._color[1] * color_marker, + self._color[2] * color_marker) + + if not stabilize_highlight: + self._highlight = ( + self._highlight[0] * highlight_marker, + self._highlight[1] * highlight_marker, + self._highlight[2] * highlight_marker) + else: + m = max(self._highlight) + self._highlight = list(self._highlight) + for i in (0, 1, 2): + if self._highlight[i] == m: + self._highlight[i] = \ + self._highlight[i] * highlight_marker + self._highlight = tuple(self._highlight) + else: + old_function(self) + except KeyError: + self.character_index = self._random_character_index + self._color = self._random_color + self._highlight = self._random_highlight + + self._update_icon() + self._update_text() + +# ba_meta export babase.Plugin + + +class Glowing(babase.Plugin): + pass diff --git a/plugins/utilities/powerup_manager.py b/plugins/utilities/powerup_manager.py index 2a81577..2dad733 100644 --- a/plugins/utilities/powerup_manager.py +++ b/plugins/utilities/powerup_manager.py @@ -1,2985 +1,2972 @@ -# ba_meta require api 9 -from __future__ import annotations - -import babase -import bauiv1 as bui -import bascenev1 as bs -import random -from bascenev1lib.actor import bomb -from bascenev1lib.actor import powerupbox as pupbox -from bascenev1lib.actor.spazbot import SpazBot -from bascenev1lib.actor.bomb import Bomb, Blast -from bauiv1lib.popup import PopupWindow, PopupMenuWindow, PopupMenu -from bascenev1lib.actor.spaz import ( - Spaz, - SpazFactory, - PickupMessage, - PunchHitMessage, - CurseExplodeMessage, - BombDiedMessage, -) -from bascenev1lib.mainmenu import MainMenuActivity, MainMenuSession -from bascenev1lib.gameutils import SharedObjects -from bascenev1lib.actor.powerupbox import PowerupBoxFactory -from bascenev1lib.actor.popuptext import PopupText -from bauiv1lib.confirm import ConfirmWindow -from bascenev1lib.actor.spaz import * - -from typing import TYPE_CHECKING - -plugman = dict( - plugin_name="powerup_manager", - description="This plugin add new modded powerups and features to manage them", - external_url="", - authors=[ - {"name": "ATD", "email": "anasdhaoidi001@gmail.com", "discord": ""}, - ], - version="1.0.0", -) - - -_sp_ = '\n' - -if TYPE_CHECKING: - pass - - -# === Mod updated by ATD and Less === - - -def getlanguage(text, subs: str = None, almacen: list = []): - if almacen == []: - almacen = list(range(1000)) - lang = bs.app.lang.language - translate = { - "Reset": {"Spanish": "Reiniciar", "English": "Reset", "Portuguese": "Reiniciar"}, - "Nothing": { - "Spanish": "Sin potenciadores", - "English": "No powerups", - "Portuguese": "Sem powerups", - }, - "Action 1": {"Spanish": "Potenciadores", "English": "Powerups", "Portuguese": "Powerups"}, - "Action 2": {"Spanish": "Configuración", "English": "Settings", "Portuguese": "Definições"}, - "Action 3": {"Spanish": "Extras", "English": "Extras", "Portuguese": "Extras"}, - "Action 4": {"Spanish": "Tienda", "English": "Store", "Portuguese": "Loja"}, - "Action 5": { - "Spanish": "Canjear código", - "English": "Enter Code", - "Portuguese": "Código promocional", - }, - "Custom": {"Spanish": "", "English": "Customize", "Portuguese": "Customizar"}, - "Impairment Bombs": { - "Spanish": "Bombas menoscabo", - "English": "Hyperactive bombs", - "Portuguese": "Bombas hiperativas", - }, - "Speed": {"Spanish": "Velocidad", "English": "Speed", "Portuguese": "Velocidade"}, - "Fire Bombs": { - "Spanish": "Bombas de fuego", - "English": "Fire Bombs", - "Portuguese": "Bombas de fogo", - }, - "Ice Man": { - "Spanish": "Hombre de hielo", - "English": "Ice man", - "Portuguese": "Homem de gelo", - }, - "Fly Bombs": { - "Spanish": "Bombas expansivas", - "English": "Expansive bombs", - "Portuguese": "Bombas expansivas", - }, - "Goodbye": {"Spanish": "¡Hasta luego!", "English": "Goodbye!", "Portuguese": "Adeus!"}, - "Healing Damage": { - "Spanish": "Auto-curación", - "English": "Healing Damage", - "Portuguese": "Auto-cura", - }, - "Tank Shield": { - "Spanish": "Súper blindaje", - "English": "Reinforced shield", - "Portuguese": "Escudo reforçado", - }, - "Tank Shield PTG": { - "Spanish": "Porcentaje de disminución", - "English": "Percentage decreased", - "Portuguese": "Percentual reduzido", - }, - "Healing Damage PTG": { - "Spanish": "Porcentaje de recuperación de salud", - "English": "Percentage of health recovered", - "Portuguese": "Porcentagem de recuperação de saúde", - }, - "SY: BALL": {"Spanish": "Esfera", "English": "Sphere", "Portuguese": "Esfera"}, - "SY: Impact": {"Spanish": "Especial", "English": "Special", "Portuguese": "Especial"}, - "SY: Egg": {"Spanish": "Huevito", "English": "Egg shape", "Portuguese": "Ovo"}, - "Powerup Scale": { - "Spanish": "Tamaño del potenciador", - "English": "Powerups size", - "Portuguese": "Tamanho de powerups", - }, - "Powerup With Shield": { - "Spanish": "Potenciadores con escudo", - "English": "Powerups with shield", - "Portuguese": "Powerups com escudo", - }, - "Powerup Time": { - "Spanish": "Mostrar Temporizador", - "English": "Show end time", - "Portuguese": "Mostrar cronômetro", - }, - "Powerup Style": { - "Spanish": "Forma de los potenciadores", - "English": "Shape of powerup", - "Portuguese": "Forma de powerup", - }, - "Powerup Name": { - "Spanish": "Mostrar nombre en los potenciadores", - "English": "Show name on powerups", - "Portuguese": "Mostrar nome em powerups", - }, - "Percentage": { - "Spanish": "Probabilidad", - "English": "Show percentage", - "Portuguese": "Mostrar porcentagem", - }, - "Only Items": { - "Spanish": "Sólo Accesorios", - "English": "Only utensils", - "Portuguese": "Apenas utensilios", - }, - "New": {"Spanish": "Nuevo", "English": "New", "Portuguese": "Novo"}, - "Only Bombs": { - "Spanish": "Sólo Bombas", - "English": "Only bombs", - "Portuguese": "Apenas bombas", - }, - "Coins 0": { - "Spanish": "Monedas Insuficientes", - "English": "Insufficient coins", - "Portuguese": "Moedas insuficientes", - }, - "Purchase": { - "Spanish": "Compra realizada correctamente", - "English": "Successful purchase", - "Portuguese": "Compra Bem Sucedida", - }, - "Double Product": { - "Spanish": "Ya has comprado este artículo", - "English": "You've already bought this", - "Portuguese": "Voce ja comprou isto", - }, - "Bought": {"Spanish": "Comprado", "English": "Bought", "Portuguese": "Comprou"}, - "Confirm Purchase": { - "Spanish": f'Tienes {subs} monedas. {_sp_} ¿Deseas comprar esto?', - "English": f'You have {subs} coins. {_sp_} Do you want to buy this?', - "Portuguese": f'Você tem {subs} moedas. {_sp_} Deseja comprar isto?', - }, - "FireBombs Store": { - "Spanish": 'Bombas de fuego', - "English": 'Fire bombs', - "Portuguese": 'Bombas de incêndio', - }, - "Timer Store": {"Spanish": 'Temporizador', "English": 'Timer', "Portuguese": 'Timer'}, - "Percentages Store": {"Spanish": 'Extras', "English": 'Extras', "Portuguese": 'Extras'}, - "Block Option Store": { - "Spanish": f"Uuups..{_sp_}Esta opción está bloqueada.{_sp_} Para acceder a ella puedes {_sp_} comprarla en la tienda.{_sp_} Gracias...", - "English": f"Oooops...{_sp_}This option is blocked. {_sp_} To access it you can buy {_sp_} it in the store.{_sp_} Thank you...", - "Portuguese": f"Ooops...{_sp_}Esta opção está bloqueada. {_sp_} Para acessá-lo, você pode {_sp_} comprá-lo na loja.{_sp_} Obrigado...", - }, - "True Code": { - "Spanish": "¡Código canjeado!", - "English": "Successful code!", - "Portuguese": "¡Código válido!", - }, - "False Code": { - "Spanish": "Código ya canjeado", - "English": "Expired code", - "Portuguese": "Código expirado", - }, - "Invalid Code": { - "Spanish": "Código inválido", - "English": "Invalid code", - "Portuguese": "Código inválido", - }, - "Reward Code": { - "Spanish": f"¡Felicitaciones! ¡Ganaste {subs} monedas!", - "English": f"Congratulations! You've {subs} coins", - "Portuguese": f"Parabéns! Você tem {subs} moedas", - }, - "Creator": { - "Spanish": "Mod edited by ATD", - "English": "Mod edited by ATD", - "Portuguese": "Mod edited by ATD", - }, - "Mod Info": { - "Spanish": f"Un mod genial que te permite gestionar {_sp_} los potenciadores a tu antojo. {_sp_} también incluye 8 potenciadores extra{_sp_} dejando 17 en total... ¡Guay!", - "English": f"A cool mod that allows you to manage {_sp_} powerups at your whims. {_sp_} also includes 8 extra powerups{_sp_} leaving 17 in total... Wow!", - "Portuguese": f"Um mod legal que permite que você gerencie os{_sp_} powerups de de acordo com seus caprichos. {_sp_} também inclui 8 powerups extras,{_sp_} deixando 17 no total... Uau!", - }, - "Coins Message": { - "Spanish": f"Recompensa: {subs} Monedas", - "English": f"Reward: {subs} Coins", - "Portuguese": f"Recompensa: {subs} Moedas", - }, - "Coins Limit Message": { - "Spanish": f"Ganaste {almacen[0]} Monedas.{_sp_} Pero has superado el límite de {almacen[1]}", - "English": f"You won {almacen[0]} Coins. {_sp_} But you have exceeded the limit of {almacen[1]}", - "Portuguese": f"Você ganhou {almacen[0]} Moedas. {_sp_} Mas você excedeu o limite de {almacen[1]}", - }, - } - languages = ['Spanish', 'Portuguese', 'English'] - if lang not in languages: - lang = 'English' - - if text not in translate: - return text - - return translate[text][lang] - - -def settings_distribution(): - return { - "Powers Gravity": False, - "Tank Shield PTG": 96, - "Healing Damage PTG": 72, - "Powerup Style": 'Auto', - "Powerup Scale": 1.0, - "Powerup Name": False, - "Powerup With Shield": False, - "Powerup Time": False, - } - - -apg = babase.app.config -if "PPM Settings" in apg: - old = apg['PPM Settings'] - for settings in settings_distribution(): - if settings not in old: - apg['PPM Settings'] = settings_distribution() -else: - apg['PPM Settings'] = settings_distribution() -apg.apply_and_commit() - -config = apg['PPM Settings'] - - -def default_powerups(): - return { - "Shield": 2, - "Punch": 3, - "Mine Bombs": 2, - "Impact Bombs": 3, - "Ice Bombs": 3, - "Triple": 3, - "Sticky Bombs": 3, - "Curse": 1, - "Health": 1, - "Speed": 2, - "Healing Damage": 1, - "Goodbye": 2, - "Ice Man": 1, - "Tank Shield": 1, - "Impairment Bombs": 2, - "Fire Bombs": 3, - "Fly Bombs": 3, - } - - -if "Powerups" in config: - p_old = config['Powerups'] - for powerups in default_powerups(): - if powerups not in p_old: - config['Powerups'] = default_powerups() -else: - config['Powerups'] = default_powerups() -apg.apply_and_commit() - -powerups = config['Powerups'] - -# === EXTRAS === - -GLOBAL = {"Tab": 'Action 1', "Cls Powerup": 0, "Coins Message": []} - - -# === STORE === -def promo_codes(): - return { - "G-Am54igO42Os": [True, 1100], - "P-tRo8nM8dZ": [True, 2800], - "Y-tU2B3S": [True, 500], - "B-0mB3RYT2z": [True, 910], - "B-Asd14mON9G0D": [True, 910], - "D-rAcK0cJ23": [True, 910], - "E-a27ZO6f3Y": [True, 600], - "E-Am54igO42Os": [True, 600], - "E-M4uN3K34XB": [True, 840], - "PM-731ClcAF": [True, 50000], - } - - -def store_items(): - return {"Buy Firebombs": True, "Buy Option": True, "Buy Percentage": True} - - -if apg.get('Bear Coin') is None: - apg['Bear Coin'] = 0 - apg.apply_and_commit() - -if apg.get('Bear Coin') is not None: - if apg['Bear Coin'] <= 0: - apg['Bear Coin'] = 0 - apg['Bear Coin'] = int(apg['Bear Coin']) - -if apg.get('Bear Store') is None: - apg['Bear Store'] = {} - -for i, j in store_items().items(): - store = apg['Bear Store'] - if i not in store: - if store.get(i) is None: - store[i] = j - apg.apply_and_commit() - -STORE = apg['Bear Store'] - -if STORE.get('Promo Code') is None: - STORE['Promo Code'] = promo_codes() - -for i, x in promo_codes().items(): - pmcode = STORE['Promo Code'] - if i not in pmcode: - if pmcode.get(i) is None: - pmcode[i] = x - -apg.apply_and_commit() - - -class BearStore: - def __init__(self, price: int = 1000, value: str = '', callback: call = None): - - self.price = price - self.value = value - self.store = STORE[value] - self.coins = apg['Bear Coin'] - self.callback = callback - - def buy(self): - if not self.store: - if self.coins >= (self.price): - - def confirm(): - STORE[self.value] = True - apg['Bear Coin'] -= int(self.price) - bs.broadcastmessage(getlanguage('Purchase'), (0, 1, 0)) - bui.getsound('cashRegister').play() - apg.apply_and_commit() - self.callback() - - ConfirmWindow( - getlanguage('Confirm Purchase', subs=self.coins), - width=400, - height=120, - action=confirm, - ok_text=babase.Lstr(resource='okText'), - ) - else: - bs.broadcastmessage(getlanguage('Coins 0'), (1, 0, 0)) - bui.getsound('error').play() - else: - bs.broadcastmessage(getlanguage('Double Product'), (1, 0, 0)) - bui.getsound('error').play() - - def __del__(self): - apg['Bear Coin'] = int(apg['Bear Coin']) - apg.apply_and_commit() - - -class PromoCode: - def __init__(self, code: str = ''): - self.code = code - self.codes_store = STORE['Promo Code'] - if self.code in self.codes_store: - self.code_type = STORE['Promo Code'][code] - self.promo_code_expire = self.code_type[0] - self.promo_code_amount = self.code_type[1] - - def __del__(self): - apg['Bear Coin'] = int(apg['Bear Coin']) - apg.apply_and_commit() - - def code_confirmation(self): - if self.code != "": - bs.broadcastmessage(babase.Lstr(resource='submittingPromoCodeText'), (0, 1, 0)) - try: - babase.pushcall(babase.CallPartial(self.validate_code), from_other_thread=True) - except: - pass - - def validate_code(self): - if self.code in self.codes_store: - if self.promo_code_expire: - with babase.ContextRef.empty(): - babase.pushcall( - babase.CallPartial(self.successful_code), from_other_thread=True - ) - bs.broadcastmessage(getlanguage('True Code'), (0, 1, 0)) - bui.getsound('cheer').play() - self.code_type[0] = False - else: - bs.broadcastmessage(getlanguage('False Code'), (1, 0, 0)) - bui.getsound('error').play() - else: - bs.broadcastmessage(getlanguage('Invalid Code'), (1, 0, 0)) - bui.getsound('error').play() - - def successful_code(self): - apg['Bear Coin'] += self.promo_code_amount - bs.broadcastmessage(getlanguage('Reward Code', subs=self.promo_code_amount), (0, 1, 0)) - bui.getsound('cashRegister2').play() - - -MainMenuActivity.super_transition_in = MainMenuActivity.on_transition_in - - -def new_on_transition_in(self): - self.super_transition_in() - limit = 8400 - bear_coin = apg['Bear Coin'] - coins_message = GLOBAL['Coins Message'] - try: - if not (STORE['Buy Firebombs'] and STORE['Buy Option'] and STORE['Buy Percentage']): - - if coins_message != []: - result = 0 - for i in coins_message: - result += i - - if not bear_coin >= (limit - 1): - bs.broadcastmessage(getlanguage('Coins Message', subs=result), (0, 1, 0)) - bui.getsound('cashRegister').play() - else: - bs.broadcastmessage( - getlanguage('Coins Limit Message', almacen=[result, limit]), (1, 0, 0) - ) - bui.getsound('error').play() - self.bear_coin_message = True - GLOBAL['Coins Message'] = [] - except: - pass - - -SpazBot.super_handlemessage = SpazBot.handlemessage - - -def bot_handlemessage(self, msg: Any): - self.super_handlemessage(msg) - if isinstance(msg, bs.DieMessage): - if not self.die: - self.die = True - self.limit = 8400 - self.free_coins = random.randint(1, 25) - self.bear_coins = apg['Bear Coin'] - - if not self.bear_coins >= (self.limit): - self.bear_coins += self.free_coins - GLOBAL['Coins Message'].append(self.free_coins) - - if self.bear_coins >= (self.limit): - self.bear_coins = self.limit - - apg['Bear Coin'] = int(self.bear_coins) - apg.apply_and_commit() - - else: - GLOBAL['Coins Message'].append(self.free_coins) - - -def cls_pow_color(): - return [ - (1, 0.1, 0.1), - (0.1, 0.5, 0.9), - (0.1, 0.9, 0.9), - (0.1, 0.9, 0.1), - (0.1, 1, 0.5), - (1, 1, 0.2), - (2, 0.5, 0.5), - (1, 0, 6), - ] - - -def random_color(): - a = random.random() * 3 - b = random.random() * 3 - c = random.random() * 3 - return (a, b, c) - - -def powerup_dist(): - return ( - ('triple_bombs', powerups['Triple']), - ('ice_bombs', powerups['Ice Bombs']), - ('punch', powerups['Punch']), - ('impact_bombs', powerups['Impact Bombs']), - ('land_mines', powerups['Mine Bombs']), - ('sticky_bombs', powerups['Sticky Bombs']), - ('shield', powerups['Shield']), - ('health', powerups['Health']), - ('curse', powerups['Curse']), - ('speed', powerups['Speed']), - ('health_damage', powerups['Healing Damage']), - ('goodbye', powerups['Goodbye']), - ('ice_man', powerups['Ice Man']), - ('tank_shield', powerups['Tank Shield']), - ('impairment_bombs', powerups['Impairment Bombs']), - ('fire_bombs', powerups['Fire Bombs']), - ('fly_bombs', powerups['Fly Bombs']), - ) - - -def percentage_tank_shield(): - percentage = config['Tank Shield PTG'] - percentage_text = ('0.') + str(percentage) - return float(percentage_text) - - -def percentage_health_damage(): - percentage = config['Healing Damage PTG'] - percentage_text = ('0.') + str(percentage) - return float(percentage_text) - - -# === Modify class === - - -class NewProfileBrowserWindow: - def __init__( - self, - transition: str = 'in_right', - in_main_menu: bool = True, - selected_profile: str = None, - origin_widget: bui.Widget = None, - ): - super().__init__(transition, in_main_menu, selected_profile, origin_widget) - - self.session = bs.get_foreground_host_session() - uiscale = bui.app.ui_v1.uiscale - width = 100 if uiscale is babase.UIScale.SMALL else -14 - size = 50 - position = (width * 1.65, 300) - - if isinstance(self.session, MainMenuSession): - self.button = bui.buttonwidget( - parent=self._root_widget, - autoselect=True, - position=position, - size=(size, size), - button_type='square', - label='', - on_activate_call=babase.CallPartial(self.powerupmanager_window), - ) - - size = size * 0.60 - self.image = bui.imagewidget( - parent=self._root_widget, - size=(size, size), - draw_controller=self.button, - position=(position[0] + 10.5, position[1] + 17), - texture=bui.gettexture('powerupSpeed'), - ) - - self.text = bui.textwidget( - parent=self._root_widget, - position=(position[0] + 25, position[1] + 10), - size=(0, 0), - scale=0.45, - color=(0.7, 0.9, 0.7, 1.0), - draw_controller=self.button, - maxwidth=60, - text=(f"Ultimate Powerup {_sp_}Manager"), - h_align='center', - v_align='center', - ) - - def powerupmanager_window(self): - bui.containerwidget(edit=self._root_widget, transition='out_left') - PowerupManagerWindow() - - -class NewPowerupBoxFactory(pupbox.PowerupBoxFactory): - def __init__(self) -> None: - super().__init__() - self.tex_speed = bs.gettexture('powerupSpeed') - self.tex_health_damage = bs.gettexture('heart') - self.tex_goodbye = bs.gettexture('achievementOnslaught') - self.tex_ice_man = bs.gettexture('ouyaUButton') - self.tex_tank_shield = bs.gettexture('achievementSuperPunch') - self.tex_impairment_bombs = bs.gettexture('levelIcon') - self.tex_fire_bombs = bs.gettexture('ouyaOButton') - self.tex_fly_bombs = bs.gettexture('star') - - self._powerupdist = [] - for powerup, freq in powerup_dist(): - for _i in range(int(freq)): - self._powerupdist.append(powerup) - - def get_random_powerup_type(self, forcetype=None, excludetypes=None): - - try: - self.mapa = bs.getactivity()._map.getname() - except: - self.mapa = None - - speed_banned_maps = ['Hockey Stadium', 'Lake Frigid', 'Happy Thoughts'] - - if self.mapa in speed_banned_maps: - powerup_disable = ['speed'] - else: - powerup_disable = [] - - if excludetypes is None: - excludetypes = [] - if forcetype: - ptype = forcetype - else: - if self._lastpoweruptype == 'curse': - ptype = 'health' - else: - while True: - ptype = self._powerupdist[random.randint(0, len(self._powerupdist) - 1)] - if ptype not in excludetypes and ptype not in powerup_disable: - break - self._lastpoweruptype = ptype - return ptype - - -def fire_effect(self): - if self.node.exists(): - bs.emitfx( - position=self.node.position, scale=3, count=50 * 2, spread=0.3, chunk_type='sweat' - ) - else: - self.fire_effect_time = None - - -# BOMBS -Bomb._pm_old_bomb = Bomb.__init__ - - -def _bomb_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, - bomb_scale: float = 1.0, - source_player: bs.Player = None, - owner: bs.Node = None, -): - - self.bm_type = bomb_type - new_bomb_type = 'ice' if bomb_type in ['ice_bubble', 'impairment', 'fire', 'fly'] else bomb_type - - # Call original __init__ - self._pm_old_bomb( - position=position, - velocity=velocity, - bomb_type=new_bomb_type, - blast_radius=blast_radius, - bomb_scale=bomb_scale, - source_player=source_player, - owner=owner, - ) - - tex = self.node.color_texture - - if self.bm_type == 'ice_bubble': - self.bomb_type = self.bm_type - self.node.mesh = None - self.shield_ice = bs.newnode( - 'shield', owner=self.node, attrs={'color': (0.5, 1.0, 7.0), 'radius': 0.6} - ) - self.node.connectattr('position', self.shield_ice, 'position') - - elif self.bm_type == 'fire': - self.bomb_type = self.bm_type - self.node.mesh = None - self.shield_fire = bs.newnode( - 'shield', owner=self.node, attrs={'color': (6.5, 6.5, 2.0), 'radius': 0.6} - ) - self.node.connectattr('position', self.shield_fire, 'position') - self.fire_effect_time = bs.Timer(0.1, bs.WeakCallPartial(fire_effect, self), repeat=True) - elif self.bm_type == 'impairment': - self.bomb_type = self.bm_type - tex = bs.gettexture('eggTex3') - elif self.bm_type == 'fly': - self.bomb_type = self.bm_type - tex = bs.gettexture('eggTex1') - - if 'tex' in locals(): - self.node.color_texture = tex - self.hit_subtype = self.bomb_type - - if self.bomb_type == 'ice_bubble': - self.blast_radius *= 1.2 - elif self.bomb_type == 'fly': - self.blast_radius *= 2.2 - - -def bomb_handlemessage(self, msg: Any) -> Any: - assert not self.expired - - if isinstance(msg, bs.DieMessage): - if self.node: - self.node.delete() - - elif isinstance(msg, bomb.ExplodeHitMessage): - node = bs.getcollision().opposingnode - assert self.node - nodepos = self.node.position - mag = 2000.0 - if self.blast_type in ('ice', 'ice_bubble'): - mag *= 0.5 - elif self.blast_type == 'land_mine': - mag *= 2.5 - elif self.blast_type == 'tnt': - mag *= 2.0 - elif self.blast_type == 'fire': - mag *= 0.6 - elif self.blast_type == 'fly': - mag *= 5.5 - - node.handlemessage( - bs.HitMessage( - pos=nodepos, - velocity=(0, 0, 0), - magnitude=mag, - hit_type=self.hit_type, - hit_subtype=self.hit_subtype, - radius=self.radius, - source_player=babase.existing(self._source_player), - ) - ) - if self.blast_type in ('ice', 'ice_bubble'): - bomb.BombFactory.get().freeze_sound.play(10) - node.handlemessage(bs.FreezeMessage()) - - return None - - -def powerup_translated(self, type: str): - powerups_names = { - 'triple_bombs': babase.Lstr(resource='helpWindow.' + 'powerupBombNameText'), - 'ice_bombs': babase.Lstr(resource='helpWindow.' + 'powerupIceBombsNameText'), - 'punch': babase.Lstr(resource='helpWindow.' + 'powerupPunchNameText'), - 'impact_bombs': babase.Lstr(resource='helpWindow.' + 'powerupImpactBombsNameText'), - 'land_mines': babase.Lstr(resource='helpWindow.' + 'powerupLandMinesNameText'), - 'sticky_bombs': babase.Lstr(resource='helpWindow.' + 'powerupStickyBombsNameText'), - 'shield': babase.Lstr(resource='helpWindow.' + 'powerupShieldNameText'), - 'health': babase.Lstr(resource='helpWindow.' + 'powerupHealthNameText'), - 'curse': babase.Lstr(resource='helpWindow.' + 'powerupCurseNameText'), - 'speed': getlanguage('Speed'), - 'health_damage': getlanguage('Healing Damage'), - 'goodbye': getlanguage('Goodbye'), - 'ice_man': getlanguage('Ice Man'), - 'tank_shield': getlanguage('Tank Shield'), - 'impairment_bombs': getlanguage('Impairment Bombs'), - 'fire_bombs': getlanguage('Fire Bombs'), - 'fly_bombs': getlanguage('Fly Bombs'), - } - self.texts['Name'].text = powerups_names[type] - - -# POWERUP -pupbox.PowerupBox._old_pbx_ = pupbox.PowerupBox.__init__ - - -def _pbx_( - self, - position: Sequence[float] = (0.0, 1.0, 0.0), - poweruptype: str = 'triple_bombs', - expire: bool = True, -): - self.news: list = [] - for x, i in powerup_dist(): - self.news.append(x) - - self.box: list = [] - self.texts = {} - self.news = self.news[9:] - self.box.append(poweruptype) - self.npowerup = self.box[0] - factory = NewPowerupBoxFactory.get() - - if self.npowerup in self.news: - new_poweruptype = 'shield' - else: - new_poweruptype = poweruptype - self._old_pbx_(position, new_poweruptype, expire) - - type = new_poweruptype - tex = self.node.color_texture - mesh = self.node.mesh - - if self.npowerup == 'speed': - type = self.npowerup - tex = factory.tex_speed - elif self.npowerup == 'health_damage': - type = self.npowerup - tex = factory.tex_health_damage - elif self.npowerup == 'goodbye': - type = self.npowerup - tex = factory.tex_goodbye - elif self.npowerup == 'ice_man': - type = self.npowerup - tex = factory.tex_ice_man - elif self.npowerup == 'tank_shield': - type = self.npowerup - tex = factory.tex_tank_shield - elif self.npowerup == 'impairment_bombs': - type = self.npowerup - tex = factory.tex_impairment_bombs - elif self.npowerup == 'fire_bombs': - type = self.npowerup - tex = factory.tex_fire_bombs - elif self.npowerup == 'fly_bombs': - type = self.npowerup - tex = factory.tex_fly_bombs - - self.poweruptype = type - self.node.mesh = mesh - self.node.color_texture = tex - n_scale = config['Powerup Scale'] - style = config['Powerup Style'] - - curve = bs.animate(self.node, 'mesh_scale', {0: 0, 0.14: 1.6, 0.2: n_scale}) - bs.timer(0.2, curve.delete) - - def util_text( - type: str, - text: str, - scale: float = 1, - color: list = [1, 1, 1], - position: list = [0, 0.7, 0], - colors_name: bool = False, - ): - m = bs.newnode( - 'math', - owner=self.node, - attrs={'input1': (position[0], position[1], position[2]), 'operation': 'add'}, - ) - self.node.connectattr('position', m, 'input2') - self.texts[type] = bs.newnode( - 'text', - owner=self.node, - attrs={ - 'text': str(text), - 'in_world': True, - 'scale': 0.02, - 'shadow': 0.5, - 'flatness': 1.0, - 'color': (color[0], color[1], color[2]), - 'h_align': 'center', - }, - ) - m.connectattr('output', self.texts[type], 'position') - bs.animate(self.texts[type], 'scale', {0: 0.017, 0.4: 0.017, 0.5: 0.01 * scale}) - - if colors_name: - bs.animate_array( - self.texts[type], - 'color', - 3, - { - 0: (1, 0, 0), - 0.2: (1, 0.5, 0), - 0.4: (1, 1, 0), - 0.6: (0, 1, 0), - 0.8: (0, 1, 1), - 1.0: (1, 0, 1), - 1.2: (1, 0, 0), - }, - loop=True, - ) - - def update_time(time): - if hasattr(self, 'texts') and 'Time' in self.texts and self.texts['Time']: - self.texts['Time'].text = str(time) - - if config['Powerup Time']: - interval = int(pupbox.DEFAULT_POWERUP_INTERVAL) - time2 = interval - 1 - time = 1 - - util_text( - 'Time', time2, scale=1.5, color=(2, 2, 2), position=[0, 0.9, 0], colors_name=False - ) - - while interval + 3: - bs.timer(time - 1, babase.CallPartial(update_time, f'{time2}s')) - - if time2 == 0: - break - - time += 1 - time2 -= 1 - - if config['Powerup With Shield']: - scale = config['Powerup Scale'] - self.shield = bs.newnode( - 'shield', owner=self.node, attrs={'color': (1, 1, 0), 'radius': 1.3 * scale} - ) - self.node.connectattr('position', self.shield, 'position') - bs.animate_array( - self.shield, - 'color', - 3, - {0: (2, 0, 0), 0.5: (0, 2, 0), 1: (0, 1, 6), 1.5: (2, 0, 0)}, - loop=True, - ) - - if config['Powerup Name']: - util_text('Name', self.poweruptype, scale=1.2, position=[0, 0.4, 0], colors_name=True) - powerup_translated(self, self.poweruptype) - - if style == 'SY: BALL': - self.node.mesh = bs.getmesh('frostyPelvis') - elif style == 'SY: Impact': - self.node.mesh = bs.getmesh('impactBomb') - elif style == 'SY: Egg': - self.node.mesh = bs.getmesh('egg') - - -# SPAZ -def _speed_off_flash(self): - if self.node: - factory = NewPowerupBoxFactory.get() - self.node.billboard_texture = factory.tex_speed - self.node.billboard_opacity = 1.0 - self.node.billboard_cross_out = True - - -def _speed_wear_off(self): - if self.node: - self.node.hockey = False - self.node.billboard_opacity = 0.0 - bui.getsound('powerdown01').play() - - -def _ice_man_off_flash(self): - if self.node: - factory = NewPowerupBoxFactory.get() - self.node.billboard_texture = factory.tex_ice_man - self.node.billboard_opacity = 1.0 - self.node.billboard_cross_out = True - - -def _ice_man_wear_off(self): - if self.node: - f = self.color[0] - i = (0, 1, 4) - - bomb = self.bmb_color[0] - if bomb != 'ice_bubble': - self.bomb_type = bomb - else: - self.bomb_type = 'normal' - - self.freeze_punch = False - self.node.billboard_opacity = 0.0 - bs.animate_array(self.node, 'color', 3, {0: f, 0.3: i, 0.6: f}) - bui.getsound('powerdown01').play() - - -Spaz._pm2_spz_old = Spaz.__init__ - - -def _init_spaz_(self, *args, **kwargs): - self._pm2_spz_old(*args, **kwargs) - self.edg_eff = False - self.kill_eff = False - self.freeze_punch = False - self.die = False - self.color: list = [] - self.color.append(self.node.color) - - self.tankshield = {"Tank": False, "Reduction": False, "Shield": None} - - -Spaz._super_on_punch_press = Spaz.on_punch_press - - -def spaz_on_punch_press(self) -> None: - self._super_on_punch_press() - - if self.tankshield['Tank']: - try: - self.tankshield['Reduction'] = True - - shield = bs.newnode( - 'shield', owner=self.node, attrs={'color': (4, 1, 4), 'radius': 1.3} - ) - self.node.connectattr('position_center', shield, 'position') - - self.tankshield['Shield'] = shield - except: - pass - - -Spaz._super_on_punch_release = Spaz.on_punch_release - - -def spaz_on_punch_release(self) -> None: - self._super_on_punch_release() - try: - self.tankshield['Shield'].delete() - self.tankshield['Reduction'] = False - except: - pass - - -def new_get_bomb_type_tex(self) -> babase.Texture: - factory = NewPowerupBoxFactory.get() - if self.bomb_type == 'sticky': - return factory.tex_sticky_bombs - if self.bomb_type == 'ice': - return factory.tex_ice_bombs - if self.bomb_type == 'impact': - return factory.tex_impact_bombs - if self.bomb_type == 'impairment': - return factory.tex_impairment_bombs - if self.bomb_type == 'fire': - return factory.tex_fire_bombs - if self.bomb_type == 'fly': - return factory.tex_fly_bombs - return None - - -def new_handlemessage(self, msg: Any) -> Any: - assert not self.expired - - if isinstance(msg, bs.PickedUpMessage): - if self.node: - self.node.handlemessage('hurt_sound') - self.node.handlemessage('picked_up') - - self._num_times_hit += 1 - - elif isinstance(msg, bs.ShouldShatterMessage): - bs.timer(0.001, bs.WeakCallPartial(self.shatter)) - - elif isinstance(msg, bs.ImpactDamageMessage): - bs.timer(0.001, bs.WeakCallPartial(self._hit_self, msg.intensity)) - elif isinstance(msg, bs.PowerupMessage): - factory = NewPowerupBoxFactory.get() - if self._dead or not self.node: - return True - if self.pick_up_powerup_callback is not None: - self.pick_up_powerup_callback(self) - if msg.poweruptype == 'triple_bombs': - tex = PowerupBoxFactory.get().tex_bomb - self._flash_billboard(tex) - self.set_bomb_count(3) - if self.powerups_expire: - self.node.mini_billboard_1_texture = tex - t_ms = int(bs.time() * 1000) - 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 = bs.Timer( - (POWERUP_WEAR_OFF_TIME - 2000), - babase.CallPartial(self._multi_bomb_wear_off_flash), - ) - self._multi_bomb_wear_off_timer = bs.Timer( - POWERUP_WEAR_OFF_TIME, babase.CallPartial(self._multi_bomb_wear_off) - ) - 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 = int(bs.time() * 1000) - 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 = bs.Timer( - POWERUP_WEAR_OFF_TIME - 2000, babase.CallPartial(self._bomb_wear_off_flash) - ) - self._bomb_wear_off_timer = bs.Timer( - POWERUP_WEAR_OFF_TIME, babase.CallPartial(self._bomb_wear_off) - ) - 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 = int(bs.time() * 1000) - 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 = bs.Timer( - POWERUP_WEAR_OFF_TIME - 2000, babase.CallPartial(self._bomb_wear_off_flash) - ) - self._bomb_wear_off_timer = bs.Timer( - POWERUP_WEAR_OFF_TIME, babase.CallPartial(self._bomb_wear_off) - ) - elif msg.poweruptype == 'punch': - self._has_boxing_gloves = True - tex = PowerupBoxFactory.get().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 = int(bs.time() * 1000) - 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 = bs.Timer( - POWERUP_WEAR_OFF_TIME - 2000, bs.WeakCallPartial(self._gloves_wear_off_flash) - ) - self._boxing_gloves_wear_off_timer = bs.Timer( - POWERUP_WEAR_OFF_TIME, - bs.WeakCallPartial(self._gloves_wear_off), - ) - elif msg.poweruptype == 'shield': - factory = SpazFactory.get() - 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 = bs.time() * 1000 - 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 = bs.Timer( - (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, - bs.WeakCallPartial(self._bomb_wear_off_flash), - ) - - self._bomb_wear_off_timer = bs.Timer( - POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCallStrict(self._bomb_wear_off) - ) - - elif msg.poweruptype == 'health': - if self.edg_eff: - f = self.color[0] - r = (2, 0, 0) - g = (0, 2, 0) - bs.animate_array(self.node, 'color', 3, {0: r, 0.6: g, 1.0: f}) - self.edg_eff = False - if self._cursed: - self._cursed = False - factory = SpazFactory.get() - 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(PowerupBoxFactory.get().tex_health) - self.node.hurt = 0 - self._last_hit_time = None - self._num_times_hit = 0 - - elif msg.poweruptype == 'tank_shield': - self.tankshield['Tank'] = True - self.edg_eff = False - tex = factory.tex_tank_shield - self._flash_billboard(tex) - - elif msg.poweruptype == 'health_damage': - tex = factory.tex_health_damage - self.edg_eff = True - f = self.color[0] - i = (2, 0.5, 2) - bs.animate_array(self.node, 'color', 3, {0: i, 0.5: i, 0.6: f}) - self._flash_billboard(tex) - self.tankshield['Tank'] = False - self.freeze_punch = False - - elif msg.poweruptype == 'goodbye': - tex = factory.tex_goodbye - self._flash_billboard(tex) - self.kill_eff = True - - elif msg.poweruptype == 'fly_bombs': - self.bomb_type = 'fly' - tex = self._get_bomb_type_tex() - self._flash_billboard(tex) - if self.powerups_expire: - self.node.mini_billboard_2_texture = tex - t_ms = bs.time() * 1000 - 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 = bs.Timer( - (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, - bs.WeakCallStrict(self._bomb_wear_off_flash), - ) - - self._bomb_wear_off_timer = bs.Timer( - POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCallStrict(self._bomb_wear_off) - ) - - elif msg.poweruptype == 'fire_bombs': - self.bomb_type = 'fire' - tex = self._get_bomb_type_tex() - self._flash_billboard(tex) - if self.powerups_expire: - self.node.mini_billboard_2_texture = tex - t_ms = bs.time() * 1000 - 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 = bs.Timer( - (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, - bs.WeakCallStrict(self._bomb_wear_off_flash), - ) - - self._bomb_wear_off_timer = bs.Timer( - POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCallStrict(self._bomb_wear_off) - ) - - elif msg.poweruptype == 'impairment_bombs': - self.bomb_type = 'impairment' - tex = self._get_bomb_type_tex() - self._flash_billboard(tex) - if self.powerups_expire: - self.node.mini_billboard_2_texture = tex - t_ms = bs.time() * 1000 - 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 = bs.Timer( - (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, - bs.WeakCallStrict(self._bomb_wear_off_flash), - ) - - self._bomb_wear_off_timer = bs.Timer( - POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCallStrict(self._bomb_wear_off) - ) - - elif msg.poweruptype == 'ice_man': - tex = factory.tex_ice_man - self.bomb_type = 'ice_bubble' - self.freeze_punch = True - self.edg_eff = False - self.node.color = (0, 1, 4) - self._flash_billboard(tex) - - if self.powerups_expire: - ice_man_time = 17000 - self.node.mini_billboard_2_texture = tex - t_ms = bs.time() * 1000 - assert isinstance(t_ms, int) - self.node.mini_billboard_2_start_time = t_ms - self.node.mini_billboard_2_end_time = t_ms + ice_man_time - - self.ice_man_flash_timer = bs.Timer( - (ice_man_time - 2000) / 1000.0, babase.CallPartial(_ice_man_off_flash, self) - ) - - self.ice_man_timer = bs.Timer( - ice_man_time / 1000.0, babase.CallPartial(_ice_man_wear_off, self) - ) - - elif msg.poweruptype == 'speed': - self.node.hockey = True - tex = factory.tex_speed - self._flash_billboard(tex) - if self.powerups_expire: - speed_time = 15000 - self.node.mini_billboard_2_texture = tex - t_ms = bs.time() * 1000 - assert isinstance(t_ms, int) - self.node.mini_billboard_2_start_time = t_ms - self.node.mini_billboard_2_end_time = t_ms + speed_time - - self.speed_flash_timer = bs.Timer( - (speed_time - 2000) / 1000.0, babase.Call(_speed_off_flash, self) - ) - - self.speed_timer = bs.Timer( - speed_time / 1000.0, bs.WeakCallPartial(_speed_wear_off, self) - ) - - self.bmb_color: list = [] - self.bmb_color.append(self.bomb_type) - - self.node.handlemessage('flash') - if msg.sourcenode: - msg.sourcenode.handlemessage(bs.PowerupAcceptMessage()) - return True - - elif isinstance(msg, bs.FreezeMessage): - if not self.node: - return None - if self.node.invincible: - SpazFactory.get().block_sound.play(1.0, self.node.position) - return None - if self.shield: - return None - if not self.frozen: - self.frozen = True - self.node.frozen = True - bs.timer(5.0, bs.WeakCallPartial(self.handlemessage, bs.ThawMessage())) - if self.hitpoints <= 0: - self.shatter() - if self.freeze_punch: - self.handlemessage(bs.ThawMessage()) - - elif isinstance(msg, bs.ThawMessage): - if self.frozen and not self.shattered and self.node: - self.frozen = False - self.node.frozen = False - - elif isinstance(msg, bs.HitMessage): - if not self.node: - return None - if self.node.invincible: - SpazFactory.get().block_sound.play(1.0, self.node.position) - return True - - local_time = bs.time() * 1000 - 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 - - def fire_effect(): - if not self.shield: - if self.node.exists(): - bs.emitfx( - position=self.node.position, - scale=3, - count=50 * 2, - spread=0.3, - chunk_type='sweat', - ) - self.node.handlemessage('celebrate', 560) - else: - self._fire_time = None - else: - self._fire_time = None - - def fire(time, damage): - if not self.shield and not self._dead: - self.hitpoints -= damage - bs.show_damage_count(f'-{damage}HP', self.node.position, msg.force_direction) - bui.getsound('fuse01').play() - - if duration != time: - self._fire_time = bs.Timer(0.1, babase.CallPartial(fire_effect), repeat=True) - else: - self._fire_time = None - - if self.hitpoints < 0: - self.node.handlemessage(bs.DieMessage()) - - if msg.hit_subtype == 'fly': - damage_scale = 0.0 - - if self.shield: - self.shield_hitpoints -= 300 - - if self.shield_hitpoints < 0: - self.shield.delete() - self.shield = None - SpazFactory.get().shield_down_sound.play(1.0, self.node.position) - elif msg.hit_subtype == 'fire': - index = 1 - duration = 5 - damage = 103 - if not self.shield: - for firex in range(duration): - bs.timer(index, bs.WeakCallPartial(fire, index, damage)) - self._fire_time = bs.Timer(0.1, babase.CallPartial(fire_effect), repeat=True) - index += 1 - else: - self.shield_hitpoints -= 80 - if self.shield_hitpoints < 1: - self.shield.delete() - self.shield = None - SpazFactory.get().shield_down_sound.play(1.0, self.node.position) - elif msg.hit_subtype == 'impairment': - damage_scale = 0 - - if self.shield: - self.shield.delete() - self.shield = None - SpazFactory.get().shield_down_sound.play(1.0, self.node.position) - else: - hitpoints = int(self.hitpoints * 0.80) - self.hitpoints -= int(hitpoints) - bs.show_damage_count( - (f'-{int(hitpoints / 10)}%'), self.node.position, msg.force_direction - ) - - if self.hitpoints < 0 or hitpoints < 95: - self.node.handlemessage(bs.DieMessage()) - - if self.shield: - if msg.flat_damage: - damage = msg.flat_damage * self.impact_scale - else: - 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 - - max_spillover = SpazFactory.get().max_shield_spillover_damage - if self.shield_hitpoints <= 0: - - self.shield.delete() - self.shield = None - SpazFactory.get().shield_down_sound.play(1.0, self.node.position) - - npos = self.node.position - bs.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: - SpazFactory.get().shield_hit_sound.play(0.5, self.node.position) - - assert msg.force_direction is not None - bs.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 self.shield_hitpoints <= -max_spillover: - leftover_damage = -max_spillover - self.shield_hitpoints - shield_leftover_ratio = leftover_damage / damage - - mag *= shield_leftover_ratio - velocity_mag *= shield_leftover_ratio - else: - return True - else: - shield_leftover_ratio = 1.0 - - if msg.flat_damage: - damage = int(msg.flat_damage * self.impact_scale * shield_leftover_ratio) - else: - 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) - - if self.tankshield['Reduction']: - porcentaje = percentage_tank_shield() - dism = int(damage * porcentaje) - damage = int(damage - dism) - - bs.show_damage_count('-' + str(int(damage / 10)) + '%', msg.pos, msg.force_direction) - - self.node.handlemessage('hurt_sound') - - if self.edg_eff: - porcentaje = percentage_health_damage() - dmg_dism = int(damage * porcentaje) - self.hitpoints += dmg_dism - - PopupText( - text=f'+{int(dmg_dism / 10)}%', - scale=1.5, - position=self.node.position, - color=(0, 1, 0), - ).autoretain() - bs.animate_array( - self.node, 'color', 3, {0: (0, 1, 0), 0.39: (0, 2, 0), 0.4: self.color[0]} - ) - bui.getsound('healthPowerup').play() - - if msg.hit_type == 'punch': - self.on_punched(damage) - - try: - if msg.get_source_player(bs.Player).actor.freeze_punch: - self.node.color = (0, 1, 4) - bui.getsound('freeze').play() - self.node.handlemessage(bs.FreezeMessage()) - except: - pass - - if damage > 350: - assert msg.force_direction is not None - bs.show_damage_count( - '-' + str(int(damage / 10)) + '%', msg.pos, msg.force_direction - ) - - if msg.hit_subtype == 'super_punch': - SpazFactory.get().punch_sound_stronger.play(1.0, self.node.position) - if damage > 500: - sounds = SpazFactory.get().punch_sound_strong - sound = sounds[random.randrange(len(sounds))] - else: - sound = SpazFactory.get().punch_sound - sound.play(1.0, self.node.position) - - assert msg.force_direction is not None - bs.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, - ) - - bs.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, - ) - - 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 = bs.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, - }, - ) - bs.timer(0.06, light.delete) - - flash = bs.newnode( - 'flash', - attrs={'position': punchpos, 'size': 0.17 + 0.17 * hurtiness, 'color': flash_color}, - ) - bs.timer(0.06, flash.delete) - - if msg.hit_type == 'impact': - assert msg.force_direction is not None - bs.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: - if msg.hit_type == 'impact' and damage > self.hitpoints: - newdamage = max(damage - 200, self.hitpoints - 10) - damage = newdamage - self.node.handlemessage('flash') - - if damage > 0.0 and self.node.hold_node: - self.node.hold_node = None - self.hitpoints -= damage - self.node.hurt = 1.0 - float(self.hitpoints) / self.hitpoints_max - - if self._cursed and damage > 0: - bs.timer( - 0.05, bs.WeakCallPartial(self.curse_explode, msg.get_source_player(bs.Player)) - ) - - if self.frozen and (damage > 200 or self.hitpoints <= 0): - self.shatter() - elif self.hitpoints <= 0: - self.node.handlemessage(bs.DieMessage(how=bs.DeathType.IMPACT)) - - 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, bs.DieMessage): - - def drop_bomb(): - for xbomb in range(3): - p = self.node.position - pos = (p[0] + xbomb, p[1] + 5, p[2] - xbomb) - ball = bomb.Bomb(position=pos, bomb_type='impact').autoretain() - ball.node.mesh_scale = 0.6 - ball.node.mesh = bs.getmesh('egg') - ball.node.gravity_scale = 2 - - if self.edg_eff: - self.edg_eff = False - - wasdead = self._dead - self._dead = True - self.hitpoints = 0 - if msg.immediate: - if self.node: - self.node.delete() - elif self.node: - self.node.hurt = 1.0 - if self.play_big_death_sound and not wasdead: - SpazFactory.get().single_player_death_sound.play() - self.node.dead = True - bs.timer(2.0, self.node.delete) - - t = 0 - if self.kill_eff: - for bombs in range(3): - bs.timer(t, babase.CallPartial(drop_bomb)) - t += 0.15 - self.kill_eff = False - - elif isinstance(msg, bs.OutOfBoundsMessage): - self.handlemessage(bs.DieMessage(how=bs.DeathType.FALL)) - - elif isinstance(msg, bs.StandMessage): - self._last_stand_pos = (msg.position[0], msg.position[1], msg.position[2]) - if self.node: - 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): - if not self.node: - return None - node = bs.getcollision().opposingnode - - 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 - - if node.getnodetype() != 'spaz': - sounds = SpazFactory.get().impact_sounds_medium - sound = sounds[random.randrange(len(sounds))] - sound.play(1.0, 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( - bs.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'), - ) - ) - - 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): - if not self.node: - return None - - try: - collision = bs.getcollision() - opposingnode = collision.opposingnode - opposingbody = collision.opposingbody - except bs.NotFoundError: - return True - - try: - if opposingnode.invincible: - return True - except Exception: - pass - - if ( - opposingnode.getnodetype() == 'spaz' - and not opposingnode.shattered - and opposingbody == 4 - ): - opposingbody = 1 - - held = self.node.hold_node - if held and held.getnodetype() == 'flag': - return True - - self.node.hold_body = opposingbody - self.node.hold_node = opposingnode - elif isinstance(msg, bs.CelebrateMessage): - if self.node: - self.node.handlemessage('celebrate', int(msg.duration * 1000)) - - return None - - -class PowerupManagerWindow(PopupWindow): - def __init__(self, transition='in_right'): - columns = 2 - self._width = width = 800 - self._height = height = 500 - self._sub_height = 200 - self._scroll_width = self._width * 0.90 - self._scroll_height = self._height - 180 - self._sub_width = self._scroll_width * 0.95 - self.tab_buttons: set = {} - self.list_cls_power: list = [] - self.default_powerups = default_powerups() - self.default_power_list = list(self.default_powerups) - self.coins = apg['Bear Coin'] - self.popup_cls_power = None - - if not STORE['Buy Firebombs']: - powerups['Fire Bombs'] = 0 - self.default_power_list.remove('Fire Bombs') - - self.charstr = [ - babase.charstr(babase.SpecialChar.LEFT_ARROW), - babase.charstr(babase.SpecialChar.RIGHT_ARROW), - babase.charstr(babase.SpecialChar.UP_ARROW), - babase.charstr(babase.SpecialChar.DOWN_ARROW), - ] - - self.tabdefs = { - "Action 1": ['powerupIceBombs', (1, 1, 1)], - "Action 2": ['settingsIcon', (0, 1, 0)], - "Action 3": ['inventoryIcon', (1, 1, 1)], - "Action 4": ['storeIcon', (1, 1, 1)], - "Action 5": ['advancedIcon', (1, 1, 1)], - "About": ['heart', (1.5, 0.3, 0.3)], - } - - if STORE['Buy Firebombs'] and STORE['Buy Option'] and STORE['Buy Percentage']: - self.tabdefs = { - "Action 1": ['powerupIceBombs', (1, 1, 1)], - "Action 2": ['settingsIcon', (0, 1, 0)], - "Action 3": ['inventoryIcon', (1, 1, 1)], - "About": ['heart', (1.5, 0.3, 0.3)], - } - - self.listdef = list(self.tabdefs) - - self.count = len(self.tabdefs) - - self._current_tab = GLOBAL['Tab'] - - app = bui.app.ui_v1 - uiscale = app.uiscale - - self._root_widget = bui.containerwidget( - size=(width + 90, height + 80), - transition=transition, - scale=1.5 if uiscale is babase.UIScale.SMALL else 1.0, - stack_offset=(0, -30) if uiscale is babase.UIScale.SMALL else (0, 0), - ) - - self._backButton = b = bui.buttonwidget( - parent=self._root_widget, - autoselect=True, - position=(60, self._height - 15), - size=(130, 60), - scale=0.8, - text_scale=1.2, - label=babase.Lstr(resource='backText'), - button_type='back', - on_activate_call=babase.CallPartial(self._back), - ) - bui.buttonwidget( - edit=self._backButton, - button_type='backSmall', - size=(60, 60), - label=babase.charstr(babase.SpecialChar.BACK), - ) - bui.containerwidget(edit=self._root_widget, cancel_button=b) - - self.titletext = bui.textwidget( - parent=self._root_widget, - position=(0, height - 15), - size=(width, 50), - h_align="center", - color=bui.app.ui_v1.title_color, - v_align="center", - maxwidth=width * 1.3, - ) - - index = 0 - for tab in range(self.count): - for tab2 in range(columns): - - tag = self.listdef[index] - - position = (620 + (tab2 * 120), self._height - 50 * 2.5 - (tab * 120)) - - if tag == 'About': - text = babase.Lstr(resource='gatherWindow.aboutText') - elif tab == 'Action 4': - text = babase.Lstr(resource='storeText') - else: - text = getlanguage(tag) - - self.tab_buttons[tag] = bui.buttonwidget( - parent=self._root_widget, - autoselect=True, - position=position, - size=(110, 110), - scale=1, - label='', - enable_sound=False, - button_type='square', - on_activate_call=babase.CallPartial(self._set_tab, tag, sound=True), - ) - - self.text = bui.textwidget( - parent=self._root_widget, - position=(position[0] + 55, position[1] + 30), - size=(0, 0), - scale=1, - color=bui.app.ui_v1.title_color, - draw_controller=self.tab_buttons[tag], - maxwidth=100, - text=text, - h_align='center', - v_align='center', - ) - - self.image = bui.imagewidget( - parent=self._root_widget, - size=(60, 60), - color=self.tabdefs[tag][1], - draw_controller=self.tab_buttons[tag], - position=(position[0] + 25, position[1] + 40), - texture=bui.gettexture(self.tabdefs[tag][0]), - ) - - index += 1 - - if self.count == index: - break - - if self.count == index: - break - - self._scrollwidget = None - self._tab_container = None - self._set_tab(self._current_tab) - - def __del__(self): - apg.apply_and_commit() - - def popup_menu_closing(self, window): - print("saliendo") - - def _set_tab(self, tab, sound: bool = False): - self.sound = sound - GLOBAL['Tab'] = tab - apg.apply_and_commit() - - if self._tab_container is not None and self._tab_container.exists(): - self._tab_container.delete() - - if self.sound: - bui.getsound('swish').play() - - if self._scrollwidget: - self._scrollwidget.delete() - - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - position=(self._width * 0.08, 51 * 1.8), - size=(self._sub_width - 140, self._scroll_height + 60 * 1.2), - ) - - if tab == 'Action 4': - if self._scrollwidget: - self._scrollwidget.delete() - - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - position=(self._width * 0.08, 51 * 1.8), - size=(self._sub_width - 140, self._scroll_height + 60 * 1.2), - capture_arrows=True, - center_small_content=False, - selection_loops_to_parent=True, - claims_left_right=True, - claims_up_down=False, - color=(0.3, 0.3, 0.4), - ) - bui.textwidget(edit=self.titletext, text=babase.Lstr(resource='storeText')) - - elif tab == 'About': - bui.textwidget(edit=self.titletext, text=babase.Lstr(resource='gatherWindow.aboutText')) - else: - bui.textwidget(edit=self.titletext, text=getlanguage(tab)) - - choices = ['Reset', 'Only Bombs', 'Only Items', 'New', 'Nothing'] - c_display = [] - - for display in choices: - choices_display = babase.Lstr(translate=("", getlanguage(display))) - c_display.append(choices_display) - - if tab == 'Action 1': - self.popup_cls_power = PopupMenu( - parent=self._root_widget, - position=(130, self._width * 0.61), - button_size=(150, 50), - scale=2.5, - choices=choices, - width=150, - choices_display=c_display, - current_choice=GLOBAL['Cls Powerup'], - on_value_change_call=self._set_concept, - ) - self.list_cls_power.append(self.popup_cls_power._button) - - self.button_cls_power = bui.buttonwidget( - parent=self._root_widget, - position=(500, self._width * 0.61), - size=(50, 50), - autoselect=True, - scale=1, - label=('%'), - text_scale=1, - button_type='square', - on_activate_call=self._percentage_window, - ) - self.list_cls_power.append(self.button_cls_power) - - rewindow = [self.popup_cls_power._button, self.button_cls_power] - - for ( - cls - ) in self.list_cls_power: # this is very important so that pupups don't accumulate - if cls not in rewindow: - cls.delete() - - elif tab == 'Action 4': - self.button_coin = bui.buttonwidget( - parent=self._root_widget, - icon=bui.gettexture('coin'), - position=(550, self._width * 0.614), - size=(160, 40), - textcolor=(0, 1, 0), - color=(0, 1, 6), - scale=1, - label=str(apg['Bear Coin']), - text_scale=1, - autoselect=True, - on_activate_call=None, - ) # self._percentage_window) - self.list_cls_power.append(self.button_coin) - - try: - rewindow.append(self.button_coin) - except: - rewindow = [self.button_coin] - for ( - cls - ) in self.list_cls_power: # this is very important so that pupups don't accumulate - if cls not in rewindow: - cls.delete() - - else: - try: - for cls in self.list_cls_power: - cls.delete() - except: - pass - - if tab == 'Action 1': - sub_height = len(self.default_power_list) * 90 - v = sub_height - 55 - width = 300 - posi = 0 - id_power = list(self.default_powerups) - new_powerups = id_power[9:] - self.listpower = {} - - self._tab_container = c = bui.containerwidget( - parent=self._scrollwidget, - size=(self._sub_width, sub_height), - background=False, - selection_loops_to_parent=True, - ) - - for power in self.default_power_list: - if power == id_power[0]: - text = 'helpWindow.powerupShieldNameText' - tex = bui.gettexture('powerupShield') - elif power == id_power[1]: - text = 'helpWindow.powerupPunchNameText' - tex = bui.gettexture('powerupPunch') - elif power == id_power[2]: - text = 'helpWindow.powerupLandMinesNameText' - tex = bui.gettexture('powerupLandMines') - elif power == id_power[3]: - text = 'helpWindow.powerupImpactBombsNameText' - tex = bui.gettexture('powerupImpactBombs') - elif power == id_power[4]: - text = 'helpWindow.powerupIceBombsNameText' - tex = bui.gettexture('powerupIceBombs') - elif power == id_power[5]: - text = 'helpWindow.powerupBombNameText' - tex = bui.gettexture('powerupBomb') - elif power == id_power[6]: - text = 'helpWindow.powerupStickyBombsNameText' - tex = bui.gettexture('powerupStickyBombs') - elif power == id_power[7]: - text = 'helpWindow.powerupCurseNameText' - tex = bui.gettexture('powerupCurse') - elif power == id_power[8]: - text = 'helpWindow.powerupHealthNameText' - tex = bui.gettexture('powerupHealth') - elif power == id_power[9]: - text = power - tex = bui.gettexture('powerupSpeed') - elif power == id_power[10]: - text = power - tex = bui.gettexture('heart') - elif power == id_power[11]: - text = "Goodbye!" - tex = bui.gettexture('achievementOnslaught') - elif power == id_power[12]: - text = power - tex = bui.gettexture('ouyaUButton') - elif power == id_power[13]: - text = power - tex = bui.gettexture('achievementSuperPunch') - elif power == id_power[14]: - text = power - tex = bui.gettexture('levelIcon') - elif power == id_power[15]: - text = power - tex = bui.gettexture('ouyaOButton') - elif power == id_power[16]: - text = power - tex = bui.gettexture('star') - - if power in new_powerups: - label = getlanguage(power) - else: - label = babase.Lstr(resource=text) - - apperance = powerups[power] - position = (90, v - posi) - - t = bui.textwidget( - parent=c, - position=(position[0] - 30, position[1] - 15), - size=(width, 50), - h_align="center", - color=(bui.app.ui_v1.title_color), - text=label, - v_align="center", - maxwidth=width * 1.3, - ) - - self.powprev = bui.imagewidget( - parent=c, - position=(position[0] - 70, position[1] - 10), - size=(50, 50), - texture=tex, - ) - - dipos = 0 - for direc in ['-', '+']: - bui.buttonwidget( - parent=c, - autoselect=True, - position=(position[0] + 270 + dipos, position[1] - 10), - size=(100, 100), - scale=0.4, - label=direc, - button_type='square', - text_scale=4, - on_activate_call=babase.CallPartial(self.apperance_powerups, power, direc), - ) - - dipos += 100 - - textwidget = bui.textwidget( - parent=c, - position=(position[0] + 190, position[1] - 15), - size=(width, 50), - h_align="center", - color=cls_pow_color()[apperance], - text=str(apperance), - v_align="center", - maxwidth=width * 1.3, - ) - self.listpower[power] = textwidget - - posi += 90 - - elif tab == 'Action 2': - sub_height = 370 if not STORE['Buy Option'] else 450 - v = sub_height - 55 - width = 300 - - self._tab_container = c = bui.containerwidget( - parent=self._scrollwidget, - size=(self._sub_width, sub_height), - background=False, - selection_loops_to_parent=True, - ) - - position = (40, v - 20) - - c_display = [] - choices = ['Auto', 'SY: BALL', 'SY: Impact', 'SY: Egg'] - for display in choices: - choices_display = babase.Lstr(translate=("", getlanguage(display))) - c_display.append(choices_display) - - popup = PopupMenu( - parent=c, - position=(position[0] + 300, position[1]), - button_size=(150, 50), - scale=2.5, - choices=choices, - width=150, - choices_display=c_display, - current_choice=config['Powerup Style'], - on_value_change_call=babase.CallPartial(self._all_popup, 'Powerup Style'), - ) - - text = getlanguage('Powerup Style') - wt = len(text) * 0.80 - t = bui.textwidget( - parent=c, - position=(position[0] - 60 + wt, position[1]), - size=(width, 50), - maxwidth=width * 0.9, - scale=1.1, - h_align="center", - color=bui.app.ui_v1.title_color, - text=getlanguage('Powerup Style'), - v_align="center", - ) - - dipos = 0 - for direc in ['-', '+']: - bui.buttonwidget( - parent=c, - autoselect=True, - position=(position[0] + 310 + dipos, position[1] - 100), - size=(100, 100), - repeat=True, - scale=0.4, - label=direc, - button_type='square', - text_scale=4, - on_activate_call=babase.CallPartial(self._powerups_scale, direc), - ) - dipos += 100 - - txt_scale = config['Powerup Scale'] - self.txt_scale = bui.textwidget( - parent=c, - position=(position[0] + 230, position[1] - 105), - size=(width, 50), - scale=1.1, - h_align="center", - color=(0, 1, 0), - text=str(txt_scale), - v_align="center", - maxwidth=width * 1.3, - ) - - text = getlanguage('Powerup Scale') - wt = len(text) * 0.80 - t = bui.textwidget( - parent=c, - position=(position[0] - 60 + wt, position[1] - 100), - size=(width, 50), - maxwidth=width * 0.9, - scale=1.1, - h_align="center", - color=bui.app.ui_v1.title_color, - text=text, - v_align="center", - ) - - position = (position[0] - 20, position[1] + 40) - - self.check = bui.checkboxwidget( - parent=c, - position=(position[0] + 30, position[1] - 230), - value=config['Powerup Name'], - on_value_change_call=babase.CallPartial(self._switches, 'Powerup Name'), - maxwidth=self._scroll_width * 0.9, - text=getlanguage('Powerup Name'), - autoselect=True, - ) - - self.check = bui.checkboxwidget( - parent=c, - position=(position[0] + 30, position[1] - 230 * 1.3), - value=config['Powerup With Shield'], - on_value_change_call=babase.CallPartial(self._switches, 'Powerup With Shield'), - maxwidth=self._scroll_width * 0.9, - text=getlanguage('Powerup With Shield'), - autoselect=True, - ) - - if STORE['Buy Option']: - self.check = bui.checkboxwidget( - parent=c, - position=(position[0] + 30, position[1] - 230 * 1.6), - value=config['Powerup Time'], - on_value_change_call=babase.CallPartial(self._switches, 'Powerup Time'), - maxwidth=self._scroll_width * 0.9, - text=getlanguage('Powerup Time'), - autoselect=True, - ) - - elif tab == 'Action 3': - sub_height = 300 - v = sub_height - 55 - width = 300 - - self._tab_container = c = bui.containerwidget( - parent=self._scrollwidget, - size=(self._sub_width, sub_height), - background=False, - selection_loops_to_parent=True, - ) - - v -= 20 - position = (110, v - 45 * 1.72) - - if not STORE['Buy Percentage']: - t = bui.textwidget( - parent=c, - position=(90, v - 100), - size=(30 + width, 50), - h_align="center", - text=getlanguage('Block Option Store'), - color=bui.app.ui_v1.title_color, - v_align="center", - maxwidth=width * 1.5, - scale=1.5, - ) - - i = bui.imagewidget( - parent=c, - position=(position[0] + 100, position[1] - 205), - size=(80, 80), - texture=bui.gettexture('lock'), - ) - else: - t = bui.textwidget( - parent=c, - position=(position[0] - 14, position[1] + 70), - size=(30 + width, 50), - h_align="center", - text=f"{getlanguage('Tank Shield PTG')} ({getlanguage('Tank Shield')})", - color=bui.app.ui_v1.title_color, - v_align="center", - maxwidth=width * 1.5, - scale=1.5, - ) - - b = bui.buttonwidget( - parent=c, - autoselect=True, - position=position, - size=(100, 100), - repeat=True, - scale=0.6, - label=self.charstr[3], - button_type='square', - text_scale=2, - on_activate_call=babase.CallPartial(self.tank_shield_percentage, 'Decrement'), - ) - - b = bui.buttonwidget( - parent=c, - autoselect=True, - repeat=True, - text_scale=2, - position=(position[0] * 3.2, position[1]), - size=(100, 100), - scale=0.6, - label=self.charstr[2], - button_type='square', - on_activate_call=babase.CallPartial(self.tank_shield_percentage, 'Increment'), - ) - - porcentaje = config['Tank Shield PTG'] - if porcentaje > 59: - color = (0, 1, 0) - elif porcentaje < 40: - color = (1, 1, 0) - else: - color = (0, 1, 0.8) - - self.tank_text = bui.textwidget( - parent=c, - position=(position[0] - 14, position[1] + 5), - size=(30 + width, 50), - h_align="center", - text=str(porcentaje) + '%', - color=color, - v_align="center", - maxwidth=width * 1.3, - scale=2, - ) - - # -----> - - position = (110, v - 160 * 1.6) - t = bui.textwidget( - parent=c, - position=(position[0] - 14, position[1] + 70), - size=(30 + width, 50), - h_align="center", - text=f"{getlanguage('Healing Damage PTG')}{_sp_}({getlanguage('Healing Damage')})", - color=bui.app.ui_v1.title_color, - v_align="center", - maxwidth=width * 1.3, - scale=1.4, - ) - - b = bui.buttonwidget( - parent=c, - autoselect=True, - position=position, - size=(100, 100), - repeat=True, - scale=0.6, - label=self.charstr[3], - button_type='square', - text_scale=2, - on_activate_call=babase.CallPartial(self.health_damage_percentage, 'Decrement'), - ) - - b = bui.buttonwidget( - parent=c, - autoselect=True, - repeat=True, - text_scale=2, - position=(position[0] * 3.2, position[1]), - size=(100, 100), - scale=0.6, - label=self.charstr[2], - button_type='square', - on_activate_call=babase.CallPartial(self.health_damage_percentage, 'Increment'), - ) - - porcentaje = config['Healing Damage PTG'] - if porcentaje > 59: - color = (0, 1, 0) - elif porcentaje < 40: - color = (1, 1, 0) - else: - color = (0, 1, 0.8) - - self.hlg_text = bui.textwidget( - parent=c, - position=(position[0] - 14, position[1] + 5), - size=(30 + width, 50), - h_align="center", - text=str(porcentaje) + '%', - color=color, - v_align="center", - maxwidth=width * 1.3, - scale=2, - ) - - elif tab == 'Percentage': - sub_height = len(self.default_power_list) * 90 - v = sub_height - 55 - width = 300 - posi = 0 - id_power = list(self.default_powerups) - new_powerups = id_power[9:] - self.listpower = {} - - self._tab_container = c = bui.containerwidget( - parent=self._scrollwidget, - size=(self._sub_width, sub_height), - background=False, - selection_loops_to_parent=True, - ) - - for power in self.default_power_list: - if power == id_power[0]: - text = 'helpWindow.powerupShieldNameText' - tex = bui.gettexture('powerupShield') - elif power == id_power[1]: - text = 'helpWindow.powerupPunchNameText' - tex = bui.gettexture('powerupPunch') - elif power == id_power[2]: - text = 'helpWindow.powerupLandMinesNameText' - tex = bui.gettexture('powerupLandMines') - elif power == id_power[3]: - text = 'helpWindow.powerupImpactBombsNameText' - tex = bui.gettexture('powerupImpactBombs') - elif power == id_power[4]: - text = 'helpWindow.powerupIceBombsNameText' - tex = bui.gettexture('powerupIceBombs') - elif power == id_power[5]: - text = 'helpWindow.powerupBombNameText' - tex = bui.gettexture('powerupBomb') - elif power == id_power[6]: - text = 'helpWindow.powerupStickyBombsNameText' - tex = bui.gettexture('powerupStickyBombs') - elif power == id_power[7]: - text = 'helpWindow.powerupCurseNameText' - tex = bui.gettexture('powerupCurse') - elif power == id_power[8]: - text = 'helpWindow.powerupHealthNameText' - tex = bui.gettexture('powerupHealth') - elif power == id_power[9]: - text = power - tex = bui.gettexture('powerupSpeed') - elif power == id_power[10]: - text = power - tex = bui.gettexture('heart') - elif power == id_power[11]: - text = "Goodbye!" - tex = bui.gettexture('achievementOnslaught') - elif power == id_power[12]: - text = power - tex = bui.gettexture('ouyaUButton') - elif power == id_power[13]: - text = power - tex = bui.gettexture('achievementSuperPunch') - elif power == id_power[14]: - text = power - tex = bui.gettexture('levelIcon') - elif power == id_power[15]: - text = power - tex = bui.gettexture('ouyaOButton') - elif power == id_power[16]: - text = power - tex = bui.gettexture('star') - - if power in new_powerups: - label = getlanguage(power) - else: - label = babase.Lstr(resource=text) - - apperance = powerups[power] - position = (90, v - posi) - - t = bui.textwidget( - parent=c, - position=(position[0] - 30, position[1] - 15), - size=(width, 50), - h_align="center", - color=(bui.app.ui_v1.title_color), - text=label, - v_align="center", - maxwidth=width * 1.3, - ) - - self.powprev = bui.imagewidget( - parent=c, - position=(position[0] - 70, position[1] - 10), - size=(50, 50), - texture=tex, - ) - - ptg = str(self.total_percentage(power)) - t = bui.textwidget( - parent=c, - position=(position[0] + 170, position[1] - 10), - size=(width, 50), - h_align="center", - color=(0, 1, 0), - text=(f'{ptg}%'), - v_align="center", - maxwidth=width * 1.3, - ) - - posi += 90 - - elif tab == 'Action 4': - sub_height = 370 - width = 300 - v = sub_height - 55 - u = width - 60 - - if not self._scrollwidget or not self._scrollwidget.exists(): - return - self._tab_container = c = bui.containerwidget( - parent=self._scrollwidget, - size=(width + 500, sub_height), - background=False, - selection_loops_to_parent=True, - ) - - position = (u + 150, v - 250) - n_pos = 0 - prices = [7560, 5150, 3360] - str_name = ["FireBombs Store", "Timer Store", "Percentages Store"] - images = ["ouyaOButton", "settingsIcon", "inventoryIcon"] - - index = 0 - for store in store_items(): - p = prices[index] - txt = str_name[index] - label = getlanguage(txt) - tx_pos = len(label) * 1.8 - lb_scale = len(label) * 0.20 - preview = images[index] - - if STORE[store]: - text = getlanguage('Bought') - icon = bui.gettexture('graphicsIcon') - color = (0.52, 0.48, 0.63) - txt_scale = 1.5 - else: - text = str(p) - icon = bui.gettexture('coin') - color = (0.5, 0.4, 0.93) - txt_scale = 2 - - b = bui.buttonwidget( - parent=c, - autoselect=True, - position=(position[0] + 210 - n_pos, position[1]), - size=(250, 80), - scale=0.7, - label=text, - text_scale=txt_scale, - icon=icon, - color=color, - iconscale=1.7, - on_activate_call=babase.CallPartial(self._buy_object, store, p), - ) - - s = 180 - b = bui.buttonwidget( - parent=c, - autoselect=True, - position=(position[0] + 210 - n_pos, position[1] + 55), - size=(s, s + 30), - scale=1, - label='', - color=color, - button_type='square', - on_activate_call=babase.CallPartial(self._buy_object, store, p), - ) - - s -= 80 - i = bui.imagewidget( - parent=c, - draw_controller=b, - position=(position[0] + 250 - n_pos, position[1] + 140), - size=(s, s), - texture=bui.gettexture(preview), - ) - - t = bui.textwidget( - parent=c, - position=(position[0] + 270 - n_pos, position[1] + 101), - h_align="center", - color=(bui.app.ui_v1.title_color), - text=label, - v_align="center", - maxwidth=130, - ) - - n_pos += 280 - index += 1 - - elif tab == 'Action 5': - sub_height = 370 - v = sub_height - 55 - width = 300 - - self._tab_container = c = bui.containerwidget( - parent=self._scrollwidget, - size=(self._sub_width, sub_height), - background=False, - selection_loops_to_parent=True, - ) - - position = (0, v - 30) - - t = bui.textwidget( - parent=c, - position=(position[0] + 80, position[1] - 30), - size=(width + 60, 50), - scale=1, - h_align="center", - color=(bui.app.ui_v1.title_color), - text=babase.Lstr(resource='settingsWindowAdvanced.enterPromoCodeText'), - v_align="center", - maxwidth=width * 1.3, - ) - - self.promocode_text = bui.textwidget( - parent=c, - position=(position[0] + 80, position[1] - 100), - size=(width + 60, 50), - scale=1, - editable=True, - h_align="center", - color=(bui.app.ui_v1.title_color), - text='', - v_align="center", - maxwidth=width * 1.3, - max_chars=30, - description=babase.Lstr(resource='settingsWindowAdvanced.enterPromoCodeText'), - ) - - self.promocode_button = bui.buttonwidget( - parent=c, - position=(position[0] + 160, position[1] - 170), - size=(200, 60), - scale=1.0, - label=babase.Lstr(resource='submitText'), - on_activate_call=self._promocode, - ) - - else: - sub_height = 0 - v = sub_height - 55 - width = 300 - - self._tab_container = c = bui.containerwidget( - parent=self._scrollwidget, - size=(self._sub_width, sub_height), - background=False, - selection_loops_to_parent=True, - ) - - t = bui.textwidget( - parent=c, - position=(110, v - 20), - size=(width, 50), - scale=1.4, - color=(0.2, 1.2, 0.2), - h_align="center", - v_align="center", - text=("Ultimate Powerup Manager v2.5"), - maxwidth=width * 30, - ) - - t = bui.textwidget( - parent=c, - position=(110, v - 90), - size=(width, 50), - scale=1, - color=(1.3, 0.5, 1.0), - h_align="center", - v_align="center", - text=getlanguage('Creator'), - maxwidth=width * 30, - ) - - t = bui.textwidget( - parent=c, - position=(110, v - 220), - size=(width, 50), - scale=1, - color=(1.0, 1.2, 0.3), - h_align="center", - v_align="center", - text=getlanguage('Mod Info'), - maxwidth=width * 30, - ) - - for select_tab, button_tab in self.tab_buttons.items(): - if select_tab == tab: - bui.buttonwidget(edit=button_tab, color=(0.5, 0.4, 1.5)) - else: - bui.buttonwidget(edit=button_tab, color=(0.52, 0.48, 0.63)) - - def _all_popup(self, tag: str, popup: str) -> None: - config[tag] = popup - apg.apply_and_commit() - - def _set_concept(self, concept: str) -> None: - GLOBAL['Cls Powerup'] = concept - - if concept == 'Reset': - for power, deflt in default_powerups().items(): - powerups[power] = deflt - elif concept == 'Nothing': - for power in default_powerups(): - powerups[power] = 0 - elif concept == 'Only Bombs': - for power, deflt in default_powerups().items(): - if 'Bombs' not in power: - powerups[power] = 0 - else: - powerups[power] = 3 - elif concept == 'Only Items': - for power, deflt in default_powerups().items(): - if 'Bombs' in power: - powerups[power] = 0 - else: - powerups[power] = deflt - elif concept == 'New': - default_power = default_powerups() - new_powerups = list(default_power)[9:] - for power, deflt in default_power.items(): - if power not in new_powerups: - powerups[power] = 0 - else: - powerups[power] = deflt - - if not STORE['Buy Firebombs']: - powerups['Fire Bombs'] = 0 - - self._set_tab('Action 1') - - def tank_shield_percentage(self, tag): - max = 96 - min = 40 - if tag == 'Increment': - config['Tank Shield PTG'] += 1 - if config['Tank Shield PTG'] > max: - config['Tank Shield PTG'] = min - elif tag == 'Decrement': - config['Tank Shield PTG'] -= 1 - if config['Tank Shield PTG'] < min: - config['Tank Shield PTG'] = max - - porcentaje = config['Tank Shield PTG'] - if porcentaje > 59: - color = (0, 1, 0) - elif porcentaje < 40: - color = (1, 1, 0) - else: - color = (0, 1, 0.8) - bui.textwidget(edit=self.tank_text, text=str(porcentaje) + '%', color=color) - - def health_damage_percentage(self, tag): - max = 80 - min = 35 - if tag == 'Increment': - config['Healing Damage PTG'] += 1 - if config['Healing Damage PTG'] > max: - config['Healing Damage PTG'] = min - elif tag == 'Decrement': - config['Healing Damage PTG'] -= 1 - if config['Healing Damage PTG'] < min: - config['Healing Damage PTG'] = max - - porcentaje = config['Healing Damage PTG'] - if porcentaje > 59: - color = (0, 1, 0) - elif porcentaje < 40: - color = (1, 1, 0) - else: - color = (0, 1, 0.8) - bui.textwidget(edit=self.hlg_text, text=str(porcentaje) + '%', color=color) - - def apperance_powerups(self, powerup: str, ID: str): - max = 7 - if ID == "-": - if powerups[powerup] == 0: - powerups[powerup] = max - else: - powerups[powerup] -= 1 - elif ID == "+": - if powerups[powerup] == max: - powerups[powerup] = 0 - else: - powerups[powerup] += 1 - enum = powerups[powerup] - bui.textwidget( - edit=self.listpower[powerup], text=str(powerups[powerup]), color=cls_pow_color()[enum] - ) - - def _powerups_scale(self, ID: str): - max = 1.5 - min = 0.5 - sc = 0.1 - if ID == "-": - if config['Powerup Scale'] < (min + 0.1): - config['Powerup Scale'] = max - else: - config['Powerup Scale'] -= sc - elif ID == "+": - if config['Powerup Scale'] > (max - 0.1): - config['Powerup Scale'] = min - else: - config['Powerup Scale'] += sc - config['Powerup Scale'] = round(config['Powerup Scale'], 1) - bui.textwidget(edit=self.txt_scale, text=str(config['Powerup Scale'])) - - def total_percentage(self, power): - total = 0 - pw = powerups[power] - for i, i2 in powerups.items(): - total += i2 - if total == 0: - return float(total) - else: - ptg = 100 * pw / total - result = round(ptg, 2) - return result - - def store_refresh(self, tag: str): - if tag == 'Buy Firebombs': - powerups['Fire Bombs'] = 3 - self.default_power_list.append('Fire Bombs') - self._set_tab('Action 4') - - def _buy_object(self, tag: str, price: int): - store = BearStore( - value=tag, price=price, callback=babase.CallPartial(self.store_refresh, tag) - ) - store.buy() - - def _promocode(self): - code = bui.textwidget(query=self.promocode_text) - promo = PromoCode(code=code) - promo.code_confirmation() - bui.textwidget(edit=self.promocode_text, text="") - - def _switches(self, tag, m): - config[tag] = False if m == 0 else True - apg.apply_and_commit() - - def _percentage_window(self): - self._set_tab('Percentage') - - def _back(self): - bui.containerwidget(edit=self._root_widget, transition='out_left') - babase.app.classic.profile_browser_window() - - -def add_plugin(): - try: - from baBearModz import BearPlugin - except Exception as e: - return bs.timer(2.5, lambda e=e: bs.broadcastmessage('Error plugin: ' + str(e), (1, 0, 0))) - BearPlugin( - icon='logo', - creator='UPDATE TO API 9 BY ATD(anas) and less', - button_color=(1, 1, 0), - plugin=UltimatePowerupManager, - window=PowerupManagerWindow, - ) - - -# ba_meta export babase.Plugin - - -class UltimatePowerupManager(babase.Plugin): - # browser.ProfileBrowserWindow = NewProfileBrowserWindow - pupbox.PowerupBoxFactory = NewPowerupBoxFactory - pupbox.PowerupBox.__init__ = _pbx_ - Bomb.__init__ = _bomb_init - SpazBot.handlemessage = bot_handlemessage - Blast.handlemessage = bomb_handlemessage - Spaz.handlemessage = new_handlemessage - Spaz.__init__ = _init_spaz_ - Spaz._get_bomb_type_tex = new_get_bomb_type_tex - Spaz.on_punch_press = spaz_on_punch_press - Spaz.on_punch_release = spaz_on_punch_release - MainMenuActivity.on_transition_in = new_on_transition_in - - def __init__(self) -> None: - - # add_plugin() - ... - - def has_settings_ui(self): - return True - - def show_settings_ui(self, origin_widget): - PowerupManagerWindow() +# ba_meta require api 9 +from __future__ import annotations + +import babase +import bauiv1 as bui +import bascenev1 as bs +import random +from bascenev1lib.actor import bomb +from bascenev1lib.actor import powerupbox as pupbox +from bascenev1lib.actor.spazbot import SpazBot +from bascenev1lib.actor.bomb import Bomb, Blast +from bauiv1lib.popup import PopupWindow, PopupMenuWindow, PopupMenu +from bascenev1lib.actor.spaz import ( + Spaz, + SpazFactory, + PickupMessage, + PunchHitMessage, + CurseExplodeMessage, + BombDiedMessage, +) +from bascenev1lib.mainmenu import MainMenuActivity, MainMenuSession +from bascenev1lib.gameutils import SharedObjects +from bascenev1lib.actor.powerupbox import PowerupBoxFactory +from bascenev1lib.actor.popuptext import PopupText +from bauiv1lib.confirm import ConfirmWindow +from bascenev1lib.actor.spaz import * + +from typing import TYPE_CHECKING + +plugman = dict( + plugin_name="powerup_manager", + description="This plugin add new modded powerups and features to manage them", + external_url="", + authors=[ + {"name": "ATD", "email": "anasdhaoidi001@gmail.com", "discord": ""}, + ], + version="1.0.1", +) + + +_sp_ = '\n' + +if TYPE_CHECKING: + pass + + +# === Mod updated by ATD and Less === + + +def getlanguage(text, subs: str = None, almacen: list = []): + if almacen == []: + almacen = list(range(1000)) + lang = bs.app.lang.language + translate = { + "Reset": {"Spanish": "Reiniciar", "English": "Reset", "Portuguese": "Reiniciar"}, + "Nothing": { + "Spanish": "Sin potenciadores", + "English": "No powerups", + "Portuguese": "Sem powerups", + }, + "Action 1": {"Spanish": "Potenciadores", "English": "Powerups", "Portuguese": "Powerups"}, + "Action 2": {"Spanish": "Configuración", "English": "Settings", "Portuguese": "Definições"}, + "Action 3": {"Spanish": "Extras", "English": "Extras", "Portuguese": "Extras"}, + "Action 4": {"Spanish": "Tienda", "English": "Store", "Portuguese": "Loja"}, + "Action 5": { + "Spanish": "Canjear código", + "English": "Enter Code", + "Portuguese": "Código promocional", + }, + "Custom": {"Spanish": "", "English": "Customize", "Portuguese": "Customizar"}, + "Impairment Bombs": { + "Spanish": "Bombas menoscabo", + "English": "Hyperactive bombs", + "Portuguese": "Bombas hiperativas", + }, + "Speed": {"Spanish": "Velocidad", "English": "Speed", "Portuguese": "Velocidade"}, + "Fire Bombs": { + "Spanish": "Bombas de fuego", + "English": "Fire Bombs", + "Portuguese": "Bombas de fogo", + }, + "Ice Man": { + "Spanish": "Hombre de hielo", + "English": "Ice man", + "Portuguese": "Homem de gelo", + }, + "Fly Bombs": { + "Spanish": "Bombas expansivas", + "English": "Expansive bombs", + "Portuguese": "Bombas expansivas", + }, + "Goodbye": {"Spanish": "¡Hasta luego!", "English": "Goodbye!", "Portuguese": "Adeus!"}, + "Healing Damage": { + "Spanish": "Auto-curación", + "English": "Healing Damage", + "Portuguese": "Auto-cura", + }, + "Tank Shield": { + "Spanish": "Súper blindaje", + "English": "Reinforced shield", + "Portuguese": "Escudo reforçado", + }, + "Tank Shield PTG": { + "Spanish": "Porcentaje de disminución", + "English": "Percentage decreased", + "Portuguese": "Percentual reduzido", + }, + "Healing Damage PTG": { + "Spanish": "Porcentaje de recuperación de salud", + "English": "Percentage of health recovered", + "Portuguese": "Porcentagem de recuperação de saúde", + }, + "SY: BALL": {"Spanish": "Esfera", "English": "Sphere", "Portuguese": "Esfera"}, + "SY: Impact": {"Spanish": "Especial", "English": "Special", "Portuguese": "Especial"}, + "SY: Egg": {"Spanish": "Huevito", "English": "Egg shape", "Portuguese": "Ovo"}, + "Powerup Scale": { + "Spanish": "Tamaño del potenciador", + "English": "Powerups size", + "Portuguese": "Tamanho de powerups", + }, + "Powerup With Shield": { + "Spanish": "Potenciadores con escudo", + "English": "Powerups with shield", + "Portuguese": "Powerups com escudo", + }, + "Powerup Time": { + "Spanish": "Mostrar Temporizador", + "English": "Show end time", + "Portuguese": "Mostrar cronômetro", + }, + "Powerup Style": { + "Spanish": "Forma de los potenciadores", + "English": "Shape of powerup", + "Portuguese": "Forma de powerup", + }, + "Powerup Name": { + "Spanish": "Mostrar nombre en los potenciadores", + "English": "Show name on powerups", + "Portuguese": "Mostrar nome em powerups", + }, + "Percentage": { + "Spanish": "Probabilidad", + "English": "Show percentage", + "Portuguese": "Mostrar porcentagem", + }, + "Only Items": { + "Spanish": "Sólo Accesorios", + "English": "Only utensils", + "Portuguese": "Apenas utensilios", + }, + "New": {"Spanish": "Nuevo", "English": "New", "Portuguese": "Novo"}, + "Only Bombs": { + "Spanish": "Sólo Bombas", + "English": "Only bombs", + "Portuguese": "Apenas bombas", + }, + "Coins 0": { + "Spanish": "Monedas Insuficientes", + "English": "Insufficient coins", + "Portuguese": "Moedas insuficientes", + }, + "Purchase": { + "Spanish": "Compra realizada correctamente", + "English": "Successful purchase", + "Portuguese": "Compra Bem Sucedida", + }, + "Double Product": { + "Spanish": "Ya has comprado este artículo", + "English": "You've already bought this", + "Portuguese": "Voce ja comprou isto", + }, + "Bought": {"Spanish": "Comprado", "English": "Bought", "Portuguese": "Comprou"}, + "Confirm Purchase": { + "Spanish": f'Tienes {subs} monedas. {_sp_} ¿Deseas comprar esto?', + "English": f'You have {subs} coins. {_sp_} Do you want to buy this?', + "Portuguese": f'Você tem {subs} moedas. {_sp_} Deseja comprar isto?', + }, + "FireBombs Store": { + "Spanish": 'Bombas de fuego', + "English": 'Fire bombs', + "Portuguese": 'Bombas de incêndio', + }, + "Timer Store": {"Spanish": 'Temporizador', "English": 'Timer', "Portuguese": 'Timer'}, + "Percentages Store": {"Spanish": 'Extras', "English": 'Extras', "Portuguese": 'Extras'}, + "Block Option Store": { + "Spanish": f"Uuups..{_sp_}Esta opción está bloqueada.{_sp_} Para acceder a ella puedes {_sp_} comprarla en la tienda.{_sp_} Gracias...", + "English": f"Oooops...{_sp_}This option is blocked. {_sp_} To access it you can buy {_sp_} it in the store.{_sp_} Thank you...", + "Portuguese": f"Ooops...{_sp_}Esta opção está bloqueada. {_sp_} Para acessá-lo, você pode {_sp_} comprá-lo na loja.{_sp_} Obrigado...", + }, + "True Code": { + "Spanish": "¡Código canjeado!", + "English": "Successful code!", + "Portuguese": "¡Código válido!", + }, + "False Code": { + "Spanish": "Código ya canjeado", + "English": "Expired code", + "Portuguese": "Código expirado", + }, + "Invalid Code": { + "Spanish": "Código inválido", + "English": "Invalid code", + "Portuguese": "Código inválido", + }, + "Reward Code": { + "Spanish": f"¡Felicitaciones! ¡Ganaste {subs} monedas!", + "English": f"Congratulations! You've {subs} coins", + "Portuguese": f"Parabéns! Você tem {subs} moedas", + }, + "Creator": { + "Spanish": "Mod edited by ATD", + "English": "Mod edited by ATD", + "Portuguese": "Mod edited by ATD", + }, + "Mod Info": { + "Spanish": f"Un mod genial que te permite gestionar {_sp_} los potenciadores a tu antojo. {_sp_} también incluye 8 potenciadores extra{_sp_} dejando 17 en total... ¡Guay!", + "English": f"A cool mod that allows you to manage {_sp_} powerups at your whims. {_sp_} also includes 8 extra powerups{_sp_} leaving 17 in total... Wow!", + "Portuguese": f"Um mod legal que permite que você gerencie os{_sp_} powerups de de acordo com seus caprichos. {_sp_} também inclui 8 powerups extras,{_sp_} deixando 17 no total... Uau!", + }, + "Coins Message": { + "Spanish": f"Recompensa: {subs} Monedas", + "English": f"Reward: {subs} Coins", + "Portuguese": f"Recompensa: {subs} Moedas", + }, + "Coins Limit Message": { + "Spanish": f"Ganaste {almacen[0]} Monedas.{_sp_} Pero has superado el límite de {almacen[1]}", + "English": f"You won {almacen[0]} Coins. {_sp_} But you have exceeded the limit of {almacen[1]}", + "Portuguese": f"Você ganhou {almacen[0]} Moedas. {_sp_} Mas você excedeu o limite de {almacen[1]}", + }, + } + languages = ['Spanish', 'Portuguese', 'English'] + if lang not in languages: + lang = 'English' + + if text not in translate: + return text + + return translate[text][lang] + + +def settings_distribution(): + return { + "Powers Gravity": False, + "Tank Shield PTG": 96, + "Healing Damage PTG": 72, + "Powerup Style": 'Auto', + "Powerup Scale": 1.0, + "Powerup Name": False, + "Powerup With Shield": False, + "Powerup Time": False, + } + + +apg = babase.app.config +if "PPM Settings" in apg: + old = apg['PPM Settings'] + for settings in settings_distribution(): + if settings not in old: + apg['PPM Settings'] = settings_distribution() +else: + apg['PPM Settings'] = settings_distribution() +apg.apply_and_commit() + +config = apg['PPM Settings'] + + +def default_powerups(): + return { + "Shield": 2, + "Punch": 3, + "Mine Bombs": 2, + "Impact Bombs": 3, + "Ice Bombs": 3, + "Triple": 3, + "Sticky Bombs": 3, + "Curse": 1, + "Health": 1, + "Speed": 2, + "Healing Damage": 1, + "Goodbye": 2, + "Ice Man": 1, + "Tank Shield": 1, + "Impairment Bombs": 2, + "Fire Bombs": 3, + "Fly Bombs": 3, + } + + +if "Powerups" in config: + p_old = config['Powerups'] + for powerups in default_powerups(): + if powerups not in p_old: + config['Powerups'] = default_powerups() +else: + config['Powerups'] = default_powerups() +apg.apply_and_commit() + +powerups = config['Powerups'] + +# === EXTRAS === + +GLOBAL = {"Tab": 'Action 1', "Cls Powerup": 0, "Coins Message": []} + + +# === STORE === +def promo_codes(): + return { + "G-Am54igO42Os": [True, 1100], + "P-tRo8nM8dZ": [True, 2800], + "Y-tU2B3S": [True, 500], + "B-0mB3RYT2z": [True, 910], + "B-Asd14mON9G0D": [True, 910], + "D-rAcK0cJ23": [True, 910], + "E-a27ZO6f3Y": [True, 600], + "E-Am54igO42Os": [True, 600], + "E-M4uN3K34XB": [True, 840], + "PM-731ClcAF": [True, 50000], + } + + +def store_items(): + return {"Buy Firebombs": True, "Buy Option": True, "Buy Percentage": True} + + +if apg.get('Bear Coin') is None: + apg['Bear Coin'] = 0 + apg.apply_and_commit() + +if apg.get('Bear Coin') is not None: + if apg['Bear Coin'] <= 0: + apg['Bear Coin'] = 0 + apg['Bear Coin'] = int(apg['Bear Coin']) + +if apg.get('Bear Store') is None: + apg['Bear Store'] = {} + +for i, j in store_items().items(): + store = apg['Bear Store'] + if i not in store: + if store.get(i) is None: + store[i] = j + apg.apply_and_commit() + +STORE = apg['Bear Store'] + +if STORE.get('Promo Code') is None: + STORE['Promo Code'] = promo_codes() + +for i, x in promo_codes().items(): + pmcode = STORE['Promo Code'] + if i not in pmcode: + if pmcode.get(i) is None: + pmcode[i] = x + +apg.apply_and_commit() + + +class BearStore: + def __init__(self, price: int = 1000, value: str = '', callback: call = None): + + self.price = price + self.value = value + self.store = STORE[value] + self.coins = apg['Bear Coin'] + self.callback = callback + + def buy(self): + if not self.store: + if self.coins >= (self.price): + + def confirm(): + STORE[self.value] = True + apg['Bear Coin'] -= int(self.price) + bs.broadcastmessage(getlanguage('Purchase'), (0, 1, 0)) + bui.getsound('cashRegister').play() + apg.apply_and_commit() + self.callback() + + ConfirmWindow( + getlanguage('Confirm Purchase', subs=self.coins), + width=400, + height=120, + action=confirm, + ok_text=babase.Lstr(resource='okText'), + ) + else: + bs.broadcastmessage(getlanguage('Coins 0'), (1, 0, 0)) + bui.getsound('error').play() + else: + bs.broadcastmessage(getlanguage('Double Product'), (1, 0, 0)) + bui.getsound('error').play() + + def __del__(self): + apg['Bear Coin'] = int(apg['Bear Coin']) + apg.apply_and_commit() + + +class PromoCode: + def __init__(self, code: str = ''): + self.code = code + self.codes_store = STORE['Promo Code'] + if self.code in self.codes_store: + self.code_type = STORE['Promo Code'][code] + self.promo_code_expire = self.code_type[0] + self.promo_code_amount = self.code_type[1] + + def __del__(self): + apg['Bear Coin'] = int(apg['Bear Coin']) + apg.apply_and_commit() + + def code_confirmation(self): + if self.code != "": + bs.broadcastmessage(babase.Lstr(resource='submittingPromoCodeText'), (0, 1, 0)) + try: + babase.pushcall(babase.CallPartial(self.validate_code), from_other_thread=True) + except: + pass + + def validate_code(self): + if self.code in self.codes_store: + if self.promo_code_expire: + with babase.ContextRef.empty(): + babase.pushcall( + babase.CallPartial(self.successful_code), from_other_thread=True + ) + bs.broadcastmessage(getlanguage('True Code'), (0, 1, 0)) + bui.getsound('cheer').play() + self.code_type[0] = False + else: + bs.broadcastmessage(getlanguage('False Code'), (1, 0, 0)) + bui.getsound('error').play() + else: + bs.broadcastmessage(getlanguage('Invalid Code'), (1, 0, 0)) + bui.getsound('error').play() + + def successful_code(self): + apg['Bear Coin'] += self.promo_code_amount + bs.broadcastmessage(getlanguage('Reward Code', subs=self.promo_code_amount), (0, 1, 0)) + bui.getsound('cashRegister2').play() + + +MainMenuActivity.super_transition_in = MainMenuActivity.on_transition_in + + +def new_on_transition_in(self): + self.super_transition_in() + limit = 8400 + bear_coin = apg['Bear Coin'] + coins_message = GLOBAL['Coins Message'] + try: + if not (STORE['Buy Firebombs'] and STORE['Buy Option'] and STORE['Buy Percentage']): + + if coins_message != []: + result = 0 + for i in coins_message: + result += i + + if not bear_coin >= (limit - 1): + bs.broadcastmessage(getlanguage('Coins Message', subs=result), (0, 1, 0)) + bui.getsound('cashRegister').play() + else: + bs.broadcastmessage( + getlanguage('Coins Limit Message', almacen=[result, limit]), (1, 0, 0) + ) + bui.getsound('error').play() + self.bear_coin_message = True + GLOBAL['Coins Message'] = [] + except: + pass + + +SpazBot.super_handlemessage = SpazBot.handlemessage + + +def bot_handlemessage(self, msg: Any): + self.super_handlemessage(msg) + if isinstance(msg, bs.DieMessage): + if not self.die: + self.die = True + self.limit = 8400 + self.free_coins = random.randint(1, 25) + self.bear_coins = apg['Bear Coin'] + + if not self.bear_coins >= (self.limit): + self.bear_coins += self.free_coins + GLOBAL['Coins Message'].append(self.free_coins) + + if self.bear_coins >= (self.limit): + self.bear_coins = self.limit + + apg['Bear Coin'] = int(self.bear_coins) + apg.apply_and_commit() + + else: + GLOBAL['Coins Message'].append(self.free_coins) + + +def cls_pow_color(): + return [ + (1, 0.1, 0.1), + (0.1, 0.5, 0.9), + (0.1, 0.9, 0.9), + (0.1, 0.9, 0.1), + (0.1, 1, 0.5), + (1, 1, 0.2), + (2, 0.5, 0.5), + (1, 0, 6), + ] + + +def random_color(): + a = random.random() * 3 + b = random.random() * 3 + c = random.random() * 3 + return (a, b, c) + + +def powerup_dist(): + return ( + ('triple_bombs', powerups['Triple']), + ('ice_bombs', powerups['Ice Bombs']), + ('punch', powerups['Punch']), + ('impact_bombs', powerups['Impact Bombs']), + ('land_mines', powerups['Mine Bombs']), + ('sticky_bombs', powerups['Sticky Bombs']), + ('shield', powerups['Shield']), + ('health', powerups['Health']), + ('curse', powerups['Curse']), + ('speed', powerups['Speed']), + ('health_damage', powerups['Healing Damage']), + ('goodbye', powerups['Goodbye']), + ('ice_man', powerups['Ice Man']), + ('tank_shield', powerups['Tank Shield']), + ('impairment_bombs', powerups['Impairment Bombs']), + ('fire_bombs', powerups['Fire Bombs']), + ('fly_bombs', powerups['Fly Bombs']), + ) + + +def percentage_tank_shield(): + percentage = config['Tank Shield PTG'] + percentage_text = ('0.') + str(percentage) + return float(percentage_text) + + +def percentage_health_damage(): + percentage = config['Healing Damage PTG'] + percentage_text = ('0.') + str(percentage) + return float(percentage_text) + + +# === Modify class === + + +class NewProfileBrowserWindow: + def __init__( + self, + transition: str = 'in_right', + in_main_menu: bool = True, + selected_profile: str = None, + origin_widget: bui.Widget = None, + ): + super().__init__(transition, in_main_menu, selected_profile, origin_widget) + + self.session = bs.get_foreground_host_session() + uiscale = bui.app.ui_v1.uiscale + width = 100 if uiscale is babase.UIScale.SMALL else -14 + size = 50 + position = (width * 1.65, 300) + + if isinstance(self.session, MainMenuSession): + self.button = bui.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=position, + size=(size, size), + button_type='square', + label='', + on_activate_call=babase.CallPartial(self.powerupmanager_window), + ) + + size = size * 0.60 + self.image = bui.imagewidget( + parent=self._root_widget, + size=(size, size), + draw_controller=self.button, + position=(position[0] + 10.5, position[1] + 17), + texture=bui.gettexture('powerupSpeed'), + ) + + self.text = bui.textwidget( + parent=self._root_widget, + position=(position[0] + 25, position[1] + 10), + size=(0, 0), + scale=0.45, + color=(0.5, 0.5, 0.5, 0.5), + draw_controller=self.button, + maxwidth=60, + text=(f"powerups manager 1.1.1"), + h_align='center', + v_align='center', + ) + + def powerupmanager_window(self): + bui.containerwidget(edit=self._root_widget, transition='out_left') + PowerupManagerWindow() + + +class NewPowerupBoxFactory(pupbox.PowerupBoxFactory): + def __init__(self) -> None: + super().__init__() + self.tex_speed = bs.gettexture('powerupSpeed') + self.tex_health_damage = bs.gettexture('heart') + self.tex_goodbye = bs.gettexture('achievementOnslaught') + self.tex_ice_man = bs.gettexture('ouyaUButton') + self.tex_tank_shield = bs.gettexture('achievementSuperPunch') + self.tex_impairment_bombs = bs.gettexture('levelIcon') + self.tex_fire_bombs = bs.gettexture('ouyaOButton') + self.tex_fly_bombs = bs.gettexture('star') + + self._powerupdist = [] + for powerup, freq in powerup_dist(): + for _i in range(int(freq)): + self._powerupdist.append(powerup) + + def get_random_powerup_type(self, forcetype=None, excludetypes=None): + + try: + self.mapa = bs.getactivity()._map.getname() + except: + self.mapa = None + + speed_banned_maps = ['Hockey Stadium', 'Lake Frigid', 'Happy Thoughts'] + + if self.mapa in speed_banned_maps: + powerup_disable = ['speed'] + else: + powerup_disable = [] + + if excludetypes is None: + excludetypes = [] + if forcetype: + ptype = forcetype + else: + if self._lastpoweruptype == 'curse': + ptype = 'health' + else: + while True: + ptype = self._powerupdist[random.randint(0, len(self._powerupdist) - 1)] + if ptype not in excludetypes and ptype not in powerup_disable: + break + self._lastpoweruptype = ptype + return ptype + + +def fire_effect(self): + if self.node.exists(): + bs.emitfx( + position=self.node.position, scale=3, count=50 * 2, spread=0.3, chunk_type='sweat' + ) + else: + self.fire_effect_time = None + + +# BOMBS +Bomb._pm_old_bomb = Bomb.__init__ + + +def _bomb_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, + bomb_scale: float = 1.0, + source_player: bs.Player = None, + owner: bs.Node = None, +): + + self.bm_type = bomb_type + new_bomb_type = 'ice' if bomb_type in ['ice_bubble', 'impairment', 'fire', 'fly'] else bomb_type + + # Call original __init__ + self._pm_old_bomb( + position=position, + velocity=velocity, + bomb_type=new_bomb_type, + blast_radius=blast_radius, + bomb_scale=bomb_scale, + source_player=source_player, + owner=owner, + ) + + tex = self.node.color_texture + + if self.bm_type == 'ice_bubble': + self.bomb_type = self.bm_type + self.node.mesh = None + self.shield_ice = bs.newnode( + 'shield', owner=self.node, attrs={'color': (0.5, 1.0, 7.0), 'radius': 0.6} + ) + self.node.connectattr('position', self.shield_ice, 'position') + + elif self.bm_type == 'fire': + self.bomb_type = self.bm_type + self.node.mesh = None + self.shield_fire = bs.newnode( + 'shield', owner=self.node, attrs={'color': (6.5, 6.5, 2.0), 'radius': 0.6} + ) + self.node.connectattr('position', self.shield_fire, 'position') + self.fire_effect_time = bs.Timer(0.1, bs.CallPartial(fire_effect, self), repeat=True) + elif self.bm_type == 'impairment': + self.bomb_type = self.bm_type + tex = bs.gettexture('eggTex3') + elif self.bm_type == 'fly': + self.bomb_type = self.bm_type + tex = bs.gettexture('eggTex1') + + if 'tex' in locals(): + self.node.color_texture = tex + self.hit_subtype = self.bomb_type + + if self.bomb_type == 'ice_bubble': + self.blast_radius *= 1.2 + elif self.bomb_type == 'fly': + self.blast_radius *= 2.2 + + +def bomb_handlemessage(self, msg: Any) -> Any: + assert not self.expired + + if isinstance(msg, bs.DieMessage): + if self.node: + self.node.delete() + + elif isinstance(msg, bomb.ExplodeHitMessage): + node = bs.getcollision().opposingnode + assert self.node + nodepos = self.node.position + mag = 2000.0 + if self.blast_type in ('ice', 'ice_bubble'): + mag *= 0.5 + elif self.blast_type == 'land_mine': + mag *= 2.5 + elif self.blast_type == 'tnt': + mag *= 2.0 + elif self.blast_type == 'fire': + mag *= 0.6 + elif self.blast_type == 'fly': + mag *= 5.5 + + node.handlemessage( + bs.HitMessage( + pos=nodepos, + velocity=(0, 0, 0), + magnitude=mag, + hit_type=self.hit_type, + hit_subtype=self.hit_subtype, + radius=self.radius, + source_player=babase.existing(self._source_player), + ) + ) + if self.blast_type in ('ice', 'ice_bubble'): + bomb.BombFactory.get().freeze_sound.play(10) + node.handlemessage(bs.FreezeMessage()) + + return None + + +def powerup_translated(self, type: str): + powerups_names = { + 'triple_bombs': babase.Lstr(resource='helpWindow.' + 'powerupBombNameText'), + 'ice_bombs': babase.Lstr(resource='helpWindow.' + 'powerupIceBombsNameText'), + 'punch': babase.Lstr(resource='helpWindow.' + 'powerupPunchNameText'), + 'impact_bombs': babase.Lstr(resource='helpWindow.' + 'powerupImpactBombsNameText'), + 'land_mines': babase.Lstr(resource='helpWindow.' + 'powerupLandMinesNameText'), + 'sticky_bombs': babase.Lstr(resource='helpWindow.' + 'powerupStickyBombsNameText'), + 'shield': babase.Lstr(resource='helpWindow.' + 'powerupShieldNameText'), + 'health': babase.Lstr(resource='helpWindow.' + 'powerupHealthNameText'), + 'curse': babase.Lstr(resource='helpWindow.' + 'powerupCurseNameText'), + 'speed': getlanguage('Speed'), + 'health_damage': getlanguage('Healing Damage'), + 'goodbye': getlanguage('Goodbye'), + 'ice_man': getlanguage('Ice Man'), + 'tank_shield': getlanguage('Tank Shield'), + 'impairment_bombs': getlanguage('Impairment Bombs'), + 'fire_bombs': getlanguage('Fire Bombs'), + 'fly_bombs': getlanguage('Fly Bombs'), + } + self.texts['Name'].text = powerups_names[type] + + +# POWERUP +pupbox.PowerupBox._old_pbx_ = pupbox.PowerupBox.__init__ + + +def _pbx_( + self, + position: Sequence[float] = (0.0, 1.0, 0.0), + poweruptype: str = 'triple_bombs', + expire: bool = True, +): + self.news: list = [] + for x, i in powerup_dist(): + self.news.append(x) + + self.box: list = [] + self.texts = {} + self.news = self.news[9:] + self.box.append(poweruptype) + self.npowerup = self.box[0] + factory = NewPowerupBoxFactory.get() + + if self.npowerup in self.news: + new_poweruptype = 'shield' + else: + new_poweruptype = poweruptype + self._old_pbx_(position, new_poweruptype, expire) + + type = new_poweruptype + tex = self.node.color_texture + mesh = self.node.mesh + + if self.npowerup == 'speed': + type = self.npowerup + tex = factory.tex_speed + elif self.npowerup == 'health_damage': + type = self.npowerup + tex = factory.tex_health_damage + elif self.npowerup == 'goodbye': + type = self.npowerup + tex = factory.tex_goodbye + elif self.npowerup == 'ice_man': + type = self.npowerup + tex = factory.tex_ice_man + elif self.npowerup == 'tank_shield': + type = self.npowerup + tex = factory.tex_tank_shield + elif self.npowerup == 'impairment_bombs': + type = self.npowerup + tex = factory.tex_impairment_bombs + elif self.npowerup == 'fire_bombs': + type = self.npowerup + tex = factory.tex_fire_bombs + elif self.npowerup == 'fly_bombs': + type = self.npowerup + tex = factory.tex_fly_bombs + + self.poweruptype = type + self.node.mesh = mesh + self.node.color_texture = tex + n_scale = config['Powerup Scale'] + style = config['Powerup Style'] + + curve = bs.animate(self.node, 'mesh_scale', {0: 0, 0.14: 1.6, 0.2: n_scale}) + bs.timer(0.2, curve.delete) + + def util_text( + type: str, + text: str, + scale: float = 1, + color: list = [1, 1, 1], + position: list = [0, 0.7, 0], + colors_name: bool = False, + ): + m = bs.newnode( + 'math', + owner=self.node, + attrs={'input1': (position[0], position[1], position[2]), 'operation': 'add'}, + ) + self.node.connectattr('position', m, 'input2') + self.texts[type] = bs.newnode( + 'text', + owner=self.node, + attrs={ + 'text': str(text), + 'in_world': True, + 'scale': 0.02, + 'shadow': 0.5, + 'flatness': 1.0, + 'color': (color[0], color[1], color[2]), + 'h_align': 'center', + }, + ) + m.connectattr('output', self.texts[type], 'position') + bs.animate(self.texts[type], 'scale', {0: 0.017, 0.4: 0.017, 0.5: 0.01 * scale}) + + if colors_name: + bs.animate_array( + self.texts[type], + 'color', + 3, + { + 0: (1, 0, 0), + 0.2: (1, 0.5, 0), + 0.4: (1, 1, 0), + 0.6: (0, 1, 0), + 0.8: (0, 1, 1), + 1.0: (1, 0, 1), + 1.2: (1, 0, 0), + }, + loop=True, + ) + + def update_time(time): + if hasattr(self, 'texts') and 'Time' in self.texts and self.texts['Time']: + self.texts['Time'].text = str(time) + + if config['Powerup Time']: + interval = int(pupbox.DEFAULT_POWERUP_INTERVAL) + time2 = interval - 1 + time = 1 + + util_text( + 'Time', time2, scale=1.5, color=(2, 2, 2), position=[0, 0.9, 0], colors_name=False + ) + + while interval + 3: + bs.timer(time - 1, babase.CallPartial(update_time, f'{time2}s')) + + if time2 == 0: + break + + time += 1 + time2 -= 1 + + if config['Powerup With Shield']: + scale = config['Powerup Scale'] + self.shield = bs.newnode( + 'shield', owner=self.node, attrs={'color': (1, 1, 0), 'radius': 1.3 * scale} + ) + self.node.connectattr('position', self.shield, 'position') + bs.animate_array( + self.shield, + 'color', + 3, + {0: (2, 0, 0), 0.5: (0, 2, 0), 1: (0, 1, 6), 1.5: (2, 0, 0)}, + loop=True, + ) + + if config['Powerup Name']: + util_text('Name', self.poweruptype, scale=1.2, position=[0, 0.4, 0], colors_name=True) + powerup_translated(self, self.poweruptype) + + if style == 'SY: BALL': + self.node.mesh = bs.getmesh('frostyPelvis') + elif style == 'SY: Impact': + self.node.mesh = bs.getmesh('impactBomb') + elif style == 'SY: Egg': + self.node.mesh = bs.getmesh('egg') + + +# SPAZ +def _speed_off_flash(self): + if self.node: + factory = NewPowerupBoxFactory.get() + self.node.billboard_texture = factory.tex_speed + self.node.billboard_opacity = 1.0 + self.node.billboard_cross_out = True + + +def _speed_wear_off(self): + if self.node: + self.node.hockey = False + self.node.billboard_opacity = 0.0 + bui.getsound('powerdown01').play() + + +def _ice_man_off_flash(self): + if self.node: + factory = NewPowerupBoxFactory.get() + self.node.billboard_texture = factory.tex_ice_man + self.node.billboard_opacity = 1.0 + self.node.billboard_cross_out = True + + +def _ice_man_wear_off(self): + if self.node: + f = self.color[0] + i = (0, 1, 4) + + bomb = self.bmb_color[0] + if bomb != 'ice_bubble': + self.bomb_type = bomb + else: + self.bomb_type = 'normal' + + self.freeze_punch = False + self.node.billboard_opacity = 0.0 + bs.animate_array(self.node, 'color', 3, {0: f, 0.3: i, 0.6: f}) + bui.getsound('powerdown01').play() + + +Spaz._pm2_spz_old = Spaz.__init__ + + +def _init_spaz_(self, *args, **kwargs): + self._pm2_spz_old(*args, **kwargs) + self.edg_eff = False + self.kill_eff = False + self.freeze_punch = False + self.die = False + self.color: list = [] + self.color.append(self.node.color) + + self.tankshield = {"Tank": False, "Reduction": False, "Shield": None} + + +Spaz._super_on_punch_press = Spaz.on_punch_press + + +def spaz_on_punch_press(self) -> None: + self._super_on_punch_press() + + if self.tankshield['Tank']: + try: + self.tankshield['Reduction'] = True + + shield = bs.newnode( + 'shield', owner=self.node, attrs={'color': (4, 1, 4), 'radius': 1.3} + ) + self.node.connectattr('position_center', shield, 'position') + + self.tankshield['Shield'] = shield + except: + pass + + +Spaz._super_on_punch_release = Spaz.on_punch_release + + +def spaz_on_punch_release(self) -> None: + self._super_on_punch_release() + try: + self.tankshield['Shield'].delete() + self.tankshield['Reduction'] = False + except: + pass + + +def new_get_bomb_type_tex(self) -> babase.Texture: + factory = NewPowerupBoxFactory.get() + if self.bomb_type == 'sticky': + return factory.tex_sticky_bombs + if self.bomb_type == 'ice': + return factory.tex_ice_bombs + if self.bomb_type == 'impact': + return factory.tex_impact_bombs + if self.bomb_type == 'impairment': + return factory.tex_impairment_bombs + if self.bomb_type == 'fire': + return factory.tex_fire_bombs + if self.bomb_type == 'fly': + return factory.tex_fly_bombs + return None + + +def new_handlemessage(self, msg: Any) -> Any: + assert not self.expired + + if isinstance(msg, bs.PickedUpMessage): + if self.node: + self.node.handlemessage('hurt_sound') + self.node.handlemessage('picked_up') + + self._num_times_hit += 1 + + elif isinstance(msg, bs.ShouldShatterMessage): + bs.timer(0.001, bs.WeakCallPartial(self.shatter)) + + elif isinstance(msg, bs.ImpactDamageMessage): + bs.timer(0.001, bs.WeakCallPartial(self._hit_self, msg.intensity)) + elif isinstance(msg, bs.PowerupMessage): + factory = NewPowerupBoxFactory.get() + if self._dead or not self.node: + return True + if self.pick_up_powerup_callback is not None: + self.pick_up_powerup_callback(self) + if msg.poweruptype == 'triple_bombs': + tex = PowerupBoxFactory.get().tex_bomb + self._flash_billboard(tex) + self.set_bomb_count(3) + if self.powerups_expire: + self.node.mini_billboard_1_texture = tex + t_ms = int(bs.time() * 1000) + 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 = bs.Timer( + (POWERUP_WEAR_OFF_TIME - 2000), + babase.CallPartial(self._multi_bomb_wear_off_flash), + ) + self._multi_bomb_wear_off_timer = bs.Timer( + POWERUP_WEAR_OFF_TIME, babase.CallPartial(self._multi_bomb_wear_off) + ) + 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 = int(bs.time() * 1000) + 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 = bs.Timer( + POWERUP_WEAR_OFF_TIME - 2000, babase.CallPartial(self._bomb_wear_off_flash) + ) + self._bomb_wear_off_timer = bs.Timer( + POWERUP_WEAR_OFF_TIME, babase.CallPartial(self._bomb_wear_off) + ) + 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 = int(bs.time() * 1000) + 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 = bs.Timer( + POWERUP_WEAR_OFF_TIME - 2000, babase.CallPartial(self._bomb_wear_off_flash) + ) + self._bomb_wear_off_timer = bs.Timer( + POWERUP_WEAR_OFF_TIME, babase.CallPartial(self._bomb_wear_off) + ) + elif msg.poweruptype == 'punch': + self._has_boxing_gloves = True + tex = PowerupBoxFactory.get().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 = int(bs.time() * 1000) + 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 = bs.Timer( + POWERUP_WEAR_OFF_TIME - 2000, bs.WeakCallPartial(self._gloves_wear_off_flash) + ) + self._boxing_gloves_wear_off_timer = bs.Timer( + POWERUP_WEAR_OFF_TIME, + bs.WeakCallPartial(self._gloves_wear_off), + ) + elif msg.poweruptype == 'shield': + factory = SpazFactory.get() + 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 = bs.time() * 1000 + 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 = bs.Timer( + (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, + bs.WeakCallPartial(self._bomb_wear_off_flash), + ) + + self._bomb_wear_off_timer = bs.Timer( + POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCallStrict(self._bomb_wear_off) + ) + + elif msg.poweruptype == 'health': + if self.edg_eff: + f = self.color[0] + r = (2, 0, 0) + g = (0, 2, 0) + bs.animate_array(self.node, 'color', 3, {0: r, 0.6: g, 1.0: f}) + self.edg_eff = False + if self._cursed: + self._cursed = False + factory = SpazFactory.get() + 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(PowerupBoxFactory.get().tex_health) + self.node.hurt = 0 + self._last_hit_time = None + self._num_times_hit = 0 + + elif msg.poweruptype == 'tank_shield': + self.tankshield['Tank'] = True + self.edg_eff = False + tex = factory.tex_tank_shield + self._flash_billboard(tex) + + elif msg.poweruptype == 'health_damage': + tex = factory.tex_health_damage + self.edg_eff = True + f = self.color[0] + i = (2, 0.5, 2) + bs.animate_array(self.node, 'color', 3, {0: i, 0.5: i, 0.6: f}) + self._flash_billboard(tex) + self.tankshield['Tank'] = False + self.freeze_punch = False + + elif msg.poweruptype == 'goodbye': + tex = factory.tex_goodbye + self._flash_billboard(tex) + self.kill_eff = True + + elif msg.poweruptype == 'fly_bombs': + self.bomb_type = 'fly' + tex = self._get_bomb_type_tex() + self._flash_billboard(tex) + if self.powerups_expire: + self.node.mini_billboard_2_texture = tex + t_ms = bs.time() * 1000 + 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 = bs.Timer( + (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, + bs.WeakCallStrict(self._bomb_wear_off_flash), + ) + + self._bomb_wear_off_timer = bs.Timer( + POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCallStrict(self._bomb_wear_off) + ) + + elif msg.poweruptype == 'fire_bombs': + self.bomb_type = 'fire' + tex = self._get_bomb_type_tex() + self._flash_billboard(tex) + if self.powerups_expire: + self.node.mini_billboard_2_texture = tex + t_ms = bs.time() * 1000 + 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 = bs.Timer( + (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, + bs.WeakCallStrict(self._bomb_wear_off_flash), + ) + + self._bomb_wear_off_timer = bs.Timer( + POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCallStrict(self._bomb_wear_off) + ) + + elif msg.poweruptype == 'impairment_bombs': + self.bomb_type = 'impairment' + tex = self._get_bomb_type_tex() + self._flash_billboard(tex) + if self.powerups_expire: + self.node.mini_billboard_2_texture = tex + t_ms = bs.time() * 1000 + 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 = bs.Timer( + (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, + bs.WeakCallStrict(self._bomb_wear_off_flash), + ) + + self._bomb_wear_off_timer = bs.Timer( + POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCallStrict(self._bomb_wear_off) + ) + + elif msg.poweruptype == 'ice_man': + tex = factory.tex_ice_man + self.bomb_type = 'ice_bubble' + self.freeze_punch = True + self.edg_eff = False + self.node.color = (0, 1, 4) + self._flash_billboard(tex) + + if self.powerups_expire: + ice_man_time = 17000 + self.node.mini_billboard_2_texture = tex + t_ms = bs.time() * 1000 + assert isinstance(t_ms, int) + self.node.mini_billboard_2_start_time = t_ms + self.node.mini_billboard_2_end_time = t_ms + ice_man_time + + self.ice_man_flash_timer = bs.Timer( + (ice_man_time - 2000) / 1000.0, babase.CallPartial(_ice_man_off_flash, self) + ) + + self.ice_man_timer = bs.Timer( + ice_man_time / 1000.0, babase.CallPartial(_ice_man_wear_off, self) + ) + + elif msg.poweruptype == 'speed': + self.node.hockey = True + tex = factory.tex_speed + self._flash_billboard(tex) + if self.powerups_expire: + speed_time = 15000 + self.node.mini_billboard_2_texture = tex + t_ms = bs.time() * 1000 + assert isinstance(t_ms, int) + self.node.mini_billboard_2_start_time = t_ms + self.node.mini_billboard_2_end_time = t_ms + speed_time + + self.speed_flash_timer = bs.Timer( + (speed_time - 2000) / 1000.0, babase.Call(_speed_off_flash, self) + ) + + self.speed_timer = bs.Timer( + speed_time / 1000.0, bs.WeakCallPartial(_speed_wear_off, self) + ) + + self.bmb_color: list = [] + self.bmb_color.append(self.bomb_type) + + self.node.handlemessage('flash') + if msg.sourcenode: + msg.sourcenode.handlemessage(bs.PowerupAcceptMessage()) + return True + + elif isinstance(msg, bs.FreezeMessage): + if not self.node: + return None + if self.node.invincible: + SpazFactory.get().block_sound.play(1.0, self.node.position) + return None + if self.shield: + return None + if not self.frozen: + self.frozen = True + self.node.frozen = True + bs.timer(5.0, bs.WeakCallPartial(self.handlemessage, bs.ThawMessage())) + if self.hitpoints <= 0: + self.shatter() + if self.freeze_punch: + self.handlemessage(bs.ThawMessage()) + + elif isinstance(msg, bs.ThawMessage): + if self.frozen and not self.shattered and self.node: + self.frozen = False + self.node.frozen = False + + elif isinstance(msg, bs.HitMessage): + if not self.node: + return None + if self.node.invincible: + SpazFactory.get().block_sound.play(1.0, self.node.position) + return True + + local_time = bs.time() * 1000 + 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 + + def fire_effect(): + if not self.shield: + if self.node.exists(): + bs.emitfx( + position=self.node.position, + scale=3, + count=50 * 2, + spread=0.3, + chunk_type='sweat', + ) + self.node.handlemessage('celebrate', 560) + else: + self._fire_time = None + else: + self._fire_time = None + + def fire(time, damage): + if not self.shield and not self._dead: + self.hitpoints -= damage + bs.show_damage_count(f'-{damage}HP', self.node.position, msg.force_direction) + bui.getsound('fuse01').play() + + if duration != time: + self._fire_time = bs.Timer(0.1, babase.CallPartial(fire_effect), repeat=True) + else: + self._fire_time = None + + if self.hitpoints < 0: + self.node.handlemessage(bs.DieMessage()) + + if msg.hit_subtype == 'fly': + damage_scale = 0.0 + + if self.shield: + self.shield_hitpoints -= 300 + + if self.shield_hitpoints < 0: + self.shield.delete() + self.shield = None + SpazFactory.get().shield_down_sound.play(1.0, self.node.position) + elif msg.hit_subtype == 'fire': + index = 1 + duration = 5 + damage = 103 + if not self.shield: + for firex in range(duration): + bs.timer(index, bs.CallPartial(fire, index, damage)) + self._fire_time = bs.Timer(0.1, babase.CallPartial(fire_effect), repeat=True) + index += 1 + else: + self.shield_hitpoints -= 80 + if self.shield_hitpoints < 1: + self.shield.delete() + self.shield = None + SpazFactory.get().shield_down_sound.play(1.0, self.node.position) + elif msg.hit_subtype == 'impairment': + damage_scale = 0 + + if self.shield: + self.shield.delete() + self.shield = None + SpazFactory.get().shield_down_sound.play(1.0, self.node.position) + else: + hitpoints = int(self.hitpoints * 0.80) + self.hitpoints -= int(hitpoints) + bs.show_damage_count( + (f'-{int(hitpoints / 10)}%'), self.node.position, msg.force_direction + ) + + if self.hitpoints < 0 or hitpoints < 95: + self.node.handlemessage(bs.DieMessage()) + + if self.shield: + if msg.flat_damage: + damage = msg.flat_damage * self.impact_scale + else: + 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 + + max_spillover = SpazFactory.get().max_shield_spillover_damage + if self.shield_hitpoints <= 0: + + self.shield.delete() + self.shield = None + SpazFactory.get().shield_down_sound.play(1.0, self.node.position) + + npos = self.node.position + bs.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: + SpazFactory.get().shield_hit_sound.play(0.5, self.node.position) + + assert msg.force_direction is not None + bs.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 self.shield_hitpoints <= -max_spillover: + leftover_damage = -max_spillover - self.shield_hitpoints + shield_leftover_ratio = leftover_damage / damage + + mag *= shield_leftover_ratio + velocity_mag *= shield_leftover_ratio + else: + return True + else: + shield_leftover_ratio = 1.0 + + if msg.flat_damage: + damage = int(msg.flat_damage * self.impact_scale * shield_leftover_ratio) + else: + 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) + + if self.tankshield['Reduction']: + porcentaje = percentage_tank_shield() + dism = int(damage * porcentaje) + damage = int(damage - dism) + + bs.show_damage_count('-' + str(int(damage / 10)) + '%', msg.pos, msg.force_direction) + + self.node.handlemessage('hurt_sound') + + if self.edg_eff: + porcentaje = percentage_health_damage() + dmg_dism = int(damage * porcentaje) + self.hitpoints += dmg_dism + + PopupText( + text=f'+{int(dmg_dism / 10)}%', + scale=1.5, + position=self.node.position, + color=(0, 1, 0), + ).autoretain() + bs.animate_array( + self.node, 'color', 3, {0: (0, 1, 0), 0.39: (0, 2, 0), 0.4: self.color[0]} + ) + bui.getsound('healthPowerup').play() + + if msg.hit_type == 'punch': + self.on_punched(damage) + + try: + if msg.get_source_player(bs.Player).actor.freeze_punch: + self.node.color = (0, 1, 4) + bui.getsound('freeze').play() + self.node.handlemessage(bs.FreezeMessage()) + except: + pass + + if damage > 350: + assert msg.force_direction is not None + bs.show_damage_count( + '-' + str(int(damage / 10)) + '%', msg.pos, msg.force_direction + ) + + if msg.hit_subtype == 'super_punch': + SpazFactory.get().punch_sound_stronger.play(1.0, self.node.position) + if damage > 500: + sounds = SpazFactory.get().punch_sound_strong + sound = sounds[random.randrange(len(sounds))] + else: + sound = SpazFactory.get().punch_sound + sound.play(1.0, self.node.position) + + assert msg.force_direction is not None + bs.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, + ) + + bs.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, + ) + + 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 = bs.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, + }, + ) + bs.timer(0.06, light.delete) + + flash = bs.newnode( + 'flash', + attrs={'position': punchpos, 'size': 0.17 + 0.17 * hurtiness, 'color': flash_color}, + ) + bs.timer(0.06, flash.delete) + + if msg.hit_type == 'impact': + assert msg.force_direction is not None + bs.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: + if msg.hit_type == 'impact' and damage > self.hitpoints: + newdamage = max(damage - 200, self.hitpoints - 10) + damage = newdamage + self.node.handlemessage('flash') + + if damage > 0.0 and self.node.hold_node: + self.node.hold_node = None + self.hitpoints -= damage + self.node.hurt = 1.0 - float(self.hitpoints) / self.hitpoints_max + + if self._cursed and damage > 0: + bs.timer( + 0.05, bs.WeakCallPartial(self.curse_explode, msg.get_source_player(bs.Player)) + ) + + if self.frozen and (damage > 200 or self.hitpoints <= 0): + self.shatter() + elif self.hitpoints <= 0: + self.node.handlemessage(bs.DieMessage(how=bs.DeathType.IMPACT)) + + 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, bs.DieMessage): + + def drop_bomb(): + for xbomb in range(3): + p = self.node.position + pos = (p[0] + xbomb, p[1] + 5, p[2] - xbomb) + ball = bomb.Bomb(position=pos, bomb_type='impact').autoretain() + ball.node.mesh_scale = 0.6 + ball.node.mesh = bs.getmesh('egg') + ball.node.gravity_scale = 2 + + if self.edg_eff: + self.edg_eff = False + + wasdead = self._dead + self._dead = True + self.hitpoints = 0 + if msg.immediate: + if self.node: + self.node.delete() + elif self.node: + self.node.hurt = 1.0 + if self.play_big_death_sound and not wasdead: + SpazFactory.get().single_player_death_sound.play() + self.node.dead = True + bs.timer(2.0, self.node.delete) + + t = 0 + if self.kill_eff: + for bombs in range(3): + bs.timer(t, babase.CallPartial(drop_bomb)) + t += 0.15 + self.kill_eff = False + + elif isinstance(msg, bs.OutOfBoundsMessage): + self.handlemessage(bs.DieMessage(how=bs.DeathType.FALL)) + + elif isinstance(msg, bs.StandMessage): + self._last_stand_pos = (msg.position[0], msg.position[1], msg.position[2]) + if self.node: + 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): + if not self.node: + return None + node = bs.getcollision().opposingnode + + 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 + + if node.getnodetype() != 'spaz': + sounds = SpazFactory.get().impact_sounds_medium + sound = sounds[random.randrange(len(sounds))] + sound.play(1.0, 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( + bs.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'), + ) + ) + + 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): + if not self.node: + return None + + try: + collision = bs.getcollision() + opposingnode = collision.opposingnode + opposingbody = collision.opposingbody + except bs.NotFoundError: + return True + + try: + if opposingnode.invincible: + return True + except Exception: + pass + + if ( + opposingnode.getnodetype() == 'spaz' + and not opposingnode.shattered + and opposingbody == 4 + ): + opposingbody = 1 + + held = self.node.hold_node + if held and held.getnodetype() == 'flag': + return True + + self.node.hold_body = opposingbody + self.node.hold_node = opposingnode + elif isinstance(msg, bs.CelebrateMessage): + if self.node: + self.node.handlemessage('celebrate', int(msg.duration * 1000)) + + return None + + +class PowerupManagerWindow(PopupWindow): + def __init__(self, transition='in_right'): + columns = 2 + self._width = width = 800 + self._height = height = 500 + self._sub_height = 200 + self._scroll_width = self._width * 0.90 + self._scroll_height = self._height - 180 + self._sub_width = self._scroll_width * 0.95 + self.tab_buttons: set = {} + self.list_cls_power: list = [] + self.default_powerups = default_powerups() + self.default_power_list = list(self.default_powerups) + self.coins = apg['Bear Coin'] + self.popup_cls_power = None + + if not STORE['Buy Firebombs']: + powerups['Fire Bombs'] = 0 + self.default_power_list.remove('Fire Bombs') + + self.charstr = [ + babase.charstr(babase.SpecialChar.LEFT_ARROW), + babase.charstr(babase.SpecialChar.RIGHT_ARROW), + babase.charstr(babase.SpecialChar.UP_ARROW), + babase.charstr(babase.SpecialChar.DOWN_ARROW), + ] + + self.tabdefs = { + "Action 1": ['levelIcon', (1, 1, 1)], + "Action 2": ['settingsIcon', (1, 1, 1)], + "Action 3": ['inventoryIcon', (1, 1, 1)], + "Action 4": ['storeIcon', (1, 1, 1)], + "Action 5": ['advancedIcon', (1, 1, 1)], + "About": ['achievementEmpty', (1, 1, 1)], + } + + if STORE['Buy Firebombs'] and STORE['Buy Option'] and STORE['Buy Percentage']: + self.tabdefs = { + "Action 1": ['levelIcon', (1, 1, 1)], + "Action 2": ['settingsIcon', (1, 1, 1)], + "Action 3": ['inventoryIcon', (1, 1, 1)], + "About": ['achievementEmpty', (1, 1, 1)], + } + + self.listdef = list(self.tabdefs) + + self.count = len(self.tabdefs) + + self._current_tab = GLOBAL['Tab'] + + app = bui.app.ui_v1 + uiscale = app.uiscale + + self._root_widget = bui.containerwidget( + size=(width + 90, height + 80), + transition=transition, + scale=1.5 if uiscale is babase.UIScale.SMALL else 1.0, + stack_offset=(0, -30) if uiscale is babase.UIScale.SMALL else (0, 0), + ) + + self._backButton = b = bui.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(60, self._height - 15), + size=(130, 60), + scale=0.8, + text_scale=1.2, + label=babase.Lstr(resource='backText'), + button_type='back', + on_activate_call=babase.CallPartial(self._back), + ) + bui.buttonwidget( + edit=self._backButton, + button_type='backSmall', + size=(60, 60), + label=babase.charstr(babase.SpecialChar.BACK), + ) + bui.containerwidget(edit=self._root_widget, cancel_button=b) + + self.titletext = bui.textwidget( + parent=self._root_widget, + position=(0, height - 15), + size=(width, 50), + h_align="center", + color=bui.app.ui_v1.title_color, + v_align="center", + maxwidth=width * 1.3, + ) + + index = 0 + for tab in range(self.count): + for tab2 in range(columns): + + tag = self.listdef[index] + + position = (620 + (tab2 * 120), self._height - 50 * 2.5 - (tab * 120)) + + if tag == 'About': + text = babase.Lstr(resource='gatherWindow.aboutText') + elif tab == 'Action 4': + text = babase.Lstr(resource='storeText') + else: + text = getlanguage(tag) + + self.tab_buttons[tag] = bui.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=position, + size=(110, 110), + scale=1, + label='', + enable_sound=False, + button_type='square', + on_activate_call=babase.CallPartial(self._set_tab, tag, sound=True), + ) + + self.text = bui.textwidget( + parent=self._root_widget, + position=(position[0] + 55, position[1] + 30), + size=(0, 0), + scale=1, + color=bui.app.ui_v1.title_color, + draw_controller=self.tab_buttons[tag], + maxwidth=100, + text=text, + h_align='center', + v_align='center', + ) + + self.image = bui.imagewidget( + parent=self._root_widget, + size=(60, 60), + color=self.tabdefs[tag][1], + draw_controller=self.tab_buttons[tag], + position=(position[0] + 25, position[1] + 40), + texture=bui.gettexture(self.tabdefs[tag][0]), + ) + + index += 1 + + if self.count == index: + break + + if self.count == index: + break + + self._scrollwidget = None + self._tab_container = None + self._set_tab(self._current_tab) + + def __del__(self): + apg.apply_and_commit() + + def popup_menu_closing(self, window): + print("saliendo") + + def _set_tab(self, tab, sound: bool = False): + self.sound = sound + GLOBAL['Tab'] = tab + apg.apply_and_commit() + + if self._tab_container is not None and self._tab_container.exists(): + self._tab_container.delete() + + if self.sound: + bui.getsound('swish').play() + + if self._scrollwidget: + self._scrollwidget.delete() + + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + position=(self._width * 0.08, 51 * 1.8), + size=(self._sub_width - 140, self._scroll_height + 60 * 1.2), + ) + + if tab == 'Action 4': + if self._scrollwidget: + self._scrollwidget.delete() + + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + position=(self._width * 0.08, 51 * 1.8), + size=(self._sub_width - 140, self._scroll_height + 60 * 1.2), + capture_arrows=True, + center_small_content=False, + selection_loops_to_parent=True, + claims_left_right=True, + claims_up_down=False, + color=(0.3, 0.3, 0.4), + ) + bui.textwidget(edit=self.titletext, text=babase.Lstr(resource='storeText')) + + elif tab == 'About': + bui.textwidget(edit=self.titletext, text=babase.Lstr(resource='gatherWindow.aboutText')) + else: + bui.textwidget(edit=self.titletext, text=getlanguage(tab)) + + choices = ['Reset', 'Only Bombs', 'Only Items', 'New', 'Nothing'] + c_display = [] + + for display in choices: + choices_display = babase.Lstr(translate=("", getlanguage(display))) + c_display.append(choices_display) + + if tab == 'Action 1': + self.popup_cls_power = PopupMenu( + parent=self._root_widget, + position=(130, self._width * 0.61), + button_size=(150, 50), + scale=2.5, + choices=choices, + width=150, + choices_display=c_display, + current_choice=GLOBAL['Cls Powerup'], + on_value_change_call=self._set_concept, + ) + self.list_cls_power.append(self.popup_cls_power._button) + + self.button_cls_power = bui.buttonwidget( + parent=self._root_widget, + position=(500, self._width * 0.61), + size=(50, 50), + autoselect=True, + scale=1, + label=('%'), + text_scale=1, + button_type='square', + on_activate_call=self._percentage_window, + ) + self.list_cls_power.append(self.button_cls_power) + + rewindow = [self.popup_cls_power._button, self.button_cls_power] + + for ( + cls + ) in self.list_cls_power: # this is very important so that pupups don't accumulate + if cls not in rewindow: + cls.delete() + + elif tab == 'Action 4': + self.button_coin = bui.buttonwidget( + parent=self._root_widget, + icon=bui.gettexture('coin'), + position=(550, self._width * 0.614), + size=(160, 40), + textcolor=(0, 1, 0), + color=(0, 1, 6), + scale=1, + label=str(apg['Bear Coin']), + text_scale=1, + autoselect=True, + on_activate_call=None, + ) # self._percentage_window) + self.list_cls_power.append(self.button_coin) + + try: + rewindow.append(self.button_coin) + except: + rewindow = [self.button_coin] + for ( + cls + ) in self.list_cls_power: # this is very important so that pupups don't accumulate + if cls not in rewindow: + cls.delete() + + else: + try: + for cls in self.list_cls_power: + cls.delete() + except: + pass + + if tab == 'Action 1': + sub_height = len(self.default_power_list) * 90 + v = sub_height - 55 + width = 300 + posi = 0 + id_power = list(self.default_powerups) + new_powerups = id_power[9:] + self.listpower = {} + + self._tab_container = c = bui.containerwidget( + parent=self._scrollwidget, + size=(self._sub_width, sub_height), + background=False, + selection_loops_to_parent=True, + ) + + for power in self.default_power_list: + if power == id_power[0]: + text = 'helpWindow.powerupShieldNameText' + tex = bui.gettexture('powerupShield') + elif power == id_power[1]: + text = 'helpWindow.powerupPunchNameText' + tex = bui.gettexture('powerupPunch') + elif power == id_power[2]: + text = 'helpWindow.powerupLandMinesNameText' + tex = bui.gettexture('powerupLandMines') + elif power == id_power[3]: + text = 'helpWindow.powerupImpactBombsNameText' + tex = bui.gettexture('powerupImpactBombs') + elif power == id_power[4]: + text = 'helpWindow.powerupIceBombsNameText' + tex = bui.gettexture('powerupIceBombs') + elif power == id_power[5]: + text = 'helpWindow.powerupBombNameText' + tex = bui.gettexture('powerupBomb') + elif power == id_power[6]: + text = 'helpWindow.powerupStickyBombsNameText' + tex = bui.gettexture('powerupStickyBombs') + elif power == id_power[7]: + text = 'helpWindow.powerupCurseNameText' + tex = bui.gettexture('powerupCurse') + elif power == id_power[8]: + text = 'helpWindow.powerupHealthNameText' + tex = bui.gettexture('powerupHealth') + elif power == id_power[9]: + text = power + tex = bui.gettexture('powerupSpeed') + elif power == id_power[10]: + text = power + tex = bui.gettexture('heart') + elif power == id_power[11]: + text = "Goodbye!" + tex = bui.gettexture('achievementOnslaught') + elif power == id_power[12]: + text = power + tex = bui.gettexture('ouyaUButton') + elif power == id_power[13]: + text = power + tex = bui.gettexture('achievementSuperPunch') + elif power == id_power[14]: + text = power + tex = bui.gettexture('levelIcon') + elif power == id_power[15]: + text = power + tex = bui.gettexture('ouyaOButton') + elif power == id_power[16]: + text = power + tex = bui.gettexture('star') + + if power in new_powerups: + label = getlanguage(power) + else: + label = babase.Lstr(resource=text) + + apperance = powerups[power] + position = (90, v - posi) + + t = bui.textwidget( + parent=c, + position=(position[0] - 30, position[1] - 15), + size=(width, 50), + h_align="center", + color=(bui.app.ui_v1.title_color), + text=label, + v_align="center", + maxwidth=width * 1.3, + ) + + self.powprev = bui.imagewidget( + parent=c, + position=(position[0] - 70, position[1] - 10), + size=(50, 50), + texture=tex, + ) + + dipos = 0 + for direc in ['-', '+']: + bui.buttonwidget( + parent=c, + autoselect=True, + position=(position[0] + 270 + dipos, position[1] - 10), + size=(100, 100), + scale=0.4, + label=direc, + button_type='square', + text_scale=4, + on_activate_call=babase.CallPartial(self.apperance_powerups, power, direc), + ) + + dipos += 100 + + textwidget = bui.textwidget( + parent=c, + position=(position[0] + 190, position[1] - 15), + size=(width, 50), + h_align="center", + color=cls_pow_color()[apperance], + text=str(apperance), + v_align="center", + maxwidth=width * 1.3, + ) + self.listpower[power] = textwidget + + posi += 90 + + elif tab == 'Action 2': + sub_height = 370 if not STORE['Buy Option'] else 450 + v = sub_height - 55 + width = 300 + + self._tab_container = c = bui.containerwidget( + parent=self._scrollwidget, + size=(self._sub_width, sub_height), + background=False, + selection_loops_to_parent=True, + ) + + position = (40, v - 20) + + c_display = [] + choices = ['Auto', 'SY: BALL', 'SY: Impact', 'SY: Egg'] + for display in choices: + choices_display = babase.Lstr(translate=("", getlanguage(display))) + c_display.append(choices_display) + + popup = PopupMenu( + parent=c, + position=(position[0] + 300, position[1]), + button_size=(150, 50), + scale=2.5, + choices=choices, + width=150, + choices_display=c_display, + current_choice=config['Powerup Style'], + on_value_change_call=babase.CallPartial(self._all_popup, 'Powerup Style'), + ) + + text = getlanguage('Powerup Style') + wt = len(text) * 0.80 + t = bui.textwidget( + parent=c, + position=(position[0] - 60 + wt, position[1]), + size=(width, 50), + maxwidth=width * 0.9, + scale=1.1, + h_align="center", + color=bui.app.ui_v1.title_color, + text=getlanguage('Powerup Style'), + v_align="center", + ) + + dipos = 0 + for direc in ['-', '+']: + bui.buttonwidget( + parent=c, + autoselect=True, + position=(position[0] + 310 + dipos, position[1] - 100), + size=(100, 100), + repeat=True, + scale=0.4, + label=direc, + button_type='square', + text_scale=4, + on_activate_call=babase.CallPartial(self._powerups_scale, direc), + ) + dipos += 100 + + txt_scale = config['Powerup Scale'] + self.txt_scale = bui.textwidget( + parent=c, + position=(position[0] + 230, position[1] - 105), + size=(width, 50), + scale=1.1, + h_align="center", + color=(0, 1, 0), + text=str(txt_scale), + v_align="center", + maxwidth=width * 1.3, + ) + + text = getlanguage('Powerup Scale') + wt = len(text) * 0.80 + t = bui.textwidget( + parent=c, + position=(position[0] - 60 + wt, position[1] - 100), + size=(width, 50), + maxwidth=width * 0.9, + scale=1.1, + h_align="center", + color=bui.app.ui_v1.title_color, + text=text, + v_align="center", + ) + + position = (position[0] - 20, position[1] + 40) + + self.check = bui.checkboxwidget( + parent=c, + position=(position[0] + 30, position[1] - 230), + value=config['Powerup Name'], + on_value_change_call=babase.CallPartial(self._switches, 'Powerup Name'), + maxwidth=self._scroll_width * 0.9, + text=getlanguage('Powerup Name'), + autoselect=True, + ) + + self.check = bui.checkboxwidget( + parent=c, + position=(position[0] + 30, position[1] - 230 * 1.3), + value=config['Powerup With Shield'], + on_value_change_call=babase.CallPartial(self._switches, 'Powerup With Shield'), + maxwidth=self._scroll_width * 0.9, + text=getlanguage('Powerup With Shield'), + autoselect=True, + ) + + if STORE['Buy Option']: + self.check = bui.checkboxwidget( + parent=c, + position=(position[0] + 30, position[1] - 230 * 1.6), + value=config['Powerup Time'], + on_value_change_call=babase.CallPartial(self._switches, 'Powerup Time'), + maxwidth=self._scroll_width * 0.9, + text=getlanguage('Powerup Time'), + autoselect=True, + ) + + elif tab == 'Action 3': + sub_height = 300 + v = sub_height - 55 + width = 300 + + self._tab_container = c = bui.containerwidget( + parent=self._scrollwidget, + size=(self._sub_width, sub_height), + background=False, + selection_loops_to_parent=True, + ) + + v -= 20 + position = (110, v - 45 * 1.72) + + if not STORE['Buy Percentage']: + t = bui.textwidget( + parent=c, + position=(90, v - 100), + size=(30 + width, 50), + h_align="center", + text=getlanguage('Block Option Store'), + color=bui.app.ui_v1.title_color, + v_align="center", + maxwidth=width * 1.5, + scale=1.5, + ) + + i = bui.imagewidget( + parent=c, + position=(position[0] + 100, position[1] - 205), + size=(80, 80), + texture=bui.gettexture('lock'), + ) + else: + t = bui.textwidget( + parent=c, + position=(position[0] - 14, position[1] + 70), + size=(30 + width, 50), + h_align="center", + text=f"{getlanguage('Tank Shield PTG')} ({getlanguage('Tank Shield')})", + color=bui.app.ui_v1.title_color, + v_align="center", + maxwidth=width * 1.5, + scale=1.5, + ) + + b = bui.buttonwidget( + parent=c, + autoselect=True, + position=position, + size=(100, 100), + repeat=True, + scale=0.6, + label=self.charstr[3], + button_type='square', + text_scale=2, + on_activate_call=babase.CallPartial(self.tank_shield_percentage, 'Decrement'), + ) + + b = bui.buttonwidget( + parent=c, + autoselect=True, + repeat=True, + text_scale=2, + position=(position[0] * 3.2, position[1]), + size=(100, 100), + scale=0.6, + label=self.charstr[2], + button_type='square', + on_activate_call=babase.CallPartial(self.tank_shield_percentage, 'Increment'), + ) + + porcentaje = config['Tank Shield PTG'] + if porcentaje > 59: + color = (0, 1, 0) + elif porcentaje < 40: + color = (1, 1, 0) + else: + color = (0, 1, 0.8) + + self.tank_text = bui.textwidget( + parent=c, + position=(position[0] - 14, position[1] + 5), + size=(30 + width, 50), + h_align="center", + text=str(porcentaje) + '%', + color=color, + v_align="center", + maxwidth=width * 1.3, + scale=2, + ) + + # -----> + + position = (110, v - 160 * 1.6) + t = bui.textwidget( + parent=c, + position=(position[0] - 14, position[1] + 70), + size=(30 + width, 50), + h_align="center", + text=f"{getlanguage('Healing Damage PTG')}{_sp_}({getlanguage('Healing Damage')})", + color=bui.app.ui_v1.title_color, + v_align="center", + maxwidth=width * 1.3, + scale=1.4, + ) + + b = bui.buttonwidget( + parent=c, + autoselect=True, + position=position, + size=(100, 100), + repeat=True, + scale=0.6, + label=self.charstr[3], + button_type='square', + text_scale=2, + on_activate_call=babase.CallPartial(self.health_damage_percentage, 'Decrement'), + ) + + b = bui.buttonwidget( + parent=c, + autoselect=True, + repeat=True, + text_scale=2, + position=(position[0] * 3.2, position[1]), + size=(100, 100), + scale=0.6, + label=self.charstr[2], + button_type='square', + on_activate_call=babase.CallPartial(self.health_damage_percentage, 'Increment'), + ) + + porcentaje = config['Healing Damage PTG'] + if porcentaje > 59: + color = (0, 1, 0) + elif porcentaje < 40: + color = (1, 1, 0) + else: + color = (0, 1, 0.8) + + self.hlg_text = bui.textwidget( + parent=c, + position=(position[0] - 14, position[1] + 5), + size=(30 + width, 50), + h_align="center", + text=str(porcentaje) + '%', + color=color, + v_align="center", + maxwidth=width * 1.3, + scale=2, + ) + + elif tab == 'Percentage': + sub_height = len(self.default_power_list) * 90 + v = sub_height - 55 + width = 300 + posi = 0 + id_power = list(self.default_powerups) + new_powerups = id_power[9:] + self.listpower = {} + + self._tab_container = c = bui.containerwidget( + parent=self._scrollwidget, + size=(self._sub_width, sub_height), + background=False, + selection_loops_to_parent=True, + ) + + for power in self.default_power_list: + if power == id_power[0]: + text = 'helpWindow.powerupShieldNameText' + tex = bui.gettexture('powerupShield') + elif power == id_power[1]: + text = 'helpWindow.powerupPunchNameText' + tex = bui.gettexture('powerupPunch') + elif power == id_power[2]: + text = 'helpWindow.powerupLandMinesNameText' + tex = bui.gettexture('powerupLandMines') + elif power == id_power[3]: + text = 'helpWindow.powerupImpactBombsNameText' + tex = bui.gettexture('powerupImpactBombs') + elif power == id_power[4]: + text = 'helpWindow.powerupIceBombsNameText' + tex = bui.gettexture('powerupIceBombs') + elif power == id_power[5]: + text = 'helpWindow.powerupBombNameText' + tex = bui.gettexture('powerupBomb') + elif power == id_power[6]: + text = 'helpWindow.powerupStickyBombsNameText' + tex = bui.gettexture('powerupStickyBombs') + elif power == id_power[7]: + text = 'helpWindow.powerupCurseNameText' + tex = bui.gettexture('powerupCurse') + elif power == id_power[8]: + text = 'helpWindow.powerupHealthNameText' + tex = bui.gettexture('powerupHealth') + elif power == id_power[9]: + text = power + tex = bui.gettexture('powerupSpeed') + elif power == id_power[10]: + text = power + tex = bui.gettexture('heart') + elif power == id_power[11]: + text = "Goodbye!" + tex = bui.gettexture('achievementOnslaught') + elif power == id_power[12]: + text = power + tex = bui.gettexture('ouyaUButton') + elif power == id_power[13]: + text = power + tex = bui.gettexture('achievementSuperPunch') + elif power == id_power[14]: + text = power + tex = bui.gettexture('levelIcon') + elif power == id_power[15]: + text = power + tex = bui.gettexture('ouyaOButton') + elif power == id_power[16]: + text = power + tex = bui.gettexture('star') + + if power in new_powerups: + label = getlanguage(power) + else: + label = babase.Lstr(resource=text) + + apperance = powerups[power] + position = (90, v - posi) + + t = bui.textwidget( + parent=c, + position=(position[0] - 30, position[1] - 15), + size=(width, 50), + h_align="center", + color=(bui.app.ui_v1.title_color), + text=label, + v_align="center", + maxwidth=width * 1.3, + ) + + self.powprev = bui.imagewidget( + parent=c, + position=(position[0] - 70, position[1] - 10), + size=(50, 50), + texture=tex, + ) + + ptg = str(self.total_percentage(power)) + t = bui.textwidget( + parent=c, + position=(position[0] + 170, position[1] - 10), + size=(width, 50), + h_align="center", + color=(0, 1, 0), + text=(f'{ptg}%'), + v_align="center", + maxwidth=width * 1.3, + ) + + posi += 90 + + elif tab == 'Action 4': + sub_height = 370 + width = 300 + v = sub_height - 55 + u = width - 60 + + if not self._scrollwidget or not self._scrollwidget.exists(): + return + self._tab_container = c = bui.containerwidget( + parent=self._scrollwidget, + size=(width + 500, sub_height), + background=False, + selection_loops_to_parent=True, + ) + + position = (u + 150, v - 250) + n_pos = 0 + prices = [7560, 5150, 3360] + str_name = ["FireBombs Store", "Timer Store", "Percentages Store"] + images = ["ouyaOButton", "settingsIcon", "inventoryIcon"] + + index = 0 + for store in store_items(): + p = prices[index] + txt = str_name[index] + label = getlanguage(txt) + tx_pos = len(label) * 1.8 + lb_scale = len(label) * 0.20 + preview = images[index] + + if STORE[store]: + text = getlanguage('Bought') + icon = bui.gettexture('graphicsIcon') + color = (0.52, 0.48, 0.63) + txt_scale = 1.5 + else: + text = str(p) + icon = bui.gettexture('coin') + color = (0.5, 0.4, 0.93) + txt_scale = 2 + + b = bui.buttonwidget( + parent=c, + autoselect=True, + position=(position[0] + 210 - n_pos, position[1]), + size=(250, 80), + scale=0.7, + label=text, + text_scale=txt_scale, + icon=icon, + color=color, + iconscale=1.7, + on_activate_call=babase.CallPartial(self._buy_object, store, p), + ) + + s = 180 + b = bui.buttonwidget( + parent=c, + autoselect=True, + position=(position[0] + 210 - n_pos, position[1] + 55), + size=(s, s + 30), + scale=1, + label='', + color=color, + button_type='square', + on_activate_call=babase.CallPartial(self._buy_object, store, p), + ) + + s -= 80 + i = bui.imagewidget( + parent=c, + draw_controller=b, + position=(position[0] + 250 - n_pos, position[1] + 140), + size=(s, s), + texture=bui.gettexture(preview), + ) + + t = bui.textwidget( + parent=c, + position=(position[0] + 270 - n_pos, position[1] + 101), + h_align="center", + color=(bui.app.ui_v1.title_color), + text=label, + v_align="center", + maxwidth=130, + ) + + n_pos += 280 + index += 1 + + elif tab == 'Action 5': + sub_height = 370 + v = sub_height - 55 + width = 300 + + self._tab_container = c = bui.containerwidget( + parent=self._scrollwidget, + size=(self._sub_width, sub_height), + background=False, + selection_loops_to_parent=True, + ) + + position = (0, v - 30) + + t = bui.textwidget( + parent=c, + position=(position[0] + 80, position[1] - 30), + size=(width + 60, 50), + scale=1, + h_align="center", + color=(bui.app.ui_v1.title_color), + text=babase.Lstr(resource='settingsWindowAdvanced.enterPromoCodeText'), + v_align="center", + maxwidth=width * 1.3, + ) + + self.promocode_text = bui.textwidget( + parent=c, + position=(position[0] + 80, position[1] - 100), + size=(width + 60, 50), + scale=1, + editable=True, + h_align="center", + color=(bui.app.ui_v1.title_color), + text='', + v_align="center", + maxwidth=width * 1.3, + max_chars=30, + description=babase.Lstr(resource='settingsWindowAdvanced.enterPromoCodeText'), + ) + + self.promocode_button = bui.buttonwidget( + parent=c, + position=(position[0] + 160, position[1] - 170), + size=(200, 60), + scale=1.0, + label=babase.Lstr(resource='submitText'), + on_activate_call=self._promocode, + ) + + else: + sub_height = 0 + v = sub_height - 55 + width = 300 + + self._tab_container = c = bui.containerwidget( + parent=self._scrollwidget, + size=(self._sub_width, sub_height), + background=False, + selection_loops_to_parent=True, + ) + + t = bui.textwidget( + parent=c, + position=(110, v - 20), + size=(width, 50), + scale=1.4, + big=True, + color=(0.5, 0.5, 0.5), + h_align="center", + v_align="center", + text=("powerup manager 1.1.1"), + maxwidth=width * 30, + ) + + t = bui.textwidget( + parent=c, + position=(110, v - 90), + size=(width, 50), + scale=1, + color=(1.3, 0.5, 1.0), + h_align="center", + v_align="center", + text=getlanguage('Creator'), + maxwidth=width * 30, + ) + + t = bui.textwidget( + parent=c, + position=(110, v - 220), + size=(width, 50), + scale=1, + color=(1.0, 1.2, 0.3), + h_align="center", + v_align="center", + text=getlanguage('Mod Info'), + maxwidth=width * 30, + ) + + for select_tab, button_tab in self.tab_buttons.items(): + if select_tab == tab: + bui.buttonwidget(edit=button_tab, color=(0.5, 0.4, 1.5)) + else: + bui.buttonwidget(edit=button_tab, color=(0.52, 0.48, 0.63)) + + def _all_popup(self, tag: str, popup: str) -> None: + config[tag] = popup + apg.apply_and_commit() + + def _set_concept(self, concept: str) -> None: + GLOBAL['Cls Powerup'] = concept + + if concept == 'Reset': + for power, deflt in default_powerups().items(): + powerups[power] = deflt + elif concept == 'Nothing': + for power in default_powerups(): + powerups[power] = 0 + elif concept == 'Only Bombs': + for power, deflt in default_powerups().items(): + if 'Bombs' not in power: + powerups[power] = 0 + else: + powerups[power] = 3 + elif concept == 'Only Items': + for power, deflt in default_powerups().items(): + if 'Bombs' in power: + powerups[power] = 0 + else: + powerups[power] = deflt + elif concept == 'New': + default_power = default_powerups() + new_powerups = list(default_power)[9:] + for power, deflt in default_power.items(): + if power not in new_powerups: + powerups[power] = 0 + else: + powerups[power] = deflt + + if not STORE['Buy Firebombs']: + powerups['Fire Bombs'] = 0 + + self._set_tab('Action 1') + + def tank_shield_percentage(self, tag): + max = 96 + min = 40 + if tag == 'Increment': + config['Tank Shield PTG'] += 1 + if config['Tank Shield PTG'] > max: + config['Tank Shield PTG'] = min + elif tag == 'Decrement': + config['Tank Shield PTG'] -= 1 + if config['Tank Shield PTG'] < min: + config['Tank Shield PTG'] = max + + porcentaje = config['Tank Shield PTG'] + if porcentaje > 59: + color = (0, 1, 0) + elif porcentaje < 40: + color = (1, 1, 0) + else: + color = (0, 1, 0.8) + bui.textwidget(edit=self.tank_text, text=str(porcentaje) + '%', color=color) + + def health_damage_percentage(self, tag): + max = 80 + min = 35 + if tag == 'Increment': + config['Healing Damage PTG'] += 1 + if config['Healing Damage PTG'] > max: + config['Healing Damage PTG'] = min + elif tag == 'Decrement': + config['Healing Damage PTG'] -= 1 + if config['Healing Damage PTG'] < min: + config['Healing Damage PTG'] = max + + porcentaje = config['Healing Damage PTG'] + if porcentaje > 59: + color = (0, 1, 0) + elif porcentaje < 40: + color = (1, 1, 0) + else: + color = (0, 1, 0.8) + bui.textwidget(edit=self.hlg_text, text=str(porcentaje) + '%', color=color) + + def apperance_powerups(self, powerup: str, ID: str): + max = 7 + if ID == "-": + if powerups[powerup] == 0: + powerups[powerup] = max + else: + powerups[powerup] -= 1 + elif ID == "+": + if powerups[powerup] == max: + powerups[powerup] = 0 + else: + powerups[powerup] += 1 + enum = powerups[powerup] + bui.textwidget( + edit=self.listpower[powerup], text=str(powerups[powerup]), color=cls_pow_color()[enum] + ) + + def _powerups_scale(self, ID: str): + max = 1.5 + min = 0.5 + sc = 0.1 + if ID == "-": + if config['Powerup Scale'] < (min + 0.1): + config['Powerup Scale'] = max + else: + config['Powerup Scale'] -= sc + elif ID == "+": + if config['Powerup Scale'] > (max - 0.1): + config['Powerup Scale'] = min + else: + config['Powerup Scale'] += sc + config['Powerup Scale'] = round(config['Powerup Scale'], 1) + bui.textwidget(edit=self.txt_scale, text=str(config['Powerup Scale'])) + + def total_percentage(self, power): + total = 0 + pw = powerups[power] + for i, i2 in powerups.items(): + total += i2 + if total == 0: + return float(total) + else: + ptg = 100 * pw / total + result = round(ptg, 2) + return result + + def store_refresh(self, tag: str): + if tag == 'Buy Firebombs': + powerups['Fire Bombs'] = 3 + self.default_power_list.append('Fire Bombs') + self._set_tab('Action 4') + + def _buy_object(self, tag: str, price: int): + store = BearStore( + value=tag, price=price, callback=babase.CallPartial(self.store_refresh, tag) + ) + store.buy() + + def _promocode(self): + code = bui.textwidget(query=self.promocode_text) + promo = PromoCode(code=code) + promo.code_confirmation() + bui.textwidget(edit=self.promocode_text, text="") + + def _switches(self, tag, m): + config[tag] = False if m == 0 else True + apg.apply_and_commit() + + def _percentage_window(self): + self._set_tab('Percentage') + + def _back(self): + bui.containerwidget(edit=self._root_widget, transition='out_left') + babase.app.classic.profile_browser_window() + + +# ba_meta export babase.Plugin + + +class UltimatePowerupManager(babase.Plugin): + # ProfileBrowserWindow = NewProfileBrowserWindow + pupbox.PowerupBoxFactory = NewPowerupBoxFactory + pupbox.PowerupBox.__init__ = _pbx_ + Bomb.__init__ = _bomb_init + SpazBot.handlemessage = bot_handlemessage + Blast.handlemessage = bomb_handlemessage + Spaz.handlemessage = new_handlemessage + Spaz.__init__ = _init_spaz_ + Spaz._get_bomb_type_tex = new_get_bomb_type_tex + Spaz.on_punch_press = spaz_on_punch_press + Spaz.on_punch_release = spaz_on_punch_release + MainMenuActivity.on_transition_in = new_on_transition_in + + def __init__(self) -> None: + + # add_plugin() + ... + + def has_settings_ui(self): + return True + + def show_settings_ui(self, origin_widget): + PowerupManagerWindow() diff --git a/plugins/utilities/rank_system.py b/plugins/utilities/rank_system.py new file mode 100644 index 0000000..681bcb4 --- /dev/null +++ b/plugins/utilities/rank_system.py @@ -0,0 +1,142 @@ +# ba_meta require api 9 + +import os +import json +import babase +import bascenev1 as bs + +plugman = dict( + plugin_name="rank_system", + description="ranks system for servers or for local players", + external_url="", + authors=[ + {"name": "ATD", "email": "anasdhaoidi001@gmail.com", "discord": ""}, + ], + version="1.0.0", +) + +MODS_DIR = os.path.dirname(__file__) +STATS_DIR = os.path.join(MODS_DIR, "stats") +STATS_FILE = os.path.join(STATS_DIR, "ranks.json") + + +class RankSystem: + + def __init__(self): + self.data = {} + self.load() + + def load(self): + if not os.path.exists(STATS_DIR): + os.makedirs(STATS_DIR) + + if os.path.exists(STATS_FILE): + try: + with open(STATS_FILE, "r") as f: + self.data = json.load(f) + except: + self.data = {} + + def save(self): + with open(STATS_FILE, "w") as f: + json.dump(self.data, f, indent=4) + + def add_score(self, account_id, score=1): + if account_id not in self.data: + self.data[account_id] = { + "score": 0, + "rank": 0 + } + + self.data[account_id]["score"] += score + self.update_ranks() + self.save() + + def update_ranks(self): + sorted_players = sorted( + self.data.items(), + key=lambda x: x[1]["score"], + reverse=True + ) + + for i, (aid, p) in enumerate(sorted_players): + p["rank"] = i + 1 + + def get_rank(self, account_id): + return self.data.get(account_id, {}).get("rank") + + +rank_sys = RankSystem() + + +class RankTag: + def __init__(self, node, rank): + + m = bs.newnode( + "math", + owner=node, + attrs={ + "input1": (0, 1.2, 0), + "operation": "add" + } + ) + + node.connectattr("torso_position", m, "input2") + + if rank == 1: + text = "1" + color = (1, 1, 1) + elif rank == 2: + text = "2" + color = (1, 1, 1) + elif rank == 3: + text = "3" + color = (1, 1, 1) + else: + text = f"#{rank}" + color = (1, 1, 1) + + t = bs.newnode( + "text", + owner=node, + attrs={ + "text": text, + "in_world": True, + "color": color, + "scale": 0.01, + "h_align": "center" + } + ) + + m.connectattr("output", t, "position") + + +# 🔌 plugin +# ba_meta export babase.Plugin +class byATD(babase.Plugin): + + def on_app_running(self): + print("Rank System Loaded ") + + import bascenev1._gameactivity as ga + + old_spawn = ga.GameActivity.spawn_player_spaz + + def new_spawn(self, player, *args, **kwargs): + spaz = old_spawn(self, player, *args, **kwargs) + + try: + aid = player.sessionplayer.get_account_id() + + rank_sys.add_score(aid, 1) + + rank = rank_sys.get_rank(aid) + if rank: + RankTag(spaz.node, rank) + + except Exception as e: + print("Rank error:", e) + + return spaz + + ga.GameActivity.spawn_player_spaz = new_spawn diff --git a/plugins/utilities/sleep_on_afk.py b/plugins/utilities/sleep_on_afk.py new file mode 100644 index 0000000..3f3ad3b --- /dev/null +++ b/plugins/utilities/sleep_on_afk.py @@ -0,0 +1,129 @@ +# ba_meta require api 9 +from __future__ import annotations + +import babase +import bascenev1 as bs + +from bascenev1lib.actor.spaz import Spaz + +plugman = dict( + plugin_name="sleep_on_afk", + description="Staying idle for 40 seconds will make your character fall asleep, they need rest too..", + external_url="", + authors=[ + {"name": "DinoWattz", "email": "", "discord": ""} + ], + version="1.0.0", +) + +INGAME_TIME = 40 # (in seconds) + +# Spaz Changes + + +def _afk_sleep_knockout(self, value: float) -> None: + if not self.node: + return + if not self.is_alive() and hasattr(self.getplayer(self), 'sessionplayer'): + current_activity = self.getactivity() + player = self.getplayer(self).sessionplayer + # Pop timer data if the player is dead + if current_activity.customdata.get(str(player.id) + '_knockoutTimer') is not None: + current_activity.customdata.pop(str(player.id) + '_knockoutTimer') + return + + self.node.handlemessage('knockout', value) + + +Spaz._afk_sleep_knockout = _afk_sleep_knockout # type: ignore + +# Idle Checker + + +def idle_start(activity: bs.Activity): + activity.customdata['afk_timer'] = bs.Timer( + 0.5, bs.CallStrict(idle_check, activity), repeat=True) + + +def idle_check(current_activity: bs.Activity): + current_session = current_activity.session + wait_time = INGAME_TIME if not current_activity.slow_motion else round(INGAME_TIME / 3) + current = bs.time() * 1000 + if not current_session: + return + for player in current_session.sessionplayers: + if ( + not player.exists() + or not player.in_game + or not getattr(player, 'activityplayer', None) + or not getattr(player.activityplayer, 'actor', None) + or not getattr(player.activityplayer.actor, 'node', None) + or not player.activityplayer.is_alive() + or not player.activityplayer.actor.node + or not player.activityplayer.actor.node.exists() + and not getattr(player.activityplayer.actor.node, 'invincible', False) + ): + continue + + player_actor = player.activityplayer.actor + player_node = player_actor.node + player_data = player.activityplayer.customdata + player_turbo_times = player_actor._turbo_filter_times + + if player_node.move_up_down != 0.0 or player_node.move_left_right != 0.0: + player_data['last_input'] = current + elif player_turbo_times: + highest_turbo_time = player_turbo_times.get( + max(player_turbo_times, key=player_turbo_times.get)) + if highest_turbo_time > player_data.get('last_input', current): + player_data.update({'last_input': highest_turbo_time}) + player_data['last_input'] = player_data.get('last_input', current) + + last_input = max(0, player_data['last_input']) + afk_time = int((current - last_input) / 1000) + + # print(player_data) + # print(last_input) + # print(current) + # print(player.getname() + ": " + str(afk_time)) + # print(wait_time) + + if afk_time >= wait_time: + current_activity.customdata[str(player.id) + '_knockoutTimer'] = bs.Timer( + 0.1, bs.WeakCallStrict(player_actor._afk_sleep_knockout, 100.0), repeat=True) + + # Make the player's node not an area of interest if it was one + if not getattr(player_actor, '_previous_is_area_of_interest', False): + player_actor._previous_is_area_of_interest = player_node.is_area_of_interest + + if player_actor._previous_is_area_of_interest: + player_node.is_area_of_interest = False + + elif current_activity.customdata.get(str(player.id) + '_knockoutTimer') is not None: + current_activity.customdata.pop(str(player.id) + '_knockoutTimer') + + # Restore the player's node area of interest if necessary + if getattr(player_actor, '_previous_is_area_of_interest', False): + player_node.is_area_of_interest = True + + if hasattr(player_actor, "_previous_is_area_of_interest"): + delattr(player_actor, "_previous_is_area_of_interest") + + +# Setup new activity +org_on_begin = bs.Activity.on_begin + + +def patched_on_begin(self, *args, **kwargs): + idle_start(self) + + return org_on_begin(self, *args, **kwargs) + + +bs.Activity.on_begin = patched_on_begin + +# ba_meta export babase.Plugin + + +class Plugin(babase.Plugin): + pass diff --git a/test/auto_apply_plugin_metadata.py b/test/auto_apply_plugin_metadata.py index 228e810..088e3cc 100644 --- a/test/auto_apply_plugin_metadata.py +++ b/test/auto_apply_plugin_metadata.py @@ -2,11 +2,20 @@ import sys import json import ast import os +import hashlib +import subprocess import get_latest -import versioning_tools +from auto_apply_version_metadata import get_comparable_version_tuple_from_string DEBUG = True +MANIFEST_PATHS = { + "minigames": "plugins/minigames.json", + "utilities": "plugins/utilities.json", + "maps": "plugins/maps.json", + "plugman": "index.json", +} + print("DOES THIS RUN AUTO APPLY PLUGIN METADATA?") @@ -15,23 +24,69 @@ def debug_print(*args, **kwargs): print(*args, **kwargs) -def get_latest_version(plugin_name, category) -> str: - filepaths = { - "minigames": "plugins/minigames.json", - "utilities": "plugins/utilities.json", - "maps": "plugins/maps.json", - "plugman": "index.json", - } +def version_key(version): + """Comparison/sort key for a version string, so 1.0.10 sorts above 1.0.9.""" + try: + return get_comparable_version_tuple_from_string(version) + except ValueError: + raise ValueError(f"Version {version!r} is not in x.y.z form.") + +def md5sum_of(path): + with open(path, "rb") as fin: + return hashlib.md5(fin.read()).hexdigest() + + +def get_latest_version(plugin_name, category) -> str: try: if category != "plugman": - return get_latest.get_latest_plugin_version(plugin_name, filepaths[category]) + return get_latest.get_latest_plugin_version(plugin_name, MANIFEST_PATHS[category]) return get_latest.get_latest_plugman_version() except Exception as e: raise e +def read_manifest_at(path, ref): + """Load a manifest as it exists at `ref`, or None if it can't be read there.""" + try: + blob = subprocess.run( + ["git", "show", f"{ref}:{path}"], + capture_output=True, + check=True, + ).stdout + return json.loads(blob) + except (OSError, subprocess.CalledProcessError, json.JSONDecodeError): + return None + + +def get_published_versions(plugin_name, category): + """Versions of `plugin_name` that are already published on the PR's base branch. + + The working tree cannot answer this. Once ci-apply.yml has pushed its + "[ci] apply-plugin-metadata-and-formatting" commit back to the PR branch, + the PR's own copy of the manifest already lists the version being added - + so comparing against the working tree rejects every re-run of PR Check, + including the one ci-apply.yml's own push triggers, and every re-run caused + by a contributor pushing a follow-up commit. + + PLUGMAN_BASE_REF is set by ci-check.yml to the PR's base sha. Local runs + fall back to origin/main, then to the working tree when there is no history + to consult at all. + """ + path = MANIFEST_PATHS[category] + manifest = None + for ref in (os.environ.get("PLUGMAN_BASE_REF"), "origin/main", "main"): + if ref: + manifest = read_manifest_at(path, ref) + if manifest is not None: + break + if manifest is None: + with open(path, "r") as file: + manifest = json.load(file) + return manifest["plugins"].get(plugin_name, {}).get("versions", {}) + + def update_plugman_json(version): with open("index.json", "r+") as file: data = json.load(file) @@ -45,39 +100,58 @@ def update_plugman_json(version): data["versions"] = dict(sorted(data["versions"].items(), reverse=True)) -def update_plugin_json(plugin_info, category): +def update_plugin_json(plugin_info, category, plugin_path): name = plugin_info["plugin_name"] + version = plugin_info["version"] + + # Ensure the version is always greater than the already PUBLISHED version - + # what is on the base branch, not what this PR's own tree happens to say. + published = get_published_versions(name, category) + if published: + latest_published = max(published, key=version_key) + if version_key(version) <= version_key(latest_published): + raise Exception( + "Version cant be lower or equal than the previous version. " + f"{name} {latest_published} is already published; bump the version " + f"in its plugman dict (currently {version})." + ) with open(f"plugins/{category}.json", "r+") as file: data = json.load(file) - try: - # Check if plugin is already in the json - plugin = data["plugins"][name] - plugman_version = int( - versioning_tools.semantic_to_str(get_latest_version(name, category)) - ) - current_version = int(versioning_tools.semantic_to_str(plugin_info["version"])) - # Ensure the version is always greater from the already released version - if current_version > plugman_version: - plugin["versions"][plugin_info["version"]] = None - # Ensure latest version appears first - plugin["versions"] = dict(sorted(plugin["versions"].items(), reverse=True)) - plugin["description"] = plugin_info["description"] - plugin["external_url"] = plugin_info["external_url"] - plugin["authors"] = plugin_info["authors"] - # In future - # We can clear the version metadata for if current_version = plugman_version for PRs - # So that its easier to update the plugin after review without changing version - elif current_version <= plugman_version: - raise Exception("Version cant be lower or equal than the previous version.") - except KeyError: - data["plugins"][name] = { + plugin = data["plugins"].get(name) + if plugin is None: + # New plugin. Key order here is the shape every other entry has. + plugin = data["plugins"][name] = { "description": plugin_info["description"], "external_url": plugin_info["external_url"], "authors": plugin_info["authors"], - "versions": {plugin_info["version"]: None}, + "versions": {}, } + versions = plugin["versions"] + stamped = versions.get(version) + # A null placeholder is only (re)written when there is something for + # auto_apply_version_metadata.py to stamp, which keeps re-runs over an + # already-processed tree a no-op - otherwise ci-apply.yml's push would + # trigger a PR Check that undoes the stamp, forever. + # + # A stamped entry whose md5sum no longer matches the file means the + # contributor pushed further edits under the same UNPUBLISHED version + # (the published case raised above). Reset it so the stamp is + # recomputed, rather than demanding a bump for every review iteration. + if version not in versions or ( + isinstance(stamped, dict) and stamped.get("md5sum") != md5sum_of(plugin_path) + ): + versions[version] = None + + # Ensure latest version appears first + plugin["versions"] = dict( + sorted(versions.items(), key=lambda item: version_key(item[0]), reverse=True) + ) + plugin["description"] = plugin_info["description"] + plugin["external_url"] = plugin_info["external_url"] + plugin["authors"] = plugin_info["authors"] + file.seek(0) json.dump(data, file, indent=2, ensure_ascii=False) # Ensure old content is removed @@ -132,7 +206,7 @@ def extract_plugman(plugins): ) result[kw.arg] = ast.literal_eval(kw.value) if category: - update_plugin_json(result, category=category) + update_plugin_json(result, category=category, plugin_path=plugin) else: update_plugman_json(result) # raise ValueError("Variable plugman not found in the file or has unsupported format.") diff --git a/test/test_checks.py b/test/test_checks.py index c5bc6df..5de8010 100644 --- a/test/test_checks.py +++ b/test/test_checks.py @@ -11,6 +11,39 @@ from packaging.version import Version import unittest +# Raised by Repo.commit() when a revision cannot be resolved. Both entries are +# required because which one you get depends on how the sha is spelled: an +# unresolvable abbreviated name - the 7-8 char form this repo actually stores, +# see auto_apply_version_metadata.py - raises BadName, whereas a full-length +# but absent 40-hex sha raises plain ValueError. BadName/BadObject are NOT +# ValueError subclasses, so neither entry is redundant. Kept narrow so lenient +# mode can't mask unrelated repository errors. +# +# BadName/BadObject originate in gitdb but are re-exported by GitPython in +# git.exc.__all__, so git.exc.* is the supported public spelling and is used +# here deliberately - it avoids importing gitdb, a transitive dependency that +# test/pip_reqs.txt does not declare directly. +UNRESOLVED_COMMIT_ERRORS = (ValueError, git.exc.BadName, git.exc.BadObject) + + +def is_unpublished_version(repository, version_metadata): + """True if this version entry is being introduced by the PR under test. + + Such an entry can't satisfy the history checks yet: its metadata is either + still an unstamped ``null`` placeholder, or was stamped against the current + HEAD by ci-check.yml's preview - describing the reformatted working tree + rather than anything committed. Published entries always point at an + earlier commit whose tree really does contain the described file, so they + are unaffected. Only consulted in lenient mode (ci-check.yml); the + authoritative strict run on push-to-main still validates these. + """ + if not version_metadata or not version_metadata.get("commit_sha"): + return True + try: + return repository.commit(version_metadata["commit_sha"]) == repository.head.commit + except UNRESOLVED_COMMIT_ERRORS: + return False # unresolvable: let the caller's handler report it + class TestPluginManagerMetadata(unittest.TestCase): def setUp(self): @@ -40,8 +73,19 @@ class TestPluginManagerMetadata(unittest.TestCase): assert sorted_versions == versions def test_versions(self): + lenient = os.environ.get("PLUGMAN_CI_LENIENT_HISTORY") == "1" for version_name, version_metadata in self.content["versions"].items(): - commit = self.repository.commit(version_metadata["commit_sha"]) + if lenient and is_unpublished_version(self.repository, version_metadata): + print(f"[lenient] skipping {version_name}: not committed yet") + continue + try: + commit = self.repository.commit(version_metadata["commit_sha"]) + except UNRESOLVED_COMMIT_ERRORS as err: + if lenient: + print(f"[lenient] skipping {version_name}: commit " + f"{version_metadata['commit_sha']} not found yet ({err})") + continue + raise plugin_manager = commit.tree / self.plugin_manager with io.BytesIO(plugin_manager.data_stream.read()) as fin: content = fin.read() @@ -144,9 +188,21 @@ class BaseCategoryMetadataTestCases: self.assertTrue(len(plugin_metadata["versions"]) > 0) def test_versions(self): + lenient = os.environ.get("PLUGMAN_CI_LENIENT_HISTORY") == "1" for plugin_name, plugin_metadata in self.content["plugins"].items(): for version_name, version_metadata in plugin_metadata["versions"].items(): - commit = self.repository.commit(version_metadata["commit_sha"]) + if lenient and is_unpublished_version(self.repository, version_metadata): + print(f"[lenient] skipping {plugin_name} {version_name}: " + "not committed yet") + continue + try: + commit = self.repository.commit(version_metadata["commit_sha"]) + except UNRESOLVED_COMMIT_ERRORS as err: + if lenient: + print(f"[lenient] skipping {plugin_name} {version_name}: " + f"commit {version_metadata['commit_sha']} not found yet ({err})") + continue + raise plugin = os.path.join(self.category, f"{plugin_name}.py") plugin_commit_sha = commit.tree / plugin with io.BytesIO(plugin_commit_sha.data_stream.read()) as fin: