merge conflicts

This commit is contained in:
Ayush Saini 2023-05-06 23:12:19 +05:30
commit d770fd9ee1
14 changed files with 3129 additions and 267 deletions

View file

@ -1,5 +1,24 @@
## Plugin Manager (dd-mm-yyyy)
### 0.3.2 (30-04-2023)
- Fix sometimes same sound would repeat twice when pressing a button.
- Low key attempt to experiment with staging branch by changing current tag in `plugin_manager.py`.
- Assume underscores as spaces when searching for plugins in game.
### 0.3.1 (04-03-2023)
- Resize the plugin window to limit the overlapping of plugin description.
### 0.3.0 (12-02-2023)
- Displays a tutorial button in the plugin window, whenever there is a supported url present in the plugin data.
### 0.2.2 (18-01-2023)
- Auto add new line breaks in long plugin descriptions.
- Fixed an issue where pressing back on the main plugin manager window would play the sound twice.
### 0.2.1 (17-12-2022)
- Add Google DNS as a fallback for Jio ISP DNS blocking resolution of raw.githubusercontent.com domain.

View file

@ -1,8 +1,3 @@
[![DownloadIcon]][DownloadLink]
[DownloadIcon]:https://img.shields.io/badge/Download-5555ff?style=for-the-badge&logoColor=white&logo=DocuSign
[DownloadLink]:https://cdn.jsdelivr.net/gh/bombsquad-community/plugin-manager/plugin_manager.py
[![CI](https://github.com/bombsquad-community/plugin-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/bombsquad-community/plugin-manager/actions/workflows/ci.yml)
# plugin-manager
@ -10,6 +5,11 @@
A plugin manager for the game - [Bombsquad](https://www.froemling.net/apps/bombsquad). Plugin manager is a plugin in itself,
which makes further modding of your game more convenient by providing easier access to community created content.
[![DownloadIcon]][DownloadLink]
[DownloadIcon]:https://img.shields.io/badge/Download-5555ff?style=for-the-badge&logoColor=white&logo=DocuSign
[DownloadLink]:https://cdn.jsdelivr.net/gh/bombsquad-community/plugin-manager/plugin_manager.py
![Plugin Manager GIF](https://user-images.githubusercontent.com/106954762/190505304-519c4b91-2461-42b1-be57-655a3fb0cbe8.gif)
## Features
@ -178,6 +178,14 @@ That's it! Now you can make a [pull request](../../compare) with both the update
repository in your plugin manager by adding `rikkolovescats/sahilp-plugins` as a custom source through the category
selection popup window in-game.
#### Known 3rd Party Plugin Sources
If you maintain or know of a 3rd party plugin source, let us know and we'll add it below so people can know about it. It
will also help us to notify the maintainers of any future breaking changes in plugin manager that could affect 3rd party
plugin sources.
https://github.com/rikkolovescats/sahilp-plugins
## Tests

View file

@ -1,6 +1,30 @@
{
"plugin_manager_url": "https://github.com/bombsquad-community/plugin-manager/{content_type}/{tag}/plugin_manager.py",
"versions": {
"0.3.2": {
"api_version": 7,
"commit_sha": "789f95f",
"released_on": "30-04-2023",
"md5sum": "ff679f8411e426e5e4e92a2f958eec02"
},
"0.3.1": {
"api_version": 7,
"commit_sha": "0b856ba",
"released_on": "04-03-2023",
"md5sum": "52fdce0f242b1bc52a1cbf2e7d78d230"
},
"0.3.0": {
"api_version": 7,
"commit_sha": "cb5df36",
"released_on": "12-02-2023",
"md5sum": "d149fedf64b002c97fbb883ae5629d49"
},
"0.2.2": {
"api_version": 7,
"commit_sha": "2672a5a",
"released_on": "18-01-2023",
"md5sum": "2ef9761e4a02057cd93db3d280427f12"
},
"0.2.1": {
"api_version": 7,
"commit_sha": "8ac1032",

View file

@ -1,7 +1,7 @@
# ba_meta require api 7
import ba
import _ba
from bastd.ui import popup
from bastd.ui import popup, confirm
import urllib.request
import http.client
@ -24,8 +24,10 @@ _env = _ba.env()
_uiscale = ba.app.ui.uiscale
PLUGIN_MANAGER_VERSION = "0.2.1"
PLUGIN_MANAGER_VERSION = "0.3.2"
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.
CURRENT_TAG = "main"
INDEX_META = "{repository_url}/{content_type}/{tag}/index.json"
HEADERS = {
@ -104,10 +106,6 @@ async def async_stream_network_response_to_file(request, file, md5sum=None, retr
return content
def play_sound():
ba.playsound(ba.getsound('swish'))
def partial_format(string_template, **kwargs):
for key, value in kwargs.items():
string_template = string_template.replace("{" + key + "}", value)
@ -312,8 +310,11 @@ class Category:
async def fetch_metadata(self):
if self._metadata is None:
# Let's keep depending on the "main" branch for 3rd party sources
# even if we're using a different branch of plugin manager's repository.
tag = "main" if self.is_3rd_party else CURRENT_TAG
request = urllib.request.Request(
self.meta_url.format(content_type="raw", tag=CURRENT_TAG),
self.meta_url.format(content_type="raw", tag=tag),
headers=self.request_headers,
)
response = await async_send_network_request(request)
@ -788,13 +789,37 @@ class PluginWindow(popup.PopupWindow):
loop = asyncio.get_event_loop()
loop.create_task(self.draw_ui())
def get_description(self, minimum_character_offset=40):
"""
Splits the loong plugin description into multiple lines.
"""
string = self.plugin.info["description"]
string_length = len(string)
partitioned_string = ""
partitioned_string_length = len(partitioned_string)
while partitioned_string_length != string_length:
next_empty_space = string[partitioned_string_length +
minimum_character_offset:].find(" ")
next_word_end_position = partitioned_string_length + \
minimum_character_offset + max(0, next_empty_space)
partitioned_string += string[partitioned_string_length:next_word_end_position]
if next_empty_space != -1:
# Insert a line break here, there's still more partitioning to do.
partitioned_string += "\n"
partitioned_string_length = len(partitioned_string)
return partitioned_string
async def draw_ui(self):
# print(ba.app.plugins.active_plugins)
play_sound()
ba.playsound(ba.getsound('swish'))
b_text_color = (0.75, 0.7, 0.8)
s = 1.1 if _uiscale is ba.UIScale.SMALL else 1.27 if ba.UIScale.MEDIUM else 1.57
width = 360 * s
height = 100 + 100 * s
s = 1.25 if _uiscale is ba.UIScale.SMALL else 1.39 if ba.UIScale.MEDIUM else 1.67
width = 400 * s
height = 120 + 100 * s
color = (1, 1, 1)
text_scale = 0.7 * s
self._transition_out = 'out_scale'
@ -803,7 +828,7 @@ class PluginWindow(popup.PopupWindow):
self._root_widget = ba.containerwidget(size=(width, height),
# parent=_ba.get_special_widget(
# 'overlay_stack'),
on_outside_click_call=self._ok,
on_outside_click_call=self._cancel,
transition=transition,
scale=(2.1 if _uiscale is ba.UIScale.SMALL else 1.5
if _uiscale is ba.UIScale.MEDIUM else 1.0),
@ -837,7 +862,7 @@ class PluginWindow(popup.PopupWindow):
ba.textwidget(parent=self._root_widget,
position=(width * 0.49, pos), size=(0, 0),
h_align='center', v_align='center',
text=self.plugin.info["description"],
text=self.get_description(),
scale=text_scale * 0.6, color=color,
maxwidth=width * 0.95)
b1_color = None
@ -907,10 +932,10 @@ class PluginWindow(popup.PopupWindow):
text_scale=1,
label=button3_label)
ba.containerwidget(edit=self._root_widget,
on_cancel_call=self._ok)
on_cancel_call=self._cancel)
open_pos_x = (300 if _uiscale is ba.UIScale.SMALL else
360 if _uiscale is ba.UIScale.MEDIUM else 350)
open_pos_x = (390 if _uiscale is ba.UIScale.SMALL else
450 if _uiscale is ba.UIScale.MEDIUM else 440)
open_pos_y = (100 if _uiscale is ba.UIScale.SMALL else
110 if _uiscale is ba.UIScale.MEDIUM else 120)
open_button = ba.buttonwidget(parent=self._root_widget,
@ -937,6 +962,45 @@ class PluginWindow(popup.PopupWindow):
rotate=25,
scale=0.45)
# Below snippet handles the tutorial button in the plugin window
tutorial_url = self.plugin.info["external_url"]
if tutorial_url:
def tutorial_confirm_window():
text = "This will take you to \n\""+self.plugin.info["external_url"] + "\""
tutorial_confirm_window = confirm.ConfirmWindow(
text=text,
action=lambda: ba.open_url(self.plugin.info["external_url"]),
)
open_pos_x = (440 if _uiscale is ba.UIScale.SMALL else
500 if _uiscale is ba.UIScale.MEDIUM else 490)
open_pos_y = (100 if _uiscale is ba.UIScale.SMALL else
110 if _uiscale is ba.UIScale.MEDIUM else 120)
open_button = ba.buttonwidget(parent=self._root_widget,
autoselect=True,
position=(open_pos_x, open_pos_y),
size=(40, 40),
button_type="square",
label="",
# color=ba.app.ui.title_color,
color=(0.6, 0.53, 0.63),
on_activate_call=tutorial_confirm_window)
ba.imagewidget(parent=self._root_widget,
position=(open_pos_x, open_pos_y),
size=(40, 40),
color=(0.8, 0.95, 1),
texture=ba.gettexture("frameInset"),
draw_controller=open_button)
ba.textwidget(parent=self._root_widget,
position=(open_pos_x - 3, open_pos_y + 12),
text="Tutorial",
size=(10, 10),
draw_controller=open_button,
color=(1, 1, 1, 1),
rotate=25,
scale=0.45)
if to_draw_button4:
settings_pos_x = (60 if _uiscale is ba.UIScale.SMALL else
60 if _uiscale is ba.UIScale.MEDIUM else 60)
@ -963,7 +1027,10 @@ class PluginWindow(popup.PopupWindow):
# ba.containerwidget(edit=self._root_widget, start_button=button3)
def _ok(self) -> None:
play_sound()
ba.containerwidget(edit=self._root_widget, transition='out_scale')
def _cancel(self) -> None:
ba.playsound(ba.getsound('swish'))
ba.containerwidget(edit=self._root_widget, transition='out_scale')
def button(fn):
@ -1130,7 +1197,6 @@ class PluginManager:
class PluginSourcesWindow(popup.PopupWindow):
def __init__(self, origin_widget):
play_sound()
self.selected_source = None
self.scale_origin = origin_widget.get_screen_space_center()
@ -1301,7 +1367,7 @@ class PluginSourcesWindow(popup.PopupWindow):
self.draw_sources()
def _ok(self) -> None:
play_sound()
ba.playsound(ba.getsound('swish'))
ba.containerwidget(edit=self._root_widget, transition='out_scale')
@ -1336,7 +1402,7 @@ class PluginCategoryWindow(popup.PopupMenuWindow):
PluginSourcesWindow(origin_widget=self.root_widget)
def _ok(self) -> None:
play_sound()
ba.playsound(ba.getsound('swish'))
ba.containerwidget(edit=self.root_widget, transition='out_scale')
@ -1420,7 +1486,6 @@ class PluginManagerWindow(ba.Window):
)
def _back(self) -> None:
play_sound()
from bastd.ui.settings.allsettings import AllSettingsWindow
ba.containerwidget(edit=self._root_widget,
transition=self._transition_out)
@ -1615,6 +1680,8 @@ class PluginManagerWindow(ba.Window):
draw_controller=controller_button)
def search_term_filterer(self, plugin, search_term):
# This helps resolve "plugin name" to "plugin_name".
search_term = search_term.replace(" ", "_")
if search_term in plugin.name:
return True
if search_term in plugin.info["description"].lower():
@ -1711,7 +1778,6 @@ class PluginManagerWindow(ba.Window):
PluginWindow(plugin, self._root_widget, lambda: self.draw_plugin_name(plugin))
def show_categories_window(self):
play_sound()
PluginCategoryWindow(
self.plugin_manager.categories.keys(),
self.selected_category,
@ -1751,7 +1817,6 @@ class PluginManagerWindow(ba.Window):
class PluginManagerSettingsWindow(popup.PopupWindow):
def __init__(self, plugin_manager, origin_widget):
play_sound()
self._plugin_manager = plugin_manager
self.scale_origin = origin_widget.get_screen_space_center()
self.settings = ba.app.config["Community Plugin Manager"]["Settings"].copy()
@ -1958,7 +2023,7 @@ class PluginManagerSettingsWindow(popup.PopupWindow):
self._update_button.delete()
def _ok(self) -> None:
play_sound()
ba.playsound(ba.getsound('swish'))
ba.containerwidget(edit=self._root_widget, transition='out_scale')

View file

@ -250,6 +250,30 @@
}
}
},
"hot_bomb": {
"description": "Get the bomb to explode on the enemy team to win.",
"external_url": "",
"authors": [
{
"name": "SEBASTIAN2059",
"email": "",
"discord": "SEBASTIAN2059#5751"
},
{
"name": "zPanxo",
"email": "",
"discord": "zPanxo#7201"
}
],
"versions": {
"1.0.0": {
"api_version": 7,
"commit_sha": "095a773",
"released_on": "29-04-2023",
"md5sum": "7296a71c9ed796ab1f54c2eb5d843bda"
}
}
},
"shimla": {
"description": "Death match with elevators, 2-D view.",
"external_url": "https://www.youtube.com/channel/UCaQajfKHrTPgiOhuias5iPg",

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,30 @@
"description": "Utilities",
"plugins_base_url": "https://github.com/bombsquad-community/plugin-manager/{content_type}/{tag}/plugins/utilities",
"plugins": {
"random_join": {
"description": "Come visit the unknown servers around all the world! Plugin designed not to join servers with similar names more frequently than rare ones. Have fun!",
"external_url": "",
"authors": [
{
"name": "maxick",
"email": "",
"discord": "maxick#9227"
},
{
"name": "LoupGarou",
"email": "LoupGarou5418@outlook.com",
"discord": "ʟօʊքɢǟʀօʊ#3063"
}
],
"versions": {
"1.0.0": {
"api_version": 7,
"commit_sha": "2454845",
"released_on": "26-12-2022",
"md5sum": "7bac6bfe837ff89e7da10a0ab45691d1"
}
}
},
"share_replay": {
"description": "Export replays to mods folder and share them with friends or have a backup",
"external_url": "",
@ -14,6 +38,12 @@
}
],
"versions": {
"1.3.0": {
"api_version": 7,
"commit_sha": "ec116b3",
"released_on": "20-12-2022",
"md5sum": "dbb9d85a5fb0041631dc12765a257fce"
},
"1.2.1": {
"api_version": 7,
"commit_sha": "7753b87",
@ -179,7 +209,7 @@
},
"colorscheme": {
"description": "Create custom UI colorschemes!",
"external_url": "https://www.youtube.com/watch?v=qatwWrBAvjc",
"external_url": "https://www.youtube.com/watch?v=G6824StL4eg",
"authors": [
{
"name": "Rikko",
@ -299,6 +329,12 @@
}
],
"versions": {
"1.2.1": {
"api_version": 7,
"commit_sha": "64e8a5c",
"released_on": "18-12-2022",
"md5sum": "5237713243bd3ba5dd20a5efc568f40d"
},
"1.2.0": {
"api_version": 7,
"commit_sha": "b7036af",
@ -324,6 +360,12 @@
}
],
"versions": {
"1.0.1": {
"api_version": 7,
"commit_sha": "64e8a5c",
"released_on": "18-12-2022",
"md5sum": "7807b532802d17b77a0017c46ac1cbfb"
},
"1.0.0": {
"api_version": 7,
"commit_sha": "e994af5",
@ -362,6 +404,12 @@
}
],
"versions": {
"1.0.1": {
"api_version": 7,
"commit_sha": "64e8a5c",
"released_on": "18-12-2022",
"md5sum": "8efcf38604e5519d66a858cc38868641"
},
"1.0.0": {
"api_version": 7,
"commit_sha": "e994af5",
@ -434,7 +482,7 @@
}
},
"pro_unlocker": {
"description": "Unlocks some pro-only features - custom colors, playlist maker, etc.\n(Please support the game developer if you can!)",
"description": "Unlocks some pro-only features - custom colors, playlist maker, etc. (Please support the game developer if you can!)",
"external_url": "",
"authors": [
{
@ -510,7 +558,7 @@
}
},
"bomb_radius_visualizer": {
"description": "With this cutting edge technology, you precisely know\nhow close to the bomb you can tread.\nSupports modified blast radius values!",
"description": "With this cutting edge technology, you precisely know how close to the bomb you can tread. Supports modified blast radius values!",
"external_url": "",
"authors": [
{
@ -565,6 +613,63 @@
"md5sum": "a04c30c11a43443fe192fe70ad528f22"
}
}
},
"autorun": {
"description": "Run without holding any buttons. Made for beginners or players on mobile. Keeps your character maneuverable. Start running as usual to override.",
"external_url": "",
"authors": [
{
"name": "TheMikirog",
"email": "",
"discord": "TheMikirog#1984"
}
],
"versions": {
"1.0.0": {
"api_version": 7,
"commit_sha": "cb2d952",
"released_on": "01-01-2023",
"md5sum": "22f54996dc55008267d09bf48a2cffe3"
}
}
},
"tnt_respawn_text": {
"description": "Shows when a TNT box is about to respawn with non-intrusive text.",
"external_url": "",
"authors": [
{
"name": "TheMikirog",
"email": "",
"discord": "TheMikirog#1984"
}
],
"versions": {
"1.0.0": {
"api_version": 7,
"commit_sha": "05ffa9f",
"released_on": "14-01-2023",
"md5sum": "cc1738b0326c9679453bdf1489ded483"
}
}
},
"allow_invisible_models": {
"description": "Changing model to None will make it invisible instead of raising an exception.",
"external_url": "",
"authors": [
{
"name": "Rikko",
"email": "rikkolovescats@proton.me",
"discord": "Rikko#7383"
}
],
"versions": {
"1.0.0": {
"api_version": 7,
"commit_sha": "3221b3a",
"released_on": "22-01-2023",
"md5sum": "24913c665d05c3056c8ba390fe88155e"
}
}
}
}
}

View file

@ -67,7 +67,7 @@ ssl._create_default_https_context = ssl._create_unverified_context
def newconnect_to_party(address, port=43210, print_progress=False):
global ip_add
global p_port
dd = _ba.get_connection_to_host_info()
if (dd != {}):
_ba.disconnect_from_host()
@ -1724,7 +1724,7 @@ def fetchAccountInfo(account, loading_widget):
if account in fdata:
servers = fdata[account]
url = f'https://{BCSSERVER}/player?key={base64.b64encode(account.encode("utf-8")).decode("utf-8")}&base64=true'
data = urllib.request.urlopen(url)
account_data = json.loads(data.read().decode('utf-8'))[0]
pbid = account_data["pbid"]
@ -2175,6 +2175,8 @@ class CustomAccountViewerWindow(viewer.AccountViewerWindow):
ba.print_exception('Error displaying account info.')
# ba_meta export plugin
class bySmoothy(ba.Plugin):
def __init__(self):
if _ba.env().get("build_number", 0) >= 20577:

View file

@ -0,0 +1,15 @@
# ba_meta require api 7
import ba
original_getmodel = ba.getmodel
def get_model_gracefully(model):
if model is not None:
return original_getmodel(model)
# ba_meta export plugin
class Main(ba.Plugin):
def on_app_running(self):
ba.getmodel = get_model_gracefully

View file

@ -0,0 +1,259 @@
# ba_meta require api 7
"""
AutoRun by TheMikirog
Version 1
Run without holding any buttons. Made for beginners or players on mobile.
Keeps your character maneuverable.
Start running as usual to override.
Heavily commented for easy modding learning!
No Rights Reserved
"""
from __future__ import annotations
from typing import TYPE_CHECKING
# Let's import everything we need and nothing more.
import ba
import bastd
import math
from ba._generated.enums import TimeType
from bastd.actor.spaz import Spaz
if TYPE_CHECKING:
pass
"""
This mod is much more "technical" than my other mods.
I highly recommend checking out the code of this mod once you have a good understanding of programming.
At the very least check out my other heavily commented mods like my Hot Potato gamemode. It's pretty dank!
Normally you shouldn't flood your scripts with comments like that.
I do it here to help people like you get the basic tools required to make your own mods similar to this one.
If you write your own code, only comment what can't be easily inferred from reading the code alone.
Consider this an interactive tutorial of sorts.
Let's start with the goal of this mod; the conception.
If you play on mobile, the only way you get to run is if you press and hold any other action button like jump or punch.
Basically, all action buttons do two things at once unless a gamemode disables one of those actions.
Playing on a gamepad or keyboard gives you the luxury of a dedicated run button, which gives you much more control
over your movement. This basically forces mobile players that are running to:
- Punch and risk being open to attacks.
- Kill all your momentum by jumping.
- Using the bomb to run, but only after using that same button to throw an already held bomb.
- Using an inconvenient out of the way grab button to avoid all of that hassle.
It's a mess. Get a gamepad.
This mod exists as an alternative to those who can't play on a gamepad, but don't want
to be inconvenienced by running quirks if they JUST WANT TO PLAY.
The naive implementation of this would be to just running all the time, but here's the catch:
Running makes turning less tight, which is the compromise for being really fast.
If you want to have tighter turns, you'd release the run button for a split second, turn and press it again.
Much easier and more convenient to do if you're on a gamepad.
The goal of this mod is to replicate this behavior and making it automatic.
My aim is to get the player moving as fast as possible without making it significantly harder to control.
This is supposed to help mobile players, not handicap them.
I can imagine the sweet relief of not being forced to babysit an action button just for running fast.
Actually it should help gamepad users too, since holding your trigger can be exhausting
or even impossible for those with physical disabilities.
For your information, I started writing this mod THREE times.
Each time with the goal of trying out different ways of achieving my goals.
I used the code and failures of the previous scripts to make the next one better.
What you're seeing here is the final iteration; the finished product.
Don't expect your code to look like this the first time, especially if you're trying something ballsy.
You will fail, but don't be afraid to experiment.
Only through experimentation you can forge a failure into a success.
"""
# ba_meta export plugin
class AutoRun(ba.Plugin):
# During my research and prototyping I figured I'd have to do some linear algebgra.
# I didn't want to use libraries, since this is supposed to be a standalone mod.
# Because of this I made certain functions from scratch that are easily accessible.
# If you are curious over the details, look these up on the Internet.
# I'll only briefly cover their purpose in the context of the mod.
# Here's the dot product function.
# To keep it short, it returns the difference in angle between two vectors.
# We're gonna use that knowledge to check how tight our turn is.
# I'll touch on that later.
def dot(vector_a, vector_b):
return vector_a[0] * vector_b[0] + vector_a[1] * vector_b[1]
# This clamping function will make sure a certain value won't go above or below a certain threshold.
# self.node.run attribute expects a value between 0-1, so this is one way of enforcing this.
def clamp(num, min_value, max_value):
num = max(min(num, max_value), min_value)
return num
# A vector can be of any length, but we need them to be of length 1.
# This vector normalization function changes the magnitude of a vector without changing its direction.
def normalize(vector):
length = math.hypot(vector[0], vector[1]) # Pythagoras says hi
# Sometimes we'll get a [0,0] vector and dividing by 0 is iffy.
# Let's leave the vector unchanged if that's the case.
if length > 0:
return [vector[0] / length, vector[1] / length]
else:
return vector
# We use a decorator to add extra code to existing code, increasing mod compatibility.
# We're gonna use decorators ALOT in this mod.
# Here I'm defining a new spaz init function that'll be replaced.
def new_init(func):
def wrapper(*args, **kwargs):
# Here's where we execute the original game's code, so it's not lost.
# We want to add our code at the end of the existing code, so our code goes under that.
func(*args, **kwargs)
# We define some variables that we need to keep track of.
# For future reference, if you see args[0] anywhere, that is "self" in the original function.
args[0].autorun_timer: ba.Timer | None = None
args[0].autorun_override = False
# We wanna do our auto run calculations when the player moves their analog stick to make it responsive.
# However doing this ONLY tracks changes in analog stick position and some bugs come up because of that.
# For example moving via dpad on a gamepad can sometimes not execute the run at all.
# To keep the behavior predictable, we also want to update our auto run functionality with a periodic timer.
# We could ignore the update on analog stick movement, but then it feels terrible to play. We need both.
# Update on analog movement for responsive controls, timer to foolproof everything else.
# To make our timer, we want to have access to our function responsible for doing the auto run logic.
# The issue is that timers only work when a function is created within the context of the game.
# Timer throws a tantrum if it references the run_update function, but NOT if that function is an intermediary.
def spaz_autorun_update():
AutoRun.run_update(args[0])
# We don't want this logic to be ran on bots, only players.
# Check if we have a player assigned to that spaz. If we do, let's make our timer.
if args[0].source_player:
# And here's our timer.
# It loops indefinitely thanks to the 'repeat' argument that is set to True.
# Notice how it's the capital T Timer instead of the small letter.
# That's important, because big T returns a timer object we can manipulate.
# We need it assigned to a variable, because we have to delete it once it stops being relevant.
args[0].autorun_timer = ba.Timer(
0.1, spaz_autorun_update, timetype=TimeType.SIM, repeat=True)
return wrapper
# Let's replace the original function with our modified version.
bastd.actor.spaz.Spaz.__init__ = new_init(bastd.actor.spaz.Spaz.__init__)
# This is the bulk of our mod. Our run_update function.
# The goal here is to change the self.node.run attribute of our character.
# This attribute handles running behavior based on how far we pushed the running trigger.
# 0 means not running and 1 means run trigger fully pressed.
# On mobile it's always 0 and 1, but on gamepad you can have values between them
# For example you can do a jog instead of a sprint.
# We activate this function periodically via a timer and every time the player moves their analog stick.
# The idea is to make it 1 when the player is running forward and make it 0
# when the player makes the tightest turn possible.
# We also want to account for how far the analog stick is pushed.
def run_update(self) -> None:
# Let's not run this code if our character does not exist or the player decides to run "manually".
if not self.node or self.autorun_override:
return
# Let's read our player's analog stick.
# Notice how the vertical direction is inverted (there's a minus in front of the variable).
# We want the directions to corespond to the game world.
vertical = -self.node.move_up_down
horizontal = self.node.move_left_right
movement_vector = [horizontal, vertical]
# Get our character's facing direction
facing_direction = (self.node.position[0] - self.node.position_forward[0],
self.node.position[2] - self.node.position_forward[2])
# We want our character's facing direction to be a normalized vector (magnitude of 1).
facing_direction = AutoRun.normalize(facing_direction)
# We don't want to run our code if the player has their analog stick in a neutral position.
if movement_vector == [0.0, 0.0]:
return
# Get the difference between our current facing direction and where we plan on moving towards.
# Check the dot function higher up in the script for details.
dot = AutoRun.dot(facing_direction, AutoRun.normalize(movement_vector))
if dot > 0.0:
# Our dot value ranges from -1 to 1.
# We want it from 0 to 1.
# 0 being 180 degree turn, 1 being running exactly straight.
dot = (dot + 1) / 2
# Let's read how far our player pushed his stick. 1 being full tilt, 0 being neutral.
# Heres our homie Pythagoras once again
run_power = math.hypot(movement_vector[0], movement_vector[1])
# I noticed the player starts running too fast if the stick is pushed half-way.
# I changed the linear scale to be exponential.
# easings.net is a great website that shows you different ways of converting a linear curve to some other kind.
# Here I used the EaseInQuad easing, which is just raising the value to the power of 2.
# This should make half-way pushes less severe.
run_power = pow(run_power, 2)
# Just in case let's clamp our value from 0 to 1.
run_power = AutoRun.clamp(run_power, 0.0, 1.0)
# Here we combine our dot result with how far we pushed our stick to get the final running value.
# Clamping from 0 to 1 for good measure.
self.node.run = AutoRun.clamp(run_power * dot, 0.0, 1.0)
# This function is called every time we want to run or touch a running trigger.
# We have our auto run stuff, but we also want for our mod to play nice with the current running behavior.
# We also want this to work with my Quickturn mod.
def new_onrun(func):
def wrapper(*args, **kwargs):
# When we hold an action button or press our running trigger at any point, our mod should stop interfering.
# This won't work if your gamepad has borked triggers though.
args[0].autorun_override = args[1]
# Here's our original unchanged function
func(*args, **kwargs)
return wrapper
# We replace the character running function with our modified version.
bastd.actor.spaz.Spaz.on_run = new_onrun(bastd.actor.spaz.Spaz.on_run)
# There's two function that are called when our player pushes the analog stick - two for each axis.
# Here's for the vertical axis.
def new_updown(func):
def wrapper(*args, **kwargs):
# Original function
func(*args, **kwargs)
# If we're not holding the run button and we're a player, run our auto run behavior.
if not args[0].autorun_override and args[0].source_player:
AutoRun.run_update(args[0])
return wrapper
# You get the idea.
bastd.actor.spaz.Spaz.on_move_up_down = new_updown(bastd.actor.spaz.Spaz.on_move_up_down)
# Let's do the same for our horizontal axis.
# Second verse same as the first.
def new_leftright(func):
def wrapper(*args, **kwargs):
func(*args, **kwargs)
if not args[0].autorun_override and args[0].source_player:
AutoRun.run_update(args[0])
return wrapper
bastd.actor.spaz.Spaz.on_move_left_right = new_leftright(
bastd.actor.spaz.Spaz.on_move_left_right)
# There's one downside to the looping timer - it runs constantly even if the player is dead.
# We don't want to waste computational power on something like that.
# Let's kill our timer when the player dies.
def new_handlemessage(func):
def wrapper(*args, **kwargs):
# Only react to the death message.
if isinstance(args[1], ba.DieMessage):
# Kill the timer.
args[0].autorun_timer = None
# Original function.
func(*args, **kwargs)
return wrapper
bastd.actor.spaz.Spaz.handlemessage = new_handlemessage(bastd.actor.spaz.Spaz.handlemessage)

View file

@ -63,6 +63,7 @@ https://ballistica.net/discord
"""
BCSURL = 'https://bcsserver.bombsquad.ga/bannedservers'
def is_game_version_lower_than(version):
"""
Returns a boolean value indicating whether the current game
@ -79,6 +80,7 @@ if is_game_version_lower_than("1.7.7"):
else:
ba_internal = ba.internal
def updateBannedServersCache():
response = None
config = ba.app.config
@ -508,61 +510,62 @@ def popup_menu_selected_choice(self, window: popup.PopupMenu,
def _update_party_lists(self) -> None:
if not self._party_lists_dirty:
return
starttime = time.time()
config = ba.app.config
bannedservers = config.get('Banned Servers',[])
assert len(self._parties_sorted) == len(self._parties)
if not self._party_lists_dirty:
return
starttime = time.time()
config = ba.app.config
bannedservers = config.get('Banned Servers', [])
assert len(self._parties_sorted) == len(self._parties)
self._parties_sorted.sort(
key=lambda p: (
p[1].ping if p[1].ping is not None else 999999.0,
p[1].index,
)
self._parties_sorted.sort(
key=lambda p: (
p[1].ping if p[1].ping is not None else 999999.0,
p[1].index,
)
)
# If signed out or errored, show no parties.
if (
ba.internal.get_v1_account_state() != 'signed_in'
or not self._have_valid_server_list
):
self._parties_displayed = {}
else:
if self._filter_value:
filterval = self._filter_value.lower()
self._parties_displayed = {
k: v
for k, v in self._parties_sorted
if (filterval in v.name.lower() or filterval in v.address) and (v.address not in bannedservers if ENABLE_SERVER_BANNING else True)
}
else:
self._parties_displayed = {
k: v
for k, v in self._parties_sorted
if (v.address not in bannedservers if ENABLE_SERVER_BANNING else True)
}
# Any time our selection disappears from the displayed list, go back to
# auto-selecting the top entry.
if (
self._selection is not None
and self._selection.entry_key not in self._parties_displayed
):
self._have_user_selected_row = False
# Whenever the user hasn't selected something, keep the first visible
# row selected.
if not self._have_user_selected_row and self._parties_displayed:
firstpartykey = next(iter(self._parties_displayed))
self._selection = Selection(firstpartykey, SelectionComponent.NAME)
self._party_lists_dirty = False
if DEBUG_PROCESSING:
print(
f'Sorted {len(self._parties_sorted)} parties in'
f' {time.time()-starttime:.5f}s.'
)
# If signed out or errored, show no parties.
if (
ba.internal.get_v1_account_state() != 'signed_in'
or not self._have_valid_server_list
):
self._parties_displayed = {}
else:
if self._filter_value:
filterval = self._filter_value.lower()
self._parties_displayed = {
k: v
for k, v in self._parties_sorted
if (filterval in v.name.lower() or filterval in v.address) and (v.address not in bannedservers if ENABLE_SERVER_BANNING else True)
}
else:
self._parties_displayed = {
k: v
for k, v in self._parties_sorted
if (v.address not in bannedservers if ENABLE_SERVER_BANNING else True)
}
# Any time our selection disappears from the displayed list, go back to
# auto-selecting the top entry.
if (
self._selection is not None
and self._selection.entry_key not in self._parties_displayed
):
self._have_user_selected_row = False
# Whenever the user hasn't selected something, keep the first visible
# row selected.
if not self._have_user_selected_row and self._parties_displayed:
firstpartykey = next(iter(self._parties_displayed))
self._selection = Selection(firstpartykey, SelectionComponent.NAME)
self._party_lists_dirty = False
if DEBUG_PROCESSING:
print(
f'Sorted {len(self._parties_sorted)} parties in'
f' {time.time()-starttime:.5f}s.'
)
def replace():
manualtab.ManualGatherTab._build_favorites_tab = new_build_favorites_tab
@ -697,5 +700,5 @@ class InitalRun(ba.Plugin):
def __init__(self):
replace()
config = ba.app.config
if config["launchCount"]% 5 ==0:
if config["launchCount"] % 5 == 0:
updateBannedServersCache()

View file

@ -0,0 +1,315 @@
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar
import _ba
import ba
import ba.internal
import random
from bastd.ui.gather.publictab import PublicGatherTab, PartyEntry, PingThread
if TYPE_CHECKING:
from typing import Callable
ClassType = TypeVar('ClassType')
MethodType = TypeVar('Methodtype')
def override(cls: ClassType) -> Callable[[MethodType], MethodType]:
def decorator(newfunc: MethodType) -> MethodType:
funcname = newfunc.__code__.co_name
if hasattr(cls, funcname):
oldfunc = getattr(cls, funcname)
setattr(cls, f'_old_{funcname}', oldfunc)
setattr(cls, funcname, newfunc)
return newfunc
return decorator
# Can this stuff break mro? (P.S. yes, so we're not using super() anymore).
# Although it gives nice auto-completion.
# And anyways, why not just GatherPublicTab = NewGatherPublicTab?
# But hmm, if we imagine someone used `from blah.blah import Blah`, using
# `blah.Blah = NewBlah` AFTERWARDS would be meaningless.
class NewPublicGatherTab(PublicGatherTab, PingThread):
@override(PublicGatherTab)
def _build_join_tab(self, region_width: float,
region_height: float,
oldfunc: Callable = None) -> None:
# noinspection PyUnresolvedReferences
self._old__build_join_tab(region_width, region_height)
# Copy-pasted from original function.
c_width = region_width
c_height = region_height - 20
sub_scroll_height = c_height - 125
sub_scroll_width = 830
v = c_height - 35
v -= 60
self._random_join_button = ba.buttonwidget(
parent=self._container,
label='random',
size=(90, 45),
position=(710, v + 10),
on_activate_call=ba.WeakCall(self._join_random_server),
)
ba.widget(edit=self._random_join_button, up_widget=self._host_text,
left_widget=self._filter_text)
# We could place it somewhere under plugin settings which is kind of
# official way to customise plugins. Although it's too deep:
# Gather Window -> Main Menu -> Settings -> Advanced -(scroll)->
# Plugins -(scroll probably)-> RandomJoin Settings.
self._random_join_settings_button = ba.buttonwidget(
parent=self._container,
icon=ba.gettexture('settingsIcon'),
size=(40, 40),
position=(820, v + 13),
on_activate_call=ba.WeakCall(self._show_random_join_settings),
)
@override(PublicGatherTab)
def _show_random_join_settings(self) -> None:
RandomJoinSettingsPopup(
origin_widget=self._random_join_settings_button)
@override(PublicGatherTab)
def _get_parties_list(self) -> list[PartyEntry]:
if (self._parties_sorted and
(randomjoin.maximum_ping == 9999 or
# Ensure that we've pinged at least 10%.
len([p for k, p in self._parties_sorted
if p.ping is not None]) > len(self._parties_sorted) / 10)):
randomjoin.cached_parties = [p for k, p in self._parties_sorted]
return randomjoin.cached_parties
@override(PublicGatherTab)
def _join_random_server(self) -> None:
name_prefixes = set()
parties = [p for p in self._get_parties_list() if
(p.size >= randomjoin.minimum_players
and p.size < p.size_max and (randomjoin.maximum_ping == 9999
or (p.ping is not None
and p.ping <= randomjoin.maximum_ping)))]
if not parties:
ba.screenmessage('No suitable servers found; wait',
color=(1, 0, 0))
ba.playsound(ba.getsound('error'))
return
for party in parties:
name_prefixes.add(party.name[:6])
random.choice(list(name_prefixes))
party = random.choice(
[p for p in parties if p.name[:6] in name_prefixes])
ba.internal.connect_to_party(party.address, party.port)
class RandomJoinSettingsPopup(ba.Window):
def __init__(self, origin_widget: ba.Widget) -> None:
c_width = 600
c_height = 400
uiscale = ba.app.ui.uiscale
super().__init__(root_widget=ba.containerwidget(
scale=(
1.8
if uiscale is ba.UIScale.SMALL
else 1.55
if uiscale is ba.UIScale.MEDIUM
else 1.0
),
scale_origin_stack_offset=origin_widget.get_screen_space_center(),
stack_offset=(0, -10)
if uiscale is ba.UIScale.SMALL
else (0, 15)
if uiscale is ba.UIScale.MEDIUM
else (0, 0),
size=(c_width, c_height),
transition='in_scale',
))
ba.textwidget(
parent=self._root_widget,
size=(0, 0),
h_align='center',
v_align='center',
text='Random Join Settings',
scale=1.5,
color=(0.6, 1.0, 0.6),
maxwidth=c_width * 0.8,
position=(c_width * 0.5, c_height - 60),
)
v = c_height - 120
ba.textwidget(
parent=self._root_widget,
size=(0, 0),
h_align='right',
v_align='center',
text='Maximum ping',
maxwidth=c_width * 0.3,
position=(c_width * 0.4, v),
)
self._maximum_ping_edit = ba.textwidget(
parent=self._root_widget,
size=(c_width * 0.3, 40),
h_align='left',
v_align='center',
text=str(randomjoin.maximum_ping),
editable=True,
description='Maximum ping (ms)',
position=(c_width * 0.6, v - 20),
autoselect=True,
max_chars=4,
)
v -= 60
ba.textwidget(
parent=self._root_widget,
size=(0, 0),
h_align='right',
v_align='center',
text='Minimum players',
maxwidth=c_width * 0.3,
position=(c_width * 0.4, v),
)
self._minimum_players_edit = ba.textwidget(
parent=self._root_widget,
size=(c_width * 0.3, 40),
h_align='left',
v_align='center',
text=str(randomjoin.minimum_players),
editable=True,
description='Minimum number of players',
position=(c_width * 0.6, v - 20),
autoselect=True,
max_chars=4,
)
v -= 60
# Cancel button.
self.cancel_button = btn = ba.buttonwidget(
parent=self._root_widget,
label=ba.Lstr(resource='cancelText'),
size=(180, 60),
color=(1.0, 0.2, 0.2),
position=(40, 30),
on_activate_call=self._cancel,
autoselect=True,
)
ba.containerwidget(edit=self._root_widget, cancel_button=btn)
# Save button.
self.savebtn = btn = ba.buttonwidget(
parent=self._root_widget,
label=ba.Lstr(resource='saveText'),
size=(180, 60),
position=(c_width - 200, 30),
on_activate_call=self._save,
autoselect=True,
)
ba.containerwidget(edit=self._root_widget, start_button=btn)
def _save(self) -> None:
errored = False
minimum_players: int | None = None
maximum_ping: int | None = None
try:
minimum_players = int(
ba.textwidget(query=self._minimum_players_edit))
except ValueError:
ba.screenmessage('"Minimum players" should be integer',
color=(1, 0, 0))
ba.playsound(ba.getsound('error'))
errored = True
try:
maximum_ping = int(
ba.textwidget(query=self._maximum_ping_edit))
except ValueError:
ba.screenmessage('"Maximum ping" should be integer',
color=(1, 0, 0))
ba.playsound(ba.getsound('error'))
errored = True
if errored:
return
assert minimum_players is not None
assert maximum_ping is not None
if minimum_players < 0:
ba.screenmessage('"Minimum players" should be at least 0',
color=(1, 0, 0))
ba.playsound(ba.getsound('error'))
errored = True
if maximum_ping <= 0:
ba.screenmessage('"Maximum ping" should be greater than 0',
color=(1, 0, 0))
ba.playsound(ba.getsound('error'))
ba.screenmessage('(use 9999 as dont-care value)',
color=(1, 0, 0))
errored = True
if errored:
return
randomjoin.maximum_ping = maximum_ping
randomjoin.minimum_players = minimum_players
randomjoin.commit_config()
ba.playsound(ba.getsound('shieldUp'))
self._transition_out()
def _cancel(self) -> None:
ba.playsound(ba.getsound('shieldDown'))
self._transition_out()
def _transition_out(self) -> None:
ba.containerwidget(edit=self._root_widget, transition='out_scale')
class RandomJoin:
def __init__(self) -> None:
self.cached_parties: list[PartyEntry] = []
self.maximum_ping: int = 9999
self.minimum_players: int = 2
self.load_config()
def load_config(self) -> None:
cfg = ba.app.config.get('Random Join', {
'maximum_ping': self.maximum_ping,
'minimum_players': self.minimum_players,
})
try:
self.maximum_ping = cfg['maximum_ping']
self.minimum_players = cfg['minimum_players']
except KeyError:
ba.screenmessage('Error: RandomJoin config is broken, resetting..',
color=(1, 0, 0), log=True)
ba.playsound(ba.getsound('error'))
self.commit_config()
def commit_config(self) -> None:
ba.app.config['Random Join'] = {
'maximum_ping': self.maximum_ping,
'minimum_players': self.minimum_players,
}
ba.app.config.commit()
randomjoin = RandomJoin()
# ba_meta require api 7
# ba_meta export ba.Plugin
class RandomJoinPlugin(ba.Plugin):
def on_app_running(self) -> None:
# I feel bad that all patching logic happens not here.
pass

View file

@ -1,10 +1,24 @@
"""
Plugin by LoupGarou a.k.a Loup/Soup
Discord ʟօʊքɢǟʀօʊ#3063
Share replays easily with your friends or have a backup
Exported replays are stored in replays folder which is inside mods folder
You can start sharing replays by opening the watch window and going to share replay tab
Feel free to let me know if you use this plugin,i love to hear that :)
Message me in discord if you find some bug
Use this code for your experiments or plugin but please dont rename this plugin and distribute with your name,don't do that,its bad'
"""
# ba_meta require api 7
from __future__ import annotations
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from typing import Any, Sequence, Callable, List, Dict, Tuple, Optional, Union
from os import listdir, mkdir, path, sep
from os import listdir, mkdir, path, sep, remove
from shutil import copy, copytree
import ba
@ -12,11 +26,23 @@ import _ba
from enum import Enum
from bastd.ui.tabs import TabRow
from bastd.ui.confirm import ConfirmWindow
from bastd.ui.watch import WatchWindow as ww
from bastd.ui.watch import WatchWindow
from bastd.ui.popup import PopupWindow
# mod by ʟօʊքɢǟʀօʊ
# export replays to mods folder and share with your friends or have a backup
title = "SHARE REPLAY"
internal_dir = _ba.get_replays_dir()+sep
external_dir = path.join(_ba.env()["python_directory_user"], "replays"+sep)
uiscale = ba.app.ui.uiscale
# colors
pink = (1, 0.2, 0.8)
green = (0.4, 1, 0.4)
red = (1, 0, 0)
blue = (0.26, 0.65, 0.94)
blue_highlight = (0.4, 0.7, 1)
b_color = (0.6, 0.53, 0.63)
b_textcolor = (0.75, 0.7, 0.8)
def Print(*args, color=None, top=None):
@ -35,174 +61,30 @@ def cprint(*args):
_ba.chatmessage(out)
title = "SHARE REPLAY"
internal_dir = _ba.get_replays_dir()+sep
external_dir = path.join(_ba.env()["python_directory_user"], "replays"+sep)
# colors
pink = (1, 0.2, 0.8)
green = (0.4, 1, 0.4)
red = (1, 0, 0)
blue = (0.26, 0.65, 0.94)
blue_highlight = (0.4, 0.7, 1)
if not path.exists(external_dir):
mkdir(external_dir)
Print("You are ready to share replays", color=pink)
class Help(PopupWindow):
def __init__(self):
uiscale = ba.app.ui.uiscale
self.width = 1000
self.height = 300
def override(cls: ClassType) -> Callable[[MethodType], MethodType]:
def decorator(newfunc: MethodType) -> MethodType:
funcname = newfunc.__code__.co_name
if hasattr(cls, funcname):
oldfunc = getattr(cls, funcname)
setattr(cls, f'_old_{funcname}', oldfunc)
PopupWindow.__init__(self,
position=(0.0, 0.0),
size=(self.width, self.height),
scale=1.2,)
setattr(cls, funcname, newfunc)
return newfunc
ba.containerwidget(edit=self.root_widget, on_outside_click_call=self.close)
ba.textwidget(parent=self.root_widget, position=(0, self.height * 0.6),
text=f">Replays are exported to\n {external_dir}\n>Copy replays to the above folder to be able to import them into the game\n>I would live to hear from you,meet me on discord\n -LoupGarou(author)")
def close(self):
ba.playsound(ba.getsound('swish'))
ba.containerwidget(edit=self.root_widget, transition="out_right",)
return decorator
class SettingWindow():
def __init__(self):
self.draw_ui()
ba.containerwidget(edit=self.root, cancel_button=self.close_button)
self.selected_name = None
# setting tab when window opens
self.on_tab_select(self.TabId.INTERNAL)
self.tab_id = self.TabId.INTERNAL
class TabId(Enum):
INTERNAL = "internal"
EXTERNAL = "external"
class CommonUtilities:
def sync_confirmation(self):
ConfirmWindow(text="WARNING:\nreplays with same name in mods folder\n will be overwritten",
action=self.sync, cancel_is_selected=True)
def on_select_text(self, widget, name):
existing_widgets = self.scroll2.get_children()
for i in existing_widgets:
ba.textwidget(edit=i, color=(1, 1, 1))
ba.textwidget(edit=widget, color=(1, 1, 0))
self.selected_name = name
def on_tab_select(self, tab_id):
self.tab_id = tab_id
if tab_id == self.TabId.INTERNAL:
dir_list = listdir(internal_dir)
ba.buttonwidget(edit=self.share_button, label="EXPORT", icon=ba.gettexture("upButton"),)
else:
dir_list = listdir(external_dir)
ba.buttonwidget(edit=self.share_button, label="IMPORT",
icon=ba.gettexture("downButton"),)
self.tab_row.update_appearance(tab_id)
dir_list = sorted(dir_list)
existing_widgets = self.scroll2.get_children()
if existing_widgets:
for i in existing_widgets:
i.delete()
height = 900
# making textwidgets for all replays
for i in dir_list:
height -= 40
a = i
i = ba.textwidget(
parent=self.scroll2,
size=(500, 50),
text=i.split(".")[0],
position=(10, height),
selectable=True,
max_chars=40,
corner_scale=1.3,
click_activate=True,)
ba.textwidget(edit=i, on_activate_call=ba.Call(self.on_select_text, i, a))
def draw_ui(self):
self.uiscale = ba.app.ui.uiscale
self.root = ba.Window(ba.containerwidget(
size=(900, 670), on_outside_click_call=self.close, transition="in_right")).get_root_widget()
self.close_button = ba.buttonwidget(
parent=self.root,
position=(90, 560),
button_type='backSmall',
size=(60, 60),
label=ba.charstr(ba.SpecialChar.BACK),
scale=1.5,
on_activate_call=self.close)
ba.textwidget(
parent=self.root,
size=(200, 100),
position=(350, 550),
scale=2,
selectable=False,
h_align="center",
v_align="center",
text=title,
color=green)
ba.buttonwidget(
parent=self.root,
position=(650, 580),
size=(35, 35),
texture=ba.gettexture("achievementEmpty"),
label="",
on_activate_call=Help)
tabdefs = [(self.TabId.INTERNAL, 'INTERNAL'), (self.TabId.EXTERNAL, "EXTERNAL")]
self.tab_row = TabRow(self.root, tabdefs, pos=(150, 500-5),
size=(500, 300), on_select_call=self.on_tab_select)
self.share_button = ba.buttonwidget(
parent=self.root,
position=(720, 400),
size=(110, 50),
scale=1.5,
button_type="square",
label="EXPORT",
text_scale=2,
icon=ba.gettexture("upButton"),
on_activate_call=self.share)
sync_button = ba.buttonwidget(
parent=self.root,
position=(720, 300),
size=(110, 50),
scale=1.5,
button_type="square",
label="SYNC",
text_scale=2,
icon=ba.gettexture("ouyaYButton"),
on_activate_call=self.sync_confirmation)
scroll = ba.scrollwidget(
parent=self.root,
size=(600, 400),
position=(100, 100),)
self.scroll2 = ba.columnwidget(parent=scroll, size=(
500, 900))
def share(self):
if self.selected_name is None:
Print("Select a replay", color=red)
return
if self.tab_id == self.TabId.INTERNAL:
self.export()
else:
self.importx()
# image={"texture":ba.gettexture("bombColor"),"tint_texture":None,"tint_color":None,"tint2_color":None})
def sync(self):
internal_list = listdir(internal_dir)
external_list = listdir(external_dir)
@ -215,13 +97,233 @@ class SettingWindow():
copy(external_dir+sep+i, internal_dir+sep+i)
Print("Synced all replays", color=pink)
def export(self):
copy(internal_dir+self.selected_name, external_dir+self.selected_name)
Print(self.selected_name[0:-4]+" exported", top=True, color=pink)
def _copy(self, selected_replay, tab_id):
if selected_replay is None:
Print("Select a replay", color=red)
return
elif tab_id == MyTabId.INTERNAL:
copy(internal_dir+selected_replay, external_dir+selected_replay)
Print(selected_replay[0:-4]+" exported", top=True, color=pink)
else:
copy(external_dir+selected_replay, internal_dir+selected_replay)
Print(selected_replay[0:-4]+" imported", top=True, color=green)
def importx(self):
copy(external_dir+self.selected_name, internal_dir+self.selected_name)
Print(self.selected_name[0:-4]+" imported", top=True, color=green)
def delete_replay(self, selected_replay, tab_id, cls_inst):
if selected_replay is None:
Print("Select a replay", color=red)
return
def do_it():
if tab_id == MyTabId.INTERNAL:
remove(internal_dir+selected_replay)
elif tab_id == MyTabId.EXTERNAL:
remove(external_dir+selected_replay)
cls_inst.on_tab_select(tab_id) # updating the tab
Print(selected_replay[0:-4]+" was deleted", top=True, color=red)
ConfirmWindow(text=f"Delete \"{selected_replay.split('.')[0]}\" \nfrom {'internal directory' if tab_id==MyTabId.INTERNAL else 'external directory'}?",
action=do_it, cancel_is_selected=True)
CommonUtils = CommonUtilities()
class MyTabId(Enum):
INTERNAL = "internal"
EXTERNAL = "external"
SHARE_REPLAYS = "share_replay"
class Help(PopupWindow):
def __init__(self):
self.width = 1200
self.height = 250
self.root_widget = ba.Window(ba.containerwidget(
size=(self.width, self.height), on_outside_click_call=self.close, transition="in_right")).get_root_widget()
ba.containerwidget(edit=self.root_widget, on_outside_click_call=self.close)
ba.textwidget(parent=self.root_widget, position=(0, self.height * 0.7), corner_scale=1.2, color=green,
text=f"»Replays are exported to\n {external_dir}\n»Copy replays to the above folder to be able to import them into the game\n»I would love to hear from you,meet me on discord\n -LoupGarou(author)")
def close(self):
ba.playsound(ba.getsound('swish'))
ba.containerwidget(edit=self.root_widget, transition="out_right",)
class ShareTabUi(WatchWindow):
def __init__(self, root_widget=None):
self.tab_id = MyTabId.INTERNAL
self.selected_replay = None
if root_widget is None:
self.root = ba.Window(ba.containerwidget(
size=(1000, 600), on_outside_click_call=self.close, transition="in_right")).get_root_widget()
else:
self.root = root_widget
self.draw_ui()
def on_select_text(self, widget, name):
existing_widgets = self.scroll2.get_children()
for i in existing_widgets:
ba.textwidget(edit=i, color=(1, 1, 1))
ba.textwidget(edit=widget, color=(1.0, 1, 0.4))
self.selected_replay = name
def on_tab_select(self, tab_id):
self.selected_replay = None
self.tab_id = tab_id
t_scale = 1.6
if tab_id == MyTabId.INTERNAL:
dir_list = listdir(internal_dir)
ba.buttonwidget(edit=self.share_button, label="Export\nReplay")
else:
dir_list = listdir(external_dir)
ba.buttonwidget(edit=self.share_button, label="Import\nReplay")
self.tab_row.update_appearance(tab_id)
dir_list = sorted(dir_list)
existing_widgets = self.scroll2.get_children()
if existing_widgets: # deleting textwidgets from old tab
for i in existing_widgets:
i.delete()
height = 900
for i in dir_list: # making textwidgets for all replays
height -= 50
a = i
i = ba.textwidget(
parent=self.scroll2,
size=(self._my_replays_scroll_width/t_scale, 30),
text=i.split(".")[0],
position=(20, height),
selectable=True,
max_chars=40,
corner_scale=t_scale,
click_activate=True,
always_highlight=True,)
ba.textwidget(edit=i, on_activate_call=ba.Call(self.on_select_text, i, a))
def draw_ui(self):
self._r = 'watchWindow'
x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
scroll_buffer_h = 130 + 2 * x_inset
self._width = 1240 if uiscale is ba.UIScale.SMALL else 1040
self._height = (
578
if uiscale is ba.UIScale.SMALL
else 670
if uiscale is ba.UIScale.MEDIUM
else 800)
self._scroll_width = self._width - scroll_buffer_h
self._scroll_height = self._height - 180
#
c_width = self._scroll_width
c_height = self._scroll_height - 20
sub_scroll_height = c_height - 63
self._my_replays_scroll_width = sub_scroll_width = (
680 if uiscale is ba.UIScale.SMALL else 640
)
v = c_height - 30
b_width = 140 if uiscale is ba.UIScale.SMALL else 178
b_height = (
107
if uiscale is ba.UIScale.SMALL
else 142
if uiscale is ba.UIScale.MEDIUM
else 190
)
b_space_extra = (
0
if uiscale is ba.UIScale.SMALL
else -2
if uiscale is ba.UIScale.MEDIUM
else -5
)
b_color = (0.6, 0.53, 0.63)
b_textcolor = (0.75, 0.7, 0.8)
btnv = (c_height - (48
if uiscale is ba.UIScale.SMALL
else 45
if uiscale is ba.UIScale.MEDIUM
else 40) - b_height)
btnh = 40 if uiscale is ba.UIScale.SMALL else 40
smlh = 190 if uiscale is ba.UIScale.SMALL else 225
tscl = 1.0 if uiscale is ba.UIScale.SMALL else 1.2
stab_width = 500
stab_height = 300
stab_h = smlh
v -= sub_scroll_height + 23
scroll = ba.scrollwidget(
parent=self.root,
position=(smlh, v),
size=(sub_scroll_width, sub_scroll_height),
)
self.scroll2 = ba.columnwidget(parent=scroll,
size=(sub_scroll_width, sub_scroll_height))
tabdefs = [(MyTabId.INTERNAL, 'INTERNAL'), (MyTabId.EXTERNAL, "EXTERNAL")]
self.tab_row = TabRow(self.root, tabdefs, pos=(stab_h, sub_scroll_height),
size=(stab_width, stab_height), on_select_call=self.on_tab_select)
helpbtn_space = 20
helpbtn_v = stab_h+stab_width+helpbtn_space+120
helpbtn_h = sub_scroll_height+helpbtn_space
ba.buttonwidget(
parent=self.root,
position=(helpbtn_v, helpbtn_h),
size=(35, 35),
button_type="square",
label="?",
text_scale=1.5,
color=b_color,
textcolor=b_textcolor,
on_activate_call=Help)
def call_copy(): return CommonUtils._copy(self.selected_replay, self.tab_id)
self.share_button = ba.buttonwidget(
parent=self.root,
size=(b_width, b_height),
position=(btnh, btnv),
button_type="square",
label="Export\nReplay",
text_scale=tscl,
color=b_color,
textcolor=b_textcolor,
on_activate_call=call_copy)
btnv -= b_height + b_space_extra
sync_button = ba.buttonwidget(
parent=self.root,
size=(b_width, b_height),
position=(btnh, btnv),
button_type="square",
label="Sync\nReplay",
text_scale=tscl,
color=b_color,
textcolor=b_textcolor,
on_activate_call=CommonUtils.sync_confirmation)
btnv -= b_height + b_space_extra
def call_delete(): return CommonUtils.delete_replay(self.selected_replay, self.tab_id, self)
delete_replay_button = ba.buttonwidget(
parent=self.root,
size=(b_width, b_height),
position=(btnh, btnv),
button_type="square",
label=ba.Lstr(resource=self._r + '.deleteReplayButtonText'),
text_scale=tscl,
color=b_color,
textcolor=b_textcolor,
on_activate_call=call_delete)
self.on_tab_select(MyTabId.INTERNAL)
def close(self):
ba.playsound(ba.getsound('swish'))
@ -232,33 +334,73 @@ class SettingWindow():
#ba.widget(edit=self.enable_button, up_widget=decrease_button, down_widget=self.lower_text,left_widget=save_button, right_widget=save_button)
# --------------------------------------------------------------------------------------------------
# ----------------------------------------------------------------------------------------------------
ww.__old_init__ = ww.__init__
class ShareTab(WatchWindow):
@override(WatchWindow)
def __init__(self,
transition: str | None = 'in_right',
origin_widget: ba.Widget | None = None,
oldmethod=None):
self.my_tab_container = None
self._old___init__(transition, origin_widget)
def new_init(self, transition="in_right", origin_widget=None):
self.__old_init__(transition, origin_widget)
self._share_button = ba.buttonwidget(
parent=self._root_widget,
position=(self._width*0.70, self._height*0.80),
size=(220, 60),
scale=1.0,
color=green,
icon=ba.gettexture('usersButton'),
iconscale=1.5,
label=title,
on_activate_call=SettingWindow)
self._tab_row.tabs[self.TabID.MY_REPLAYS].button.delete() # deleting old tab button
tabdefs = [(self.TabID.MY_REPLAYS,
ba.Lstr(resource=self._r + '.myReplaysText'),),
(MyTabId.SHARE_REPLAYS, "Share Replays"),]
uiscale = ba.app.ui.uiscale
x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
tab_buffer_h = 750 + 2 * x_inset
self._tab_row = TabRow(
self._root_widget,
tabdefs,
pos=((tab_buffer_h / 1.5) * 0.5, self._height - 130),
size=((self._width - tab_buffer_h)*2, 50),
on_select_call=self._set_tab)
self._tab_row.update_appearance(self.TabID.MY_REPLAYS)
@override(WatchWindow)
def _set_tab(self, tab_id, oldfunc=None):
self._old__set_tab(tab_id)
if self.my_tab_container:
self.my_tab_container.delete()
if tab_id == MyTabId.SHARE_REPLAYS:
scroll_left = (self._width - self._scroll_width) * 0.5
scroll_bottom = self._height - self._scroll_height - 79 - 48
c_width = self._scroll_width
c_height = self._scroll_height - 20
sub_scroll_height = c_height - 63
self._my_replays_scroll_width = sub_scroll_width = (
680 if uiscale is ba.UIScale.SMALL else 640
)
self.my_tab_container = ba.containerwidget(
parent=self._root_widget,
position=(scroll_left,
scroll_bottom + (self._scroll_height - c_height) * 0.5,),
size=(c_width, c_height),
background=False,
selection_loops_to_parent=True,
)
ShareTabUi(self.my_tab_container)
# ba_meta export plugin
class Loup(ba.Plugin):
def on_app_running(self):
ww.__init__ = new_init
WatchWindow.__init__ = ShareTab.__init__
def has_settings_ui(self):
return True
def show_settings_ui(self, button):
SettingWindow()
Print("Open share replay tab in replay window to share your replays", color=blue)

View file

@ -0,0 +1,215 @@
# ba_meta require api 7
"""
TNT Respawn Text by TheMikirog
Version 1
Shows when a TNT box is about to respawn with non-intrusive text.
Heavily commented for easy modding learning!
No Rights Reserved
"""
from __future__ import annotations
from typing import TYPE_CHECKING
# Let's import everything we need and nothing more.
import ba
import bastd
import math
import random
from bastd.actor.bomb import Bomb
if TYPE_CHECKING:
pass
"""
Turns out TNT respawning got changed during the 1.5 update.
At first I planned to make an accurate timer text that just counts down seconds to respawn.
However, to prevent timer stacking, Eric decided to update the timer every 1.1s seconds instead of the standard 1.0s.
This makes it a pain to mod, so instead I had to make some compromises and go for a percentage charge instead.
The goal here is to make it easier to intuit when the box respawns, so you can still play around it.
Percentage until respawn is still more helpful than absolutely nothing.
I wanted to keep the original TNT box's respawn design here, so I didn't touch the timer.
I prefer adding onto existing behavior than editing existing code.
This mod is supposed to be a quality of life thing after all.
"""
# ba_meta export plugin
class TNTRespawnText(ba.Plugin):
# This clamping function will make sure a certain value can't go above or below a certain threshold.
# We're gonna need this functionality in just a bit.
def clamp(num, min_value, max_value):
num = max(min(num, max_value), min_value)
return num
# This function gets called every time the TNT dies. Doesn't matter how.
# Explosions, getting thrown out of bounds, stuff.
# I want the text appearing animation to start as soon as the TNT box blows up.
def on_tnt_exploded(self):
self.tnt_has_callback = False
self._respawn_text.color = (1.0, 1.0, 1.0)
ba.animate(
self._respawn_text,
'opacity',
{
0: 0.0,
self._respawn_time * 0.5: 0.175,
self._respawn_time: 0.4
},
)
# We're gonna use the magic of decorators to expand the original code with new stuff.
# This even works with other mods too! Don't replace functions, use decorators!
# Anyway we're gonna access the TNTSpawner class' init function.
def new_init(func):
def wrapper(*args, **kwargs):
# The update function is not only called by a timer, but also manually
# during the original init function's execution.
# This means the code expects a variable that doesn't exist.
# Let's make it prematurely.
# args[0] is "self" in the original game code.
args[0]._respawn_text = None
# This is where the original game's code is executed.
func(*args, **kwargs)
# For each TNT we make we want to add a callback.
# It's basically a flag that tells the TNT to call a function.
# We don't want to add several of the same flag at once.
# We set this to True every time we add a callback.
# We check for this variable before adding a new one.
args[0].tnt_has_callback = True
# Let's make the text.
# We tap into the spawner position in order to decide where the text should be.
respawn_text_position = (args[0]._position[0],
args[0]._position[1] - 0.4,
args[0]._position[2])
args[0]._respawn_text = ba.newnode(
'text',
attrs={
'text': "", # we'll set the text later
'in_world': True,
'position': respawn_text_position,
'shadow': 1.0,
'flatness': 1.0,
'color': (1.0, 1.0, 1.0),
'opacity': 0.0,
'scale': 0.0225,
'h_align': 'center',
'v_align': 'center',
},
)
# Here we add our callback.
# Timers don't like calling functions that are outside of the game's "universe".
# If we call the function directly, we get a PyCallable error.
# We make a dummy function to avoid this.
def tnt_callback():
TNTRespawnText.on_tnt_exploded(args[0])
# One disadvantage of the documentation is that it doesn't tell you all functions related to the node system.
# To learn about all possible atttributes and functions you just gotta explore the code and experiment.
# This add_death_action function of the node system is used in the original game
# to let the player know if the node got removed.
# For bombs that would be explosions or when they go out of bounds.
# This is used to increase the owner's bomb count by one.
# Here however we'll use this function to manipulate our text logic.
# We want to animate our text the moment the TNT box dies.
args[0]._tnt.node.add_death_action(tnt_callback)
return wrapper
# Let's replace the original init function with our modified version.
bastd.actor.bomb.TNTSpawner.__init__ = new_init(bastd.actor.bomb.TNTSpawner.__init__)
# Our modified update function.
# This gets called every 1.1s. Check the TNTSpawner class in the game's code for details.
def new_update(func):
def wrapper(*args, **kwargs):
# Check if our TNT box is still kickin'.
tnt_alive = args[0]._tnt is not None and args[0]._tnt.node
func(*args, **kwargs) # original code
# The first time this code executes, nothing happens.
# However once our text node is created properly, let's do some work.
if args[0]._respawn_text:
# Let's make a value that will represent percentage.
# 0 means timer started and 100 means ready.
value = args[0]._wait_time / args[0]._respawn_time
# It's annoying when the number jumps from 99% to 100% and it's delayed.
# Let's make sure this happens less often.
# I turned a linear curve into an exponential one.
value = math.pow(value - 0.001, 2)
# Let's turn the value into a percentage.
value = math.floor(value * 100)
# Let's make sure it's actually between 0 and 100.
value = TNTRespawnText.clamp(value, 0, 100)
# Let's finish it off with a percentage symbol and preso!
args[0]._respawn_text.text = str(value)+"%"
# When the timer ticks, we do different things depending on the time and the state of our TNT box.
if not tnt_alive:
# Code goes here if we don't have a TNT box and we reached 100%.
if args[0]._tnt is None or args[0]._wait_time >= args[0]._respawn_time and args[0]._respawn_text:
# Animate the text "bounce" to draw attention
ba.animate(
args[0]._respawn_text,
'scale',
{
0: args[0]._respawn_text.scale * 1.2,
0.3: args[0]._respawn_text.scale * 1.05,
0.6: args[0]._respawn_text.scale * 1.025,
1.1: args[0]._respawn_text.scale
},
)
# Fade the text away
ba.animate(
args[0]._respawn_text,
'opacity',
{
0: args[0]._respawn_text.opacity,
1.1: 0.0
},
)
# Make sure it says 100%, because our value we calculated earlier might not be accurate at that point.
args[0]._respawn_text.text = "100%"
# Make our text orange.
args[0]._respawn_text.color = (1.0, 0.75, 0.5)
# Make some sparks to draw the eye.
ba.emitfx(
position=args[0]._position,
count=int(5.0 + random.random() * 10),
scale=0.8,
spread=1.25,
chunk_type='spark',
)
# What if we still have our TNT box?
else:
# If the TNT box is fresly spawned spawned earlier in the function, chances are it doesn't have a callback.
# If it has, ignore. Otherwise let's add it.
# Cloning code that already exists in init is not very clean, but that'll do.
if args[0].tnt_has_callback:
return
def tnt_callback():
TNTRespawnText.on_tnt_exploded(args[0])
args[0]._tnt.node.add_death_action(tnt_callback)
return wrapper
# Let's replace the original update function with our modified version.
bastd.actor.bomb.TNTSpawner._update = new_update(bastd.actor.bomb.TNTSpawner._update)