mirror of
https://github.com/RYDE-WORK/ballistica.git
synced 2026-01-19 21:37:57 +08:00
Pulling initial set of stuff from private
This commit is contained in:
parent
16d87a4a67
commit
827e0f5dc9
25
.editorconfig
Normal file
25
.editorconfig
Normal file
@ -0,0 +1,25 @@
|
||||
# Synced from bsmaster.
|
||||
# EFRO_SYNC_HASH=114884515626536986523570707155141627178
|
||||
#
|
||||
# For configuring supported editors
|
||||
|
||||
# This is the top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Defaults for all files
|
||||
[*]
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
# Python overrides
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
max_line_length = 79
|
||||
charset = utf-8
|
||||
|
||||
# Makefile overrides
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
max_line_length = 80
|
||||
23
.gitattributes
vendored
Normal file
23
.gitattributes
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
*.wav filter=lfs diff=lfs merge=lfs -text
|
||||
*.fdata filter=lfs diff=lfs merge=lfs -text
|
||||
*.obj filter=lfs diff=lfs merge=lfs -text
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.psd filter=lfs diff=lfs merge=lfs -text
|
||||
*.lib filter=lfs diff=lfs merge=lfs -text
|
||||
*.dll filter=lfs diff=lfs merge=lfs -text
|
||||
*.icns filter=lfs diff=lfs merge=lfs -text
|
||||
*.ico filter=lfs diff=lfs merge=lfs -text
|
||||
*.tgz filter=lfs diff=lfs merge=lfs -text
|
||||
*.zip filter=lfs diff=lfs merge=lfs -text
|
||||
*.bmp filter=lfs diff=lfs merge=lfs -text
|
||||
*.xbm filter=lfs diff=lfs merge=lfs -text
|
||||
*.jar filter=lfs diff=lfs merge=lfs -text
|
||||
*.aar filter=lfs diff=lfs merge=lfs -text
|
||||
*.pyd filter=lfs diff=lfs merge=lfs -text
|
||||
*.exe filter=lfs diff=lfs merge=lfs -text
|
||||
*.a filter=lfs diff=lfs merge=lfs -text
|
||||
/tools/make_bob/mac/* filter=lfs diff=lfs merge=lfs -text
|
||||
/tools/mali_texture_compression_tool/mac/* filter=lfs diff=lfs merge=lfs -text
|
||||
/tools/nvidia_texture_tools/mac/* filter=lfs diff=lfs merge=lfs -text
|
||||
/tools/powervr_tools/mac/* filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
4
.projectile
Normal file
4
.projectile
Normal file
@ -0,0 +1,4 @@
|
||||
+/tools
|
||||
+/src/ballistica
|
||||
+/src/generated_src
|
||||
+/assets/src/data/scripts
|
||||
3838
assets/src/data/scripts/_ba.py
Normal file
3838
assets/src/data/scripts/_ba.py
Normal file
File diff suppressed because it is too large
Load Diff
78
assets/src/data/scripts/ba/__init__.py
Normal file
78
assets/src/data/scripts/ba/__init__.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""The public face of Ballistica.
|
||||
|
||||
This top level module is a collection of most commonly used functionality.
|
||||
For many modding purposes, the bits exposed here are all you'll need.
|
||||
In some specific cases you may need to pull in individual submodules instead.
|
||||
"""
|
||||
|
||||
# pylint: disable=unused-import
|
||||
# pylint: disable=redefined-builtin
|
||||
|
||||
from _ba import (CollideModel, Context, ContextCall, Data, InputDevice,
|
||||
Material, Model, Node, Player, Sound, Texture, Timer, Vec3,
|
||||
Widget, buttonwidget, camerashake, checkboxwidget,
|
||||
columnwidget, containerwidget, do_once, emitfx,
|
||||
get_collision_info, getactivity, getcollidemodel, getmodel,
|
||||
getnodes, getsession, getsound, gettexture, hscrollwidget,
|
||||
imagewidget, log, new_activity, newnode, playsound,
|
||||
printnodes, printobjects, pushcall, quit, rowwidget,
|
||||
safecolor, screenmessage, scrollwidget, set_analytics_screen,
|
||||
charstr, textwidget, time, timer, open_url, widget)
|
||||
from ba._activity import Activity
|
||||
from ba._actor import Actor
|
||||
from ba._app import App
|
||||
from ba._coopgame import CoopGameActivity
|
||||
from ba._coopsession import CoopSession
|
||||
from ba._dep import Dep, Dependency, DepComponent, DepSet, AssetPackage
|
||||
from ba._enums import TimeType, Permission, TimeFormat, SpecialChar
|
||||
from ba._error import (UNHANDLED, print_exception, print_error, NotFoundError,
|
||||
PlayerNotFoundError, NodeNotFoundError,
|
||||
ActorNotFoundError, InputDeviceNotFoundError,
|
||||
WidgetNotFoundError, ActivityNotFoundError,
|
||||
TeamNotFoundError, SessionNotFoundError,
|
||||
DependencyError)
|
||||
from ba._freeforallsession import FreeForAllSession
|
||||
from ba._gameactivity import GameActivity
|
||||
from ba._gameresults import TeamGameResults
|
||||
from ba._lang import Lstr, setlanguage, get_valid_languages
|
||||
from ba._maps import Map, getmaps
|
||||
from ba._session import Session
|
||||
from ba._stats import PlayerScoredMessage, PlayerRecord, Stats
|
||||
from ba._team import Team
|
||||
from ba._teamgame import TeamGameActivity
|
||||
from ba._teamssession import TeamsSession
|
||||
from ba._achievement import Achievement
|
||||
from ba._appconfig import AppConfig
|
||||
from ba._appdelegate import AppDelegate
|
||||
from ba._apputils import is_browser_likely_available
|
||||
from ba._campaign import Campaign
|
||||
from ba._gameutils import (animate, animate_array, show_damage_count,
|
||||
sharedobj, timestring, cameraflash)
|
||||
from ba._general import WeakCall, Call
|
||||
from ba._level import Level
|
||||
from ba._lobby import Lobby, Chooser
|
||||
from ba._math import normalized_color, is_point_in_box, vec3validate
|
||||
from ba._messages import (OutOfBoundsMessage, DieMessage, StandMessage,
|
||||
PickUpMessage, DropMessage, PickedUpMessage,
|
||||
DroppedMessage, ShouldShatterMessage,
|
||||
ImpactDamageMessage, FreezeMessage, ThawMessage,
|
||||
HitMessage)
|
||||
from ba._music import setmusic, MusicPlayer
|
||||
from ba._powerup import PowerupMessage, PowerupAcceptMessage
|
||||
from ba._teambasesession import TeamBaseSession
|
||||
from ba.ui import (OldWindow, UILocation, UILocationWindow, UIController,
|
||||
uicleanupcheck)
|
||||
|
||||
app: App
|
||||
|
||||
|
||||
# Change everything's listed module to ba (instead of ba.foo.bar.etc).
|
||||
def _simplify_module_names() -> None:
|
||||
for attr, obj in globals().items():
|
||||
if not attr.startswith('_'):
|
||||
if getattr(obj, '__module__', None) not in [None, 'ba']:
|
||||
obj.__module__ = 'ba'
|
||||
|
||||
|
||||
_simplify_module_names()
|
||||
del _simplify_module_names
|
||||
195
assets/src/data/scripts/ba/_account.py
Normal file
195
assets/src/data/scripts/ba/_account.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""Account related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Optional, Dict, List
|
||||
|
||||
|
||||
def handle_account_gained_tickets(count: int) -> None:
|
||||
"""Called when the current account has been awarded tickets.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
from ba._lang import Lstr
|
||||
_ba.screenmessage(Lstr(resource='getTicketsWindow.receivedTicketsText',
|
||||
subs=[('${COUNT}', str(count))]),
|
||||
color=(0, 1, 0))
|
||||
_ba.playsound(_ba.getsound('cashRegister'))
|
||||
|
||||
|
||||
def cache_league_rank_data(data: Any) -> None:
|
||||
"""(internal)"""
|
||||
_ba.app.league_rank_cache['info'] = copy.deepcopy(data)
|
||||
|
||||
|
||||
def get_cached_league_rank_data() -> Any:
|
||||
"""(internal)"""
|
||||
return _ba.app.league_rank_cache.get('info', None)
|
||||
|
||||
|
||||
def get_league_rank_points(data: Optional[Dict[str, Any]],
|
||||
subset: str = None) -> int:
|
||||
"""(internal)"""
|
||||
if data is None:
|
||||
return 0
|
||||
|
||||
# If the data contains an achievement total, use that. otherwise calc
|
||||
# locally.
|
||||
if data['at'] is not None:
|
||||
total_ach_value = data['at']
|
||||
else:
|
||||
total_ach_value = 0
|
||||
for ach in _ba.app.achievements:
|
||||
if ach.complete:
|
||||
total_ach_value += ach.power_ranking_value
|
||||
|
||||
trophies_total: int = (data['t0a'] * data['t0am'] +
|
||||
data['t0b'] * data['t0bm'] +
|
||||
data['t1'] * data['t1m'] +
|
||||
data['t2'] * data['t2m'] +
|
||||
data['t3'] * data['t3m'] + data['t4'] * data['t4m'])
|
||||
if subset == 'trophyCount':
|
||||
val: int = (data['t0a'] + data['t0b'] + data['t1'] + data['t2'] +
|
||||
data['t3'] + data['t4'])
|
||||
assert isinstance(val, int)
|
||||
return val
|
||||
if subset == 'trophies':
|
||||
assert isinstance(trophies_total, int)
|
||||
return trophies_total
|
||||
if subset is not None:
|
||||
raise Exception("invalid subset value: " + str(subset))
|
||||
|
||||
if data['p']:
|
||||
pro_mult = 1.0 + float(
|
||||
_ba.get_account_misc_read_val('proPowerRankingBoost', 0.0)) * 0.01
|
||||
else:
|
||||
pro_mult = 1.0
|
||||
|
||||
# For final value, apply our pro mult and activeness-mult.
|
||||
return int((total_ach_value + trophies_total) *
|
||||
(data['act'] if data['act'] is not None else 1.0) * pro_mult)
|
||||
|
||||
|
||||
def cache_tournament_info(info: Any) -> None:
|
||||
"""(internal)"""
|
||||
from ba._enums import TimeType, TimeFormat
|
||||
for entry in info:
|
||||
cache_entry = _ba.app.tournament_info[entry['tournamentID']] = (
|
||||
copy.deepcopy(entry))
|
||||
|
||||
# Also store the time we received this, so we can adjust
|
||||
# time-remaining values/etc.
|
||||
cache_entry['timeReceived'] = _ba.time(TimeType.REAL,
|
||||
TimeFormat.MILLISECONDS)
|
||||
cache_entry['valid'] = True
|
||||
|
||||
|
||||
def get_purchased_icons() -> List[str]:
|
||||
"""(internal)"""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _store
|
||||
if _ba.get_account_state() != 'signed_in':
|
||||
return []
|
||||
icons = []
|
||||
store_items = _store.get_store_items()
|
||||
for item_name, item in list(store_items.items()):
|
||||
if item_name.startswith('icons.') and _ba.get_purchased(item_name):
|
||||
icons.append(item['icon'])
|
||||
return icons
|
||||
|
||||
|
||||
def ensure_have_account_player_profile() -> None:
|
||||
"""
|
||||
Ensure the standard account-named player profile exists;
|
||||
creating if needed.
|
||||
"""
|
||||
# This only applies when we're signed in.
|
||||
if _ba.get_account_state() != 'signed_in':
|
||||
return
|
||||
|
||||
# If the short version of our account name currently cant be
|
||||
# displayed by the game, cancel.
|
||||
if not _ba.have_chars(_ba.get_account_display_string(full=False)):
|
||||
return
|
||||
|
||||
config = _ba.app.config
|
||||
if ('Player Profiles' not in config
|
||||
or '__account__' not in config['Player Profiles']):
|
||||
|
||||
# Create a spaz with a nice default purply color.
|
||||
_ba.add_transaction({
|
||||
'type': 'ADD_PLAYER_PROFILE',
|
||||
'name': '__account__',
|
||||
'profile': {
|
||||
'character': 'Spaz',
|
||||
'color': [0.5, 0.25, 1.0],
|
||||
'highlight': [0.5, 0.25, 1.0]
|
||||
}
|
||||
})
|
||||
_ba.run_transactions()
|
||||
|
||||
|
||||
def have_pro() -> bool:
|
||||
"""Return whether pro is currently unlocked."""
|
||||
|
||||
# Check our tickets-based pro upgrade and our two real-IAP based upgrades.
|
||||
return bool(
|
||||
_ba.get_purchased('upgrades.pro') or _ba.get_purchased('static.pro')
|
||||
or _ba.get_purchased('static.pro_sale'))
|
||||
|
||||
|
||||
def have_pro_options() -> bool:
|
||||
"""Return whether pro-options are present.
|
||||
|
||||
This is True for owners of Pro or old installs
|
||||
before Pro was a requirement for these.
|
||||
"""
|
||||
|
||||
# We expose pro options if the server tells us to
|
||||
# (which is generally just when we own pro),
|
||||
# or also if we've been grandfathered in.
|
||||
return bool(
|
||||
_ba.get_account_misc_read_val_2('proOptionsUnlocked', False)
|
||||
or _ba.app.config.get('lc14292', 0) > 1)
|
||||
|
||||
|
||||
def show_post_purchase_message() -> None:
|
||||
"""(internal)"""
|
||||
from ba._lang import Lstr
|
||||
from ba._enums import TimeType
|
||||
app = _ba.app
|
||||
cur_time = _ba.time(TimeType.REAL)
|
||||
if (app.last_post_purchase_message_time is None
|
||||
or cur_time - app.last_post_purchase_message_time > 3.0):
|
||||
app.last_post_purchase_message_time = cur_time
|
||||
with _ba.Context('ui'):
|
||||
_ba.screenmessage(Lstr(resource='updatingAccountText',
|
||||
fallback_resource='purchasingText'),
|
||||
color=(0, 1, 0))
|
||||
_ba.playsound(_ba.getsound('click01'))
|
||||
|
||||
|
||||
def on_account_state_changed() -> None:
|
||||
"""(internal)"""
|
||||
import time
|
||||
from ba import _lang
|
||||
app = _ba.app
|
||||
|
||||
# Run any pending promo codes we had queued up while not signed in.
|
||||
if _ba.get_account_state() == 'signed_in' and app.pending_promo_codes:
|
||||
for code in app.pending_promo_codes:
|
||||
_ba.screenmessage(_lang.Lstr(resource='submittingPromoCodeText'),
|
||||
color=(0, 1, 0))
|
||||
_ba.add_transaction({
|
||||
'type': 'PROMO_CODE',
|
||||
'expire_time': time.time() + 5,
|
||||
'code': code
|
||||
})
|
||||
_ba.run_transactions()
|
||||
app.pending_promo_codes = []
|
||||
1192
assets/src/data/scripts/ba/_achievement.py
Normal file
1192
assets/src/data/scripts/ba/_achievement.py
Normal file
File diff suppressed because it is too large
Load Diff
638
assets/src/data/scripts/ba/_activity.py
Normal file
638
assets/src/data/scripts/ba/_activity.py
Normal file
@ -0,0 +1,638 @@
|
||||
"""Defines Activity class."""
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._dep import InstancedDepComponent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from weakref import ReferenceType
|
||||
from typing import Optional, Type, Any, Dict, List
|
||||
import ba
|
||||
from bastd.actor.respawnicon import RespawnIcon
|
||||
|
||||
|
||||
class Activity(InstancedDepComponent):
|
||||
"""Units of execution wrangled by a ba.Session.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
Examples of Activities include games, score-screens, cutscenes, etc.
|
||||
A ba.Session has one 'current' Activity at any time, though their existence
|
||||
can overlap during transitions.
|
||||
|
||||
Attributes:
|
||||
|
||||
settings
|
||||
The settings dict passed in when the activity was made.
|
||||
|
||||
teams
|
||||
The list of ba.Teams in the Activity. This gets populated just before
|
||||
before on_begin() is called and is updated automatically as players
|
||||
join or leave the game. (at least in free-for-all mode where every
|
||||
player gets their own team; in teams mode there are always 2 teams
|
||||
regardless of the player count).
|
||||
|
||||
players
|
||||
The list of ba.Players in the Activity. This gets populated just
|
||||
before on_begin() is called and is updated automatically as players
|
||||
join or leave the game.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
# Annotating attr types at the class level lets us introspect them.
|
||||
settings: Dict[str, Any]
|
||||
teams: List[ba.Team]
|
||||
players: List[_ba.Player]
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
"""Creates an activity in the current ba.Session.
|
||||
|
||||
The activity will not be actually run until ba.Session.set_activity()
|
||||
is called. 'settings' should be a dict of key/value pairs specific
|
||||
to the activity.
|
||||
|
||||
Activities should preload as much of their media/etc as possible in
|
||||
their constructor, but none of it should actually be used until they
|
||||
are transitioned in.
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
# FIXME: Relocate this stuff.
|
||||
self.sharedobjs: Dict[str, Any] = {}
|
||||
self.paused_text: Optional[ba.Actor] = None
|
||||
self.spaz_respawn_icons_right: Dict[int, RespawnIcon]
|
||||
|
||||
# Create our internal engine data.
|
||||
self._activity_data = _ba.register_activity(self)
|
||||
|
||||
session = _ba.getsession()
|
||||
if session is None:
|
||||
raise Exception("No current session")
|
||||
self._session = weakref.ref(session)
|
||||
|
||||
# Preloaded data for actors, maps, etc; indexed by type.
|
||||
self.preloads: Dict[Type, Any] = {}
|
||||
|
||||
if not isinstance(settings, dict):
|
||||
raise Exception("expected dict for settings")
|
||||
if _ba.getactivity(doraise=False) is not self:
|
||||
raise Exception('invalid context state')
|
||||
|
||||
self.settings = settings
|
||||
|
||||
self._has_transitioned_in = False
|
||||
self._has_begun = False
|
||||
self._has_ended = False
|
||||
self._should_end_immediately = False
|
||||
self._should_end_immediately_results: (
|
||||
Optional[ba.TeamGameResults]) = None
|
||||
self._should_end_immediately_delay = 0.0
|
||||
self._called_activity_on_transition_in = False
|
||||
self._called_activity_on_begin = False
|
||||
|
||||
self._activity_death_check_timer: Optional[ba.Timer] = None
|
||||
self._expired = False
|
||||
|
||||
# Whether to print every time a player dies. This can be pertinent
|
||||
# in games such as Death-Match but can be annoying in games where it
|
||||
# doesn't matter.
|
||||
self.announce_player_deaths = False
|
||||
|
||||
# Joining activities are for waiting for initial player joins.
|
||||
# They are treated slightly differently than regular activities,
|
||||
# mainly in that all players are passed to the activity at once
|
||||
# instead of as each joins.
|
||||
self.is_joining_activity = False
|
||||
|
||||
# Whether game-time should still progress when in menus/etc.
|
||||
self.allow_pausing = False
|
||||
|
||||
# Whether idle players can potentially be kicked (should not happen in
|
||||
# menus/etc).
|
||||
self.allow_kick_idle_players = True
|
||||
|
||||
# In vr mode, this determines whether overlay nodes (text, images, etc)
|
||||
# are created at a fixed position in space or one that moves based on
|
||||
# the current map. Generally this should be on for games and off for
|
||||
# transitions/score-screens/etc. that persist between maps.
|
||||
self.use_fixed_vr_overlay = False
|
||||
|
||||
# If True, runs in slow motion and turns down sound pitch.
|
||||
self.slow_motion = False
|
||||
|
||||
# Set this to True to inherit slow motion setting from previous
|
||||
# activity (useful for transitions to avoid hitches).
|
||||
self.inherits_slow_motion = False
|
||||
|
||||
# Set this to True to keep playing the music from the previous activity
|
||||
# (without even restarting it).
|
||||
self.inherits_music = False
|
||||
|
||||
# Set this to true to inherit VR camera offsets from the previous
|
||||
# activity (useful for preventing sporadic camera movement
|
||||
# during transitions).
|
||||
self.inherits_camera_vr_offset = False
|
||||
|
||||
# Set this to true to inherit (non-fixed) VR overlay positioning from
|
||||
# the previous activity (useful for prevent sporadic overlay jostling
|
||||
# during transitions).
|
||||
self.inherits_vr_overlay_center = False
|
||||
|
||||
# Set this to true to inherit screen tint/vignette colors from the
|
||||
# previous activity (useful to prevent sudden color changes during
|
||||
# transitions).
|
||||
self.inherits_tint = False
|
||||
|
||||
# If the activity fades or transitions in, it should set the length of
|
||||
# time here so that previous activities will be kept alive for that
|
||||
# long (avoiding 'holes' in the screen)
|
||||
# This value is given in real-time seconds.
|
||||
self.transition_time = 0.0
|
||||
|
||||
# Is it ok to show an ad after this activity ends before showing
|
||||
# the next activity?
|
||||
self.can_show_ad_on_death = False
|
||||
|
||||
# This gets set once another activity has begun transitioning in but
|
||||
# before this one is killed. The on_transition_out() method is also
|
||||
# called at this time. Make sure to not assign player inputs,
|
||||
# change music, or anything else with global implications once this
|
||||
# happens.
|
||||
self._transitioning_out = False
|
||||
|
||||
# A handy place to put most actors; this list is pruned of dead
|
||||
# actors regularly and these actors are insta-killed as the activity
|
||||
# is dying.
|
||||
self._actor_refs: List[ba.Actor] = []
|
||||
self._actor_weak_refs: List[ReferenceType[ba.Actor]] = []
|
||||
self._last_dead_object_prune_time = _ba.time()
|
||||
|
||||
# This stuff gets filled in just before on_begin() is called.
|
||||
self.teams = []
|
||||
self.players = []
|
||||
self._stats: Optional[ba.Stats] = None
|
||||
|
||||
self.lobby = None
|
||||
self._prune_dead_objects_timer: Optional[ba.Timer] = None
|
||||
|
||||
@property
|
||||
def stats(self) -> ba.Stats:
|
||||
"""The stats instance accessible while the activity is running.
|
||||
|
||||
If access is attempted before or after, raises a ba.NotFoundError.
|
||||
"""
|
||||
if self._stats is None:
|
||||
from ba._error import NotFoundError
|
||||
raise NotFoundError()
|
||||
return self._stats
|
||||
|
||||
def on_expire(self) -> None:
|
||||
"""Called when your activity is being expired.
|
||||
|
||||
If your activity has created anything explicitly that may be retaining
|
||||
a strong reference to the activity and preventing it from dying, you
|
||||
should clear that out here. From this point on your activity's sole
|
||||
purpose in life is to hit zero references and die so the next activity
|
||||
can begin.
|
||||
"""
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Return whether the activity is expired.
|
||||
|
||||
An activity is set as expired when shutting down.
|
||||
At this point no new nodes, timers, etc should be made,
|
||||
run, etc, and the activity should be considered a 'zombie'.
|
||||
"""
|
||||
return self._expired
|
||||
|
||||
def __del__(self) -> None:
|
||||
|
||||
from ba._apputils import garbage_collect, call_after_ad
|
||||
|
||||
# If the activity has been run then we should have already cleaned
|
||||
# it up, but we still need to run expire calls for un-run activities.
|
||||
if not self._expired:
|
||||
with _ba.Context('empty'):
|
||||
self._expire()
|
||||
|
||||
# Since we're mostly between activities at this point, lets run a cycle
|
||||
# of garbage collection; hopefully it won't cause hitches here.
|
||||
garbage_collect(session_end=False)
|
||||
|
||||
# Now that our object is officially gonna be dead, tell the session it
|
||||
# can fire up the next activity.
|
||||
if self._transitioning_out:
|
||||
session = self._session()
|
||||
if session is not None:
|
||||
with _ba.Context(session):
|
||||
if self.can_show_ad_on_death:
|
||||
call_after_ad(session.begin_next_activity)
|
||||
else:
|
||||
_ba.pushcall(session.begin_next_activity)
|
||||
|
||||
def set_has_ended(self, val: bool) -> None:
|
||||
"""(internal)"""
|
||||
self._has_ended = val
|
||||
|
||||
def set_immediate_end(self, results: ba.TeamGameResults, delay: float,
|
||||
force: bool) -> None:
|
||||
"""Set the activity to die immediately after beginning.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
if self.has_begun():
|
||||
raise Exception('This should only be called for Activities'
|
||||
'that have not yet begun.')
|
||||
if not self._should_end_immediately or force:
|
||||
self._should_end_immediately = True
|
||||
self._should_end_immediately_results = results
|
||||
self._should_end_immediately_delay = delay
|
||||
|
||||
def _get_player_icon(self, player: ba.Player) -> Dict[str, Any]:
|
||||
|
||||
# Do we want to cache these somehow?
|
||||
info = player.get_icon_info()
|
||||
return {
|
||||
'texture': _ba.gettexture(info['texture']),
|
||||
'tint_texture': _ba.gettexture(info['tint_texture']),
|
||||
'tint_color': info['tint_color'],
|
||||
'tint2_color': info['tint2_color']
|
||||
}
|
||||
|
||||
def _destroy(self) -> None:
|
||||
from ba._general import Call
|
||||
from ba._enums import TimeType
|
||||
|
||||
# Create a real-timer that watches a weak-ref of this activity
|
||||
# and reports any lingering references keeping it alive.
|
||||
# We store the timer on the activity so as soon as the activity dies
|
||||
# it gets cleaned up.
|
||||
with _ba.Context('ui'):
|
||||
ref = weakref.ref(self)
|
||||
self._activity_death_check_timer = _ba.Timer(
|
||||
5.0,
|
||||
Call(self._check_activity_death, ref, [0]),
|
||||
repeat=True,
|
||||
timetype=TimeType.REAL)
|
||||
|
||||
# Run _expire in an empty context; nothing should be happening in
|
||||
# there except deleting things which requires no context.
|
||||
# (plus, _expire() runs in the destructor for un-run activities
|
||||
# and we can't properly provide context in that situation anyway; might
|
||||
# as well be consistent).
|
||||
if not self._expired:
|
||||
with _ba.Context('empty'):
|
||||
self._expire()
|
||||
else:
|
||||
raise Exception("_destroy() called multiple times")
|
||||
|
||||
@classmethod
|
||||
def _check_activity_death(cls, activity_ref: ReferenceType[Activity],
|
||||
counter: List[int]) -> None:
|
||||
"""Sanity check to make sure an Activity was destroyed properly.
|
||||
|
||||
Receives a weakref to a ba.Activity which should have torn itself
|
||||
down due to no longer being referenced anywhere. Will complain
|
||||
and/or print debugging info if the Activity still exists.
|
||||
"""
|
||||
try:
|
||||
import gc
|
||||
import types
|
||||
activity = activity_ref()
|
||||
print('ERROR: Activity is not dying when expected:', activity,
|
||||
'(warning ' + str(counter[0] + 1) + ')')
|
||||
print('This means something is still strong-referencing it.')
|
||||
counter[0] += 1
|
||||
|
||||
# FIXME: Running the code below shows us references but winds up
|
||||
# keeping the object alive; need to figure out why.
|
||||
# For now we just print refs if the count gets to 3, and then we
|
||||
# kill the app at 4 so it doesn't matter anyway.
|
||||
if counter[0] == 3:
|
||||
print('Activity references for', activity, ':')
|
||||
refs = list(gc.get_referrers(activity))
|
||||
i = 1
|
||||
for ref in refs:
|
||||
if isinstance(ref, types.FrameType):
|
||||
continue
|
||||
print(' reference', i, ':', ref)
|
||||
i += 1
|
||||
if counter[0] == 4:
|
||||
print('Killing app due to stuck activity... :-(')
|
||||
_ba.quit()
|
||||
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('exception on _check_activity_death:')
|
||||
|
||||
def _expire(self) -> None:
|
||||
from ba import _error
|
||||
self._expired = True
|
||||
|
||||
# Do some default cleanup.
|
||||
try:
|
||||
try:
|
||||
self.on_expire()
|
||||
except Exception:
|
||||
_error.print_exception('Error in activity on_expire()', self)
|
||||
|
||||
# Send finalize notices to all remaining actors.
|
||||
for actor_ref in self._actor_weak_refs:
|
||||
try:
|
||||
actor = actor_ref()
|
||||
if actor is not None:
|
||||
actor.on_expire()
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'Exception on ba.Activity._expire()'
|
||||
' in actor on_expire():', actor_ref())
|
||||
|
||||
# Reset all players.
|
||||
# (releases any attached actors, clears game-data, etc)
|
||||
for player in self.players:
|
||||
if player:
|
||||
try:
|
||||
player.reset()
|
||||
player.set_activity(None)
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'Exception on ba.Activity._expire()'
|
||||
' resetting player:', player)
|
||||
|
||||
# Ditto with teams.
|
||||
for team in self.teams:
|
||||
try:
|
||||
team.reset()
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'Exception on ba.Activity._expire() resetting team:',
|
||||
team)
|
||||
|
||||
except Exception:
|
||||
_error.print_exception('Exception during ba.Activity._expire():')
|
||||
|
||||
# Regardless of what happened here, we want to destroy our data, as
|
||||
# our activity might not go down if we don't. This will kill all
|
||||
# Timers, Nodes, etc, which should clear up any remaining refs to our
|
||||
# Actors and Activity and allow us to die peacefully.
|
||||
try:
|
||||
self._activity_data.destroy()
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'Exception during ba.Activity._expire() destroying data:')
|
||||
|
||||
def _prune_dead_objects(self) -> None:
|
||||
self._actor_refs = [a for a in self._actor_refs if a]
|
||||
self._actor_weak_refs = [a for a in self._actor_weak_refs if a()]
|
||||
self._last_dead_object_prune_time = _ba.time()
|
||||
|
||||
def retain_actor(self, actor: ba.Actor) -> None:
|
||||
"""Add a strong-reference to a ba.Actor to this Activity.
|
||||
|
||||
The reference will be lazily released once ba.Actor.exists()
|
||||
returns False for the Actor. The ba.Actor.autoretain() method
|
||||
is a convenient way to access this same functionality.
|
||||
"""
|
||||
from ba import _actor as bsactor
|
||||
from ba import _error
|
||||
if not isinstance(actor, bsactor.Actor):
|
||||
raise Exception("non-actor passed to _retain_actor")
|
||||
if (self.has_transitioned_in()
|
||||
and _ba.time() - self._last_dead_object_prune_time > 10.0):
|
||||
_error.print_error('it looks like nodes/actors are not'
|
||||
' being pruned in your activity;'
|
||||
' did you call Activity.on_transition_in()'
|
||||
' from your subclass?; ' + str(self) +
|
||||
' (loc. a)')
|
||||
self._actor_refs.append(actor)
|
||||
|
||||
def add_actor_weak_ref(self, actor: ba.Actor) -> None:
|
||||
"""Add a weak-reference to a ba.Actor to the ba.Activity.
|
||||
|
||||
(called by the ba.Actor base class)
|
||||
"""
|
||||
from ba import _actor as bsactor
|
||||
from ba import _error
|
||||
if not isinstance(actor, bsactor.Actor):
|
||||
raise Exception("non-actor passed to _add_actor_weak_ref")
|
||||
if (self.has_transitioned_in()
|
||||
and _ba.time() - self._last_dead_object_prune_time > 10.0):
|
||||
_error.print_error('it looks like nodes/actors are '
|
||||
'not being pruned in your activity;'
|
||||
' did you call Activity.on_transition_in()'
|
||||
' from your subclass?; ' + str(self) +
|
||||
' (loc. b)')
|
||||
self._actor_weak_refs.append(weakref.ref(actor))
|
||||
|
||||
@property
|
||||
def session(self) -> ba.Session:
|
||||
"""The ba.Session this ba.Activity belongs go.
|
||||
|
||||
Raises a ba.SessionNotFoundError if the Session no longer exists.
|
||||
"""
|
||||
session = self._session()
|
||||
if session is None:
|
||||
from ba._error import SessionNotFoundError
|
||||
raise SessionNotFoundError()
|
||||
return session
|
||||
|
||||
def on_player_join(self, player: ba.Player) -> None:
|
||||
"""Called when a new ba.Player has joined the Activity.
|
||||
|
||||
(including the initial set of Players)
|
||||
"""
|
||||
|
||||
def on_player_leave(self, player: ba.Player) -> None:
|
||||
"""Called when a ba.Player is leaving the Activity."""
|
||||
|
||||
def on_team_join(self, team: ba.Team) -> None:
|
||||
"""Called when a new ba.Team joins the Activity.
|
||||
|
||||
(including the initial set of Teams)
|
||||
"""
|
||||
|
||||
def on_team_leave(self, team: ba.Team) -> None:
|
||||
"""Called when a ba.Team leaves the Activity."""
|
||||
|
||||
def on_transition_in(self) -> None:
|
||||
"""Called when the Activity is first becoming visible.
|
||||
|
||||
Upon this call, the Activity should fade in backgrounds,
|
||||
start playing music, etc. It does not yet have access to ba.Players
|
||||
or ba.Teams, however. They remain owned by the previous Activity
|
||||
up until ba.Activity.on_begin() is called.
|
||||
"""
|
||||
from ba._general import WeakCall
|
||||
|
||||
self._called_activity_on_transition_in = True
|
||||
|
||||
# Start pruning our transient actors periodically.
|
||||
self._prune_dead_objects_timer = _ba.Timer(
|
||||
5.17, WeakCall(self._prune_dead_objects), repeat=True)
|
||||
self._prune_dead_objects()
|
||||
|
||||
# Also start our low-level scene-graph running.
|
||||
self._activity_data.start()
|
||||
|
||||
def on_transition_out(self) -> None:
|
||||
"""Called when your activity begins transitioning out.
|
||||
|
||||
Note that this may happen at any time even if finish() has not been
|
||||
called.
|
||||
"""
|
||||
|
||||
def on_begin(self) -> None:
|
||||
"""Called once the previous ba.Activity has finished transitioning out.
|
||||
|
||||
At this point the activity's initial players and teams are filled in
|
||||
and it should begin its actual game logic.
|
||||
"""
|
||||
self._called_activity_on_begin = True
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
"""General message handling; can be passed any message object."""
|
||||
|
||||
def end(self, results: Any = None, delay: float = 0.0,
|
||||
force: bool = False) -> None:
|
||||
"""Commences Activity shutdown and delivers results to the ba.Session.
|
||||
|
||||
'delay' is the time delay before the Activity actually ends
|
||||
(in seconds). Further calls to end() will be ignored up until
|
||||
this time, unless 'force' is True, in which case the new results
|
||||
will replace the old.
|
||||
"""
|
||||
|
||||
# Ask the session to end us.
|
||||
self.session.end_activity(self, results, delay, force)
|
||||
|
||||
def has_transitioned_in(self) -> bool:
|
||||
"""Return whether on_transition_in() has been called."""
|
||||
return self._has_transitioned_in
|
||||
|
||||
def has_begun(self) -> bool:
|
||||
"""Return whether on_begin() has been called."""
|
||||
return self._has_begun
|
||||
|
||||
def has_ended(self) -> bool:
|
||||
"""Return whether the activity has commenced ending."""
|
||||
return self._has_ended
|
||||
|
||||
def is_transitioning_out(self) -> bool:
|
||||
"""Return whether on_transition_out() has been called."""
|
||||
return self._transitioning_out
|
||||
|
||||
def start_transition_in(self) -> None:
|
||||
"""Called by Session to kick of transition-in.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
assert not self._has_transitioned_in
|
||||
self._has_transitioned_in = True
|
||||
self.on_transition_in()
|
||||
|
||||
def create_player_node(self, player: ba.Player) -> ba.Node:
|
||||
"""Create the 'player' node associated with the provided ba.Player."""
|
||||
from ba import _actor
|
||||
with _ba.Context(self):
|
||||
node = _ba.newnode('player', attrs={'playerID': player.get_id()})
|
||||
# FIXME: Should add a dedicated slot for this on ba.Player
|
||||
# instead of cluttering up their gamedata dict.
|
||||
player.gamedata['_playernode'] = _actor.Actor(node)
|
||||
return node
|
||||
|
||||
def begin(self, session: ba.Session) -> None:
|
||||
"""Begin the activity. (should only be called by Session).
|
||||
|
||||
(internal)"""
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
from ba import _error
|
||||
|
||||
if self._has_begun:
|
||||
_error.print_error("_begin called twice; this shouldn't happen")
|
||||
return
|
||||
|
||||
self._stats = session.stats
|
||||
|
||||
# Operate on the subset of session players who have passed team/char
|
||||
# selection.
|
||||
players = []
|
||||
chooser_players = []
|
||||
for player in session.players:
|
||||
assert player # should we ever have invalid players?..
|
||||
if player:
|
||||
try:
|
||||
team: Optional[ba.Team] = player.team
|
||||
except _error.TeamNotFoundError:
|
||||
team = None
|
||||
|
||||
if team is not None:
|
||||
player.reset_input()
|
||||
players.append(player)
|
||||
else:
|
||||
# Simply ignore players sitting in the lobby.
|
||||
# (though this technically shouldn't happen anymore since
|
||||
# choosers now get cleared when starting new activities.)
|
||||
print('unexpected: got no-team player in _begin')
|
||||
chooser_players.append(player)
|
||||
else:
|
||||
_error.print_error(
|
||||
"got nonexistent player in Activity._begin()")
|
||||
|
||||
# Add teams in one by one and send team-joined messages for each.
|
||||
for team in session.teams:
|
||||
if team in self.teams:
|
||||
raise Exception("Duplicate Team Entry")
|
||||
self.teams.append(team)
|
||||
try:
|
||||
with _ba.Context(self):
|
||||
self.on_team_join(team)
|
||||
except Exception:
|
||||
_error.print_exception('Error in on_team_join for', self)
|
||||
|
||||
# Now add each player to the activity and to its team's list,
|
||||
# and send player-joined messages for each.
|
||||
for player in players:
|
||||
self.players.append(player)
|
||||
player.team.players.append(player)
|
||||
player.set_activity(self)
|
||||
pnode = self.create_player_node(player)
|
||||
player.set_node(pnode)
|
||||
try:
|
||||
with _ba.Context(self):
|
||||
self.on_player_join(player)
|
||||
except Exception:
|
||||
_error.print_exception('Error in on_player_join for', self)
|
||||
|
||||
with _ba.Context(self):
|
||||
# And finally tell the game to start.
|
||||
self._has_begun = True
|
||||
self.on_begin()
|
||||
|
||||
# Make sure that ba.Activity.on_transition_in() got called
|
||||
# at some point.
|
||||
if not self._called_activity_on_transition_in:
|
||||
_error.print_error(
|
||||
"ba.Activity.on_transition_in() never got called for " +
|
||||
str(self) + "; did you forget to call it"
|
||||
" in your on_transition_in override?")
|
||||
|
||||
# Make sure that ba.Activity.on_begin() got called at some point.
|
||||
if not self._called_activity_on_begin:
|
||||
_error.print_error(
|
||||
"ba.Activity.on_begin() never got called for " + str(self) +
|
||||
"; did you forget to call it in your on_begin override?")
|
||||
|
||||
# If the whole session wants to die and was waiting on us, can get
|
||||
# that going now.
|
||||
if session.wants_to_end:
|
||||
session.launch_end_session_activity()
|
||||
else:
|
||||
# Otherwise, if we've already been told to die, do so now.
|
||||
if self._should_end_immediately:
|
||||
self.end(self._should_end_immediately_results,
|
||||
self._should_end_immediately_delay)
|
||||
252
assets/src/data/scripts/ba/_activitytypes.py
Normal file
252
assets/src/data/scripts/ba/_activitytypes.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""Some handy base class and special purpose Activity types."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba import _activity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict, Optional
|
||||
import ba
|
||||
from ba._lobby import JoinInfo
|
||||
|
||||
|
||||
class EndSessionActivity(_activity.Activity):
|
||||
"""Special ba.Activity to fade out and end the current ba.Session."""
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings)
|
||||
|
||||
# Keeps prev activity alive while we fadeout.
|
||||
self.transition_time = 0.25
|
||||
self.inherits_tint = True
|
||||
self.inherits_slow_motion = True
|
||||
self.inherits_camera_vr_offset = True
|
||||
self.inherits_vr_overlay_center = True
|
||||
|
||||
def on_transition_in(self) -> None:
|
||||
super().on_transition_in()
|
||||
_ba.fade_screen(False)
|
||||
_ba.lock_all_input()
|
||||
|
||||
def on_begin(self) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.mainmenu import MainMenuSession
|
||||
from ba._apputils import call_after_ad
|
||||
from ba._general import Call
|
||||
super().on_begin()
|
||||
_ba.unlock_all_input()
|
||||
call_after_ad(Call(_ba.new_host_session, MainMenuSession))
|
||||
|
||||
|
||||
class JoiningActivity(_activity.Activity):
|
||||
"""Standard activity for waiting for players to join.
|
||||
|
||||
It shows tips and other info and waits for all players to check ready.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings)
|
||||
|
||||
# This activity is a special 'joiner' activity.
|
||||
# It will get shut down as soon as all players have checked ready.
|
||||
self.is_joining_activity = True
|
||||
|
||||
# Players may be idle waiting for joiners; lets not kick them for it.
|
||||
self.allow_kick_idle_players = False
|
||||
|
||||
# In vr mode we don't want stuff moving around.
|
||||
self.use_fixed_vr_overlay = True
|
||||
|
||||
self._background: Optional[ba.Actor] = None
|
||||
self._tips_text: Optional[ba.Actor] = None
|
||||
self._join_info: Optional[JoinInfo] = None
|
||||
|
||||
def on_transition_in(self) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.actor.tipstext import TipsText
|
||||
from bastd.actor.background import Background
|
||||
from ba import _music
|
||||
super().on_transition_in()
|
||||
self._background = Background(fade_time=0.5,
|
||||
start_faded=True,
|
||||
show_logo=True)
|
||||
self._tips_text = TipsText()
|
||||
_music.setmusic('CharSelect')
|
||||
self._join_info = self.session.lobby.create_join_info()
|
||||
_ba.set_analytics_screen('Joining Screen')
|
||||
|
||||
|
||||
class TransitionActivity(_activity.Activity):
|
||||
"""A simple overlay fade out/in.
|
||||
|
||||
Useful as a bare minimum transition between two level based activities.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings)
|
||||
|
||||
# Keep prev activity alive while we fade in.
|
||||
self.transition_time = 0.5
|
||||
self.inherits_slow_motion = True # Don't change.
|
||||
self.inherits_tint = True # Don't change.
|
||||
self.inherits_camera_vr_offset = True # Don't change.
|
||||
self.inherits_vr_overlay_center = True
|
||||
self.use_fixed_vr_overlay = True
|
||||
self._background: Optional[ba.Actor] = None
|
||||
|
||||
def on_transition_in(self) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.actor import background # FIXME: Don't use bastd from ba.
|
||||
super().on_transition_in()
|
||||
self._background = background.Background(fade_time=0.5,
|
||||
start_faded=False,
|
||||
show_logo=False)
|
||||
|
||||
def on_begin(self) -> None:
|
||||
super().on_begin()
|
||||
|
||||
# Die almost immediately.
|
||||
_ba.timer(0.1, self.end)
|
||||
|
||||
|
||||
class ScoreScreenActivity(_activity.Activity):
|
||||
"""A standard score screen that fades in and shows stuff for a while.
|
||||
|
||||
After a specified delay, player input is assigned to end the activity.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings)
|
||||
self.transition_time = 0.5
|
||||
self._birth_time = _ba.time()
|
||||
self._min_view_time = 5.0
|
||||
self.inherits_tint = True
|
||||
self.inherits_camera_vr_offset = True
|
||||
self.use_fixed_vr_overlay = True
|
||||
self._allow_server_restart = False
|
||||
self._background: Optional[ba.Actor] = None
|
||||
self._tips_text: Optional[ba.Actor] = None
|
||||
self._kicked_off_server_shutdown = False
|
||||
self._kicked_off_server_restart = False
|
||||
|
||||
def on_player_join(self, player: ba.Player) -> None:
|
||||
from ba import _general
|
||||
super().on_player_join(player)
|
||||
time_till_assign = max(
|
||||
0, self._birth_time + self._min_view_time - _ba.time())
|
||||
|
||||
# If we're still kicking at the end of our assign-delay, assign this
|
||||
# guy's input to trigger us.
|
||||
_ba.timer(time_till_assign, _general.WeakCall(self._safe_assign,
|
||||
player))
|
||||
|
||||
def on_transition_in(self,
|
||||
music: Optional[str] = 'Scores',
|
||||
show_tips: bool = True) -> None:
|
||||
# FIXME: Unify args.
|
||||
# pylint: disable=arguments-differ
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.actor import tipstext
|
||||
from bastd.actor import background
|
||||
from ba import _music as bs_music
|
||||
super().on_transition_in()
|
||||
self._background = background.Background(fade_time=0.5,
|
||||
start_faded=False,
|
||||
show_logo=True)
|
||||
if show_tips:
|
||||
self._tips_text = tipstext.TipsText()
|
||||
bs_music.setmusic(music)
|
||||
|
||||
def on_begin(self, custom_continue_message: ba.Lstr = None) -> None:
|
||||
# FIXME: Unify args.
|
||||
# pylint: disable=arguments-differ
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.actor import text
|
||||
from ba import _lang
|
||||
super().on_begin()
|
||||
|
||||
# Pop up a 'press any button to continue' statement after our
|
||||
# min-view-time show a 'press any button to continue..'
|
||||
# thing after a bit.
|
||||
if _ba.app.interface_type == 'large':
|
||||
# FIXME: Need a better way to determine whether we've probably
|
||||
# got a keyboard.
|
||||
sval = _lang.Lstr(resource='pressAnyKeyButtonText')
|
||||
else:
|
||||
sval = _lang.Lstr(resource='pressAnyButtonText')
|
||||
|
||||
text.Text(custom_continue_message if custom_continue_message else sval,
|
||||
v_attach='bottom',
|
||||
h_align='center',
|
||||
flash=True,
|
||||
vr_depth=50,
|
||||
position=(0, 10),
|
||||
scale=0.8,
|
||||
color=(0.5, 0.7, 0.5, 0.5),
|
||||
transition='in_bottom_slow',
|
||||
transition_delay=self._min_view_time).autoretain()
|
||||
|
||||
def _player_press(self) -> None:
|
||||
|
||||
# If we're running in server-mode and it wants to shut down
|
||||
# or restart, this is a good place to do it
|
||||
if self._handle_server_restarts():
|
||||
return
|
||||
self.end()
|
||||
|
||||
def _safe_assign(self, player: ba.Player) -> None:
|
||||
|
||||
# Just to be extra careful, don't assign if we're transitioning out.
|
||||
# (though theoretically that would be ok).
|
||||
if not self.is_transitioning_out() and player:
|
||||
player.assign_input_call(
|
||||
('jumpPress', 'punchPress', 'bombPress', 'pickUpPress'),
|
||||
self._player_press)
|
||||
|
||||
def _handle_server_restarts(self) -> bool:
|
||||
"""Handle automatic restarts/shutdowns in server mode.
|
||||
|
||||
Returns True if an action was taken; otherwise default action
|
||||
should occur (starting next round, etc).
|
||||
"""
|
||||
# pylint: disable=cyclic-import
|
||||
|
||||
# FIXME: Move server stuff to its own module.
|
||||
if self._allow_server_restart and _ba.app.server_config_dirty:
|
||||
from ba import _server
|
||||
from ba._lang import Lstr
|
||||
from ba._general import Call
|
||||
from ba._enums import TimeType
|
||||
if _ba.app.server_config.get('quit', False):
|
||||
if not self._kicked_off_server_shutdown:
|
||||
if _ba.app.server_config.get(
|
||||
'quit_reason') == 'restarting':
|
||||
# FIXME: Should add a server-screen-message call
|
||||
# or something.
|
||||
_ba.chat_message(
|
||||
Lstr(resource='internal.serverRestartingText').
|
||||
evaluate())
|
||||
print(('Exiting for server-restart at ' +
|
||||
time.strftime('%c')))
|
||||
else:
|
||||
print(('Exiting for server-shutdown at ' +
|
||||
time.strftime('%c')))
|
||||
with _ba.Context('ui'):
|
||||
_ba.timer(2.0, _ba.quit, timetype=TimeType.REAL)
|
||||
self._kicked_off_server_shutdown = True
|
||||
return True
|
||||
else:
|
||||
if not self._kicked_off_server_restart:
|
||||
print(('Running updated server config at ' +
|
||||
time.strftime('%c')))
|
||||
with _ba.Context('ui'):
|
||||
_ba.timer(1.0,
|
||||
Call(_ba.pushcall,
|
||||
_server.launch_server_session),
|
||||
timetype=TimeType.REAL)
|
||||
self._kicked_off_server_restart = True
|
||||
return True
|
||||
return False
|
||||
211
assets/src/data/scripts/ba/_actor.py
Normal file
211
assets/src/data/scripts/ba/_actor.py
Normal file
@ -0,0 +1,211 @@
|
||||
"""Defines base Actor class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Optional
|
||||
import ba
|
||||
|
||||
T = TypeVar('T', bound='Actor')
|
||||
|
||||
|
||||
class Actor:
|
||||
"""High level logical entities in a game/activity.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
Actors act as controllers, combining some number of ba.Nodes,
|
||||
ba.Textures, ba.Sounds, etc. into one cohesive unit.
|
||||
|
||||
Some example actors include ba.Bomb, ba.Flag, and ba.Spaz.
|
||||
|
||||
One key feature of Actors is that they generally 'die'
|
||||
(killing off or transitioning out their nodes) when the last Python
|
||||
reference to them disappears, so you can use logic such as:
|
||||
|
||||
# create a flag Actor in our game activity
|
||||
self.flag = ba.Flag(position=(0, 10, 0))
|
||||
|
||||
# later, destroy the flag..
|
||||
# (provided nothing else is holding a reference to it)
|
||||
# we could also just assign a new flag to this value.
|
||||
# either way, the old flag disappears.
|
||||
self.flag = None
|
||||
|
||||
This is in contrast to the behavior of the more low level ba.Nodes,
|
||||
which are always explicitly created and destroyed and don't care
|
||||
how many Python references to them exist.
|
||||
|
||||
Note, however, that you can use the ba.Actor.autoretain() method
|
||||
if you want an Actor to stick around until explicitly killed
|
||||
regardless of references.
|
||||
|
||||
Another key feature of ba.Actor is its handlemessage() method, which
|
||||
takes a single arbitrary object as an argument. This provides a safe way
|
||||
to communicate between ba.Actor, ba.Activity, ba.Session, and any other
|
||||
class providing a handlemessage() method. The most universally handled
|
||||
message type for actors is the ba.DieMessage.
|
||||
|
||||
# another way to kill the flag from the example above:
|
||||
# we can safely call this on any type with a 'handlemessage' method
|
||||
# (though its not guaranteed to always have a meaningful effect)
|
||||
# in this case the Actor instance will still be around, but its exists()
|
||||
# and is_alive() methods will both return False
|
||||
self.flag.handlemessage(ba.DieMessage())
|
||||
"""
|
||||
|
||||
def __init__(self, node: ba.Node = None):
|
||||
"""Instantiates an Actor in the current ba.Activity.
|
||||
|
||||
If 'node' is provided, it is stored as the 'node' attribute
|
||||
and the default ba.Actor.handlemessage() and ba.Actor.exists()
|
||||
implementations will apply to it. This allows the creation of
|
||||
simple node-wrapping Actors without having to create a new subclass.
|
||||
"""
|
||||
self.node: Optional[ba.Node] = None
|
||||
activity = _ba.getactivity()
|
||||
self._activity = weakref.ref(activity)
|
||||
activity.add_actor_weak_ref(self)
|
||||
if node is not None:
|
||||
self.node = node
|
||||
|
||||
def __del__(self) -> None:
|
||||
try:
|
||||
# Non-expired Actors send themselves a DieMessage when going down.
|
||||
# That way we can treat DieMessage handling as the single
|
||||
# point-of-action for death.
|
||||
if not self.is_expired():
|
||||
from ba import _messages
|
||||
self.handlemessage(_messages.DieMessage())
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('exception in ba.Actor.__del__() for', self)
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
"""General message handling; can be passed any message object.
|
||||
|
||||
The default implementation will handle ba.DieMessages by
|
||||
calling self.node.delete() if self contains a 'node' attribute.
|
||||
"""
|
||||
from ba import _messages
|
||||
from ba import _error
|
||||
if isinstance(msg, _messages.DieMessage):
|
||||
node = getattr(self, 'node', None)
|
||||
if node is not None:
|
||||
node.delete()
|
||||
return None
|
||||
return _error.UNHANDLED
|
||||
|
||||
def _handlemessage_sanity_check(self) -> None:
|
||||
if self.is_expired():
|
||||
from ba import _error
|
||||
_error.print_error(
|
||||
f'handlemessage called on expired actor: {self}')
|
||||
|
||||
def autoretain(self: T) -> T:
|
||||
"""Keep this Actor alive without needing to hold a reference to it.
|
||||
|
||||
This keeps the ba.Actor in existence by storing a reference to it
|
||||
with the ba.Activity it was created in. The reference is lazily
|
||||
released once ba.Actor.exists() returns False for it or when the
|
||||
Activity is set as expired. This can be a convenient alternative
|
||||
to storing references explicitly just to keep a ba.Actor from dying.
|
||||
For convenience, this method returns the ba.Actor it is called with,
|
||||
enabling chained statements such as: myflag = ba.Flag().autoretain()
|
||||
"""
|
||||
activity = self._activity()
|
||||
if activity is None:
|
||||
from ba._error import ActivityNotFoundError
|
||||
raise ActivityNotFoundError()
|
||||
activity.retain_actor(self)
|
||||
return self
|
||||
|
||||
def on_expire(self) -> None:
|
||||
"""Called for remaining ba.Actors when their ba.Activity shuts down.
|
||||
|
||||
Actors can use this opportunity to clear callbacks
|
||||
or other references which have the potential of keeping the
|
||||
ba.Activity alive inadvertently (Activities can not exit cleanly while
|
||||
any Python references to them remain.)
|
||||
|
||||
Once an actor is expired (see ba.Actor.is_expired()) it should no
|
||||
longer perform any game-affecting operations (creating, modifying,
|
||||
or deleting nodes, media, timers, etc.) Attempts to do so will
|
||||
likely result in errors.
|
||||
"""
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Returns whether the Actor is expired.
|
||||
|
||||
(see ba.Actor.on_expire())
|
||||
"""
|
||||
activity = self.getactivity(doraise=False)
|
||||
return True if activity is None else activity.is_expired()
|
||||
|
||||
def exists(self) -> bool:
|
||||
"""Returns whether the Actor is still present in a meaningful way.
|
||||
|
||||
Note that a dying character should still return True here as long as
|
||||
their corpse is visible; this is about presence, not being 'alive'
|
||||
(see ba.Actor.is_alive() for that).
|
||||
|
||||
If this returns False, it is assumed the Actor can be completely
|
||||
deleted without affecting the game; this call is often used
|
||||
when pruning lists of Actors, such as with ba.Actor.autoretain()
|
||||
|
||||
The default implementation of this method returns 'node.exists()'
|
||||
if the Actor has a 'node' attr; otherwise True.
|
||||
|
||||
Note that the boolean operator for the Actor class calls this method,
|
||||
so a simple "if myactor" test will conveniently do the right thing
|
||||
even if myactor is set to None.
|
||||
"""
|
||||
|
||||
# As a default, if we have a 'node' attr, return whether it exists.
|
||||
node: ba.Node = getattr(self, 'node', None)
|
||||
if node is not None:
|
||||
return node.exists()
|
||||
return True
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
# Cleaner way to test existence; friendlier to None values.
|
||||
return self.exists()
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Returns whether the Actor is 'alive'.
|
||||
|
||||
What this means is up to the Actor.
|
||||
It is not a requirement for Actors to be
|
||||
able to die; just that they report whether
|
||||
they are Alive or not.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def activity(self) -> ba.Activity:
|
||||
"""The Activity this Actor was created in.
|
||||
|
||||
Raises a ba.ActivityNotFoundError if the Activity no longer exists.
|
||||
"""
|
||||
activity = self._activity()
|
||||
if activity is None:
|
||||
from ba._error import ActivityNotFoundError
|
||||
raise ActivityNotFoundError()
|
||||
return activity
|
||||
|
||||
def getactivity(self, doraise: bool = True) -> Optional[ba.Activity]:
|
||||
"""Return the ba.Activity this Actor is associated with.
|
||||
|
||||
If the Activity no longer exists, raises a ba.ActivityNotFoundError
|
||||
or returns None depending on whether 'doraise' is set.
|
||||
"""
|
||||
activity = self._activity()
|
||||
if activity is None and doraise:
|
||||
from ba._error import ActivityNotFoundError
|
||||
raise ActivityNotFoundError()
|
||||
return activity
|
||||
832
assets/src/data/scripts/ba/_app.py
Normal file
832
assets/src/data/scripts/ba/_app.py
Normal file
@ -0,0 +1,832 @@
|
||||
"""Functionality related to the high level state of the app."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ba
|
||||
from ba import _lang as bs_lang
|
||||
from bastd.actor import spazappearance
|
||||
from typing import (Optional, Dict, Tuple, Set, Any, List, Type, Tuple,
|
||||
Callable)
|
||||
|
||||
|
||||
class App:
|
||||
"""A class for high level app functionality and state.
|
||||
|
||||
category: General Utility Classes
|
||||
|
||||
Use ba.app to access the single shared instance of this class.
|
||||
|
||||
Note that properties not documented here should be considered internal
|
||||
and subject to change without warning.
|
||||
"""
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
# Note: many values here are simple method attrs and thus don't show
|
||||
# up in docs. If there's any that'd be useful to expose publicly, they
|
||||
# should be converted to properties so its possible to validate values
|
||||
# and provide docs.
|
||||
|
||||
@property
|
||||
def build_number(self) -> int:
|
||||
"""Integer build number.
|
||||
|
||||
This value increases by at least 1 with each release of the game.
|
||||
It is independent of the human readable ba.App.version string.
|
||||
"""
|
||||
return self._build_number
|
||||
|
||||
@property
|
||||
def config_file_path(self) -> str:
|
||||
"""Where the game's config file is stored on disk."""
|
||||
return self._config_file_path
|
||||
|
||||
@property
|
||||
def locale(self) -> str:
|
||||
"""Raw country/language code detected by the game (such as 'en_US').
|
||||
|
||||
Generally for language-specific code you should look at
|
||||
ba.App.language, which is the language the game is using
|
||||
(which may differ from locale if the user sets a language, etc.)
|
||||
"""
|
||||
return self._locale
|
||||
|
||||
def can_display_language(self, language: str) -> bool:
|
||||
"""Tell whether we can display a particular language.
|
||||
|
||||
(internal)
|
||||
|
||||
On some platforms we don't have unicode rendering yet
|
||||
which limits the languages we can draw.
|
||||
"""
|
||||
|
||||
# We don't yet support full unicode display on windows or linux :-(.
|
||||
if (language in ('Chinese', 'Persian', 'Korean', 'Arabic', 'Hindi')
|
||||
and self.platform in ('windows', 'linux')):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_default_language(self) -> str:
|
||||
languages = {
|
||||
'de': 'German',
|
||||
'es': 'Spanish',
|
||||
'it': 'Italian',
|
||||
'nl': 'Dutch',
|
||||
'da': 'Danish',
|
||||
'pt': 'Portuguese',
|
||||
'fr': 'French',
|
||||
'el': 'Greek',
|
||||
'ru': 'Russian',
|
||||
'pl': 'Polish',
|
||||
'sv': 'Swedish',
|
||||
'eo': 'Esperanto',
|
||||
'cs': 'Czech',
|
||||
'hr': 'Croatian',
|
||||
'hu': 'Hungarian',
|
||||
'be': 'Belarussian',
|
||||
'ro': 'Romanian',
|
||||
'ko': 'Korean',
|
||||
'fa': 'Persian',
|
||||
'ar': 'Arabic',
|
||||
'zh': 'Chinese',
|
||||
'tr': 'Turkish',
|
||||
'id': 'Indonesian',
|
||||
'sr': 'Serbian',
|
||||
'uk': 'Ukrainian',
|
||||
'hi': 'Hindi'
|
||||
}
|
||||
language = languages.get(self.locale[:2], 'English')
|
||||
if not self.can_display_language(language):
|
||||
language = 'English'
|
||||
return language
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
"""The name of the language the game is running in.
|
||||
|
||||
This can be selected explicitly by the user or may be set
|
||||
automatically based on ba.App.locale or other factors.
|
||||
"""
|
||||
assert isinstance(self.config, dict)
|
||||
return self.config.get('Lang', self.default_language)
|
||||
|
||||
@property
|
||||
def user_agent_string(self) -> str:
|
||||
"""String containing various bits of info about OS/device/etc."""
|
||||
return self._user_agent_string
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Human-readable version string; something like '1.3.24'.
|
||||
|
||||
This should not be interpreted as a number; it may contain
|
||||
string elements such as 'alpha', 'beta', 'test', etc.
|
||||
If a numeric version is needed, use 'ba.App.build_number'.
|
||||
"""
|
||||
return self._version
|
||||
|
||||
@property
|
||||
def debug_build(self) -> bool:
|
||||
"""Whether the game was compiled in debug mode.
|
||||
|
||||
Debug builds generally run substantially slower than non-debug
|
||||
builds due to compiler optimizations being disabled and extra
|
||||
checks being run.
|
||||
"""
|
||||
return self._debug_build
|
||||
|
||||
@property
|
||||
def test_build(self) -> bool:
|
||||
"""Whether the game was compiled in test mode.
|
||||
|
||||
Test mode enables extra checks and features that are useful for
|
||||
release testing but which do not slow the game down significantly.
|
||||
"""
|
||||
return self._test_build
|
||||
|
||||
@property
|
||||
def user_scripts_directory(self) -> str:
|
||||
"""Path where the game is looking for custom user scripts."""
|
||||
return self._user_scripts_directory
|
||||
|
||||
@property
|
||||
def system_scripts_directory(self) -> str:
|
||||
"""Path where the game is looking for its bundled scripts."""
|
||||
return self._system_scripts_directory
|
||||
|
||||
@property
|
||||
def config(self) -> ba.AppConfig:
|
||||
"""The ba.AppConfig instance representing the app's config state."""
|
||||
assert self._config is not None
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def platform(self) -> str:
|
||||
"""Name of the current platform.
|
||||
|
||||
Examples are: 'mac', 'windows', android'.
|
||||
"""
|
||||
return self._platform
|
||||
|
||||
@property
|
||||
def subplatform(self) -> str:
|
||||
"""String for subplatform.
|
||||
|
||||
Can be empty. For the 'android' platform, subplatform may
|
||||
be 'google', 'amazon', etc.
|
||||
"""
|
||||
return self._subplatform
|
||||
|
||||
@property
|
||||
def interface_type(self) -> str:
|
||||
"""Interface mode the game is in; can be 'large', 'medium', or 'small'.
|
||||
|
||||
'large' is used by system such as desktop PC where elements on screen
|
||||
remain usable even at small sizes, allowing more to be shown.
|
||||
'small' is used by small devices such as phones, where elements on
|
||||
screen must be larger to remain readable and usable.
|
||||
'medium' is used by tablets and other middle-of-the-road situations
|
||||
such as VR or TV.
|
||||
"""
|
||||
return self._interface_type
|
||||
|
||||
@property
|
||||
def on_tv(self) -> bool:
|
||||
"""Bool value for if the game is running on a TV."""
|
||||
return self._on_tv
|
||||
|
||||
@property
|
||||
def vr_mode(self) -> bool:
|
||||
"""Bool value for if the game is running in VR."""
|
||||
return self._vr_mode
|
||||
|
||||
@property
|
||||
def ui_bounds(self) -> Tuple[float, float, float, float]:
|
||||
"""Bounds of the 'safe' screen area in ui space.
|
||||
|
||||
This tuple contains: (x-min, x-max, y-min, y-max)
|
||||
"""
|
||||
return _ba.uibounds()
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""(internal)
|
||||
|
||||
Do not instantiate this class; use ba.app to access
|
||||
the single shared instance.
|
||||
"""
|
||||
# pylint: disable=too-many-statements
|
||||
|
||||
test_https = False
|
||||
if test_https:
|
||||
# Testing https support (would be nice to get this working on
|
||||
# our custom python builds; need to wrangle certificates somehow).
|
||||
import urllib.request
|
||||
try:
|
||||
val = urllib.request.urlopen('https://example.com').read()
|
||||
print("HTTPS SUCCESS", len(val))
|
||||
except Exception as exc:
|
||||
print("GOT EXC", exc)
|
||||
|
||||
try:
|
||||
import sqlite3
|
||||
print("GOT SQLITE", sqlite3)
|
||||
except Exception as exc:
|
||||
print("EXC IMPORTING SQLITE", exc)
|
||||
|
||||
try:
|
||||
import csv
|
||||
print("GOT CSV", csv)
|
||||
except Exception as exc:
|
||||
print("EXC IMPORTING CSV", exc)
|
||||
|
||||
try:
|
||||
import lzma
|
||||
print("GOT LZMA", lzma)
|
||||
except Exception as exc:
|
||||
print("EXC IMPORTING LZMA", exc)
|
||||
|
||||
# Config.
|
||||
self.config_file_healthy = False
|
||||
|
||||
# This is incremented any time the app is backgrounded/foregrounded;
|
||||
# can be a simple way to determine if network data should be
|
||||
# refreshed/etc.
|
||||
self.fg_state = 0
|
||||
|
||||
# Environment stuff (pulling these out as attrs so we can type-check
|
||||
# them).
|
||||
env = _ba.env()
|
||||
self._build_number: int = env['build_number']
|
||||
self._config_file_path: str = env['config_file_path']
|
||||
self._locale: str = env['locale']
|
||||
self._user_agent_string: str = env['user_agent_string']
|
||||
self._version: str = env['version']
|
||||
self._debug_build: bool = env['debug_build']
|
||||
self._test_build: bool = env['test_build']
|
||||
self._user_scripts_directory: str = env['user_scripts_directory']
|
||||
self._system_scripts_directory: str = env['system_scripts_directory']
|
||||
self._platform: str = env['platform']
|
||||
self._subplatform: str = env['subplatform']
|
||||
self._interface_type: str = env['interface_type']
|
||||
self._on_tv: bool = env['on_tv'] #
|
||||
self._vr_mode: bool = env['vr_mode']
|
||||
self.protocol_version: int = env['protocol_version']
|
||||
self.toolbar_test: bool = env['toolbar_test']
|
||||
self.kiosk_mode: bool = env['kiosk_mode']
|
||||
|
||||
# Misc.
|
||||
self.default_language = self._get_default_language()
|
||||
self.metascan: Optional[Dict[str, Any]] = None
|
||||
self.tips: List[str] = []
|
||||
self.stress_test_reset_timer: Optional[ba.Timer] = None
|
||||
self.suppress_debug_reports = False
|
||||
self.last_ad_completion_time: Optional[float] = None
|
||||
self.last_ad_was_short = False
|
||||
self.did_weak_call_warning = False
|
||||
self.ran_on_launch = False
|
||||
|
||||
# If we try to run promo-codes due to launch-args/etc we might
|
||||
# not be signed in yet; go ahead and queue them up in that case.
|
||||
self.pending_promo_codes: List[str] = []
|
||||
self.last_in_game_ad_remove_message_show_time: Optional[float] = None
|
||||
self.log_have_new = False
|
||||
self.log_upload_timer_started = False
|
||||
self._config: Optional[ba.AppConfig] = None
|
||||
self.printed_live_object_warning = False
|
||||
self.last_post_purchase_message_time: Optional[float] = None
|
||||
|
||||
# We include this extra hash with shared input-mapping names so
|
||||
# that we don't share mappings between differently-configured
|
||||
# systems. For instance, different android devices may give different
|
||||
# key values for the same controller type so we keep their mappings
|
||||
# distinct.
|
||||
self.input_map_hash: Optional[str] = None
|
||||
|
||||
# Co-op Campaigns.
|
||||
self.campaigns: Dict[str, ba.Campaign] = {}
|
||||
|
||||
# Server-Mode.
|
||||
self.server_config: Dict[str, Any] = {}
|
||||
self.server_config_dirty = False
|
||||
self.run_server_wait_timer: Optional[ba.Timer] = None
|
||||
self.server_playlist_fetch: Optional[Dict[str, Any]] = None
|
||||
self.launched_server = False
|
||||
self.run_server_first_run = True
|
||||
|
||||
# Ads.
|
||||
self.last_ad_network = 'unknown'
|
||||
self.last_ad_network_set_time = time.time()
|
||||
self.ad_amt: Optional[float] = None
|
||||
self.last_ad_purpose = 'invalid'
|
||||
self.attempted_first_ad = False
|
||||
|
||||
# Music.
|
||||
self.music: Optional[ba.Node] = None
|
||||
self.music_mode: str = 'regular'
|
||||
self.music_player: Optional[ba.MusicPlayer] = None
|
||||
self.music_player_type: Optional[Type[ba.MusicPlayer]] = None
|
||||
self.music_types: Dict[str, Optional[str]] = {
|
||||
'regular': None,
|
||||
'test': None
|
||||
}
|
||||
|
||||
# Language.
|
||||
self.language_target: Optional[bs_lang.AttrDict] = None
|
||||
self.language_merged: Optional[bs_lang.AttrDict] = None
|
||||
|
||||
# Achievements.
|
||||
self.achievements: List[ba.Achievement] = []
|
||||
self.achievements_to_display: (List[Tuple[ba.Achievement, bool]]) = []
|
||||
self.achievement_display_timer: Optional[_ba.Timer] = None
|
||||
self.last_achievement_display_time: float = 0.0
|
||||
self.achievement_completion_banner_slots: Set[int] = set()
|
||||
|
||||
# Lobby.
|
||||
self.lobby_random_profile_index: int = 1
|
||||
self.lobby_random_char_index_offset: Optional[int] = None
|
||||
self.lobby_account_profile_device_id: Optional[int] = None
|
||||
|
||||
# Main Menu.
|
||||
self.main_menu_did_initial_transition = False
|
||||
self.main_menu_last_news_fetch_time: Optional[float] = None
|
||||
|
||||
# Spaz.
|
||||
self.spaz_appearances: Dict[str, spazappearance.Appearance] = {}
|
||||
self.last_spaz_turbo_warn_time: float = -99999.0
|
||||
|
||||
# Maps.
|
||||
self.maps: Dict[str, Type[ba.Map]] = {}
|
||||
|
||||
# Gameplay.
|
||||
self.teams_series_length = 7
|
||||
self.ffa_series_length = 24
|
||||
self.coop_session_args: dict = {}
|
||||
|
||||
# UI.
|
||||
self.uicontroller: Optional[ba.UIController] = None
|
||||
self.main_menu_window: Optional[_ba.Widget] = None # FIXME: Kill this.
|
||||
self.window_states: dict = {} # FIXME: Kill this.
|
||||
self.windows: dict = {} # FIXME: Kill this.
|
||||
self.main_window: Optional[str] = None # FIXME: Kill this.
|
||||
self.main_menu_selection: Optional[str] = None # FIXME: Kill this.
|
||||
self.have_party_queue_window = False
|
||||
self.quit_window: Any = None
|
||||
self.dismiss_wii_remotes_window_call: (
|
||||
Optional[Callable[[], Any]]) = None
|
||||
self.value_test_defaults: dict = {}
|
||||
self.main_menu_window_refresh_check_count = 0
|
||||
self.first_main_menu = True # FIXME: Move to mainmenu class.
|
||||
self.did_menu_intro = False # FIXME: Move to mainmenu class.
|
||||
self.main_menu_resume_callbacks: list = [] # can probably go away
|
||||
self.special_offer = None
|
||||
self.league_rank_cache: dict = {}
|
||||
self.tournament_info: dict = {}
|
||||
self.account_tournament_list: Optional[Tuple[int, List[str]]] = None
|
||||
self.ping_thread_count = 0
|
||||
self.invite_confirm_windows: List[Any] = [] # FIXME: Don't use Any.
|
||||
self.store_layout: Optional[Dict[str, List[Dict[str, Any]]]] = None
|
||||
self.store_items: Optional[Dict[str, Dict]] = None
|
||||
self.pro_sale_start_time: Optional[int] = None
|
||||
self.pro_sale_start_val: Optional[int] = None
|
||||
self.party_window: Any = None # FIXME: Don't use Any.
|
||||
self.title_color = (0.72, 0.7, 0.75)
|
||||
self.heading_color = (0.72, 0.7, 0.75)
|
||||
self.infotextcolor = (0.7, 0.9, 0.7)
|
||||
self.uicleanupchecks: List[dict] = []
|
||||
self.uiupkeeptimer: Optional[ba.Timer] = None
|
||||
self.delegate: Optional[ba.AppDelegate] = None
|
||||
|
||||
# A few shortcuts.
|
||||
self.small_ui = env['interface_type'] == 'small'
|
||||
self.med_ui = env['interface_type'] == 'medium'
|
||||
self.large_ui = env['interface_type'] == 'large'
|
||||
self.toolbars = env.get('toolbar_test', True)
|
||||
|
||||
def on_launch(self) -> None:
|
||||
"""Runs after the app finishes bootstrapping.
|
||||
|
||||
(internal)"""
|
||||
# FIXME: Break this up.
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _apputils
|
||||
from ba._general import Call
|
||||
from ba import _appconfig
|
||||
from ba import ui as bsui
|
||||
from ba import _achievement
|
||||
from ba import _maps
|
||||
from ba import _meta
|
||||
from ba import _music
|
||||
from ba import _campaign
|
||||
from bastd import appdelegate
|
||||
from bastd import maps as stdmaps
|
||||
from bastd.actor import spazappearance
|
||||
from ba._enums import TimeType
|
||||
|
||||
cfg = self.config
|
||||
|
||||
# Set up our app delegate.
|
||||
self.delegate = appdelegate.AppDelegate()
|
||||
self.uicontroller = bsui.UIController()
|
||||
_achievement.init_achievements()
|
||||
spazappearance.register_appearances()
|
||||
_campaign.init_campaigns()
|
||||
if _ba.env()['platform'] == 'android':
|
||||
self.music_player_type = _music.InternalMusicPlayer
|
||||
elif _ba.env()['platform'] == 'mac' and hasattr(_ba, 'itunes_init'):
|
||||
self.music_player_type = _music.MacITunesMusicPlayer
|
||||
for maptype in [
|
||||
stdmaps.HockeyStadium, stdmaps.FootballStadium,
|
||||
stdmaps.Bridgit, stdmaps.BigG, stdmaps.Roundabout,
|
||||
stdmaps.MonkeyFace, stdmaps.ZigZag, stdmaps.ThePad,
|
||||
stdmaps.DoomShroom, stdmaps.LakeFrigid, stdmaps.TipTop,
|
||||
stdmaps.CragCastle, stdmaps.TowerD, stdmaps.HappyThoughts,
|
||||
stdmaps.StepRightUp, stdmaps.Courtyard, stdmaps.Rampage
|
||||
]:
|
||||
_maps.register_map(maptype)
|
||||
|
||||
if self.debug_build:
|
||||
_apputils.suppress_debug_reports()
|
||||
|
||||
# IMPORTANT - if tweaking UI stuff, you need to make sure it behaves
|
||||
# for small, medium, and large UI modes. (doesn't run off screen, etc).
|
||||
# Set these to 1 to test with different sizes. Generally small is used
|
||||
# on phones, medium is used on tablets, and large is on desktops or
|
||||
# large tablets.
|
||||
|
||||
# Kick off our periodic UI upkeep.
|
||||
# FIXME: Can probably kill this if we do immediate UI death checks.
|
||||
self.uiupkeeptimer = _ba.Timer(2.6543,
|
||||
bsui.upkeep,
|
||||
timetype=TimeType.REAL,
|
||||
repeat=True)
|
||||
|
||||
# pylint: disable=using-constant-test
|
||||
# noinspection PyUnreachableCode
|
||||
if 0: # force-test small UI
|
||||
self.small_ui = True
|
||||
self.med_ui = False
|
||||
with _ba.Context('ui'):
|
||||
_ba.pushcall(
|
||||
Call(_ba.screenmessage,
|
||||
'FORCING SMALL UI FOR TESTING',
|
||||
color=(1, 0, 1),
|
||||
log=True))
|
||||
# noinspection PyUnreachableCode
|
||||
if 0: # force-test medium UI
|
||||
self.small_ui = False
|
||||
self.med_ui = True
|
||||
with _ba.Context('ui'):
|
||||
_ba.pushcall(
|
||||
Call(_ba.screenmessage,
|
||||
'FORCING MEDIUM UI FOR TESTING',
|
||||
color=(1, 0, 1),
|
||||
log=True))
|
||||
# noinspection PyUnreachableCode
|
||||
if 0: # force-test large UI
|
||||
self.small_ui = False
|
||||
self.med_ui = False
|
||||
with _ba.Context('ui'):
|
||||
_ba.pushcall(
|
||||
Call(_ba.screenmessage,
|
||||
'FORCING LARGE UI FOR TESTING',
|
||||
color=(1, 0, 1),
|
||||
log=True))
|
||||
# pylint: enable=using-constant-test
|
||||
|
||||
# If there's a leftover log file, attempt to upload
|
||||
# it to the server and/or get rid of it.
|
||||
_apputils.handle_leftover_log_file()
|
||||
try:
|
||||
_apputils.handle_leftover_log_file()
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('Error handling leftover log file')
|
||||
|
||||
# Notify the user if we're using custom system scripts.
|
||||
# FIXME: This no longer works since sys-scripts is an absolute path;
|
||||
# need to just add a proper call to query this.
|
||||
# if env['system_scripts_directory'] != 'data/scripts':
|
||||
# ba.screenmessage("Using custom system scripts...",
|
||||
# color=(0, 1, 0))
|
||||
|
||||
# Only do this stuff if our config file is healthy so we don't
|
||||
# overwrite a broken one or whatnot and wipe out data.
|
||||
if not self.config_file_healthy:
|
||||
if self.platform in ('mac', 'linux', 'windows'):
|
||||
from bastd.ui import configerror
|
||||
configerror.ConfigErrorWindow()
|
||||
return
|
||||
|
||||
# For now on other systems we just overwrite the bum config.
|
||||
# At this point settings are already set; lets just commit them
|
||||
# to disk.
|
||||
_appconfig.commit_app_config(force=True)
|
||||
|
||||
# If we're using a non-default playlist, lets go ahead and get our
|
||||
# music-player going since it may hitch (better while we're faded
|
||||
# out than later).
|
||||
try:
|
||||
if ('Soundtrack' in cfg and cfg['Soundtrack'] not in [
|
||||
'__default__', 'Default Soundtrack'
|
||||
]):
|
||||
_music.get_music_player()
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('error prepping music-player')
|
||||
|
||||
launch_count = cfg.get('launchCount', 0)
|
||||
launch_count += 1
|
||||
|
||||
for key in ('lc14173', 'lc14292'):
|
||||
cfg.setdefault(key, launch_count)
|
||||
|
||||
# Debugging - make note if we're using the local test server so we
|
||||
# don't accidentally leave it on in a release.
|
||||
server_addr = _ba.get_master_server_address()
|
||||
if 'localhost' in server_addr:
|
||||
_ba.timer(2.0,
|
||||
Call(_ba.screenmessage,
|
||||
"Note: using local server", (1, 1, 0),
|
||||
log=True),
|
||||
timetype=TimeType.REAL)
|
||||
elif 'test' in server_addr:
|
||||
_ba.timer(2.0,
|
||||
Call(_ba.screenmessage,
|
||||
"Note: using test server-module", (1, 1, 0),
|
||||
log=True),
|
||||
timetype=TimeType.REAL)
|
||||
|
||||
cfg['launchCount'] = launch_count
|
||||
cfg.commit()
|
||||
|
||||
# Run a test in a few seconds to see if we should pop up an existing
|
||||
# pending special offer.
|
||||
def check_special_offer() -> None:
|
||||
from bastd.ui import specialoffer
|
||||
config = self.config
|
||||
if ('pendingSpecialOffer' in config and _ba.get_public_login_id()
|
||||
== config['pendingSpecialOffer']['a']):
|
||||
self.special_offer = config['pendingSpecialOffer']['o']
|
||||
specialoffer.show_offer()
|
||||
|
||||
if self.subplatform != 'headless':
|
||||
_ba.timer(3.0, check_special_offer, timetype=TimeType.REAL)
|
||||
|
||||
_meta.startscan()
|
||||
|
||||
# Start scanning for stuff available in our scripts.
|
||||
# meta.get_game_types()
|
||||
|
||||
# Auto-sign-in to a local account in a moment if we're set to.
|
||||
def do_auto_sign_in() -> None:
|
||||
if self.subplatform == 'headless':
|
||||
_ba.sign_in('Local')
|
||||
elif cfg.get('Auto Account State') == 'Local':
|
||||
_ba.sign_in('Local')
|
||||
|
||||
_ba.pushcall(do_auto_sign_in)
|
||||
|
||||
self.ran_on_launch = True
|
||||
|
||||
from ba._dep import test_depset
|
||||
test_depset()
|
||||
# print('GAME TYPES ARE', meta.get_game_types())
|
||||
# _bs.quit()
|
||||
|
||||
def read_config(self) -> None:
|
||||
"""(internal)"""
|
||||
from ba import _appconfig
|
||||
self._config, self.config_file_healthy = _appconfig.read_config()
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause the game due to a user request or menu popping up.
|
||||
|
||||
If there's a foreground host-activity that says it's pausable, tell it
|
||||
to pause ..we now no longer pause if there are connected clients.
|
||||
"""
|
||||
# pylint: disable=cyclic-import
|
||||
activity = _ba.get_foreground_host_activity()
|
||||
if (activity is not None and activity.allow_pausing
|
||||
and not _ba.have_connected_clients()):
|
||||
# FIXME: Shouldn't be touching scene stuff here;
|
||||
# should just pass the request on to the host-session.
|
||||
import ba
|
||||
with ba.Context(activity):
|
||||
globs = ba.sharedobj('globals')
|
||||
if not globs.paused:
|
||||
ba.playsound(ba.getsound('refWhistle'))
|
||||
globs.paused = True
|
||||
|
||||
# FIXME: This should not be an attr on Actor.
|
||||
activity.paused_text = ba.Actor(
|
||||
ba.newnode(
|
||||
'text',
|
||||
attrs={
|
||||
'text': ba.Lstr(resource='pausedByHostText'),
|
||||
'client_only': True,
|
||||
'flatness': 1.0,
|
||||
'h_align': 'center'
|
||||
}))
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume the game due to a user request or menu closing.
|
||||
|
||||
If there's a foreground host-activity that's currently paused, tell it
|
||||
to resume.
|
||||
"""
|
||||
from ba import _gameutils
|
||||
# FIXME: Shouldn't be touching scene stuff here;
|
||||
# should just pass the request on to the host-session.
|
||||
activity = _ba.get_foreground_host_activity()
|
||||
if activity is not None:
|
||||
with _ba.Context(activity):
|
||||
globs = _gameutils.sharedobj('globals')
|
||||
if globs.paused:
|
||||
_ba.playsound(_ba.getsound('refWhistle'))
|
||||
globs.paused = False
|
||||
|
||||
# FIXME: This should not be an actor attr.
|
||||
activity.paused_text = None
|
||||
|
||||
def return_to_main_menu_session_gracefully(self) -> None:
|
||||
"""Attempt to cleanly get back to the main menu."""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _benchmark
|
||||
from ba._general import Call
|
||||
from bastd import mainmenu
|
||||
_ba.app.main_window = None
|
||||
if isinstance(_ba.get_foreground_host_session(),
|
||||
mainmenu.MainMenuSession):
|
||||
# It may be possible we're on the main menu but the screen is faded
|
||||
# so fade back in.
|
||||
_ba.fade_screen(True)
|
||||
return
|
||||
|
||||
_benchmark.stop_stress_test() # Stop stress-test if in progress.
|
||||
|
||||
# If we're in a host-session, tell them to end.
|
||||
# This lets them tear themselves down gracefully.
|
||||
host_session = _ba.get_foreground_host_session()
|
||||
if host_session is not None:
|
||||
|
||||
# Kick off a little transaction so we'll hopefully have all the
|
||||
# latest account state when we get back to the menu.
|
||||
_ba.add_transaction({
|
||||
'type': 'END_SESSION',
|
||||
'sType': str(type(host_session))
|
||||
})
|
||||
_ba.run_transactions()
|
||||
|
||||
host_session.end()
|
||||
|
||||
# Otherwise just force the issue.
|
||||
else:
|
||||
_ba.pushcall(Call(_ba.new_host_session, mainmenu.MainMenuSession))
|
||||
|
||||
def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None:
|
||||
"""(internal)"""
|
||||
|
||||
# If there's no main menu up, just call immediately.
|
||||
if not self.main_menu_window:
|
||||
with _ba.Context('ui'):
|
||||
call()
|
||||
else:
|
||||
self.main_menu_resume_callbacks.append(call)
|
||||
|
||||
def handle_app_pause(self) -> None:
|
||||
"""Called when the app goes to a suspended state."""
|
||||
|
||||
def handle_app_resume(self) -> None:
|
||||
"""Run when the app resumes from a suspended state."""
|
||||
|
||||
# If there's music playing externally, make sure we aren't playing
|
||||
# ours.
|
||||
from ba import _music
|
||||
_music.handle_app_resume()
|
||||
self.fg_state += 1
|
||||
|
||||
# Mark our cached tourneys as invalid so anyone using them knows
|
||||
# they might be out of date.
|
||||
for entry in list(self.tournament_info.values()):
|
||||
entry['valid'] = False
|
||||
|
||||
def launch_coop_game(self,
|
||||
game: str,
|
||||
force: bool = False,
|
||||
args: Dict = None) -> bool:
|
||||
"""High level way to launch a co-op session locally."""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._campaign import get_campaign
|
||||
from bastd.ui.coop.level import CoopLevelLockedWindow
|
||||
if args is None:
|
||||
args = {}
|
||||
if game == '':
|
||||
raise Exception("empty game name")
|
||||
campaignname, levelname = game.split(':')
|
||||
campaign = get_campaign(campaignname)
|
||||
levels = campaign.get_levels()
|
||||
|
||||
# If this campaign is sequential, make sure we've completed the
|
||||
# one before this.
|
||||
if campaign.sequential and not force:
|
||||
for level in levels:
|
||||
if level.name == levelname:
|
||||
break
|
||||
if not level.complete:
|
||||
CoopLevelLockedWindow(
|
||||
campaign.get_level(levelname).displayname,
|
||||
campaign.get_level(level.name).displayname)
|
||||
return False
|
||||
|
||||
# Ok, we're good to go.
|
||||
self.coop_session_args = {'campaign': campaignname, 'level': levelname}
|
||||
for arg_name, arg_val in list(args.items()):
|
||||
self.coop_session_args[arg_name] = arg_val
|
||||
|
||||
def _fade_end() -> None:
|
||||
from ba import _coopsession
|
||||
try:
|
||||
_ba.new_host_session(_coopsession.CoopSession)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception()
|
||||
from bastd import mainmenu
|
||||
_ba.new_host_session(mainmenu.MainMenuSession)
|
||||
|
||||
_ba.fade_screen(False, endcall=_fade_end)
|
||||
return True
|
||||
|
||||
def do_remove_in_game_ads_message(self) -> None:
|
||||
"""(internal)"""
|
||||
from ba._lang import Lstr
|
||||
from ba._general import Call
|
||||
from ba._enums import TimeType
|
||||
|
||||
# Print this message once every 10 minutes at most.
|
||||
tval = _ba.time(TimeType.REAL)
|
||||
if (self.last_in_game_ad_remove_message_show_time is None or
|
||||
(tval - self.last_in_game_ad_remove_message_show_time > 60 * 10)):
|
||||
self.last_in_game_ad_remove_message_show_time = tval
|
||||
with _ba.Context('ui'):
|
||||
_ba.timer(
|
||||
1.0,
|
||||
Call(_ba.screenmessage,
|
||||
Lstr(
|
||||
resource='removeInGameAdsText',
|
||||
subs=[
|
||||
('${PRO}',
|
||||
Lstr(resource='store.bombSquadProNameText')),
|
||||
('${APP_NAME}', Lstr(resource='titleText'))
|
||||
]),
|
||||
color=(1, 1, 0)),
|
||||
timetype=TimeType.REAL)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""(internal)"""
|
||||
if self.music_player is not None:
|
||||
self.music_player.shutdown()
|
||||
|
||||
def handle_deep_link(self, url: str) -> None:
|
||||
"""Handle a deep link URL."""
|
||||
from ba._lang import Lstr
|
||||
from ba._enums import TimeType
|
||||
if url.startswith('ballisticacore://code/'):
|
||||
code = url.replace('ballisticacore://code/', '')
|
||||
|
||||
# If we're not signed in, queue up the code to run the next time we
|
||||
# are and issue a warning if we haven't signed in within the next
|
||||
# few seconds.
|
||||
if _ba.get_account_state() != 'signed_in':
|
||||
|
||||
def check_pending_codes() -> None:
|
||||
"""(internal)"""
|
||||
|
||||
# If we're still not signed in and have pending codes,
|
||||
# inform the user that they need to sign in to use them.
|
||||
if _ba.app.pending_promo_codes:
|
||||
_ba.screenmessage(
|
||||
Lstr(resource='signInForPromoCodeText'),
|
||||
color=(1, 0, 0))
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
|
||||
_ba.app.pending_promo_codes.append(code)
|
||||
_ba.timer(6.0, check_pending_codes, timetype=TimeType.REAL)
|
||||
return
|
||||
_ba.screenmessage(Lstr(resource='submittingPromoCodeText'),
|
||||
color=(0, 1, 0))
|
||||
_ba.add_transaction({
|
||||
'type': 'PROMO_CODE',
|
||||
'expire_time': time.time() + 5,
|
||||
'code': code
|
||||
})
|
||||
_ba.run_transactions()
|
||||
else:
|
||||
_ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
164
assets/src/data/scripts/ba/_appconfig.py
Normal file
164
assets/src/data/scripts/ba/_appconfig.py
Normal file
@ -0,0 +1,164 @@
|
||||
"""Provides the AppConfig class."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
|
||||
class AppConfig(dict):
|
||||
"""A special dict that holds the game's persistent configuration values.
|
||||
|
||||
Category: General Utility Classes
|
||||
|
||||
It also provides methods for fetching values with app-defined fallback
|
||||
defaults, applying contained values to the game, and committing the
|
||||
config to storage.
|
||||
|
||||
Call ba.appconfig() to get the single shared instance of this class.
|
||||
|
||||
AppConfig data is stored as json on disk on so make sure to only place
|
||||
json-friendly values in it (dict, list, str, float, int, bool).
|
||||
Be aware that tuples will be quietly converted to lists.
|
||||
"""
|
||||
|
||||
def resolve(self, key: str) -> Any:
|
||||
"""Given a string key, return a config value (type varies).
|
||||
|
||||
This will substitute application defaults for values not present in
|
||||
the config dict, filter some invalid values, etc. Note that these
|
||||
values do not represent the state of the app; simply the state of its
|
||||
config. Use ba.App to access actual live state.
|
||||
|
||||
Raises an Exception for unrecognized key names. To get the list of keys
|
||||
supported by this method, use ba.AppConfig.builtin_keys(). Note that it
|
||||
is perfectly legal to store other data in the config; it just needs to
|
||||
be accessed through standard dict methods and missing values handled
|
||||
manually.
|
||||
"""
|
||||
return _ba.resolve_appconfig_value(key)
|
||||
|
||||
def default_value(self, key: str) -> Any:
|
||||
"""Given a string key, return its predefined default value.
|
||||
|
||||
This is the value that will be returned by ba.AppConfig.resolve() if
|
||||
the key is not present in the config dict or of an incompatible type.
|
||||
|
||||
Raises an Exception for unrecognized key names. To get the list of keys
|
||||
supported by this method, use ba.AppConfig.builtin_keys(). Note that it
|
||||
is perfectly legal to store other data in the config; it just needs to
|
||||
be accessed through standard dict methods and missing values handled
|
||||
manually.
|
||||
"""
|
||||
return _ba.get_appconfig_default_value(key)
|
||||
|
||||
def builtin_keys(self) -> List[str]:
|
||||
"""Return the list of valid key names recognized by ba.AppConfig.
|
||||
|
||||
This set of keys can be used with resolve(), default_value(), etc.
|
||||
It does not vary across platforms and may include keys that are
|
||||
obsolete or not relevant on the current running version. (for instance,
|
||||
VR related keys on non-VR platforms). This is to minimize the amount
|
||||
of platform checking necessary)
|
||||
|
||||
Note that it is perfectly legal to store arbitrary named data in the
|
||||
config, but in that case it is up to the user to test for the existence
|
||||
of the key in the config dict, fall back to consistent defaults, etc.
|
||||
"""
|
||||
return _ba.get_appconfig_builtin_keys()
|
||||
|
||||
def apply(self) -> None:
|
||||
"""Apply config values to the running app."""
|
||||
_ba.apply_config()
|
||||
|
||||
def commit(self) -> None:
|
||||
"""Commits the config to local storage.
|
||||
|
||||
Note that this call is asynchronous so the actual write to disk may not
|
||||
occur immediately.
|
||||
"""
|
||||
commit_app_config()
|
||||
|
||||
def apply_and_commit(self) -> None:
|
||||
"""Run apply() followed by commit(); for convenience.
|
||||
|
||||
(This way the commit() will not occur if apply() hits invalid data)
|
||||
"""
|
||||
self.apply()
|
||||
self.commit()
|
||||
|
||||
|
||||
def read_config() -> Tuple[AppConfig, bool]:
|
||||
"""Read the game config."""
|
||||
import os
|
||||
import json
|
||||
from ba._enums import TimeType
|
||||
|
||||
config_file_healthy = False
|
||||
|
||||
# NOTE: it is assumed that this only gets called once and the
|
||||
# config object will not change from here on out
|
||||
config_file_path = _ba.app.config_file_path
|
||||
config_contents = ''
|
||||
try:
|
||||
if os.path.exists(config_file_path):
|
||||
with open(config_file_path) as infile:
|
||||
config_contents = infile.read()
|
||||
config = AppConfig(json.loads(config_contents))
|
||||
else:
|
||||
config = AppConfig()
|
||||
config_file_healthy = True
|
||||
|
||||
except Exception as exc:
|
||||
print(('error reading config file at time ' +
|
||||
str(_ba.time(TimeType.REAL)) + ': \'' + config_file_path +
|
||||
'\':\n'), exc)
|
||||
|
||||
# Whenever this happens lets back up the broken one just in case it
|
||||
# gets overwritten accidentally.
|
||||
print(('backing up current config file to \'' + config_file_path +
|
||||
".broken\'"))
|
||||
try:
|
||||
import shutil
|
||||
shutil.copyfile(config_file_path, config_file_path + '.broken')
|
||||
except Exception as exc:
|
||||
print('EXC copying broken config:', exc)
|
||||
try:
|
||||
_ba.log('broken config contents:\n' +
|
||||
config_contents.replace('\000', '<NULL_BYTE>'),
|
||||
to_console=False)
|
||||
except Exception as exc:
|
||||
print('EXC logging broken config contents:', exc)
|
||||
config = AppConfig()
|
||||
|
||||
# Now attempt to read one of our 'prev' backup copies.
|
||||
prev_path = config_file_path + '.prev'
|
||||
try:
|
||||
if os.path.exists(prev_path):
|
||||
with open(prev_path) as infile:
|
||||
config_contents = infile.read()
|
||||
config = AppConfig(json.loads(config_contents))
|
||||
else:
|
||||
config = AppConfig()
|
||||
config_file_healthy = True
|
||||
print('successfully read backup config.')
|
||||
except Exception as exc:
|
||||
print('EXC reading prev backup config:', exc)
|
||||
return config, config_file_healthy
|
||||
|
||||
|
||||
def commit_app_config(force: bool = False) -> None:
|
||||
"""Commit the config to persistent storage.
|
||||
|
||||
Category: General Utility Functions
|
||||
|
||||
(internal)
|
||||
"""
|
||||
if not _ba.app.config_file_healthy and not force:
|
||||
print("Current config file is broken; "
|
||||
"skipping write to avoid losing settings.")
|
||||
return
|
||||
_ba.mark_config_dirty()
|
||||
27
assets/src/data/scripts/ba/_appdelegate.py
Normal file
27
assets/src/data/scripts/ba/_appdelegate.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""Defines AppDelegate class for handling high level app functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Type, Optional, Any, Dict, Callable
|
||||
import ba
|
||||
|
||||
|
||||
class AppDelegate:
|
||||
"""Defines handlers for high level app functionality."""
|
||||
|
||||
def create_default_game_config_ui(
|
||||
self, gameclass: Type[ba.GameActivity],
|
||||
sessionclass: Type[ba.Session], config: Optional[Dict[str, Any]],
|
||||
completion_call: Callable[[Optional[Dict[str, Any]]], None]
|
||||
) -> None:
|
||||
"""Launch a UI to configure the given game config.
|
||||
|
||||
It should manipulate the contents of config and call completion_call
|
||||
when done.
|
||||
"""
|
||||
del gameclass, sessionclass, config, completion_call # unused
|
||||
from ba import _error
|
||||
_error.print_error(
|
||||
"create_default_game_config_ui needs to be overridden")
|
||||
394
assets/src/data/scripts/ba/_apputils.py
Normal file
394
assets/src/data/scripts/ba/_apputils.py
Normal file
@ -0,0 +1,394 @@
|
||||
"""Utility functionality related to the overall operation of the app."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import List, Any, Callable, Optional
|
||||
import ba
|
||||
|
||||
|
||||
def is_browser_likely_available() -> bool:
|
||||
"""Return whether a browser likely exists on the current device.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
If this returns False you may want to avoid calling ba.show_url()
|
||||
with any lengthy addresses. (ba.show_url() will display an address
|
||||
as a string in a window if unable to bring up a browser, but that
|
||||
is only useful for simple URLs.)
|
||||
"""
|
||||
app = _ba.app
|
||||
platform = app.platform
|
||||
touchscreen = _ba.get_input_device('TouchScreen', '#1', doraise=False)
|
||||
|
||||
# If we're on a vr device or an android device with no touchscreen,
|
||||
# assume no browser.
|
||||
# FIXME: Might not be the case anymore; should make this definable
|
||||
# at the platform level.
|
||||
if app.vr_mode or (platform == 'android' and touchscreen is None):
|
||||
return False
|
||||
|
||||
# Anywhere else assume we've got one.
|
||||
return True
|
||||
|
||||
|
||||
def get_remote_app_name() -> ba.Lstr:
|
||||
"""(internal)"""
|
||||
from ba import _lang
|
||||
return _lang.Lstr(resource='remote_app.app_name')
|
||||
|
||||
|
||||
def should_submit_debug_info() -> bool:
|
||||
"""(internal)"""
|
||||
return _ba.app.config.get('Submit Debug Info', True)
|
||||
|
||||
|
||||
def suppress_debug_reports() -> None:
|
||||
"""Turn debug-reporting to the master server off.
|
||||
|
||||
This should be called in devel/debug situations to avoid spamming
|
||||
the master server with spurious logs.
|
||||
"""
|
||||
_ba.screenmessage("Suppressing debug reports.", color=(1, 0, 0))
|
||||
_ba.app.suppress_debug_reports = True
|
||||
|
||||
|
||||
def handle_log() -> None:
|
||||
"""Called on debug log prints.
|
||||
|
||||
When this happens, we can upload our log to the server
|
||||
after a short bit if desired.
|
||||
"""
|
||||
from ba._netutils import serverput
|
||||
from ba._enums import TimeType
|
||||
app = _ba.app
|
||||
app.log_have_new = True
|
||||
if not app.log_upload_timer_started:
|
||||
|
||||
def _put_log() -> None:
|
||||
if not app.suppress_debug_reports:
|
||||
try:
|
||||
sessionname = str(_ba.get_foreground_host_session())
|
||||
except Exception:
|
||||
sessionname = 'unavailable'
|
||||
try:
|
||||
activityname = str(_ba.get_foreground_host_activity())
|
||||
except Exception:
|
||||
activityname = 'unavailable'
|
||||
info = {
|
||||
'log': _ba.get_log(),
|
||||
'version': app.version,
|
||||
'build': app.build_number,
|
||||
'userAgentString': app.user_agent_string,
|
||||
'session': sessionname,
|
||||
'activity': activityname,
|
||||
'fatal': 0,
|
||||
'userRanCommands': _ba.has_user_run_commands(),
|
||||
'time': _ba.time(TimeType.REAL),
|
||||
'userModded': _ba.has_user_mods()
|
||||
}
|
||||
|
||||
def response(data: Any) -> None:
|
||||
# A non-None response means success; lets
|
||||
# take note that we don't need to report further
|
||||
# log info this run
|
||||
if data is not None:
|
||||
app.log_have_new = False
|
||||
_ba.mark_log_sent()
|
||||
|
||||
serverput('bsLog', info, response)
|
||||
|
||||
app.log_upload_timer_started = True
|
||||
|
||||
# Delay our log upload slightly in case other
|
||||
# pertinent info gets printed between now and then.
|
||||
with _ba.Context('ui'):
|
||||
_ba.timer(3.0, _put_log, timetype=TimeType.REAL)
|
||||
|
||||
# After a while, allow another log-put.
|
||||
def _reset() -> None:
|
||||
app.log_upload_timer_started = False
|
||||
if app.log_have_new:
|
||||
handle_log()
|
||||
|
||||
if not _ba.is_log_full():
|
||||
with _ba.Context('ui'):
|
||||
_ba.timer(600.0,
|
||||
_reset,
|
||||
timetype=TimeType.REAL,
|
||||
suppress_format_warning=True)
|
||||
|
||||
|
||||
def handle_leftover_log_file() -> None:
|
||||
"""Handle an un-uploaded log from a previous run."""
|
||||
import json
|
||||
from ba._netutils import serverput
|
||||
|
||||
if os.path.exists(_ba.get_log_file_path()):
|
||||
with open(_ba.get_log_file_path()) as infile:
|
||||
info = json.loads(infile.read())
|
||||
infile.close()
|
||||
do_send = should_submit_debug_info()
|
||||
if do_send:
|
||||
|
||||
def response(data: Any) -> None:
|
||||
# Non-None response means we were successful;
|
||||
# lets kill it.
|
||||
if data is not None:
|
||||
os.remove(_ba.get_log_file_path())
|
||||
|
||||
serverput('bsLog', info, response)
|
||||
else:
|
||||
# If they don't want logs uploaded just kill it.
|
||||
os.remove(_ba.get_log_file_path())
|
||||
|
||||
|
||||
def garbage_collect(session_end: bool = True) -> None:
|
||||
"""Run an explicit pass of garbage collection."""
|
||||
import gc
|
||||
gc.collect()
|
||||
|
||||
# Can be handy to print this to check for leaks between games.
|
||||
# noinspection PyUnreachableCode
|
||||
if False: # pylint: disable=using-constant-test
|
||||
print('PY OBJ COUNT', len(gc.get_objects()))
|
||||
if gc.garbage:
|
||||
print('PYTHON GC FOUND', len(gc.garbage), 'UNCOLLECTIBLE OBJECTS:')
|
||||
for i, obj in enumerate(gc.garbage):
|
||||
print(str(i) + ':', obj)
|
||||
if session_end:
|
||||
print_live_object_warnings('after session shutdown')
|
||||
|
||||
|
||||
def print_live_object_warnings(when: Any,
|
||||
ignore_session: ba.Session = None,
|
||||
ignore_activity: ba.Activity = None) -> None:
|
||||
"""Print warnings for remaining objects in the current context."""
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=cyclic-import
|
||||
import gc
|
||||
from ba import _session as bs_session
|
||||
from ba import _actor as bs_actor
|
||||
from ba import _activity as bs_activity
|
||||
sessions: List[ba.Session] = []
|
||||
activities: List[ba.Activity] = []
|
||||
actors = []
|
||||
if _ba.app.printed_live_object_warning:
|
||||
# print 'skipping live obj check due to previous found live object(s)'
|
||||
return
|
||||
for obj in gc.get_objects():
|
||||
if isinstance(obj, bs_actor.Actor):
|
||||
actors.append(obj)
|
||||
elif isinstance(obj, bs_session.Session):
|
||||
sessions.append(obj)
|
||||
elif isinstance(obj, bs_activity.Activity):
|
||||
activities.append(obj)
|
||||
|
||||
# Complain about any remaining sessions.
|
||||
for session in sessions:
|
||||
if session is ignore_session:
|
||||
continue
|
||||
_ba.app.printed_live_object_warning = True
|
||||
print('ERROR: Session found', when, ':', session)
|
||||
# refs = list(gc.get_referrers(session))
|
||||
# i = 1
|
||||
# for ref in refs:
|
||||
# if type(ref) is types.FrameType: continue
|
||||
# print ' ref', i, ':', ref
|
||||
# i += 1
|
||||
# if type(ref) is list or type(ref) is tuple or type(ref) is dict:
|
||||
# refs2 = list(gc.get_referrers(ref))
|
||||
# j = 1
|
||||
# for ref2 in refs2:
|
||||
# if type(ref2) is types.FrameType: continue
|
||||
# print ' ref\'s ref', j, ':', ref2
|
||||
# j += 1
|
||||
|
||||
# Complain about any remaining activities.
|
||||
for activity in activities:
|
||||
if activity is ignore_activity:
|
||||
continue
|
||||
_ba.app.printed_live_object_warning = True
|
||||
print('ERROR: Activity found', when, ':', activity)
|
||||
# refs = list(gc.get_referrers(activity))
|
||||
# i = 1
|
||||
# for ref in refs:
|
||||
# if type(ref) is types.FrameType: continue
|
||||
# print ' ref', i, ':', ref
|
||||
# i += 1
|
||||
# if type(ref) is list or type(ref) is tuple or type(ref) is dict:
|
||||
# refs2 = list(gc.get_referrers(ref))
|
||||
# j = 1
|
||||
# for ref2 in refs2:
|
||||
# if type(ref2) is types.FrameType: continue
|
||||
# print ' ref\'s ref', j, ':', ref2
|
||||
# j += 1
|
||||
|
||||
# Complain about any remaining actors.
|
||||
for actor in actors:
|
||||
_ba.app.printed_live_object_warning = True
|
||||
print('ERROR: Actor found', when, ':', actor)
|
||||
if isinstance(actor, bs_actor.Actor):
|
||||
try:
|
||||
if actor.node:
|
||||
print(' - contains node:', actor.node.getnodetype(), ';',
|
||||
actor.node.get_name())
|
||||
except Exception as exc:
|
||||
print(' - exception checking actor node:', exc)
|
||||
# refs = list(gc.get_referrers(actor))
|
||||
# i = 1
|
||||
# for ref in refs:
|
||||
# if type(ref) is types.FrameType: continue
|
||||
# print ' ref', i, ':', ref
|
||||
# i += 1
|
||||
# if type(ref) is list or type(ref) is tuple or type(ref) is dict:
|
||||
# refs2 = list(gc.get_referrers(ref))
|
||||
# j = 1
|
||||
# for ref2 in refs2:
|
||||
# if type(ref2) is types.FrameType: continue
|
||||
# print ' ref\'s ref', j, ':', ref2
|
||||
# j += 1
|
||||
|
||||
|
||||
def print_corrupt_file_error() -> None:
|
||||
"""Print an error if a corrupt file is found."""
|
||||
from ba._lang import get_resource
|
||||
from ba._general import Call
|
||||
from ba._enums import TimeType
|
||||
_ba.timer(2.0,
|
||||
Call(_ba.screenmessage,
|
||||
get_resource('internal.corruptFileText').replace(
|
||||
'${EMAIL}', 'support@froemling.net'),
|
||||
color=(1, 0, 0)),
|
||||
timetype=TimeType.REAL)
|
||||
_ba.timer(2.0,
|
||||
Call(_ba.playsound, _ba.getsound('error')),
|
||||
timetype=TimeType.REAL)
|
||||
|
||||
|
||||
def show_ad(purpose: str,
|
||||
on_completion_call: Callable[[], Any] = None,
|
||||
pass_actually_showed: bool = False) -> None:
|
||||
"""(internal)"""
|
||||
_ba.app.last_ad_purpose = purpose
|
||||
_ba.show_ad(purpose, on_completion_call, pass_actually_showed)
|
||||
|
||||
|
||||
def call_after_ad(call: Callable[[], Any]) -> None:
|
||||
"""Run a call after potentially showing an ad."""
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-locals
|
||||
from ba._account import have_pro
|
||||
from ba._enums import TimeType
|
||||
import time
|
||||
app = _ba.app
|
||||
show = True
|
||||
|
||||
# No ads without net-connections, etc.
|
||||
if not _ba.can_show_ad():
|
||||
show = False
|
||||
if have_pro():
|
||||
show = False # Pro disables interstitials.
|
||||
try:
|
||||
is_tournament = (_ba.get_foreground_host_session().tournament_id is
|
||||
not None)
|
||||
except Exception:
|
||||
is_tournament = False
|
||||
if is_tournament:
|
||||
show = False # Never show ads during tournaments.
|
||||
|
||||
if show:
|
||||
interval: Optional[float]
|
||||
launch_count = app.config.get('launchCount', 0)
|
||||
|
||||
# If we're seeing short ads we may want to space them differently.
|
||||
interval_mult = (_ba.get_account_misc_read_val(
|
||||
'ads.shortIntervalMult', 1.0) if app.last_ad_was_short else 1.0)
|
||||
if app.ad_amt is None:
|
||||
if launch_count <= 1:
|
||||
app.ad_amt = _ba.get_account_misc_read_val(
|
||||
'ads.startVal1', 0.99)
|
||||
else:
|
||||
app.ad_amt = _ba.get_account_misc_read_val(
|
||||
'ads.startVal2', 1.0)
|
||||
interval = None
|
||||
else:
|
||||
# So far we're cleared to show; now calc our ad-show-threshold and
|
||||
# see if we should *actually* show (we reach our threshold faster
|
||||
# the longer we've been playing).
|
||||
base = 'ads' if _ba.has_video_ads() else 'ads2'
|
||||
min_lc = _ba.get_account_misc_read_val(base + '.minLC', 0.0)
|
||||
max_lc = _ba.get_account_misc_read_val(base + '.maxLC', 5.0)
|
||||
min_lc_scale = (_ba.get_account_misc_read_val(
|
||||
base + '.minLCScale', 0.25))
|
||||
max_lc_scale = (_ba.get_account_misc_read_val(
|
||||
base + '.maxLCScale', 0.34))
|
||||
min_lc_interval = (_ba.get_account_misc_read_val(
|
||||
base + '.minLCInterval', 360))
|
||||
max_lc_interval = (_ba.get_account_misc_read_val(
|
||||
base + '.maxLCInterval', 300))
|
||||
if launch_count < min_lc:
|
||||
lc_amt = 0.0
|
||||
elif launch_count > max_lc:
|
||||
lc_amt = 1.0
|
||||
else:
|
||||
lc_amt = ((float(launch_count) - min_lc) / (max_lc - min_lc))
|
||||
incr = (1.0 - lc_amt) * min_lc_scale + lc_amt * max_lc_scale
|
||||
interval = ((1.0 - lc_amt) * min_lc_interval +
|
||||
lc_amt * max_lc_interval)
|
||||
app.ad_amt += incr
|
||||
assert app.ad_amt is not None
|
||||
if app.ad_amt >= 1.0:
|
||||
app.ad_amt = app.ad_amt % 1.0
|
||||
app.attempted_first_ad = True
|
||||
|
||||
# After we've reached the traditional show-threshold once,
|
||||
# try again whenever its been INTERVAL since our last successful show.
|
||||
elif (app.attempted_first_ad
|
||||
and (app.last_ad_completion_time is None or
|
||||
(interval is not None
|
||||
and _ba.time(TimeType.REAL) - app.last_ad_completion_time >
|
||||
(interval * interval_mult)))):
|
||||
# Reset our other counter too in this case.
|
||||
app.ad_amt = 0.0
|
||||
else:
|
||||
show = False
|
||||
|
||||
# If we're *still* cleared to show, actually tell the system to show.
|
||||
if show:
|
||||
# As a safety-check, set up an object that will run
|
||||
# the completion callback if we've returned and sat for 10 seconds
|
||||
# (in case some random ad network doesn't properly deliver its
|
||||
# completion callback).
|
||||
class _Payload:
|
||||
|
||||
def __init__(self, pcall: Callable[[], Any]):
|
||||
self._call = pcall
|
||||
self._ran = False
|
||||
|
||||
def run(self, fallback: bool = False) -> None:
|
||||
"""Run the fallback call (and issues a warning about it)."""
|
||||
if not self._ran:
|
||||
if fallback:
|
||||
print((
|
||||
'ERROR: relying on fallback ad-callback! '
|
||||
'last network: ' + app.last_ad_network + ' (set ' +
|
||||
str(int(time.time() -
|
||||
app.last_ad_network_set_time)) +
|
||||
's ago); purpose=' + app.last_ad_purpose))
|
||||
_ba.pushcall(self._call)
|
||||
self._ran = True
|
||||
|
||||
payload = _Payload(call)
|
||||
with _ba.Context('ui'):
|
||||
_ba.timer(5.0,
|
||||
lambda: payload.run(fallback=True),
|
||||
timetype=TimeType.REAL)
|
||||
show_ad('between_game', on_completion_call=payload.run)
|
||||
else:
|
||||
_ba.pushcall(call) # Just run the callback without the ad.
|
||||
165
assets/src/data/scripts/ba/_benchmark.py
Normal file
165
assets/src/data/scripts/ba/_benchmark.py
Normal file
@ -0,0 +1,165 @@
|
||||
"""Benchmark/Stress-Test related functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Dict, Any, Sequence
|
||||
import ba
|
||||
|
||||
|
||||
def run_cpu_benchmark() -> None:
|
||||
"""Run a cpu benchmark."""
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd import tutorial
|
||||
from ba._session import Session
|
||||
|
||||
class BenchmarkSession(Session):
|
||||
"""Session type for cpu benchmark."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
print('FIXME: BENCHMARK SESSION WOULD CALC DEPS.')
|
||||
depsets: Sequence[ba.DepSet] = []
|
||||
|
||||
super().__init__(depsets)
|
||||
|
||||
# Store old graphics settings.
|
||||
self._old_quality = _ba.app.config.resolve('Graphics Quality')
|
||||
cfg = _ba.app.config
|
||||
cfg['Graphics Quality'] = "Low"
|
||||
cfg.apply()
|
||||
self.benchmark_type = 'cpu'
|
||||
self.set_activity(_ba.new_activity(tutorial.TutorialActivity))
|
||||
|
||||
def __del__(self) -> None:
|
||||
|
||||
# When we're torn down, restore old graphics settings.
|
||||
cfg = _ba.app.config
|
||||
cfg['Graphics Quality'] = self._old_quality
|
||||
cfg.apply()
|
||||
|
||||
def on_player_request(self, player: ba.Player) -> bool:
|
||||
return False
|
||||
|
||||
_ba.new_host_session(BenchmarkSession, benchmark_type='cpu')
|
||||
|
||||
|
||||
def run_stress_test(playlist_type: str = 'Random',
|
||||
playlist_name: str = '__default__',
|
||||
player_count: int = 8,
|
||||
round_duration: int = 30) -> None:
|
||||
"""Run a stress test."""
|
||||
from ba import _modutils
|
||||
from ba._general import Call
|
||||
from ba._enums import TimeType
|
||||
_ba.screenmessage(
|
||||
'Beginning stress test.. use '
|
||||
'\'End Game\' to stop testing.',
|
||||
color=(1, 1, 0))
|
||||
with _ba.Context('ui'):
|
||||
start_stress_test({
|
||||
'playlist_type': playlist_type,
|
||||
'playlist_name': playlist_name,
|
||||
'player_count': player_count,
|
||||
'round_duration': round_duration
|
||||
})
|
||||
_ba.timer(7.0,
|
||||
Call(_ba.screenmessage,
|
||||
('stats will be written to ' +
|
||||
_modutils.get_human_readable_user_scripts_path() +
|
||||
'/stressTestStats.csv')),
|
||||
timetype=TimeType.REAL)
|
||||
|
||||
|
||||
def stop_stress_test() -> None:
|
||||
"""End a running stress test."""
|
||||
_ba.set_stress_testing(False, 0)
|
||||
try:
|
||||
if _ba.app.stress_test_reset_timer is not None:
|
||||
_ba.screenmessage("Ending stress test...", color=(1, 1, 0))
|
||||
except Exception:
|
||||
pass
|
||||
_ba.app.stress_test_reset_timer = None
|
||||
|
||||
|
||||
def start_stress_test(args: Dict[str, Any]) -> None:
|
||||
"""(internal)"""
|
||||
from ba._general import Call
|
||||
from ba._teamssession import TeamsSession
|
||||
from ba._freeforallsession import FreeForAllSession
|
||||
from ba._enums import TimeType, TimeFormat
|
||||
bs_config = _ba.app.config
|
||||
playlist_type = args['playlist_type']
|
||||
if playlist_type == 'Random':
|
||||
if random.random() < 0.5:
|
||||
playlist_type = 'Teams'
|
||||
else:
|
||||
playlist_type = 'Free-For-All'
|
||||
_ba.screenmessage('Running Stress Test (listType="' + playlist_type +
|
||||
'", listName="' + args['playlist_name'] + '")...')
|
||||
if playlist_type == 'Teams':
|
||||
bs_config['Team Tournament Playlist Selection'] = args['playlist_name']
|
||||
bs_config['Team Tournament Playlist Randomize'] = 1
|
||||
_ba.timer(1.0,
|
||||
Call(_ba.pushcall, Call(_ba.new_host_session, TeamsSession)),
|
||||
timetype=TimeType.REAL)
|
||||
else:
|
||||
bs_config['Free-for-All Playlist Selection'] = args['playlist_name']
|
||||
bs_config['Free-for-All Playlist Randomize'] = 1
|
||||
_ba.timer(1.0,
|
||||
Call(_ba.pushcall,
|
||||
Call(_ba.new_host_session, FreeForAllSession)),
|
||||
timetype=TimeType.REAL)
|
||||
_ba.set_stress_testing(True, args['player_count'])
|
||||
_ba.app.stress_test_reset_timer = _ba.Timer(
|
||||
args['round_duration'] * 1000,
|
||||
Call(_reset_stress_test, args),
|
||||
timetype=TimeType.REAL,
|
||||
timeformat=TimeFormat.MILLISECONDS)
|
||||
|
||||
|
||||
def _reset_stress_test(args: Dict[str, Any]) -> None:
|
||||
from ba._general import Call
|
||||
from ba._enums import TimeType
|
||||
_ba.set_stress_testing(False, args['player_count'])
|
||||
_ba.screenmessage('Resetting stress test...')
|
||||
_ba.get_foreground_host_session().end()
|
||||
_ba.timer(1.0, Call(start_stress_test, args), timetype=TimeType.REAL)
|
||||
|
||||
|
||||
def run_gpu_benchmark() -> None:
|
||||
"""Kick off a benchmark to test gpu speeds."""
|
||||
_ba.screenmessage("FIXME: Not wired up yet.", color=(1, 0, 0))
|
||||
|
||||
|
||||
def run_media_reload_benchmark() -> None:
|
||||
"""Kick off a benchmark to test media reloading speeds."""
|
||||
from ba._general import Call
|
||||
from ba._enums import TimeType
|
||||
_ba.reload_media()
|
||||
_ba.show_progress_bar()
|
||||
|
||||
def delay_add(start_time: float) -> None:
|
||||
|
||||
def doit(start_time_2: float) -> None:
|
||||
from ba import _lang
|
||||
_ba.screenmessage(
|
||||
_lang.get_resource('debugWindow.totalReloadTimeText').replace(
|
||||
'${TIME}', str(_ba.time(TimeType.REAL) - start_time_2)))
|
||||
_ba.print_load_info()
|
||||
if _ba.app.config.resolve("Texture Quality") != 'High':
|
||||
_ba.screenmessage(_lang.get_resource(
|
||||
'debugWindow.reloadBenchmarkBestResultsText'),
|
||||
color=(1, 1, 0))
|
||||
|
||||
_ba.add_clean_frame_callback(Call(doit, start_time))
|
||||
|
||||
# The reload starts (should add a completion callback to the
|
||||
# reload func to fix this).
|
||||
_ba.timer(0.05,
|
||||
Call(delay_add, _ba.time(TimeType.REAL)),
|
||||
timetype=TimeType.REAL)
|
||||
340
assets/src/data/scripts/ba/_campaign.py
Normal file
340
assets/src/data/scripts/ba/_campaign.py
Normal file
@ -0,0 +1,340 @@
|
||||
"""Functionality related to co-op campaigns."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, List, Dict
|
||||
import ba
|
||||
|
||||
|
||||
def register_campaign(campaign: ba.Campaign) -> None:
|
||||
"""Register a new campaign."""
|
||||
_ba.app.campaigns[campaign.name] = campaign
|
||||
|
||||
|
||||
def get_campaign(name: str) -> ba.Campaign:
|
||||
"""Return a campaign by name."""
|
||||
return _ba.app.campaigns[name]
|
||||
|
||||
|
||||
class Campaign:
|
||||
"""Represents a unique set or series of ba.Levels."""
|
||||
|
||||
def __init__(self, name: str, sequential: bool = True):
|
||||
self._name = name
|
||||
self._levels: List[ba.Level] = []
|
||||
self._sequential = sequential
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""The name of the Campaign."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def sequential(self) -> bool:
|
||||
"""Whether this Campaign's levels must be played in sequence."""
|
||||
return self._sequential
|
||||
|
||||
def add_level(self, level: ba.Level) -> None:
|
||||
"""Adds a ba.Level to the Campaign."""
|
||||
if level.get_campaign() is not None:
|
||||
raise Exception("level already belongs to a campaign")
|
||||
level.set_campaign(self, len(self._levels))
|
||||
self._levels.append(level)
|
||||
|
||||
def get_levels(self) -> List[ba.Level]:
|
||||
"""Return the set of ba.Levels in the Campaign."""
|
||||
return self._levels
|
||||
|
||||
def get_level(self, name: str) -> ba.Level:
|
||||
"""Return a contained ba.Level by name."""
|
||||
for level in self._levels:
|
||||
if level.name == name:
|
||||
return level
|
||||
raise Exception("Level '" + name + "' not found in campaign '" +
|
||||
self.name + "'")
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset state for the Campaign."""
|
||||
_ba.app.config.setdefault('Campaigns', {})[self._name] = {}
|
||||
|
||||
# FIXME should these give/take ba.Level instances instead of level names?..
|
||||
def set_selected_level(self, levelname: str) -> None:
|
||||
"""Set the Level currently selected in the UI (by name)."""
|
||||
self.get_config_dict()['Selection'] = levelname
|
||||
_ba.app.config.commit()
|
||||
|
||||
def get_selected_level(self) -> str:
|
||||
"""Return the name of the Level currently selected in the UI."""
|
||||
return self.get_config_dict().get('Selection', self._levels[0].name)
|
||||
|
||||
def get_config_dict(self) -> Dict[str, Any]:
|
||||
"""Return the live config dict for this campaign."""
|
||||
val: Dict[str, Any] = (_ba.app.config.setdefault('Campaigns',
|
||||
{}).setdefault(
|
||||
self._name, {}))
|
||||
assert isinstance(val, dict)
|
||||
return val
|
||||
|
||||
|
||||
def init_campaigns() -> None:
|
||||
"""Fill out initial default Campaigns."""
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _level
|
||||
from bastd.game.onslaught import OnslaughtGame
|
||||
from bastd.game.football import FootballCoopGame
|
||||
from bastd.game.runaround import RunaroundGame
|
||||
from bastd.game.thelaststand import TheLastStandGame
|
||||
from bastd.game.race import RaceGame
|
||||
from bastd.game.targetpractice import TargetPracticeGame
|
||||
from bastd.game.meteorshower import MeteorShowerGame
|
||||
from bastd.game.easteregghunt import EasterEggHuntGame
|
||||
from bastd.game.ninjafight import NinjaFightGame
|
||||
|
||||
# FIXME: Once translations catch up, we can convert these to use the
|
||||
# generic display-name '${GAME} Training' type stuff.
|
||||
campaign = Campaign('Easy')
|
||||
campaign.add_level(
|
||||
_level.Level('Onslaught Training',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'training_easy'},
|
||||
preview_texture_name='doomShroomPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Rookie Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'rookie_easy'},
|
||||
preview_texture_name='courtyardPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Rookie Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'rookie_easy'},
|
||||
preview_texture_name='footballStadiumPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Pro Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'pro_easy'},
|
||||
preview_texture_name='doomShroomPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Pro Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'pro_easy'},
|
||||
preview_texture_name='footballStadiumPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Pro Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'pro_easy'},
|
||||
preview_texture_name='towerDPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Uber Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'uber_easy'},
|
||||
preview_texture_name='courtyardPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Uber Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'uber_easy'},
|
||||
preview_texture_name='footballStadiumPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Uber Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'uber_easy'},
|
||||
preview_texture_name='towerDPreview'))
|
||||
register_campaign(campaign)
|
||||
|
||||
# "hard" mode
|
||||
campaign = Campaign('Default')
|
||||
campaign.add_level(
|
||||
_level.Level('Onslaught Training',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'training'},
|
||||
preview_texture_name='doomShroomPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Rookie Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'rookie'},
|
||||
preview_texture_name='courtyardPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Rookie Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'rookie'},
|
||||
preview_texture_name='footballStadiumPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Pro Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'pro'},
|
||||
preview_texture_name='doomShroomPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Pro Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'pro'},
|
||||
preview_texture_name='footballStadiumPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Pro Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'pro'},
|
||||
preview_texture_name='towerDPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Uber Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'uber'},
|
||||
preview_texture_name='courtyardPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Uber Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'uber'},
|
||||
preview_texture_name='footballStadiumPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Uber Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'uber'},
|
||||
preview_texture_name='towerDPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('The Last Stand',
|
||||
gametype=TheLastStandGame,
|
||||
settings={},
|
||||
preview_texture_name='rampagePreview'))
|
||||
register_campaign(campaign)
|
||||
|
||||
# challenges: our 'official' random extra co-op levels
|
||||
campaign = Campaign('Challenges', sequential=False)
|
||||
campaign.add_level(
|
||||
_level.Level('Infinite Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'endless'},
|
||||
preview_texture_name='doomShroomPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Infinite Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'endless'},
|
||||
preview_texture_name='towerDPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Race',
|
||||
displayname='${GAME}',
|
||||
gametype=RaceGame,
|
||||
settings={
|
||||
'map': 'Big G',
|
||||
'Laps': 3,
|
||||
'Bomb Spawning': 0
|
||||
},
|
||||
preview_texture_name='bigGPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Pro Race',
|
||||
displayname='Pro ${GAME}',
|
||||
gametype=RaceGame,
|
||||
settings={
|
||||
'map': 'Big G',
|
||||
'Laps': 3,
|
||||
'Bomb Spawning': 1000
|
||||
},
|
||||
preview_texture_name='bigGPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Lake Frigid Race',
|
||||
displayname='${GAME}',
|
||||
gametype=RaceGame,
|
||||
settings={
|
||||
'map': 'Lake Frigid',
|
||||
'Laps': 6,
|
||||
'Mine Spawning': 2000,
|
||||
'Bomb Spawning': 0
|
||||
},
|
||||
preview_texture_name='lakeFrigidPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Football',
|
||||
displayname='${GAME}',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'tournament'},
|
||||
preview_texture_name='footballStadiumPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Pro Football',
|
||||
displayname='Pro ${GAME}',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'tournament_pro'},
|
||||
preview_texture_name='footballStadiumPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Runaround',
|
||||
displayname='${GAME}',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'tournament'},
|
||||
preview_texture_name='towerDPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Uber Runaround',
|
||||
displayname='Uber ${GAME}',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'tournament_uber'},
|
||||
preview_texture_name='towerDPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('The Last Stand',
|
||||
displayname='${GAME}',
|
||||
gametype=TheLastStandGame,
|
||||
settings={'preset': 'tournament'},
|
||||
preview_texture_name='rampagePreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Tournament Infinite Onslaught',
|
||||
displayname='Infinite Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'endless_tournament'},
|
||||
preview_texture_name='doomShroomPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Tournament Infinite Runaround',
|
||||
displayname='Infinite Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'endless_tournament'},
|
||||
preview_texture_name='towerDPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Target Practice',
|
||||
displayname='Pro ${GAME}',
|
||||
gametype=TargetPracticeGame,
|
||||
settings={},
|
||||
preview_texture_name='doomShroomPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Target Practice B',
|
||||
displayname='${GAME}',
|
||||
gametype=TargetPracticeGame,
|
||||
settings={
|
||||
'Target Count': 2,
|
||||
'Enable Impact Bombs': False,
|
||||
'Enable Triple Bombs': False
|
||||
},
|
||||
preview_texture_name='doomShroomPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Meteor Shower',
|
||||
displayname='${GAME}',
|
||||
gametype=MeteorShowerGame,
|
||||
settings={},
|
||||
preview_texture_name='rampagePreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Epic Meteor Shower',
|
||||
displayname='${GAME}',
|
||||
gametype=MeteorShowerGame,
|
||||
settings={'Epic Mode': True},
|
||||
preview_texture_name='rampagePreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Easter Egg Hunt',
|
||||
displayname='${GAME}',
|
||||
gametype=EasterEggHuntGame,
|
||||
settings={},
|
||||
preview_texture_name='towerDPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level('Pro Easter Egg Hunt',
|
||||
displayname='Pro ${GAME}',
|
||||
gametype=EasterEggHuntGame,
|
||||
settings={'Pro Mode': True},
|
||||
preview_texture_name='towerDPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level(
|
||||
name='Ninja Fight', # (unique id not seen by player)
|
||||
displayname='${GAME}', # (readable name seen by player)
|
||||
gametype=NinjaFightGame,
|
||||
settings={'preset': 'regular'},
|
||||
preview_texture_name='courtyardPreview'))
|
||||
campaign.add_level(
|
||||
_level.Level(name='Pro Ninja Fight',
|
||||
displayname='Pro ${GAME}',
|
||||
gametype=NinjaFightGame,
|
||||
settings={'preset': 'pro'},
|
||||
preview_texture_name='courtyardPreview'))
|
||||
register_campaign(campaign)
|
||||
270
assets/src/data/scripts/ba/_coopgame.py
Normal file
270
assets/src/data/scripts/ba/_coopgame.py
Normal file
@ -0,0 +1,270 @@
|
||||
"""Functionality related to co-op games."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._gameactivity import GameActivity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Type, Dict, Any, Set, List, Sequence, Optional
|
||||
from bastd.actor.playerspaz import PlayerSpaz
|
||||
import ba
|
||||
|
||||
|
||||
class CoopGameActivity(GameActivity):
|
||||
"""Base class for cooperative-mode games.
|
||||
|
||||
Category: Gameplay Classes
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool:
|
||||
from ba import _coopsession
|
||||
return issubclass(sessiontype, _coopsession.CoopSession)
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings)
|
||||
|
||||
# Cache these for efficiency.
|
||||
self._achievements_awarded: Set[str] = set()
|
||||
|
||||
self._life_warning_beep: Optional[ba.Actor] = None
|
||||
self._life_warning_beep_timer: Optional[ba.Timer] = None
|
||||
self._warn_beeps_sound = _ba.getsound('warnBeeps')
|
||||
|
||||
def on_begin(self) -> None:
|
||||
from ba import _general
|
||||
super().on_begin()
|
||||
|
||||
# Show achievements remaining.
|
||||
if not _ba.app.kiosk_mode:
|
||||
_ba.timer(3.8,
|
||||
_general.WeakCall(self._show_remaining_achievements))
|
||||
|
||||
# Preload achievement images in case we get some.
|
||||
_ba.timer(2.0, _general.WeakCall(self._preload_achievements))
|
||||
|
||||
# Let's ask the server for a 'time-to-beat' value.
|
||||
levelname = self._get_coop_level_name()
|
||||
campaign = self.session.campaign
|
||||
assert campaign is not None
|
||||
config_str = (str(len(self.players)) + "p" + campaign.get_level(
|
||||
self.settings['name']).get_score_version_string().replace(
|
||||
' ', '_'))
|
||||
_ba.get_scores_to_beat(levelname, config_str,
|
||||
_general.WeakCall(self._on_got_scores_to_beat))
|
||||
|
||||
def _on_got_scores_to_beat(self, scores: List[Dict[str, Any]]) -> None:
|
||||
pass
|
||||
|
||||
def _show_standard_scores_to_beat_ui(self,
|
||||
scores: List[Dict[str, Any]]) -> None:
|
||||
from ba import _gameutils
|
||||
from ba import _actor
|
||||
from ba._enums import TimeFormat
|
||||
display_type = self.get_score_type()
|
||||
if scores is not None:
|
||||
|
||||
# Sort by originating date so that the most recent is first.
|
||||
scores.sort(reverse=True, key=lambda s: s['time'])
|
||||
|
||||
# Now make a display for the most recent challenge.
|
||||
for score in scores:
|
||||
if score['type'] == 'score_challenge':
|
||||
tval = (
|
||||
score['player'] + ': ' +
|
||||
(_gameutils.timestring(
|
||||
int(score['value']) * 10,
|
||||
timeformat=TimeFormat.MILLISECONDS).evaluate()
|
||||
if display_type == 'time' else str(score['value'])))
|
||||
hattach = 'center' if display_type == 'time' else 'left'
|
||||
halign = 'center' if display_type == 'time' else 'left'
|
||||
pos = (20, -70) if display_type == 'time' else (20, -130)
|
||||
txt = _actor.Actor(
|
||||
_ba.newnode('text',
|
||||
attrs={
|
||||
'v_attach': 'top',
|
||||
'h_attach': hattach,
|
||||
'h_align': halign,
|
||||
'color': (0.7, 0.4, 1, 1),
|
||||
'shadow': 0.5,
|
||||
'flatness': 1.0,
|
||||
'position': pos,
|
||||
'scale': 0.6,
|
||||
'text': tval
|
||||
})).autoretain()
|
||||
assert txt.node is not None
|
||||
_gameutils.animate(txt.node, 'scale', {
|
||||
1.0: 0.0,
|
||||
1.1: 0.7,
|
||||
1.2: 0.6
|
||||
})
|
||||
break
|
||||
|
||||
# FIXME: this is now redundant with activityutils.get_score_info();
|
||||
# need to kill this.
|
||||
def get_score_type(self) -> str:
|
||||
"""
|
||||
Return the score unit this co-op game uses ('point', 'seconds', etc.)
|
||||
"""
|
||||
return 'points'
|
||||
|
||||
def _get_coop_level_name(self) -> str:
|
||||
assert self.session.campaign is not None
|
||||
return self.session.campaign.name + ":" + str(self.settings['name'])
|
||||
|
||||
def celebrate(self, duration: float) -> None:
|
||||
"""Tells all existing player-controlled characters to celebrate.
|
||||
|
||||
Can be useful in co-op games when the good guys score or complete
|
||||
a wave.
|
||||
duration is given in seconds.
|
||||
"""
|
||||
for player in self.players:
|
||||
if player.actor is not None and player.actor.node:
|
||||
player.actor.node.handlemessage('celebrate',
|
||||
int(duration * 1000))
|
||||
|
||||
def _preload_achievements(self) -> None:
|
||||
from ba import _achievement
|
||||
achievements = _achievement.get_achievements_for_coop_level(
|
||||
self._get_coop_level_name())
|
||||
for ach in achievements:
|
||||
ach.get_icon_texture(True)
|
||||
|
||||
def _show_remaining_achievements(self) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _achievement
|
||||
from ba import _lang
|
||||
from bastd.actor.text import Text
|
||||
ts_h_offs = 30
|
||||
v_offs = -200
|
||||
achievements = [
|
||||
a for a in _achievement.get_achievements_for_coop_level(
|
||||
self._get_coop_level_name()) if not a.complete
|
||||
]
|
||||
vrmode = _ba.app.vr_mode
|
||||
if achievements:
|
||||
Text(_lang.Lstr(resource='achievementsRemainingText'),
|
||||
host_only=True,
|
||||
position=(ts_h_offs - 10 + 40, v_offs - 10),
|
||||
transition='fade_in',
|
||||
scale=1.1,
|
||||
h_attach="left",
|
||||
v_attach="top",
|
||||
color=(1, 1, 1.2, 1) if vrmode else (0.8, 0.8, 1.0, 1.0),
|
||||
flatness=1.0 if vrmode else 0.6,
|
||||
shadow=1.0 if vrmode else 0.5,
|
||||
transition_delay=0.0,
|
||||
transition_out_delay=1.3
|
||||
if self.slow_motion else 4000).autoretain()
|
||||
hval = 70
|
||||
vval = -50
|
||||
tdelay = 0
|
||||
for ach in achievements:
|
||||
tdelay += 50
|
||||
ach.create_display(hval + 40,
|
||||
vval + v_offs,
|
||||
0 + tdelay,
|
||||
outdelay=1300 if self.slow_motion else 4000,
|
||||
style='in_game')
|
||||
vval -= 55
|
||||
|
||||
def spawn_player_spaz(self,
|
||||
player: ba.Player,
|
||||
position: Sequence[float] = (0.0, 0.0, 0.0),
|
||||
angle: float = None) -> PlayerSpaz:
|
||||
"""Spawn and wire up a standard player spaz."""
|
||||
spaz = super().spawn_player_spaz(player, position, angle)
|
||||
|
||||
# Deaths are noteworthy in co-op games.
|
||||
spaz.play_big_death_sound = True
|
||||
return spaz
|
||||
|
||||
def _award_achievement(self, achievement_name: str,
|
||||
sound: bool = True) -> None:
|
||||
"""Award an achievement.
|
||||
|
||||
Returns True if a banner will be shown;
|
||||
False otherwise
|
||||
"""
|
||||
from ba import _achievement
|
||||
|
||||
if achievement_name in self._achievements_awarded:
|
||||
return
|
||||
|
||||
ach = _achievement.get_achievement(achievement_name)
|
||||
|
||||
# If we're in the easy campaign and this achievement is hard-mode-only,
|
||||
# ignore it.
|
||||
try:
|
||||
campaign = self.session.campaign
|
||||
assert campaign is not None
|
||||
if ach.hard_mode_only and campaign.name == 'Easy':
|
||||
return
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception()
|
||||
|
||||
# If we haven't awarded this one, check to see if we've got it.
|
||||
# If not, set it through the game service *and* add a transaction
|
||||
# for it.
|
||||
if not ach.complete:
|
||||
self._achievements_awarded.add(achievement_name)
|
||||
|
||||
# Report new achievements to the game-service.
|
||||
_ba.report_achievement(achievement_name)
|
||||
|
||||
# ...and to our account.
|
||||
_ba.add_transaction({
|
||||
'type': 'ACHIEVEMENT',
|
||||
'name': achievement_name
|
||||
})
|
||||
|
||||
# Now bring up a celebration banner.
|
||||
ach.announce_completion(sound=sound)
|
||||
|
||||
def fade_to_red(self) -> None:
|
||||
"""Fade the screen to red; (such as when the good guys have lost)."""
|
||||
from ba import _gameutils
|
||||
c_existing = _gameutils.sharedobj('globals').tint
|
||||
cnode = _ba.newnode("combine",
|
||||
attrs={
|
||||
'input0': c_existing[0],
|
||||
'input1': c_existing[1],
|
||||
'input2': c_existing[2],
|
||||
'size': 3
|
||||
})
|
||||
_gameutils.animate(cnode, 'input1', {0: c_existing[1], 2.0: 0})
|
||||
_gameutils.animate(cnode, 'input2', {0: c_existing[2], 2.0: 0})
|
||||
cnode.connectattr('output', _gameutils.sharedobj('globals'), 'tint')
|
||||
|
||||
def setup_low_life_warning_sound(self) -> None:
|
||||
"""Set up a beeping noise to play when any players are near death."""
|
||||
from ba import _general
|
||||
self._life_warning_beep = None
|
||||
self._life_warning_beep_timer = _ba.Timer(
|
||||
1.0, _general.WeakCall(self._update_life_warning), repeat=True)
|
||||
|
||||
def _update_life_warning(self) -> None:
|
||||
# Beep continuously if anyone is close to death.
|
||||
should_beep = False
|
||||
for player in self.players:
|
||||
if player.is_alive():
|
||||
# FIXME: Should abstract this instead of
|
||||
# reading hitpoints directly.
|
||||
if getattr(player.actor, 'hitpoints', 999) < 200:
|
||||
should_beep = True
|
||||
break
|
||||
if should_beep and self._life_warning_beep is None:
|
||||
from ba import _actor
|
||||
self._life_warning_beep = _actor.Actor(
|
||||
_ba.newnode('sound',
|
||||
attrs={
|
||||
'sound': self._warn_beeps_sound,
|
||||
'positional': False,
|
||||
'loop': True
|
||||
}))
|
||||
if self._life_warning_beep is not None and not should_beep:
|
||||
self._life_warning_beep = None
|
||||
382
assets/src/data/scripts/ba/_coopsession.py
Normal file
382
assets/src/data/scripts/ba/_coopsession.py
Normal file
@ -0,0 +1,382 @@
|
||||
"""Functionality related to coop-mode sessions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._session import Session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, List, Dict, Optional, Callable, Sequence
|
||||
import ba
|
||||
|
||||
TEAM_COLORS = ((0.2, 0.4, 1.6), )
|
||||
TEAM_NAMES = ("Good Guys", )
|
||||
|
||||
|
||||
class CoopSession(Session):
|
||||
"""A ba.Session which runs cooperative-mode games.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
These generally consist of 1-4 players against
|
||||
the computer and include functionality such as
|
||||
high score lists.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Instantiate a co-op mode session."""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._campaign import get_campaign
|
||||
from bastd.activity.coopjoinscreen import CoopJoiningActivity
|
||||
|
||||
_ba.increment_analytics_count('Co-op session start')
|
||||
|
||||
app = _ba.app
|
||||
|
||||
# If they passed in explicit min/max, honor that.
|
||||
# Otherwise defer to user overrides or defaults.
|
||||
if 'min_players' in app.coop_session_args:
|
||||
min_players = app.coop_session_args['min_players']
|
||||
else:
|
||||
min_players = 1
|
||||
if 'max_players' in app.coop_session_args:
|
||||
max_players = app.coop_session_args['max_players']
|
||||
else:
|
||||
try:
|
||||
max_players = app.config['Coop Game Max Players']
|
||||
except Exception:
|
||||
# Old pref value.
|
||||
try:
|
||||
max_players = app.config['Challenge Game Max Players']
|
||||
except Exception:
|
||||
max_players = 4
|
||||
|
||||
print('FIXME: COOP SESSION WOULD CALC DEPS.')
|
||||
depsets: Sequence[ba.DepSet] = []
|
||||
|
||||
super().__init__(depsets,
|
||||
team_names=TEAM_NAMES,
|
||||
team_colors=TEAM_COLORS,
|
||||
use_team_colors=False,
|
||||
min_players=min_players,
|
||||
max_players=max_players,
|
||||
allow_mid_activity_joins=False)
|
||||
|
||||
# Tournament-ID if we correspond to a co-op tournament (otherwise None)
|
||||
self.tournament_id = (app.coop_session_args['tournament_id']
|
||||
if 'tournament_id' in app.coop_session_args else
|
||||
None)
|
||||
|
||||
# FIXME: Could be nice to pass this in as actual args.
|
||||
self.campaign_state = {
|
||||
'campaign': (app.coop_session_args['campaign']),
|
||||
'level': app.coop_session_args['level']
|
||||
}
|
||||
self.campaign = get_campaign(self.campaign_state['campaign'])
|
||||
|
||||
self._ran_tutorial_activity = False
|
||||
self._tutorial_activity: Optional[ba.Activity] = None
|
||||
self._custom_menu_ui: List[Dict[str, Any]] = []
|
||||
|
||||
# Start our joining screen.
|
||||
self.set_activity(_ba.new_activity(CoopJoiningActivity))
|
||||
|
||||
self._next_game_instance: Optional[ba.GameActivity] = None
|
||||
self._next_game_name: Optional[str] = None
|
||||
self._update_on_deck_game_instances()
|
||||
|
||||
def get_current_game_instance(self) -> ba.GameActivity:
|
||||
"""Get the game instance currently being played."""
|
||||
return self._current_game_instance
|
||||
|
||||
def _update_on_deck_game_instances(self) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._gameactivity import GameActivity
|
||||
|
||||
# Instantiates levels we might be running soon
|
||||
# so they have time to load.
|
||||
|
||||
# Build an instance for the current level.
|
||||
assert self.campaign is not None
|
||||
level = self.campaign.get_level(self.campaign_state['level'])
|
||||
gametype = level.gametype
|
||||
settings = level.get_settings()
|
||||
|
||||
# Make sure all settings the game expects are present.
|
||||
neededsettings = gametype.get_settings(type(self))
|
||||
for settingname, setting in neededsettings:
|
||||
if settingname not in settings:
|
||||
settings[settingname] = setting['default']
|
||||
|
||||
newactivity = _ba.new_activity(gametype, settings)
|
||||
assert isinstance(newactivity, GameActivity)
|
||||
self._current_game_instance: GameActivity = newactivity
|
||||
|
||||
# Find the next level and build an instance for it too.
|
||||
levels = self.campaign.get_levels()
|
||||
level = self.campaign.get_level(self.campaign_state['level'])
|
||||
|
||||
nextlevel: Optional[ba.Level]
|
||||
if level.index < len(levels) - 1:
|
||||
nextlevel = levels[level.index + 1]
|
||||
else:
|
||||
nextlevel = None
|
||||
if nextlevel:
|
||||
gametype = nextlevel.gametype
|
||||
settings = nextlevel.get_settings()
|
||||
|
||||
# Make sure all settings the game expects are present.
|
||||
neededsettings = gametype.get_settings(type(self))
|
||||
for settingname, setting in neededsettings:
|
||||
if settingname not in settings:
|
||||
settings[settingname] = setting['default']
|
||||
|
||||
# We wanna be in the activity's context while taking it down.
|
||||
newactivity = _ba.new_activity(gametype, settings)
|
||||
assert isinstance(newactivity, GameActivity)
|
||||
self._next_game_instance = newactivity
|
||||
self._next_game_name = nextlevel.name
|
||||
else:
|
||||
self._next_game_instance = None
|
||||
self._next_game_name = None
|
||||
|
||||
# Special case:
|
||||
# If our current level is 'onslaught training', instantiate
|
||||
# our tutorial so its ready to go. (if we haven't run it yet).
|
||||
if (self.campaign_state['level'] == 'Onslaught Training'
|
||||
and self._tutorial_activity is None
|
||||
and not self._ran_tutorial_activity):
|
||||
from bastd.tutorial import TutorialActivity
|
||||
self._tutorial_activity = _ba.new_activity(TutorialActivity)
|
||||
|
||||
def get_custom_menu_entries(self) -> List[Dict[str, Any]]:
|
||||
return self._custom_menu_ui
|
||||
|
||||
def on_player_leave(self, player: ba.Player) -> None:
|
||||
from ba._general import WeakCall
|
||||
super().on_player_leave(player)
|
||||
|
||||
# If all our players leave we wanna quit out of the session.
|
||||
_ba.timer(2.0, WeakCall(self._end_session_if_empty))
|
||||
|
||||
def _end_session_if_empty(self) -> None:
|
||||
activity = self.getactivity()
|
||||
if activity is None:
|
||||
return # Hmm what should we do in this case?
|
||||
|
||||
# If there's still players in the current activity, we're good.
|
||||
if activity.players:
|
||||
return
|
||||
|
||||
# If there's *no* players left in the current activity but there *is*
|
||||
# in the session, restart the activity to pull them into the game
|
||||
# (or quit if they're just in the lobby).
|
||||
if activity is not None and not activity.players and self.players:
|
||||
|
||||
# Special exception for tourney games; don't auto-restart these.
|
||||
if self.tournament_id is not None:
|
||||
self.end()
|
||||
else:
|
||||
# Don't restart joining activities; this probably means there's
|
||||
# someone with a chooser up in that case.
|
||||
if not activity.is_joining_activity:
|
||||
self.restart()
|
||||
|
||||
# Hmm; no players anywhere. lets just end the session.
|
||||
else:
|
||||
self.end()
|
||||
|
||||
def _on_tournament_restart_menu_press(self,
|
||||
resume_callback: Callable[[], Any]
|
||||
) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.ui.tournamententry import TournamentEntryWindow
|
||||
from ba._gameactivity import GameActivity
|
||||
activity = self.getactivity()
|
||||
if activity is not None and not activity.is_expired():
|
||||
assert self.tournament_id is not None
|
||||
assert isinstance(activity, GameActivity)
|
||||
TournamentEntryWindow(tournament_id=self.tournament_id,
|
||||
tournament_activity=activity,
|
||||
on_close_call=resume_callback)
|
||||
|
||||
def restart(self) -> None:
|
||||
"""Restart the current game activity."""
|
||||
|
||||
# Tell the current activity to end with a 'restart' outcome.
|
||||
# We use 'force' so that we apply even if end has already been called
|
||||
# (but is in its delay period).
|
||||
|
||||
# Make an exception if there's no players left. Otherwise this
|
||||
# can override the default session end that occurs in that case.
|
||||
if not self.players:
|
||||
return
|
||||
|
||||
# This method may get called from the UI context so make sure we
|
||||
# explicitly run in the activity's context.
|
||||
activity = self.getactivity()
|
||||
if activity is not None and not activity.is_expired():
|
||||
activity.can_show_ad_on_death = True
|
||||
with _ba.Context(activity):
|
||||
activity.end(results={'outcome': 'restart'}, force=True)
|
||||
|
||||
def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
|
||||
"""Method override for co-op sessions.
|
||||
|
||||
Jumps between co-op games and score screens.
|
||||
"""
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._activitytypes import JoiningActivity, TransitionActivity
|
||||
from ba._lang import Lstr
|
||||
from ba._general import WeakCall
|
||||
from ba._coopgame import CoopGameActivity
|
||||
from ba._gameresults import TeamGameResults
|
||||
from bastd.tutorial import TutorialActivity
|
||||
from bastd.activity.coopscorescreen import CoopScoreScreen
|
||||
|
||||
app = _ba.app
|
||||
|
||||
# If we're running a TeamGameActivity we'll have a TeamGameResults
|
||||
# as results. Otherwise its an old CoopGameActivity so its giving
|
||||
# us a dict of random stuff.
|
||||
if isinstance(results, TeamGameResults):
|
||||
outcome = 'defeat' # This can't be 'beaten'.
|
||||
else:
|
||||
try:
|
||||
outcome = results['outcome']
|
||||
except Exception:
|
||||
outcome = ''
|
||||
|
||||
# If at any point we have no in-game players, quit out of the session
|
||||
# (this can happen if someone leaves in the tutorial for instance).
|
||||
active_players = [p for p in self.players if p.in_game]
|
||||
if not active_players:
|
||||
self.end()
|
||||
return
|
||||
|
||||
# If we're in a between-round activity or a restart-activity,
|
||||
# hop into a round.
|
||||
if (isinstance(
|
||||
activity,
|
||||
(JoiningActivity, CoopScoreScreen, TransitionActivity))):
|
||||
|
||||
if outcome == 'next_level':
|
||||
if self._next_game_instance is None:
|
||||
raise Exception()
|
||||
assert self._next_game_name is not None
|
||||
self.campaign_state['level'] = self._next_game_name
|
||||
next_game = self._next_game_instance
|
||||
else:
|
||||
next_game = self._current_game_instance
|
||||
|
||||
# Special case: if we're coming from a joining-activity
|
||||
# and will be going into onslaught-training, show the
|
||||
# tutorial first.
|
||||
if (isinstance(activity, JoiningActivity)
|
||||
and self.campaign_state['level'] == 'Onslaught Training'
|
||||
and not app.kiosk_mode):
|
||||
if self._tutorial_activity is None:
|
||||
raise Exception("tutorial not preloaded properly")
|
||||
self.set_activity(self._tutorial_activity)
|
||||
self._tutorial_activity = None
|
||||
self._ran_tutorial_activity = True
|
||||
self._custom_menu_ui = []
|
||||
|
||||
# Normal case; launch the next round.
|
||||
else:
|
||||
|
||||
# Reset stats for the new activity.
|
||||
self.stats.reset()
|
||||
for player in self.players:
|
||||
|
||||
# Skip players that are still choosing a team.
|
||||
if player.in_game:
|
||||
self.stats.register_player(player)
|
||||
self.stats.set_activity(next_game)
|
||||
|
||||
# Now flip the current activity.
|
||||
self.set_activity(next_game)
|
||||
|
||||
if not app.kiosk_mode:
|
||||
if self.tournament_id is not None:
|
||||
self._custom_menu_ui = [{
|
||||
'label':
|
||||
Lstr(resource='restartText'),
|
||||
'resume_on_call':
|
||||
False,
|
||||
'call':
|
||||
WeakCall(self._on_tournament_restart_menu_press
|
||||
)
|
||||
}]
|
||||
else:
|
||||
self._custom_menu_ui = [{
|
||||
'label': Lstr(resource='restartText'),
|
||||
'call': WeakCall(self.restart)
|
||||
}]
|
||||
|
||||
# If we were in a tutorial, just pop a transition to get to the
|
||||
# actual round.
|
||||
elif isinstance(activity, TutorialActivity):
|
||||
self.set_activity(_ba.new_activity(TransitionActivity))
|
||||
else:
|
||||
|
||||
# Generic team games.
|
||||
if isinstance(results, TeamGameResults):
|
||||
player_info = results.get_player_info()
|
||||
score = results.get_team_score(results.get_teams()[0])
|
||||
fail_message = None
|
||||
score_order = ('decreasing' if results.get_lower_is_better()
|
||||
else 'increasing')
|
||||
if results.get_score_type() in ('seconds', 'milliseconds',
|
||||
'time'):
|
||||
score_type = 'time'
|
||||
# Results contains milliseconds; ScoreScreen wants
|
||||
# hundredths; need to fix :-/
|
||||
if score is not None:
|
||||
score //= 10
|
||||
else:
|
||||
if results.get_score_type() != 'points':
|
||||
print(("Unknown score type: '" +
|
||||
results.get_score_type() + "'"))
|
||||
score_type = 'points'
|
||||
|
||||
# Old coop-game-specific results; should migrate away from these.
|
||||
else:
|
||||
player_info = (results['player_info']
|
||||
if 'player_info' in results else None)
|
||||
score = results['score'] if 'score' in results else None
|
||||
fail_message = (results['fail_message']
|
||||
if 'fail_message' in results else None)
|
||||
score_order = (results['score_order']
|
||||
if 'score_order' in results else 'increasing')
|
||||
activity_score_type = (activity.get_score_type() if isinstance(
|
||||
activity, CoopGameActivity) else None)
|
||||
assert activity_score_type is not None
|
||||
score_type = activity_score_type
|
||||
|
||||
# Looks like we were in a round - check the outcome and
|
||||
# go from there.
|
||||
if outcome == 'restart':
|
||||
|
||||
# This will pop up back in the same round.
|
||||
self.set_activity(_ba.new_activity(TransitionActivity))
|
||||
else:
|
||||
self.set_activity(
|
||||
_ba.new_activity(
|
||||
CoopScoreScreen, {
|
||||
'player_info': player_info,
|
||||
'score': score,
|
||||
'fail_message': fail_message,
|
||||
'score_order': score_order,
|
||||
'score_type': score_type,
|
||||
'outcome': outcome,
|
||||
'campaign': self.campaign,
|
||||
'level': self.campaign_state['level']
|
||||
}))
|
||||
|
||||
# No matter what, get the next 2 levels ready to go.
|
||||
self._update_on_deck_game_instances()
|
||||
507
assets/src/data/scripts/ba/_dep.py
Normal file
507
assets/src/data/scripts/ba/_dep.py
Normal file
@ -0,0 +1,507 @@
|
||||
"""Functionality related to object/asset dependencies."""
|
||||
# pylint: disable=redefined-builtin
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from typing import (Generic, TypeVar, TYPE_CHECKING, cast, Type, overload)
|
||||
|
||||
import _ba
|
||||
from ba import _general
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Optional, Any, Dict, List, Set
|
||||
import ba
|
||||
|
||||
T = TypeVar('T', bound='DepComponent')
|
||||
TI = TypeVar('TI', bound='InstancedDepComponent')
|
||||
TS = TypeVar('TS', bound='StaticDepComponent')
|
||||
|
||||
|
||||
class Dependency(Generic[T]):
|
||||
"""A dependency on a DepComponent (with an optional config).
|
||||
|
||||
Category: Dependency Classes
|
||||
|
||||
This class is used to request and access functionality provided
|
||||
by other DepComponent classes from a DepComponent class.
|
||||
The class functions as a descriptor, allowing dependencies to
|
||||
be added at a class level much the same as properties or methods
|
||||
and then used with class instances to access those dependencies.
|
||||
For instance, if you do 'floofcls = ba.Dependency(FloofClass)' you
|
||||
would then be able to instantiate a FloofClass in your class's
|
||||
methods via self.floofcls().
|
||||
"""
|
||||
|
||||
def __init__(self, cls: Type[T], config: Any = None):
|
||||
"""Instantiate a Dependency given a ba.DepComponent subtype.
|
||||
|
||||
Optionally, an arbitrary object can be passed as 'config' to
|
||||
influence dependency calculation for the target class.
|
||||
"""
|
||||
self.cls: Type[T] = cls
|
||||
self.config = config
|
||||
self._hash: Optional[int] = None
|
||||
|
||||
def get_hash(self) -> int:
|
||||
"""Return the dependency's hash, calculating it if necessary."""
|
||||
if self._hash is None:
|
||||
self._hash = _general.make_hash((self.cls, self.config))
|
||||
return self._hash
|
||||
|
||||
# NOTE: it appears that mypy is currently not able to do overloads based
|
||||
# on the type of 'self', otherwise we could just overload this to
|
||||
# return different things based on self's type and avoid the need for
|
||||
# the fake dep classes below.
|
||||
# See https://github.com/python/mypy/issues/5320
|
||||
# noinspection PyShadowingBuiltins
|
||||
def __get__(self, obj: Any, type: Any = None) -> Any:
|
||||
if obj is None:
|
||||
raise TypeError("Dependency must be accessed through an instance.")
|
||||
|
||||
# We expect to be instantiated from an already living DepComponent
|
||||
# with valid dep-data in place..
|
||||
assert type is not None
|
||||
depdata = getattr(obj, '_depdata')
|
||||
if depdata is None:
|
||||
raise RuntimeError("Invalid dependency access.")
|
||||
assert isinstance(depdata, DepData)
|
||||
|
||||
# Now look up the data for this particular dep
|
||||
depset = depdata.depset()
|
||||
assert isinstance(depset, DepSet)
|
||||
assert self._hash in depset.depdatas
|
||||
depdata = depset.depdatas[self._hash]
|
||||
assert isinstance(depdata, DepData)
|
||||
if depdata.valid is False:
|
||||
raise RuntimeError(
|
||||
f'Accessing DepComponent {depdata.cls} in an invalid state.')
|
||||
assert self.cls.dep_get_payload(depdata) is not None
|
||||
return self.cls.dep_get_payload(depdata)
|
||||
|
||||
|
||||
# We define a 'Dep' which at runtime simply aliases the Dependency class
|
||||
# but in type-checking points to two overloaded functions based on the argument
|
||||
# type. This lets the type system know what type of object the Dep represents.
|
||||
# (object instances in the case of StaticDep classes or object types in the
|
||||
# case of regular deps) At some point hopefully we can replace this with a
|
||||
# simple overload in Dependency.__get__ based on the type of self
|
||||
# (see note above).
|
||||
if not TYPE_CHECKING:
|
||||
Dep = Dependency
|
||||
else:
|
||||
|
||||
class _InstanceDep(Dependency[TI]):
|
||||
"""Fake stub we use to tell the type system we provide a type."""
|
||||
|
||||
# noinspection PyShadowingBuiltins
|
||||
def __get__(self, obj: Any, type: Any = None) -> Type[TI]:
|
||||
return cast(Type[TI], None)
|
||||
|
||||
class _StaticDep(Dependency[TS]):
|
||||
"""Fake stub we use to tell the type system we provide an instance."""
|
||||
|
||||
# noinspection PyShadowingBuiltins
|
||||
def __get__(self, obj: Any, type: Any = None) -> TS:
|
||||
return cast(TS, None)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
# noinspection PyPep8Naming
|
||||
@overload
|
||||
def Dep(cls: Type[TI], config: Any = None) -> _InstanceDep[TI]:
|
||||
"""test"""
|
||||
return _InstanceDep(cls, config)
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
@overload
|
||||
def Dep(cls: Type[TS], config: Any = None) -> _StaticDep[TS]:
|
||||
"""test"""
|
||||
return _StaticDep(cls, config)
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
def Dep(cls: Any, config: Any = None) -> Any:
|
||||
"""test"""
|
||||
return Dependency(cls, config)
|
||||
|
||||
# pylint: enable=invalid-name
|
||||
|
||||
|
||||
class BoundDepComponent:
|
||||
"""A DepComponent class bound to its DepSet data.
|
||||
|
||||
Can be called to instantiate the class with its data properly in place."""
|
||||
|
||||
def __init__(self, cls: Any, depdata: DepData):
|
||||
self.cls = cls
|
||||
# BoundDepComponents can be stored on depdatas so we use weakrefs
|
||||
# to avoid dependency cycles.
|
||||
self.depdata = weakref.ref(depdata)
|
||||
|
||||
def __call__(self, *args: Any, **keywds: Any) -> Any:
|
||||
# We don't simply call our target type to instantiate it;
|
||||
# instead we manually call __new__ and then __init__.
|
||||
# This allows us to inject its data properly before __init__().
|
||||
obj = self.cls.__new__(self.cls, *args, **keywds)
|
||||
obj._depdata = self.depdata()
|
||||
assert isinstance(obj._depdata, DepData)
|
||||
obj.__init__(*args, **keywds)
|
||||
return obj
|
||||
|
||||
|
||||
class DepComponent:
|
||||
"""Base class for all classes that can act as dependencies.
|
||||
|
||||
category: Dependency Classes
|
||||
"""
|
||||
|
||||
_depdata: DepData
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Instantiate a DepComponent."""
|
||||
|
||||
# For now lets issue a warning if these are instantiated without
|
||||
# data; we'll make this an error once we're no longer seeing warnings.
|
||||
depdata = getattr(self, '_depdata', None)
|
||||
if depdata is None:
|
||||
print(f'FIXME: INSTANTIATING DEP CLASS {type(self)} DIRECTLY.')
|
||||
|
||||
self.context = _ba.Context('current')
|
||||
|
||||
@classmethod
|
||||
def is_present(cls, config: Any = None) -> bool:
|
||||
"""Return whether this component/config is present on this device."""
|
||||
del config # Unused here.
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_dynamic_deps(cls, config: Any = None) -> List[Dependency]:
|
||||
"""Return any dynamically-calculated deps for this component/config.
|
||||
|
||||
Deps declared statically as part of the class do not need to be
|
||||
included here; this is only for additional deps that may vary based
|
||||
on the dep config value. (for instance a map required by a game type)
|
||||
"""
|
||||
del config # Unused here.
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def dep_get_payload(cls, depdata: DepData) -> Any:
|
||||
"""Return user-facing data for a loaded dep.
|
||||
|
||||
If this dep does not yet have a 'payload' value, it should
|
||||
be generated and cached. Otherwise the existing value
|
||||
should be returned.
|
||||
This is the value given for a DepComponent when accessed
|
||||
through a Dependency instance on a live object, etc.
|
||||
"""
|
||||
del depdata # Unused here.
|
||||
|
||||
|
||||
class DepData:
|
||||
"""Data associated with a dependency in a dependency set."""
|
||||
|
||||
def __init__(self, depset: DepSet, dep: Dependency[T]):
|
||||
# Note: identical Dep/config pairs will share data, so the dep
|
||||
# entry on a given Dep may not point to.
|
||||
self.cls = dep.cls
|
||||
self.config = dep.config
|
||||
|
||||
# Arbitrary data for use by dependencies in the resolved set
|
||||
# (the static instance for static-deps, etc).
|
||||
self.payload: Any = None
|
||||
self.valid: bool = False
|
||||
|
||||
# Weakref to the depset that includes us (to avoid ref loop).
|
||||
self.depset = weakref.ref(depset)
|
||||
|
||||
|
||||
class DepSet(Generic[TI]):
|
||||
"""Set of resolved dependencies and their associated data."""
|
||||
|
||||
def __init__(self, root: Dependency[TI]):
|
||||
self.root = root
|
||||
self._resolved = False
|
||||
|
||||
# Dependency data indexed by hash.
|
||||
self.depdatas: Dict[int, DepData] = {}
|
||||
|
||||
# Instantiated static-components.
|
||||
self.static_instances: List[StaticDepComponent] = []
|
||||
|
||||
def __del__(self) -> None:
|
||||
# When our dep-set goes down, clear out all dep-data payloads
|
||||
# so we can throw errors if anyone tries to use them anymore.
|
||||
for depdata in self.depdatas.values():
|
||||
depdata.payload = None
|
||||
depdata.valid = False
|
||||
|
||||
def resolve(self) -> None:
|
||||
"""Resolve the total set of required dependencies for the set.
|
||||
|
||||
Raises a ba.DependencyError if dependencies are missing (or other
|
||||
Exception types on other errors).
|
||||
"""
|
||||
|
||||
if self._resolved:
|
||||
raise Exception("DepSet has already been resolved.")
|
||||
|
||||
print('RESOLVING DEP SET')
|
||||
|
||||
# First, recursively expand out all dependencies.
|
||||
self._resolve(self.root, 0)
|
||||
|
||||
# Now, if any dependencies are not present, raise an Exception
|
||||
# telling exactly which ones (so hopefully they'll be able to be
|
||||
# downloaded/etc.
|
||||
missing = [
|
||||
Dependency(entry.cls, entry.config)
|
||||
for entry in self.depdatas.values()
|
||||
if not entry.cls.is_present(entry.config)
|
||||
]
|
||||
if missing:
|
||||
from ba._error import DependencyError
|
||||
raise DependencyError(missing)
|
||||
|
||||
self._resolved = True
|
||||
print('RESOLVE SUCCESS!')
|
||||
|
||||
def get_asset_package_ids(self) -> Set[str]:
|
||||
"""Return the set of asset-package-ids required by this dep-set.
|
||||
|
||||
Must be called on a resolved dep-set.
|
||||
"""
|
||||
ids: Set[str] = set()
|
||||
if not self._resolved:
|
||||
raise Exception('Must be called on a resolved dep-set.')
|
||||
for entry in self.depdatas.values():
|
||||
if issubclass(entry.cls, AssetPackage):
|
||||
assert isinstance(entry.config, str)
|
||||
ids.add(entry.config)
|
||||
return ids
|
||||
|
||||
def load(self) -> Type[TI]:
|
||||
"""Attach the resolved set to the current context.
|
||||
|
||||
Returns a wrapper which can be used to instantiate the root dep.
|
||||
"""
|
||||
# NOTE: stuff below here should probably go in a separate 'instantiate'
|
||||
# method or something.
|
||||
if not self._resolved:
|
||||
raise Exception("Can't instantiate an unresolved DepSet")
|
||||
|
||||
# Go through all of our dep entries and give them a chance to
|
||||
# preload whatever they want.
|
||||
for entry in self.depdatas.values():
|
||||
# First mark everything as valid so recursive loads don't fail.
|
||||
assert entry.valid is False
|
||||
entry.valid = True
|
||||
for entry in self.depdatas.values():
|
||||
# Do a get on everything which will init all payloads
|
||||
# in the proper order recursively.
|
||||
# NOTE: should we guard for recursion here?...
|
||||
entry.cls.dep_get_payload(entry)
|
||||
|
||||
# NOTE: like above, we're cheating here and telling the type
|
||||
# system we're simply returning the root dependency class, when
|
||||
# actually it's a bound-dependency wrapper containing its data/etc.
|
||||
# ..Should fix if/when mypy is smart enough to preserve type safety
|
||||
# on the wrapper's __call__()
|
||||
rootdata = self.depdatas[self.root.get_hash()]
|
||||
return cast(Type[TI], BoundDepComponent(self.root.cls, rootdata))
|
||||
|
||||
def _resolve(self, dep: Dependency[T], recursion: int) -> None:
|
||||
|
||||
# Watch for wacky infinite dep loops.
|
||||
if recursion > 10:
|
||||
raise Exception('Max recursion reached')
|
||||
|
||||
hashval = dep.get_hash()
|
||||
|
||||
if hashval in self.depdatas:
|
||||
# Found an already resolved one; we're done here.
|
||||
return
|
||||
|
||||
# Add our entry before we recurse so we don't repeat add it if
|
||||
# there's a dependency loop.
|
||||
self.depdatas[hashval] = DepData(self, dep)
|
||||
|
||||
# Grab all Dependency instances we find in the class.
|
||||
subdeps = [
|
||||
cls for cls in dep.cls.__dict__.values()
|
||||
if isinstance(cls, Dependency)
|
||||
]
|
||||
|
||||
# ..and add in any dynamic ones it provides.
|
||||
subdeps += dep.cls.get_dynamic_deps(dep.config)
|
||||
for subdep in subdeps:
|
||||
self._resolve(subdep, recursion + 1)
|
||||
|
||||
|
||||
class InstancedDepComponent(DepComponent):
|
||||
"""Base class for DepComponents intended to be instantiated as needed."""
|
||||
|
||||
@classmethod
|
||||
def dep_get_payload(cls, depdata: DepData) -> Any:
|
||||
"""Data provider override; returns a BoundDepComponent."""
|
||||
if depdata.payload is None:
|
||||
# The payload we want for ourself in the dep-set is simply
|
||||
# the bound-def that users can use to instantiate our class
|
||||
# with its data properly intact. We could also just store
|
||||
# the class and instantiate one of these each time.
|
||||
depdata.payload = BoundDepComponent(cls, depdata)
|
||||
return depdata.payload
|
||||
|
||||
|
||||
class StaticDepComponent(DepComponent):
|
||||
"""Base for DepComponents intended to be instanced once and shared."""
|
||||
|
||||
@classmethod
|
||||
def dep_get_payload(cls, depdata: DepData) -> Any:
|
||||
"""Data provider override; returns shared instance."""
|
||||
if depdata.payload is None:
|
||||
# We want to share a single instance of our object with anything
|
||||
# in the set that requested it, so create a temp bound-dep and
|
||||
# create an instance from that.
|
||||
depcls = BoundDepComponent(cls, depdata)
|
||||
|
||||
# Instances have a strong ref to depdata so we can't give
|
||||
# depdata a strong reference to it without creating a cycle.
|
||||
# We also can't just weak-ref the instance or else it won't be
|
||||
# kept alive. Our solution is to stick strong refs to all static
|
||||
# components somewhere on the DepSet.
|
||||
instance = depcls()
|
||||
assert depdata.depset
|
||||
depset2 = depdata.depset()
|
||||
assert depset2 is not None
|
||||
depset2.static_instances.append(instance)
|
||||
depdata.payload = weakref.ref(instance)
|
||||
assert isinstance(depdata.payload, weakref.ref)
|
||||
payload = depdata.payload()
|
||||
if payload is None:
|
||||
raise RuntimeError(
|
||||
f'Accessing DepComponent {cls} in an invalid state.')
|
||||
return payload
|
||||
|
||||
|
||||
class AssetPackage(StaticDepComponent):
|
||||
"""DepComponent representing a bundled package of game assets."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# pylint: disable=no-member
|
||||
assert isinstance(self._depdata.config, str)
|
||||
self.package_id = self._depdata.config
|
||||
print(f'LOADING ASSET PACKAGE {self.package_id}')
|
||||
|
||||
@classmethod
|
||||
def is_present(cls, config: Any = None) -> bool:
|
||||
assert isinstance(config, str)
|
||||
|
||||
# Temp: hard-coding for a single asset-package at the moment.
|
||||
if config == 'stdassets@1':
|
||||
return True
|
||||
return False
|
||||
|
||||
def gettexture(self, name: str) -> ba.Texture:
|
||||
"""Load a named ba.Texture from the AssetPackage.
|
||||
|
||||
Behavior is similar to ba.gettexture()
|
||||
"""
|
||||
return _ba.get_package_texture(self, name)
|
||||
|
||||
def getmodel(self, name: str) -> ba.Model:
|
||||
"""Load a named ba.Model from the AssetPackage.
|
||||
|
||||
Behavior is similar to ba.getmodel()
|
||||
"""
|
||||
return _ba.get_package_model(self, name)
|
||||
|
||||
def getcollidemodel(self, name: str) -> ba.CollideModel:
|
||||
"""Load a named ba.CollideModel from the AssetPackage.
|
||||
|
||||
Behavior is similar to ba.getcollideModel()
|
||||
"""
|
||||
return _ba.get_package_collide_model(self, name)
|
||||
|
||||
def getsound(self, name: str) -> ba.Sound:
|
||||
"""Load a named ba.Sound from the AssetPackage.
|
||||
|
||||
Behavior is similar to ba.getsound()
|
||||
"""
|
||||
return _ba.get_package_sound(self, name)
|
||||
|
||||
def getdata(self, name: str) -> ba.Data:
|
||||
"""Load a named ba.Data from the AssetPackage.
|
||||
|
||||
Behavior is similar to ba.getdata()
|
||||
"""
|
||||
return _ba.get_package_data(self, name)
|
||||
|
||||
|
||||
class TestClassFactory(StaticDepComponent):
|
||||
"""Another test dep-obj."""
|
||||
|
||||
_assets = Dep(AssetPackage, 'stdassets@1')
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
print("Instantiating TestClassFactory")
|
||||
self.tex = self._assets.gettexture('black')
|
||||
self.model = self._assets.getmodel('landMine')
|
||||
self.sound = self._assets.getsound('error')
|
||||
self.data = self._assets.getdata('langdata')
|
||||
|
||||
|
||||
class TestClassObj(InstancedDepComponent):
|
||||
"""Another test dep-obj."""
|
||||
|
||||
|
||||
class TestClass(InstancedDepComponent):
|
||||
"""A test dep-obj."""
|
||||
|
||||
_actorclass = Dep(TestClassObj)
|
||||
_factoryclass = Dep(TestClassFactory, 123)
|
||||
_factoryclass2 = Dep(TestClassFactory, 124)
|
||||
|
||||
def __init__(self, arg: int) -> None:
|
||||
super().__init__()
|
||||
del arg
|
||||
self._actor = self._actorclass()
|
||||
print('got actor', self._actor)
|
||||
print('have factory', self._factoryclass)
|
||||
print('have factory2', self._factoryclass2)
|
||||
|
||||
|
||||
def test_depset() -> None:
|
||||
"""Test call to try this stuff out..."""
|
||||
# noinspection PyUnreachableCode
|
||||
if False: # pylint: disable=using-constant-test
|
||||
print('running test_depset()...')
|
||||
|
||||
def doit() -> None:
|
||||
from ba._error import DependencyError
|
||||
depset = DepSet(Dep(TestClass))
|
||||
resolved = False
|
||||
try:
|
||||
depset.resolve()
|
||||
resolved = True
|
||||
except DependencyError as exc:
|
||||
for dep in exc.deps:
|
||||
if dep.cls is AssetPackage:
|
||||
print('MISSING PACKAGE', dep.config)
|
||||
else:
|
||||
raise Exception('unknown dependency error for ' +
|
||||
str(dep.cls))
|
||||
except Exception as exc:
|
||||
print('DepSet resolve failed with exc type:', type(exc))
|
||||
if resolved:
|
||||
testclass = depset.load()
|
||||
instance = testclass(123)
|
||||
print("INSTANTIATED ROOT:", instance)
|
||||
|
||||
doit()
|
||||
|
||||
# To test this, add prints on __del__ for stuff used above;
|
||||
# everything should be dead at this point if we have no cycles.
|
||||
print('everything should be cleaned up...')
|
||||
_ba.quit()
|
||||
139
assets/src/data/scripts/ba/_enums.py
Normal file
139
assets/src/data/scripts/ba/_enums.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""Enums generated by tools/update_python_enums_module in core."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TimeType(Enum):
|
||||
"""Specifies the type of time for various operations to target/use.
|
||||
|
||||
Category: Enums
|
||||
|
||||
'sim' time is the local simulation time for an activity or session.
|
||||
It can proceed at different rates depending on game speed, stops
|
||||
for pauses, etc.
|
||||
|
||||
'base' is the baseline time for an activity or session. It proceeds
|
||||
consistently regardless of game speed or pausing, but may stop during
|
||||
occurrences such as network outages.
|
||||
|
||||
'real' time is mostly based on clock time, with a few exceptions. It may
|
||||
not advance while the app is backgrounded for instance. (the engine
|
||||
attempts to prevent single large time jumps from occurring)
|
||||
"""
|
||||
SIM = 0
|
||||
BASE = 1
|
||||
REAL = 2
|
||||
|
||||
|
||||
class TimeFormat(Enum):
|
||||
"""Specifies the format time values are provided in.
|
||||
|
||||
Category: Enums
|
||||
"""
|
||||
SECONDS = 0
|
||||
MILLISECONDS = 1
|
||||
|
||||
|
||||
class Permission(Enum):
|
||||
"""Permissions that can be requested from the OS.
|
||||
|
||||
Category: Enums
|
||||
"""
|
||||
STORAGE = 0
|
||||
|
||||
|
||||
class SpecialChar(Enum):
|
||||
"""Special characters the game can print.
|
||||
|
||||
Category: Enums
|
||||
"""
|
||||
DOWN_ARROW = 0
|
||||
UP_ARROW = 1
|
||||
LEFT_ARROW = 2
|
||||
RIGHT_ARROW = 3
|
||||
TOP_BUTTON = 4
|
||||
LEFT_BUTTON = 5
|
||||
RIGHT_BUTTON = 6
|
||||
BOTTOM_BUTTON = 7
|
||||
DELETE = 8
|
||||
SHIFT = 9
|
||||
BACK = 10
|
||||
LOGO_FLAT = 11
|
||||
REWIND_BUTTON = 12
|
||||
PLAY_PAUSE_BUTTON = 13
|
||||
FAST_FORWARD_BUTTON = 14
|
||||
DPAD_CENTER_BUTTON = 15
|
||||
OUYA_BUTTON_O = 16
|
||||
OUYA_BUTTON_U = 17
|
||||
OUYA_BUTTON_Y = 18
|
||||
OUYA_BUTTON_A = 19
|
||||
OUYA_LOGO = 20
|
||||
LOGO = 21
|
||||
TICKET = 22
|
||||
GOOGLE_PLAY_GAMES_LOGO = 23
|
||||
GAME_CENTER_LOGO = 24
|
||||
DICE_BUTTON1 = 25
|
||||
DICE_BUTTON2 = 26
|
||||
DICE_BUTTON3 = 27
|
||||
DICE_BUTTON4 = 28
|
||||
GAME_CIRCLE_LOGO = 29
|
||||
PARTY_ICON = 30
|
||||
TEST_ACCOUNT = 31
|
||||
TICKET_BACKING = 32
|
||||
TROPHY1 = 33
|
||||
TROPHY2 = 34
|
||||
TROPHY3 = 35
|
||||
TROPHY0A = 36
|
||||
TROPHY0B = 37
|
||||
TROPHY4 = 38
|
||||
LOCAL_ACCOUNT = 39
|
||||
ALIBABA_LOGO = 40
|
||||
FLAG_UNITED_STATES = 41
|
||||
FLAG_MEXICO = 42
|
||||
FLAG_GERMANY = 43
|
||||
FLAG_BRAZIL = 44
|
||||
FLAG_RUSSIA = 45
|
||||
FLAG_CHINA = 46
|
||||
FLAG_UNITED_KINGDOM = 47
|
||||
FLAG_CANADA = 48
|
||||
FLAG_INDIA = 49
|
||||
FLAG_JAPAN = 50
|
||||
FLAG_FRANCE = 51
|
||||
FLAG_INDONESIA = 52
|
||||
FLAG_ITALY = 53
|
||||
FLAG_SOUTH_KOREA = 54
|
||||
FLAG_NETHERLANDS = 55
|
||||
FEDORA = 56
|
||||
HAL = 57
|
||||
CROWN = 58
|
||||
YIN_YANG = 59
|
||||
EYE_BALL = 60
|
||||
SKULL = 61
|
||||
HEART = 62
|
||||
DRAGON = 63
|
||||
HELMET = 64
|
||||
MUSHROOM = 65
|
||||
NINJA_STAR = 66
|
||||
VIKING_HELMET = 67
|
||||
MOON = 68
|
||||
SPIDER = 69
|
||||
FIREBALL = 70
|
||||
FLAG_UNITED_ARAB_EMIRATES = 71
|
||||
FLAG_QATAR = 72
|
||||
FLAG_EGYPT = 73
|
||||
FLAG_KUWAIT = 74
|
||||
FLAG_ALGERIA = 75
|
||||
FLAG_SAUDI_ARABIA = 76
|
||||
FLAG_MALAYSIA = 77
|
||||
FLAG_CZECH_REPUBLIC = 78
|
||||
FLAG_AUSTRALIA = 79
|
||||
FLAG_SINGAPORE = 80
|
||||
OCULUS_LOGO = 81
|
||||
STEAM_LOGO = 82
|
||||
NVIDIA_LOGO = 83
|
||||
FLAG_IRAN = 84
|
||||
FLAG_POLAND = 85
|
||||
FLAG_ARGENTINA = 86
|
||||
FLAG_PHILIPPINES = 87
|
||||
FLAG_CHILE = 88
|
||||
MIKIROG = 89
|
||||
194
assets/src/data/scripts/ba/_error.py
Normal file
194
assets/src/data/scripts/ba/_error.py
Normal file
@ -0,0 +1,194 @@
|
||||
"""Error related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, List
|
||||
import ba
|
||||
|
||||
|
||||
class _UnhandledType:
|
||||
pass
|
||||
|
||||
|
||||
# A special value that should be returned from handlemessage()
|
||||
# functions for unhandled message types. This may result
|
||||
# in fallback message types being attempted/etc.
|
||||
UNHANDLED = _UnhandledType()
|
||||
|
||||
|
||||
class DependencyError(Exception):
|
||||
"""Exception raised when one or more ba.Dependency items are missing.
|
||||
|
||||
category: Exception Classes
|
||||
|
||||
(this will generally be missing assets).
|
||||
"""
|
||||
|
||||
def __init__(self, deps: List[ba.Dependency]):
|
||||
super().__init__()
|
||||
self._deps = deps
|
||||
|
||||
@property
|
||||
def deps(self) -> List[ba.Dependency]:
|
||||
"""The list of missing dependencies causing this error."""
|
||||
return self._deps
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
"""Exception raised when a referenced object does not exist.
|
||||
|
||||
category: Exception Classes
|
||||
"""
|
||||
|
||||
|
||||
class PlayerNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Player does not exist.
|
||||
|
||||
category: Exception Classes
|
||||
"""
|
||||
|
||||
|
||||
class TeamNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Team does not exist.
|
||||
|
||||
category: Exception Classes
|
||||
"""
|
||||
|
||||
|
||||
class NodeNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Node does not exist.
|
||||
|
||||
category: Exception Classes
|
||||
"""
|
||||
|
||||
|
||||
class ActorNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Actor does not exist.
|
||||
|
||||
category: Exception Classes
|
||||
"""
|
||||
|
||||
|
||||
class ActivityNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Activity does not exist.
|
||||
|
||||
category: Exception Classes
|
||||
"""
|
||||
|
||||
|
||||
class SessionNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Session does not exist.
|
||||
|
||||
category: Exception Classes
|
||||
"""
|
||||
|
||||
|
||||
class InputDeviceNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.InputDevice does not exist.
|
||||
|
||||
category: Exception Classes
|
||||
"""
|
||||
|
||||
|
||||
class WidgetNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Widget does not exist.
|
||||
|
||||
category: Exception Classes
|
||||
"""
|
||||
|
||||
|
||||
def exc_str() -> str:
|
||||
"""Returns a tidied up string for the current exception.
|
||||
|
||||
This performs some minor cleanup such as printing paths relative
|
||||
to script dirs (full paths are often unwieldy in game installs).
|
||||
"""
|
||||
import traceback
|
||||
excstr = traceback.format_exc()
|
||||
for path in sys.path:
|
||||
excstr = excstr.replace(path + '/', '')
|
||||
return excstr
|
||||
|
||||
|
||||
def print_exception(*args: Any, **keywds: Any) -> None:
|
||||
"""Print info about an exception along with pertinent context state.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
Prints all arguments provided along with various info about the
|
||||
current context and the outstanding exception.
|
||||
Pass the keyword 'once' as True if you want the call to only happen
|
||||
one time from an exact calling location.
|
||||
"""
|
||||
import traceback
|
||||
if keywds:
|
||||
allowed_keywds = ['once']
|
||||
if any(keywd not in allowed_keywds for keywd in keywds):
|
||||
raise Exception("invalid keyword(s)")
|
||||
try:
|
||||
# If we're only printing once and already have, bail.
|
||||
if keywds.get('once', False):
|
||||
if not _ba.do_once():
|
||||
return
|
||||
|
||||
# Most tracebacks are gonna have ugly long install directories in them;
|
||||
# lets strip those out when we can.
|
||||
err_str = ' '.join([str(a) for a in args])
|
||||
print('ERROR:', err_str)
|
||||
_ba.print_context()
|
||||
print('PRINTED-FROM:')
|
||||
|
||||
# Basically the output of traceback.print_stack() slightly prettified:
|
||||
stackstr = ''.join(traceback.format_stack())
|
||||
for path in sys.path:
|
||||
stackstr = stackstr.replace(path + '/', '')
|
||||
print(stackstr, end='')
|
||||
print('EXCEPTION:')
|
||||
|
||||
# Basically the output of traceback.print_exc() slightly prettified:
|
||||
excstr = traceback.format_exc()
|
||||
for path in sys.path:
|
||||
excstr = excstr.replace(path + '/', '')
|
||||
print('\n'.join(' ' + l for l in excstr.splitlines()))
|
||||
except Exception:
|
||||
# I suppose using print_exception here would be a bad idea.
|
||||
print('ERROR: exception in ba.print_exception():')
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def print_error(err_str: str, once: bool = False) -> None:
|
||||
"""Print info about an error along with pertinent context state.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
Prints all positional arguments provided along with various info about the
|
||||
current context.
|
||||
Pass the keyword 'once' as True if you want the call to only happen
|
||||
one time from an exact calling location.
|
||||
"""
|
||||
import traceback
|
||||
try:
|
||||
# If we're only printing once and already have, bail.
|
||||
if once:
|
||||
if not _ba.do_once():
|
||||
return
|
||||
|
||||
# Most tracebacks are gonna have ugly long install directories in them;
|
||||
# lets strip those out when we can.
|
||||
print('ERROR:', err_str)
|
||||
_ba.print_context()
|
||||
|
||||
# Basically the output of traceback.print_stack() slightly prettified:
|
||||
stackstr = ''.join(traceback.format_stack())
|
||||
for path in sys.path:
|
||||
stackstr = stackstr.replace(path + '/', '')
|
||||
print(stackstr, end='')
|
||||
except Exception:
|
||||
print('ERROR: exception in ba.print_error():')
|
||||
traceback.print_exc()
|
||||
95
assets/src/data/scripts/ba/_freeforallsession.py
Normal file
95
assets/src/data/scripts/ba/_freeforallsession.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Functionality related to free-for-all sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._teambasesession import TeamBaseSession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Dict
|
||||
import ba
|
||||
|
||||
|
||||
class FreeForAllSession(TeamBaseSession):
|
||||
"""ba.Session type for free-for-all mode games.
|
||||
|
||||
Category: Gameplay Classes
|
||||
"""
|
||||
_use_teams = False
|
||||
_playlist_selection_var = 'Free-for-All Playlist Selection'
|
||||
_playlist_randomize_var = 'Free-for-All Playlist Randomize'
|
||||
_playlists_var = 'Free-for-All Playlists'
|
||||
|
||||
def get_ffa_point_awards(self) -> Dict[int, int]:
|
||||
"""Return the number of points awarded for different rankings.
|
||||
|
||||
This is based on the current number of players.
|
||||
"""
|
||||
point_awards: Dict[int, int]
|
||||
if len(self.players) == 1:
|
||||
point_awards = {}
|
||||
elif len(self.players) == 2:
|
||||
point_awards = {0: 6}
|
||||
elif len(self.players) == 3:
|
||||
point_awards = {0: 6, 1: 3}
|
||||
elif len(self.players) == 4:
|
||||
point_awards = {0: 8, 1: 4, 2: 2}
|
||||
elif len(self.players) == 5:
|
||||
point_awards = {0: 8, 1: 4, 2: 2}
|
||||
elif len(self.players) == 6:
|
||||
point_awards = {0: 8, 1: 4, 2: 2}
|
||||
else:
|
||||
point_awards = {0: 8, 1: 4, 2: 2, 3: 1}
|
||||
return point_awards
|
||||
|
||||
def __init__(self) -> None:
|
||||
_ba.increment_analytics_count('Free-for-all session start')
|
||||
super().__init__()
|
||||
|
||||
def _switch_to_score_screen(self, results: ba.TeamGameResults) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.activity import drawscreen
|
||||
from bastd.activity import multiteamendscreen
|
||||
from bastd.activity import freeforallendscreen
|
||||
winners = results.get_winners()
|
||||
|
||||
# If there's multiple players and everyone has the same score,
|
||||
# call it a draw.
|
||||
if len(self.players) > 1 and len(winners) < 2:
|
||||
self.set_activity(
|
||||
_ba.new_activity(drawscreen.DrawScoreScreenActivity,
|
||||
{'results': results}))
|
||||
else:
|
||||
# Award different point amounts based on number of players.
|
||||
point_awards = self.get_ffa_point_awards()
|
||||
|
||||
for i, winner in enumerate(winners):
|
||||
for team in winner.teams:
|
||||
points = (point_awards[i] if i in point_awards else 0)
|
||||
team.sessiondata['previous_score'] = (
|
||||
team.sessiondata['score'])
|
||||
team.sessiondata['score'] += points
|
||||
|
||||
series_winners = [
|
||||
team for team in self.teams
|
||||
if team.sessiondata['score'] >= self._ffa_series_length
|
||||
]
|
||||
series_winners.sort(reverse=True,
|
||||
key=lambda tm: (tm.sessiondata['score']))
|
||||
if (len(series_winners) == 1
|
||||
or (len(series_winners) > 1
|
||||
and series_winners[0].sessiondata['score'] !=
|
||||
series_winners[1].sessiondata['score'])):
|
||||
self.set_activity(
|
||||
_ba.new_activity(
|
||||
multiteamendscreen.
|
||||
TeamSeriesVictoryScoreScreenActivity,
|
||||
{'winner': series_winners[0]}))
|
||||
else:
|
||||
self.set_activity(
|
||||
_ba.new_activity(
|
||||
freeforallendscreen.
|
||||
FreeForAllVictoryScoreScreenActivity,
|
||||
{'results': results}))
|
||||
1357
assets/src/data/scripts/ba/_gameactivity.py
Normal file
1357
assets/src/data/scripts/ba/_gameactivity.py
Normal file
File diff suppressed because it is too large
Load Diff
190
assets/src/data/scripts/ba/_gameresults.py
Normal file
190
assets/src/data/scripts/ba/_gameresults.py
Normal file
@ -0,0 +1,190 @@
|
||||
"""Functionality related to game results."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from weakref import ReferenceType
|
||||
from typing import Sequence, Tuple, Any, Optional, Dict, List
|
||||
import ba
|
||||
|
||||
|
||||
@dataclass
|
||||
class WinnerGroup:
|
||||
"""Entry for a winning team or teams calculated by game-results."""
|
||||
score: Optional[int]
|
||||
teams: Sequence[ba.Team]
|
||||
|
||||
|
||||
class TeamGameResults:
|
||||
"""
|
||||
Results for a completed ba.TeamGameActivity.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
Upon completion, a game should fill one of these out and pass it to its
|
||||
ba.Activity.end() call.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Instantiate a results instance."""
|
||||
self._game_set = False
|
||||
self._scores: Dict[int, Tuple[ReferenceType[ba.Team], int]] = {}
|
||||
self._teams: Optional[List[ReferenceType[ba.Team]]] = None
|
||||
self._player_info: Optional[List[Dict[str, Any]]] = None
|
||||
self._lower_is_better: Optional[bool] = None
|
||||
self._score_name: Optional[str] = None
|
||||
self._none_is_winner: Optional[bool] = None
|
||||
self._score_type: Optional[str] = None
|
||||
|
||||
def set_game(self, game: ba.GameActivity) -> None:
|
||||
"""Set the game instance these results are applying to."""
|
||||
if self._game_set:
|
||||
raise RuntimeError("Game set twice for TeamGameResults.")
|
||||
self._game_set = True
|
||||
self._teams = [weakref.ref(team) for team in game.teams]
|
||||
score_info = game.get_resolved_score_info()
|
||||
self._player_info = copy.deepcopy(game.initial_player_info)
|
||||
self._lower_is_better = score_info['lower_is_better']
|
||||
self._score_name = score_info['score_name']
|
||||
self._none_is_winner = score_info['none_is_winner']
|
||||
self._score_type = score_info['score_type']
|
||||
|
||||
def set_team_score(self, team: ba.Team, score: int) -> None:
|
||||
"""Set the score for a given ba.Team.
|
||||
|
||||
This can be a number or None.
|
||||
(see the none_is_winner arg in the constructor)
|
||||
"""
|
||||
self._scores[team.get_id()] = (weakref.ref(team), score)
|
||||
|
||||
def get_team_score(self, team: ba.Team) -> Optional[int]:
|
||||
"""Return the score for a given team."""
|
||||
for score in list(self._scores.values()):
|
||||
if score[0]() is team:
|
||||
return score[1]
|
||||
|
||||
# If we have no score value, assume None.
|
||||
return None
|
||||
|
||||
def get_teams(self) -> List[ba.Team]:
|
||||
"""Return all ba.Teams in the results."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get teams until game is set.")
|
||||
teams = []
|
||||
assert self._teams is not None
|
||||
for team_ref in self._teams:
|
||||
team = team_ref()
|
||||
if team is not None:
|
||||
teams.append(team)
|
||||
return teams
|
||||
|
||||
def has_score_for_team(self, team: ba.Team) -> bool:
|
||||
"""Return whether there is a score for a given team."""
|
||||
for score in list(self._scores.values()):
|
||||
if score[0]() is team:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_team_score_str(self, team: ba.Team) -> ba.Lstr:
|
||||
"""Return the score for the given ba.Team as an Lstr.
|
||||
|
||||
(properly formatted for the score type.)
|
||||
"""
|
||||
from ba._gameutils import timestring
|
||||
from ba._lang import Lstr
|
||||
from ba._enums import TimeFormat
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get team-score-str until game is set.")
|
||||
for score in list(self._scores.values()):
|
||||
if score[0]() is team:
|
||||
if score[1] is None:
|
||||
return Lstr(value='-')
|
||||
if self._score_type == 'seconds':
|
||||
return timestring(score[1] * 1000,
|
||||
centi=False,
|
||||
timeformat=TimeFormat.MILLISECONDS)
|
||||
if self._score_type == 'milliseconds':
|
||||
return timestring(score[1],
|
||||
centi=True,
|
||||
timeformat=TimeFormat.MILLISECONDS)
|
||||
return Lstr(value=str(score[1]))
|
||||
return Lstr(value='-')
|
||||
|
||||
def get_player_info(self) -> List[Dict[str, Any]]:
|
||||
"""Get info about the players represented by the results."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get player-info until game is set.")
|
||||
assert self._player_info is not None
|
||||
return self._player_info
|
||||
|
||||
def get_score_type(self) -> str:
|
||||
"""Get the type of score."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get score-type until game is set.")
|
||||
assert self._score_type is not None
|
||||
return self._score_type
|
||||
|
||||
def get_score_name(self) -> str:
|
||||
"""Get the name associated with scores ('points', etc)."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get score-name until game is set.")
|
||||
assert self._score_name is not None
|
||||
return self._score_name
|
||||
|
||||
def get_lower_is_better(self) -> bool:
|
||||
"""Return whether lower scores are better."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get lower-is-better until game is set.")
|
||||
assert self._lower_is_better is not None
|
||||
return self._lower_is_better
|
||||
|
||||
def get_winning_team(self) -> Optional[ba.Team]:
|
||||
"""Get the winning ba.Team if there is exactly one; None otherwise."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get winners until game is set.")
|
||||
winners = self.get_winners()
|
||||
if winners and len(winners[0].teams) == 1:
|
||||
return winners[0].teams[0]
|
||||
return None
|
||||
|
||||
def get_winners(self) -> List[WinnerGroup]:
|
||||
"""Get an ordered list of winner groups."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get winners until game is set.")
|
||||
|
||||
# Group by best scoring teams.
|
||||
winners: Dict[int, List[ba.Team]] = {}
|
||||
scores = [
|
||||
score for score in self._scores.values()
|
||||
if score[0]() is not None and score[1] is not None
|
||||
]
|
||||
for score in scores:
|
||||
sval = winners.setdefault(score[1], [])
|
||||
team = score[0]()
|
||||
assert team is not None
|
||||
sval.append(team)
|
||||
results: List[Tuple[Optional[int], List[ba.Team]]] = list(
|
||||
winners.items())
|
||||
results.sort(reverse=not self._lower_is_better)
|
||||
|
||||
# Also group the 'None' scores.
|
||||
none_teams: List[ba.Team] = []
|
||||
for score in self._scores.values():
|
||||
if score[0]() is not None and score[1] is None:
|
||||
none_teams.append(score[0]())
|
||||
|
||||
# Add the Nones to the list (either as winners or losers
|
||||
# depending on the rules).
|
||||
if none_teams:
|
||||
nones: List[Tuple[Optional[int], List[ba.Team]]] = [(None,
|
||||
none_teams)]
|
||||
if self._none_is_winner:
|
||||
results = nones + results
|
||||
else:
|
||||
results = results + nones
|
||||
|
||||
return [WinnerGroup(score, team) for score, team in results]
|
||||
489
assets/src/data/scripts/ba/_gameutils.py
Normal file
489
assets/src/data/scripts/ba/_gameutils.py
Normal file
@ -0,0 +1,489 @@
|
||||
"""Utility functionality pertaining to gameplay."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._enums import TimeType, TimeFormat, SpecialChar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict, Sequence
|
||||
import ba
|
||||
|
||||
TROPHY_CHARS = {
|
||||
'1': SpecialChar.TROPHY1,
|
||||
'2': SpecialChar.TROPHY2,
|
||||
'3': SpecialChar.TROPHY3,
|
||||
'0a': SpecialChar.TROPHY0A,
|
||||
'0b': SpecialChar.TROPHY0B,
|
||||
'4': SpecialChar.TROPHY4
|
||||
}
|
||||
|
||||
|
||||
def get_trophy_string(trophy_id: str) -> str:
|
||||
"""Given a trophy id, returns a string to visualize it."""
|
||||
if trophy_id in TROPHY_CHARS:
|
||||
return _ba.charstr(TROPHY_CHARS[trophy_id])
|
||||
return '?'
|
||||
|
||||
|
||||
def sharedobj(name: str) -> Any:
|
||||
"""Return a predefined object for the current Activity, creating if needed.
|
||||
|
||||
Category: Gameplay Functions
|
||||
|
||||
Available values for 'name':
|
||||
|
||||
'globals': returns the 'globals' ba.Node, containing various global
|
||||
controls & values.
|
||||
|
||||
'object_material': a ba.Material that should be applied to any small,
|
||||
normal, physical objects such as bombs, boxes, players, etc. Other
|
||||
materials often check for the presence of this material as a
|
||||
prerequisite for performing certain actions (such as disabling collisions
|
||||
between initially-overlapping objects)
|
||||
|
||||
'player_material': a ba.Material to be applied to player parts. Generally,
|
||||
materials related to the process of scoring when reaching a goal, etc
|
||||
will look for the presence of this material on things that hit them.
|
||||
|
||||
'pickup_material': a ba.Material; collision shapes used for picking things
|
||||
up will have this material applied. To prevent an object from being
|
||||
picked up, you can add a material that disables collisions against things
|
||||
containing this material.
|
||||
|
||||
'footing_material': anything that can be 'walked on' should have this
|
||||
ba.Material applied; generally just terrain and whatnot. A character will
|
||||
snap upright whenever touching something with this material so it should
|
||||
not be applied to props, etc.
|
||||
|
||||
'attack_material': a ba.Material applied to explosion shapes, punch
|
||||
shapes, etc. An object not wanting to receive impulse/etc messages can
|
||||
disable collisions against this material.
|
||||
|
||||
'death_material': a ba.Material that sends a ba.DieMessage() to anything
|
||||
that touches it; handy for terrain below a cliff, etc.
|
||||
|
||||
'region_material': a ba.Material used for non-physical collision shapes
|
||||
(regions); collisions can generally be allowed with this material even
|
||||
when initially overlapping since it is not physical.
|
||||
|
||||
'railing_material': a ba.Material with a very low friction/stiffness/etc
|
||||
that can be applied to invisible 'railings' useful for gently keeping
|
||||
characters from falling off of cliffs.
|
||||
"""
|
||||
# pylint: disable=too-many-branches
|
||||
from ba._messages import DieMessage
|
||||
|
||||
# We store these on the current context; whether its an activity or
|
||||
# session.
|
||||
activity = _ba.getactivity(doraise=False)
|
||||
if activity is not None:
|
||||
|
||||
# Grab shared-objs dict.
|
||||
sharedobjs = getattr(activity, 'sharedobjs', None)
|
||||
|
||||
# Grab item out of it.
|
||||
try:
|
||||
return sharedobjs[name]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
obj: Any
|
||||
|
||||
# Hmm looks like it doesn't yet exist; create it if its a valid value.
|
||||
if name == 'globals':
|
||||
node_obj = _ba.newnode('globals')
|
||||
obj = node_obj
|
||||
elif name in [
|
||||
'object_material', 'player_material', 'pickup_material',
|
||||
'footing_material', 'attack_material'
|
||||
]:
|
||||
obj = _ba.Material()
|
||||
elif name == 'death_material':
|
||||
mat = obj = _ba.Material()
|
||||
mat.add_actions(
|
||||
('message', 'their_node', 'at_connect', DieMessage()))
|
||||
elif name == 'region_material':
|
||||
obj = _ba.Material()
|
||||
elif name == 'railing_material':
|
||||
mat = obj = _ba.Material()
|
||||
mat.add_actions(('modify_part_collision', 'collide', False))
|
||||
mat.add_actions(('modify_part_collision', 'stiffness', 0.003))
|
||||
mat.add_actions(('modify_part_collision', 'damping', 0.00001))
|
||||
mat.add_actions(conditions=('they_have_material',
|
||||
sharedobj('player_material')),
|
||||
actions=(('modify_part_collision', 'collide',
|
||||
True), ('modify_part_collision',
|
||||
'friction', 0.0)))
|
||||
else:
|
||||
raise Exception(
|
||||
"unrecognized shared object (activity context): '" + name +
|
||||
"'")
|
||||
else:
|
||||
session = _ba.getsession(doraise=False)
|
||||
if session is not None:
|
||||
|
||||
# Grab shared-objs dict (creating if necessary).
|
||||
sharedobjs = session.sharedobjs
|
||||
|
||||
# Grab item out of it.
|
||||
obj = sharedobjs.get(name)
|
||||
if obj is not None:
|
||||
return obj
|
||||
|
||||
# Hmm looks like it doesn't yet exist; create if its a valid value.
|
||||
if name == 'globals':
|
||||
obj = _ba.newnode('sessionglobals')
|
||||
else:
|
||||
raise Exception("unrecognized shared object "
|
||||
"(session context): '" + name + "'")
|
||||
else:
|
||||
raise Exception("no current activity or session context")
|
||||
|
||||
# Ok, got a shiny new shared obj; store it for quick access next time.
|
||||
sharedobjs[name] = obj
|
||||
return obj
|
||||
|
||||
|
||||
def animate(node: ba.Node,
|
||||
attr: str,
|
||||
keys: Dict[float, float],
|
||||
loop: bool = False,
|
||||
offset: float = 0,
|
||||
timetype: ba.TimeType = TimeType.SIM,
|
||||
timeformat: ba.TimeFormat = TimeFormat.SECONDS,
|
||||
suppress_format_warning: bool = False) -> ba.Node:
|
||||
"""Animate values on a target ba.Node.
|
||||
|
||||
Category: Gameplay Functions
|
||||
|
||||
Creates an 'animcurve' node with the provided values and time as an input,
|
||||
connect it to the provided attribute, and set it to die with the target.
|
||||
Key values are provided as time:value dictionary pairs. Time values are
|
||||
relative to the current time. By default, times are specified in seconds,
|
||||
but timeformat can also be set to MILLISECONDS to recreate the old behavior
|
||||
(prior to ba 1.5) of taking milliseconds. Returns the animcurve node.
|
||||
"""
|
||||
if timetype is TimeType.SIM:
|
||||
driver = 'time'
|
||||
else:
|
||||
raise Exception("FIXME; only SIM timetype is supported currently.")
|
||||
items = list(keys.items())
|
||||
items.sort()
|
||||
|
||||
# Temp sanity check while we transition from milliseconds to seconds
|
||||
# based time values.
|
||||
if _ba.app.test_build and not suppress_format_warning:
|
||||
for item in items:
|
||||
# (PyCharm seems to think item is a float, not a tuple)
|
||||
# noinspection PyUnresolvedReferences
|
||||
_ba.time_format_check(timeformat, item[0])
|
||||
|
||||
curve = _ba.newnode("animcurve",
|
||||
owner=node,
|
||||
name='Driving ' + str(node) + ' \'' + attr + '\'')
|
||||
|
||||
if timeformat is TimeFormat.SECONDS:
|
||||
mult = 1000
|
||||
elif timeformat is TimeFormat.MILLISECONDS:
|
||||
mult = 1
|
||||
else:
|
||||
raise Exception(f'invalid timeformat value: {timeformat}')
|
||||
|
||||
curve.times = [int(mult * time) for time, val in items]
|
||||
curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int(
|
||||
mult * offset)
|
||||
curve.values = [val for time, val in items]
|
||||
curve.loop = loop
|
||||
|
||||
# If we're not looping, set a timer to kill this curve
|
||||
# after its done its job.
|
||||
# FIXME: Even if we are looping we should have a way to die once we
|
||||
# get disconnected.
|
||||
if not loop:
|
||||
# (PyCharm seems to think item is a float, not a tuple)
|
||||
# noinspection PyUnresolvedReferences
|
||||
_ba.timer(int(mult * items[-1][0]) + 1000,
|
||||
curve.delete,
|
||||
timeformat=TimeFormat.MILLISECONDS)
|
||||
|
||||
# Do the connects last so all our attrs are in place when we push initial
|
||||
# values through.
|
||||
sharedobj('globals').connectattr(driver, curve, "in")
|
||||
curve.connectattr("out", node, attr)
|
||||
return curve
|
||||
|
||||
|
||||
def animate_array(node: ba.Node,
|
||||
attr: str,
|
||||
size: int,
|
||||
keys: Dict[float, Sequence[float]],
|
||||
loop: bool = False,
|
||||
offset: float = 0,
|
||||
timetype: ba.TimeType = TimeType.SIM,
|
||||
timeformat: ba.TimeFormat = TimeFormat.SECONDS,
|
||||
suppress_format_warning: bool = False) -> None:
|
||||
"""Animate an array of values on a target ba.Node.
|
||||
|
||||
Category: Gameplay Functions
|
||||
|
||||
Like ba.animate(), but operates on array attributes.
|
||||
"""
|
||||
# pylint: disable=too-many-locals
|
||||
combine = _ba.newnode('combine', owner=node, attrs={'size': size})
|
||||
if timetype is TimeType.SIM:
|
||||
driver = 'time'
|
||||
else:
|
||||
raise Exception("FIXME: Only SIM timetype is supported currently.")
|
||||
items = list(keys.items())
|
||||
items.sort()
|
||||
|
||||
# Temp sanity check while we transition from milliseconds to seconds
|
||||
# based time values.
|
||||
if _ba.app.test_build and not suppress_format_warning:
|
||||
for item in items:
|
||||
# (PyCharm seems to think item is a float, not a tuple)
|
||||
# noinspection PyUnresolvedReferences
|
||||
_ba.time_format_check(timeformat, item[0])
|
||||
|
||||
if timeformat is TimeFormat.SECONDS:
|
||||
mult = 1000
|
||||
elif timeformat is TimeFormat.MILLISECONDS:
|
||||
mult = 1
|
||||
else:
|
||||
raise Exception('invalid timeformat value: "' + str(timeformat) + '"')
|
||||
|
||||
for i in range(size):
|
||||
curve = _ba.newnode("animcurve",
|
||||
owner=node,
|
||||
name=('Driving ' + str(node) + ' \'' + attr +
|
||||
'\' member ' + str(i)))
|
||||
sharedobj('globals').connectattr(driver, curve, "in")
|
||||
curve.times = [int(mult * time) for time, val in items]
|
||||
curve.values = [val[i] for time, val in items]
|
||||
curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int(
|
||||
mult * offset)
|
||||
curve.loop = loop
|
||||
curve.connectattr("out", combine, 'input' + str(i))
|
||||
|
||||
# If we're not looping, set a timer to kill this
|
||||
# curve after its done its job.
|
||||
if not loop:
|
||||
# (PyCharm seems to think item is a float, not a tuple)
|
||||
# noinspection PyUnresolvedReferences
|
||||
_ba.timer(int(mult * items[-1][0]) + 1000,
|
||||
curve.delete,
|
||||
timeformat=TimeFormat.MILLISECONDS)
|
||||
combine.connectattr('output', node, attr)
|
||||
|
||||
# If we're not looping, set a timer to kill the combine once
|
||||
# the job is done.
|
||||
# FIXME: Even if we are looping we should have a way to die
|
||||
# once we get disconnected.
|
||||
if not loop:
|
||||
# (PyCharm seems to think item is a float, not a tuple)
|
||||
# noinspection PyUnresolvedReferences
|
||||
_ba.timer(int(mult * items[-1][0]) + 1000,
|
||||
combine.delete,
|
||||
timeformat=TimeFormat.MILLISECONDS)
|
||||
|
||||
|
||||
def show_damage_count(damage: str, position: Sequence[float],
|
||||
direction: Sequence[float]) -> None:
|
||||
"""Pop up a damage count at a position in space."""
|
||||
lifespan = 1.0
|
||||
app = _ba.app
|
||||
|
||||
# FIXME: Should never vary game elements based on local config.
|
||||
# (connected clients may have differing configs so they won't
|
||||
# get the intended results).
|
||||
do_big = app.interface_type == 'small' or app.vr_mode
|
||||
txtnode = _ba.newnode('text',
|
||||
attrs={
|
||||
'text': damage,
|
||||
'in_world': True,
|
||||
'h_align': 'center',
|
||||
'flatness': 1.0,
|
||||
'shadow': 1.0 if do_big else 0.7,
|
||||
'color': (1, 0.25, 0.25, 1),
|
||||
'scale': 0.015 if do_big else 0.01
|
||||
})
|
||||
# Translate upward.
|
||||
tcombine = _ba.newnode("combine", owner=txtnode, attrs={'size': 3})
|
||||
tcombine.connectattr('output', txtnode, 'position')
|
||||
v_vals = []
|
||||
pval = 0.0
|
||||
vval = 0.07
|
||||
count = 6
|
||||
for i in range(count):
|
||||
v_vals.append((float(i) / count, pval))
|
||||
pval += vval
|
||||
vval *= 0.5
|
||||
p_start = position[0]
|
||||
p_dir = direction[0]
|
||||
animate(tcombine, "input0",
|
||||
{i[0] * lifespan: p_start + p_dir * i[1]
|
||||
for i in v_vals})
|
||||
p_start = position[1]
|
||||
p_dir = direction[1]
|
||||
animate(tcombine, "input1",
|
||||
{i[0] * lifespan: p_start + p_dir * i[1]
|
||||
for i in v_vals})
|
||||
p_start = position[2]
|
||||
p_dir = direction[2]
|
||||
animate(tcombine, "input2",
|
||||
{i[0] * lifespan: p_start + p_dir * i[1]
|
||||
for i in v_vals})
|
||||
animate(txtnode, 'opacity', {0.7 * lifespan: 1.0, lifespan: 0.0})
|
||||
_ba.timer(lifespan, txtnode.delete)
|
||||
|
||||
|
||||
def timestring(timeval: float,
|
||||
centi: bool = True,
|
||||
timeformat: ba.TimeFormat = TimeFormat.SECONDS,
|
||||
suppress_format_warning: bool = False) -> ba.Lstr:
|
||||
"""Generate a ba.Lstr for displaying a time value.
|
||||
|
||||
Category: General Utility Functions
|
||||
|
||||
Given a time value, returns a ba.Lstr with:
|
||||
(hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).
|
||||
|
||||
Time 'timeval' is specified in seconds by default, or 'timeformat' can
|
||||
be set to ba.TimeFormat.MILLISECONDS to accept milliseconds instead.
|
||||
|
||||
WARNING: the underlying Lstr value is somewhat large so don't use this
|
||||
to rapidly update Node text values for an onscreen timer or you may
|
||||
consume significant network bandwidth. For that purpose you should
|
||||
use a 'timedisplay' Node and attribute connections.
|
||||
|
||||
"""
|
||||
from ba._lang import Lstr
|
||||
|
||||
# Temp sanity check while we transition from milliseconds to seconds
|
||||
# based time values.
|
||||
if _ba.app.test_build and not suppress_format_warning:
|
||||
_ba.time_format_check(timeformat, timeval)
|
||||
|
||||
# We operate on milliseconds internally.
|
||||
if timeformat is TimeFormat.SECONDS:
|
||||
timeval = int(1000 * timeval)
|
||||
elif timeformat is TimeFormat.MILLISECONDS:
|
||||
pass
|
||||
else:
|
||||
raise Exception(f'invalid timeformat: {timeformat}')
|
||||
if not isinstance(timeval, int):
|
||||
timeval = int(timeval)
|
||||
bits = []
|
||||
subs = []
|
||||
hval = (timeval // 1000) // (60 * 60)
|
||||
if hval != 0:
|
||||
bits.append('${H}')
|
||||
subs.append(('${H}',
|
||||
Lstr(resource='timeSuffixHoursText',
|
||||
subs=[('${COUNT}', str(hval))])))
|
||||
mval = ((timeval // 1000) // 60) % 60
|
||||
if mval != 0:
|
||||
bits.append('${M}')
|
||||
subs.append(('${M}',
|
||||
Lstr(resource='timeSuffixMinutesText',
|
||||
subs=[('${COUNT}', str(mval))])))
|
||||
|
||||
# We add seconds if its non-zero *or* we haven't added anything else.
|
||||
if centi:
|
||||
sval = (timeval / 1000.0 % 60.0)
|
||||
if sval >= 0.005 or not bits:
|
||||
bits.append('${S}')
|
||||
subs.append(('${S}',
|
||||
Lstr(resource='timeSuffixSecondsText',
|
||||
subs=[('${COUNT}', ('%.2f' % sval))])))
|
||||
else:
|
||||
sval = (timeval // 1000 % 60)
|
||||
if sval != 0 or not bits:
|
||||
bits.append('${S}')
|
||||
subs.append(('${S}',
|
||||
Lstr(resource='timeSuffixSecondsText',
|
||||
subs=[('${COUNT}', str(sval))])))
|
||||
return Lstr(value=' '.join(bits), subs=subs)
|
||||
|
||||
|
||||
def cameraflash(duration: float = 999.0) -> None:
|
||||
"""Create a strobing camera flash effect.
|
||||
|
||||
Category: Gameplay Functions
|
||||
|
||||
(as seen when a team wins a game)
|
||||
Duration is in seconds.
|
||||
"""
|
||||
# pylint: disable=too-many-locals
|
||||
import random
|
||||
from ba._actor import Actor
|
||||
x_spread = 10
|
||||
y_spread = 5
|
||||
positions = [[-x_spread, -y_spread], [0, -y_spread], [0, y_spread],
|
||||
[x_spread, -y_spread], [x_spread, y_spread],
|
||||
[-x_spread, y_spread]]
|
||||
times = [0, 2700, 1000, 1800, 500, 1400]
|
||||
|
||||
# Store this on the current activity so we only have one at a time.
|
||||
# FIXME: Need a type safe way to do this.
|
||||
activity = _ba.getactivity()
|
||||
# noinspection PyTypeHints
|
||||
activity.camera_flash_data = [] # type: ignore
|
||||
for i in range(6):
|
||||
light = Actor(
|
||||
_ba.newnode("light",
|
||||
attrs={
|
||||
'position': (positions[i][0], 0, positions[i][1]),
|
||||
'radius': 1.0,
|
||||
'lights_volumes': False,
|
||||
'height_attenuated': False,
|
||||
'color': (0.2, 0.2, 0.8)
|
||||
}))
|
||||
sval = 1.87
|
||||
iscale = 1.3
|
||||
tcombine = _ba.newnode("combine",
|
||||
owner=light.node,
|
||||
attrs={
|
||||
'size': 3,
|
||||
'input0': positions[i][0],
|
||||
'input1': 0,
|
||||
'input2': positions[i][1]
|
||||
})
|
||||
assert light.node
|
||||
tcombine.connectattr('output', light.node, 'position')
|
||||
xval = positions[i][0]
|
||||
yval = positions[i][1]
|
||||
spd = 0.5 + random.random()
|
||||
spd2 = 0.5 + random.random()
|
||||
animate(tcombine,
|
||||
'input0', {
|
||||
0.0: xval + 0,
|
||||
0.069 * spd: xval + 10.0,
|
||||
0.143 * spd: xval - 10.0,
|
||||
0.201 * spd: xval + 0
|
||||
},
|
||||
loop=True)
|
||||
animate(tcombine,
|
||||
'input2', {
|
||||
0.0: yval + 0,
|
||||
0.15 * spd2: yval + 10.0,
|
||||
0.287 * spd2: yval - 10.0,
|
||||
0.398 * spd2: yval + 0
|
||||
},
|
||||
loop=True)
|
||||
animate(light.node,
|
||||
"intensity", {
|
||||
0.0: 0,
|
||||
0.02 * sval: 0,
|
||||
0.05 * sval: 0.8 * iscale,
|
||||
0.08 * sval: 0,
|
||||
0.1 * sval: 0
|
||||
},
|
||||
loop=True,
|
||||
offset=times[i])
|
||||
_ba.timer((times[i] + random.randint(1, int(duration)) * 40 * sval),
|
||||
light.node.delete,
|
||||
timeformat=TimeFormat.MILLISECONDS)
|
||||
activity.camera_flash_data.append(light) # type: ignore
|
||||
247
assets/src/data/scripts/ba/_general.py
Normal file
247
assets/src/data/scripts/ba/_general.py
Normal file
@ -0,0 +1,247 @@
|
||||
"""Utility snippets applying to generic Python code."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import types
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Type
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def getclass(name: str, subclassof: Type[T]) -> Type[T]:
|
||||
"""Given a full class name such as foo.bar.MyClass, return the class.
|
||||
|
||||
Category: General Utility Functions
|
||||
|
||||
If 'subclassof' is given, the class will be checked to make sure
|
||||
it is a subclass of the provided class, and a TypeError will be
|
||||
raised if not.
|
||||
"""
|
||||
import importlib
|
||||
splits = name.split('.')
|
||||
modulename = '.'.join(splits[:-1])
|
||||
classname = splits[-1]
|
||||
module = importlib.import_module(modulename)
|
||||
cls: Type = getattr(module, classname)
|
||||
|
||||
if subclassof is not None and not issubclass(cls, subclassof):
|
||||
raise TypeError(name + ' is not a subclass of ' + str(subclassof))
|
||||
return cls
|
||||
|
||||
|
||||
def json_prep(data: Any) -> Any:
|
||||
"""Return a json-friendly version of the provided data.
|
||||
|
||||
This converts any tuples to lists and any bytes to strings
|
||||
(interpreted as utf-8, ignoring errors). Logs errors (just once)
|
||||
if any data is modified/discarded/unsupported.
|
||||
"""
|
||||
|
||||
if isinstance(data, dict):
|
||||
return dict((json_prep(key), json_prep(value))
|
||||
for key, value in list(data.items()))
|
||||
if isinstance(data, list):
|
||||
return [json_prep(element) for element in data]
|
||||
if isinstance(data, tuple):
|
||||
from ba import _error
|
||||
_error.print_error('json_prep encountered tuple', once=True)
|
||||
return [json_prep(element) for element in data]
|
||||
if isinstance(data, bytes):
|
||||
try:
|
||||
return data.decode(errors='ignore')
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_error('json_prep encountered utf-8 decode error',
|
||||
once=True)
|
||||
return data.decode(errors='ignore')
|
||||
if not isinstance(data, (str, float, bool, type(None), int)):
|
||||
from ba import _error
|
||||
_error.print_error('got unsupported type in json_prep:' +
|
||||
str(type(data)),
|
||||
once=True)
|
||||
return data
|
||||
|
||||
|
||||
def utf8_all(data: Any) -> Any:
|
||||
"""Convert any unicode data in provided sequence(s)to utf8 bytes."""
|
||||
if isinstance(data, dict):
|
||||
return dict((utf8_all(key), utf8_all(value))
|
||||
for key, value in list(data.items()))
|
||||
if isinstance(data, list):
|
||||
return [utf8_all(element) for element in data]
|
||||
if isinstance(data, tuple):
|
||||
return tuple(utf8_all(element) for element in data)
|
||||
if isinstance(data, str):
|
||||
return data.encode('utf-8', errors='ignore')
|
||||
return data
|
||||
|
||||
|
||||
def print_refs(obj: Any) -> None:
|
||||
"""Print a list of known live references to an object."""
|
||||
import gc
|
||||
|
||||
# Hmmm; I just noticed that calling this on an object
|
||||
# seems to keep it alive. Should figure out why.
|
||||
print('REFERENCES FOR', obj, ':')
|
||||
refs = list(gc.get_referrers(obj))
|
||||
i = 1
|
||||
for ref in refs:
|
||||
print(' ref', i, ':', ref)
|
||||
i += 1
|
||||
|
||||
|
||||
def get_type_name(cls: Type) -> str:
|
||||
"""Return a full type name including module for a class."""
|
||||
return cls.__module__ + '.' + cls.__name__
|
||||
|
||||
|
||||
class WeakCall:
|
||||
"""Wrap a callable and arguments into a single callable object.
|
||||
|
||||
Category: General Utility Classes
|
||||
|
||||
When passed a bound method as the callable, the instance portion
|
||||
of it is weak-referenced, meaning the underlying instance is
|
||||
free to die if all other references to it go away. Should this
|
||||
occur, calling the WeakCall is simply a no-op.
|
||||
|
||||
Think of this as a handy way to tell an object to do something
|
||||
at some point in the future if it happens to still exist.
|
||||
|
||||
# EXAMPLE A: this code will create a FooClass instance and call its
|
||||
# bar() method 5 seconds later; it will be kept alive even though
|
||||
# we overwrite its variable with None because the bound method
|
||||
# we pass as a timer callback (foo.bar) strong-references it
|
||||
foo = FooClass()
|
||||
ba.timer(5.0, foo.bar)
|
||||
foo = None
|
||||
|
||||
# EXAMPLE B: this code will *not* keep our object alive; it will die
|
||||
# when we overwrite it with None and the timer will be a no-op when it
|
||||
# fires
|
||||
foo = FooClass()
|
||||
ba.timer(5.0, ba.WeakCall(foo.bar))
|
||||
foo = None
|
||||
|
||||
Note: additional args and keywords you provide to the WeakCall()
|
||||
constructor are stored as regular strong-references; you'll need
|
||||
to wrap them in weakrefs manually if desired.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **keywds: Any) -> None:
|
||||
"""
|
||||
Instantiate a WeakCall; pass a callable as the first
|
||||
arg, followed by any number of arguments or keywords.
|
||||
|
||||
# example: wrap a method call with some positional and keyword args:
|
||||
myweakcall = ba.WeakCall(myobj.dostuff, argval1, namedarg=argval2)
|
||||
|
||||
# Now we have a single callable to run that whole mess.
|
||||
# This is the same as calling myobj.dostuff(argval1, namedarg=argval2)
|
||||
# (provided my_obj still exists; this will do nothing otherwise)
|
||||
myweakcall()
|
||||
"""
|
||||
if hasattr(args[0], '__func__'):
|
||||
self._call = WeakMethod(args[0])
|
||||
else:
|
||||
app = _ba.app
|
||||
if not app.did_weak_call_warning:
|
||||
print(('Warning: callable passed to ba.WeakCall() is not'
|
||||
' weak-referencable (' + str(args[0]) +
|
||||
'); use ba.Call() instead to avoid this '
|
||||
'warning. Stack-trace:'))
|
||||
import traceback
|
||||
traceback.print_stack()
|
||||
app.did_weak_call_warning = True
|
||||
self._call = args[0]
|
||||
self._args = args[1:]
|
||||
self._keywds = keywds
|
||||
|
||||
def __call__(self, *args_extra: Any) -> Any:
|
||||
return self._call(*self._args + args_extra, **self._keywds)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return ('<ba.WeakCall object; _call=' + str(self._call) + ' _args=' +
|
||||
str(self._args) + ' _keywds=' + str(self._keywds) + '>')
|
||||
|
||||
|
||||
class Call:
|
||||
"""Wraps a callable and arguments into a single callable object.
|
||||
|
||||
Category: General Utility Classes
|
||||
|
||||
The callable is strong-referenced so it won't die until this object does.
|
||||
Note that a bound method (ex: myobj.dosomething) contains a reference
|
||||
to 'self' (myobj in that case), so you will be keeping that object alive
|
||||
too. Use ba.WeakCall if you want to pass a method to callback without
|
||||
keeping its object alive.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **keywds: Any):
|
||||
"""
|
||||
Instantiate a Call; pass a callable as the first
|
||||
arg, followed by any number of arguments or keywords.
|
||||
|
||||
# example: wrap a method call with 1 positional and 1 keyword arg
|
||||
mycall = ba.Call(myobj.dostuff, argval1, namedarg=argval2)
|
||||
|
||||
# now we have a single callable to run that whole mess
|
||||
# this is the same as calling myobj.dostuff(argval1, namedarg=argval2)
|
||||
mycall()
|
||||
"""
|
||||
self._call = args[0]
|
||||
self._args = args[1:]
|
||||
self._keywds = keywds
|
||||
|
||||
def __call__(self, *args_extra: Any) -> Any:
|
||||
return self._call(*self._args + args_extra, **self._keywds)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return ('<ba.Call object; _call=' + str(self._call) + ' _args=' +
|
||||
str(self._args) + ' _keywds=' + str(self._keywds) + '>')
|
||||
|
||||
|
||||
class WeakMethod:
|
||||
"""A weak-referenced bound method.
|
||||
|
||||
Wraps a bound method using weak references so that the original is
|
||||
free to die. If called with a dead target, is simply a no-op.
|
||||
"""
|
||||
|
||||
def __init__(self, call: types.MethodType):
|
||||
assert isinstance(call, types.MethodType)
|
||||
self._func = call.__func__
|
||||
self._obj = weakref.ref(call.__self__)
|
||||
|
||||
def __call__(self, *args: Any, **keywds: Any) -> Any:
|
||||
obj = self._obj()
|
||||
if obj is None:
|
||||
return None
|
||||
return self._func(*((obj, ) + args), **keywds)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return '<ba.WeakMethod object; call=' + str(self._func) + '>'
|
||||
|
||||
|
||||
def make_hash(obj: Any) -> int:
|
||||
"""Makes a hash from a dictionary, list, tuple or set to any level,
|
||||
that contains only other hashable types (including any lists, tuples,
|
||||
sets, and dictionaries).
|
||||
"""
|
||||
|
||||
if isinstance(obj, (set, tuple, list)):
|
||||
return hash(tuple([make_hash(e) for e in obj]))
|
||||
if not isinstance(obj, dict):
|
||||
return hash(obj)
|
||||
|
||||
new_obj = copy.deepcopy(obj)
|
||||
for k, v in new_obj.items():
|
||||
new_obj[k] = make_hash(v)
|
||||
|
||||
return hash(tuple(frozenset(sorted(new_obj.items()))))
|
||||
331
assets/src/data/scripts/ba/_hooks.py
Normal file
331
assets/src/data/scripts/ba/_hooks.py
Normal file
@ -0,0 +1,331 @@
|
||||
"""Snippets of code for use by the internal C++ layer.
|
||||
|
||||
History: originally I would dynamically compile/eval bits of Python text
|
||||
from within C++ code, but the major downside there was that I would
|
||||
never catch code breakage until the code was next run. By defining all
|
||||
snippets I use here and then capturing references to them all at launch
|
||||
I can verify everything I'm looking for exists and pylint can do
|
||||
its magic on this file.
|
||||
"""
|
||||
# (most of these are self-explanatory)
|
||||
# pylint: disable=missing-function-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import List, Sequence, Optional
|
||||
import ba
|
||||
|
||||
|
||||
def reset_to_main_menu() -> None:
|
||||
"""Reset the game to the main menu gracefully."""
|
||||
_ba.app.return_to_main_menu_session_gracefully()
|
||||
|
||||
|
||||
def set_config_fullscreen_on() -> None:
|
||||
"""The app has set fullscreen on its own and we should note it."""
|
||||
_ba.app.config['Fullscreen'] = True
|
||||
_ba.app.config.commit()
|
||||
|
||||
|
||||
def set_config_fullscreen_off() -> None:
|
||||
"""The app has set fullscreen on its own and we should note it."""
|
||||
_ba.app.config['Fullscreen'] = False
|
||||
_ba.app.config.commit()
|
||||
|
||||
|
||||
def not_signed_in_screen_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.screenmessage(Lstr(resource='notSignedInErrorText'))
|
||||
|
||||
|
||||
def connecting_to_party_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.screenmessage(Lstr(resource='internal.connectingToPartyText'),
|
||||
color=(1, 1, 1))
|
||||
|
||||
|
||||
def rejecting_invite_already_in_party_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.screenmessage(
|
||||
Lstr(resource='internal.rejectingInviteAlreadyInPartyText'),
|
||||
color=(1, 0.5, 0))
|
||||
|
||||
|
||||
def connection_failed_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.screenmessage(Lstr(resource='internal.connectionFailedText'),
|
||||
color=(1, 0.5, 0))
|
||||
|
||||
|
||||
def temporarily_unavailable_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
_ba.screenmessage(
|
||||
Lstr(resource='getTicketsWindow.unavailableTemporarilyText'),
|
||||
color=(1, 0, 0))
|
||||
|
||||
|
||||
def in_progress_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
_ba.screenmessage(Lstr(resource='getTicketsWindow.inProgressText'),
|
||||
color=(1, 0, 0))
|
||||
|
||||
|
||||
def error_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
_ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
|
||||
|
||||
|
||||
def purchase_not_valid_error() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
_ba.screenmessage(Lstr(resource='store.purchaseNotValidError',
|
||||
subs=[('${EMAIL}', 'support@froemling.net')]),
|
||||
color=(1, 0, 0))
|
||||
|
||||
|
||||
def purchase_already_in_progress_error() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
_ba.screenmessage(Lstr(resource='store.purchaseAlreadyInProgressText'),
|
||||
color=(1, 0, 0))
|
||||
|
||||
|
||||
def gear_vr_controller_warning() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
_ba.screenmessage(Lstr(resource='usesExternalControllerText'),
|
||||
color=(1, 0, 0))
|
||||
|
||||
|
||||
def orientation_reset_cb_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.screenmessage(
|
||||
Lstr(resource='internal.vrOrientationResetCardboardText'),
|
||||
color=(0, 1, 0))
|
||||
|
||||
|
||||
def orientation_reset_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.screenmessage(Lstr(resource='internal.vrOrientationResetText'),
|
||||
color=(0, 1, 0))
|
||||
|
||||
|
||||
def handle_app_resume() -> None:
|
||||
_ba.app.handle_app_resume()
|
||||
|
||||
|
||||
def launch_main_menu_session() -> None:
|
||||
from bastd.mainmenu import MainMenuSession
|
||||
_ba.new_host_session(MainMenuSession)
|
||||
|
||||
|
||||
def language_test_toggle() -> None:
|
||||
from ba._lang import setlanguage
|
||||
setlanguage('Gibberish' if _ba.app.language == 'English' else 'English')
|
||||
|
||||
|
||||
def award_in_control_achievement() -> None:
|
||||
from ba._achievement import award_local_achievement
|
||||
award_local_achievement('In Control')
|
||||
|
||||
|
||||
def award_dual_wielding_achievement() -> None:
|
||||
from ba._achievement import award_local_achievement
|
||||
award_local_achievement('Dual Wielding')
|
||||
|
||||
|
||||
def play_gong_sound() -> None:
|
||||
_ba.playsound(_ba.getsound('gong'))
|
||||
|
||||
|
||||
def launch_coop_game(name: str) -> None:
|
||||
_ba.app.launch_coop_game(name)
|
||||
|
||||
|
||||
def purchases_restored_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.screenmessage(Lstr(resource='getTicketsWindow.purchasesRestoredText'),
|
||||
color=(0, 1, 0))
|
||||
|
||||
|
||||
def dismiss_wii_remotes_window() -> None:
|
||||
call = _ba.app.dismiss_wii_remotes_window_call
|
||||
if call is not None:
|
||||
# Weird; this seems to trigger pylint only sometimes.
|
||||
# pylint: disable=useless-suppression
|
||||
# pylint: disable=not-callable
|
||||
call()
|
||||
|
||||
|
||||
def unavailable_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.screenmessage(Lstr(resource='getTicketsWindow.unavailableText'),
|
||||
color=(1, 0, 0))
|
||||
|
||||
|
||||
def submit_analytics_counts(sval: str) -> None:
|
||||
_ba.add_transaction({'type': 'ANALYTICS_COUNTS', 'values': sval})
|
||||
_ba.run_transactions()
|
||||
|
||||
|
||||
def set_last_ad_network(sval: str) -> None:
|
||||
import time
|
||||
_ba.app.last_ad_network = sval
|
||||
_ba.app.last_ad_network_set_time = time.time()
|
||||
|
||||
|
||||
def no_game_circle_message() -> None:
|
||||
from ba._lang import Lstr
|
||||
_ba.screenmessage(Lstr(resource='noGameCircleText'), color=(1, 0, 0))
|
||||
|
||||
|
||||
def empty_call() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def level_icon_press() -> None:
|
||||
print('LEVEL ICON PRESSED')
|
||||
|
||||
|
||||
def trophy_icon_press() -> None:
|
||||
print('TROPHY ICON PRESSED')
|
||||
|
||||
|
||||
def coin_icon_press() -> None:
|
||||
print('COIN ICON PRESSED')
|
||||
|
||||
|
||||
def ticket_icon_press() -> None:
|
||||
from bastd.ui.resourcetypeinfo import ResourceTypeInfoWindow
|
||||
ResourceTypeInfoWindow(
|
||||
origin_widget=_ba.get_special_widget('tickets_info_button'))
|
||||
|
||||
|
||||
def back_button_press() -> None:
|
||||
_ba.back_press()
|
||||
|
||||
|
||||
def friends_button_press() -> None:
|
||||
print('FRIEND BUTTON PRESSED!')
|
||||
|
||||
|
||||
def print_trace() -> None:
|
||||
import traceback
|
||||
print('Python Traceback (most recent call last):')
|
||||
traceback.print_stack()
|
||||
|
||||
|
||||
def toggle_fullscreen() -> None:
|
||||
cfg = _ba.app.config
|
||||
cfg['Fullscreen'] = not cfg.resolve('Fullscreen')
|
||||
cfg.apply_and_commit()
|
||||
|
||||
|
||||
def party_icon_activate(origin: Sequence[float]) -> None:
|
||||
import weakref
|
||||
from bastd.ui.party import PartyWindow
|
||||
app = _ba.app
|
||||
_ba.playsound(_ba.getsound('swish'))
|
||||
|
||||
# If it exists, dismiss it; otherwise make a new one.
|
||||
if app.party_window is not None and app.party_window() is not None:
|
||||
app.party_window().close()
|
||||
else:
|
||||
app.party_window = weakref.ref(PartyWindow(origin=origin))
|
||||
|
||||
|
||||
def read_config() -> None:
|
||||
_ba.app.read_config()
|
||||
|
||||
|
||||
def ui_remote_press() -> None:
|
||||
"""Handle a press by a remote device that is only usable for nav."""
|
||||
from ba._lang import Lstr
|
||||
_ba.screenmessage(Lstr(resource="internal.controllerForMenusOnlyText"),
|
||||
color=(1, 0, 0))
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
|
||||
|
||||
def quit_window() -> None:
|
||||
from bastd.ui.confirm import QuitWindow
|
||||
QuitWindow()
|
||||
|
||||
|
||||
def remove_in_game_ads_message() -> None:
|
||||
_ba.app.do_remove_in_game_ads_message()
|
||||
|
||||
|
||||
def telnet_access_request() -> None:
|
||||
from bastd.ui.telnet import TelnetAccessRequestWindow
|
||||
TelnetAccessRequestWindow()
|
||||
|
||||
|
||||
def app_pause() -> None:
|
||||
_ba.app.handle_app_pause()
|
||||
|
||||
|
||||
def do_quit() -> None:
|
||||
_ba.quit()
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
_ba.app.shutdown()
|
||||
|
||||
|
||||
def gc_disable() -> None:
|
||||
import gc
|
||||
gc.disable()
|
||||
|
||||
|
||||
def device_menu_press(device: ba.InputDevice) -> None:
|
||||
from bastd.ui.mainmenu import MainMenuWindow
|
||||
in_main_menu = bool(_ba.app.main_menu_window)
|
||||
if not in_main_menu:
|
||||
_ba.set_ui_input_device(device)
|
||||
_ba.playsound(_ba.getsound('swish'))
|
||||
_ba.app.main_menu_window = (MainMenuWindow().get_root_widget())
|
||||
|
||||
|
||||
def show_url_window(address: str) -> None:
|
||||
from bastd.ui.url import ShowURLWindow
|
||||
ShowURLWindow(address)
|
||||
|
||||
|
||||
def party_invite_revoke(invite_id: str) -> None:
|
||||
# If there's a confirm window up for joining this particular
|
||||
# invite, kill it.
|
||||
for winref in _ba.app.invite_confirm_windows:
|
||||
win = winref()
|
||||
if win is not None and win.ew_party_invite_id == invite_id:
|
||||
_ba.containerwidget(edit=win.get_root_widget(),
|
||||
transition='out_right')
|
||||
|
||||
|
||||
def filter_chat_message(msg: str, client_id: int) -> Optional[str]:
|
||||
"""Intercept/filter chat messages.
|
||||
|
||||
Called for all chat messages while hosting.
|
||||
Messages originating from the host will have clientID -1.
|
||||
Should filter and return the string to be displayed, or return None
|
||||
to ignore the message.
|
||||
"""
|
||||
del client_id # Unused by default.
|
||||
return msg
|
||||
|
||||
|
||||
def local_chat_message(msg: str) -> None:
|
||||
if (_ba.app.party_window is not None
|
||||
and _ba.app.party_window() is not None):
|
||||
_ba.app.party_window().on_chat_message(msg)
|
||||
|
||||
|
||||
def handle_remote_achievement_list(completed_achievements: List[str]) -> None:
|
||||
from ba import _achievement
|
||||
_achievement.set_completed_achievements(completed_achievements)
|
||||
618
assets/src/data/scripts/ba/_input.py
Normal file
618
assets/src/data/scripts/ba/_input.py
Normal file
@ -0,0 +1,618 @@
|
||||
"""Input related functionality"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict, Tuple
|
||||
import ba
|
||||
|
||||
|
||||
def get_device_value(device: ba.InputDevice, name: str) -> Any:
|
||||
"""Returns a mapped value for an input device.
|
||||
|
||||
This checks the user config and falls back to default values
|
||||
where available.
|
||||
"""
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-return-statements
|
||||
# pylint: disable=too-many-branches
|
||||
devicename = device.name
|
||||
unique_id = device.unique_identifier
|
||||
app = _ba.app
|
||||
useragentstring = app.user_agent_string
|
||||
platform = app.platform
|
||||
subplatform = app.subplatform
|
||||
bs_config = _ba.app.config
|
||||
|
||||
# If there's an entry in our config for this controller, use it.
|
||||
if "Controllers" in bs_config:
|
||||
ccfgs = bs_config["Controllers"]
|
||||
if devicename in ccfgs:
|
||||
mapping = None
|
||||
if unique_id in ccfgs[devicename]:
|
||||
mapping = ccfgs[devicename][unique_id]
|
||||
elif "default" in ccfgs[devicename]:
|
||||
mapping = ccfgs[devicename]["default"]
|
||||
if mapping is not None:
|
||||
return mapping.get(name, -1)
|
||||
|
||||
if platform == 'windows':
|
||||
|
||||
# XInput (hopefully this mapping is consistent?...)
|
||||
if devicename.startswith('XInput Controller'):
|
||||
return {
|
||||
'triggerRun2': 3,
|
||||
'unassignedButtonsRun': False,
|
||||
'buttonPickUp': 4,
|
||||
'buttonBomb': 2,
|
||||
'buttonStart': 8,
|
||||
'buttonIgnored2': 7,
|
||||
'triggerRun1': 6,
|
||||
'buttonPunch': 3,
|
||||
'buttonRun2': 5,
|
||||
'buttonRun1': 6,
|
||||
'buttonJump': 1,
|
||||
'buttonIgnored': 11
|
||||
}.get(name, -1)
|
||||
|
||||
# Ps4 controller.
|
||||
if devicename == 'Wireless Controller':
|
||||
return {
|
||||
'triggerRun2': 4,
|
||||
'unassignedButtonsRun': False,
|
||||
'buttonPickUp': 4,
|
||||
'buttonBomb': 3,
|
||||
'buttonJump': 2,
|
||||
'buttonStart': 10,
|
||||
'buttonPunch': 1,
|
||||
'buttonRun2': 5,
|
||||
'buttonRun1': 6,
|
||||
'triggerRun1': 5
|
||||
}.get(name, -1)
|
||||
|
||||
# Look for some exact types.
|
||||
if _ba.is_running_on_fire_tv():
|
||||
if devicename in ['Thunder', 'Amazon Fire Game Controller']:
|
||||
return {
|
||||
'triggerRun2': 23,
|
||||
'unassignedButtonsRun': False,
|
||||
'buttonPickUp': 101,
|
||||
'buttonBomb': 98,
|
||||
'buttonJump': 97,
|
||||
'analogStickDeadZone': 0.0,
|
||||
'startButtonActivatesDefaultWidget': False,
|
||||
'buttonStart': 83,
|
||||
'buttonPunch': 100,
|
||||
'buttonRun2': 103,
|
||||
'buttonRun1': 104,
|
||||
'triggerRun1': 24
|
||||
}.get(name, -1)
|
||||
if devicename == 'NYKO PLAYPAD PRO':
|
||||
return {
|
||||
'triggerRun2': 23,
|
||||
'triggerRun1': 24,
|
||||
'buttonPickUp': 101,
|
||||
'buttonBomb': 98,
|
||||
'buttonJump': 97,
|
||||
'buttonUp': 20,
|
||||
'buttonLeft': 22,
|
||||
'buttonRight': 23,
|
||||
'buttonStart': 83,
|
||||
'buttonPunch': 100,
|
||||
'buttonDown': 21
|
||||
}.get(name, -1)
|
||||
if devicename == 'Logitech Dual Action':
|
||||
return {
|
||||
'triggerRun2': 23,
|
||||
'triggerRun1': 24,
|
||||
'buttonPickUp': 98,
|
||||
'buttonBomb': 101,
|
||||
'buttonJump': 100,
|
||||
'buttonStart': 109,
|
||||
'buttonPunch': 97
|
||||
}.get(name, -1)
|
||||
if devicename == 'Xbox 360 Wireless Receiver':
|
||||
return {
|
||||
'triggerRun2': 23,
|
||||
'triggerRun1': 24,
|
||||
'buttonPickUp': 101,
|
||||
'buttonBomb': 98,
|
||||
'buttonJump': 97,
|
||||
'buttonUp': 20,
|
||||
'buttonLeft': 22,
|
||||
'buttonRight': 23,
|
||||
'buttonStart': 83,
|
||||
'buttonPunch': 100,
|
||||
'buttonDown': 21
|
||||
}.get(name, -1)
|
||||
if devicename == 'Microsoft X-Box 360 pad':
|
||||
return {
|
||||
'triggerRun2': 23,
|
||||
'triggerRun1': 24,
|
||||
'buttonPickUp': 101,
|
||||
'buttonBomb': 98,
|
||||
'buttonJump': 97,
|
||||
'buttonStart': 83,
|
||||
'buttonPunch': 100
|
||||
}.get(name, -1)
|
||||
if devicename in [
|
||||
'Amazon Remote', 'Amazon Bluetooth Dev',
|
||||
'Amazon Fire TV Remote'
|
||||
]:
|
||||
return {
|
||||
'triggerRun2': 23,
|
||||
'triggerRun1': 24,
|
||||
'buttonPickUp': 24,
|
||||
'buttonBomb': 91,
|
||||
'buttonJump': 86,
|
||||
'buttonUp': 20,
|
||||
'buttonLeft': 22,
|
||||
'startButtonActivatesDefaultWidget': False,
|
||||
'buttonRight': 23,
|
||||
'buttonStart': 83,
|
||||
'buttonPunch': 90,
|
||||
'buttonDown': 21
|
||||
}.get(name, -1)
|
||||
|
||||
elif 'NVIDIA SHIELD;' in useragentstring:
|
||||
if 'NVIDIA Controller' in devicename:
|
||||
return {
|
||||
'triggerRun2': 19,
|
||||
'triggerRun1': 18,
|
||||
'buttonPickUp': 101,
|
||||
'buttonBomb': 98,
|
||||
'buttonJump': 97,
|
||||
'analogStickDeadZone': 0.0,
|
||||
'buttonStart': 109,
|
||||
'buttonPunch': 100,
|
||||
'buttonIgnored': 184,
|
||||
'buttonIgnored2': 86
|
||||
}.get(name, -1)
|
||||
elif platform == 'mac':
|
||||
if devicename == 'PLAYSTATION(R)3 Controller':
|
||||
return {
|
||||
'buttonLeft': 8,
|
||||
'buttonUp': 5,
|
||||
'buttonRight': 6,
|
||||
'buttonDown': 7,
|
||||
'buttonJump': 15,
|
||||
'buttonPunch': 16,
|
||||
'buttonBomb': 14,
|
||||
'buttonPickUp': 13,
|
||||
'buttonStart': 4,
|
||||
'buttonIgnored': 17
|
||||
}.get(name, -1)
|
||||
if devicename in ['Wireless 360 Controller', 'Controller']:
|
||||
|
||||
# Xbox360 gamepads
|
||||
return {
|
||||
'analogStickDeadZone': 1.2,
|
||||
'buttonBomb': 13,
|
||||
'buttonDown': 2,
|
||||
'buttonJump': 12,
|
||||
'buttonLeft': 3,
|
||||
'buttonPickUp': 15,
|
||||
'buttonPunch': 14,
|
||||
'buttonRight': 4,
|
||||
'buttonStart': 5,
|
||||
'buttonUp': 1,
|
||||
'triggerRun1': 5,
|
||||
'triggerRun2': 6,
|
||||
'buttonIgnored': 11
|
||||
}.get(name, -1)
|
||||
if (devicename in [
|
||||
'Logitech Dual Action', 'Logitech Cordless RumblePad 2'
|
||||
]):
|
||||
return {
|
||||
'buttonJump': 2,
|
||||
'buttonPunch': 1,
|
||||
'buttonBomb': 3,
|
||||
'buttonPickUp': 4,
|
||||
'buttonStart': 10
|
||||
}.get(name, -1)
|
||||
|
||||
# Old gravis gamepad.
|
||||
if devicename == 'GamePad Pro USB ':
|
||||
return {
|
||||
'buttonJump': 2,
|
||||
'buttonPunch': 1,
|
||||
'buttonBomb': 3,
|
||||
'buttonPickUp': 4,
|
||||
'buttonStart': 10
|
||||
}.get(name, -1)
|
||||
|
||||
if devicename == 'Microsoft SideWinder Plug & Play Game Pad':
|
||||
return {
|
||||
'buttonJump': 1,
|
||||
'buttonPunch': 3,
|
||||
'buttonBomb': 2,
|
||||
'buttonPickUp': 4,
|
||||
'buttonStart': 6
|
||||
}.get(name, -1)
|
||||
|
||||
# Saitek P2500 Rumble Force Pad.. (hopefully works for others too?..)
|
||||
if devicename == 'Saitek P2500 Rumble Force Pad':
|
||||
return {
|
||||
'buttonJump': 3,
|
||||
'buttonPunch': 1,
|
||||
'buttonBomb': 4,
|
||||
'buttonPickUp': 2,
|
||||
'buttonStart': 11
|
||||
}.get(name, -1)
|
||||
|
||||
# Some crazy 'Senze' dual gamepad.
|
||||
if devicename == 'Twin USB Joystick':
|
||||
return {
|
||||
'analogStickLR': 3,
|
||||
'analogStickLR_B': 7,
|
||||
'analogStickUD': 4,
|
||||
'analogStickUD_B': 8,
|
||||
'buttonBomb': 2,
|
||||
'buttonBomb_B': 14,
|
||||
'buttonJump': 3,
|
||||
'buttonJump_B': 15,
|
||||
'buttonPickUp': 1,
|
||||
'buttonPickUp_B': 13,
|
||||
'buttonPunch': 4,
|
||||
'buttonPunch_B': 16,
|
||||
'buttonRun1': 7,
|
||||
'buttonRun1_B': 19,
|
||||
'buttonRun2': 8,
|
||||
'buttonRun2_B': 20,
|
||||
'buttonStart': 10,
|
||||
'buttonStart_B': 22,
|
||||
'enableSecondary': 1,
|
||||
'unassignedButtonsRun': False
|
||||
}.get(name, -1)
|
||||
if devicename == 'USB Gamepad ': # some weird 'JITE' gamepad
|
||||
return {
|
||||
'analogStickLR': 4,
|
||||
'analogStickUD': 5,
|
||||
'buttonJump': 3,
|
||||
'buttonPunch': 4,
|
||||
'buttonBomb': 2,
|
||||
'buttonPickUp': 1,
|
||||
'buttonStart': 10
|
||||
}.get(name, -1)
|
||||
|
||||
default_android_mapping = {
|
||||
'triggerRun2': 19,
|
||||
'unassignedButtonsRun': False,
|
||||
'buttonPickUp': 101,
|
||||
'buttonBomb': 98,
|
||||
'buttonJump': 97,
|
||||
'buttonStart': 83,
|
||||
'buttonStart2': 109,
|
||||
'buttonPunch': 100,
|
||||
'buttonRun2': 104,
|
||||
'buttonRun1': 103,
|
||||
'triggerRun1': 18,
|
||||
'buttonLeft': 22,
|
||||
'buttonRight': 23,
|
||||
'buttonUp': 20,
|
||||
'buttonDown': 21,
|
||||
'buttonVRReorient': 110
|
||||
}
|
||||
|
||||
# Generic android...
|
||||
if platform == 'android':
|
||||
|
||||
# Steelseries stratus xl.
|
||||
if devicename == 'SteelSeries Stratus XL':
|
||||
return {
|
||||
'triggerRun2': 23,
|
||||
'unassignedButtonsRun': False,
|
||||
'buttonPickUp': 101,
|
||||
'buttonBomb': 98,
|
||||
'buttonJump': 97,
|
||||
'buttonStart': 83,
|
||||
'buttonStart2': 109,
|
||||
'buttonPunch': 100,
|
||||
'buttonRun2': 104,
|
||||
'buttonRun1': 103,
|
||||
'triggerRun1': 24,
|
||||
'buttonLeft': 22,
|
||||
'buttonRight': 23,
|
||||
'buttonUp': 20,
|
||||
'buttonDown': 21,
|
||||
'buttonVRReorient': 108
|
||||
}.get(name, -1)
|
||||
|
||||
# Adt-1 gamepad (use funky 'mode' button for start).
|
||||
if devicename == 'Gamepad':
|
||||
return {
|
||||
'triggerRun2': 19,
|
||||
'unassignedButtonsRun': False,
|
||||
'buttonPickUp': 101,
|
||||
'buttonBomb': 98,
|
||||
'buttonJump': 97,
|
||||
'buttonStart': 111,
|
||||
'buttonPunch': 100,
|
||||
'startButtonActivatesDefaultWidget': False,
|
||||
'buttonRun2': 104,
|
||||
'buttonRun1': 103,
|
||||
'triggerRun1': 18
|
||||
}.get(name, -1)
|
||||
# Nexus player remote.
|
||||
if devicename == 'Nexus Remote':
|
||||
return {
|
||||
'triggerRun2': 19,
|
||||
'unassignedButtonsRun': False,
|
||||
'buttonPickUp': 101,
|
||||
'buttonBomb': 98,
|
||||
'buttonJump': 97,
|
||||
'buttonUp': 20,
|
||||
'buttonLeft': 22,
|
||||
'buttonDown': 21,
|
||||
'buttonRight': 23,
|
||||
'buttonStart': 83,
|
||||
'buttonStart2': 109,
|
||||
'buttonPunch': 24,
|
||||
'buttonRun2': 104,
|
||||
'buttonRun1': 103,
|
||||
'triggerRun1': 18
|
||||
}.get(name, -1)
|
||||
|
||||
if devicename == "virtual-remote":
|
||||
return {
|
||||
'triggerRun2': 19,
|
||||
'unassignedButtonsRun': False,
|
||||
'buttonPickUp': 101,
|
||||
'buttonBomb': 98,
|
||||
'buttonStart': 83,
|
||||
'buttonJump': 24,
|
||||
'buttonUp': 20,
|
||||
'buttonLeft': 22,
|
||||
'buttonRight': 23,
|
||||
'triggerRun1': 18,
|
||||
'buttonStart2': 109,
|
||||
'buttonPunch': 100,
|
||||
'buttonRun2': 104,
|
||||
'buttonRun1': 103,
|
||||
'buttonDown': 21,
|
||||
'startButtonActivatesDefaultWidget': False,
|
||||
'uiOnly': True
|
||||
}.get(name, -1)
|
||||
|
||||
# flag particular gamepads to use exact android defaults..
|
||||
# (so they don't even ask to configure themselves)
|
||||
if devicename in ['Samsung Game Pad EI-GP20', 'ASUS Gamepad'
|
||||
] or devicename.startswith('Freefly VR Glide'):
|
||||
return default_android_mapping.get(name, -1)
|
||||
|
||||
# Nvidia controller is default, but gets some strange
|
||||
# keypresses we want to ignore.. touching the touchpad,
|
||||
# so lets ignore those.
|
||||
if 'NVIDIA Controller' in devicename:
|
||||
return {
|
||||
'triggerRun2': 19,
|
||||
'unassignedButtonsRun': False,
|
||||
'buttonPickUp': 101,
|
||||
'buttonIgnored': 126,
|
||||
'buttonIgnored2': 1,
|
||||
'buttonBomb': 98,
|
||||
'buttonJump': 97,
|
||||
'buttonStart': 83,
|
||||
'buttonStart2': 109,
|
||||
'buttonPunch': 100,
|
||||
'buttonRun2': 104,
|
||||
'buttonRun1': 103,
|
||||
'triggerRun1': 18
|
||||
}.get(name, -1)
|
||||
|
||||
# Default keyboard vals across platforms..
|
||||
if devicename == 'Keyboard' and unique_id == '#2':
|
||||
if platform == 'mac' and subplatform == 'appstore':
|
||||
return {
|
||||
'buttonJump': 258,
|
||||
'buttonPunch': 257,
|
||||
'buttonBomb': 262,
|
||||
'buttonPickUp': 261,
|
||||
'buttonUp': 273,
|
||||
'buttonDown': 274,
|
||||
'buttonLeft': 276,
|
||||
'buttonRight': 275,
|
||||
'buttonStart': 263
|
||||
}.get(name, -1)
|
||||
return {
|
||||
'buttonPickUp': 1073741917,
|
||||
'buttonBomb': 1073741918,
|
||||
'buttonJump': 1073741914,
|
||||
'buttonUp': 1073741906,
|
||||
'buttonLeft': 1073741904,
|
||||
'buttonRight': 1073741903,
|
||||
'buttonStart': 1073741919,
|
||||
'buttonPunch': 1073741913,
|
||||
'buttonDown': 1073741905
|
||||
}.get(name, -1)
|
||||
if devicename == 'Keyboard' and unique_id == '#1':
|
||||
return {
|
||||
'buttonJump': 107,
|
||||
'buttonPunch': 106,
|
||||
'buttonBomb': 111,
|
||||
'buttonPickUp': 105,
|
||||
'buttonUp': 119,
|
||||
'buttonDown': 115,
|
||||
'buttonLeft': 97,
|
||||
'buttonRight': 100
|
||||
}.get(name, -1)
|
||||
|
||||
# Ok, this gamepad's not in our specific preset list;
|
||||
# fall back to some (hopefully) reasonable defaults.
|
||||
|
||||
# Leaving these in here for now but not gonna add any more now that we have
|
||||
# fancy-pants config sharing across the internet.
|
||||
if platform == 'mac':
|
||||
if 'PLAYSTATION' in devicename: # ps3 gamepad?..
|
||||
return {
|
||||
'buttonLeft': 8,
|
||||
'buttonUp': 5,
|
||||
'buttonRight': 6,
|
||||
'buttonDown': 7,
|
||||
'buttonJump': 15,
|
||||
'buttonPunch': 16,
|
||||
'buttonBomb': 14,
|
||||
'buttonPickUp': 13,
|
||||
'buttonStart': 4
|
||||
}.get(name, -1)
|
||||
|
||||
# Dual Action Config - hopefully applies to more...
|
||||
if 'Logitech' in devicename:
|
||||
return {
|
||||
'buttonJump': 2,
|
||||
'buttonPunch': 1,
|
||||
'buttonBomb': 3,
|
||||
'buttonPickUp': 4,
|
||||
'buttonStart': 10
|
||||
}.get(name, -1)
|
||||
|
||||
# Saitek P2500 Rumble Force Pad.. (hopefully works for others too?..)
|
||||
if 'Saitek' in devicename:
|
||||
return {
|
||||
'buttonJump': 3,
|
||||
'buttonPunch': 1,
|
||||
'buttonBomb': 4,
|
||||
'buttonPickUp': 2,
|
||||
'buttonStart': 11
|
||||
}.get(name, -1)
|
||||
|
||||
# Gravis stuff?...
|
||||
if 'GamePad' in devicename:
|
||||
return {
|
||||
'buttonJump': 2,
|
||||
'buttonPunch': 1,
|
||||
'buttonBomb': 3,
|
||||
'buttonPickUp': 4,
|
||||
'buttonStart': 10
|
||||
}.get(name, -1)
|
||||
|
||||
# Reasonable defaults.
|
||||
if platform == 'android':
|
||||
if _ba.is_running_on_fire_tv():
|
||||
|
||||
# Mostly same as default firetv controller.
|
||||
return {
|
||||
'triggerRun2': 23,
|
||||
'triggerRun1': 24,
|
||||
'buttonPickUp': 101,
|
||||
'buttonBomb': 98,
|
||||
'buttonJump': 97,
|
||||
'buttonStart': 83,
|
||||
'buttonPunch': 100,
|
||||
'buttonDown': 21,
|
||||
'buttonUp': 20,
|
||||
'buttonLeft': 22,
|
||||
'buttonRight': 23,
|
||||
'startButtonActivatesDefaultWidget': False,
|
||||
}.get(name, -1)
|
||||
|
||||
# Mostly same as 'Gamepad' except with 'menu' for default start
|
||||
# button instead of 'mode'.
|
||||
return default_android_mapping.get(name, -1)
|
||||
|
||||
# Is there a point to any sort of fallbacks here?.. should check.
|
||||
return {
|
||||
'buttonJump': 1,
|
||||
'buttonPunch': 2,
|
||||
'buttonBomb': 3,
|
||||
'buttonPickUp': 4,
|
||||
'buttonStart': 5
|
||||
}.get(name, -1)
|
||||
|
||||
|
||||
def _gen_android_input_hash() -> str:
|
||||
import os
|
||||
import hashlib
|
||||
md5 = hashlib.md5()
|
||||
|
||||
# Currently we just do a single hash of *all* inputs on android
|
||||
# and that's it.. good enough.
|
||||
# (grabbing mappings for a specific device looks to be non-trivial)
|
||||
for dirname in [
|
||||
'/system/usr/keylayout', '/data/usr/keylayout',
|
||||
'/data/system/devices/keylayout'
|
||||
]:
|
||||
try:
|
||||
if os.path.isdir(dirname):
|
||||
for f_name in os.listdir(dirname):
|
||||
# This is usually volume keys and stuff;
|
||||
# assume we can skip it?..
|
||||
# (since it'll vary a lot across devices)
|
||||
if f_name == 'gpio-keys.kl':
|
||||
continue
|
||||
with open(dirname + '/' + f_name, 'rb') as infile:
|
||||
md5.update(infile.read())
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception(
|
||||
'error in _gen_android_input_hash inner loop')
|
||||
return md5.hexdigest()
|
||||
|
||||
|
||||
def get_input_map_hash(inputdevice: ba.InputDevice) -> str:
|
||||
"""Given an input device, return a hash based on its raw input values.
|
||||
|
||||
This lets us avoid sharing mappings across devices that may
|
||||
have the same name but actually produce different input values.
|
||||
(Different Android versions, for example, may return different
|
||||
key codes for button presses on a given type of controller)
|
||||
"""
|
||||
del inputdevice # Currently unused.
|
||||
app = _ba.app
|
||||
try:
|
||||
if app.input_map_hash is None:
|
||||
if app.platform == 'android':
|
||||
app.input_map_hash = _gen_android_input_hash()
|
||||
else:
|
||||
app.input_map_hash = ''
|
||||
return app.input_map_hash
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('Exception in get_input_map_hash')
|
||||
return ''
|
||||
|
||||
|
||||
def get_input_device_config(device: ba.InputDevice,
|
||||
default: bool) -> Tuple[Dict, str]:
|
||||
"""Given an input device, return its config dict in the app config.
|
||||
|
||||
The dict will be created if it does not exist.
|
||||
"""
|
||||
cfg = _ba.app.config
|
||||
name = device.name
|
||||
ccfgs: Dict[str, Any] = cfg.setdefault("Controllers", {})
|
||||
ccfgs.setdefault(name, {})
|
||||
unique_id = device.unique_identifier
|
||||
if default:
|
||||
if unique_id in ccfgs[name]:
|
||||
del ccfgs[name][unique_id]
|
||||
if 'default' not in ccfgs[name]:
|
||||
ccfgs[name]['default'] = {}
|
||||
return ccfgs[name], 'default'
|
||||
if unique_id not in ccfgs[name]:
|
||||
ccfgs[name][unique_id] = {}
|
||||
return ccfgs[name], unique_id
|
||||
|
||||
|
||||
def get_last_player_name_from_input_device(device: ba.InputDevice) -> str:
|
||||
"""Return a reasonable player name associated with a device.
|
||||
|
||||
(generally the last one used there)
|
||||
"""
|
||||
bs_config = _ba.app.config
|
||||
|
||||
# Look for a default player profile name for them;
|
||||
# otherwise default to their current random name.
|
||||
profilename = '_random'
|
||||
key_name = device.name + ' ' + device.unique_identifier
|
||||
if ('Default Player Profiles' in bs_config
|
||||
and key_name in bs_config['Default Player Profiles']):
|
||||
profilename = bs_config['Default Player Profiles'][key_name]
|
||||
if profilename == '_random':
|
||||
profilename = device.get_default_player_name()
|
||||
if profilename == '__account__':
|
||||
profilename = _ba.get_account_display_string()
|
||||
return profilename
|
||||
411
assets/src/data/scripts/ba/_lang.py
Normal file
411
assets/src/data/scripts/ba/_lang.py
Normal file
@ -0,0 +1,411 @@
|
||||
"""Language related functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class Lstr:
|
||||
"""Used to specify strings in a language-independent way.
|
||||
|
||||
category: General Utility Classes
|
||||
|
||||
These should be used whenever possible in place of hard-coded strings
|
||||
so that in-game or UI elements show up correctly on all clients in their
|
||||
currently-active language.
|
||||
|
||||
To see available resource keys, look at any of the bs_language_*.py files
|
||||
in the game or the translations pages at bombsquadgame.com/translate.
|
||||
|
||||
# EXAMPLE 1: specify a string from a resource path
|
||||
mynode.text = ba.Lstr(resource='audioSettingsWindow.titleText')
|
||||
|
||||
# EXAMPLE 2: specify a translated string via a category and english value;
|
||||
# if a translated value is available, it will be used; otherwise the
|
||||
# english value will be. To see available translation categories, look
|
||||
# under the 'translations' resource section.
|
||||
mynode.text = ba.Lstr(translate=('gameDescriptions', 'Defeat all enemies'))
|
||||
|
||||
# EXAMPLE 3: specify a raw value and some substitutions. Substitutions can
|
||||
# be used with resource and translate modes as well.
|
||||
mynode.text = ba.Lstr(value='${A} / ${B}',
|
||||
subs=[('${A}', str(score)), ('${B}', str(total))])
|
||||
|
||||
# EXAMPLE 4: Lstrs can be nested. This example would display the resource
|
||||
# at res_a but replace ${NAME} with the value of the resource at res_b
|
||||
mytextnode.text = ba.Lstr(resource='res_a',
|
||||
subs=[('${NAME}', ba.Lstr(resource='res_b'))])
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **keywds: Any) -> None:
|
||||
"""Instantiate a Lstr.
|
||||
|
||||
Pass a value for either 'resource', 'translate',
|
||||
or 'value'. (see Lstr help for examples).
|
||||
'subs' can be a sequence of 2-member sequences consisting of values
|
||||
and replacements.
|
||||
'fallback_resource' can be a resource key that will be used if the
|
||||
main one is not present for
|
||||
the current language in place of falling back to the english value
|
||||
('resource' mode only).
|
||||
'fallback_value' can be a literal string that will be used if neither
|
||||
the resource nor the fallback resource is found ('resource' mode only).
|
||||
"""
|
||||
# pylint: disable=too-many-branches
|
||||
if args:
|
||||
raise Exception('Lstr accepts only keyword arguments')
|
||||
|
||||
# Basically just store the exact args they passed.
|
||||
# However if they passed any Lstr values for subs,
|
||||
# replace them with that Lstr's dict.
|
||||
self.args = keywds
|
||||
our_type = type(self)
|
||||
|
||||
if isinstance(self.args.get('value'), our_type):
|
||||
raise Exception("'value' must be a regular string; not an Lstr")
|
||||
|
||||
if 'subs' in self.args:
|
||||
subs_new = []
|
||||
for key, value in keywds['subs']:
|
||||
if isinstance(value, our_type):
|
||||
subs_new.append((key, value.args))
|
||||
else:
|
||||
subs_new.append((key, value))
|
||||
self.args['subs'] = subs_new
|
||||
|
||||
# As of protocol 31 we support compact key names
|
||||
# ('t' instead of 'translate', etc). Convert as needed.
|
||||
if 'translate' in keywds:
|
||||
keywds['t'] = keywds['translate']
|
||||
del keywds['translate']
|
||||
if 'resource' in keywds:
|
||||
keywds['r'] = keywds['resource']
|
||||
del keywds['resource']
|
||||
if 'value' in keywds:
|
||||
keywds['v'] = keywds['value']
|
||||
del keywds['value']
|
||||
if 'fallback' in keywds:
|
||||
from ba import _error
|
||||
_error.print_error(
|
||||
'deprecated "fallback" arg passed to Lstr(); use '
|
||||
'either "fallback_resource" or "fallback_value"',
|
||||
once=True)
|
||||
keywds['f'] = keywds['fallback']
|
||||
del keywds['fallback']
|
||||
if 'fallback_resource' in keywds:
|
||||
keywds['f'] = keywds['fallback_resource']
|
||||
del keywds['fallback_resource']
|
||||
if 'subs' in keywds:
|
||||
keywds['s'] = keywds['subs']
|
||||
del keywds['subs']
|
||||
if 'fallback_value' in keywds:
|
||||
keywds['fv'] = keywds['fallback_value']
|
||||
del keywds['fallback_value']
|
||||
|
||||
def evaluate(self) -> str:
|
||||
"""Evaluate the Lstr and returns a flat string in the current language.
|
||||
|
||||
You should avoid doing this as much as possible and instead pass
|
||||
and store Lstr values.
|
||||
"""
|
||||
return _ba.evaluate_lstr(self._get_json())
|
||||
|
||||
def is_flat_value(self) -> bool:
|
||||
"""Return whether the Lstr is a 'flat' value.
|
||||
|
||||
This is defined as a simple string value incorporating no translations,
|
||||
resources, or substitutions. In this case it may be reasonable to
|
||||
replace it with a raw string value, perform string manipulation on it,
|
||||
etc.
|
||||
"""
|
||||
return bool('v' in self.args and not self.args.get('s', []))
|
||||
|
||||
def _get_json(self) -> str:
|
||||
try:
|
||||
return json.dumps(self.args, separators=(',', ':'))
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('_get_json failed for', self.args)
|
||||
return 'JSON_ERR'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return '<ba.Lstr: ' + self._get_json() + '>'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '<ba.Lstr: ' + self._get_json() + '>'
|
||||
|
||||
|
||||
def setlanguage(language: Optional[str],
|
||||
print_change: bool = True,
|
||||
store_to_config: bool = True) -> None:
|
||||
"""Set the active language used for the game.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
Pass None to use OS default language.
|
||||
"""
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-branches
|
||||
cfg = _ba.app.config
|
||||
cur_language = cfg.get('Lang', None)
|
||||
|
||||
# Store this in the config if its changing.
|
||||
if language != cur_language and store_to_config:
|
||||
if language is None:
|
||||
if 'Lang' in cfg:
|
||||
del cfg['Lang'] # Clear it out for default.
|
||||
else:
|
||||
cfg['Lang'] = language
|
||||
cfg.commit()
|
||||
switched = True
|
||||
else:
|
||||
switched = False
|
||||
|
||||
with open('data/data/languages/english.json') as infile:
|
||||
lenglishvalues = json.loads(infile.read())
|
||||
|
||||
# None implies default.
|
||||
if language is None:
|
||||
language = _ba.app.default_language
|
||||
try:
|
||||
if language == 'English':
|
||||
lmodvalues = None
|
||||
else:
|
||||
lmodfile = 'data/data/languages/' + language.lower() + '.json'
|
||||
with open(lmodfile) as infile:
|
||||
lmodvalues = json.loads(infile.read())
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('Exception importing language:', language)
|
||||
_ba.screenmessage("Error setting language to '" + language +
|
||||
"'; see log for details",
|
||||
color=(1, 0, 0))
|
||||
switched = False
|
||||
lmodvalues = None
|
||||
|
||||
# Create an attrdict of *just* our target language.
|
||||
_ba.app.language_target = AttrDict()
|
||||
langtarget = _ba.app.language_target
|
||||
assert langtarget is not None
|
||||
_add_to_attr_dict(langtarget,
|
||||
lmodvalues if lmodvalues is not None else lenglishvalues)
|
||||
|
||||
# Create an attrdict of our target language overlaid on our base (english).
|
||||
languages = [lenglishvalues]
|
||||
if lmodvalues is not None:
|
||||
languages.append(lmodvalues)
|
||||
lfull = AttrDict()
|
||||
for lmod in languages:
|
||||
_add_to_attr_dict(lfull, lmod)
|
||||
_ba.app.language_merged = lfull
|
||||
|
||||
# Pass some keys/values in for low level code to use;
|
||||
# start with everything in their 'internal' section.
|
||||
internal_vals = [
|
||||
v for v in list(lfull['internal'].items()) if isinstance(v[1], str)
|
||||
]
|
||||
|
||||
# Cherry-pick various other values to include.
|
||||
# (should probably get rid of the 'internal' section
|
||||
# and do everything this way)
|
||||
for value in [
|
||||
'replayNameDefaultText', 'replayWriteErrorText',
|
||||
'replayVersionErrorText', 'replayReadErrorText'
|
||||
]:
|
||||
internal_vals.append((value, lfull[value]))
|
||||
internal_vals.append(
|
||||
('axisText', lfull['configGamepadWindow']['axisText']))
|
||||
lmerged = _ba.app.language_merged
|
||||
assert lmerged is not None
|
||||
random_names = [
|
||||
n.strip() for n in lmerged['randomPlayerNamesText'].split(',')
|
||||
]
|
||||
random_names = [n for n in random_names if n != '']
|
||||
_ba.set_internal_language_keys(internal_vals, random_names)
|
||||
if switched and print_change:
|
||||
_ba.screenmessage(Lstr(resource='languageSetText',
|
||||
subs=[('${LANGUAGE}',
|
||||
Lstr(translate=('languages', language)))
|
||||
]),
|
||||
color=(0, 1, 0))
|
||||
|
||||
|
||||
def _add_to_attr_dict(dst: AttrDict, src: Dict) -> None:
|
||||
for key, value in list(src.items()):
|
||||
if isinstance(value, dict):
|
||||
try:
|
||||
dst_dict = dst[key]
|
||||
except Exception:
|
||||
dst_dict = dst[key] = AttrDict()
|
||||
if not isinstance(dst_dict, AttrDict):
|
||||
raise Exception("language key '" + key +
|
||||
"' is defined both as a dict and value")
|
||||
_add_to_attr_dict(dst_dict, value)
|
||||
else:
|
||||
if not isinstance(value, (float, int, bool, str, str, type(None))):
|
||||
raise Exception("invalid value type for res '" + key + "': " +
|
||||
str(type(value)))
|
||||
dst[key] = value
|
||||
|
||||
|
||||
class AttrDict(dict):
|
||||
"""A dict that can be accessed with dot notation.
|
||||
|
||||
(so foo.bar is equivalent to foo['bar'])
|
||||
"""
|
||||
|
||||
def __getattr__(self, attr: str) -> Any:
|
||||
val = self[attr]
|
||||
assert not isinstance(val, bytes)
|
||||
return val
|
||||
|
||||
def __setattr__(self, attr: str, value: Any) -> None:
|
||||
raise Exception()
|
||||
|
||||
|
||||
def get_resource(resource: str,
|
||||
fallback_resource: str = None,
|
||||
fallback_value: Any = None) -> Any:
|
||||
"""Return a translation resource by name."""
|
||||
try:
|
||||
# If we have no language set, go ahead and set it.
|
||||
if _ba.app.language_merged is None:
|
||||
language = _ba.app.language
|
||||
try:
|
||||
setlanguage(language,
|
||||
print_change=False,
|
||||
store_to_config=False)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('exception setting language to',
|
||||
language)
|
||||
|
||||
# Try english as a fallback.
|
||||
if language != 'English':
|
||||
print('Resorting to fallback language (English)')
|
||||
try:
|
||||
setlanguage('English',
|
||||
print_change=False,
|
||||
store_to_config=False)
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'error setting language to english fallback')
|
||||
|
||||
# If they provided a fallback_resource value, try the
|
||||
# target-language-only dict first and then fall back to trying the
|
||||
# fallback_resource value in the merged dict.
|
||||
if fallback_resource is not None:
|
||||
try:
|
||||
values = _ba.app.language_target
|
||||
splits = resource.split('.')
|
||||
dicts = splits[:-1]
|
||||
key = splits[-1]
|
||||
for dct in dicts:
|
||||
assert values is not None
|
||||
values = values[dct]
|
||||
assert values is not None
|
||||
val = values[key]
|
||||
return val
|
||||
except Exception:
|
||||
# FIXME: Shouldn't we try the fallback resource in the merged
|
||||
# dict AFTER we try the main resource in the merged dict?
|
||||
try:
|
||||
values = _ba.app.language_merged
|
||||
splits = fallback_resource.split('.')
|
||||
dicts = splits[:-1]
|
||||
key = splits[-1]
|
||||
for dct in dicts:
|
||||
assert values is not None
|
||||
values = values[dct]
|
||||
assert values is not None
|
||||
val = values[key]
|
||||
return val
|
||||
|
||||
except Exception:
|
||||
# If we got nothing for fallback_resource, default to the
|
||||
# normal code which checks or primary value in the merge
|
||||
# dict; there's a chance we can get an english value for
|
||||
# it (which we weren't looking for the first time through).
|
||||
pass
|
||||
|
||||
values = _ba.app.language_merged
|
||||
splits = resource.split('.')
|
||||
dicts = splits[:-1]
|
||||
key = splits[-1]
|
||||
for dct in dicts:
|
||||
assert values is not None
|
||||
values = values[dct]
|
||||
assert values is not None
|
||||
val = values[key]
|
||||
return val
|
||||
|
||||
except Exception:
|
||||
# Ok, looks like we couldn't find our main or fallback resource
|
||||
# anywhere. Now if we've been given a fallback value, return it;
|
||||
# otherwise fail.
|
||||
if fallback_value is not None:
|
||||
return fallback_value
|
||||
raise Exception("resource not found: '" + resource + "'")
|
||||
|
||||
|
||||
def translate(category: str,
|
||||
strval: str,
|
||||
raise_exceptions: bool = False,
|
||||
print_errors: bool = False) -> str:
|
||||
"""Translate a value (or return the value if no translation available)
|
||||
|
||||
Generally you should use ba.Lstr which handles dynamic translation,
|
||||
as opposed to this which returns a flat string.
|
||||
"""
|
||||
try:
|
||||
translated = get_resource('translations')[category][strval]
|
||||
except Exception as exc:
|
||||
if raise_exceptions:
|
||||
raise
|
||||
if print_errors:
|
||||
print(('Translate error: category=\'' + category + '\' name=\'' +
|
||||
strval + '\' exc=' + str(exc) + ''))
|
||||
translated = None
|
||||
translated_out: str
|
||||
if translated is None:
|
||||
translated_out = strval
|
||||
else:
|
||||
translated_out = translated
|
||||
assert isinstance(translated_out, str)
|
||||
return translated_out
|
||||
|
||||
|
||||
def get_valid_languages() -> List[str]:
|
||||
"""Return a list containing names of all available languages.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
Languages that may be present but are not displayable on the running
|
||||
version of the game are ignored.
|
||||
"""
|
||||
langs = set()
|
||||
app = _ba.app
|
||||
try:
|
||||
names = os.listdir('data/data/languages')
|
||||
names = [n.replace('.json', '').capitalize() for n in names]
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception()
|
||||
names = []
|
||||
for name in names:
|
||||
if app.can_display_language(name):
|
||||
langs.add(name)
|
||||
return sorted(name for name in names if app.can_display_language(name))
|
||||
|
||||
|
||||
def is_custom_unicode_char(char: str) -> bool:
|
||||
"""Return whether a char is in the custom unicode range we use."""
|
||||
if not isinstance(char, str) or len(char) != 1:
|
||||
raise Exception("Invalid Input; not unicode or not length 1")
|
||||
return 0xE000 <= ord(char) <= 0xF8FF
|
||||
167
assets/src/data/scripts/ba/_level.py
Normal file
167
assets/src/data/scripts/ba/_level.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""Functionality related to individual levels in a campaign."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from weakref import ReferenceType
|
||||
from typing import Type, Any, Dict, Optional
|
||||
import ba
|
||||
|
||||
|
||||
class Level:
|
||||
"""An entry in a ba.Campaign consisting of a name, game type, and settings.
|
||||
|
||||
category: Gameplay Classes
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
name: str,
|
||||
gametype: Type[ba.GameActivity],
|
||||
settings: Dict[str, Any],
|
||||
preview_texture_name: str,
|
||||
displayname: str = None):
|
||||
"""Initializes a Level object with the provided values."""
|
||||
self._name = name
|
||||
self._gametype = gametype
|
||||
self._settings = settings
|
||||
self._preview_texture_name = preview_texture_name
|
||||
self._displayname = displayname
|
||||
self._campaign: Optional[ReferenceType[ba.Campaign]] = None
|
||||
self._index: Optional[int] = None
|
||||
self._score_version_string: Optional[str] = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""The unique name for this Level."""
|
||||
return self._name
|
||||
|
||||
def get_settings(self) -> Dict[str, Any]:
|
||||
"""Returns the settings for this Level."""
|
||||
settings = copy.deepcopy(self._settings)
|
||||
|
||||
# So the game knows what the level is called.
|
||||
# Hmm; seems hacky; I think we should take this out.
|
||||
settings['name'] = self._name
|
||||
return settings
|
||||
|
||||
@property
|
||||
def preview_texture_name(self) -> str:
|
||||
"""The preview texture name for this Level."""
|
||||
return self._preview_texture_name
|
||||
|
||||
def get_preview_texture(self) -> ba.Texture:
|
||||
"""Load/return the preview Texture for this Level."""
|
||||
return _ba.gettexture(self._preview_texture_name)
|
||||
|
||||
@property
|
||||
def displayname(self) -> ba.Lstr:
|
||||
"""The localized name for this Level."""
|
||||
from ba import _lang
|
||||
return _lang.Lstr(
|
||||
translate=('coopLevelNames', self._displayname
|
||||
if self._displayname is not None else self._name),
|
||||
subs=[('${GAME}',
|
||||
self._gametype.get_display_string(self._settings))])
|
||||
|
||||
@property
|
||||
def gametype(self) -> Type[ba.GameActivity]:
|
||||
"""The type of game used for this Level."""
|
||||
return self._gametype
|
||||
|
||||
def get_campaign(self) -> Optional[ba.Campaign]:
|
||||
"""Return the ba.Campaign this Level is associated with, or None."""
|
||||
return None if self._campaign is None else self._campaign()
|
||||
|
||||
@property
|
||||
def index(self) -> int:
|
||||
"""The zero-based index of this Level in its ba.Campaign.
|
||||
|
||||
Access results in a RuntimeError if the Level is not assigned to a
|
||||
Campaign.
|
||||
"""
|
||||
if self._index is None:
|
||||
raise RuntimeError("Level is not part of a Campaign")
|
||||
return self._index
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""Whether this Level has been completed."""
|
||||
config = self._get_config_dict()
|
||||
return config.get('Complete', False)
|
||||
|
||||
def set_complete(self, val: bool) -> None:
|
||||
"""Set whether or not this level is complete."""
|
||||
old_val = self.complete
|
||||
assert isinstance(old_val, bool) and isinstance(val, bool)
|
||||
if val != old_val:
|
||||
config = self._get_config_dict()
|
||||
config['Complete'] = val
|
||||
|
||||
def get_high_scores(self) -> Dict:
|
||||
"""Return the current high scores for this Level."""
|
||||
config = self._get_config_dict()
|
||||
high_scores_key = 'High Scores' + self.get_score_version_string()
|
||||
if high_scores_key not in config:
|
||||
return {}
|
||||
return copy.deepcopy(config[high_scores_key])
|
||||
|
||||
def set_high_scores(self, high_scores: Dict) -> None:
|
||||
"""Set high scores for this level."""
|
||||
config = self._get_config_dict()
|
||||
high_scores_key = 'High Scores' + self.get_score_version_string()
|
||||
config[high_scores_key] = high_scores
|
||||
|
||||
def get_score_version_string(self) -> str:
|
||||
"""Return the score version string for this Level.
|
||||
|
||||
If a Level's gameplay changes significantly, its version string
|
||||
can be changed to separate its new high score lists/etc. from the old.
|
||||
"""
|
||||
if self._score_version_string is None:
|
||||
scorever = (
|
||||
self._gametype.get_resolved_score_info()['score_version'])
|
||||
if scorever != '':
|
||||
scorever = ' ' + scorever
|
||||
self._score_version_string = scorever
|
||||
assert self._score_version_string is not None
|
||||
return self._score_version_string
|
||||
|
||||
@property
|
||||
def rating(self) -> float:
|
||||
"""The current rating for this Level."""
|
||||
return self._get_config_dict().get('Rating', 0.0)
|
||||
|
||||
def set_rating(self, rating: float) -> None:
|
||||
"""Set a rating for this Level, replacing the old ONLY IF higher."""
|
||||
old_rating = self.rating
|
||||
config = self._get_config_dict()
|
||||
config['Rating'] = max(old_rating, rating)
|
||||
|
||||
def _get_config_dict(self) -> Dict[str, Any]:
|
||||
"""Return/create the persistent state dict for this level.
|
||||
|
||||
The referenced dict exists under the game's config dict and
|
||||
can be modified in place."""
|
||||
campaign = self.get_campaign()
|
||||
if campaign is None:
|
||||
raise Exception("level is not in a campaign")
|
||||
campaign_config = campaign.get_config_dict()
|
||||
val: Dict[str, Any] = campaign_config.setdefault(
|
||||
self._name, {
|
||||
'Rating': 0.0,
|
||||
'Complete': False
|
||||
})
|
||||
assert isinstance(val, dict)
|
||||
return val
|
||||
|
||||
def set_campaign(self, campaign: ba.Campaign, index: int) -> None:
|
||||
"""For use by ba.Campaign when adding levels to itself.
|
||||
|
||||
(internal)"""
|
||||
self._campaign = weakref.ref(campaign)
|
||||
self._index = index
|
||||
992
assets/src/data/scripts/ba/_lobby.py
Normal file
992
assets/src/data/scripts/ba/_lobby.py
Normal file
@ -0,0 +1,992 @@
|
||||
"""Implements lobby system for gathering before games, char select, etc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Optional, List, Dict, Any, Sequence, Union
|
||||
import ba
|
||||
|
||||
|
||||
# Hmm should we move this to actors?..
|
||||
class JoinInfo:
|
||||
"""Display useful info for joiners."""
|
||||
|
||||
def __init__(self, lobby: ba.Lobby):
|
||||
# pylint: disable=too-many-locals
|
||||
from ba import _input
|
||||
from ba._lang import Lstr
|
||||
from ba import _actor
|
||||
from ba import _general
|
||||
from ba._enums import SpecialChar
|
||||
can_switch_teams = (len(lobby.teams) > 1)
|
||||
self._state = 0
|
||||
press_to_punch: Union[str, ba.Lstr] = _ba.charstr(
|
||||
SpecialChar.LEFT_BUTTON)
|
||||
press_to_bomb: Union[str, ba.Lstr] = _ba.charstr(
|
||||
SpecialChar.RIGHT_BUTTON)
|
||||
|
||||
# If we have a keyboard, grab keys for punch and pickup.
|
||||
# FIXME: This of course is only correct on the local device;
|
||||
# Should change this for net games.
|
||||
keyboard = _ba.get_input_device('Keyboard', '#1', doraise=False)
|
||||
if keyboard is not None:
|
||||
punch_key = keyboard.get_button_name(
|
||||
_input.get_device_value(keyboard, 'buttonPunch'))
|
||||
press_to_punch = Lstr(resource='orText',
|
||||
subs=[('${A}',
|
||||
Lstr(value='\'${K}\'',
|
||||
subs=[('${K}', punch_key)])),
|
||||
('${B}', press_to_punch)])
|
||||
bomb_key = keyboard.get_button_name(
|
||||
_input.get_device_value(keyboard, 'buttonBomb'))
|
||||
press_to_bomb = Lstr(resource='orText',
|
||||
subs=[('${A}',
|
||||
Lstr(value='\'${K}\'',
|
||||
subs=[('${K}', bomb_key)])),
|
||||
('${B}', press_to_bomb)])
|
||||
join_str = Lstr(value='${A} < ${B} >',
|
||||
subs=[('${A}',
|
||||
Lstr(resource='pressPunchToJoinText')),
|
||||
('${B}', press_to_punch)])
|
||||
else:
|
||||
join_str = Lstr(resource='pressAnyButtonToJoinText')
|
||||
|
||||
flatness = 1.0 if _ba.app.vr_mode else 0.0
|
||||
self._text = _actor.Actor(
|
||||
_ba.newnode('text',
|
||||
attrs={
|
||||
'position': (0, -40),
|
||||
'h_attach': 'center',
|
||||
'v_attach': 'top',
|
||||
'h_align': 'center',
|
||||
'color': (0.7, 0.7, 0.95, 1.0),
|
||||
'flatness': flatness,
|
||||
'text': join_str
|
||||
}))
|
||||
|
||||
if _ba.app.kiosk_mode:
|
||||
self._messages = [join_str]
|
||||
else:
|
||||
msg1 = Lstr(resource='pressToSelectProfileText',
|
||||
subs=[
|
||||
('${BUTTONS}', _ba.charstr(SpecialChar.UP_ARROW) +
|
||||
' ' + _ba.charstr(SpecialChar.DOWN_ARROW))
|
||||
])
|
||||
msg2 = Lstr(resource='pressToOverrideCharacterText',
|
||||
subs=[('${BUTTONS}', Lstr(resource='bombBoldText'))])
|
||||
msg3 = Lstr(value='${A} < ${B} >',
|
||||
subs=[('${A}', msg2), ('${B}', press_to_bomb)])
|
||||
self._messages = (([
|
||||
Lstr(resource='pressToSelectTeamText',
|
||||
subs=[('${BUTTONS}', _ba.charstr(SpecialChar.LEFT_ARROW) +
|
||||
' ' + _ba.charstr(SpecialChar.RIGHT_ARROW))])
|
||||
] if can_switch_teams else []) + [msg1] + [msg3] + [join_str])
|
||||
|
||||
self._timer = _ba.Timer(4.0,
|
||||
_general.WeakCall(self._update),
|
||||
repeat=True)
|
||||
|
||||
def _update(self) -> None:
|
||||
assert self._text.node
|
||||
self._text.node.text = self._messages[self._state]
|
||||
self._state = (self._state + 1) % len(self._messages)
|
||||
|
||||
|
||||
class PlayerReadyMessage:
|
||||
"""Tells an object a player has been selected from the given chooser."""
|
||||
|
||||
def __init__(self, chooser: ba.Chooser):
|
||||
self.chooser = chooser
|
||||
|
||||
|
||||
class ChangeMessage:
|
||||
"""Tells an object a selection is being changed."""
|
||||
|
||||
def __init__(self, what: str, value: int):
|
||||
self.what = what
|
||||
self.value = value
|
||||
|
||||
|
||||
class Chooser:
|
||||
"""A character/team selector for a single player."""
|
||||
|
||||
def __del__(self) -> None:
|
||||
|
||||
# Just kill off our base node; the rest should go down with it.
|
||||
if self._text_node:
|
||||
self._text_node.delete()
|
||||
|
||||
def __init__(self, vpos: float, player: _ba.Player,
|
||||
lobby: 'Lobby') -> None:
|
||||
# FIXME: Tidy up around here.
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-statements
|
||||
from ba import _gameutils
|
||||
from ba import _profile
|
||||
from ba import _lang
|
||||
app = _ba.app
|
||||
self._deek_sound = _ba.getsound('deek')
|
||||
self._click_sound = _ba.getsound('click01')
|
||||
self._punchsound = _ba.getsound('punch01')
|
||||
self._swish_sound = _ba.getsound('punchSwish')
|
||||
self._errorsound = _ba.getsound('error')
|
||||
self._mask_texture = _ba.gettexture('characterIconMask')
|
||||
self._vpos = vpos
|
||||
self._lobby = weakref.ref(lobby)
|
||||
self._player = player
|
||||
self._inited = False
|
||||
self._dead = False
|
||||
self._text_node: Optional[ba.Node] = None
|
||||
self._profilename = ''
|
||||
self._profilenames: List[str] = []
|
||||
self._ready: bool = False
|
||||
self.character_names: List[str] = []
|
||||
|
||||
# Hmm does this need to be public?
|
||||
self.profiles: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Load available profiles either from the local config or from the
|
||||
# remote device.
|
||||
self.reload_profiles()
|
||||
|
||||
# Note: this is just our local index out of available teams; *not*
|
||||
# the team-id!
|
||||
self._selected_team_index: int = self.lobby.next_add_team
|
||||
|
||||
# Store a persistent random character index; we'll use this for the
|
||||
# '_random' profile. Let's use their input_device id to seed it. This
|
||||
# will give a persistent character for them between games and will
|
||||
# distribute characters nicely if everyone is random.
|
||||
try:
|
||||
input_device_id = self._player.get_input_device().id
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('Error getting device-id on chooser create')
|
||||
input_device_id = 0
|
||||
|
||||
if app.lobby_random_char_index_offset is None:
|
||||
|
||||
# We want the first device that asks for a chooser to always get
|
||||
# spaz as a random character..
|
||||
# scratch that.. we now kinda accomplish the same thing with
|
||||
# account profiles so lets just be fully random here.
|
||||
app.lobby_random_char_index_offset = (random.randrange(1000))
|
||||
|
||||
# To calc our random index we pick a random character out of our
|
||||
# unlocked list and then locate that character's index in the full
|
||||
# list.
|
||||
char_index_offset = app.lobby_random_char_index_offset
|
||||
assert char_index_offset is not None
|
||||
self._random_character_index = ((input_device_id + char_index_offset) %
|
||||
len(self.character_names))
|
||||
self._random_color, self._random_highlight = (
|
||||
_profile.get_player_profile_colors(None))
|
||||
|
||||
# Attempt to pick an initial profile based on what's been stored
|
||||
# for this input device.
|
||||
input_device = self._player.get_input_device()
|
||||
try:
|
||||
name = input_device.name
|
||||
unique_id = input_device.unique_identifier
|
||||
self._profilename = (
|
||||
app.config['Default Player Profiles'][name + ' ' + unique_id])
|
||||
self._profileindex = self._profilenames.index(self._profilename)
|
||||
|
||||
# If this one is __account__ and is local and we haven't marked
|
||||
# anyone as the account-profile device yet, mark this guy as it.
|
||||
# (prevents the next joiner from getting the account profile too).
|
||||
if (self._profilename == '__account__'
|
||||
and not input_device.is_remote_client
|
||||
and app.lobby_account_profile_device_id is None):
|
||||
app.lobby_account_profile_device_id = input_device_id
|
||||
|
||||
# Well hmm that didn't work.. pick __account__, _random, or some
|
||||
# other random profile.
|
||||
except Exception:
|
||||
|
||||
profilenames = self._profilenames
|
||||
|
||||
# We want the first local input-device in the game to latch on to
|
||||
# the account profile.
|
||||
if (not input_device.is_remote_client
|
||||
and not input_device.is_controller_app):
|
||||
if (app.lobby_account_profile_device_id is None
|
||||
and '__account__' in profilenames):
|
||||
app.lobby_account_profile_device_id = input_device_id
|
||||
|
||||
# If this is the designated account-profile-device, try to default
|
||||
# to the account profile.
|
||||
if (input_device_id == app.lobby_account_profile_device_id
|
||||
and '__account__' in profilenames):
|
||||
self._profileindex = profilenames.index('__account__')
|
||||
else:
|
||||
|
||||
# If this is the controller app, it defaults to using a random
|
||||
# profile (since we can pull the random name from the app).
|
||||
if input_device.is_controller_app:
|
||||
self._profileindex = profilenames.index('_random')
|
||||
else:
|
||||
|
||||
# If its a client connection, for now just force
|
||||
# the account profile if possible.. (need to provide a
|
||||
# way for clients to specify/remember their default
|
||||
# profile on remote servers that do not already know them).
|
||||
if (input_device.is_remote_client
|
||||
and '__account__' in profilenames):
|
||||
self._profileindex = profilenames.index('__account__')
|
||||
else:
|
||||
|
||||
# Cycle through our non-random profiles once; after
|
||||
# that, everyone gets random.
|
||||
while (app.lobby_random_profile_index <
|
||||
len(profilenames) and
|
||||
profilenames[app.lobby_random_profile_index] in
|
||||
('_random', '__account__', '_edit')):
|
||||
app.lobby_random_profile_index += 1
|
||||
if (app.lobby_random_profile_index <
|
||||
len(profilenames)):
|
||||
self._profileindex = (
|
||||
app.lobby_random_profile_index)
|
||||
app.lobby_random_profile_index += 1
|
||||
else:
|
||||
self._profileindex = profilenames.index('_random')
|
||||
|
||||
self._profilename = profilenames[self._profileindex]
|
||||
|
||||
self.character_index = self._random_character_index
|
||||
self._color = self._random_color
|
||||
self._highlight = self._random_highlight
|
||||
self._text_node = _ba.newnode('text',
|
||||
delegate=self,
|
||||
attrs={
|
||||
'position': (-100, self._vpos),
|
||||
'maxwidth': 160,
|
||||
'shadow': 0.5,
|
||||
'vr_depth': -20,
|
||||
'h_align': 'left',
|
||||
'v_align': 'center',
|
||||
'v_attach': 'top'
|
||||
})
|
||||
|
||||
_gameutils.animate(self._text_node, 'scale', {0: 0, 0.1: 1.0})
|
||||
self.icon = _ba.newnode('image',
|
||||
owner=self._text_node,
|
||||
attrs={
|
||||
'position': (-130, self._vpos + 20),
|
||||
'mask_texture': self._mask_texture,
|
||||
'vr_depth': -10,
|
||||
'attach': 'topCenter'
|
||||
})
|
||||
|
||||
_gameutils.animate_array(self.icon, 'scale', 2, {
|
||||
0: (0, 0),
|
||||
0.1: (45, 45)
|
||||
})
|
||||
|
||||
self._set_ready(False)
|
||||
|
||||
# Set our initial name to '<choosing player>' in case anyone asks.
|
||||
self._player.set_name(
|
||||
_lang.Lstr(resource='choosingPlayerText').evaluate(), real=False)
|
||||
|
||||
self.update_from_player_profiles()
|
||||
self.update_position()
|
||||
self._inited = True
|
||||
|
||||
@property
|
||||
def player(self) -> ba.Player:
|
||||
"""The ba.Player associated with this chooser."""
|
||||
return self._player
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
"""Whether this chooser is checked in as ready."""
|
||||
return self._ready
|
||||
|
||||
def set_vpos(self, vpos: float) -> None:
|
||||
"""(internal)"""
|
||||
self._vpos = vpos
|
||||
|
||||
def set_dead(self, val: bool) -> None:
|
||||
"""(internal)"""
|
||||
self._dead = val
|
||||
|
||||
def get_team(self) -> ba.Team:
|
||||
"""Return this chooser's selected ba.Team."""
|
||||
return self.lobby.teams[self._selected_team_index]
|
||||
|
||||
@property
|
||||
def lobby(self) -> ba.Lobby:
|
||||
"""The chooser's ba.Lobby."""
|
||||
lobby = self._lobby()
|
||||
if lobby is None:
|
||||
raise Exception('Lobby does not exist.')
|
||||
return lobby
|
||||
|
||||
def get_lobby(self) -> Optional[ba.Lobby]:
|
||||
"""Return this chooser's lobby if it still exists; otherwise None."""
|
||||
return self._lobby()
|
||||
|
||||
def update_from_player_profiles(self) -> None:
|
||||
"""Set character based on profile; otherwise use pre-picked random."""
|
||||
try:
|
||||
from ba import _profile
|
||||
|
||||
# Store the name even though we usually use index (in case
|
||||
# the profile list changes)
|
||||
self._profilename = self._profilenames[self._profileindex]
|
||||
character = self.profiles[self._profilename]['character']
|
||||
|
||||
# Hmmm; at the moment we're not properly pulling the list
|
||||
# of available characters from clients, so profiles might use a
|
||||
# character not in their list. for now, just go ahead and add
|
||||
# the character name to their list as long as we're aware of it
|
||||
# UPDATE: actually we now should be getting their character list,
|
||||
# so this should no longer be happening; adding warning print
|
||||
# for now and can delete later.
|
||||
if (character not in self.character_names
|
||||
and character in _ba.app.spaz_appearances):
|
||||
print('got remote character not in their character list:',
|
||||
character, self.character_names)
|
||||
self.character_names.append(character)
|
||||
self.character_index = self.character_names.index(character)
|
||||
self._color, self._highlight = (_profile.get_player_profile_colors(
|
||||
self._profilename, profiles=self.profiles))
|
||||
except Exception:
|
||||
# FIXME: Should never use top level Exception for logic; only
|
||||
# error catching (and they should always be logged).
|
||||
self.character_index = self._random_character_index
|
||||
self._color = self._random_color
|
||||
self._highlight = self._random_highlight
|
||||
self._update_icon()
|
||||
self._update_text()
|
||||
|
||||
def reload_profiles(self) -> None:
|
||||
"""Reload all player profiles."""
|
||||
from ba import _general
|
||||
app = _ba.app
|
||||
|
||||
# Re-construct our profile index and other stuff since the profile
|
||||
# list might have changed.
|
||||
input_device = self._player.get_input_device()
|
||||
is_remote = input_device.is_remote_client
|
||||
is_test_input = (input_device is not None
|
||||
and input_device.name.startswith('TestInput'))
|
||||
|
||||
# Pull this player's list of unlocked characters.
|
||||
if is_remote:
|
||||
# FIXME: Pull this from remote player (but make sure to
|
||||
# filter it to ones we've got).
|
||||
self.character_names = ['Spaz']
|
||||
else:
|
||||
self.character_names = self.lobby.character_names_local_unlocked
|
||||
|
||||
# If we're a local player, pull our local profiles from the config.
|
||||
# Otherwise ask the remote-input-device for its profile list.
|
||||
if is_remote:
|
||||
self.profiles = input_device.get_player_profiles()
|
||||
else:
|
||||
self.profiles = app.config.get('Player Profiles', {})
|
||||
|
||||
# These may have come over the wire from an older
|
||||
# (non-unicode/non-json) version.
|
||||
# Make sure they conform to our standards
|
||||
# (unicode strings, no tuples, etc)
|
||||
self.profiles = _general.json_prep(self.profiles)
|
||||
|
||||
# Filter out any characters we're unaware of.
|
||||
for profile in list(self.profiles.items()):
|
||||
if profile[1].get('character', '') not in app.spaz_appearances:
|
||||
profile[1]['character'] = 'Spaz'
|
||||
|
||||
# Add in a random one so we're ok even if there's no
|
||||
# user-created profiles.
|
||||
self.profiles['_random'] = {}
|
||||
|
||||
# In kiosk mode we disable account profiles to force random.
|
||||
if app.kiosk_mode:
|
||||
if '__account__' in self.profiles:
|
||||
del self.profiles['__account__']
|
||||
|
||||
# For local devices, add it an 'edit' option which will pop up
|
||||
# the profile window.
|
||||
if not is_remote and not is_test_input and not app.kiosk_mode:
|
||||
self.profiles['_edit'] = {}
|
||||
|
||||
# Build a sorted name list we can iterate through.
|
||||
self._profilenames = list(self.profiles.keys())
|
||||
self._profilenames.sort(key=lambda x: x.lower())
|
||||
|
||||
if self._profilename in self._profilenames:
|
||||
self._profileindex = self._profilenames.index(self._profilename)
|
||||
else:
|
||||
self._profileindex = 0
|
||||
self._profilename = self._profilenames[self._profileindex]
|
||||
|
||||
def update_position(self) -> None:
|
||||
"""Update this chooser's position."""
|
||||
from ba import _gameutils
|
||||
|
||||
# Hmmm this shouldn't be happening.
|
||||
if not self._text_node:
|
||||
print('Error: chooser text nonexistent..')
|
||||
import traceback
|
||||
traceback.print_stack()
|
||||
return
|
||||
spacing = 350
|
||||
teams = self.lobby.teams
|
||||
offs = (spacing * -0.5 * len(teams) +
|
||||
spacing * self._selected_team_index + 250)
|
||||
if len(teams) > 1:
|
||||
offs -= 35
|
||||
_gameutils.animate_array(self._text_node, 'position', 2, {
|
||||
0: self._text_node.position,
|
||||
0.1: (-100 + offs, self._vpos + 23)
|
||||
})
|
||||
_gameutils.animate_array(self.icon, 'position', 2, {
|
||||
0: self.icon.position,
|
||||
0.1: (-130 + offs, self._vpos + 22)
|
||||
})
|
||||
|
||||
def get_character_name(self) -> str:
|
||||
"""Return the selected character name."""
|
||||
return self.character_names[self.character_index]
|
||||
|
||||
def _do_nothing(self) -> None:
|
||||
"""Does nothing! (hacky way to disable callbacks)"""
|
||||
|
||||
def _get_name(self, full: bool = False) -> str:
|
||||
# FIXME: Needs cleanup.
|
||||
# pylint: disable=too-many-branches
|
||||
from ba._lang import Lstr
|
||||
from ba._enums import SpecialChar
|
||||
name_raw = name = self._profilenames[self._profileindex]
|
||||
clamp = False
|
||||
if name == '_random':
|
||||
input_device: Optional[ba.InputDevice]
|
||||
try:
|
||||
input_device = self._player.get_input_device()
|
||||
except Exception:
|
||||
input_device = None
|
||||
if input_device is not None:
|
||||
name = input_device.get_default_player_name()
|
||||
else:
|
||||
name = 'Invalid'
|
||||
if not full:
|
||||
clamp = True
|
||||
elif name == '__account__':
|
||||
try:
|
||||
input_device = self._player.get_input_device()
|
||||
except Exception:
|
||||
input_device = None
|
||||
if input_device is not None:
|
||||
name = input_device.get_account_name(full)
|
||||
else:
|
||||
name = 'Invalid'
|
||||
if not full:
|
||||
clamp = True
|
||||
elif name == '_edit':
|
||||
# FIXME: This causes problems as an Lstr, but its ok to
|
||||
# explicitly translate for now since this is only shown on the
|
||||
# host. (also should elaborate; don't remember what problems this
|
||||
# caused)
|
||||
name = (Lstr(
|
||||
resource='createEditPlayerText',
|
||||
fallback_resource='editProfileWindow.titleNewText').evaluate())
|
||||
else:
|
||||
|
||||
# If we have a regular profile marked as global with an icon,
|
||||
# use it (for full only).
|
||||
if full:
|
||||
try:
|
||||
if self.profiles[name_raw].get('global', False):
|
||||
icon = (self.profiles[name_raw]['icon']
|
||||
if 'icon' in self.profiles[name_raw] else
|
||||
_ba.charstr(SpecialChar.LOGO))
|
||||
name = icon + name
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('Error applying global icon')
|
||||
else:
|
||||
|
||||
# We now clamp non-full versions of names so there's at
|
||||
# least some hope of reading them in-game.
|
||||
clamp = True
|
||||
|
||||
if clamp:
|
||||
if len(name) > 10:
|
||||
name = name[:10] + '...'
|
||||
return name
|
||||
|
||||
def _set_ready(self, ready: bool) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.ui.profile import browser as pbrowser
|
||||
from ba import _general
|
||||
profilename = self._profilenames[self._profileindex]
|
||||
|
||||
# Handle '_edit' as a special case.
|
||||
if profilename == '_edit' and ready:
|
||||
with _ba.Context('ui'):
|
||||
pbrowser.ProfileBrowserWindow(in_main_menu=False)
|
||||
|
||||
# give their input-device UI ownership too
|
||||
# (prevent someone else from snatching it in crowded games)
|
||||
_ba.set_ui_input_device(self._player.get_input_device())
|
||||
return
|
||||
|
||||
if not ready:
|
||||
self._player.assign_input_call(
|
||||
'leftPress',
|
||||
_general.Call(self.handlemessage, ChangeMessage('team', -1)))
|
||||
self._player.assign_input_call(
|
||||
'rightPress',
|
||||
_general.Call(self.handlemessage, ChangeMessage('team', 1)))
|
||||
self._player.assign_input_call(
|
||||
'bombPress',
|
||||
_general.Call(self.handlemessage,
|
||||
ChangeMessage('character', 1)))
|
||||
self._player.assign_input_call(
|
||||
'upPress',
|
||||
_general.Call(self.handlemessage,
|
||||
ChangeMessage('profileindex', -1)))
|
||||
self._player.assign_input_call(
|
||||
'downPress',
|
||||
_general.Call(self.handlemessage,
|
||||
ChangeMessage('profileindex', 1)))
|
||||
self._player.assign_input_call(
|
||||
('jumpPress', 'pickUpPress', 'punchPress'),
|
||||
_general.Call(self.handlemessage, ChangeMessage('ready', 1)))
|
||||
self._ready = False
|
||||
self._update_text()
|
||||
self._player.set_name('untitled', real=False)
|
||||
else:
|
||||
self._player.assign_input_call(
|
||||
('leftPress', 'rightPress', 'upPress', 'downPress',
|
||||
'jumpPress', 'bombPress', 'pickUpPress'), self._do_nothing)
|
||||
self._player.assign_input_call(
|
||||
('jumpPress', 'bombPress', 'pickUpPress', 'punchPress'),
|
||||
_general.Call(self.handlemessage, ChangeMessage('ready', 0)))
|
||||
|
||||
# Store the last profile picked by this input for reuse.
|
||||
input_device = self._player.get_input_device()
|
||||
name = input_device.name
|
||||
unique_id = input_device.unique_identifier
|
||||
device_profiles = _ba.app.config.setdefault(
|
||||
'Default Player Profiles', {})
|
||||
|
||||
# Make an exception if we have no custom profiles and are set
|
||||
# to random; in that case we'll want to start picking up custom
|
||||
# profiles if/when one is made so keep our setting cleared.
|
||||
special = ('_random', '_edit', '__account__')
|
||||
have_custom_profiles = any(p not in special for p in self.profiles)
|
||||
|
||||
profilekey = name + ' ' + unique_id
|
||||
if profilename == '_random' and not have_custom_profiles:
|
||||
if profilekey in device_profiles:
|
||||
del device_profiles[profilekey]
|
||||
else:
|
||||
device_profiles[profilekey] = profilename
|
||||
_ba.app.config.commit()
|
||||
|
||||
# Set this player's short and full name.
|
||||
self._player.set_name(self._get_name(),
|
||||
self._get_name(full=True),
|
||||
real=True)
|
||||
self._ready = True
|
||||
self._update_text()
|
||||
|
||||
# Inform the session that this player is ready.
|
||||
_ba.getsession().handlemessage(PlayerReadyMessage(self))
|
||||
|
||||
def _handle_ready_msg(self, ready: bool) -> None:
|
||||
force_team_switch = False
|
||||
|
||||
# Team auto-balance kicks us to another team if we try to
|
||||
# join the team with the most players.
|
||||
if not self._ready:
|
||||
if _ba.app.config.get('Auto Balance Teams', False):
|
||||
lobby = self.lobby
|
||||
teams = lobby.teams
|
||||
if len(teams) > 1:
|
||||
|
||||
# First, calc how many players are on each team
|
||||
# ..we need to count both active players and
|
||||
# choosers that have been marked as ready.
|
||||
team_player_counts = {}
|
||||
for team in teams:
|
||||
team_player_counts[team.get_id()] = (len(team.players))
|
||||
for chooser in lobby.choosers:
|
||||
if chooser.ready:
|
||||
team_player_counts[
|
||||
chooser.get_team().get_id()] += 1
|
||||
largest_team_size = max(team_player_counts.values())
|
||||
smallest_team_size = (min(team_player_counts.values()))
|
||||
|
||||
# Force switch if we're on the biggest team
|
||||
# and there's a smaller one available.
|
||||
if (largest_team_size != smallest_team_size
|
||||
and team_player_counts[self.get_team().get_id()] >=
|
||||
largest_team_size):
|
||||
force_team_switch = True
|
||||
|
||||
# Either force switch teams, or actually for realsies do the set-ready.
|
||||
if force_team_switch:
|
||||
_ba.playsound(self._errorsound)
|
||||
self.handlemessage(ChangeMessage('team', 1))
|
||||
else:
|
||||
_ba.playsound(self._punchsound)
|
||||
self._set_ready(ready)
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
"""Standard generic message handler."""
|
||||
if isinstance(msg, ChangeMessage):
|
||||
|
||||
# If we've been removed from the lobby, ignore this stuff.
|
||||
if self._dead:
|
||||
from ba import _error
|
||||
_error.print_error("chooser got ChangeMessage after dying")
|
||||
return
|
||||
|
||||
if not self._text_node:
|
||||
from ba import _error
|
||||
_error.print_error('got ChangeMessage after nodes died')
|
||||
return
|
||||
|
||||
if msg.what == 'team':
|
||||
teams = self.lobby.teams
|
||||
if len(teams) > 1:
|
||||
_ba.playsound(self._swish_sound)
|
||||
self._selected_team_index = (
|
||||
(self._selected_team_index + msg.value) % len(teams))
|
||||
self._update_text()
|
||||
self.update_position()
|
||||
self._update_icon()
|
||||
|
||||
elif msg.what == 'profileindex':
|
||||
if len(self._profilenames) == 1:
|
||||
|
||||
# This should be pretty hard to hit now with
|
||||
# automatic local accounts.
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
else:
|
||||
|
||||
# Pick the next player profile and assign our name
|
||||
# and character based on that.
|
||||
_ba.playsound(self._deek_sound)
|
||||
self._profileindex = ((self._profileindex + msg.value) %
|
||||
len(self._profilenames))
|
||||
self.update_from_player_profiles()
|
||||
|
||||
elif msg.what == 'character':
|
||||
_ba.playsound(self._click_sound)
|
||||
# update our index in our local list of characters
|
||||
self.character_index = ((self.character_index + msg.value) %
|
||||
len(self.character_names))
|
||||
self._update_text()
|
||||
self._update_icon()
|
||||
|
||||
elif msg.what == 'ready':
|
||||
self._handle_ready_msg(bool(msg.value))
|
||||
|
||||
def _update_text(self) -> None:
|
||||
from ba import _gameutils
|
||||
from ba._lang import Lstr
|
||||
assert self._text_node is not None
|
||||
if self._ready:
|
||||
|
||||
# Once we're ready, we've saved the name, so lets ask the system
|
||||
# for it so we get appended numbers and stuff.
|
||||
text = Lstr(value=self._player.get_name(full=True))
|
||||
text = Lstr(value='${A} (${B})',
|
||||
subs=[('${A}', text),
|
||||
('${B}', Lstr(resource='readyText'))])
|
||||
else:
|
||||
text = Lstr(value=self._get_name(full=True))
|
||||
|
||||
can_switch_teams = len(self.lobby.teams) > 1
|
||||
|
||||
# Flash as we're coming in.
|
||||
fin_color = _ba.safecolor(self.get_color()) + (1, )
|
||||
if not self._inited:
|
||||
_gameutils.animate_array(self._text_node, 'color', 4, {
|
||||
0.15: fin_color,
|
||||
0.25: (2, 2, 2, 1),
|
||||
0.35: fin_color
|
||||
})
|
||||
else:
|
||||
|
||||
# Blend if we're in teams mode; switch instantly otherwise.
|
||||
if can_switch_teams:
|
||||
_gameutils.animate_array(self._text_node, 'color', 4, {
|
||||
0: self._text_node.color,
|
||||
0.1: fin_color
|
||||
})
|
||||
else:
|
||||
self._text_node.color = fin_color
|
||||
|
||||
self._text_node.text = text
|
||||
|
||||
def get_color(self) -> Sequence[float]:
|
||||
"""Return the currently selected color."""
|
||||
val: Sequence[float]
|
||||
# if self._profilenames[self._profileindex] == '_edit':
|
||||
# val = (0, 1, 0)
|
||||
if self.lobby.use_team_colors:
|
||||
val = self.lobby.teams[self._selected_team_index].color
|
||||
else:
|
||||
val = self._color
|
||||
if len(val) != 3:
|
||||
print('get_color: ignoring invalid color of len', len(val))
|
||||
val = (0, 1, 0)
|
||||
return val
|
||||
|
||||
def get_highlight(self) -> Sequence[float]:
|
||||
"""Return the currently selected highlight."""
|
||||
if self._profilenames[self._profileindex] == '_edit':
|
||||
return 0, 1, 0
|
||||
|
||||
# If we're using team colors we wanna make sure our highlight color
|
||||
# isn't too close to any other team's color.
|
||||
highlight = list(self._highlight)
|
||||
if self.lobby.use_team_colors:
|
||||
for i, team in enumerate(self.lobby.teams):
|
||||
if i != self._selected_team_index:
|
||||
|
||||
# Find the dominant component of this team's color
|
||||
# and adjust ours so that the component is
|
||||
# not super-dominant.
|
||||
max_val = 0.0
|
||||
max_index = 0
|
||||
for j in range(3):
|
||||
if team.color[j] > max_val:
|
||||
max_val = team.color[j]
|
||||
max_index = j
|
||||
that_color_for_us = highlight[max_index]
|
||||
our_second_biggest = max(highlight[(max_index + 1) % 3],
|
||||
highlight[(max_index + 2) % 3])
|
||||
diff = (that_color_for_us - our_second_biggest)
|
||||
if diff > 0:
|
||||
highlight[max_index] -= diff * 0.6
|
||||
highlight[(max_index + 1) % 3] += diff * 0.3
|
||||
highlight[(max_index + 2) % 3] += diff * 0.2
|
||||
return highlight
|
||||
|
||||
def getplayer(self) -> ba.Player:
|
||||
"""Return the player associated with this chooser."""
|
||||
return self._player
|
||||
|
||||
def _update_icon(self) -> None:
|
||||
from ba import _gameutils
|
||||
if self._profilenames[self._profileindex] == '_edit':
|
||||
tex = _ba.gettexture('black')
|
||||
tint_tex = _ba.gettexture('black')
|
||||
self.icon.color = (1, 1, 1)
|
||||
self.icon.texture = tex
|
||||
self.icon.tint_texture = tint_tex
|
||||
self.icon.tint_color = (0, 1, 0)
|
||||
return
|
||||
|
||||
try:
|
||||
tex_name = (_ba.app.spaz_appearances[self.character_names[
|
||||
self.character_index]].icon_texture)
|
||||
tint_tex_name = (_ba.app.spaz_appearances[self.character_names[
|
||||
self.character_index]].icon_mask_texture)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('Error updating char icon list')
|
||||
tex_name = 'neoSpazIcon'
|
||||
tint_tex_name = 'neoSpazIconColorMask'
|
||||
|
||||
tex = _ba.gettexture(tex_name)
|
||||
tint_tex = _ba.gettexture(tint_tex_name)
|
||||
|
||||
self.icon.color = (1, 1, 1)
|
||||
self.icon.texture = tex
|
||||
self.icon.tint_texture = tint_tex
|
||||
clr = self.get_color()
|
||||
clr2 = self.get_highlight()
|
||||
|
||||
can_switch_teams = len(self.lobby.teams) > 1
|
||||
|
||||
# If we're initing, flash.
|
||||
if not self._inited:
|
||||
_gameutils.animate_array(self.icon, 'color', 3, {
|
||||
0.15: (1, 1, 1),
|
||||
0.25: (2, 2, 2),
|
||||
0.35: (1, 1, 1)
|
||||
})
|
||||
|
||||
# Blend in teams mode; switch instantly in ffa-mode.
|
||||
if can_switch_teams:
|
||||
_gameutils.animate_array(self.icon, 'tint_color', 3, {
|
||||
0: self.icon.tint_color,
|
||||
0.1: clr
|
||||
})
|
||||
else:
|
||||
self.icon.tint_color = clr
|
||||
self.icon.tint2_color = clr2
|
||||
|
||||
# Store the icon info the the player.
|
||||
self._player.set_icon_info(tex_name, tint_tex_name, clr, clr2)
|
||||
|
||||
|
||||
class Lobby:
|
||||
"""Container for choosers."""
|
||||
|
||||
def __del__(self) -> None:
|
||||
|
||||
# Reset any players that still have a chooser in us
|
||||
# (should allow the choosers to die).
|
||||
players = [
|
||||
chooser.player for chooser in self.choosers if chooser.player
|
||||
]
|
||||
for player in players:
|
||||
player.reset()
|
||||
|
||||
def __init__(self) -> None:
|
||||
from ba import _team as bs_team
|
||||
from ba import _coopsession
|
||||
session = _ba.getsession()
|
||||
teams = session.teams if session.use_teams else None
|
||||
self._use_team_colors = session.use_team_colors
|
||||
if teams is not None:
|
||||
self._teams = [weakref.ref(team) for team in teams]
|
||||
else:
|
||||
self._dummy_teams = bs_team.Team()
|
||||
self._teams = [weakref.ref(self._dummy_teams)]
|
||||
v_offset = (-150
|
||||
if isinstance(session, _coopsession.CoopSession) else -50)
|
||||
self.choosers: List[Chooser] = []
|
||||
self.base_v_offset = v_offset
|
||||
self.update_positions()
|
||||
self._next_add_team = 0
|
||||
self.character_names_local_unlocked: List[str] = []
|
||||
self._vpos = 0
|
||||
|
||||
# Grab available profiles.
|
||||
self.reload_profiles()
|
||||
|
||||
self._join_info_text = None
|
||||
|
||||
@property
|
||||
def next_add_team(self) -> int:
|
||||
"""(internal)"""
|
||||
return self._next_add_team
|
||||
|
||||
@property
|
||||
def use_team_colors(self) -> bool:
|
||||
"""A bool for whether this lobby is using team colors.
|
||||
|
||||
If False, inidividual player colors are used instead.
|
||||
"""
|
||||
return self._use_team_colors
|
||||
|
||||
@property
|
||||
def teams(self) -> List[ba.Team]:
|
||||
"""Teams available in this lobby."""
|
||||
allteams = []
|
||||
for tref in self._teams:
|
||||
team = tref()
|
||||
assert team is not None
|
||||
allteams.append(team)
|
||||
return allteams
|
||||
|
||||
def get_choosers(self) -> List[Chooser]:
|
||||
"""Return the lobby's current choosers."""
|
||||
return self.choosers
|
||||
|
||||
def create_join_info(self) -> JoinInfo:
|
||||
"""Create a display of on-screen information for joiners.
|
||||
|
||||
(how to switch teams, players, etc.)
|
||||
Intended for use in initial joining-screens.
|
||||
"""
|
||||
return JoinInfo(self)
|
||||
|
||||
def reload_profiles(self) -> None:
|
||||
"""Reload available player profiles."""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._account import ensure_have_account_player_profile
|
||||
from bastd.actor.spazappearance import get_appearances
|
||||
|
||||
# We may have gained or lost character names if the user
|
||||
# bought something; reload these too.
|
||||
self.character_names_local_unlocked = get_appearances()
|
||||
self.character_names_local_unlocked.sort(key=lambda x: x.lower())
|
||||
|
||||
# Do any overall prep we need to such as creating account profile.
|
||||
ensure_have_account_player_profile()
|
||||
for chooser in self.choosers:
|
||||
try:
|
||||
chooser.reload_profiles()
|
||||
chooser.update_from_player_profiles()
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('error reloading profiles')
|
||||
|
||||
def update_positions(self) -> None:
|
||||
"""Update positions for all choosers."""
|
||||
self._vpos = -100 + self.base_v_offset
|
||||
for chooser in self.choosers:
|
||||
chooser.set_vpos(self._vpos)
|
||||
chooser.update_position()
|
||||
self._vpos -= 48
|
||||
|
||||
def check_all_ready(self) -> bool:
|
||||
"""Return whether all choosers are marked ready."""
|
||||
return all(chooser.ready for chooser in self.choosers)
|
||||
|
||||
def add_chooser(self, player: ba.Player) -> None:
|
||||
"""Add a chooser to the lobby for the provided player."""
|
||||
self.choosers.append(
|
||||
Chooser(vpos=self._vpos, player=player, lobby=self))
|
||||
self._next_add_team = (self._next_add_team + 1) % len(self._teams)
|
||||
self._vpos -= 48
|
||||
|
||||
def remove_chooser(self, player: ba.Player) -> None:
|
||||
"""Remove a single player's chooser; does not kick him.
|
||||
|
||||
This is used when a player enters the game and no longer
|
||||
needs a chooser."""
|
||||
found = False
|
||||
chooser = None
|
||||
for chooser in self.choosers:
|
||||
if chooser.getplayer() is player:
|
||||
found = True
|
||||
|
||||
# Mark it as dead since there could be more
|
||||
# change-commands/etc coming in still for it;
|
||||
# want to avoid duplicate player-adds/etc.
|
||||
chooser.set_dead(True)
|
||||
self.choosers.remove(chooser)
|
||||
break
|
||||
if not found:
|
||||
from ba import _error
|
||||
_error.print_error(f'remove_chooser did not find player {player}')
|
||||
elif chooser in self.choosers:
|
||||
from ba import _error
|
||||
_error.print_error(f'chooser remains after removal for {player}')
|
||||
self.update_positions()
|
||||
|
||||
def remove_all_choosers(self) -> None:
|
||||
"""Remove all choosers without kicking players.
|
||||
|
||||
This is called after all players check in and enter a game.
|
||||
"""
|
||||
self.choosers = []
|
||||
self.update_positions()
|
||||
|
||||
def remove_all_choosers_and_kick_players(self) -> None:
|
||||
"""Remove all player choosers and kick attached players."""
|
||||
|
||||
# Copy the list; it can change under us otherwise.
|
||||
for chooser in list(self.choosers):
|
||||
if chooser.player:
|
||||
chooser.player.remove_from_game()
|
||||
self.remove_all_choosers()
|
||||
406
assets/src/data/scripts/ba/_maps.py
Normal file
406
assets/src/data/scripts/ba/_maps.py
Normal file
@ -0,0 +1,406 @@
|
||||
"""Map related functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba import _math
|
||||
from ba._actor import Actor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Set, List, Type, Optional, Sequence, Any, Tuple
|
||||
import ba
|
||||
|
||||
|
||||
def preload_map_preview_media() -> None:
|
||||
"""Preload media needed for map preview UIs.
|
||||
|
||||
Category: Asset Functions
|
||||
"""
|
||||
_ba.getmodel('level_select_button_opaque')
|
||||
_ba.getmodel('level_select_button_transparent')
|
||||
for maptype in list(_ba.app.maps.values()):
|
||||
map_tex_name = maptype.get_preview_texture_name()
|
||||
if map_tex_name is not None:
|
||||
_ba.gettexture(map_tex_name)
|
||||
|
||||
|
||||
def get_filtered_map_name(name: str) -> str:
|
||||
"""Filter a map name to account for name changes, etc.
|
||||
|
||||
Category: Asset Functions
|
||||
|
||||
This can be used to support old playlists, etc.
|
||||
"""
|
||||
# Some legacy name fallbacks... can remove these eventually.
|
||||
if name in ('AlwaysLand', 'Happy Land'):
|
||||
name = 'Happy Thoughts'
|
||||
if name == 'Hockey Arena':
|
||||
name = 'Hockey Stadium'
|
||||
return name
|
||||
|
||||
|
||||
def get_map_display_string(name: str) -> ba.Lstr:
|
||||
"""Return a ba.Lstr for displaying a given map\'s name.
|
||||
|
||||
Category: Asset Functions
|
||||
"""
|
||||
from ba import _lang
|
||||
return _lang.Lstr(translate=('mapsNames', name))
|
||||
|
||||
|
||||
def getmaps(playtype: str) -> List[str]:
|
||||
"""Return a list of ba.Map types supporting a playtype str.
|
||||
|
||||
Category: Asset Functions
|
||||
|
||||
Maps supporting a given playtype must provide a particular set of
|
||||
features and lend themselves to a certain style of play.
|
||||
|
||||
Play Types:
|
||||
|
||||
'melee'
|
||||
General fighting map.
|
||||
Has one or more 'spawn' locations.
|
||||
|
||||
'team_flag'
|
||||
For games such as Capture The Flag where each team spawns by a flag.
|
||||
Has two or more 'spawn' locations, each with a corresponding 'flag'
|
||||
location (based on index).
|
||||
|
||||
'single_flag'
|
||||
For games such as King of the Hill or Keep Away where multiple teams
|
||||
are fighting over a single flag.
|
||||
Has two or more 'spawn' locations and 1 'flag_default' location.
|
||||
|
||||
'conquest'
|
||||
For games such as Conquest where flags are spread throughout the map
|
||||
- has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
|
||||
|
||||
'king_of_the_hill' - has 2+ 'spawn' locations, 1+ 'flag_default' locations,
|
||||
and 1+ 'powerup_spawn' locations
|
||||
|
||||
'hockey'
|
||||
For hockey games.
|
||||
Has two 'goal' locations, corresponding 'spawn' locations, and one
|
||||
'flag_default' location (for where puck spawns)
|
||||
|
||||
'football'
|
||||
For football games.
|
||||
Has two 'goal' locations, corresponding 'spawn' locations, and one
|
||||
'flag_default' location (for where flag/ball/etc. spawns)
|
||||
|
||||
'race'
|
||||
For racing games where players much touch each region in order.
|
||||
Has two or more 'race_point' locations.
|
||||
"""
|
||||
return sorted(key for key, val in _ba.app.maps.items()
|
||||
if playtype in val.get_play_types())
|
||||
|
||||
|
||||
def get_unowned_maps() -> List[str]:
|
||||
"""Return the list of local maps not owned by the current account.
|
||||
|
||||
Category: Asset Functions
|
||||
"""
|
||||
from ba import _store
|
||||
unowned_maps: Set[str] = set()
|
||||
if _ba.app.subplatform != 'headless':
|
||||
for map_section in _store.get_store_layout()['maps']:
|
||||
for mapitem in map_section['items']:
|
||||
if not _ba.get_purchased(mapitem):
|
||||
m_info = _store.get_store_item(mapitem)
|
||||
unowned_maps.add(m_info['map_type'].name)
|
||||
return sorted(unowned_maps)
|
||||
|
||||
|
||||
def get_map_class(name: str) -> Type[ba.Map]:
|
||||
"""Return a map type given a name.
|
||||
|
||||
Category: Asset Functions
|
||||
"""
|
||||
name = get_filtered_map_name(name)
|
||||
try:
|
||||
return _ba.app.maps[name]
|
||||
except Exception:
|
||||
raise Exception("Map not found: '" + name + "'")
|
||||
|
||||
|
||||
class Map(Actor):
|
||||
"""A game map.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
Consists of a collection of terrain nodes, metadata, and other
|
||||
functionality comprising a game map.
|
||||
"""
|
||||
defs: Any = None
|
||||
name = "Map"
|
||||
_playtypes: List[str] = []
|
||||
|
||||
@classmethod
|
||||
def preload(cls) -> None:
|
||||
"""Preload map media.
|
||||
|
||||
This runs the class's on_preload() method as needed to prep it to run.
|
||||
Preloading should generally be done in a ba.Activity's __init__ method.
|
||||
Note that this is a classmethod since it is not operate on map
|
||||
instances but rather on the class itself before instances are made
|
||||
"""
|
||||
activity = _ba.getactivity()
|
||||
if cls not in activity.preloads:
|
||||
activity.preloads[cls] = cls.on_preload()
|
||||
|
||||
@classmethod
|
||||
def get_play_types(cls) -> List[str]:
|
||||
"""Return valid play types for this map."""
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_preview_texture_name(cls) -> Optional[str]:
|
||||
"""Return the name of the preview texture for this map."""
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def on_preload(cls) -> Any:
|
||||
"""Called when the map is being preloaded.
|
||||
|
||||
It should return any media/data it requires to operate
|
||||
"""
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_name(cls) -> str:
|
||||
"""Return the unique name of this map, in English."""
|
||||
return cls.name
|
||||
|
||||
@classmethod
|
||||
def get_music_type(cls) -> Optional[str]:
|
||||
"""Return a music-type string that should be played on this map.
|
||||
|
||||
If None is returned, default music will be used.
|
||||
"""
|
||||
return None
|
||||
|
||||
def __init__(self,
|
||||
vr_overlay_offset: Optional[Sequence[float]] = None) -> None:
|
||||
"""Instantiate a map."""
|
||||
from ba import _gameutils
|
||||
super().__init__()
|
||||
|
||||
# This is expected to always be a ba.Node object (whether valid or not)
|
||||
# should be set to something meaningful by child classes.
|
||||
self.node = None
|
||||
|
||||
# Make our class' preload-data available to us
|
||||
# (and instruct the user if we weren't preloaded properly).
|
||||
try:
|
||||
self.preloaddata = _ba.getactivity().preloads[type(self)]
|
||||
except Exception:
|
||||
raise Exception('Preload data not found for ' + str(type(self)) +
|
||||
'; make sure to call the type\'s preload()'
|
||||
' staticmethod in the activity constructor')
|
||||
|
||||
# Set various globals.
|
||||
gnode = _gameutils.sharedobj('globals')
|
||||
|
||||
# Set area-of-interest bounds.
|
||||
aoi_bounds = self.get_def_bound_box("area_of_interest_bounds")
|
||||
if aoi_bounds is None:
|
||||
print('WARNING: no "aoi_bounds" found for map:', self.get_name())
|
||||
aoi_bounds = (-1, -1, -1, 1, 1, 1)
|
||||
gnode.area_of_interest_bounds = aoi_bounds
|
||||
|
||||
# Set map bounds.
|
||||
map_bounds = self.get_def_bound_box("map_bounds")
|
||||
if map_bounds is None:
|
||||
print('WARNING: no "map_bounds" found for map:', self.get_name())
|
||||
map_bounds = (-30, -10, -30, 30, 100, 30)
|
||||
_ba.set_map_bounds(map_bounds)
|
||||
|
||||
# Set shadow ranges.
|
||||
try:
|
||||
gnode.shadow_range = [
|
||||
self.defs.points[v][1] for v in [
|
||||
'shadow_lower_bottom', 'shadow_lower_top',
|
||||
'shadow_upper_bottom', 'shadow_upper_top'
|
||||
]
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# In vr, set a fixed point in space for the overlay to show up at.
|
||||
# By default we use the bounds center but allow the map to override it.
|
||||
center = ((aoi_bounds[0] + aoi_bounds[3]) * 0.5,
|
||||
(aoi_bounds[1] + aoi_bounds[4]) * 0.5,
|
||||
(aoi_bounds[2] + aoi_bounds[5]) * 0.5)
|
||||
if vr_overlay_offset is not None:
|
||||
center = (center[0] + vr_overlay_offset[0],
|
||||
center[1] + vr_overlay_offset[1],
|
||||
center[2] + vr_overlay_offset[2])
|
||||
gnode.vr_overlay_center = center
|
||||
gnode.vr_overlay_center_enabled = True
|
||||
|
||||
self.spawn_points = (self.get_def_points('spawn')
|
||||
or [(0, 0, 0, 0, 0, 0)])
|
||||
self.ffa_spawn_points = (self.get_def_points('ffa_spawn')
|
||||
or [(0, 0, 0, 0, 0, 0)])
|
||||
self.spawn_by_flag_points = (self.get_def_points('spawn_by_flag')
|
||||
or [(0, 0, 0, 0, 0, 0)])
|
||||
self.flag_points = self.get_def_points("flag") or [(0, 0, 0)]
|
||||
|
||||
# We just want points.
|
||||
self.flag_points = [p[:3] for p in self.flag_points]
|
||||
self.flag_points_default = (self.get_def_point('flag_default')
|
||||
or (0, 1, 0))
|
||||
self.powerup_spawn_points = self.get_def_points('powerup_spawn') or [
|
||||
(0, 0, 0)
|
||||
]
|
||||
|
||||
# We just want points.
|
||||
self.powerup_spawn_points = ([
|
||||
p[:3] for p in self.powerup_spawn_points
|
||||
])
|
||||
self.tnt_points = self.get_def_points("tnt") or []
|
||||
|
||||
# We just want points.
|
||||
self.tnt_points = [p[:3] for p in self.tnt_points]
|
||||
|
||||
self.is_hockey = False
|
||||
self.is_flying = False
|
||||
|
||||
# FIXME: this should be part of game; not map.
|
||||
self._next_ffa_start_index = 0
|
||||
|
||||
def is_point_near_edge(self, point: ba.Vec3,
|
||||
running: bool = False) -> bool:
|
||||
"""Return whether the provided point is near an edge of the map.
|
||||
|
||||
Simple bot logic uses this call to determine if they
|
||||
are approaching a cliff or wall. If this returns True they will
|
||||
generally not walk/run any farther away from the origin.
|
||||
If 'running' is True, the buffer should be a bit larger.
|
||||
"""
|
||||
del point, running # Unused.
|
||||
return False
|
||||
|
||||
def get_def_bound_box(
|
||||
self, name: str
|
||||
) -> Optional[Tuple[float, float, float, float, float, float]]:
|
||||
"""Return a 6 member bounds tuple or None if it is not defined."""
|
||||
try:
|
||||
box = self.defs.boxes[name]
|
||||
return (box[0] - box[6] / 2.0, box[1] - box[7] / 2.0,
|
||||
box[2] - box[8] / 2.0, box[0] + box[6] / 2.0,
|
||||
box[1] + box[7] / 2.0, box[2] + box[8] / 2.0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_def_point(self, name: str) -> Optional[Sequence[float]]:
|
||||
"""Return a single defined point or a default value in its absence."""
|
||||
val = self.defs.points.get(name)
|
||||
return (None if val is None else
|
||||
_math.vec3validate(val) if __debug__ else val)
|
||||
|
||||
def get_def_points(self, name: str) -> List[Sequence[float]]:
|
||||
"""Return a list of named points.
|
||||
|
||||
Return as many sequential ones are defined (flag1, flag2, flag3), etc.
|
||||
If none are defined, returns an empty list.
|
||||
"""
|
||||
point_list = []
|
||||
if self.defs and name + "1" in self.defs.points:
|
||||
i = 1
|
||||
while name + str(i) in self.defs.points:
|
||||
pts = self.defs.points[name + str(i)]
|
||||
if len(pts) == 6:
|
||||
point_list.append(pts)
|
||||
else:
|
||||
if len(pts) != 3:
|
||||
raise Exception("invalid point")
|
||||
point_list.append(pts + (0, 0, 0))
|
||||
i += 1
|
||||
return point_list
|
||||
|
||||
def get_start_position(self, team_index: int) -> Sequence[float]:
|
||||
"""Return a random starting position for the given team index."""
|
||||
pnt = self.spawn_points[team_index % len(self.spawn_points)]
|
||||
x_range = (-0.5, 0.5) if pnt[3] == 0.0 else (-pnt[3], pnt[3])
|
||||
z_range = (-0.5, 0.5) if pnt[5] == 0.0 else (-pnt[5], pnt[5])
|
||||
pnt = (pnt[0] + random.uniform(*x_range), pnt[1],
|
||||
pnt[2] + random.uniform(*z_range))
|
||||
return pnt
|
||||
|
||||
def get_ffa_start_position(self, players: Sequence[ba.Player]
|
||||
) -> Sequence[float]:
|
||||
"""Return a random starting position in one of the FFA spawn areas.
|
||||
|
||||
If a list of ba.Players is provided; the returned points will be
|
||||
as far from these players as possible.
|
||||
"""
|
||||
|
||||
# Get positions for existing players.
|
||||
player_pts = []
|
||||
for player in players:
|
||||
try:
|
||||
if player.actor is not None and player.actor.is_alive():
|
||||
assert player.actor.node
|
||||
pnt = _ba.Vec3(player.actor.node.position)
|
||||
player_pts.append(pnt)
|
||||
except Exception as exc:
|
||||
print('EXC in get_ffa_start_position:', exc)
|
||||
|
||||
def _getpt() -> Sequence[float]:
|
||||
point = self.ffa_spawn_points[self._next_ffa_start_index]
|
||||
self._next_ffa_start_index = ((self._next_ffa_start_index + 1) %
|
||||
len(self.ffa_spawn_points))
|
||||
x_range = (-0.5, 0.5) if point[3] == 0.0 else (-point[3], point[3])
|
||||
z_range = (-0.5, 0.5) if point[5] == 0.0 else (-point[5], point[5])
|
||||
point = (point[0] + random.uniform(*x_range), point[1],
|
||||
point[2] + random.uniform(*z_range))
|
||||
return point
|
||||
|
||||
if not player_pts:
|
||||
return _getpt()
|
||||
|
||||
# Let's calc several start points and then pick whichever is
|
||||
# farthest from all existing players.
|
||||
farthestpt_dist = -1.0
|
||||
farthestpt = None
|
||||
for _i in range(10):
|
||||
testpt = _ba.Vec3(_getpt())
|
||||
closest_player_dist = 9999.0
|
||||
for ppt in player_pts:
|
||||
dist = (ppt - testpt).length()
|
||||
if dist < closest_player_dist:
|
||||
closest_player_dist = dist
|
||||
if closest_player_dist > farthestpt_dist:
|
||||
farthestpt_dist = closest_player_dist
|
||||
farthestpt = testpt
|
||||
assert farthestpt is not None
|
||||
return tuple(farthestpt)
|
||||
|
||||
def get_flag_position(self, team_index: int = None) -> Sequence[float]:
|
||||
"""Return a flag position on the map for the given team index.
|
||||
|
||||
Pass None to get the default flag point.
|
||||
(used for things such as king-of-the-hill)
|
||||
"""
|
||||
if team_index is None:
|
||||
return self.flag_points_default[:3]
|
||||
return self.flag_points[team_index % len(self.flag_points)][:3]
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
from ba import _messages
|
||||
if isinstance(msg, _messages.DieMessage):
|
||||
if self.node:
|
||||
self.node.delete()
|
||||
super().handlemessage(msg)
|
||||
|
||||
|
||||
def register_map(maptype: Type[Map]) -> None:
|
||||
"""Register a map class with the game."""
|
||||
if maptype.name in _ba.app.maps:
|
||||
raise Exception("map \"" + maptype.name + "\" already registered")
|
||||
_ba.app.maps[maptype.name] = maptype
|
||||
53
assets/src/data/scripts/ba/_math.py
Normal file
53
assets/src/data/scripts/ba/_math.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Math related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import abc
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Tuple, Sequence
|
||||
|
||||
|
||||
def vec3validate(value: Sequence[float]) -> Sequence[float]:
|
||||
"""Ensure a value is valid for use as a Vec3.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
Raises a TypeError exception if not.
|
||||
Valid values include any type of sequence consisting of 3 numeric values.
|
||||
Returns the same value as passed in (but with a definite type
|
||||
so this can be used to disambiguate 'Any' types).
|
||||
Generally this should be used in 'if __debug__' or assert clauses
|
||||
to keep runtime overhead minimal.
|
||||
"""
|
||||
from numbers import Number
|
||||
if not isinstance(value, abc.Sequence):
|
||||
raise TypeError(f"Expected a sequence; got {type(value)}")
|
||||
if len(value) != 3:
|
||||
raise TypeError(f"Expected a length-3 sequence (got {len(value)})")
|
||||
if not all(isinstance(i, Number) for i in value):
|
||||
raise TypeError(f"Non-numeric value passed for vec3: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool:
|
||||
"""Return whether a given point is within a given box.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
For use with standard def boxes (position|rotate|scale).
|
||||
"""
|
||||
return ((abs(pnt[0] - box[0]) <= box[6] * 0.5)
|
||||
and (abs(pnt[1] - box[1]) <= box[7] * 0.5)
|
||||
and (abs(pnt[2] - box[2]) <= box[8] * 0.5))
|
||||
|
||||
|
||||
def normalized_color(color: Sequence[float]) -> Tuple[float, ...]:
|
||||
"""Scale a color so its largest value is 1; useful for coloring lights.
|
||||
|
||||
category: General Utility Functions
|
||||
"""
|
||||
color_biased = tuple(max(c, 0.01) for c in color) # account for black
|
||||
mult = 1.0 / max(color_biased)
|
||||
return tuple(c * mult for c in color_biased)
|
||||
200
assets/src/data/scripts/ba/_messages.py
Normal file
200
assets/src/data/scripts/ba/_messages.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""Defines some standard message objects for use with handlemessage() calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Sequence
|
||||
import ba
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutOfBoundsMessage:
|
||||
"""A message telling an object that it is out of bounds.
|
||||
|
||||
Category: Message Classes
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DieMessage:
|
||||
"""A message telling an object to die.
|
||||
|
||||
Category: Message Classes
|
||||
|
||||
Most ba.Actors respond to this.
|
||||
|
||||
Attributes:
|
||||
|
||||
immediate
|
||||
If this is set to True, the actor should disappear immediately.
|
||||
This is for 'removing' stuff from the game more so than 'killing'
|
||||
it. If False, the actor should die a 'normal' death and can take
|
||||
its time with lingering corpses, sound effects, etc.
|
||||
|
||||
how
|
||||
The particular reason for death; 'fall', 'impact', 'leftGame', etc.
|
||||
This can be examined for scoring or other purposes.
|
||||
|
||||
"""
|
||||
immediate: bool = False
|
||||
how: str = 'generic'
|
||||
|
||||
|
||||
@dataclass
|
||||
class StandMessage:
|
||||
"""A message telling an object to move to a position in space.
|
||||
|
||||
Category: Message Classes
|
||||
|
||||
Used when teleporting players to home base, etc.
|
||||
|
||||
Attributes:
|
||||
|
||||
position
|
||||
Where to move to.
|
||||
|
||||
angle
|
||||
The angle to face (in degrees)
|
||||
"""
|
||||
position: Sequence[float] = (0.0, 0.0, 0.0)
|
||||
angle: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PickUpMessage:
|
||||
"""Tells an object that it has picked something up.
|
||||
|
||||
Category: Message Classes
|
||||
|
||||
Attributes:
|
||||
|
||||
node
|
||||
The ba.Node that is getting picked up.
|
||||
"""
|
||||
node: ba.Node
|
||||
|
||||
|
||||
@dataclass
|
||||
class DropMessage:
|
||||
"""Tells an object that it has dropped what it was holding.
|
||||
|
||||
Category: Message Classes
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PickedUpMessage:
|
||||
"""Tells an object that it has been picked up by something.
|
||||
|
||||
Category: Message Classes
|
||||
|
||||
Attributes:
|
||||
|
||||
node
|
||||
The ba.Node doing the picking up.
|
||||
"""
|
||||
node: ba.Node
|
||||
|
||||
|
||||
@dataclass
|
||||
class DroppedMessage:
|
||||
"""Tells an object that it has been dropped.
|
||||
|
||||
Category: Message Classes
|
||||
|
||||
Attributes:
|
||||
|
||||
node
|
||||
The ba.Node doing the dropping.
|
||||
"""
|
||||
node: ba.Node
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShouldShatterMessage:
|
||||
"""Tells an object that it should shatter.
|
||||
|
||||
Category: Message Classes
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImpactDamageMessage:
|
||||
"""Tells an object that it has been jarred violently.
|
||||
|
||||
Category: Message Classes
|
||||
|
||||
Attributes:
|
||||
|
||||
intensity
|
||||
The intensity of the impact.
|
||||
"""
|
||||
intensity: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class FreezeMessage:
|
||||
"""Tells an object to become frozen.
|
||||
|
||||
Category: Message Classes
|
||||
|
||||
As seen in the effects of an ice ba.Bomb.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThawMessage:
|
||||
"""Tells an object to stop being frozen.
|
||||
|
||||
Category: Message Classes
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class HitMessage:
|
||||
"""Tells an object it has been hit in some way.
|
||||
|
||||
Category: Message Classes
|
||||
|
||||
This is used by punches, explosions, etc to convey
|
||||
their effect to a target.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
srcnode: ba.Node = None,
|
||||
pos: Sequence[float] = None,
|
||||
velocity: Sequence[float] = None,
|
||||
magnitude: float = 1.0,
|
||||
velocity_magnitude: float = 0.0,
|
||||
radius: float = 1.0,
|
||||
source_player: ba.Player = None,
|
||||
kick_back: float = 1.0,
|
||||
flat_damage: float = None,
|
||||
hit_type: str = 'generic',
|
||||
force_direction: Sequence[float] = None,
|
||||
hit_subtype: str = 'default'):
|
||||
"""Instantiate a message with given values."""
|
||||
|
||||
self.srcnode = srcnode
|
||||
self.pos = pos if pos is not None else _ba.Vec3()
|
||||
self.velocity = velocity if velocity is not None else _ba.Vec3()
|
||||
self.magnitude = magnitude
|
||||
self.velocity_magnitude = velocity_magnitude
|
||||
self.radius = radius
|
||||
self.source_player = source_player
|
||||
self.kick_back = kick_back
|
||||
self.flat_damage = flat_damage
|
||||
self.hit_type = hit_type
|
||||
self.hit_subtype = hit_subtype
|
||||
self.force_direction = (force_direction
|
||||
if force_direction is not None else velocity)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerProfilesChangedMessage:
|
||||
"""Signals player profiles may have changed and should be reloaded."""
|
||||
342
assets/src/data/scripts/ba/_meta.py
Normal file
342
assets/src/data/scripts/ba/_meta.py
Normal file
@ -0,0 +1,342 @@
|
||||
"""Functionality related to dynamic discoverability of classes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import (Any, Dict, List, Tuple, Union, Optional, Type, Set)
|
||||
import ba
|
||||
|
||||
# The API version of this build of the game.
|
||||
# Only packages and modules requiring this exact api version
|
||||
# will be considered when scanning directories.
|
||||
# See bombsquadgame.com/apichanges for differences between api versions.
|
||||
CURRENT_API_VERSION = 6
|
||||
|
||||
|
||||
def startscan() -> None:
|
||||
"""Begin scanning script directories for scripts containing metadata.
|
||||
|
||||
Should be called only once at launch."""
|
||||
app = _ba.app
|
||||
if app.metascan is not None:
|
||||
print('WARNING: meta scan run more than once.')
|
||||
scriptdirs = [app.system_scripts_directory, app.user_scripts_directory]
|
||||
thread = ScanThread(scriptdirs)
|
||||
thread.start()
|
||||
|
||||
|
||||
def handle_scan_results(results: Dict[str, Any]) -> None:
|
||||
"""Called in the game thread with results of a completed scan."""
|
||||
from ba import _lang
|
||||
|
||||
# Warnings generally only get printed locally for users' benefit
|
||||
# (things like out-of-date scripts being ignored, etc.)
|
||||
# Errors are more serious and will get included in the regular log
|
||||
warnings = results.get('warnings', '')
|
||||
errors = results.get('errors', '')
|
||||
if warnings != '' or errors != '':
|
||||
_ba.screenmessage(_lang.Lstr(resource='scanScriptsErrorText'),
|
||||
color=(1, 0, 0))
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
if warnings != '':
|
||||
_ba.log(warnings, to_server=False)
|
||||
if errors != '':
|
||||
_ba.log(errors)
|
||||
|
||||
|
||||
class ScanThread(threading.Thread):
|
||||
"""Thread to scan script dirs for metadata."""
|
||||
|
||||
def __init__(self, dirs: List[str]):
|
||||
super().__init__()
|
||||
self._dirs = dirs
|
||||
|
||||
def run(self) -> None:
|
||||
from ba import _general
|
||||
try:
|
||||
scan = DirectoryScan(self._dirs)
|
||||
scan.scan()
|
||||
results = scan.results
|
||||
except Exception as exc:
|
||||
results = {'errors': 'Scan exception: ' + str(exc)}
|
||||
|
||||
# Push a call to the game thread to print warnings/errors
|
||||
# or otherwise deal with scan results.
|
||||
_ba.pushcall(_general.Call(handle_scan_results, results),
|
||||
from_other_thread=True)
|
||||
|
||||
# We also, however, immediately make results available.
|
||||
# This is because the game thread may be blocked waiting
|
||||
# for them so we can't push a call or we'd get deadlock.
|
||||
_ba.app.metascan = results
|
||||
|
||||
|
||||
class DirectoryScan:
|
||||
"""Handles scanning directories for metadata."""
|
||||
|
||||
def __init__(self, paths: List[str]):
|
||||
"""Given one or more paths, parses available meta information.
|
||||
|
||||
It is assumed that these paths are also in PYTHONPATH.
|
||||
It is also assumed that any subdirectories are Python packages.
|
||||
The returned dict contains the following:
|
||||
'powerups': list of ba.Powerup classes found.
|
||||
'campaigns': list of ba.Campaign classes found.
|
||||
'modifiers': list of ba.Modifier classes found.
|
||||
'maps': list of ba.Map classes found.
|
||||
'games': list of ba.GameActivity classes found.
|
||||
'warnings': warnings from scan; should be printed for local feedback
|
||||
'errors': errors encountered during scan; should be fully logged
|
||||
"""
|
||||
self.paths = [pathlib.Path(p) for p in paths]
|
||||
self.results: Dict[str, Any] = {
|
||||
'errors': '',
|
||||
'warnings': '',
|
||||
'games': []
|
||||
}
|
||||
|
||||
def _get_path_module_entries(
|
||||
self, path: pathlib.Path, subpath: Union[str, pathlib.Path],
|
||||
modules: List[Tuple[pathlib.Path, pathlib.Path]]) -> None:
|
||||
"""Scan provided path and add module entries to provided list."""
|
||||
try:
|
||||
# Special case: let's save some time and skip the whole 'ba'
|
||||
# package since we know it doesn't contain any meta tags.
|
||||
fullpath = pathlib.Path(path, subpath)
|
||||
entries = [(path, pathlib.Path(subpath, name))
|
||||
for name in os.listdir(fullpath) if name != 'ba']
|
||||
except PermissionError:
|
||||
# Expected sometimes.
|
||||
entries = []
|
||||
except Exception as exc:
|
||||
# Unexpected; report this.
|
||||
self.results['errors'] += str(exc) + '\n'
|
||||
entries = []
|
||||
|
||||
# Now identify python packages/modules out of what we found.
|
||||
for entry in entries:
|
||||
if entry[1].name.endswith('.py'):
|
||||
modules.append(entry)
|
||||
elif (pathlib.Path(entry[0], entry[1]).is_dir() and pathlib.Path(
|
||||
entry[0], entry[1], '__init__.py').is_file()):
|
||||
modules.append(entry)
|
||||
|
||||
def scan(self) -> None:
|
||||
"""Scan provided paths."""
|
||||
modules: List[Tuple[pathlib.Path, pathlib.Path]] = []
|
||||
for path in self.paths:
|
||||
self._get_path_module_entries(path, '', modules)
|
||||
for moduledir, subpath in modules:
|
||||
try:
|
||||
self.scan_module(moduledir, subpath)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
self.results['warnings'] += ("Error scanning '" +
|
||||
str(subpath) + "': " +
|
||||
_error.exc_str() + '\n')
|
||||
|
||||
def scan_module(self, moduledir: pathlib.Path,
|
||||
subpath: pathlib.Path) -> None:
|
||||
"""Scan an individual module and add the findings to results."""
|
||||
if subpath.name.endswith('.py'):
|
||||
fpath = pathlib.Path(moduledir, subpath)
|
||||
ispackage = False
|
||||
else:
|
||||
fpath = pathlib.Path(moduledir, subpath, '__init__.py')
|
||||
ispackage = True
|
||||
with fpath.open() as infile:
|
||||
flines = infile.readlines()
|
||||
meta_lines = {
|
||||
lnum: l[1:].split()
|
||||
for lnum, l in enumerate(flines) if 'bs_meta' in l
|
||||
}
|
||||
toplevel = len(subpath.parts) <= 1
|
||||
required_api = self.get_api_requirement(subpath, meta_lines, toplevel)
|
||||
|
||||
# Top level modules with no discernible api version get ignored.
|
||||
if toplevel and required_api is None:
|
||||
return
|
||||
|
||||
# If we find a module requiring a different api version, warn
|
||||
# and ignore.
|
||||
if required_api is not None and required_api != CURRENT_API_VERSION:
|
||||
self.results['warnings'] += ('Warning: ' + str(subpath) +
|
||||
' requires api ' + str(required_api) +
|
||||
' but we are running ' +
|
||||
str(CURRENT_API_VERSION) +
|
||||
'; ignoring module.\n')
|
||||
return
|
||||
|
||||
# Ok; can proceed with a full scan of this module.
|
||||
self._process_module_meta_tags(subpath, flines, meta_lines)
|
||||
|
||||
# If its a package, recurse into its subpackages.
|
||||
if ispackage:
|
||||
try:
|
||||
submodules: List[Tuple[pathlib.Path, pathlib.Path]] = []
|
||||
self._get_path_module_entries(moduledir, subpath, submodules)
|
||||
for submodule in submodules:
|
||||
self.scan_module(submodule[0], submodule[1])
|
||||
except Exception:
|
||||
from ba import _error
|
||||
self.results['warnings'] += ("Error scanning '" +
|
||||
str(subpath) + "': " +
|
||||
_error.exc_str() + '\n')
|
||||
|
||||
def _process_module_meta_tags(self, subpath: pathlib.Path,
|
||||
flines: List[str],
|
||||
meta_lines: Dict[int, str]) -> None:
|
||||
"""Pull data from a module based on its bs_meta tags."""
|
||||
for lindex, mline in meta_lines.items():
|
||||
# meta_lines is just anything containing 'bs_meta'; make sure
|
||||
# the bs_meta is in the right place.
|
||||
if mline[0] != 'bs_meta':
|
||||
self.results['warnings'] += (
|
||||
'Warning: ' + str(subpath) +
|
||||
': malformed bs_meta statement on line ' +
|
||||
str(lindex + 1) + '.\n')
|
||||
elif (len(mline) == 4 and mline[1] == 'require'
|
||||
and mline[2] == 'api'):
|
||||
# Ignore 'require api X' lines in this pass.
|
||||
pass
|
||||
elif len(mline) != 3 or mline[1] != 'export':
|
||||
# Currently we only support 'bs_meta export FOO';
|
||||
# complain for anything else we see.
|
||||
self.results['warnings'] += (
|
||||
'Warning: ' + str(subpath) +
|
||||
': unrecognized bs_meta statement on line ' +
|
||||
str(lindex + 1) + '.\n')
|
||||
else:
|
||||
# Looks like we've got a valid export line!
|
||||
modulename = '.'.join(subpath.parts)
|
||||
if subpath.name.endswith('.py'):
|
||||
modulename = modulename[:-3]
|
||||
exporttype = mline[2]
|
||||
export_class_name = self._get_export_class_name(
|
||||
subpath, flines, lindex)
|
||||
if export_class_name is not None:
|
||||
classname = modulename + '.' + export_class_name
|
||||
if exporttype == 'game':
|
||||
self.results['games'].append(classname)
|
||||
else:
|
||||
self.results['warnings'] += (
|
||||
'Warning: ' + str(subpath) +
|
||||
': unrecognized export type "' + exporttype +
|
||||
'" on line ' + str(lindex + 1) + '.\n')
|
||||
|
||||
def _get_export_class_name(self, subpath: pathlib.Path, lines: List[str],
|
||||
lindex: int) -> Optional[str]:
|
||||
"""Given line num of an export tag, returns its operand class name."""
|
||||
lindexorig = lindex
|
||||
classname = None
|
||||
while True:
|
||||
lindex += 1
|
||||
if lindex >= len(lines):
|
||||
break
|
||||
lbits = lines[lindex].split()
|
||||
if not lbits:
|
||||
continue # Skip empty lines.
|
||||
if lbits[0] != 'class':
|
||||
break
|
||||
if len(lbits) > 1:
|
||||
cbits = lbits[1].split('(')
|
||||
if len(cbits) > 1 and cbits[0].isidentifier():
|
||||
classname = cbits[0]
|
||||
break # success!
|
||||
if classname is None:
|
||||
self.results['warnings'] += (
|
||||
'Warning: ' + str(subpath) + ': class definition not found'
|
||||
' below "bs_meta export" statement on line ' +
|
||||
str(lindexorig + 1) + '.\n')
|
||||
return classname
|
||||
|
||||
def get_api_requirement(self, subpath: pathlib.Path,
|
||||
meta_lines: Dict[int, str],
|
||||
toplevel: bool) -> Optional[int]:
|
||||
"""Return an API requirement integer or None if none present.
|
||||
|
||||
Malformed api requirement strings will be logged as warnings.
|
||||
"""
|
||||
lines = [
|
||||
l for l in meta_lines.values() if len(l) == 4 and l[0] == 'bs_meta'
|
||||
and l[1] == 'require' and l[2] == 'api' and l[3].isdigit()
|
||||
]
|
||||
|
||||
# we're successful if we find exactly one properly formatted line
|
||||
if len(lines) == 1:
|
||||
return int(lines[0][3])
|
||||
|
||||
# Ok; not successful. lets issue warnings for a few error cases.
|
||||
if len(lines) > 1:
|
||||
self.results['warnings'] += (
|
||||
'Warning: ' + str(subpath) +
|
||||
': multiple "# bs_meta api require <NUM>" lines found;'
|
||||
' ignoring module.\n')
|
||||
elif not lines and toplevel and meta_lines:
|
||||
# If we're a top-level module containing meta lines but
|
||||
# no valid api require, complain.
|
||||
self.results['warnings'] += (
|
||||
'Warning: ' + str(subpath) +
|
||||
': no valid "# bs_meta api require <NUM>" line found;'
|
||||
' ignoring module.\n')
|
||||
return None
|
||||
|
||||
|
||||
def getscanresults() -> Dict[str, Any]:
|
||||
"""Return meta scan results; blocking if the scan is not yet complete."""
|
||||
import time
|
||||
app = _ba.app
|
||||
if app.metascan is None:
|
||||
print('WARNING: ba.meta.getscanresults() called before scan completed.'
|
||||
' This can cause hitches.')
|
||||
|
||||
# Now wait a bit for the scan to complete.
|
||||
# Eventually error though if it doesn't.
|
||||
starttime = time.time()
|
||||
while app.metascan is None:
|
||||
time.sleep(0.05)
|
||||
if time.time() - starttime > 10.0:
|
||||
raise Exception('timeout waiting for meta scan to complete.')
|
||||
return app.metascan
|
||||
|
||||
|
||||
def get_game_types() -> List[Type[ba.GameActivity]]:
|
||||
"""Return available game types."""
|
||||
from ba import _general
|
||||
from ba import _gameactivity
|
||||
gameclassnames = getscanresults().get('games', [])
|
||||
gameclasses = []
|
||||
for gameclassname in gameclassnames:
|
||||
try:
|
||||
cls = _general.getclass(gameclassname, _gameactivity.GameActivity)
|
||||
gameclasses.append(cls)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('error importing ' + str(gameclassname))
|
||||
unowned = get_unowned_game_types()
|
||||
return [cls for cls in gameclasses if cls not in unowned]
|
||||
|
||||
|
||||
def get_unowned_game_types() -> Set[Type[ba.GameActivity]]:
|
||||
"""Return present game types not owned by the current account."""
|
||||
try:
|
||||
from ba import _store
|
||||
unowned_games: Set[Type[ba.GameActivity]] = set()
|
||||
if _ba.app.subplatform != 'headless':
|
||||
for section in _store.get_store_layout()['minigames']:
|
||||
for mname in section['items']:
|
||||
if not _ba.get_purchased(mname):
|
||||
m_info = _store.get_store_item(mname)
|
||||
unowned_games.add(m_info['gametype'])
|
||||
return unowned_games
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception("error calcing un-owned games")
|
||||
return set()
|
||||
129
assets/src/data/scripts/ba/_modutils.py
Normal file
129
assets/src/data/scripts/ba/_modutils.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""Functionality related to modding."""
|
||||
|
||||
import os
|
||||
|
||||
import _ba
|
||||
|
||||
|
||||
def get_human_readable_user_scripts_path() -> str:
|
||||
"""Return a human readable location of user-scripts.
|
||||
|
||||
This is NOT a valid filesystem path; may be something like "(SD Card)".
|
||||
"""
|
||||
from ba import _lang
|
||||
app = _ba.app
|
||||
path = app.user_scripts_directory
|
||||
if path is None:
|
||||
return '<Not Available>'
|
||||
|
||||
# On newer versions of android, the user's external storage dir is probably
|
||||
# only visible to the user's processes and thus not really useful printed
|
||||
# in its entirety; lets print it as <External Storage>/myfilepath.
|
||||
if app.platform == 'android':
|
||||
ext_storage_path = (_ba.android_get_external_storage_path())
|
||||
if (ext_storage_path is not None
|
||||
and app.user_scripts_directory.startswith(ext_storage_path)):
|
||||
path = ('<' +
|
||||
_lang.Lstr(resource='externalStorageText').evaluate() +
|
||||
'>' + app.user_scripts_directory[len(ext_storage_path):])
|
||||
return path
|
||||
|
||||
|
||||
def show_user_scripts() -> None:
|
||||
"""Open or nicely print the location of the user-scripts directory."""
|
||||
from ba import _lang
|
||||
from ba._enums import Permission
|
||||
app = _ba.app
|
||||
|
||||
# First off, if we need permission for this, ask for it.
|
||||
if not _ba.have_permission(Permission.STORAGE):
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
_ba.screenmessage(_lang.Lstr(resource='storagePermissionAccessText'),
|
||||
color=(1, 0, 0))
|
||||
_ba.request_permission(Permission.STORAGE)
|
||||
return
|
||||
|
||||
# Secondly, if the dir doesn't exist, attempt to make it.
|
||||
if not os.path.exists(app.user_scripts_directory):
|
||||
os.makedirs(app.user_scripts_directory)
|
||||
|
||||
# On android, attempt to write a file in their user-scripts dir telling
|
||||
# them about modding. This also has the side-effect of allowing us to
|
||||
# media-scan that dir so it shows up in android-file-transfer, since it
|
||||
# doesn't seem like there's a way to inform the media scanner of an empty
|
||||
# directory, which means they would have to reboot their device before
|
||||
# they can see it.
|
||||
if app.platform == 'android':
|
||||
try:
|
||||
usd = app.user_scripts_directory
|
||||
if usd is not None and os.path.isdir(usd):
|
||||
file_name = usd + '/about_this_folder.txt'
|
||||
with open(file_name, 'w') as outfile:
|
||||
outfile.write('You can drop files in here to mod the game.'
|
||||
' See settings/advanced'
|
||||
' in the game for more info.')
|
||||
_ba.android_media_scan_file(file_name)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('error writing about_this_folder stuff')
|
||||
|
||||
# On a few platforms we try to open the dir in the UI.
|
||||
if app.platform in ['mac', 'windows']:
|
||||
_ba.open_dir_externally(app.user_scripts_directory)
|
||||
|
||||
# Otherwise we just print a pretty version of it.
|
||||
else:
|
||||
_ba.screenmessage(get_human_readable_user_scripts_path())
|
||||
|
||||
|
||||
def create_user_system_scripts() -> None:
|
||||
"""Set up a copy of Ballistica system scripts under your user scripts dir.
|
||||
|
||||
(for editing and experiment with)
|
||||
"""
|
||||
app = _ba.app
|
||||
import shutil
|
||||
path = (app.user_scripts_directory + '/sys/' + app.version)
|
||||
if os.path.exists(path):
|
||||
shutil.rmtree(path)
|
||||
if os.path.exists(path + "_tmp"):
|
||||
shutil.rmtree(path + "_tmp")
|
||||
os.makedirs(path + '_tmp', exist_ok=True)
|
||||
|
||||
# Hmm; shutil.copytree doesn't seem to work nicely on android,
|
||||
# so lets do it manually.
|
||||
src_dir = app.system_scripts_directory
|
||||
dst_dir = path + "_tmp"
|
||||
filenames = os.listdir(app.system_scripts_directory)
|
||||
for fname in filenames:
|
||||
print('COPYING', src_dir + '/' + fname, '->', dst_dir)
|
||||
shutil.copyfile(src_dir + '/' + fname, dst_dir + '/' + fname)
|
||||
|
||||
print('MOVING', path + "_tmp", path)
|
||||
shutil.move(path + "_tmp", path)
|
||||
print(
|
||||
('Created system scripts at :\'' + path +
|
||||
'\'\nRestart Ballistica to use them. (use ba.quit() to exit the game)'
|
||||
))
|
||||
if app.platform == 'android':
|
||||
print('Note: the new files may not be visible via '
|
||||
'android-file-transfer until you restart your device.')
|
||||
|
||||
|
||||
def delete_user_system_scripts() -> None:
|
||||
"""Clean out the scripts created by create_user_system_scripts()."""
|
||||
import shutil
|
||||
app = _ba.app
|
||||
path = (app.user_scripts_directory + '/sys/' + app.version)
|
||||
if os.path.exists(path):
|
||||
shutil.rmtree(path)
|
||||
print(
|
||||
'User system scripts deleted.\nRestart Ballistica to use internal'
|
||||
' scripts. (use ba.quit() to exit the game)')
|
||||
else:
|
||||
print('User system scripts not found.')
|
||||
|
||||
# If the sys path is empty, kill it.
|
||||
dpath = app.user_scripts_directory + '/sys'
|
||||
if os.path.isdir(dpath) and not os.listdir(dpath):
|
||||
os.rmdir(dpath)
|
||||
732
assets/src/data/scripts/ba/_music.py
Normal file
732
assets/src/data/scripts/ba/_music.py
Normal file
@ -0,0 +1,732 @@
|
||||
"""Music related functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Callable, Any, List, Optional, Dict
|
||||
|
||||
|
||||
class MusicPlayer:
|
||||
"""Wrangles soundtrack music playback.
|
||||
|
||||
Music can be played either through the game itself
|
||||
or via a platform-specific external player.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._have_set_initial_volume = False
|
||||
self._entry_to_play = None
|
||||
self._volume = 1.0
|
||||
self._actually_playing = False
|
||||
|
||||
def select_entry(self, callback: Callable[[Any], None], current_entry: Any,
|
||||
selection_target_name: str) -> Any:
|
||||
"""Summons a UI to select a new soundtrack entry."""
|
||||
return self.on_select_entry(callback, current_entry,
|
||||
selection_target_name)
|
||||
|
||||
def set_volume(self, volume: float) -> None:
|
||||
"""Set player volume (value should be between 0 and 1)."""
|
||||
self._volume = volume
|
||||
self.on_set_volume(volume)
|
||||
self._update_play_state()
|
||||
|
||||
def play(self, entry: Any) -> None:
|
||||
"""Play provided entry."""
|
||||
if not self._have_set_initial_volume:
|
||||
self._volume = _ba.app.config.resolve('Music Volume')
|
||||
self.on_set_volume(self._volume)
|
||||
self._have_set_initial_volume = True
|
||||
self._entry_to_play = copy.deepcopy(entry)
|
||||
|
||||
# If we're currently *actually* playing something,
|
||||
# switch to the new thing.
|
||||
# Otherwise update state which will start us playing *only*
|
||||
# if proper (volume > 0, etc).
|
||||
if self._actually_playing:
|
||||
self.on_play(self._entry_to_play)
|
||||
else:
|
||||
self._update_play_state()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop any playback that is occurring."""
|
||||
self._entry_to_play = None
|
||||
self._update_play_state()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shutdown music playback completely."""
|
||||
self.on_shutdown()
|
||||
|
||||
def on_select_entry(self, callback: Callable[[Any], None],
|
||||
current_entry: Any, selection_target_name: str) -> Any:
|
||||
"""Present a GUI to select an entry.
|
||||
|
||||
The callback should be called with a valid entry or None to
|
||||
signify that the default soundtrack should be used.."""
|
||||
|
||||
# Subclasses should override the following:
|
||||
def on_set_volume(self, volume: float) -> None:
|
||||
"""Called when the volume should be changed."""
|
||||
|
||||
def on_play(self, entry: Any) -> None:
|
||||
"""Called when a new song/playlist/etc should be played."""
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""Called when the music should stop."""
|
||||
|
||||
def on_shutdown(self) -> None:
|
||||
"""Called on final app shutdown."""
|
||||
|
||||
def _update_play_state(self) -> None:
|
||||
|
||||
# If we aren't playing, should be, and have positive volume, do so.
|
||||
if not self._actually_playing:
|
||||
if self._entry_to_play is not None and self._volume > 0.0:
|
||||
self.on_play(self._entry_to_play)
|
||||
self._actually_playing = True
|
||||
else:
|
||||
if self._actually_playing and (self._entry_to_play is None
|
||||
or self._volume <= 0.0):
|
||||
self.on_stop()
|
||||
self._actually_playing = False
|
||||
|
||||
|
||||
class InternalMusicPlayer(MusicPlayer):
|
||||
"""Music player that talks to internal c layer functionality.
|
||||
|
||||
(internal)"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._want_to_play = False
|
||||
self._actually_playing = False
|
||||
|
||||
def on_select_entry(self, callback: Callable[[Any], None],
|
||||
current_entry: Any, selection_target_name: str) -> Any:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.ui.soundtrack.entrytypeselect import (
|
||||
SoundtrackEntryTypeSelectWindow)
|
||||
return SoundtrackEntryTypeSelectWindow(callback, current_entry,
|
||||
selection_target_name)
|
||||
|
||||
def on_set_volume(self, volume: float) -> None:
|
||||
_ba.music_player_set_volume(volume)
|
||||
|
||||
class _PickFolderSongThread(threading.Thread):
|
||||
|
||||
def __init__(self, path: str, callback: Callable):
|
||||
super().__init__()
|
||||
self._callback = callback
|
||||
self._path = path
|
||||
|
||||
def run(self) -> None:
|
||||
from ba import _lang
|
||||
from ba._general import Call
|
||||
try:
|
||||
_ba.set_thread_name("BA_PickFolderSongThread")
|
||||
all_files: List[str] = []
|
||||
valid_extensions = [
|
||||
'.' + x for x in get_valid_music_file_extensions()
|
||||
]
|
||||
for root, _subdirs, filenames in os.walk(self._path):
|
||||
for fname in filenames:
|
||||
if any(fname.lower().endswith(ext)
|
||||
for ext in valid_extensions):
|
||||
all_files.insert(
|
||||
random.randrange(len(all_files) + 1),
|
||||
root + '/' + fname)
|
||||
if not all_files:
|
||||
raise Exception(
|
||||
_lang.Lstr(resource='internal.noMusicFilesInFolderText'
|
||||
).evaluate())
|
||||
_ba.pushcall(Call(self._callback, result=all_files),
|
||||
from_other_thread=True)
|
||||
except Exception as exc:
|
||||
from ba import _error
|
||||
_error.print_exception()
|
||||
try:
|
||||
err_str = str(exc)
|
||||
except Exception:
|
||||
err_str = '<ENCERR4523>'
|
||||
_ba.pushcall(Call(self._callback,
|
||||
result=self._path,
|
||||
error=err_str),
|
||||
from_other_thread=True)
|
||||
|
||||
def on_play(self, entry: Any) -> None:
|
||||
entry_type = get_soundtrack_entry_type(entry)
|
||||
name = get_soundtrack_entry_name(entry)
|
||||
assert name is not None
|
||||
if entry_type == 'musicFile':
|
||||
self._want_to_play = self._actually_playing = True
|
||||
_ba.music_player_play(name)
|
||||
elif entry_type == 'musicFolder':
|
||||
|
||||
# Launch a thread to scan this folder and give us a random
|
||||
# valid file within.
|
||||
self._want_to_play = True
|
||||
self._actually_playing = False
|
||||
self._PickFolderSongThread(name, self._on_play_folder_cb).start()
|
||||
|
||||
def _on_play_folder_cb(self, result: str,
|
||||
error: Optional[str] = None) -> None:
|
||||
from ba import _lang
|
||||
if error is not None:
|
||||
rstr = (_lang.Lstr(
|
||||
resource='internal.errorPlayingMusicText').evaluate())
|
||||
err_str = (rstr.replace('${MUSIC}', os.path.basename(result)) +
|
||||
'; ' + str(error))
|
||||
_ba.screenmessage(err_str, color=(1, 0, 0))
|
||||
return
|
||||
|
||||
# There's a chance a stop could have been issued before our thread
|
||||
# returned. If that's the case, don't play.
|
||||
if not self._want_to_play:
|
||||
print('_on_play_folder_cb called with _want_to_play False')
|
||||
else:
|
||||
self._actually_playing = True
|
||||
_ba.music_player_play(result)
|
||||
|
||||
def on_stop(self) -> None:
|
||||
self._want_to_play = False
|
||||
self._actually_playing = False
|
||||
_ba.music_player_stop()
|
||||
|
||||
def on_shutdown(self) -> None:
|
||||
_ba.music_player_shutdown()
|
||||
|
||||
|
||||
# For internal music player.
|
||||
def get_valid_music_file_extensions() -> List[str]:
|
||||
"""Return file extensions for types playable on this device."""
|
||||
return ['mp3', 'ogg', 'm4a', 'wav', 'flac', 'mid']
|
||||
|
||||
|
||||
class ITunesThread(threading.Thread):
|
||||
"""Thread which wrangles iTunes/Music.app playback"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._commands_available = threading.Event()
|
||||
self._commands: List[List] = []
|
||||
self._volume = 1.0
|
||||
self._current_playlist: Optional[str] = None
|
||||
self._orig_volume: Optional[int] = None
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the iTunes/Music.app thread."""
|
||||
from ba._general import Call
|
||||
from ba._lang import Lstr
|
||||
from ba._enums import TimeType
|
||||
_ba.set_thread_name("BA_ITunesThread")
|
||||
_ba.itunes_init()
|
||||
|
||||
# It looks like launching iTunes here on 10.7/10.8 knocks us
|
||||
# out of fullscreen; ick. That might be a bug, but for now we
|
||||
# can work around it by reactivating ourself after.
|
||||
def do_print() -> None:
|
||||
_ba.timer(1.0,
|
||||
Call(_ba.screenmessage, Lstr(resource='usingItunesText'),
|
||||
(0, 1, 0)),
|
||||
timetype=TimeType.REAL)
|
||||
|
||||
_ba.pushcall(do_print, from_other_thread=True)
|
||||
|
||||
# Here we grab this to force the actual launch.
|
||||
# Currently (on 10.8 at least) this is causing a switch
|
||||
# away from our fullscreen window. to work around this we
|
||||
# explicitly focus our main window to bring ourself back.
|
||||
_ba.itunes_get_volume()
|
||||
_ba.pushcall(_ba.focus_window, from_other_thread=True)
|
||||
_ba.itunes_get_library_source()
|
||||
done = False
|
||||
while not done:
|
||||
self._commands_available.wait()
|
||||
self._commands_available.clear()
|
||||
|
||||
# We're not protecting this list with a mutex but we're
|
||||
# just using it as a simple queue so it should be fine.
|
||||
while self._commands:
|
||||
cmd = self._commands.pop(0)
|
||||
if cmd[0] == 'DIE':
|
||||
|
||||
self._handle_die_command()
|
||||
done = True
|
||||
break
|
||||
if cmd[0] == 'PLAY':
|
||||
self._handle_play_command(target=cmd[1])
|
||||
elif cmd[0] == 'GET_PLAYLISTS':
|
||||
self._handle_get_playlists_command(target=cmd[1])
|
||||
|
||||
del cmd # Allows the command data/callback/etc to be freed.
|
||||
|
||||
def set_volume(self, volume: float) -> None:
|
||||
"""Set volume to a value between 0 and 1."""
|
||||
old_volume = self._volume
|
||||
self._volume = volume
|
||||
|
||||
# If we've got nothing we're supposed to be playing,
|
||||
# don't touch itunes/music.
|
||||
if self._current_playlist is None:
|
||||
return
|
||||
|
||||
# If volume is going to zero, stop actually playing
|
||||
# but don't clear playlist.
|
||||
if old_volume > 0.0 and volume == 0.0:
|
||||
try:
|
||||
assert self._orig_volume is not None
|
||||
_ba.itunes_stop()
|
||||
_ba.itunes_set_volume(self._orig_volume)
|
||||
except Exception as exc:
|
||||
print('Error stopping iTunes music:', exc)
|
||||
elif self._volume > 0:
|
||||
|
||||
# If volume was zero, store pre-playing volume and start
|
||||
# playing.
|
||||
if old_volume == 0.0:
|
||||
self._orig_volume = _ba.itunes_get_volume()
|
||||
self._update_itunes_volume()
|
||||
if old_volume == 0.0:
|
||||
self._play_current_playlist()
|
||||
|
||||
def play_playlist(self, musictype: Optional[str]) -> None:
|
||||
"""Play the given playlist."""
|
||||
self._commands.append(['PLAY', musictype])
|
||||
self._commands_available.set()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Request that the player shuts down."""
|
||||
self._commands.append(['DIE'])
|
||||
self._commands_available.set()
|
||||
self.join()
|
||||
|
||||
def get_playlists(self, callback: Callable[[Any], None]) -> None:
|
||||
"""Request the list of playlists."""
|
||||
self._commands.append(['GET_PLAYLISTS', callback])
|
||||
self._commands_available.set()
|
||||
|
||||
def _handle_get_playlists_command(self, target: str) -> None:
|
||||
from ba._general import Call
|
||||
try:
|
||||
playlists = _ba.itunes_get_playlists()
|
||||
playlists = [
|
||||
p for p in playlists if p not in [
|
||||
'Music', 'Movies', 'TV Shows', 'Podcasts', 'iTunes\xa0U',
|
||||
'Books', 'Genius', 'iTunes DJ', 'Music Videos',
|
||||
'Home Videos', 'Voice Memos', 'Audiobooks'
|
||||
]
|
||||
]
|
||||
playlists.sort(key=lambda x: x.lower())
|
||||
except Exception as exc:
|
||||
print('Error getting iTunes playlists:', exc)
|
||||
playlists = []
|
||||
_ba.pushcall(Call(target, playlists), from_other_thread=True)
|
||||
|
||||
def _handle_play_command(self, target: Optional[str]) -> None:
|
||||
if target is None:
|
||||
if self._current_playlist is not None and self._volume > 0:
|
||||
try:
|
||||
assert self._orig_volume is not None
|
||||
_ba.itunes_stop()
|
||||
_ba.itunes_set_volume(self._orig_volume)
|
||||
except Exception as exc:
|
||||
print('Error stopping iTunes music:', exc)
|
||||
self._current_playlist = None
|
||||
else:
|
||||
# If we've got something playing with positive
|
||||
# volume, stop it.
|
||||
if self._current_playlist is not None and self._volume > 0:
|
||||
try:
|
||||
assert self._orig_volume is not None
|
||||
_ba.itunes_stop()
|
||||
_ba.itunes_set_volume(self._orig_volume)
|
||||
except Exception as exc:
|
||||
print('Error stopping iTunes music:', exc)
|
||||
|
||||
# Set our playlist and play it if our volume is up.
|
||||
self._current_playlist = target
|
||||
if self._volume > 0:
|
||||
self._orig_volume = (_ba.itunes_get_volume())
|
||||
self._update_itunes_volume()
|
||||
self._play_current_playlist()
|
||||
|
||||
def _handle_die_command(self) -> None:
|
||||
|
||||
# Only stop if we've actually played something
|
||||
# (we don't want to kill music the user has playing).
|
||||
if self._current_playlist is not None and self._volume > 0:
|
||||
try:
|
||||
assert self._orig_volume is not None
|
||||
_ba.itunes_stop()
|
||||
_ba.itunes_set_volume(self._orig_volume)
|
||||
except Exception as exc:
|
||||
print('Error stopping iTunes music:', exc)
|
||||
|
||||
def _play_current_playlist(self) -> None:
|
||||
try:
|
||||
from ba import _lang
|
||||
from ba._general import Call
|
||||
assert self._current_playlist is not None
|
||||
if _ba.itunes_play_playlist(self._current_playlist):
|
||||
pass
|
||||
else:
|
||||
_ba.pushcall(Call(
|
||||
_ba.screenmessage,
|
||||
_lang.get_resource('playlistNotFoundText') + ': \'' +
|
||||
self._current_playlist + '\'', (1, 0, 0)),
|
||||
from_other_thread=True)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception(
|
||||
f"error playing playlist {self._current_playlist}")
|
||||
|
||||
def _update_itunes_volume(self) -> None:
|
||||
_ba.itunes_set_volume(max(0, min(100, int(100.0 * self._volume))))
|
||||
|
||||
|
||||
class MacITunesMusicPlayer(MusicPlayer):
|
||||
"""A music-player that utilizes iTunes/Music.app for playback.
|
||||
|
||||
Allows selecting playlists as entries.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._thread = ITunesThread()
|
||||
self._thread.start()
|
||||
|
||||
def on_select_entry(self, callback: Callable[[Any], None],
|
||||
current_entry: Any, selection_target_name: str) -> Any:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.ui.soundtrack import entrytypeselect as etsel
|
||||
return etsel.SoundtrackEntryTypeSelectWindow(callback, current_entry,
|
||||
selection_target_name)
|
||||
|
||||
def on_set_volume(self, volume: float) -> None:
|
||||
self._thread.set_volume(volume)
|
||||
|
||||
def get_playlists(self, callback: Callable) -> None:
|
||||
"""Asynchronously fetch the list of available iTunes playlists."""
|
||||
self._thread.get_playlists(callback)
|
||||
|
||||
def on_play(self, entry: Any) -> None:
|
||||
entry_type = get_soundtrack_entry_type(entry)
|
||||
if entry_type == 'iTunesPlaylist':
|
||||
self._thread.play_playlist(get_soundtrack_entry_name(entry))
|
||||
else:
|
||||
print('MacITunesMusicPlayer passed unrecognized entry type:',
|
||||
entry_type)
|
||||
|
||||
def on_stop(self) -> None:
|
||||
self._thread.play_playlist(None)
|
||||
|
||||
def on_shutdown(self) -> None:
|
||||
self._thread.shutdown()
|
||||
|
||||
|
||||
def have_music_player() -> bool:
|
||||
"""Returns whether a music player is present."""
|
||||
return _ba.app.music_player_type is not None
|
||||
|
||||
|
||||
def get_music_player() -> MusicPlayer:
|
||||
"""Returns the system music player, instantiating if necessary."""
|
||||
app = _ba.app
|
||||
if app.music_player is None:
|
||||
if app.music_player_type is None:
|
||||
raise Exception("no music player type set")
|
||||
app.music_player = app.music_player_type()
|
||||
return app.music_player
|
||||
|
||||
|
||||
def music_volume_changed(val: float) -> None:
|
||||
"""Should be called when changing the music volume."""
|
||||
app = _ba.app
|
||||
if app.music_player is not None:
|
||||
app.music_player.set_volume(val)
|
||||
|
||||
|
||||
def set_music_play_mode(mode: str, force_restart: bool = False) -> None:
|
||||
"""Sets music play mode; used for soundtrack testing/etc."""
|
||||
app = _ba.app
|
||||
old_mode = app.music_mode
|
||||
app.music_mode = mode
|
||||
if old_mode != app.music_mode or force_restart:
|
||||
|
||||
# If we're switching into test mode we don't
|
||||
# actually play anything until its requested.
|
||||
# If we're switching *out* of test mode though
|
||||
# we want to go back to whatever the normal song was.
|
||||
if mode == 'regular':
|
||||
do_play_music(app.music_types['regular'])
|
||||
|
||||
|
||||
def supports_soundtrack_entry_type(entry_type: str) -> bool:
|
||||
"""Return whether the provided soundtrack entry type is supported here."""
|
||||
uas = _ba.app.user_agent_string
|
||||
if entry_type == 'iTunesPlaylist':
|
||||
return 'Mac' in uas
|
||||
if entry_type in ('musicFile', 'musicFolder'):
|
||||
return ('android' in uas
|
||||
and _ba.android_get_external_storage_path() is not None)
|
||||
if entry_type == 'default':
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_soundtrack_entry_type(entry: Any) -> str:
|
||||
"""Given a soundtrack entry, returns its type, taking into
|
||||
account what is supported locally."""
|
||||
try:
|
||||
if entry is None:
|
||||
entry_type = 'default'
|
||||
|
||||
# Simple string denotes iTunesPlaylist (legacy format).
|
||||
elif isinstance(entry, str):
|
||||
entry_type = 'iTunesPlaylist'
|
||||
|
||||
# For other entries we expect type and name strings in a dict.
|
||||
elif (isinstance(entry, dict) and 'type' in entry
|
||||
and isinstance(entry['type'], str) and 'name' in entry
|
||||
and isinstance(entry['name'], str)):
|
||||
entry_type = entry['type']
|
||||
else:
|
||||
raise Exception("invalid soundtrack entry: " + str(entry) +
|
||||
" (type " + str(type(entry)) + ")")
|
||||
if supports_soundtrack_entry_type(entry_type):
|
||||
return entry_type
|
||||
raise Exception("invalid soundtrack entry:" + str(entry))
|
||||
except Exception as exc:
|
||||
print('EXC on get_soundtrack_entry_type', exc)
|
||||
return 'default'
|
||||
|
||||
|
||||
def get_soundtrack_entry_name(entry: Any) -> str:
|
||||
"""Given a soundtrack entry, returns its name."""
|
||||
try:
|
||||
if entry is None:
|
||||
raise Exception('entry is None')
|
||||
|
||||
# Simple string denotes an iTunesPlaylist name (legacy entry).
|
||||
if isinstance(entry, str):
|
||||
return entry
|
||||
|
||||
# For other entries we expect type and name strings in a dict.
|
||||
if (isinstance(entry, dict) and 'type' in entry
|
||||
and isinstance(entry['type'], str) and 'name' in entry
|
||||
and isinstance(entry['name'], str)):
|
||||
return entry['name']
|
||||
raise Exception("invalid soundtrack entry:" + str(entry))
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception()
|
||||
return 'default'
|
||||
|
||||
|
||||
def setmusic(musictype: Optional[str], continuous: bool = False) -> None:
|
||||
"""Set or stop the current music based on a string musictype.
|
||||
|
||||
category: Gameplay Functions
|
||||
|
||||
Current valid values for 'musictype': 'Menu', 'Victory', 'CharSelect',
|
||||
'RunAway', 'Onslaught', 'Keep Away', 'Race', 'Epic Race', 'Scores',
|
||||
'GrandRomp', 'ToTheDeath', 'Chosen One', 'ForwardMarch', 'FlagCatcher',
|
||||
'Survival', 'Epic', 'Sports', 'Hockey', 'Football', 'Flying', 'Scary',
|
||||
'Marching'.
|
||||
|
||||
This function will handle loading and playing sound media as necessary,
|
||||
and also supports custom user soundtracks on specific platforms so the
|
||||
user can override particular game music with their own.
|
||||
|
||||
Pass None to stop music.
|
||||
|
||||
if 'continuous' is True the musictype passed is the same as what is already
|
||||
playing, the playing track will not be restarted.
|
||||
"""
|
||||
from ba import _gameutils
|
||||
|
||||
# All we do here now is set a few music attrs on the current globals
|
||||
# node. The foreground globals' current playing music then gets fed to
|
||||
# the do_play_music call below. This way we can seamlessly support custom
|
||||
# soundtracks in replays/etc since we're replaying an attr value set;
|
||||
# not an actual sound node create.
|
||||
gnode = _gameutils.sharedobj('globals')
|
||||
gnode.music_continuous = continuous
|
||||
gnode.music = '' if musictype is None else musictype
|
||||
gnode.music_count += 1
|
||||
|
||||
|
||||
def handle_app_resume() -> None:
|
||||
"""Should be run when the app resumes from a suspended state."""
|
||||
if _ba.is_os_playing_music():
|
||||
do_play_music(None)
|
||||
|
||||
|
||||
def do_play_music(musictype: Optional[str],
|
||||
continuous: bool = False,
|
||||
mode: str = 'regular',
|
||||
testsoundtrack: Dict = None) -> None:
|
||||
"""Plays the requested music type/mode.
|
||||
|
||||
For most cases setmusic() is the proper call to use, which itself calls
|
||||
this. Certain cases, however, such as soundtrack testing, may require
|
||||
calling this directly.
|
||||
"""
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-statements
|
||||
app = _ba.app
|
||||
with _ba.Context('ui'):
|
||||
|
||||
# If they don't want to restart music and we're already
|
||||
# playing what's requested, we're done.
|
||||
if continuous and app.music_types[mode] == musictype:
|
||||
return
|
||||
app.music_types[mode] = musictype
|
||||
cfg = app.config
|
||||
|
||||
# If the OS tells us there's currently music playing,
|
||||
# all our operations default to playing nothing.
|
||||
if _ba.is_os_playing_music():
|
||||
musictype = None
|
||||
|
||||
# If we're not in the mode this music is being set for,
|
||||
# don't actually change what's playing.
|
||||
if mode != app.music_mode:
|
||||
return
|
||||
|
||||
# Some platforms have a special music-player for things like iTunes
|
||||
# soundtracks, mp3s, etc. if this is the case, attempt to grab an
|
||||
# entry for this music-type, and if we have one, have the music-player
|
||||
# play it. If not, we'll play game music ourself.
|
||||
if musictype is not None and app.music_player_type is not None:
|
||||
try:
|
||||
soundtrack: Dict
|
||||
if testsoundtrack is not None:
|
||||
soundtrack = testsoundtrack
|
||||
else:
|
||||
soundtrack = cfg['Soundtracks'][cfg['Soundtrack']]
|
||||
entry = soundtrack[musictype]
|
||||
except Exception:
|
||||
entry = None
|
||||
else:
|
||||
entry = None
|
||||
|
||||
# Go through music-player.
|
||||
if entry is not None:
|
||||
|
||||
# Stop any existing internal music.
|
||||
if app.music is not None:
|
||||
app.music.delete()
|
||||
app.music = None
|
||||
|
||||
# Play music-player music.
|
||||
get_music_player().play(entry)
|
||||
|
||||
# Handle via internal music.
|
||||
else:
|
||||
if musictype is not None:
|
||||
loop = True
|
||||
if musictype == 'Menu':
|
||||
filename = 'menuMusic'
|
||||
volume = 5.0
|
||||
elif musictype == 'Victory':
|
||||
filename = 'victoryMusic'
|
||||
volume = 6.0
|
||||
loop = False
|
||||
elif musictype == 'CharSelect':
|
||||
filename = 'charSelectMusic'
|
||||
volume = 2.0
|
||||
elif musictype == 'RunAway':
|
||||
filename = 'runAwayMusic'
|
||||
volume = 6.0
|
||||
elif musictype == 'Onslaught':
|
||||
filename = 'runAwayMusic'
|
||||
volume = 6.0
|
||||
elif musictype == 'Keep Away':
|
||||
filename = 'runAwayMusic'
|
||||
volume = 6.0
|
||||
elif musictype == 'Race':
|
||||
filename = 'runAwayMusic'
|
||||
volume = 6.0
|
||||
elif musictype == 'Epic Race':
|
||||
filename = 'slowEpicMusic'
|
||||
volume = 6.0
|
||||
elif musictype == 'Scores':
|
||||
filename = 'scoresEpicMusic'
|
||||
volume = 3.0
|
||||
loop = False
|
||||
elif musictype == 'GrandRomp':
|
||||
filename = 'grandRompMusic'
|
||||
volume = 6.0
|
||||
elif musictype == 'ToTheDeath':
|
||||
filename = 'toTheDeathMusic'
|
||||
volume = 6.0
|
||||
elif musictype == 'Chosen One':
|
||||
filename = 'survivalMusic'
|
||||
volume = 4.0
|
||||
elif musictype == 'ForwardMarch':
|
||||
filename = 'forwardMarchMusic'
|
||||
volume = 4.0
|
||||
elif musictype == 'FlagCatcher':
|
||||
filename = 'flagCatcherMusic'
|
||||
volume = 6.0
|
||||
elif musictype == 'Survival':
|
||||
filename = 'survivalMusic'
|
||||
volume = 4.0
|
||||
elif musictype == 'Epic':
|
||||
filename = 'slowEpicMusic'
|
||||
volume = 6.0
|
||||
elif musictype == 'Sports':
|
||||
filename = 'sportsMusic'
|
||||
volume = 4.0
|
||||
elif musictype == 'Hockey':
|
||||
filename = 'sportsMusic'
|
||||
volume = 4.0
|
||||
elif musictype == 'Football':
|
||||
filename = 'sportsMusic'
|
||||
volume = 4.0
|
||||
elif musictype == 'Flying':
|
||||
filename = 'flyingMusic'
|
||||
volume = 4.0
|
||||
elif musictype == 'Scary':
|
||||
filename = 'scaryMusic'
|
||||
volume = 4.0
|
||||
elif musictype == 'Marching':
|
||||
filename = 'whenJohnnyComesMarchingHomeMusic'
|
||||
volume = 4.0
|
||||
else:
|
||||
print("Unknown music: '" + musictype + "'")
|
||||
filename = 'flagCatcherMusic'
|
||||
volume = 6.0
|
||||
|
||||
# Stop any existing music-player playback.
|
||||
if app.music_player is not None:
|
||||
app.music_player.stop()
|
||||
|
||||
# Stop any existing internal music.
|
||||
if app.music:
|
||||
app.music.delete()
|
||||
app.music = None
|
||||
|
||||
# Start up new internal music.
|
||||
if musictype is not None:
|
||||
|
||||
# FIXME: Currently this won't start playing if we're paused
|
||||
# since attr values don't get updated until
|
||||
# node updates happen. :-(
|
||||
# Update: hmm I don't think that's true anymore. Should check.
|
||||
app.music = _ba.newnode(type='sound',
|
||||
attrs={
|
||||
'sound': _ba.getsound(filename),
|
||||
'positional': False,
|
||||
'music': True,
|
||||
'volume': volume,
|
||||
'loop': loop
|
||||
})
|
||||
156
assets/src/data/scripts/ba/_netutils.py
Normal file
156
assets/src/data/scripts/ba/_netutils.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""Networking related functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import threading
|
||||
import weakref
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict, Union, Callable, Optional
|
||||
import socket
|
||||
ServerCallbackType = Callable[[Union[None, Dict[str, Any]]], None]
|
||||
|
||||
|
||||
def get_ip_address_type(addr: str) -> socket.AddressFamily:
|
||||
"""Return socket.AF_INET6 or socket.AF_INET4 for the provided address."""
|
||||
import socket
|
||||
socket_type = None
|
||||
|
||||
# First try it as an ipv4 address.
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, addr)
|
||||
socket_type = socket.AF_INET
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Hmm apparently not ipv4; try ipv6.
|
||||
if socket_type is None:
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, addr)
|
||||
socket_type = socket.AF_INET6
|
||||
except OSError:
|
||||
pass
|
||||
if socket_type is None:
|
||||
raise Exception("addr seems to be neither v4 or v6: " + str(addr))
|
||||
return socket_type
|
||||
|
||||
|
||||
class ServerResponseType(Enum):
|
||||
"""How to interpret responses from the server."""
|
||||
JSON = 0
|
||||
|
||||
|
||||
class ServerCallThread(threading.Thread):
|
||||
"""Thread to communicate with the master server."""
|
||||
|
||||
def __init__(self, request: str, request_type: str,
|
||||
data: Optional[Dict[str, Any]],
|
||||
callback: Optional[ServerCallbackType],
|
||||
response_type: ServerResponseType):
|
||||
super().__init__()
|
||||
self._request = request
|
||||
self._request_type = request_type
|
||||
if not isinstance(response_type, ServerResponseType):
|
||||
raise Exception(f'Invalid response type: {response_type}')
|
||||
self._response_type = response_type
|
||||
self._data = {} if data is None else copy.deepcopy(data)
|
||||
self._callback: Optional[ServerCallbackType] = callback
|
||||
self._context = _ba.Context('current')
|
||||
|
||||
# Save and restore the context we were created from.
|
||||
activity = _ba.getactivity(doraise=False)
|
||||
self._activity = weakref.ref(
|
||||
activity) if activity is not None else None
|
||||
|
||||
def _run_callback(self, arg: Union[None, Dict[str, Any]]) -> None:
|
||||
|
||||
# If we were created in an activity context and that activity has
|
||||
# since died, do nothing (hmm should we be using a context-call
|
||||
# instead of doing this manually?).
|
||||
activity = None if self._activity is None else self._activity()
|
||||
if activity is None or activity.is_expired():
|
||||
return
|
||||
|
||||
# Technically we could do the same check for session contexts,
|
||||
# but not gonna worry about it for now.
|
||||
assert self._callback is not None
|
||||
with self._context:
|
||||
self._callback(arg)
|
||||
|
||||
def run(self) -> None:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
from ba import _general
|
||||
try:
|
||||
self._data = _general.utf8_all(self._data)
|
||||
_ba.set_thread_name("BA_ServerCallThread")
|
||||
|
||||
# Seems pycharm doesn't know about urllib.parse.
|
||||
# noinspection PyUnresolvedReferences
|
||||
parse = urllib.parse
|
||||
if self._request_type == 'get':
|
||||
response = urllib.request.urlopen(
|
||||
urllib.request.Request(
|
||||
(_ba.get_master_server_address() + '/' +
|
||||
self._request + '?' + parse.urlencode(self._data)),
|
||||
None, {'User-Agent': _ba.app.user_agent_string}))
|
||||
elif self._request_type == 'post':
|
||||
response = urllib.request.urlopen(
|
||||
urllib.request.Request(
|
||||
_ba.get_master_server_address() + '/' + self._request,
|
||||
parse.urlencode(self._data).encode(),
|
||||
{'User-Agent': _ba.app.user_agent_string}))
|
||||
else:
|
||||
raise Exception("Invalid request_type: " + self._request_type)
|
||||
|
||||
# If html request failed.
|
||||
if response.getcode() != 200:
|
||||
response_data = None
|
||||
elif self._response_type == ServerResponseType.JSON:
|
||||
raw_data = response.read()
|
||||
|
||||
# Empty string here means something failed server side.
|
||||
if raw_data == b'':
|
||||
response_data = None
|
||||
else:
|
||||
# Json.loads requires str in python < 3.6.
|
||||
raw_data_s = raw_data.decode()
|
||||
response_data = json.loads(raw_data_s)
|
||||
else:
|
||||
raise Exception(f'invalid responsetype: {self._response_type}')
|
||||
except (urllib.error.URLError, ConnectionError):
|
||||
# Server rejected us, broken pipe, etc. It happens. Ignoring.
|
||||
response_data = None
|
||||
except Exception as exc:
|
||||
# Any other error here is unexpected, so let's make a note of it.
|
||||
print('Exc in ServerCallThread:', exc)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
response_data = None
|
||||
|
||||
if self._callback is not None:
|
||||
_ba.pushcall(_general.Call(self._run_callback, response_data),
|
||||
from_other_thread=True)
|
||||
|
||||
|
||||
def serverget(request: str,
|
||||
data: Dict[str, Any],
|
||||
callback: Optional[ServerCallbackType] = None,
|
||||
response_type: ServerResponseType = ServerResponseType.JSON
|
||||
) -> None:
|
||||
"""Make a call to the master server via a http GET."""
|
||||
ServerCallThread(request, 'get', data, callback, response_type).start()
|
||||
|
||||
|
||||
def serverput(request: str,
|
||||
data: Dict[str, Any],
|
||||
callback: Optional[ServerCallbackType] = None,
|
||||
response_type: ServerResponseType = ServerResponseType.JSON
|
||||
) -> None:
|
||||
"""Make a call to the master server via a http POST."""
|
||||
ServerCallThread(request, 'post', data, callback, response_type).start()
|
||||
522
assets/src/data/scripts/ba/_playlist.py
Normal file
522
assets/src/data/scripts/ba/_playlist.py
Normal file
@ -0,0 +1,522 @@
|
||||
"""Playlist related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any, TYPE_CHECKING, Dict, List
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Type, Sequence
|
||||
from ba import _session
|
||||
|
||||
PlaylistType = List[Dict[str, Any]]
|
||||
|
||||
|
||||
def filter_playlist(playlist: PlaylistType,
|
||||
sessiontype: Type[_session.Session],
|
||||
add_resolved_type: bool = False,
|
||||
remove_unowned: bool = True,
|
||||
mark_unowned: bool = False) -> PlaylistType:
|
||||
"""Return a filtered version of a playlist.
|
||||
|
||||
Strips out or replaces invalid or unowned game types, makes sure all
|
||||
settings are present, and adds in a 'resolved_type' which is the actual
|
||||
type.
|
||||
"""
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-statements
|
||||
from ba import _meta
|
||||
from ba import _maps
|
||||
from ba import _general
|
||||
from ba import _gameactivity
|
||||
goodlist: List[Dict] = []
|
||||
unowned_maps: Sequence[str]
|
||||
if remove_unowned or mark_unowned:
|
||||
unowned_maps = _maps.get_unowned_maps()
|
||||
unowned_game_types = _meta.get_unowned_game_types()
|
||||
else:
|
||||
unowned_maps = []
|
||||
unowned_game_types = set()
|
||||
|
||||
for entry in copy.deepcopy(playlist):
|
||||
# 'map' used to be called 'level' here
|
||||
if 'level' in entry:
|
||||
entry['map'] = entry['level']
|
||||
del entry['level']
|
||||
# we now stuff map into settings instead of it being its own thing
|
||||
if 'map' in entry:
|
||||
entry['settings']['map'] = entry['map']
|
||||
del entry['map']
|
||||
# update old map names to new ones
|
||||
entry['settings']['map'] = _maps.get_filtered_map_name(
|
||||
entry['settings']['map'])
|
||||
if remove_unowned and entry['settings']['map'] in unowned_maps:
|
||||
continue
|
||||
# ok, for each game in our list, try to import the module and grab
|
||||
# the actual game class. add successful ones to our initial list
|
||||
# to present to the user
|
||||
if not isinstance(entry['type'], str):
|
||||
raise Exception("invalid entry format")
|
||||
try:
|
||||
# do some type filters for backwards compat.
|
||||
if entry['type'] in ('Assault.AssaultGame',
|
||||
'Happy_Thoughts.HappyThoughtsGame',
|
||||
'bsAssault.AssaultGame',
|
||||
'bs_assault.AssaultGame'):
|
||||
entry['type'] = 'bastd.game.assault.AssaultGame'
|
||||
if entry['type'] in ('King_of_the_Hill.KingOfTheHillGame',
|
||||
'bsKingOfTheHill.KingOfTheHillGame',
|
||||
'bs_king_of_the_hill.KingOfTheHillGame'):
|
||||
entry['type'] = 'bastd.game.kingofthehill.KingOfTheHillGame'
|
||||
if entry['type'] in ('Capture_the_Flag.CTFGame',
|
||||
'bsCaptureTheFlag.CTFGame',
|
||||
'bs_capture_the_flag.CTFGame'):
|
||||
entry['type'] = (
|
||||
'bastd.game.capturetheflag.CaptureTheFlagGame')
|
||||
if entry['type'] in ('Death_Match.DeathMatchGame',
|
||||
'bsDeathMatch.DeathMatchGame',
|
||||
'bs_death_match.DeathMatchGame'):
|
||||
entry['type'] = 'bastd.game.deathmatch.DeathMatchGame'
|
||||
if entry['type'] in ('ChosenOne.ChosenOneGame',
|
||||
'bsChosenOne.ChosenOneGame',
|
||||
'bs_chosen_one.ChosenOneGame'):
|
||||
entry['type'] = 'bastd.game.chosenone.ChosenOneGame'
|
||||
if entry['type'] in ('Conquest.Conquest', 'Conquest.ConquestGame',
|
||||
'bsConquest.ConquestGame',
|
||||
'bs_conquest.ConquestGame'):
|
||||
entry['type'] = 'bastd.game.conquest.ConquestGame'
|
||||
if entry['type'] in ('Elimination.EliminationGame',
|
||||
'bsElimination.EliminationGame',
|
||||
'bs_elimination.EliminationGame'):
|
||||
entry['type'] = 'bastd.game.elimination.EliminationGame'
|
||||
if entry['type'] in ('Football.FootballGame',
|
||||
'bsFootball.FootballTeamGame',
|
||||
'bs_football.FootballTeamGame'):
|
||||
entry['type'] = 'bastd.game.football.FootballTeamGame'
|
||||
if entry['type'] in ('Hockey.HockeyGame', 'bsHockey.HockeyGame',
|
||||
'bs_hockey.HockeyGame'):
|
||||
entry['type'] = 'bastd.game.hockey.HockeyGame'
|
||||
if entry['type'] in ('Keep_Away.KeepAwayGame',
|
||||
'bsKeepAway.KeepAwayGame',
|
||||
'bs_keep_away.KeepAwayGame'):
|
||||
entry['type'] = 'bastd.game.keepaway.KeepAwayGame'
|
||||
if entry['type'] in ('Race.RaceGame', 'bsRace.RaceGame',
|
||||
'bs_race.RaceGame'):
|
||||
entry['type'] = 'bastd.game.race.RaceGame'
|
||||
if entry['type'] in ('bsEasterEggHunt.EasterEggHuntGame',
|
||||
'bs_easter_egg_hunt.EasterEggHuntGame'):
|
||||
entry['type'] = 'bastd.game.easteregghunt.EasterEggHuntGame'
|
||||
if entry['type'] in ('bsMeteorShower.MeteorShowerGame',
|
||||
'bs_meteor_shower.MeteorShowerGame'):
|
||||
entry['type'] = 'bastd.game.meteorshower.MeteorShowerGame'
|
||||
if entry['type'] in ('bsTargetPractice.TargetPracticeGame',
|
||||
'bs_target_practice.TargetPracticeGame'):
|
||||
entry['type'] = (
|
||||
'bastd.game.targetpractice.TargetPracticeGame')
|
||||
|
||||
gameclass = _general.getclass(entry['type'],
|
||||
_gameactivity.GameActivity)
|
||||
|
||||
if remove_unowned and gameclass in unowned_game_types:
|
||||
continue
|
||||
if add_resolved_type:
|
||||
entry['resolved_type'] = gameclass
|
||||
if mark_unowned and entry['settings']['map'] in unowned_maps:
|
||||
entry['is_unowned_map'] = True
|
||||
if mark_unowned and gameclass in unowned_game_types:
|
||||
entry['is_unowned_game'] = True
|
||||
|
||||
# make sure all settings the game defines are present
|
||||
neededsettings = gameclass.get_settings(sessiontype)
|
||||
for setting_name, setting in neededsettings:
|
||||
if (setting_name not in entry['settings']
|
||||
and 'default' in setting):
|
||||
entry['settings'][setting_name] = setting['default']
|
||||
goodlist.append(entry)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception()
|
||||
return goodlist
|
||||
|
||||
|
||||
def get_default_free_for_all_playlist() -> PlaylistType:
|
||||
"""Return a default playlist for free-for-all mode."""
|
||||
# NOTE: these are currently using old type/map names,
|
||||
# but filtering translates them properly to the new ones.
|
||||
# (is kinda a handy way to ensure filtering is working).
|
||||
# Eventually should update these though.
|
||||
return [{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Kills to Win Per Player': 10,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Doom Shroom'
|
||||
},
|
||||
'type': 'bs_death_match.DeathMatchGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Chosen One Gets Gloves': True,
|
||||
'Chosen One Gets Shield': False,
|
||||
'Chosen One Time': 30,
|
||||
'Epic Mode': 0,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Monkey Face'
|
||||
},
|
||||
'type': 'bs_chosen_one.ChosenOneGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Hold Time': 30,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Zigzag'
|
||||
},
|
||||
'type': 'bs_king_of_the_hill.KingOfTheHillGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'map': 'Rampage'
|
||||
},
|
||||
'type': 'bs_meteor_shower.MeteorShowerGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': 1,
|
||||
'Lives Per Player': 1,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 120,
|
||||
'map': 'Tip Top'
|
||||
},
|
||||
'type': 'bs_elimination.EliminationGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Hold Time': 30,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'The Pad'
|
||||
},
|
||||
'type': 'bs_keep_away.KeepAwayGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': True,
|
||||
'Kills to Win Per Player': 10,
|
||||
'Respawn Times': 0.25,
|
||||
'Time Limit': 120,
|
||||
'map': 'Rampage'
|
||||
},
|
||||
'type': 'bs_death_match.DeathMatchGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Bomb Spawning': 1000,
|
||||
'Epic Mode': False,
|
||||
'Laps': 3,
|
||||
'Mine Spawn Interval': 4000,
|
||||
'Mine Spawning': 4000,
|
||||
'Time Limit': 300,
|
||||
'map': 'Big G'
|
||||
},
|
||||
'type': 'bs_race.RaceGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Hold Time': 30,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Happy Thoughts'
|
||||
},
|
||||
'type': 'bs_king_of_the_hill.KingOfTheHillGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Enable Impact Bombs': 1,
|
||||
'Enable Triple Bombs': False,
|
||||
'Target Count': 2,
|
||||
'map': 'Doom Shroom'
|
||||
},
|
||||
'type': 'bs_target_practice.TargetPracticeGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Lives Per Player': 5,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Step Right Up'
|
||||
},
|
||||
'type': 'bs_elimination.EliminationGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Kills to Win Per Player': 10,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Crag Castle'
|
||||
},
|
||||
'type': 'bs_death_match.DeathMatchGame'
|
||||
},
|
||||
{
|
||||
'map': 'Lake Frigid',
|
||||
'settings': {
|
||||
'Bomb Spawning': 0,
|
||||
'Epic Mode': False,
|
||||
'Laps': 6,
|
||||
'Mine Spawning': 2000,
|
||||
'Time Limit': 300,
|
||||
'map': 'Lake Frigid'
|
||||
},
|
||||
'type': 'bs_race.RaceGame'
|
||||
}] # yapf: disable
|
||||
|
||||
|
||||
def get_default_teams_playlist() -> PlaylistType:
|
||||
"""Return a default playlist for teams mode."""
|
||||
|
||||
# NOTE: these are currently using old type/map names,
|
||||
# but filtering translates them properly to the new ones.
|
||||
# (is kinda a handy way to ensure filtering is working).
|
||||
# Eventually should update these though.
|
||||
return [{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Flag Idle Return Time': 30,
|
||||
'Flag Touch Return Time': 0,
|
||||
'Respawn Times': 1.0,
|
||||
'Score to Win': 3,
|
||||
'Time Limit': 600,
|
||||
'map': 'Bridgit'
|
||||
},
|
||||
'type': 'bs_capture_the_flag.CTFGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Respawn Times': 1.0,
|
||||
'Score to Win': 3,
|
||||
'Time Limit': 600,
|
||||
'map': 'Step Right Up'
|
||||
},
|
||||
'type': 'bs_assault.AssaultGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Balance Total Lives': False,
|
||||
'Epic Mode': False,
|
||||
'Lives Per Player': 3,
|
||||
'Respawn Times': 1.0,
|
||||
'Solo Mode': True,
|
||||
'Time Limit': 600,
|
||||
'map': 'Rampage'
|
||||
},
|
||||
'type': 'bs_elimination.EliminationGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Kills to Win Per Player': 5,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Roundabout'
|
||||
},
|
||||
'type': 'bs_death_match.DeathMatchGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Respawn Times': 1.0,
|
||||
'Score to Win': 1,
|
||||
'Time Limit': 600,
|
||||
'map': 'Hockey Stadium'
|
||||
},
|
||||
'type': 'bs_hockey.HockeyGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Hold Time': 30,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Monkey Face'
|
||||
},
|
||||
'type': 'bs_keep_away.KeepAwayGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Balance Total Lives': False,
|
||||
'Epic Mode': True,
|
||||
'Lives Per Player': 1,
|
||||
'Respawn Times': 1.0,
|
||||
'Solo Mode': False,
|
||||
'Time Limit': 120,
|
||||
'map': 'Tip Top'
|
||||
},
|
||||
'type': 'bs_elimination.EliminationGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Respawn Times': 1.0,
|
||||
'Score to Win': 3,
|
||||
'Time Limit': 300,
|
||||
'map': 'Crag Castle'
|
||||
},
|
||||
'type': 'bs_assault.AssaultGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Kills to Win Per Player': 5,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Doom Shroom'
|
||||
},
|
||||
'type': 'bs_death_match.DeathMatchGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'map': 'Rampage'
|
||||
},
|
||||
'type': 'bs_meteor_shower.MeteorShowerGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Flag Idle Return Time': 30,
|
||||
'Flag Touch Return Time': 0,
|
||||
'Respawn Times': 1.0,
|
||||
'Score to Win': 2,
|
||||
'Time Limit': 600,
|
||||
'map': 'Roundabout'
|
||||
},
|
||||
'type': 'bs_capture_the_flag.CTFGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Respawn Times': 1.0,
|
||||
'Score to Win': 21,
|
||||
'Time Limit': 600,
|
||||
'map': 'Football Stadium'
|
||||
},
|
||||
'type': 'bs_football.FootballTeamGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': True,
|
||||
'Respawn Times': 0.25,
|
||||
'Score to Win': 3,
|
||||
'Time Limit': 120,
|
||||
'map': 'Bridgit'
|
||||
},
|
||||
'type': 'bs_assault.AssaultGame'
|
||||
},
|
||||
{
|
||||
'map': 'Doom Shroom',
|
||||
'settings': {
|
||||
'Enable Impact Bombs': 1,
|
||||
'Enable Triple Bombs': False,
|
||||
'Target Count': 2,
|
||||
'map': 'Doom Shroom'
|
||||
},
|
||||
'type': 'bs_target_practice.TargetPracticeGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Hold Time': 30,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Tip Top'
|
||||
},
|
||||
'type': 'bs_king_of_the_hill.KingOfTheHillGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Respawn Times': 1.0,
|
||||
'Score to Win': 2,
|
||||
'Time Limit': 300,
|
||||
'map': 'Zigzag'
|
||||
},
|
||||
'type': 'bs_assault.AssaultGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Flag Idle Return Time': 30,
|
||||
'Flag Touch Return Time': 0,
|
||||
'Respawn Times': 1.0,
|
||||
'Score to Win': 3,
|
||||
'Time Limit': 300,
|
||||
'map': 'Happy Thoughts'
|
||||
},
|
||||
'type': 'bs_capture_the_flag.CTFGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Bomb Spawning': 1000,
|
||||
'Epic Mode': True,
|
||||
'Laps': 1,
|
||||
'Mine Spawning': 2000,
|
||||
'Time Limit': 300,
|
||||
'map': 'Big G'
|
||||
},
|
||||
'type': 'bs_race.RaceGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Kills to Win Per Player': 5,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Monkey Face'
|
||||
},
|
||||
'type': 'bs_death_match.DeathMatchGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Hold Time': 30,
|
||||
'Respawn Times': 1.0,
|
||||
'Time Limit': 300,
|
||||
'map': 'Lake Frigid'
|
||||
},
|
||||
'type': 'bs_keep_away.KeepAwayGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': False,
|
||||
'Flag Idle Return Time': 30,
|
||||
'Flag Touch Return Time': 3,
|
||||
'Respawn Times': 1.0,
|
||||
'Score to Win': 2,
|
||||
'Time Limit': 300,
|
||||
'map': 'Tip Top'
|
||||
},
|
||||
'type': 'bs_capture_the_flag.CTFGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Balance Total Lives': False,
|
||||
'Epic Mode': False,
|
||||
'Lives Per Player': 3,
|
||||
'Respawn Times': 1.0,
|
||||
'Solo Mode': False,
|
||||
'Time Limit': 300,
|
||||
'map': 'Crag Castle'
|
||||
},
|
||||
'type': 'bs_elimination.EliminationGame'
|
||||
},
|
||||
{
|
||||
'settings': {
|
||||
'Epic Mode': True,
|
||||
'Respawn Times': 0.25,
|
||||
'Time Limit': 120,
|
||||
'map': 'Zigzag'
|
||||
},
|
||||
'type': 'bs_conquest.ConquestGame'
|
||||
}] # yapf: disable
|
||||
52
assets/src/data/scripts/ba/_powerup.py
Normal file
52
assets/src/data/scripts/ba/_powerup.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Powerup related functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from dataclasses import dataclass
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Sequence, Tuple, Optional
|
||||
import ba
|
||||
|
||||
|
||||
@dataclass
|
||||
class PowerupMessage:
|
||||
# noinspection PyUnresolvedReferences
|
||||
"""A message telling an object to accept a powerup.
|
||||
|
||||
Category: Message Classes
|
||||
|
||||
This message is normally received by touching a ba.PowerupBox.
|
||||
|
||||
Attributes:
|
||||
|
||||
poweruptype
|
||||
The type of powerup to be granted (a string).
|
||||
See ba.Powerup.poweruptype for available type values.
|
||||
|
||||
source_node
|
||||
The node the powerup game from, or None otherwise.
|
||||
If a powerup is accepted, a ba.PowerupAcceptMessage should be sent
|
||||
back to the source_node to inform it of the fact. This will generally
|
||||
cause the powerup box to make a sound and disappear or whatnot.
|
||||
"""
|
||||
poweruptype: str
|
||||
source_node: Optional[ba.Node] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PowerupAcceptMessage:
|
||||
"""A message informing a ba.Powerup that it was accepted.
|
||||
|
||||
Category: Message Classes
|
||||
|
||||
This is generally sent in response to a ba.PowerupMessage
|
||||
to inform the box (or whoever granted it) that it can go away.
|
||||
"""
|
||||
|
||||
|
||||
def get_default_powerup_distribution() -> Sequence[Tuple[str, int]]:
|
||||
"""Standard set of powerups."""
|
||||
return (('triple_bombs', 3), ('ice_bombs', 3), ('punch', 3),
|
||||
('impact_bombs', 3), ('land_mines', 2), ('sticky_bombs', 3),
|
||||
('shield', 2), ('health', 1), ('curse', 1))
|
||||
90
assets/src/data/scripts/ba/_profile.py
Normal file
90
assets/src/data/scripts/ba/_profile.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Functionality related to player profiles."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import List, Tuple, Any, Dict, Optional
|
||||
|
||||
# NOTE: player color options are enforced server-side for non-pro accounts
|
||||
# so don't change these or they won't stick...
|
||||
PLAYER_COLORS = [(1, 0.15, 0.15), (0.2, 1, 0.2), (0.1, 0.1, 1), (0.2, 1, 1),
|
||||
(0.5, 0.25, 1.0), (1, 1, 0), (1, 0.5, 0), (1, 0.3, 0.5),
|
||||
(0.1, 0.1, 0.5), (0.4, 0.2, 0.1), (0.1, 0.35, 0.1),
|
||||
(1, 0.8, 0.5), (0.4, 0.05, 0.05), (0.13, 0.13, 0.13),
|
||||
(0.5, 0.5, 0.5), (1, 1, 1)] # yapf: disable
|
||||
|
||||
|
||||
def get_player_colors() -> List[Tuple[float, float, float]]:
|
||||
"""Return user-selectable player colors."""
|
||||
return PLAYER_COLORS
|
||||
|
||||
|
||||
def get_player_profile_icon(profilename: str) -> str:
|
||||
"""Given a profile name, returns an icon string for it.
|
||||
|
||||
(non-account profiles only)
|
||||
"""
|
||||
from ba._enums import SpecialChar
|
||||
|
||||
bs_config = _ba.app.config
|
||||
icon: str
|
||||
try:
|
||||
is_global = bs_config['Player Profiles'][profilename]['global']
|
||||
except Exception:
|
||||
is_global = False
|
||||
if is_global:
|
||||
try:
|
||||
icon = bs_config['Player Profiles'][profilename]['icon']
|
||||
except Exception:
|
||||
icon = _ba.charstr(SpecialChar.LOGO)
|
||||
else:
|
||||
icon = ''
|
||||
return icon
|
||||
|
||||
|
||||
def get_player_profile_colors(
|
||||
profilename: Optional[str], profiles: Dict[str, Dict[str, Any]] = None
|
||||
) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
|
||||
"""Given a profile, return colors for them."""
|
||||
bs_config = _ba.app.config
|
||||
if profiles is None:
|
||||
profiles = bs_config['Player Profiles']
|
||||
|
||||
# special case - when being asked for a random color in kiosk mode,
|
||||
# always return default purple
|
||||
if _ba.app.kiosk_mode and profilename is None:
|
||||
color = (0.5, 0.4, 1.0)
|
||||
highlight = (0.4, 0.4, 0.5)
|
||||
else:
|
||||
try:
|
||||
assert profilename is not None
|
||||
color = profiles[profilename]['color']
|
||||
except Exception:
|
||||
# key off name if possible
|
||||
if profilename is None:
|
||||
# first 6 are bright-ish
|
||||
color = PLAYER_COLORS[random.randrange(6)]
|
||||
else:
|
||||
# first 6 are bright-ish
|
||||
color = PLAYER_COLORS[sum([ord(c) for c in profilename]) % 6]
|
||||
|
||||
try:
|
||||
assert profilename is not None
|
||||
highlight = profiles[profilename]['highlight']
|
||||
except Exception:
|
||||
# key off name if possible
|
||||
if profilename is None:
|
||||
# last 2 are grey and white; ignore those or we
|
||||
# get lots of old-looking players
|
||||
highlight = PLAYER_COLORS[random.randrange(
|
||||
len(PLAYER_COLORS) - 2)]
|
||||
else:
|
||||
highlight = PLAYER_COLORS[sum(
|
||||
[ord(c) + 1
|
||||
for c in profilename]) % (len(PLAYER_COLORS) - 2)]
|
||||
|
||||
return color, highlight
|
||||
227
assets/src/data/scripts/ba/_server.py
Normal file
227
assets/src/data/scripts/ba/_server.py
Normal file
@ -0,0 +1,227 @@
|
||||
"""Functionality related to running the game in server-mode."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Optional, Dict, Any, Type
|
||||
import ba
|
||||
|
||||
|
||||
def config_server(config_file: str = None) -> None:
|
||||
"""Run the game in server mode with the provided server config file."""
|
||||
|
||||
from ba._enums import TimeType
|
||||
|
||||
app = _ba.app
|
||||
|
||||
# Read and store the provided server config and then delete the file it
|
||||
# came from.
|
||||
if config_file is not None:
|
||||
with open(config_file) as infile:
|
||||
app.server_config = json.loads(infile.read())
|
||||
os.remove(config_file)
|
||||
else:
|
||||
app.server_config = {}
|
||||
|
||||
# Make note if they want us to import a playlist;
|
||||
# we'll need to do that first if so.
|
||||
playlist_code = app.server_config.get('playlist_code')
|
||||
if playlist_code is not None:
|
||||
app.server_playlist_fetch = {
|
||||
'sent_request': False,
|
||||
'got_response': False,
|
||||
'playlist_code': str(playlist_code)
|
||||
}
|
||||
|
||||
# Apply config stuff that can take effect immediately (party name, etc).
|
||||
_config_server()
|
||||
|
||||
# Launch the server only the first time through;
|
||||
# after that it will be self-sustaining.
|
||||
if not app.launched_server:
|
||||
|
||||
# Now sit around until we're signed in and then kick off the server.
|
||||
with _ba.Context('ui'):
|
||||
|
||||
def do_it() -> None:
|
||||
if _ba.get_account_state() == 'signed_in':
|
||||
can_launch = False
|
||||
|
||||
# If we're trying to fetch a playlist, we do that first.
|
||||
if app.server_playlist_fetch is not None:
|
||||
|
||||
# Send request if we haven't.
|
||||
if not app.server_playlist_fetch['sent_request']:
|
||||
|
||||
def on_playlist_fetch_response(
|
||||
result: Optional[Dict[str, Any]]) -> None:
|
||||
if result is None:
|
||||
print('Error fetching playlist; aborting.')
|
||||
sys.exit(-1)
|
||||
|
||||
# Once we get here we simply modify our
|
||||
# config to use this playlist.
|
||||
type_name = (
|
||||
'teams' if
|
||||
result['playlistType'] == 'Team Tournament'
|
||||
else 'ffa' if result['playlistType'] ==
|
||||
'Free-for-All' else '??')
|
||||
print(('Playlist \'' + result['playlistName'] +
|
||||
'\' (' + type_name +
|
||||
') downloaded; running...'))
|
||||
assert app.server_playlist_fetch is not None
|
||||
app.server_playlist_fetch['got_response'] = (
|
||||
True)
|
||||
app.server_config['session_type'] = type_name
|
||||
app.server_config['playlist_name'] = (
|
||||
result['playlistName'])
|
||||
|
||||
print(('Requesting shared-playlist ' + str(
|
||||
app.server_playlist_fetch['playlist_code']) +
|
||||
'...'))
|
||||
app.server_playlist_fetch['sent_request'] = True
|
||||
_ba.add_transaction(
|
||||
{
|
||||
'type':
|
||||
'IMPORT_PLAYLIST',
|
||||
'code':
|
||||
app.
|
||||
server_playlist_fetch['playlist_code'],
|
||||
'overwrite':
|
||||
True
|
||||
},
|
||||
callback=on_playlist_fetch_response)
|
||||
_ba.run_transactions()
|
||||
|
||||
# If we got a valid result, forget the fetch ever
|
||||
# existed and move on.
|
||||
if app.server_playlist_fetch['got_response']:
|
||||
app.server_playlist_fetch = None
|
||||
can_launch = True
|
||||
else:
|
||||
can_launch = True
|
||||
if can_launch:
|
||||
app.run_server_wait_timer = None
|
||||
_ba.pushcall(launch_server_session)
|
||||
|
||||
app.run_server_wait_timer = _ba.Timer(0.25,
|
||||
do_it,
|
||||
timetype=TimeType.REAL,
|
||||
repeat=True)
|
||||
app.launched_server = True
|
||||
|
||||
|
||||
def launch_server_session() -> None:
|
||||
"""Kick off a host-session based on the current server config."""
|
||||
from ba._netutils import serverget
|
||||
from ba import _freeforallsession
|
||||
from ba import _teamssession
|
||||
app = _ba.app
|
||||
servercfg = copy.deepcopy(app.server_config)
|
||||
appcfg = app.config
|
||||
|
||||
# Convert string session type to the class.
|
||||
# Hmm should we just keep this as a string?
|
||||
session_type_name = servercfg.get('session_type', 'ffa')
|
||||
sessiontype: Type[ba.Session]
|
||||
if session_type_name == 'ffa':
|
||||
sessiontype = _freeforallsession.FreeForAllSession
|
||||
elif session_type_name == 'teams':
|
||||
sessiontype = _teamssession.TeamsSession
|
||||
else:
|
||||
raise Exception('invalid session_type value: ' + session_type_name)
|
||||
|
||||
if _ba.get_account_state() != 'signed_in':
|
||||
print('WARNING: launch_server_session() expects to run '
|
||||
'with a signed in server account')
|
||||
|
||||
if app.run_server_first_run:
|
||||
print((('BallisticaCore headless '
|
||||
if app.subplatform == 'headless' else 'BallisticaCore ') +
|
||||
str(app.version) + ' (' + str(app.build_number) +
|
||||
') entering server-mode ' + time.strftime('%c')))
|
||||
|
||||
playlist_shuffle = servercfg.get('playlist_shuffle', True)
|
||||
appcfg['Show Tutorial'] = False
|
||||
appcfg['Free-for-All Playlist Selection'] = (servercfg.get(
|
||||
'playlist_name', '__default__') if session_type_name == 'ffa' else
|
||||
'__default__')
|
||||
appcfg['Free-for-All Playlist Randomize'] = playlist_shuffle
|
||||
appcfg['Team Tournament Playlist Selection'] = (servercfg.get(
|
||||
'playlist_name', '__default__') if session_type_name == 'teams' else
|
||||
'__default__')
|
||||
appcfg['Team Tournament Playlist Randomize'] = playlist_shuffle
|
||||
appcfg['Port'] = servercfg.get('port', 43210)
|
||||
|
||||
# Set series lengths.
|
||||
app.teams_series_length = servercfg.get('teams_series_length', 7)
|
||||
app.ffa_series_length = servercfg.get('ffa_series_length', 24)
|
||||
|
||||
# And here we go.
|
||||
_ba.new_host_session(sessiontype)
|
||||
|
||||
# Also lets fire off an access check if this is our first time
|
||||
# through (and they want a public party).
|
||||
if app.run_server_first_run:
|
||||
|
||||
def access_check_response(data: Optional[Dict[str, Any]]) -> None:
|
||||
gameport = _ba.get_game_port()
|
||||
if data is None:
|
||||
print('error on UDP port access check (internet down?)')
|
||||
else:
|
||||
if data['accessible']:
|
||||
print('UDP port', gameport,
|
||||
('access check successful. Your '
|
||||
'server appears to be joinable '
|
||||
'from the internet.'))
|
||||
else:
|
||||
print('UDP port', gameport,
|
||||
('access check failed. Your server '
|
||||
'does not appear to be joinable '
|
||||
'from the internet.'))
|
||||
|
||||
port = _ba.get_game_port()
|
||||
serverget('bsAccessCheck', {
|
||||
'port': port,
|
||||
'b': app.build_number
|
||||
},
|
||||
callback=access_check_response)
|
||||
app.run_server_first_run = False
|
||||
app.server_config_dirty = False
|
||||
|
||||
|
||||
def _config_server() -> None:
|
||||
"""Apply server config changes that can take effect immediately.
|
||||
|
||||
(party name, etc)
|
||||
"""
|
||||
config = copy.deepcopy(_ba.app.server_config)
|
||||
|
||||
# FIXME: Should make a proper low level config entry for this or
|
||||
# else not store in in app.config. Probably shouldn't be going through
|
||||
# the app config for this anyway since it should just be for this run.
|
||||
_ba.app.config['Auto Balance Teams'] = (config.get('auto_balance_teams',
|
||||
True))
|
||||
|
||||
_ba.set_public_party_max_size(config.get('max_party_size', 9))
|
||||
_ba.set_public_party_name(config.get('party_name', 'party'))
|
||||
_ba.set_public_party_stats_url(config.get('stats_url', ''))
|
||||
|
||||
# Call set-enabled last (will push state).
|
||||
_ba.set_public_party_enabled(config.get('party_is_public', True))
|
||||
|
||||
if not _ba.app.run_server_first_run:
|
||||
print('server config updated.')
|
||||
|
||||
# FIXME: We could avoid setting this as dirty if the only changes have
|
||||
# been ones here we can apply immediately. Could reduce cases where
|
||||
# players have to rejoin.
|
||||
_ba.app.server_config_dirty = True
|
||||
796
assets/src/data/scripts/ba/_session.py
Normal file
796
assets/src/data/scripts/ba/_session.py
Normal file
@ -0,0 +1,796 @@
|
||||
"""Defines base session class."""
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from weakref import ReferenceType
|
||||
from typing import Sequence, List, Dict, Any, Optional, Set
|
||||
import ba
|
||||
|
||||
|
||||
class Session:
|
||||
"""Defines a high level series of activities with a common purpose.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
Examples of sessions are ba.FreeForAllSession, ba.TeamsSession, and
|
||||
ba.CoopSession.
|
||||
|
||||
A Session is responsible for wrangling and transitioning between various
|
||||
ba.Activity instances such as mini-games and score-screens, and for
|
||||
maintaining state between them (players, teams, score tallies, etc).
|
||||
|
||||
Attributes:
|
||||
|
||||
teams
|
||||
All the ba.Teams in the Session. Most things should use the team
|
||||
list in ba.Activity; not this.
|
||||
|
||||
players
|
||||
All ba.Players in the Session. Most things should use the player
|
||||
list in ba.Activity; not this. Some players, such as those who have
|
||||
not yet selected a character, will only appear on this list.
|
||||
|
||||
min_players
|
||||
The minimum number of Players who must be present for the Session
|
||||
to proceed past the initial joining screen.
|
||||
|
||||
max_players
|
||||
The maximum number of Players allowed in the Session.
|
||||
|
||||
lobby
|
||||
The ba.Lobby instance where new ba.Players go to select a
|
||||
Profile/Team/etc. before being added to games.
|
||||
Be aware this value may be None if a Session does not allow
|
||||
any such selection.
|
||||
|
||||
campaign
|
||||
The ba.Campaign instance this Session represents, or None if
|
||||
there is no associated Campaign.
|
||||
|
||||
"""
|
||||
|
||||
# Annotate our attrs at class level so they're available for introspection.
|
||||
teams: List[ba.Team]
|
||||
campaign: Optional[ba.Campaign]
|
||||
lobby: ba.Lobby
|
||||
min_players: int
|
||||
max_players: int
|
||||
players: List[ba.Player]
|
||||
|
||||
def __init__(self,
|
||||
depsets: Sequence[ba.DepSet],
|
||||
team_names: Sequence[str] = None,
|
||||
team_colors: Sequence[Sequence[float]] = None,
|
||||
use_team_colors: bool = True,
|
||||
min_players: int = 1,
|
||||
max_players: int = 8,
|
||||
allow_mid_activity_joins: bool = True):
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
"""Instantiate a session.
|
||||
|
||||
depsets should be a sequence of successfully resolved ba.DepSet
|
||||
instances; one for each ba.Activity the session may potentially run.
|
||||
"""
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._lobby import Lobby
|
||||
from ba._stats import Stats
|
||||
from ba._gameutils import sharedobj
|
||||
from ba._gameactivity import GameActivity
|
||||
from ba._team import Team
|
||||
from ba._error import DependencyError
|
||||
from ba._dep import Dep, AssetPackage
|
||||
|
||||
print(' WOULD LOOK AT DEP SETS', depsets)
|
||||
|
||||
# first off, resolve all dep-sets we were passed.
|
||||
# if things are missing, we'll try to gather them into
|
||||
# a single missing-deps exception if possible
|
||||
# to give the caller a clean path to download missing
|
||||
# stuff and try again.
|
||||
missing_asset_packages: Set[str] = set()
|
||||
for depset in depsets:
|
||||
try:
|
||||
depset.resolve()
|
||||
except DependencyError as exc:
|
||||
# we gather/report missing assets only; barf on anything else
|
||||
if all(issubclass(d.cls, AssetPackage) for d in exc.deps):
|
||||
for dep in exc.deps:
|
||||
assert isinstance(dep.config, str)
|
||||
missing_asset_packages.add(dep.config)
|
||||
else:
|
||||
missing_info = [(d.cls, d.config) for d in exc.deps]
|
||||
raise Exception(
|
||||
f'Missing non-asset dependencies: {missing_info}')
|
||||
# throw a combined exception if we found anything missing
|
||||
if missing_asset_packages:
|
||||
raise DependencyError([
|
||||
Dep(AssetPackage, set_id) for set_id in missing_asset_packages
|
||||
])
|
||||
|
||||
# ok; looks like our dependencies check out.
|
||||
# now give the engine a list of asset-set-ids to pass along to clients
|
||||
required_asset_packages: Set[str] = set()
|
||||
for depset in depsets:
|
||||
required_asset_packages.update(depset.get_asset_package_ids())
|
||||
print('Would set host-session asset-reqs to:', required_asset_packages)
|
||||
|
||||
if team_names is None:
|
||||
team_names = ['Good Guys']
|
||||
if team_colors is None:
|
||||
team_colors = [(0.6, 0.2, 1.0)]
|
||||
|
||||
# First thing, wire up our internal engine data.
|
||||
self._sessiondata = _ba.register_session(self)
|
||||
|
||||
self.tournament_id: Optional[str] = None
|
||||
|
||||
# FIXME: This stuff shouldn't be here.
|
||||
self.sharedobjs: Dict[str, Any] = {}
|
||||
|
||||
# TeamGameActivity uses this to display a help overlay on
|
||||
# the first activity only.
|
||||
self.have_shown_controls_help_overlay = False
|
||||
|
||||
self.campaign = None
|
||||
|
||||
# FIXME: Should be able to kill this I think.
|
||||
self.campaign_state: Dict[str, str] = {}
|
||||
|
||||
self._use_teams = (team_names is not None)
|
||||
self._use_team_colors = use_team_colors
|
||||
self._in_set_activity = False
|
||||
self._allow_mid_activity_joins = allow_mid_activity_joins
|
||||
|
||||
self.teams = []
|
||||
self.players = []
|
||||
self._next_team_id = 0
|
||||
self._activity_retained: Optional[ba.Activity] = None
|
||||
self.launch_end_session_activity_time: Optional[float] = None
|
||||
self._activity_end_timer: Optional[ba.Timer] = None
|
||||
|
||||
# Hacky way to create empty weak ref; must be a better way.
|
||||
class _EmptyObj:
|
||||
pass
|
||||
|
||||
self._activity_weak: ReferenceType[ba.Activity]
|
||||
self._activity_weak = weakref.ref(_EmptyObj()) # type: ignore
|
||||
|
||||
if self._activity_weak() is not None:
|
||||
raise Exception("error creating empty weak ref")
|
||||
|
||||
self._next_activity: Optional[ba.Activity] = None
|
||||
self.wants_to_end = False
|
||||
self._ending = False
|
||||
self.min_players = min_players
|
||||
self.max_players = max_players
|
||||
|
||||
if self._use_teams:
|
||||
for i, color in enumerate(team_colors):
|
||||
team = Team(team_id=self._next_team_id,
|
||||
name=GameActivity.get_team_display_string(
|
||||
team_names[i]),
|
||||
color=color)
|
||||
self.teams.append(team)
|
||||
self._next_team_id += 1
|
||||
|
||||
try:
|
||||
with _ba.Context(self):
|
||||
self.on_team_join(team)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('exception in on_team_join for',
|
||||
self)
|
||||
|
||||
self.lobby = Lobby()
|
||||
self.stats = Stats()
|
||||
|
||||
# instantiates our session globals node.. (so it can apply
|
||||
# default settings)
|
||||
sharedobj('globals')
|
||||
|
||||
@property
|
||||
def use_teams(self) -> bool:
|
||||
"""(internal)"""
|
||||
return self._use_teams
|
||||
|
||||
@property
|
||||
def use_team_colors(self) -> bool:
|
||||
"""(internal)"""
|
||||
return self._use_team_colors
|
||||
|
||||
def on_player_request(self, player: ba.Player) -> bool:
|
||||
"""Called when a new ba.Player wants to join the Session.
|
||||
|
||||
This should return True or False to accept/reject.
|
||||
"""
|
||||
from ba._lang import Lstr
|
||||
# limit player counts *unless* we're in a stress test
|
||||
if _ba.app.stress_test_reset_timer is None:
|
||||
|
||||
if len(self.players) >= self.max_players:
|
||||
|
||||
# print a rejection message *only* to the client trying to join
|
||||
# (prevents spamming everyone else in the game)
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
_ba.screenmessage(
|
||||
Lstr(resource='playerLimitReachedText',
|
||||
subs=[('${COUNT}', str(self.max_players))]),
|
||||
color=(0.8, 0.0, 0.0),
|
||||
clients=[player.get_input_device().client_id],
|
||||
transient=True)
|
||||
return False
|
||||
|
||||
_ba.playsound(_ba.getsound('dripity'))
|
||||
return True
|
||||
|
||||
def on_player_leave(self, player: ba.Player) -> None:
|
||||
"""Called when a previously-accepted ba.Player leaves the session."""
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._freeforallsession import FreeForAllSession
|
||||
from ba._lang import Lstr
|
||||
from ba import _error
|
||||
|
||||
# remove them from the game rosters
|
||||
if player in self.players:
|
||||
|
||||
_ba.playsound(_ba.getsound('playerLeft'))
|
||||
|
||||
team: Optional[ba.Team]
|
||||
|
||||
# the player will have no team if they are still in the lobby
|
||||
try:
|
||||
team = player.team
|
||||
except _error.TeamNotFoundError:
|
||||
team = None
|
||||
|
||||
activity = self._activity_weak()
|
||||
|
||||
# If he had no team, he's in the lobby.
|
||||
# If we have a current activity with a lobby, ask them to
|
||||
# remove him.
|
||||
if team is None:
|
||||
with _ba.Context(self):
|
||||
try:
|
||||
self.lobby.remove_chooser(player)
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'Error in Lobby.remove_chooser()')
|
||||
|
||||
# *if* he was actually in the game, announce his departure
|
||||
if team is not None:
|
||||
_ba.screenmessage(
|
||||
Lstr(resource='playerLeftText',
|
||||
subs=[('${PLAYER}', player.get_name(full=True))]))
|
||||
|
||||
# Remove him from his team and session lists.
|
||||
# (he may not be on the team list since player are re-added to
|
||||
# team lists every activity)
|
||||
if team is not None and player in team.players:
|
||||
|
||||
# testing.. can remove this eventually
|
||||
if isinstance(self, FreeForAllSession):
|
||||
if len(team.players) != 1:
|
||||
_error.print_error("expected 1 player in FFA team")
|
||||
team.players.remove(player)
|
||||
|
||||
# Remove player from any current activity.
|
||||
if activity is not None and player in activity.players:
|
||||
activity.players.remove(player)
|
||||
|
||||
# Run the activity callback unless its been expired.
|
||||
if not activity.is_expired():
|
||||
try:
|
||||
with _ba.Context(activity):
|
||||
activity.on_player_leave(player)
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'exception in on_player_leave for activity',
|
||||
activity)
|
||||
else:
|
||||
_error.print_error("expired activity in on_player_leave;"
|
||||
" shouldn't happen")
|
||||
|
||||
player.set_activity(None)
|
||||
player.set_node(None)
|
||||
|
||||
# reset the player - this will remove its actor-ref and clear
|
||||
# its calls/etc
|
||||
try:
|
||||
with _ba.Context(activity):
|
||||
player.reset()
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'exception in player.reset in'
|
||||
' on_player_leave for player', player)
|
||||
|
||||
# If we're a non-team session, remove the player's team completely.
|
||||
if not self._use_teams and team is not None:
|
||||
|
||||
# If the team's in an activity, call its on_team_leave
|
||||
# callback.
|
||||
if activity is not None and team in activity.teams:
|
||||
activity.teams.remove(team)
|
||||
|
||||
if not activity.is_expired():
|
||||
try:
|
||||
with _ba.Context(activity):
|
||||
activity.on_team_leave(team)
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'exception in on_team_leave for activity',
|
||||
activity)
|
||||
else:
|
||||
_error.print_error(
|
||||
"expired activity in on_player_leave p2"
|
||||
"; shouldn't happen")
|
||||
|
||||
# Clear the team's game-data (so dying stuff will
|
||||
# have proper context).
|
||||
try:
|
||||
with _ba.Context(activity):
|
||||
team.reset_gamedata()
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'exception clearing gamedata for team:', team,
|
||||
'for player:', player, 'in activity:', activity)
|
||||
|
||||
# Remove the team from the session.
|
||||
self.teams.remove(team)
|
||||
try:
|
||||
with _ba.Context(self):
|
||||
self.on_team_leave(team)
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'exception in on_team_leave for session', self)
|
||||
# Clear the team's session-data (so dying stuff will
|
||||
# have proper context).
|
||||
try:
|
||||
with _ba.Context(self):
|
||||
team.reset_sessiondata()
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
'exception clearing sessiondata for team:', team,
|
||||
'in session:', self)
|
||||
|
||||
# Now remove them from the session list.
|
||||
self.players.remove(player)
|
||||
|
||||
else:
|
||||
print('ERROR: Session.on_player_leave called'
|
||||
' for player not in our list.')
|
||||
|
||||
def end(self) -> None:
|
||||
"""Initiates an end to the session and a return to the main menu.
|
||||
|
||||
Note that this happens asynchronously, allowing the
|
||||
session and its activities to shut down gracefully.
|
||||
"""
|
||||
self.wants_to_end = True
|
||||
if self._next_activity is None:
|
||||
self.launch_end_session_activity()
|
||||
|
||||
def launch_end_session_activity(self) -> None:
|
||||
"""(internal)"""
|
||||
from ba import _error
|
||||
from ba._activitytypes import EndSessionActivity
|
||||
from ba._enums import TimeType
|
||||
with _ba.Context(self):
|
||||
curtime = _ba.time(TimeType.REAL)
|
||||
if self._ending:
|
||||
# ignore repeats unless its been a while..
|
||||
assert self.launch_end_session_activity_time is not None
|
||||
since_last = (curtime - self.launch_end_session_activity_time)
|
||||
if since_last < 30.0:
|
||||
return
|
||||
_error.print_error(
|
||||
"launch_end_session_activity called twice (since_last=" +
|
||||
str(since_last) + ")")
|
||||
self.launch_end_session_activity_time = curtime
|
||||
self.set_activity(_ba.new_activity(EndSessionActivity))
|
||||
self.wants_to_end = False
|
||||
self._ending = True # prevents further activity-mucking
|
||||
|
||||
def on_team_join(self, team: ba.Team) -> None:
|
||||
"""Called when a new ba.Team joins the session."""
|
||||
|
||||
def on_team_leave(self, team: ba.Team) -> None:
|
||||
"""Called when a ba.Team is leaving the session."""
|
||||
|
||||
def _complete_end_activity(self, activity: ba.Activity,
|
||||
results: Any) -> None:
|
||||
# run the subclass callback in the session context
|
||||
try:
|
||||
with _ba.Context(self):
|
||||
self.on_activity_end(activity, results)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception(
|
||||
'exception in on_activity_end() for session', self, 'activity',
|
||||
activity, 'with results', results)
|
||||
|
||||
def end_activity(self, activity: ba.Activity, results: Any, delay: float,
|
||||
force: bool) -> None:
|
||||
"""Commence shutdown of a ba.Activity (if not already occurring).
|
||||
|
||||
'delay' is the time delay before the Activity actually ends
|
||||
(in seconds). Further calls to end() will be ignored up until
|
||||
this time, unless 'force' is True, in which case the new results
|
||||
will replace the old.
|
||||
"""
|
||||
from ba._general import Call
|
||||
from ba._enums import TimeType
|
||||
# only pay attention if this is coming from our current activity..
|
||||
if activity is not self._activity_retained:
|
||||
return
|
||||
|
||||
# if this activity hasn't begun yet, just set it up to end immediately
|
||||
# once it does
|
||||
if not activity.has_begun():
|
||||
activity.set_immediate_end(results, delay, force)
|
||||
|
||||
# the activity has already begun; get ready to end it..
|
||||
else:
|
||||
if (not activity.has_ended()) or force:
|
||||
activity.set_has_ended(True)
|
||||
# set a timer to set in motion this activity's demise
|
||||
self._activity_end_timer = _ba.Timer(
|
||||
delay,
|
||||
Call(self._complete_end_activity, activity, results),
|
||||
timetype=TimeType.BASE)
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
"""General message handling; can be passed any message object."""
|
||||
from ba._lobby import PlayerReadyMessage
|
||||
from ba._error import UNHANDLED
|
||||
from ba._messages import PlayerProfilesChangedMessage
|
||||
if isinstance(msg, PlayerReadyMessage):
|
||||
self._on_player_ready(msg.chooser)
|
||||
return None
|
||||
|
||||
if isinstance(msg, PlayerProfilesChangedMessage):
|
||||
# if we have a current activity with a lobby, ask it to
|
||||
# reload profiles
|
||||
with _ba.Context(self):
|
||||
self.lobby.reload_profiles()
|
||||
return None
|
||||
|
||||
return UNHANDLED
|
||||
|
||||
def set_activity(self, activity: ba.Activity) -> None:
|
||||
"""Assign a new current ba.Activity for the session.
|
||||
|
||||
Note that this will not change the current context to the new
|
||||
Activity's. Code must be run in the new activity's methods
|
||||
(on_transition_in, etc) to get it. (so you can't do
|
||||
session.set_activity(foo) and then ba.newnode() to add a node to foo)
|
||||
"""
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
from ba import _error
|
||||
from ba._gameutils import sharedobj
|
||||
from ba._enums import TimeType
|
||||
|
||||
# sanity test - make sure this doesn't get called recursively
|
||||
if self._in_set_activity:
|
||||
raise Exception(
|
||||
"Session.set_activity() cannot be called recursively.")
|
||||
|
||||
if activity.session is not _ba.getsession():
|
||||
raise Exception("provided activity's session is not current")
|
||||
|
||||
# quietly ignore this if the whole session is going down
|
||||
if self._ending:
|
||||
return
|
||||
|
||||
if activity is self._activity_retained:
|
||||
_error.print_error("activity set to already-current activity")
|
||||
return
|
||||
|
||||
if self._next_activity is not None:
|
||||
raise Exception("Activity switch already in progress (to " +
|
||||
str(self._next_activity) + ")")
|
||||
|
||||
self._in_set_activity = True
|
||||
|
||||
prev_activity = self._activity_retained
|
||||
|
||||
if prev_activity is not None:
|
||||
with _ba.Context(prev_activity):
|
||||
gprev = sharedobj('globals')
|
||||
else:
|
||||
gprev = None
|
||||
|
||||
with _ba.Context(activity):
|
||||
|
||||
# Now that it's going to be front and center,
|
||||
# set some global values based on what the activity wants.
|
||||
glb = sharedobj('globals')
|
||||
glb.use_fixed_vr_overlay = activity.use_fixed_vr_overlay
|
||||
glb.allow_kick_idle_players = activity.allow_kick_idle_players
|
||||
if activity.inherits_slow_motion and gprev is not None:
|
||||
glb.slow_motion = gprev.slow_motion
|
||||
else:
|
||||
glb.slow_motion = activity.slow_motion
|
||||
if activity.inherits_music and gprev is not None:
|
||||
glb.music_continuous = True # prevents restarting same music
|
||||
glb.music = gprev.music
|
||||
glb.music_count += 1
|
||||
if activity.inherits_camera_vr_offset and gprev is not None:
|
||||
glb.vr_camera_offset = gprev.vr_camera_offset
|
||||
if activity.inherits_vr_overlay_center and gprev is not None:
|
||||
glb.vr_overlay_center = gprev.vr_overlay_center
|
||||
glb.vr_overlay_center_enabled = gprev.vr_overlay_center_enabled
|
||||
|
||||
# if they want to inherit tint from the previous activity..
|
||||
if activity.inherits_tint and gprev is not None:
|
||||
glb.tint = gprev.tint
|
||||
glb.vignette_outer = gprev.vignette_outer
|
||||
glb.vignette_inner = gprev.vignette_inner
|
||||
|
||||
# let the activity do its thing..
|
||||
activity.start_transition_in()
|
||||
|
||||
self._next_activity = activity
|
||||
|
||||
# if we have a current activity, tell it it's transitioning out;
|
||||
# the next one will become current once this one dies.
|
||||
if prev_activity is not None:
|
||||
# pylint: disable=protected-access
|
||||
prev_activity._transitioning_out = True
|
||||
# pylint: enable=protected-access
|
||||
|
||||
# activity will be None until the next one begins
|
||||
with _ba.Context(prev_activity):
|
||||
prev_activity.on_transition_out()
|
||||
|
||||
# setting this to None should free up the old activity to die
|
||||
# which will call begin_next_activity.
|
||||
# we can still access our old activity through
|
||||
# self._activity_weak() to keep it up to date on player
|
||||
# joins/departures/etc until it dies
|
||||
self._activity_retained = None
|
||||
|
||||
# there's no existing activity; lets just go ahead with the begin call
|
||||
else:
|
||||
self.begin_next_activity()
|
||||
|
||||
# tell the C layer that this new activity is now 'foregrounded'
|
||||
# this means that its globals node controls global stuff and
|
||||
# stuff like console operations, keyboard shortcuts, etc will run in it
|
||||
# pylint: disable=protected-access
|
||||
# noinspection PyProtectedMember
|
||||
activity._activity_data.make_foreground()
|
||||
# pylint: enable=protected-access
|
||||
|
||||
# we want to call _destroy() for the previous activity once it should
|
||||
# tear itself down, clear out any self-refs, etc. If the new activity
|
||||
# has a transition-time, set it up to be called after that passes;
|
||||
# otherwise call it immediately. After this call the activity should
|
||||
# have no refs left to it and should die (which will trigger the next
|
||||
# activity to run)
|
||||
if prev_activity is not None:
|
||||
if activity.transition_time > 0.0:
|
||||
# FIXME: We should tweak the activity to not allow
|
||||
# node-creation/etc when we call _destroy (or after).
|
||||
with _ba.Context('ui'):
|
||||
# pylint: disable=protected-access
|
||||
# noinspection PyProtectedMember
|
||||
_ba.timer(activity.transition_time,
|
||||
prev_activity._destroy,
|
||||
timetype=TimeType.REAL)
|
||||
# Just run immediately.
|
||||
else:
|
||||
# noinspection PyProtectedMember
|
||||
prev_activity._destroy() # pylint: disable=protected-access
|
||||
self._in_set_activity = False
|
||||
|
||||
def getactivity(self) -> Optional[ba.Activity]:
|
||||
"""Return the current foreground activity for this session."""
|
||||
return self._activity_weak()
|
||||
|
||||
def get_custom_menu_entries(self) -> List[Dict[str, Any]]:
|
||||
"""Subclasses can override this to provide custom menu entries.
|
||||
|
||||
The returned value should be a list of dicts, each containing
|
||||
a 'label' and 'call' entry, with 'label' being the text for
|
||||
the entry and 'call' being the callable to trigger if the entry
|
||||
is pressed.
|
||||
"""
|
||||
return []
|
||||
|
||||
def _request_player(self, player: ba.Player) -> bool:
|
||||
|
||||
# if we're ending, allow no new players
|
||||
if self._ending:
|
||||
return False
|
||||
|
||||
# ask the user
|
||||
try:
|
||||
with _ba.Context(self):
|
||||
result = self.on_player_request(player)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('error in on_player_request call for', self)
|
||||
result = False
|
||||
|
||||
# if the user said yes, add the player to the session list
|
||||
if result:
|
||||
self.players.append(player)
|
||||
|
||||
# if we have a current activity with a lobby,
|
||||
# ask it to bring up a chooser for this player.
|
||||
# otherwise they'll have to wait around for the next activity.
|
||||
with _ba.Context(self):
|
||||
try:
|
||||
self.lobby.add_chooser(player)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('exception in lobby.add_chooser()')
|
||||
|
||||
return result
|
||||
|
||||
def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
|
||||
"""Called when the current ba.Activity has ended.
|
||||
|
||||
The ba.Session should look at the results and start
|
||||
another ba.Activity.
|
||||
"""
|
||||
|
||||
def begin_next_activity(self) -> None:
|
||||
"""Called once the previous activity has been totally torn down.
|
||||
|
||||
This means we're ready to begin the next one
|
||||
"""
|
||||
if self._next_activity is not None:
|
||||
|
||||
# we store both a weak and a strong ref to the new activity;
|
||||
# the strong is to keep it alive and the weak is so we can access
|
||||
# it even after we've released the strong-ref to allow it to die
|
||||
self._activity_retained = self._next_activity
|
||||
self._activity_weak = weakref.ref(self._next_activity)
|
||||
self._next_activity = None
|
||||
|
||||
# lets kick out any players sitting in the lobby since
|
||||
# new activities such as score screens could cover them up;
|
||||
# better to have them rejoin
|
||||
self.lobby.remove_all_choosers_and_kick_players()
|
||||
activity = self._activity_weak()
|
||||
assert activity is not None
|
||||
activity.begin(self)
|
||||
|
||||
def _on_player_ready(self, chooser: ba.Chooser) -> None:
|
||||
"""Called when a ba.Player has checked themself ready."""
|
||||
from ba._lang import Lstr
|
||||
lobby = chooser.lobby
|
||||
activity = self._activity_weak()
|
||||
|
||||
# in joining activities, we wait till all choosers are ready
|
||||
# and then create all players at once
|
||||
if activity is not None and activity.is_joining_activity:
|
||||
if lobby.check_all_ready():
|
||||
choosers = lobby.get_choosers()
|
||||
min_players = self.min_players
|
||||
if len(choosers) >= min_players:
|
||||
for lch in lobby.get_choosers():
|
||||
self._add_chosen_player(lch)
|
||||
lobby.remove_all_choosers()
|
||||
# get our next activity going..
|
||||
self._complete_end_activity(activity, {})
|
||||
else:
|
||||
_ba.screenmessage(Lstr(resource='notEnoughPlayersText',
|
||||
subs=[('${COUNT}', str(min_players))
|
||||
]),
|
||||
color=(1, 1, 0))
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
else:
|
||||
return
|
||||
# otherwise just add players on the fly
|
||||
else:
|
||||
self._add_chosen_player(chooser)
|
||||
lobby.remove_chooser(chooser.getplayer())
|
||||
|
||||
def _add_chosen_player(self, chooser: ba.Chooser) -> ba.Player:
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
from ba import _error
|
||||
from ba._lang import Lstr
|
||||
from ba._team import Team
|
||||
from ba import _freeforallsession
|
||||
player = chooser.getplayer()
|
||||
if player not in self.players:
|
||||
_error.print_error('player not found in session '
|
||||
'player-list after chooser selection')
|
||||
|
||||
activity = self._activity_weak()
|
||||
assert activity is not None
|
||||
|
||||
# we need to reset the player's input here, as it is currently
|
||||
# referencing the chooser which could inadvertently keep it alive
|
||||
player.reset_input()
|
||||
|
||||
# pass it to the current activity if it has already begun
|
||||
# (otherwise it'll get passed once begin is called)
|
||||
pass_to_activity = (activity is not None and activity.has_begun()
|
||||
and not activity.is_joining_activity)
|
||||
|
||||
# if we're not allowing mid-game joins, don't pass; just announce
|
||||
# the arrival
|
||||
if pass_to_activity:
|
||||
if not self._allow_mid_activity_joins:
|
||||
pass_to_activity = False
|
||||
with _ba.Context(self):
|
||||
_ba.screenmessage(Lstr(resource='playerDelayedJoinText',
|
||||
subs=[('${PLAYER}',
|
||||
player.get_name(full=True))
|
||||
]),
|
||||
color=(0, 1, 0))
|
||||
|
||||
# if we're a non-team game, each player gets their own team
|
||||
# (keeps mini-game coding simpler if we can always deal with teams)
|
||||
if self._use_teams:
|
||||
team = chooser.get_team()
|
||||
else:
|
||||
our_team_id = self._next_team_id
|
||||
team = Team(team_id=our_team_id,
|
||||
name=chooser.getplayer().get_name(full=True,
|
||||
icon=False),
|
||||
color=chooser.get_color())
|
||||
self.teams.append(team)
|
||||
self._next_team_id += 1
|
||||
try:
|
||||
with _ba.Context(self):
|
||||
self.on_team_join(team)
|
||||
except Exception:
|
||||
_error.print_exception(f'exception in on_team_join for {self}')
|
||||
|
||||
if pass_to_activity:
|
||||
if team in activity.teams:
|
||||
_error.print_error(
|
||||
"Duplicate team ID in ba.Session._add_chosen_player")
|
||||
activity.teams.append(team)
|
||||
try:
|
||||
with _ba.Context(activity):
|
||||
activity.on_team_join(team)
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
f'ERROR: exception in on_team_join for {activity}')
|
||||
|
||||
player.set_data(team=team,
|
||||
character=chooser.get_character_name(),
|
||||
color=chooser.get_color(),
|
||||
highlight=chooser.get_highlight())
|
||||
|
||||
self.stats.register_player(player)
|
||||
if pass_to_activity:
|
||||
if isinstance(self, _freeforallsession.FreeForAllSession):
|
||||
if player.team.players:
|
||||
_error.print_error("expected 0 players in FFA team")
|
||||
|
||||
# Don't actually add the player to their team list if we're not
|
||||
# in an activity. (players get (re)added to their team lists
|
||||
# when the activity begins).
|
||||
player.team.players.append(player)
|
||||
if player in activity.players:
|
||||
_error.print_exception(
|
||||
f'Dup player in ba.Session._add_chosen_player: {player}')
|
||||
else:
|
||||
activity.players.append(player)
|
||||
player.set_activity(activity)
|
||||
pnode = activity.create_player_node(player)
|
||||
player.set_node(pnode)
|
||||
try:
|
||||
with _ba.Context(activity):
|
||||
activity.on_player_join(player)
|
||||
except Exception:
|
||||
_error.print_exception(
|
||||
f'Error on on_player_join for {activity}')
|
||||
return player
|
||||
506
assets/src/data/scripts/ba/_stats.py
Normal file
506
assets/src/data/scripts/ba/_stats.py
Normal file
@ -0,0 +1,506 @@
|
||||
"""Functionality related to scores and statistics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING
|
||||
from dataclasses import dataclass
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ba
|
||||
from weakref import ReferenceType
|
||||
from typing import Any, Dict, Optional, Sequence, Union
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerScoredMessage:
|
||||
# noinspection PyUnresolvedReferences
|
||||
"""Informs something that a ba.Player scored.
|
||||
|
||||
Category: Message Classes
|
||||
|
||||
Attributes:
|
||||
|
||||
score
|
||||
The score value.
|
||||
"""
|
||||
score: int
|
||||
|
||||
|
||||
class PlayerRecord:
|
||||
"""Stats for an individual player in a ba.Stats object.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
This does not necessarily correspond to a ba.Player that is
|
||||
still present (stats may be retained for players that leave
|
||||
mid-game)
|
||||
"""
|
||||
character: str
|
||||
|
||||
def __init__(self, name: str, name_full: str, player: ba.Player,
|
||||
stats: ba.Stats):
|
||||
self.name = name
|
||||
self.name_full = name_full
|
||||
self.score = 0
|
||||
self.accumscore = 0
|
||||
self.kill_count = 0
|
||||
self.accum_kill_count = 0
|
||||
self.killed_count = 0
|
||||
self.accum_killed_count = 0
|
||||
self._multi_kill_timer: Optional[ba.Timer] = None
|
||||
self._multikillcount = 0
|
||||
self._stats = weakref.ref(stats)
|
||||
self._last_player: Optional[ba.Player] = None
|
||||
self._player: Optional[ba.Player] = None
|
||||
self.associate_with_player(player)
|
||||
self._spaz: Optional[ReferenceType[ba.Actor]] = None
|
||||
self._team: Optional[ReferenceType[ba.Team]] = None
|
||||
self.streak = 0
|
||||
|
||||
@property
|
||||
def team(self) -> ba.Team:
|
||||
"""The ba.Team the last associated player was last on.
|
||||
|
||||
This can still return a valid result even if the player is gone.
|
||||
Raises a ba.TeamNotFoundError if the team no longer exists.
|
||||
"""
|
||||
assert self._team is not None
|
||||
team = self._team()
|
||||
if team is None:
|
||||
from ba._error import TeamNotFoundError
|
||||
raise TeamNotFoundError()
|
||||
return team
|
||||
|
||||
@property
|
||||
def player(self) -> ba.Player:
|
||||
"""Return the instance's associated ba.Player.
|
||||
|
||||
Raises a ba.PlayerNotFoundError if the player no longer exists."""
|
||||
if not self._player:
|
||||
from ba._error import PlayerNotFoundError
|
||||
raise PlayerNotFoundError()
|
||||
return self._player
|
||||
|
||||
def get_name(self, full: bool = False) -> str:
|
||||
"""Return the player entry's name."""
|
||||
return self.name_full if full else self.name
|
||||
|
||||
def get_icon(self) -> Dict[str, Any]:
|
||||
"""Get the icon for this instance's player."""
|
||||
player = self._last_player
|
||||
assert player is not None
|
||||
return player.get_icon()
|
||||
|
||||
def get_spaz(self) -> Optional[ba.Actor]:
|
||||
"""Return the player entry's spaz."""
|
||||
if self._spaz is None:
|
||||
return None
|
||||
return self._spaz()
|
||||
|
||||
def set_spaz(self, spaz: Optional[ba.Actor]) -> None:
|
||||
"""(internal)"""
|
||||
self._spaz = weakref.ref(spaz) if spaz is not None else None
|
||||
|
||||
def cancel_multi_kill_timer(self) -> None:
|
||||
"""Cancel any multi-kill timer for this player entry."""
|
||||
self._multi_kill_timer = None
|
||||
|
||||
def getactivity(self) -> Optional[ba.Activity]:
|
||||
"""Return the ba.Activity this instance is currently associated with.
|
||||
|
||||
Returns None if the activity no longer exists."""
|
||||
stats = self._stats()
|
||||
if stats is not None:
|
||||
return stats.getactivity()
|
||||
return None
|
||||
|
||||
def associate_with_player(self, player: ba.Player) -> None:
|
||||
"""Associate this entry with a ba.Player."""
|
||||
self._team = weakref.ref(player.team)
|
||||
self.character = player.character
|
||||
self._last_player = player
|
||||
self._player = player
|
||||
self._spaz = None
|
||||
self.streak = 0
|
||||
|
||||
def _end_multi_kill(self) -> None:
|
||||
self._multi_kill_timer = None
|
||||
self._multikillcount = 0
|
||||
|
||||
def get_last_player(self) -> ba.Player:
|
||||
"""Return the last ba.Player we were associated with."""
|
||||
assert self._last_player is not None
|
||||
return self._last_player
|
||||
|
||||
def submit_kill(self, showpoints: bool = True) -> None:
|
||||
"""Submit a kill for this player entry."""
|
||||
# FIXME Clean this up.
|
||||
# pylint: disable=too-many-statements
|
||||
from ba._lang import Lstr
|
||||
from ba._general import Call
|
||||
from ba._enums import TimeFormat
|
||||
self._multikillcount += 1
|
||||
stats = self._stats()
|
||||
assert stats
|
||||
if self._multikillcount == 1:
|
||||
score = 0
|
||||
name = None
|
||||
delay = 0
|
||||
color = (0.0, 0.0, 0.0, 1.0)
|
||||
scale = 1.0
|
||||
sound = None
|
||||
elif self._multikillcount == 2:
|
||||
score = 20
|
||||
name = Lstr(resource='twoKillText')
|
||||
color = (0.1, 1.0, 0.0, 1)
|
||||
scale = 1.0
|
||||
delay = 0
|
||||
sound = stats.orchestrahitsound1
|
||||
elif self._multikillcount == 3:
|
||||
score = 40
|
||||
name = Lstr(resource='threeKillText')
|
||||
color = (1.0, 0.7, 0.0, 1)
|
||||
scale = 1.1
|
||||
delay = 300
|
||||
sound = stats.orchestrahitsound2
|
||||
elif self._multikillcount == 4:
|
||||
score = 60
|
||||
name = Lstr(resource='fourKillText')
|
||||
color = (1.0, 1.0, 0.0, 1)
|
||||
scale = 1.2
|
||||
delay = 600
|
||||
sound = stats.orchestrahitsound3
|
||||
elif self._multikillcount == 5:
|
||||
score = 80
|
||||
name = Lstr(resource='fiveKillText')
|
||||
color = (1.0, 0.5, 0.0, 1)
|
||||
scale = 1.3
|
||||
delay = 900
|
||||
sound = stats.orchestrahitsound4
|
||||
else:
|
||||
score = 100
|
||||
name = Lstr(resource='multiKillText',
|
||||
subs=[('${COUNT}', str(self._multikillcount))])
|
||||
color = (1.0, 0.5, 0.0, 1)
|
||||
scale = 1.3
|
||||
delay = 1000
|
||||
sound = stats.orchestrahitsound4
|
||||
|
||||
def _apply(name2: str, score2: int, showpoints2: bool,
|
||||
color2: Sequence[float], scale2: float,
|
||||
sound2: ba.Sound) -> None:
|
||||
from bastd.actor.popuptext import PopupText
|
||||
|
||||
# Only award this if they're still alive and we can get
|
||||
# their pos.
|
||||
try:
|
||||
actor = self.get_spaz()
|
||||
assert actor is not None
|
||||
assert actor.node
|
||||
our_pos = actor.node.position
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Jitter position a bit since these often come in clusters.
|
||||
our_pos = (our_pos[0] + (random.random() - 0.5) * 2.0,
|
||||
our_pos[1] + (random.random() - 0.5) * 2.0,
|
||||
our_pos[2] + (random.random() - 0.5) * 2.0)
|
||||
activity = self.getactivity()
|
||||
if activity is not None:
|
||||
PopupText(Lstr(
|
||||
value=(('+' + str(score2) + ' ') if showpoints2 else '') +
|
||||
'${N}',
|
||||
subs=[('${N}', name2)]),
|
||||
color=color2,
|
||||
scale=scale2,
|
||||
position=our_pos).autoretain()
|
||||
_ba.playsound(sound2)
|
||||
|
||||
self.score += score2
|
||||
self.accumscore += score2
|
||||
|
||||
# Inform a running game of the score.
|
||||
if score2 != 0 and activity is not None:
|
||||
activity.handlemessage(PlayerScoredMessage(score=score2))
|
||||
|
||||
if name is not None:
|
||||
_ba.timer(300 + delay,
|
||||
Call(_apply, name, score, showpoints, color, scale,
|
||||
sound),
|
||||
timeformat=TimeFormat.MILLISECONDS)
|
||||
|
||||
# Keep the tally rollin'...
|
||||
# set a timer for a bit in the future.
|
||||
self._multi_kill_timer = _ba.Timer(1.0, self._end_multi_kill)
|
||||
|
||||
|
||||
class Stats:
|
||||
"""Manages scores and statistics for a ba.Session.
|
||||
|
||||
category: Gameplay Classes
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._activity: Optional[ReferenceType[ba.Activity]] = None
|
||||
self._player_records: Dict[str, PlayerRecord] = {}
|
||||
self.orchestrahitsound1: Optional[ba.Sound] = None
|
||||
self.orchestrahitsound2: Optional[ba.Sound] = None
|
||||
self.orchestrahitsound3: Optional[ba.Sound] = None
|
||||
self.orchestrahitsound4: Optional[ba.Sound] = None
|
||||
|
||||
def set_activity(self, activity: ba.Activity) -> None:
|
||||
"""Set the current activity for this instance."""
|
||||
|
||||
self._activity = None if activity is None else weakref.ref(activity)
|
||||
|
||||
# Load our media into this activity's context.
|
||||
if activity is not None:
|
||||
if activity.is_expired():
|
||||
from ba import _error
|
||||
_error.print_error('unexpected finalized activity')
|
||||
else:
|
||||
with _ba.Context(activity):
|
||||
self._load_activity_media()
|
||||
|
||||
def getactivity(self) -> Optional[ba.Activity]:
|
||||
"""Get the activity associated with this instance.
|
||||
|
||||
May return None.
|
||||
"""
|
||||
if self._activity is None:
|
||||
return None
|
||||
return self._activity()
|
||||
|
||||
def _load_activity_media(self) -> None:
|
||||
self.orchestrahitsound1 = _ba.getsound('orchestraHit')
|
||||
self.orchestrahitsound2 = _ba.getsound('orchestraHit2')
|
||||
self.orchestrahitsound3 = _ba.getsound('orchestraHit3')
|
||||
self.orchestrahitsound4 = _ba.getsound('orchestraHit4')
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the stats instance completely."""
|
||||
# Just to be safe, lets make sure no multi-kill timers are gonna go off
|
||||
# for no-longer-on-the-list players.
|
||||
for p_entry in list(self._player_records.values()):
|
||||
p_entry.cancel_multi_kill_timer()
|
||||
self._player_records = {}
|
||||
|
||||
def reset_accum(self) -> None:
|
||||
"""Reset per-sound sub-scores."""
|
||||
for s_player in list(self._player_records.values()):
|
||||
s_player.cancel_multi_kill_timer()
|
||||
s_player.accumscore = 0
|
||||
s_player.accum_kill_count = 0
|
||||
s_player.accum_killed_count = 0
|
||||
s_player.streak = 0
|
||||
|
||||
def register_player(self, player: ba.Player) -> None:
|
||||
"""Register a player with this score-set."""
|
||||
name = player.get_name()
|
||||
name_full = player.get_name(full=True)
|
||||
try:
|
||||
# If the player already exists, update his character and such as
|
||||
# it may have changed.
|
||||
self._player_records[name].associate_with_player(player)
|
||||
except Exception:
|
||||
# FIXME: Shouldn't use top level Exception catch for logic.
|
||||
# Should only have this as a fallback and always log it.
|
||||
self._player_records[name] = PlayerRecord(name, name_full, player,
|
||||
self)
|
||||
|
||||
def get_records(self) -> Dict[str, ba.PlayerRecord]:
|
||||
"""Get PlayerRecord corresponding to still-existing players."""
|
||||
records = {}
|
||||
|
||||
# Go through our player records and return ones whose player id still
|
||||
# corresponds to a player with that name.
|
||||
for record_id, record in self._player_records.items():
|
||||
lastplayer = record.get_last_player()
|
||||
if lastplayer and lastplayer.get_name() == record_id:
|
||||
records[record_id] = record
|
||||
return records
|
||||
|
||||
def _get_spaz(self, player: ba.Player) -> Optional[ba.Actor]:
|
||||
return self._player_records[player.get_name()].get_spaz()
|
||||
|
||||
def player_got_new_spaz(self, player: ba.Player, spaz: ba.Actor) -> None:
|
||||
"""Call this when a player gets a new Spaz."""
|
||||
record = self._player_records[player.get_name()]
|
||||
if record.get_spaz() is not None:
|
||||
raise Exception("got 2 player_got_new_spaz() messages in a row"
|
||||
" without a lost-spaz message")
|
||||
record.set_spaz(spaz)
|
||||
|
||||
def player_got_hit(self, player: ba.Player) -> None:
|
||||
"""Call this when a player got hit."""
|
||||
s_player = self._player_records[player.get_name()]
|
||||
s_player.streak = 0
|
||||
|
||||
def player_scored(self,
|
||||
player: ba.Player,
|
||||
base_points: int = 1,
|
||||
target: Sequence[float] = None,
|
||||
kill: bool = False,
|
||||
victim_player: ba.Player = None,
|
||||
scale: float = 1.0,
|
||||
color: Sequence[float] = None,
|
||||
title: Union[str, ba.Lstr] = None,
|
||||
screenmessage: bool = True,
|
||||
display: bool = True,
|
||||
importance: int = 1,
|
||||
showpoints: bool = True,
|
||||
big_message: bool = False) -> int:
|
||||
"""Register a score for the player.
|
||||
|
||||
Return value is actual score with multipliers and such factored in.
|
||||
"""
|
||||
# FIXME: Tidy this up.
|
||||
# pylint: disable=cyclic-import
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-statements
|
||||
from bastd.actor.popuptext import PopupText
|
||||
from ba import _math
|
||||
from ba._gameactivity import GameActivity
|
||||
from ba._lang import Lstr
|
||||
del victim_player # currently unused
|
||||
name = player.get_name()
|
||||
s_player = self._player_records[name]
|
||||
|
||||
if kill:
|
||||
s_player.submit_kill(showpoints=showpoints)
|
||||
|
||||
display_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0)
|
||||
|
||||
if color is not None:
|
||||
display_color = color
|
||||
elif importance != 1:
|
||||
display_color = (1.0, 1.0, 0.4, 1.0)
|
||||
points = base_points
|
||||
|
||||
# If they want a big announcement, throw a zoom-text up there.
|
||||
if display and big_message:
|
||||
try:
|
||||
assert self._activity is not None
|
||||
activity = self._activity()
|
||||
if isinstance(activity, GameActivity):
|
||||
name_full = player.get_name(full=True, icon=False)
|
||||
activity.show_zoom_message(
|
||||
Lstr(resource='nameScoresText',
|
||||
subs=[('${NAME}', name_full)]),
|
||||
color=_math.normalized_color(player.team.color))
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('error showing big_message')
|
||||
|
||||
# If we currently have a spaz, pop up a score over it.
|
||||
if display and showpoints:
|
||||
our_pos: Optional[Sequence[float]]
|
||||
try:
|
||||
spaz = s_player.get_spaz()
|
||||
assert spaz is not None
|
||||
assert spaz.node
|
||||
our_pos = spaz.node.position
|
||||
except Exception:
|
||||
our_pos = None
|
||||
if our_pos is not None:
|
||||
if target is None:
|
||||
target = our_pos
|
||||
|
||||
# If display-pos is *way* lower than us, raise it up
|
||||
# (so we can still see scores from dudes that fell off cliffs).
|
||||
display_pos = (target[0], max(target[1], our_pos[1] - 2.0),
|
||||
min(target[2], our_pos[2] + 2.0))
|
||||
activity = self.getactivity()
|
||||
if activity is not None:
|
||||
if title is not None:
|
||||
sval = Lstr(value='+${A} ${B}',
|
||||
subs=[('${A}', str(points)),
|
||||
('${B}', title)])
|
||||
else:
|
||||
sval = Lstr(value='+${A}',
|
||||
subs=[('${A}', str(points))])
|
||||
PopupText(sval,
|
||||
color=display_color,
|
||||
scale=1.2 * scale,
|
||||
position=display_pos).autoretain()
|
||||
|
||||
# Tally kills.
|
||||
if kill:
|
||||
s_player.accum_kill_count += 1
|
||||
s_player.kill_count += 1
|
||||
|
||||
# Report non-kill scorings.
|
||||
try:
|
||||
if screenmessage and not kill:
|
||||
_ba.screenmessage(Lstr(resource='nameScoresText',
|
||||
subs=[('${NAME}', name)]),
|
||||
top=True,
|
||||
color=player.color,
|
||||
image=player.get_icon())
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('error announcing score')
|
||||
|
||||
s_player.score += points
|
||||
s_player.accumscore += points
|
||||
|
||||
# Inform a running game of the score.
|
||||
if points != 0:
|
||||
activity = self._activity() if self._activity is not None else None
|
||||
if activity is not None:
|
||||
activity.handlemessage(PlayerScoredMessage(score=points))
|
||||
|
||||
return points
|
||||
|
||||
def player_lost_spaz(self,
|
||||
player: ba.Player,
|
||||
killed: bool = False,
|
||||
killer: ba.Player = None) -> None:
|
||||
"""Should be called when a player loses a spaz."""
|
||||
from ba._lang import Lstr
|
||||
name = player.get_name()
|
||||
prec = self._player_records[name]
|
||||
prec.set_spaz(None)
|
||||
prec.streak = 0
|
||||
if killed:
|
||||
prec.accum_killed_count += 1
|
||||
prec.killed_count += 1
|
||||
try:
|
||||
if killed and _ba.getactivity().announce_player_deaths:
|
||||
if killer == player:
|
||||
_ba.screenmessage(Lstr(resource='nameSuicideText',
|
||||
subs=[('${NAME}', name)]),
|
||||
top=True,
|
||||
color=player.color,
|
||||
image=player.get_icon())
|
||||
elif killer is not None:
|
||||
if killer.team == player.team:
|
||||
_ba.screenmessage(Lstr(resource='nameBetrayedText',
|
||||
subs=[('${NAME}',
|
||||
killer.get_name()),
|
||||
('${VICTIM}', name)]),
|
||||
top=True,
|
||||
color=killer.color,
|
||||
image=killer.get_icon())
|
||||
else:
|
||||
_ba.screenmessage(Lstr(resource='nameKilledText',
|
||||
subs=[('${NAME}',
|
||||
killer.get_name()),
|
||||
('${VICTIM}', name)]),
|
||||
top=True,
|
||||
color=killer.color,
|
||||
image=killer.get_icon())
|
||||
else:
|
||||
_ba.screenmessage(Lstr(resource='nameDiedText',
|
||||
subs=[('${NAME}', name)]),
|
||||
top=True,
|
||||
color=player.color,
|
||||
image=player.get_icon())
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('error announcing kill')
|
||||
507
assets/src/data/scripts/ba/_store.py
Normal file
507
assets/src/data/scripts/ba/_store.py
Normal file
@ -0,0 +1,507 @@
|
||||
"""Store related functionality for classic mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Type, List, Dict, Tuple, Optional, Any
|
||||
import ba
|
||||
|
||||
|
||||
def get_store_item(item: str) -> Dict[str, Any]:
|
||||
"""(internal)"""
|
||||
return get_store_items()[item]
|
||||
|
||||
|
||||
def get_store_item_name_translated(item_name: str) -> ba.Lstr:
|
||||
"""Return a ba.Lstr for a store item name."""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _lang
|
||||
from ba import _maps
|
||||
item_info = get_store_item(item_name)
|
||||
if item_name.startswith('characters.'):
|
||||
return _lang.Lstr(translate=('characterNames', item_info['character']))
|
||||
if item_name in ['upgrades.pro', 'pro']:
|
||||
return _lang.Lstr(resource='store.bombSquadProNameText',
|
||||
subs=[('${APP_NAME}',
|
||||
_lang.Lstr(resource='titleText'))])
|
||||
if item_name.startswith('maps.'):
|
||||
map_type: Type[ba.Map] = item_info['map_type']
|
||||
return _maps.get_map_display_string(map_type.name)
|
||||
if item_name.startswith('games.'):
|
||||
gametype: Type[ba.GameActivity] = item_info['gametype']
|
||||
return gametype.get_display_string()
|
||||
if item_name.startswith('icons.'):
|
||||
return _lang.Lstr(resource='editProfileWindow.iconText')
|
||||
raise Exception('unrecognized item: ' + item_name)
|
||||
|
||||
|
||||
def get_store_item_display_size(item_name: str) -> Tuple[float, float]:
|
||||
"""(internal)"""
|
||||
if item_name.startswith('characters.'):
|
||||
return 340 * 0.6, 430 * 0.6
|
||||
if item_name in ['pro', 'upgrades.pro']:
|
||||
return 650 * 0.9, 500 * 0.85
|
||||
if item_name.startswith('maps.'):
|
||||
return 510 * 0.6, 450 * 0.6
|
||||
if item_name.startswith('icons.'):
|
||||
return 265 * 0.6, 250 * 0.6
|
||||
return 450 * 0.6, 450 * 0.6
|
||||
|
||||
|
||||
def get_store_items() -> Dict[str, Dict]:
|
||||
"""Returns info about purchasable items.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._enums import SpecialChar
|
||||
from bastd import maps
|
||||
if _ba.app.store_items is None:
|
||||
from bastd.game import ninjafight
|
||||
from bastd.game import meteorshower
|
||||
from bastd.game import targetpractice
|
||||
from bastd.game import easteregghunt
|
||||
|
||||
# IMPORTANT - need to keep this synced with the master server.
|
||||
# (doing so manually for now)
|
||||
_ba.app.store_items = {
|
||||
'characters.kronk': {
|
||||
'character': 'Kronk'
|
||||
},
|
||||
'characters.zoe': {
|
||||
'character': 'Zoe'
|
||||
},
|
||||
'characters.jackmorgan': {
|
||||
'character': 'Jack Morgan'
|
||||
},
|
||||
'characters.mel': {
|
||||
'character': 'Mel'
|
||||
},
|
||||
'characters.snakeshadow': {
|
||||
'character': 'Snake Shadow'
|
||||
},
|
||||
'characters.bones': {
|
||||
'character': 'Bones'
|
||||
},
|
||||
'characters.bernard': {
|
||||
'character': 'Bernard',
|
||||
'highlight': (0.6, 0.5, 0.8)
|
||||
},
|
||||
'characters.pixie': {
|
||||
'character': 'Pixel'
|
||||
},
|
||||
'characters.wizard': {
|
||||
'character': 'Grumbledorf'
|
||||
},
|
||||
'characters.frosty': {
|
||||
'character': 'Frosty'
|
||||
},
|
||||
'characters.pascal': {
|
||||
'character': 'Pascal'
|
||||
},
|
||||
'characters.cyborg': {
|
||||
'character': 'B-9000'
|
||||
},
|
||||
'characters.agent': {
|
||||
'character': 'Agent Johnson'
|
||||
},
|
||||
'characters.taobaomascot': {
|
||||
'character': 'Taobao Mascot'
|
||||
},
|
||||
'characters.santa': {
|
||||
'character': 'Santa Claus'
|
||||
},
|
||||
'characters.bunny': {
|
||||
'character': 'Easter Bunny'
|
||||
},
|
||||
'pro': {},
|
||||
'maps.lake_frigid': {
|
||||
'map_type': maps.LakeFrigid
|
||||
},
|
||||
'games.ninja_fight': {
|
||||
'gametype': ninjafight.NinjaFightGame,
|
||||
'previewTex': 'courtyardPreview'
|
||||
},
|
||||
'games.meteor_shower': {
|
||||
'gametype': meteorshower.MeteorShowerGame,
|
||||
'previewTex': 'rampagePreview'
|
||||
},
|
||||
'games.target_practice': {
|
||||
'gametype': targetpractice.TargetPracticeGame,
|
||||
'previewTex': 'doomShroomPreview'
|
||||
},
|
||||
'games.easter_egg_hunt': {
|
||||
'gametype': easteregghunt.EasterEggHuntGame,
|
||||
'previewTex': 'towerDPreview'
|
||||
},
|
||||
'icons.flag_us': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_UNITED_STATES)
|
||||
},
|
||||
'icons.flag_mexico': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_MEXICO)
|
||||
},
|
||||
'icons.flag_germany': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_GERMANY)
|
||||
},
|
||||
'icons.flag_brazil': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_BRAZIL)
|
||||
},
|
||||
'icons.flag_russia': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_RUSSIA)
|
||||
},
|
||||
'icons.flag_china': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_CHINA)
|
||||
},
|
||||
'icons.flag_uk': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_UNITED_KINGDOM)
|
||||
},
|
||||
'icons.flag_canada': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_CANADA)
|
||||
},
|
||||
'icons.flag_india': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_INDIA)
|
||||
},
|
||||
'icons.flag_japan': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_JAPAN)
|
||||
},
|
||||
'icons.flag_france': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_FRANCE)
|
||||
},
|
||||
'icons.flag_indonesia': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_INDONESIA)
|
||||
},
|
||||
'icons.flag_italy': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_ITALY)
|
||||
},
|
||||
'icons.flag_south_korea': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_SOUTH_KOREA)
|
||||
},
|
||||
'icons.flag_netherlands': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_NETHERLANDS)
|
||||
},
|
||||
'icons.flag_uae': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_UNITED_ARAB_EMIRATES)
|
||||
},
|
||||
'icons.flag_qatar': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_QATAR)
|
||||
},
|
||||
'icons.flag_egypt': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_EGYPT)
|
||||
},
|
||||
'icons.flag_kuwait': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_KUWAIT)
|
||||
},
|
||||
'icons.flag_algeria': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_ALGERIA)
|
||||
},
|
||||
'icons.flag_saudi_arabia': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_SAUDI_ARABIA)
|
||||
},
|
||||
'icons.flag_malaysia': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_MALAYSIA)
|
||||
},
|
||||
'icons.flag_czech_republic': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_CZECH_REPUBLIC)
|
||||
},
|
||||
'icons.flag_australia': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_AUSTRALIA)
|
||||
},
|
||||
'icons.flag_singapore': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_SINGAPORE)
|
||||
},
|
||||
'icons.flag_iran': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_IRAN)
|
||||
},
|
||||
'icons.flag_poland': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_POLAND)
|
||||
},
|
||||
'icons.flag_argentina': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_ARGENTINA)
|
||||
},
|
||||
'icons.flag_philippines': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_PHILIPPINES)
|
||||
},
|
||||
'icons.flag_chile': {
|
||||
'icon': _ba.charstr(SpecialChar.FLAG_CHILE)
|
||||
},
|
||||
'icons.fedora': {
|
||||
'icon': _ba.charstr(SpecialChar.FEDORA)
|
||||
},
|
||||
'icons.hal': {
|
||||
'icon': _ba.charstr(SpecialChar.HAL)
|
||||
},
|
||||
'icons.crown': {
|
||||
'icon': _ba.charstr(SpecialChar.CROWN)
|
||||
},
|
||||
'icons.yinyang': {
|
||||
'icon': _ba.charstr(SpecialChar.YIN_YANG)
|
||||
},
|
||||
'icons.eyeball': {
|
||||
'icon': _ba.charstr(SpecialChar.EYE_BALL)
|
||||
},
|
||||
'icons.skull': {
|
||||
'icon': _ba.charstr(SpecialChar.SKULL)
|
||||
},
|
||||
'icons.heart': {
|
||||
'icon': _ba.charstr(SpecialChar.HEART)
|
||||
},
|
||||
'icons.dragon': {
|
||||
'icon': _ba.charstr(SpecialChar.DRAGON)
|
||||
},
|
||||
'icons.helmet': {
|
||||
'icon': _ba.charstr(SpecialChar.HELMET)
|
||||
},
|
||||
'icons.mushroom': {
|
||||
'icon': _ba.charstr(SpecialChar.MUSHROOM)
|
||||
},
|
||||
'icons.ninja_star': {
|
||||
'icon': _ba.charstr(SpecialChar.NINJA_STAR)
|
||||
},
|
||||
'icons.viking_helmet': {
|
||||
'icon': _ba.charstr(SpecialChar.VIKING_HELMET)
|
||||
},
|
||||
'icons.moon': {
|
||||
'icon': _ba.charstr(SpecialChar.MOON)
|
||||
},
|
||||
'icons.spider': {
|
||||
'icon': _ba.charstr(SpecialChar.SPIDER)
|
||||
},
|
||||
'icons.fireball': {
|
||||
'icon': _ba.charstr(SpecialChar.FIREBALL)
|
||||
},
|
||||
'icons.mikirog': {
|
||||
'icon': _ba.charstr(SpecialChar.MIKIROG)
|
||||
},
|
||||
}
|
||||
store_items = _ba.app.store_items
|
||||
assert store_items is not None
|
||||
return store_items
|
||||
|
||||
|
||||
def get_store_layout() -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Return what's available in the store at a given time.
|
||||
|
||||
Categorized by tab and by section."""
|
||||
if _ba.app.store_layout is None:
|
||||
_ba.app.store_layout = {
|
||||
'characters': [{
|
||||
'items': []
|
||||
}],
|
||||
'extras': [{
|
||||
'items': ['pro']
|
||||
}],
|
||||
'maps': [{
|
||||
'items': ['maps.lake_frigid']
|
||||
}],
|
||||
'minigames': [],
|
||||
'icons': [{
|
||||
'items': [
|
||||
'icons.mushroom',
|
||||
'icons.heart',
|
||||
'icons.eyeball',
|
||||
'icons.yinyang',
|
||||
'icons.hal',
|
||||
'icons.flag_us',
|
||||
'icons.flag_mexico',
|
||||
'icons.flag_germany',
|
||||
'icons.flag_brazil',
|
||||
'icons.flag_russia',
|
||||
'icons.flag_china',
|
||||
'icons.flag_uk',
|
||||
'icons.flag_canada',
|
||||
'icons.flag_india',
|
||||
'icons.flag_japan',
|
||||
'icons.flag_france',
|
||||
'icons.flag_indonesia',
|
||||
'icons.flag_italy',
|
||||
'icons.flag_south_korea',
|
||||
'icons.flag_netherlands',
|
||||
'icons.flag_uae',
|
||||
'icons.flag_qatar',
|
||||
'icons.flag_egypt',
|
||||
'icons.flag_kuwait',
|
||||
'icons.flag_algeria',
|
||||
'icons.flag_saudi_arabia',
|
||||
'icons.flag_malaysia',
|
||||
'icons.flag_czech_republic',
|
||||
'icons.flag_australia',
|
||||
'icons.flag_singapore',
|
||||
'icons.flag_iran',
|
||||
'icons.flag_poland',
|
||||
'icons.flag_argentina',
|
||||
'icons.flag_philippines',
|
||||
'icons.flag_chile',
|
||||
'icons.moon',
|
||||
'icons.fedora',
|
||||
'icons.spider',
|
||||
'icons.ninja_star',
|
||||
'icons.skull',
|
||||
'icons.dragon',
|
||||
'icons.viking_helmet',
|
||||
'icons.fireball',
|
||||
'icons.helmet',
|
||||
'icons.crown',
|
||||
]
|
||||
}]
|
||||
}
|
||||
store_layout = _ba.app.store_layout
|
||||
assert store_layout is not None
|
||||
store_layout['characters'] = [{
|
||||
'items': [
|
||||
'characters.kronk', 'characters.zoe', 'characters.jackmorgan',
|
||||
'characters.mel', 'characters.snakeshadow', 'characters.bones',
|
||||
'characters.bernard', 'characters.agent', 'characters.frosty',
|
||||
'characters.pascal', 'characters.pixie'
|
||||
]
|
||||
}]
|
||||
store_layout['minigames'] = [{
|
||||
'items': [
|
||||
'games.ninja_fight', 'games.meteor_shower', 'games.target_practice'
|
||||
]
|
||||
}]
|
||||
if _ba.get_account_misc_read_val('xmas', False):
|
||||
store_layout['characters'][0]['items'].append('characters.santa')
|
||||
store_layout['characters'][0]['items'].append('characters.wizard')
|
||||
store_layout['characters'][0]['items'].append('characters.cyborg')
|
||||
if _ba.get_account_misc_read_val('easter', False):
|
||||
store_layout['characters'].append({
|
||||
'title': 'store.holidaySpecialText',
|
||||
'items': ['characters.bunny']
|
||||
})
|
||||
store_layout['minigames'].append({
|
||||
'title': 'store.holidaySpecialText',
|
||||
'items': ['games.easter_egg_hunt']
|
||||
})
|
||||
return store_layout
|
||||
|
||||
|
||||
def get_clean_price(price_string: str) -> str:
|
||||
"""(internal)"""
|
||||
|
||||
# I'm not brave enough to try and do any numerical
|
||||
# manipulation on formatted price strings, but lets do a
|
||||
# few swap-outs to tidy things up a bit.
|
||||
psubs = {
|
||||
'$2.99': '$3.00',
|
||||
'$4.99': '$5.00',
|
||||
'$9.99': '$10.00',
|
||||
'$19.99': '$20.00',
|
||||
'$49.99': '$50.00'
|
||||
}
|
||||
return psubs.get(price_string, price_string)
|
||||
|
||||
|
||||
def get_available_purchase_count(tab: str = None) -> int:
|
||||
"""(internal)"""
|
||||
try:
|
||||
if _ba.get_account_state() != 'signed_in':
|
||||
return 0
|
||||
count = 0
|
||||
our_tickets = _ba.get_account_ticket_count()
|
||||
store_data = get_store_layout()
|
||||
if tab is not None:
|
||||
tabs = [(tab, store_data[tab])]
|
||||
else:
|
||||
tabs = list(store_data.items())
|
||||
for tab_name, tabval in tabs:
|
||||
if tab_name == 'icons':
|
||||
continue # too many of these; don't show..
|
||||
count = _calc_count_for_tab(tabval, our_tickets, count)
|
||||
return count
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('error calcing available purchases')
|
||||
return 0
|
||||
|
||||
|
||||
def _calc_count_for_tab(tabval: List[Dict[str, Any]], our_tickets: int,
|
||||
count: int) -> int:
|
||||
for section in tabval:
|
||||
for item in section['items']:
|
||||
ticket_cost = _ba.get_account_misc_read_val('price.' + item, None)
|
||||
if ticket_cost is not None:
|
||||
if (our_tickets >= ticket_cost
|
||||
and not _ba.get_purchased(item)):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def get_available_sale_time(tab: str) -> Optional[int]:
|
||||
"""(internal)"""
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-nested-blocks
|
||||
# pylint: disable=too-many-locals
|
||||
try:
|
||||
import datetime
|
||||
from ba._account import have_pro
|
||||
from ba._enums import TimeType, TimeFormat
|
||||
app = _ba.app
|
||||
sale_times: List[Optional[int]] = []
|
||||
|
||||
# Calc time for our pro sale (old special case).
|
||||
if tab == 'extras':
|
||||
config = app.config
|
||||
if have_pro():
|
||||
return None
|
||||
|
||||
# If we haven't calced/loaded start times yet.
|
||||
if app.pro_sale_start_time is None:
|
||||
|
||||
# If we've got a time-remaining in our config, start there.
|
||||
if 'PSTR' in config:
|
||||
app.pro_sale_start_time = int(
|
||||
_ba.time(TimeType.REAL, TimeFormat.MILLISECONDS))
|
||||
app.pro_sale_start_val = config['PSTR']
|
||||
else:
|
||||
|
||||
# We start the timer once we get the duration from
|
||||
# the server.
|
||||
start_duration = _ba.get_account_misc_read_val(
|
||||
'proSaleDurationMinutes', None)
|
||||
if start_duration is not None:
|
||||
app.pro_sale_start_time = int(
|
||||
_ba.time(TimeType.REAL, TimeFormat.MILLISECONDS))
|
||||
app.pro_sale_start_val = (60000 * start_duration)
|
||||
|
||||
# If we haven't heard from the server yet, no sale..
|
||||
else:
|
||||
return None
|
||||
|
||||
assert app.pro_sale_start_val is not None
|
||||
val: Optional[int] = max(
|
||||
0, app.pro_sale_start_val -
|
||||
(int(_ba.time(TimeType.REAL, TimeFormat.MILLISECONDS)) -
|
||||
app.pro_sale_start_time))
|
||||
|
||||
# Keep the value in the config up to date. I suppose we should
|
||||
# write the config occasionally but it should happen often enough
|
||||
# for other reasons.
|
||||
config['PSTR'] = val
|
||||
if val == 0:
|
||||
val = None
|
||||
sale_times.append(val)
|
||||
|
||||
# Now look for sales in this tab.
|
||||
sales_raw = _ba.get_account_misc_read_val('sales', {})
|
||||
store_layout = get_store_layout()
|
||||
for section in store_layout[tab]:
|
||||
for item in section['items']:
|
||||
if item in sales_raw:
|
||||
if not _ba.get_purchased(item):
|
||||
to_end = ((datetime.datetime.utcfromtimestamp(
|
||||
sales_raw[item]['e']) -
|
||||
datetime.datetime.utcnow()).total_seconds())
|
||||
if to_end > 0:
|
||||
sale_times.append(int(to_end * 1000))
|
||||
|
||||
# Return the smallest time i guess?
|
||||
return min(sale_times) if sale_times else None
|
||||
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('error calcing sale time')
|
||||
return None
|
||||
112
assets/src/data/scripts/ba/_team.py
Normal file
112
assets/src/data/scripts/ba/_team.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""Defines Team class."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Dict, List, Sequence, Any, Tuple, Union
|
||||
import ba
|
||||
|
||||
|
||||
class Team:
|
||||
"""A team of one or more ba.Players.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
Note that a player *always* has a team;
|
||||
in some cases, such as free-for-all ba.Sessions,
|
||||
each team consists of just one ba.Player.
|
||||
|
||||
Attributes:
|
||||
|
||||
name
|
||||
The team's name.
|
||||
|
||||
color
|
||||
The team's color.
|
||||
|
||||
players
|
||||
The list of ba.Players on the team.
|
||||
|
||||
gamedata
|
||||
A dict for use by the current ba.Activity
|
||||
for storing data associated with this team.
|
||||
This gets cleared for each new ba.Activity.
|
||||
|
||||
sessiondata
|
||||
A dict for use by the current ba.Session for
|
||||
storing data associated with this team.
|
||||
Unlike gamedata, this persists for the duration
|
||||
of the session.
|
||||
"""
|
||||
|
||||
# Annotate our attr types at the class level so they're introspectable.
|
||||
name: Union[ba.Lstr, str]
|
||||
color: Tuple[float, ...]
|
||||
players: List[ba.Player]
|
||||
gamedata: Dict
|
||||
sessiondata: Dict
|
||||
|
||||
def __init__(self,
|
||||
team_id: int = 0,
|
||||
name: Union[ba.Lstr, str] = "",
|
||||
color: Sequence[float] = (1.0, 1.0, 1.0)):
|
||||
"""Instantiate a ba.Team.
|
||||
|
||||
In most cases, all teams are provided to you by the ba.Session,
|
||||
ba.Session, so calling this shouldn't be necessary.
|
||||
"""
|
||||
|
||||
# TODO: Once we spin off team copies for each activity, we don't
|
||||
# need to bother with trying to lock things down, since it won't
|
||||
# matter at that point if the activity mucks with them.
|
||||
|
||||
# Temporarily allow us to set our own attrs
|
||||
# (keeps pylint happier than using __setattr__ explicitly for all).
|
||||
object.__setattr__(self, '_locked', False)
|
||||
self._team_id: int = team_id
|
||||
self.name = name
|
||||
self.color = tuple(color)
|
||||
self.players = []
|
||||
self.gamedata = {}
|
||||
self.sessiondata = {}
|
||||
|
||||
# Now prevent further attr sets.
|
||||
self._locked = True
|
||||
|
||||
def get_id(self) -> int:
|
||||
"""Returns the numeric team ID."""
|
||||
return self._team_id
|
||||
|
||||
def celebrate(self, duration: float = 10.0) -> None:
|
||||
"""Tells all players on the team to celebrate.
|
||||
|
||||
duration is given in seconds.
|
||||
"""
|
||||
for player in self.players:
|
||||
try:
|
||||
if player.actor is not None and player.actor.node:
|
||||
# Internal node-message is in milliseconds.
|
||||
player.actor.node.handlemessage('celebrate',
|
||||
int(duration * 1000))
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception('Error on celebrate')
|
||||
|
||||
def reset(self) -> None:
|
||||
"""(internal)"""
|
||||
self.reset_gamedata()
|
||||
object.__setattr__(self, 'players', [])
|
||||
|
||||
def reset_gamedata(self) -> None:
|
||||
"""(internal)"""
|
||||
object.__setattr__(self, 'gamedata', {})
|
||||
|
||||
def reset_sessiondata(self) -> None:
|
||||
"""(internal)"""
|
||||
object.__setattr__(self, 'sessiondata', {})
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
if self._locked:
|
||||
raise Exception("can't set attrs on ba.Team objects")
|
||||
object.__setattr__(self, name, value)
|
||||
308
assets/src/data/scripts/ba/_teambasesession.py
Normal file
308
assets/src/data/scripts/ba/_teambasesession.py
Normal file
@ -0,0 +1,308 @@
|
||||
"""Functionality related to teams sessions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._session import Session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Optional, Any, Dict, List, Type, Sequence
|
||||
import ba
|
||||
|
||||
DEFAULT_TEAM_COLORS = ((0.1, 0.25, 1.0), (1.0, 0.25, 0.2))
|
||||
DEFAULT_TEAM_NAMES = ("Blue", "Red")
|
||||
|
||||
|
||||
class TeamBaseSession(Session):
|
||||
"""Common base class for ba.TeamsSession and ba.FreeForAllSession.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
Free-for-all-mode is essentially just teams-mode with each ba.Player having
|
||||
their own ba.Team, so there is much overlap in functionality.
|
||||
"""
|
||||
|
||||
# These should be overridden.
|
||||
_playlist_selection_var = 'UNSET Playlist Selection'
|
||||
_playlist_randomize_var = 'UNSET Playlist Randomize'
|
||||
_playlists_var = 'UNSET Playlists'
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Set up playlists and launches a ba.Activity to accept joiners."""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _playlist as bsplaylist
|
||||
from bastd.activity import multiteamjoinscreen
|
||||
app = _ba.app
|
||||
cfg = app.config
|
||||
|
||||
if self._use_teams:
|
||||
team_names = cfg.get('Custom Team Names', DEFAULT_TEAM_NAMES)
|
||||
team_colors = cfg.get('Custom Team Colors', DEFAULT_TEAM_COLORS)
|
||||
else:
|
||||
team_names = None
|
||||
team_colors = None
|
||||
|
||||
print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.')
|
||||
depsets: Sequence[ba.DepSet] = []
|
||||
super().__init__(depsets,
|
||||
team_names=team_names,
|
||||
team_colors=team_colors,
|
||||
use_team_colors=self._use_teams,
|
||||
min_players=1,
|
||||
max_players=self.get_max_players())
|
||||
|
||||
self._series_length = app.teams_series_length
|
||||
self._ffa_series_length = app.ffa_series_length
|
||||
|
||||
show_tutorial = cfg.get('Show Tutorial', True)
|
||||
|
||||
self._tutorial_activity_instance: Optional[ba.Activity]
|
||||
if show_tutorial:
|
||||
from bastd.tutorial import TutorialActivity
|
||||
|
||||
# Get this loading.
|
||||
self._tutorial_activity_instance = _ba.new_activity(
|
||||
TutorialActivity)
|
||||
else:
|
||||
self._tutorial_activity_instance = None
|
||||
|
||||
self._playlist_name = cfg.get(self._playlist_selection_var,
|
||||
'__default__')
|
||||
self._playlist_randomize = cfg.get(self._playlist_randomize_var, False)
|
||||
|
||||
# Which game activity we're on.
|
||||
self._game_number = 0
|
||||
|
||||
playlists = cfg.get(self._playlists_var, {})
|
||||
|
||||
if (self._playlist_name != '__default__'
|
||||
and self._playlist_name in playlists):
|
||||
# Make sure to copy this, as we muck with it in place once we've
|
||||
# got it and we don't want that to affect our config.
|
||||
playlist = copy.deepcopy(playlists[self._playlist_name])
|
||||
else:
|
||||
if self._use_teams:
|
||||
playlist = bsplaylist.get_default_teams_playlist()
|
||||
else:
|
||||
playlist = bsplaylist.get_default_free_for_all_playlist()
|
||||
|
||||
# Resolve types and whatnot to get our final playlist.
|
||||
playlist_resolved = bsplaylist.filter_playlist(playlist,
|
||||
sessiontype=type(self),
|
||||
add_resolved_type=True)
|
||||
|
||||
if not playlist_resolved:
|
||||
raise Exception("playlist contains no valid games")
|
||||
|
||||
self._playlist = ShuffleList(playlist_resolved,
|
||||
shuffle=self._playlist_randomize)
|
||||
|
||||
# Get a game on deck ready to go.
|
||||
self._current_game_spec: Optional[Dict[str, Any]] = None
|
||||
self._next_game_spec: Dict[str, Any] = self._playlist.pull_next()
|
||||
self._next_game: Type[ba.GameActivity] = (
|
||||
self._next_game_spec['resolved_type'])
|
||||
|
||||
# Go ahead and instantiate the next game we'll
|
||||
# use so it has lots of time to load.
|
||||
self._instantiate_next_game()
|
||||
|
||||
# Start in our custom join screen.
|
||||
self.set_activity(
|
||||
_ba.new_activity(multiteamjoinscreen.TeamJoiningActivity))
|
||||
|
||||
def get_ffa_series_length(self) -> int:
|
||||
"""Return free-for-all series length."""
|
||||
return self._ffa_series_length
|
||||
|
||||
def get_series_length(self) -> int:
|
||||
"""Return teams series length."""
|
||||
return self._series_length
|
||||
|
||||
def get_next_game_description(self) -> ba.Lstr:
|
||||
"""Returns a description of the next game on deck."""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._gameactivity import GameActivity
|
||||
gametype: Type[GameActivity] = self._next_game_spec['resolved_type']
|
||||
assert issubclass(gametype, GameActivity)
|
||||
return gametype.get_config_display_string(self._next_game_spec)
|
||||
|
||||
def get_game_number(self) -> int:
|
||||
"""Returns which game in the series is currently being played."""
|
||||
return self._game_number
|
||||
|
||||
def on_team_join(self, team: ba.Team) -> None:
|
||||
team.sessiondata['previous_score'] = team.sessiondata['score'] = 0
|
||||
|
||||
def get_max_players(self) -> int:
|
||||
"""Return max number of ba.Players allowed to join the game at once."""
|
||||
if self._use_teams:
|
||||
return _ba.app.config.get('Team Game Max Players', 8)
|
||||
return _ba.app.config.get('Free-for-All Max Players', 8)
|
||||
|
||||
def _instantiate_next_game(self) -> None:
|
||||
self._next_game_instance = _ba.new_activity(
|
||||
self._next_game_spec['resolved_type'],
|
||||
self._next_game_spec['settings'])
|
||||
|
||||
def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _error
|
||||
from bastd.tutorial import TutorialActivity
|
||||
from bastd.activity.multiteamendscreen import (
|
||||
TeamSeriesVictoryScoreScreenActivity)
|
||||
from ba import _activitytypes
|
||||
|
||||
# If we have a tutorial to show,
|
||||
# that's the first thing we do no matter what.
|
||||
if self._tutorial_activity_instance is not None:
|
||||
self.set_activity(self._tutorial_activity_instance)
|
||||
self._tutorial_activity_instance = None
|
||||
|
||||
# If we're leaving the tutorial activity,
|
||||
# pop a transition activity to transition
|
||||
# us into a round gracefully (otherwise we'd
|
||||
# snap from one terrain to another instantly).
|
||||
elif isinstance(activity, TutorialActivity):
|
||||
self.set_activity(
|
||||
_ba.new_activity(_activitytypes.TransitionActivity))
|
||||
|
||||
# If we're in a between-round activity or a restart-activity,
|
||||
# hop into a round.
|
||||
elif isinstance(
|
||||
activity,
|
||||
(_activitytypes.JoiningActivity, _activitytypes.TransitionActivity,
|
||||
_activitytypes.ScoreScreenActivity)):
|
||||
|
||||
# If we're coming from a series-end activity, reset scores.
|
||||
if isinstance(activity, TeamSeriesVictoryScoreScreenActivity):
|
||||
self.stats.reset()
|
||||
self._game_number = 0
|
||||
for team in self.teams:
|
||||
team.sessiondata['score'] = 0
|
||||
# Otherwise just set accum (per-game) scores.
|
||||
else:
|
||||
self.stats.reset_accum()
|
||||
|
||||
next_game = self._next_game_instance
|
||||
|
||||
self._current_game_spec = self._next_game_spec
|
||||
self._next_game_spec = self._playlist.pull_next()
|
||||
self._game_number += 1
|
||||
|
||||
# Instantiate the next now so they have plenty of time to load.
|
||||
self._instantiate_next_game()
|
||||
|
||||
# (re)register all players and wire stats to our next activity
|
||||
for player in self.players:
|
||||
# ..but only ones who have been placed on a team
|
||||
# (ie: no longer sitting in the lobby).
|
||||
try:
|
||||
has_team = (player.team is not None)
|
||||
except _error.TeamNotFoundError:
|
||||
has_team = False
|
||||
if has_team:
|
||||
self.stats.register_player(player)
|
||||
self.stats.set_activity(next_game)
|
||||
|
||||
# Now flip the current activity.
|
||||
self.set_activity(next_game)
|
||||
|
||||
# If we're leaving a round, go to the score screen.
|
||||
else:
|
||||
self._switch_to_score_screen(results)
|
||||
|
||||
def _switch_to_score_screen(self, results: Any) -> None:
|
||||
"""Switch to a score screen after leaving a round."""
|
||||
from ba import _error
|
||||
del results # Unused arg.
|
||||
_error.print_error('this should be overridden')
|
||||
|
||||
def announce_game_results(self,
|
||||
activity: ba.GameActivity,
|
||||
results: ba.TeamGameResults,
|
||||
delay: float,
|
||||
announce_winning_team: bool = True) -> None:
|
||||
"""Show basic game result at the end of a game.
|
||||
|
||||
(before transitioning to a score screen).
|
||||
This will include a zoom-text of 'BLUE WINS'
|
||||
or whatnot, along with a possible audio
|
||||
announcement of the same.
|
||||
"""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _math
|
||||
from ba import _general
|
||||
from ba._gameutils import cameraflash
|
||||
from ba import _lang
|
||||
from ba._freeforallsession import FreeForAllSession
|
||||
_ba.timer(delay,
|
||||
_general.Call(_ba.playsound, _ba.getsound("boxingBell")))
|
||||
if announce_winning_team:
|
||||
winning_team = results.get_winning_team()
|
||||
if winning_team is not None:
|
||||
# Have all players celebrate.
|
||||
for player in winning_team.players:
|
||||
if player.actor is not None and player.actor.node:
|
||||
# Note: celebrate message takes milliseconds
|
||||
# for historical reasons.
|
||||
player.actor.node.handlemessage('celebrate', 10000)
|
||||
cameraflash()
|
||||
|
||||
# Some languages say "FOO WINS" different for teams vs players.
|
||||
if isinstance(self, FreeForAllSession):
|
||||
wins_resource = 'winsPlayerText'
|
||||
else:
|
||||
wins_resource = 'winsTeamText'
|
||||
wins_text = _lang.Lstr(resource=wins_resource,
|
||||
subs=[('${NAME}', winning_team.name)])
|
||||
activity.show_zoom_message(wins_text,
|
||||
scale=0.85,
|
||||
color=_math.normalized_color(
|
||||
winning_team.color))
|
||||
|
||||
|
||||
class ShuffleList:
|
||||
"""Smart shuffler for game playlists.
|
||||
|
||||
(avoids repeats in maps or game types)
|
||||
"""
|
||||
|
||||
def __init__(self, items: List[Dict[str, Any]], shuffle: bool = True):
|
||||
self.source_list = items
|
||||
self.shuffle = shuffle
|
||||
self.shuffle_list: List[Dict[str, Any]] = []
|
||||
self.last_gotten: Optional[Dict[str, Any]] = None
|
||||
|
||||
def pull_next(self) -> Dict[str, Any]:
|
||||
"""Pull and return the next item on the shuffle-list."""
|
||||
|
||||
# Refill our list if its empty.
|
||||
if not self.shuffle_list:
|
||||
self.shuffle_list = list(self.source_list)
|
||||
|
||||
# Ok now find an index we should pull.
|
||||
index = 0
|
||||
|
||||
if self.shuffle:
|
||||
for _i in range(4):
|
||||
index = random.randrange(0, len(self.shuffle_list))
|
||||
test_obj = self.shuffle_list[index]
|
||||
|
||||
# If the new one is the same map or game-type as the previous,
|
||||
# lets try to keep looking.
|
||||
if len(self.shuffle_list) > 1 and self.last_gotten is not None:
|
||||
if (test_obj['settings']['map'] ==
|
||||
self.last_gotten['settings']['map']):
|
||||
continue
|
||||
if test_obj['type'] == self.last_gotten['type']:
|
||||
continue
|
||||
# Sufficiently different; lets go with it.
|
||||
break
|
||||
|
||||
obj = self.shuffle_list.pop(index)
|
||||
self.last_gotten = obj
|
||||
return obj
|
||||
149
assets/src/data/scripts/ba/_teamgame.py
Normal file
149
assets/src/data/scripts/ba/_teamgame.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""Functionality related to team games."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._freeforallsession import FreeForAllSession
|
||||
from ba._gameactivity import GameActivity
|
||||
from ba._gameresults import TeamGameResults
|
||||
from ba._teamssession import TeamsSession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict, Type, Sequence
|
||||
from bastd.actor.playerspaz import PlayerSpaz
|
||||
import ba
|
||||
|
||||
|
||||
class TeamGameActivity(GameActivity):
|
||||
"""Base class for teams and free-for-all mode games.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
(Free-for-all is essentially just a special case where every
|
||||
ba.Player has their own ba.Team)
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool:
|
||||
"""
|
||||
Class method override;
|
||||
returns True for ba.TeamsSessions and ba.FreeForAllSessions;
|
||||
False otherwise.
|
||||
"""
|
||||
return (issubclass(sessiontype, TeamsSession)
|
||||
or issubclass(sessiontype, FreeForAllSession))
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings)
|
||||
|
||||
# By default we don't show kill-points in free-for-all.
|
||||
# (there's usually some activity-specific score and we don't
|
||||
# wanna confuse things)
|
||||
if isinstance(_ba.getsession(), FreeForAllSession):
|
||||
self._show_kill_points = False
|
||||
|
||||
def on_transition_in(self, music: str = None) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._coopsession import CoopSession
|
||||
from bastd.actor.controlsguide import ControlsGuide
|
||||
super().on_transition_in(music)
|
||||
|
||||
# On the first game, show the controls UI momentarily.
|
||||
# (unless we're being run in co-op mode, in which case we leave
|
||||
# it up to them)
|
||||
if not isinstance(self.session, CoopSession):
|
||||
# FIXME: Need an elegant way to store on session.
|
||||
if not self.session.have_shown_controls_help_overlay:
|
||||
delay = 4.0
|
||||
lifespan = 10.0
|
||||
if self.slow_motion:
|
||||
lifespan *= 0.3
|
||||
ControlsGuide(delay=delay,
|
||||
lifespan=lifespan,
|
||||
scale=0.8,
|
||||
position=(380, 200),
|
||||
bright=True).autoretain()
|
||||
self.session.have_shown_controls_help_overlay = True
|
||||
|
||||
def on_begin(self) -> None:
|
||||
super().on_begin()
|
||||
try:
|
||||
# Award a few achievements.
|
||||
if isinstance(self.session, FreeForAllSession):
|
||||
if len(self.players) >= 2:
|
||||
from ba import _achievement
|
||||
_achievement.award_local_achievement('Free Loader')
|
||||
elif isinstance(self.session, TeamsSession):
|
||||
if len(self.players) >= 4:
|
||||
from ba import _achievement
|
||||
_achievement.award_local_achievement('Team Player')
|
||||
except Exception:
|
||||
from ba import _error
|
||||
_error.print_exception()
|
||||
|
||||
def spawn_player_spaz(self,
|
||||
player: ba.Player,
|
||||
position: Sequence[float] = None,
|
||||
angle: float = None) -> PlayerSpaz:
|
||||
"""
|
||||
Method override; spawns and wires up a standard ba.PlayerSpaz for
|
||||
a ba.Player.
|
||||
|
||||
If position or angle is not supplied, a default will be chosen based
|
||||
on the ba.Player and their ba.Team.
|
||||
"""
|
||||
if position is None:
|
||||
# In teams-mode get our team-start-location.
|
||||
if isinstance(self.session, TeamsSession):
|
||||
position = (self.map.get_start_position(player.team.get_id()))
|
||||
else:
|
||||
# Otherwise do free-for-all spawn locations.
|
||||
position = self.map.get_ffa_start_position(self.players)
|
||||
|
||||
return super().spawn_player_spaz(player, position, angle)
|
||||
|
||||
def end( # type: ignore
|
||||
self,
|
||||
results: Any = None,
|
||||
announce_winning_team: bool = True,
|
||||
announce_delay: float = 0.1,
|
||||
force: bool = False) -> None:
|
||||
"""
|
||||
End the game and announce the single winning team
|
||||
unless 'announce_winning_team' is False.
|
||||
(for results without a single most-important winner).
|
||||
"""
|
||||
# pylint: disable=arguments-differ
|
||||
from ba._coopsession import CoopSession
|
||||
from ba._teambasesession import TeamBaseSession
|
||||
from ba._general import Call
|
||||
|
||||
# Announce win (but only for the first finish() call)
|
||||
# (also don't announce in co-op sessions; we leave that up to them).
|
||||
session = self.session
|
||||
if not isinstance(session, CoopSession):
|
||||
do_announce = not self.has_ended()
|
||||
super().end(results, delay=2.0 + announce_delay, force=force)
|
||||
# Need to do this *after* end end call so that results is valid.
|
||||
assert isinstance(results, TeamGameResults)
|
||||
if do_announce and isinstance(session, TeamBaseSession):
|
||||
session.announce_game_results(
|
||||
self,
|
||||
results,
|
||||
delay=announce_delay,
|
||||
announce_winning_team=announce_winning_team)
|
||||
|
||||
# For co-op we just pass this up the chain with a delay added
|
||||
# (in most cases). Team games expect a delay for the announce
|
||||
# portion in teams/ffa mode so this keeps it consistent.
|
||||
else:
|
||||
# don't want delay on restarts..
|
||||
if (isinstance(results, dict) and 'outcome' in results
|
||||
and results['outcome'] == 'restart'):
|
||||
delay = 0.0
|
||||
else:
|
||||
delay = 2.0
|
||||
_ba.timer(0.1, Call(_ba.playsound, _ba.getsound("boxingBell")))
|
||||
super().end(results, delay=delay, force=force)
|
||||
54
assets/src/data/scripts/ba/_teamssession.py
Normal file
54
assets/src/data/scripts/ba/_teamssession.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""Functionality related to teams sessions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba import _teambasesession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ba
|
||||
|
||||
|
||||
class TeamsSession(_teambasesession.TeamBaseSession):
|
||||
"""ba.Session type for teams mode games.
|
||||
|
||||
Category: Gameplay Classes
|
||||
"""
|
||||
_use_teams = True
|
||||
_playlist_selection_var = 'Team Tournament Playlist Selection'
|
||||
_playlist_randomize_var = 'Team Tournament Playlist Randomize'
|
||||
_playlists_var = 'Team Tournament Playlists'
|
||||
|
||||
def __init__(self) -> None:
|
||||
_ba.increment_analytics_count('Teams session start')
|
||||
super().__init__()
|
||||
|
||||
def _switch_to_score_screen(self, results: ba.TeamGameResults) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.activity import drawscreen
|
||||
from bastd.activity import dualteamscorescreen
|
||||
from bastd.activity import multiteamendscreen
|
||||
winners = results.get_winners()
|
||||
|
||||
# If everyone has the same score, call it a draw.
|
||||
if len(winners) < 2:
|
||||
self.set_activity(
|
||||
_ba.new_activity(drawscreen.DrawScoreScreenActivity))
|
||||
else:
|
||||
winner = winners[0].teams[0]
|
||||
winner.sessiondata['score'] += 1
|
||||
|
||||
# If a team has won, show final victory screen.
|
||||
if winner.sessiondata['score'] >= (self._series_length -
|
||||
1) / 2 + 1:
|
||||
self.set_activity(
|
||||
_ba.new_activity(
|
||||
multiteamendscreen.
|
||||
TeamSeriesVictoryScoreScreenActivity,
|
||||
{'winner': winner}))
|
||||
else:
|
||||
self.set_activity(
|
||||
_ba.new_activity(
|
||||
dualteamscorescreen.TeamVictoryScoreScreenActivity,
|
||||
{'winner': winner}))
|
||||
96
assets/src/data/scripts/ba/_tips.py
Normal file
96
assets/src/data/scripts/ba/_tips.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""Functionality related to game tips.
|
||||
|
||||
These can be shown at opportune times such as between rounds."""
|
||||
|
||||
import random
|
||||
from typing import List
|
||||
|
||||
import _ba
|
||||
|
||||
|
||||
def get_next_tip() -> str:
|
||||
"""Returns the next tip to be displayed."""
|
||||
app = _ba.app
|
||||
if not app.tips:
|
||||
for tip in get_all_tips():
|
||||
app.tips.insert(random.randint(0, len(app.tips)), tip)
|
||||
tip = app.tips.pop()
|
||||
return tip
|
||||
|
||||
|
||||
def get_all_tips() -> List[str]:
|
||||
"""Return the complete list of tips."""
|
||||
tips = [
|
||||
('If you are short on controllers, install the \'${REMOTE_APP_NAME}\' '
|
||||
'app\non your mobile devices to use them as controllers.'),
|
||||
('Create player profiles for yourself and your friends with\nyour '
|
||||
'preferred names and appearances instead of using random ones.'),
|
||||
('You can \'aim\' your punches by spinning left or right.\nThis is '
|
||||
'useful for knocking bad guys off edges or scoring in hockey.'),
|
||||
('If you pick up a curse, your only hope for survival is to\nfind a '
|
||||
'health powerup in the next few seconds.'),
|
||||
('A perfectly timed running-jumping-spin-punch can kill in a single '
|
||||
'hit\nand earn you lifelong respect from your friends.'),
|
||||
'Always remember to floss.',
|
||||
'Don\'t run all the time. Really. You will fall off cliffs.',
|
||||
('In Capture-the-Flag, your own flag must be at your base to score, '
|
||||
'If the other\nteam is about to score, stealing their flag can be '
|
||||
'a good way to stop them.'),
|
||||
('If you get a sticky-bomb stuck to you, jump around and spin in '
|
||||
'circles. You might\nshake the bomb off, or if nothing else your '
|
||||
'last moments will be entertaining.'),
|
||||
('You take damage when you whack your head on things,\nso try to not '
|
||||
'whack your head on things.'),
|
||||
'If you kill an enemy in one hit you get double points for it.',
|
||||
('Despite their looks, all characters\' abilities are identical,\nso '
|
||||
'just pick whichever one you most closely resemble.'),
|
||||
'You can throw bombs higher if you jump just before throwing.',
|
||||
('Throw strength is based on the direction you are holding.\nTo toss '
|
||||
'something gently in front of you, don\'t hold any direction.'),
|
||||
('If someone picks you up, punch them and they\'ll let go.\nThis '
|
||||
'works in real life too.'),
|
||||
('Don\'t get too cocky with that energy shield; you can still get '
|
||||
'yourself thrown off a cliff.'),
|
||||
('Many things can be picked up and thrown, including other players. '
|
||||
'Tossing\nyour enemies off cliffs can be an effective and '
|
||||
'emotionally fulfilling strategy.'),
|
||||
('Ice bombs are not very powerful, but they freeze\nwhoever they '
|
||||
'hit, leaving them vulnerable to shattering.'),
|
||||
'Don\'t spin for too long; you\'ll become dizzy and fall.',
|
||||
('Run back and forth before throwing a bomb\nto \'whiplash\' it '
|
||||
'and throw it farther.'),
|
||||
('Punches do more damage the faster your fists are moving,\nso '
|
||||
'try running, jumping, and spinning like crazy.'),
|
||||
'In hockey, you\'ll maintain more speed if you turn gradually.',
|
||||
('The head is the most vulnerable area, so a sticky-bomb\nto the '
|
||||
'noggin usually means game-over.'),
|
||||
('Hold down any button to run. You\'ll get places faster\nbut '
|
||||
'won\'t turn very well, so watch out for cliffs.'),
|
||||
('You can judge when a bomb is going to explode based on the\n'
|
||||
'color of sparks from its fuse: yellow..orange..red..BOOM.'),
|
||||
]
|
||||
tips += [
|
||||
'If your framerate is choppy, try turning down resolution\nor '
|
||||
'visuals in the game\'s graphics settings.'
|
||||
]
|
||||
app = _ba.app
|
||||
if app.platform in ('android', 'ios') and not app.on_tv:
|
||||
tips += [
|
||||
('If your device gets too warm or you\'d like to conserve '
|
||||
'battery power,\nturn down "Visuals" or "Resolution" '
|
||||
'in Settings->Graphics'),
|
||||
]
|
||||
if app.platform in ['mac', 'android']:
|
||||
tips += [
|
||||
'Tired of the soundtrack? Replace it with your own!'
|
||||
'\nSee Settings->Audio->Soundtrack'
|
||||
]
|
||||
|
||||
# Hot-plugging is currently only on some platforms.
|
||||
# FIXME: Should add a platform entry for this so don't forget to update it.
|
||||
if app.platform in ['mac', 'android', 'windows']:
|
||||
tips += [
|
||||
'Players can join and leave in the middle of most games,\n'
|
||||
'and you can also plug and unplug controllers on the fly.',
|
||||
]
|
||||
return tips
|
||||
58
assets/src/data/scripts/ba/_tournament.py
Normal file
58
assets/src/data/scripts/ba/_tournament.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""Functionality related to tournament play."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Dict, List, Any
|
||||
|
||||
|
||||
def get_tournament_prize_strings(entry: Dict[str, Any]) -> List:
|
||||
"""Given a tournament entry, return strings for its prize levels."""
|
||||
# pylint: disable=too-many-locals
|
||||
from ba._enums import SpecialChar
|
||||
from ba._gameutils import get_trophy_string
|
||||
range1 = entry.get('prizeRange1')
|
||||
range2 = entry.get('prizeRange2')
|
||||
range3 = entry.get('prizeRange3')
|
||||
prize1 = entry.get('prize1')
|
||||
prize2 = entry.get('prize2')
|
||||
prize3 = entry.get('prize3')
|
||||
trophy_type_1 = entry.get('prizeTrophy1')
|
||||
trophy_type_2 = entry.get('prizeTrophy2')
|
||||
trophy_type_3 = entry.get('prizeTrophy3')
|
||||
out_vals = []
|
||||
for rng, prize, trophy_type in ((range1, prize1, trophy_type_1),
|
||||
(range2, prize2, trophy_type_2),
|
||||
(range3, prize3, trophy_type_3)):
|
||||
prval = ('' if rng is None else ('#' + str(rng[0])) if
|
||||
(rng[0] == rng[1]) else
|
||||
('#' + str(rng[0]) + '-' + str(rng[1])))
|
||||
pvval = ''
|
||||
if trophy_type is not None:
|
||||
pvval += get_trophy_string(trophy_type)
|
||||
# trophy_chars = {
|
||||
# '1': SpecialChar.TROPHY1,
|
||||
# '2': SpecialChar.TROPHY2,
|
||||
# '3': SpecialChar.TROPHY3,
|
||||
# '0a': SpecialChar.TROPHY0A,
|
||||
# '0b': SpecialChar.TROPHY0B,
|
||||
# '4': SpecialChar.TROPHY4
|
||||
# }
|
||||
# if trophy_type in trophy_chars:
|
||||
# pvval += _bs.specialchar(trophy_chars[trophy_type])
|
||||
# else:
|
||||
# from ba import err
|
||||
# err.print_error(
|
||||
# f"unrecognized trophy type: {trophy_type}", once=True)
|
||||
# if we've got trophies but not for this entry, throw some space
|
||||
# in to compensate so the ticket counts line up
|
||||
if prize is not None:
|
||||
pvval = _ba.charstr(
|
||||
SpecialChar.TICKET_BACKING) + str(prize) + pvval
|
||||
out_vals.append(prval)
|
||||
out_vals.append(pvval)
|
||||
return out_vals
|
||||
11
assets/src/data/scripts/ba/deprecated.py
Normal file
11
assets/src/data/scripts/ba/deprecated.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""Deprecated functionality.
|
||||
|
||||
Classes or functions can be relocated here when they are deprecated.
|
||||
Any code using them should migrate to alternative methods, as
|
||||
deprecated items will eventually be fully removed.
|
||||
"""
|
||||
|
||||
# pylint: disable=unused-import
|
||||
|
||||
# The Lstr class should be used for all string resources.
|
||||
from ba._lang import get_resource, translate
|
||||
53
assets/src/data/scripts/ba/internal.py
Normal file
53
assets/src/data/scripts/ba/internal.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Exposed functionality not intended for full public use.
|
||||
|
||||
Classes and functions contained here, while technically 'public', may change
|
||||
or disappear without warning, so should be avoided (or used sparingly and
|
||||
defensively) in mods.
|
||||
"""
|
||||
|
||||
# pylint: disable=unused-import
|
||||
|
||||
from ba._maps import (get_unowned_maps, get_map_class, register_map,
|
||||
preload_map_preview_media, get_map_display_string,
|
||||
get_filtered_map_name)
|
||||
from ba._appconfig import commit_app_config
|
||||
from ba._input import (get_device_value, get_input_map_hash,
|
||||
get_input_device_config)
|
||||
from ba._general import getclass, json_prep, get_type_name
|
||||
from ba._account import (on_account_state_changed,
|
||||
handle_account_gained_tickets, have_pro_options,
|
||||
have_pro, cache_tournament_info,
|
||||
ensure_have_account_player_profile,
|
||||
get_purchased_icons, get_cached_league_rank_data,
|
||||
get_league_rank_points, cache_league_rank_data)
|
||||
from ba._activitytypes import JoiningActivity, ScoreScreenActivity
|
||||
from ba._achievement import (get_achievement, set_completed_achievements,
|
||||
display_achievement_banner,
|
||||
get_achievements_for_coop_level)
|
||||
from ba._apputils import (is_browser_likely_available, get_remote_app_name,
|
||||
should_submit_debug_info, show_ad)
|
||||
from ba._benchmark import (run_gpu_benchmark, run_cpu_benchmark,
|
||||
run_media_reload_benchmark, run_stress_test)
|
||||
from ba._campaign import get_campaign
|
||||
from ba._messages import PlayerProfilesChangedMessage
|
||||
from ba._meta import get_game_types
|
||||
from ba._modutils import show_user_scripts
|
||||
from ba._teambasesession import DEFAULT_TEAM_COLORS, DEFAULT_TEAM_NAMES
|
||||
from ba._music import (have_music_player, music_volume_changed, do_play_music,
|
||||
get_soundtrack_entry_name, get_soundtrack_entry_type,
|
||||
get_music_player, set_music_play_mode,
|
||||
supports_soundtrack_entry_type,
|
||||
get_valid_music_file_extensions, MacITunesMusicPlayer)
|
||||
from ba._netutils import serverget, serverput, get_ip_address_type
|
||||
from ba._powerup import get_default_powerup_distribution
|
||||
from ba._profile import (get_player_profile_colors, get_player_profile_icon,
|
||||
get_player_colors)
|
||||
from ba._tips import get_next_tip
|
||||
from ba._playlist import (get_default_free_for_all_playlist,
|
||||
get_default_teams_playlist, filter_playlist)
|
||||
from ba._store import (get_available_sale_time, get_available_purchase_count,
|
||||
get_store_item_name_translated,
|
||||
get_store_item_display_size, get_store_layout,
|
||||
get_store_item, get_clean_price)
|
||||
from ba._tournament import get_tournament_prize_strings
|
||||
from ba._gameutils import get_trophy_string
|
||||
191
assets/src/data/scripts/ba/ui/__init__.py
Normal file
191
assets/src/data/scripts/ba/ui/__init__.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Provide top level UI related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, cast, Type
|
||||
|
||||
import _ba
|
||||
from ba._enums import TimeType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Optional, List, Any
|
||||
import ba
|
||||
|
||||
|
||||
class OldWindow:
|
||||
"""Temp for transitioning windows over to UILocationWindows."""
|
||||
|
||||
def __init__(self, root_widget: ba.Widget):
|
||||
self._root_widget = root_widget
|
||||
|
||||
def get_root_widget(self) -> ba.Widget:
|
||||
"""Return the root widget."""
|
||||
return self._root_widget
|
||||
|
||||
|
||||
class UILocation:
|
||||
"""Defines a specific 'place' in the UI the user can navigate to.
|
||||
|
||||
Category: User Interface Classes
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def save_state(self) -> None:
|
||||
"""Serialize this instance's state to a dict."""
|
||||
|
||||
def restore_state(self) -> None:
|
||||
"""Restore this instance's state from a dict."""
|
||||
|
||||
def push_location(self, location: str) -> None:
|
||||
"""Push a new location to the stack and transition to it."""
|
||||
|
||||
|
||||
class UILocationWindow(UILocation):
|
||||
"""A UILocation consisting of a single root window widget.
|
||||
|
||||
Category: User Interface Classes
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._root_widget: Optional[ba.Widget] = None
|
||||
|
||||
def get_root_widget(self) -> ba.Widget:
|
||||
"""Return the root widget for this window."""
|
||||
assert self._root_widget is not None
|
||||
return self._root_widget
|
||||
|
||||
|
||||
class UIEntry:
|
||||
"""State for a UILocation on the stack."""
|
||||
|
||||
def __init__(self, name: str, controller: UIController):
|
||||
self._name = name
|
||||
self._state = None
|
||||
self._args = None
|
||||
self._instance: Optional[UILocation] = None
|
||||
self._controller = weakref.ref(controller)
|
||||
|
||||
def create(self) -> None:
|
||||
"""Create an instance of our UI."""
|
||||
cls = self._get_class()
|
||||
self._instance = cls()
|
||||
|
||||
def destroy(self) -> None:
|
||||
"""Transition out our UI if it exists."""
|
||||
if self._instance is None:
|
||||
return
|
||||
print('WOULD TRANSITION OUT', self._name)
|
||||
|
||||
def _get_class(self) -> Type[UILocation]:
|
||||
"""Returns the UI class our name points to."""
|
||||
# pylint: disable=cyclic-import
|
||||
|
||||
# TEMP HARD CODED - WILL REPLACE THIS WITH BA_META LOOKUPS.
|
||||
if self._name == 'mainmenu':
|
||||
from bastd.ui import mainmenu
|
||||
return cast(Type[UILocation], mainmenu.MainMenuWindow)
|
||||
raise Exception('unknown ui class ' + str(self._name))
|
||||
|
||||
|
||||
class UIController:
|
||||
"""Wrangles UILocations."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
# FIXME: document why we have separate stacks for game and menu...
|
||||
self._main_stack_game: List[UIEntry] = []
|
||||
self._main_stack_menu: List[UIEntry] = []
|
||||
|
||||
# This points at either the game or menu stack.
|
||||
self._main_stack: Optional[List[UIEntry]] = None
|
||||
|
||||
# There's only one of these since we don't need to preserve its state
|
||||
# between sessions.
|
||||
self._dialog_stack: List[UIEntry] = []
|
||||
|
||||
def show_main_menu(self, in_game: bool = True) -> None:
|
||||
"""Show the main menu, clearing other UIs from location stacks."""
|
||||
self._main_stack = []
|
||||
self._dialog_stack = []
|
||||
self._main_stack = (self._main_stack_game
|
||||
if in_game else self._main_stack_menu)
|
||||
self._main_stack.append(UIEntry('mainmenu', self))
|
||||
self._update_ui()
|
||||
|
||||
def _update_ui(self) -> None:
|
||||
"""Instantiates the topmost ui in our stacks."""
|
||||
|
||||
# First tell any existing UIs to get outta here.
|
||||
for stack in (self._dialog_stack, self._main_stack):
|
||||
assert stack is not None
|
||||
for entry in stack:
|
||||
entry.destroy()
|
||||
|
||||
# Now create the topmost one if there is one.
|
||||
entrynew = (self._dialog_stack[-1] if self._dialog_stack else
|
||||
self._main_stack[-1] if self._main_stack else None)
|
||||
if entrynew is not None:
|
||||
entrynew.create()
|
||||
|
||||
|
||||
def uicleanupcheck(obj: Any, widget: ba.Widget) -> None:
|
||||
"""Add a check to ensure a widget-owning object gets cleaned up properly.
|
||||
|
||||
Category: User Interface Functions
|
||||
|
||||
This adds a check which will print an error message if the provided
|
||||
object still exists ~5 seconds after the provided ba.Widget dies.
|
||||
|
||||
This is a good sanity check for any sort of object that wraps or
|
||||
controls a ba.Widget. For instance, a 'Window' class instance has
|
||||
no reason to still exist once its root container ba.Widget has fully
|
||||
transitioned out and been destroyed. Circular references or careless
|
||||
strong referencing can lead to such objects never getting destroyed,
|
||||
however, and this helps detect such cases to avoid memory leaks.
|
||||
"""
|
||||
if not isinstance(widget, _ba.Widget):
|
||||
raise Exception('widget arg is not a ba.Widget')
|
||||
|
||||
def foobar() -> None:
|
||||
"""Just testing."""
|
||||
print('FOO HERE (UICLEANUPCHECK)')
|
||||
|
||||
widget.add_delete_callback(foobar)
|
||||
_ba.app.uicleanupchecks.append({
|
||||
'obj': weakref.ref(obj),
|
||||
'widget': widget,
|
||||
'widgetdeathtime': None
|
||||
})
|
||||
|
||||
|
||||
def upkeep() -> None:
|
||||
"""Run UI cleanup checks, etc. should be called periodically."""
|
||||
app = _ba.app
|
||||
remainingchecks = []
|
||||
now = _ba.time(TimeType.REAL)
|
||||
for check in app.uicleanupchecks:
|
||||
obj = check['obj']()
|
||||
|
||||
# If the object has died, ignore and don't re-add.
|
||||
if obj is None:
|
||||
continue
|
||||
|
||||
# If the widget hadn't died yet, note if it has.
|
||||
if check['widgetdeathtime'] is None:
|
||||
remainingchecks.append(check)
|
||||
if not check['widget']:
|
||||
check['widgetdeathtime'] = now
|
||||
else:
|
||||
# Widget was already dead; complain if its been too long.
|
||||
if now - check['widgetdeathtime'] > 5.0:
|
||||
print(
|
||||
'WARNING:', obj,
|
||||
'is still alive 5 second after its widget died;'
|
||||
' you probably have a memory leak.')
|
||||
else:
|
||||
remainingchecks.append(check)
|
||||
app.uicleanupchecks = remainingchecks
|
||||
4
assets/src/data/scripts/bafoundation/__init__.py
Normal file
4
assets/src/data/scripts/bafoundation/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
# Synced from bsmaster.
|
||||
# EFRO_SYNC_HASH=47258835994253322418493299167560392753
|
||||
#
|
||||
"""Functionality shared between Ballistica client and server components."""
|
||||
34
assets/src/data/scripts/bafoundation/dataclassutils.py
Normal file
34
assets/src/data/scripts/bafoundation/dataclassutils.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Synced from bsmaster.
|
||||
# EFRO_SYNC_HASH=196941524992995247852512968857048418312
|
||||
#
|
||||
"""Utilities for working with dataclasses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# import dataclasses
|
||||
|
||||
# def dataclass_from_dict(cls, data):
|
||||
# print("Creating dataclass from dict", cls, data, type(cls))
|
||||
# try:
|
||||
# print("FLDTYPES", [field.type for field in dataclasses.fields(cls)])
|
||||
# fieldtypes = {
|
||||
# field.name: field.type
|
||||
# for field in dataclasses.fields(cls)
|
||||
# }
|
||||
# print("GOT FIELDTYPES", fieldtypes)
|
||||
# # print("GOT", cls.__name__, fieldtypes, data)
|
||||
# args = {
|
||||
# field: dataclass_from_dict(fieldtypes[field], data[field])
|
||||
# for field in data
|
||||
# }
|
||||
# print("CALCED ARGS", args)
|
||||
# val = cls(
|
||||
# **{
|
||||
# field: dataclass_from_dict(fieldtypes[field], data[field])
|
||||
# for field in data
|
||||
# })
|
||||
# print("CREATED", val)
|
||||
# return val
|
||||
# except Exception as exc:
|
||||
# print("GOT EXC", exc)
|
||||
# return data # Not a dataclass field
|
||||
24
assets/src/data/scripts/bafoundation/entity/__init__.py
Normal file
24
assets/src/data/scripts/bafoundation/entity/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Synced from bamaster.
|
||||
# EFRO_SYNC_HASH=196413726588996288733581295344706442629
|
||||
#
|
||||
"""Entity functionality.
|
||||
|
||||
A system for defining complex data-containing types, supporting both static
|
||||
and run-time type safety, serialization, efficient/sparse storage, per-field
|
||||
value limits, etc. These are heavy-weight in comparison to things such as
|
||||
dataclasses, but the increased features can make the overhead worth it for
|
||||
certain use cases.
|
||||
"""
|
||||
# pylint: disable=unused-import
|
||||
|
||||
from bafoundation.entity._entity import EntityMixin, Entity
|
||||
from bafoundation.entity._field import (Field, CompoundField, ListField,
|
||||
DictField, CompoundListField,
|
||||
CompoundDictField)
|
||||
from bafoundation.entity._value import (
|
||||
EnumValue, OptionalEnumValue, IntValue, OptionalIntValue, StringValue,
|
||||
OptionalStringValue, BoolValue, OptionalBoolValue, FloatValue,
|
||||
OptionalFloatValue, DateTimeValue, OptionalDateTimeValue, Float3Value,
|
||||
CompoundValue)
|
||||
|
||||
from bafoundation.entity._support import FieldInspector
|
||||
99
assets/src/data/scripts/bafoundation/entity/_base.py
Normal file
99
assets/src/data/scripts/bafoundation/entity/_base.py
Normal file
@ -0,0 +1,99 @@
|
||||
# Synced from bsmaster.
|
||||
# EFRO_SYNC_HASH=8117567323116015157093251373970987221
|
||||
#
|
||||
"""Base classes for the entity system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
|
||||
class DataHandler:
|
||||
"""Base class for anything that can wrangle entity data.
|
||||
|
||||
This contains common functionality shared by Fields and Values.
|
||||
"""
|
||||
|
||||
def get_default_data(self) -> Any:
|
||||
"""Return the default internal data value for this object.
|
||||
|
||||
This will be inserted when initing nonexistent entity data.
|
||||
"""
|
||||
raise RuntimeError(f'get_default_data() unimplemented for {self}')
|
||||
|
||||
def filter_input(self, data: Any, error: bool) -> Any:
|
||||
"""Given arbitrary input data, return valid internal data.
|
||||
|
||||
If error is True, exceptions should be thrown for any non-trivial
|
||||
mismatch (more than just int vs float/etc.). Otherwise the invalid
|
||||
data should be replaced with valid defaults and the problem noted
|
||||
via the logging module.
|
||||
The passed-in data can be modified in-place or returned as-is, or
|
||||
completely new data can be returned. Compound types are responsible
|
||||
for setting defaults and/or calling this recursively for their
|
||||
children. Data that is not used by the field (such as orphaned values
|
||||
in a dict field) can be left alone.
|
||||
|
||||
Supported types for internal data are:
|
||||
- anything that works with json (lists, dicts, bools, floats, ints,
|
||||
strings, None) - no tuples!
|
||||
- datetime.datetime objects
|
||||
"""
|
||||
del error # unused
|
||||
return data
|
||||
|
||||
def filter_output(self, data: Any) -> Any:
|
||||
"""Given valid internal data, return user-facing data.
|
||||
|
||||
Note that entity data is expected to be filtered to correctness on
|
||||
input, so if internal and extra entity data are the same type
|
||||
Value types such as Vec3 may store data internally as simple float
|
||||
tuples but return Vec3 objects to the user/etc. this is the mechanism
|
||||
by which they do so.
|
||||
"""
|
||||
return data
|
||||
|
||||
def prune_data(self, data: Any) -> bool:
|
||||
"""Prune internal data to strip out default values/etc.
|
||||
|
||||
Should return a bool indicating whether root data itself can be pruned.
|
||||
The object is responsible for pruning any sub-fields before returning.
|
||||
"""
|
||||
|
||||
|
||||
class BaseField(DataHandler):
|
||||
"""Base class for all field types."""
|
||||
|
||||
def __init__(self, d_key: str = None) -> None:
|
||||
|
||||
# Key for this field's data in parent dict/list (when applicable;
|
||||
# some fields such as the child field under a list field represent
|
||||
# more than a single field entry so this is unused)
|
||||
self.d_key = d_key
|
||||
|
||||
def __get__(self, obj: Any, type_in: Any = None) -> Any:
|
||||
if obj is None:
|
||||
# when called on the type, we return the field
|
||||
return self
|
||||
return self.get_with_data(obj.d_data)
|
||||
|
||||
def __set__(self, obj: Any, value: Any) -> None:
|
||||
assert obj is not None
|
||||
self.set_with_data(obj.d_data, value, error=True)
|
||||
|
||||
def get_with_data(self, data: Any) -> Any:
|
||||
"""Get the field value given an explicit data source."""
|
||||
assert self.d_key is not None
|
||||
return self.filter_output(data[self.d_key])
|
||||
|
||||
def set_with_data(self, data: Any, value: Any, error: bool) -> Any:
|
||||
"""Set the field value given an explicit data target.
|
||||
|
||||
If error is True, exceptions should be thrown for invalid data;
|
||||
otherwise the problem should be logged but corrected.
|
||||
"""
|
||||
assert self.d_key is not None
|
||||
data[self.d_key] = self.filter_input(value, error=error)
|
||||
198
assets/src/data/scripts/bafoundation/entity/_entity.py
Normal file
198
assets/src/data/scripts/bafoundation/entity/_entity.py
Normal file
@ -0,0 +1,198 @@
|
||||
# Synced from bamaster.
|
||||
# EFRO_SYNC_HASH=11716656185614230313846373816308841148
|
||||
#
|
||||
"""Functionality for the actual Entity types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
from bafoundation.entity._support import FieldInspector, BoundCompoundValue
|
||||
from bafoundation.entity._value import CompoundValue
|
||||
from bafoundation.jsonutils import ExtendedJSONEncoder, ExtendedJSONDecoder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Dict, Any, Type, Union
|
||||
|
||||
T = TypeVar('T', bound='EntityMixin')
|
||||
|
||||
|
||||
class EntityMixin:
|
||||
"""Mixin class to add data-storage to ComplexValue, forming an Entity.
|
||||
|
||||
Distinct Entity types should inherit from this first and a CompoundValue
|
||||
(sub)type second. This order ensures that constructor arguments for this
|
||||
class are accessible on the new type.
|
||||
"""
|
||||
|
||||
def __init__(self, d_data: Dict[str, Any] = None,
|
||||
error: bool = False) -> None:
|
||||
super().__init__()
|
||||
if not isinstance(self, CompoundValue):
|
||||
raise RuntimeError('EntityMixin class must be combined'
|
||||
' with a CompoundValue class.')
|
||||
|
||||
# Underlying data for this entity; fields simply operate on this.
|
||||
self.d_data: Dict[str, Any] = {}
|
||||
assert isinstance(self, EntityMixin)
|
||||
self.set_data(d_data if d_data is not None else {}, error=error)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Resets data to default."""
|
||||
self.set_data({}, error=True)
|
||||
|
||||
def set_data(self, data: Dict, error: bool = False) -> None:
|
||||
"""Set the data for this entity and apply all value filters to it.
|
||||
|
||||
Note that it is more efficient to pass data to an Entity's constructor
|
||||
than it is to create a default Entity and then call this on it.
|
||||
"""
|
||||
self.d_data = data
|
||||
assert isinstance(self, CompoundValue)
|
||||
self.apply_fields_to_data(self.d_data, error=error)
|
||||
|
||||
def copy_data(self,
|
||||
target: Union[CompoundValue, BoundCompoundValue]) -> None:
|
||||
"""Copy data from a target Entity or compound-value.
|
||||
|
||||
This first verifies that the target has a matching set of fields
|
||||
and then copies its data into ourself. To copy data into a nested
|
||||
compound field, the assignment operator can be used.
|
||||
"""
|
||||
import copy
|
||||
from bafoundation.entity.util import have_matching_fields
|
||||
tvalue: CompoundValue
|
||||
if isinstance(target, CompoundValue):
|
||||
tvalue = target
|
||||
elif isinstance(target, BoundCompoundValue):
|
||||
tvalue = target.d_value
|
||||
else:
|
||||
raise TypeError(
|
||||
'Target must be a CompoundValue or BoundCompoundValue')
|
||||
target_data = getattr(target, 'd_data', None)
|
||||
if target_data is None:
|
||||
raise ValueError('Target is not bound to data.')
|
||||
assert isinstance(self, CompoundValue)
|
||||
if not have_matching_fields(self, tvalue):
|
||||
raise ValueError(
|
||||
f"Fields for target {type(tvalue)} do not match ours"
|
||||
f" ({type(self)}); can't copy data.")
|
||||
self.d_data = copy.deepcopy(target_data)
|
||||
|
||||
def steal_data(self, target: EntityMixin) -> None:
|
||||
"""Steal data from another entity.
|
||||
|
||||
This is more efficient than copy_data, as data is moved instead
|
||||
of copied. However this leaves the target object in an invalid
|
||||
state, and it must no longer be used after this call.
|
||||
This can be convenient for entities to use to update themselves
|
||||
with the result of a database transaction (which generally return
|
||||
fresh entities).
|
||||
"""
|
||||
from bafoundation.entity.util import have_matching_fields
|
||||
if not isinstance(target, EntityMixin):
|
||||
raise TypeError('EntityMixin is required.')
|
||||
assert isinstance(target, CompoundValue)
|
||||
assert isinstance(self, CompoundValue)
|
||||
if not have_matching_fields(self, target):
|
||||
raise ValueError(
|
||||
f"Fields for target {type(target)} do not match ours"
|
||||
f" ({type(self)}); can't steal data.")
|
||||
assert target.d_data is not None
|
||||
self.d_data = target.d_data
|
||||
target.d_data = None
|
||||
|
||||
def get_pruned_data(self) -> Dict[str, Any]:
|
||||
"""Return a pruned version of this instance's data.
|
||||
|
||||
This varies from d_data in that values may be stripped out if
|
||||
they are equal to defaults (if the field allows such).
|
||||
"""
|
||||
import copy
|
||||
data = copy.deepcopy(self.d_data)
|
||||
assert isinstance(self, CompoundValue)
|
||||
self.prune_fields_data(data)
|
||||
return data
|
||||
|
||||
def to_json_str(self, pretty: bool = False) -> str:
|
||||
"""Convert the entity to a json string.
|
||||
|
||||
This uses bafoundation.jsontools.ExtendedJSONEncoder/Decoder
|
||||
to support data types not natively storable in json.
|
||||
"""
|
||||
if pretty:
|
||||
return json.dumps(self.d_data,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
cls=ExtendedJSONEncoder)
|
||||
return json.dumps(self.d_data,
|
||||
separators=(',', ':'),
|
||||
cls=ExtendedJSONEncoder)
|
||||
|
||||
@staticmethod
|
||||
def json_loads(s: str) -> Any:
|
||||
"""Load a json string with our special extended decoder.
|
||||
|
||||
Note that this simply returns loaded json data; no
|
||||
Entities are involved.
|
||||
"""
|
||||
return json.loads(s, cls=ExtendedJSONDecoder)
|
||||
|
||||
def load_from_json_str(self, s: str, error: bool = False) -> None:
|
||||
"""Set the entity's data in-place from a json string.
|
||||
|
||||
The 'error' argument determines whether Exceptions will be raised
|
||||
for invalid data values. Values will be reset/conformed to valid ones
|
||||
if error is False. Note that Exceptions will always be raised
|
||||
in the case of invalid formatted json.
|
||||
"""
|
||||
data = self.json_loads(s)
|
||||
self.set_data(data, error=error)
|
||||
|
||||
@classmethod
|
||||
def from_json_str(cls: Type[T], s: str, error: bool = False) -> T:
|
||||
"""Instantiate a new instance with provided json string.
|
||||
|
||||
The 'error' argument determines whether exceptions will be raised
|
||||
on invalid data values. Values will be reset/conformed to valid ones
|
||||
if error is False. Note that exceptions will always be raised
|
||||
in the case of invalid formatted json.
|
||||
"""
|
||||
obj = cls(d_data=cls.json_loads(s), error=error)
|
||||
return obj
|
||||
|
||||
# Note: though d_fields actually returns a FieldInspector,
|
||||
# in type-checking-land we currently just say it returns self.
|
||||
# This allows the type-checker to at least validate subfield access,
|
||||
# though the types will be incorrect (values instead of inspectors).
|
||||
# This means that anything taking FieldInspectors needs to take 'Any'
|
||||
# at the moment. Hopefully we can make this cleaner via a mypy
|
||||
# plugin at some point.
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@property
|
||||
def d_fields(self: T) -> T:
|
||||
"""For accessing entity field objects (as opposed to values)."""
|
||||
...
|
||||
else:
|
||||
|
||||
@property
|
||||
def d_fields(self):
|
||||
"""For accessing entity field objects (as opposed to values)."""
|
||||
return FieldInspector(self, self, [], [])
|
||||
|
||||
|
||||
class Entity(EntityMixin, CompoundValue):
|
||||
"""A data class consisting of Fields and their underlying data.
|
||||
|
||||
Fields and Values simply define a data layout; Entities are concrete
|
||||
objects using those layouts.
|
||||
|
||||
Inherit from this class and add Fields to define a simple Entity type.
|
||||
Alternately, combine an EntityMixin with any CompoundValue child class
|
||||
to accomplish the same. The latter allows sharing CompoundValue
|
||||
layouts between different concrete Entity types. For example, a
|
||||
'Weapon' CompoundValue could be embedded as part of a 'Character'
|
||||
Entity but also exist as a distinct Entity in an armory database.
|
||||
"""
|
||||
451
assets/src/data/scripts/bafoundation/entity/_field.py
Normal file
451
assets/src/data/scripts/bafoundation/entity/_field.py
Normal file
@ -0,0 +1,451 @@
|
||||
# Synced from bamaster.
|
||||
# EFRO_SYNC_HASH=1181984339043224435868827486253284940
|
||||
#
|
||||
"""Field types for the entity system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Generic, TypeVar, overload
|
||||
|
||||
from bafoundation.entity._support import (BaseField, BoundCompoundValue,
|
||||
BoundListField, BoundDictField,
|
||||
BoundCompoundListField,
|
||||
BoundCompoundDictField)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Dict, Type, List, Any
|
||||
from bafoundation.entity._value import TypedValue, CompoundValue
|
||||
from bafoundation.entity._support import FieldInspector
|
||||
|
||||
T = TypeVar('T')
|
||||
TK = TypeVar('TK')
|
||||
TC = TypeVar('TC', bound='CompoundValue')
|
||||
|
||||
|
||||
class Field(BaseField, Generic[T]):
|
||||
"""Field consisting of a single value."""
|
||||
|
||||
def __init__(self,
|
||||
d_key: str,
|
||||
value: 'TypedValue[T]',
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(d_key)
|
||||
self.d_value = value
|
||||
self._store_default = store_default
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<Field "{self.d_key}" with {self.d_value}>'
|
||||
|
||||
def get_default_data(self) -> Any:
|
||||
return self.d_value.get_default_data()
|
||||
|
||||
def filter_input(self, data: Any, error: bool) -> Any:
|
||||
return self.d_value.filter_input(data, error)
|
||||
|
||||
def filter_output(self, data: Any) -> Any:
|
||||
return self.d_value.filter_output(data)
|
||||
|
||||
def prune_data(self, data: Any) -> bool:
|
||||
return self.d_value.prune_data(data)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Use default runtime get/set but let type-checker know our types.
|
||||
# Note: we actually return a bound-field when accessed on
|
||||
# a type instead of an instance, but we don't reflect that here yet
|
||||
# (need to write a mypy plugin so sub-field access works first)
|
||||
|
||||
def __get__(self, obj: Any, cls: Any = None) -> T:
|
||||
...
|
||||
|
||||
def __set__(self, obj: Any, value: T) -> None:
|
||||
...
|
||||
|
||||
|
||||
class CompoundField(BaseField, Generic[TC]):
|
||||
"""Field consisting of a single compound value."""
|
||||
|
||||
def __init__(self, d_key: str, value: TC,
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(d_key)
|
||||
if __debug__ is True:
|
||||
from bafoundation.entity._value import CompoundValue
|
||||
assert isinstance(value, CompoundValue)
|
||||
assert not hasattr(value, 'd_data')
|
||||
self.d_value = value
|
||||
self._store_default = store_default
|
||||
|
||||
def get_default_data(self) -> dict:
|
||||
return self.d_value.get_default_data()
|
||||
|
||||
def filter_input(self, data: Any, error: bool) -> dict:
|
||||
return self.d_value.filter_input(data, error)
|
||||
|
||||
def prune_data(self, data: Any) -> bool:
|
||||
return self.d_value.prune_data(data)
|
||||
|
||||
# Note:
|
||||
# Currently, to the type-checker we just return a simple instance
|
||||
# of our CompoundValue so it can properly type-check access to its
|
||||
# attrs. However at runtime we return a FieldInspector or
|
||||
# BoundCompoundField which both use magic to provide the same attrs
|
||||
# dynamically (but which the type-checker doesn't understand).
|
||||
# Perhaps at some point we can write a mypy plugin to correct this.
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def __get__(self, obj: Any, cls: Any = None) -> TC:
|
||||
...
|
||||
|
||||
# Theoretically this type-checking may be too tight;
|
||||
# we can support assigning a parent class to a child class if
|
||||
# their fields match. Not sure if that'll ever come up though;
|
||||
# gonna leave this for now as I prefer to have *some* checking.
|
||||
# Also once we get BoundCompoundValues working with mypy we'll
|
||||
# need to accept those too.
|
||||
def __set__(self: CompoundField[TC], obj: Any, value: TC) -> None:
|
||||
...
|
||||
|
||||
else:
|
||||
|
||||
def __get__(self, obj, cls=None):
|
||||
if obj is None:
|
||||
# when called on the type, we return the field
|
||||
return self
|
||||
# (this is only ever called on entity root fields
|
||||
# so no need to worry about custom d_key case)
|
||||
assert self.d_key in obj.d_data
|
||||
return BoundCompoundValue(self.d_value, obj.d_data[self.d_key])
|
||||
|
||||
def __set__(self, obj, value):
|
||||
from bafoundation.entity._value import CompoundValue
|
||||
|
||||
# Ok here's the deal: our type checking above allows any subtype
|
||||
# of our CompoundValue in here, but we want to be more picky than
|
||||
# that. Let's check fields for equality. This way we'll allow
|
||||
# assigning something like a Carentity to a Car field
|
||||
# (where the data is the same), but won't allow assigning a Car
|
||||
# to a Vehicle field (as Car probably adds more fields).
|
||||
value1: CompoundValue
|
||||
if isinstance(value, BoundCompoundValue):
|
||||
value1 = value.d_value
|
||||
elif isinstance(value, CompoundValue):
|
||||
value1 = value
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Can't assign from object type {type(value)}")
|
||||
data = getattr(value, 'd_data', None)
|
||||
if data is None:
|
||||
raise ValueError(f"Can't assign from unbound object {value}")
|
||||
if self.d_value.get_fields() != value1.get_fields():
|
||||
raise ValueError(f"Can't assign to {self.d_value} from"
|
||||
f" incompatible type {value.d_value}; "
|
||||
f"sub-fields do not match.")
|
||||
|
||||
# If we're allowing this to go through, we can simply copy the
|
||||
# data from the passed in value. The fields match so it should
|
||||
# be in a valid state already.
|
||||
obj.d_data[self.d_key] = copy.deepcopy(data)
|
||||
|
||||
|
||||
class ListField(BaseField, Generic[T]):
|
||||
"""Field consisting of repeated values."""
|
||||
|
||||
def __init__(self,
|
||||
d_key: str,
|
||||
value: 'TypedValue[T]',
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(d_key)
|
||||
self.d_value = value
|
||||
self._store_default = store_default
|
||||
|
||||
def get_default_data(self) -> list:
|
||||
return []
|
||||
|
||||
def filter_input(self, data: Any, error: bool) -> Any:
|
||||
if not isinstance(data, list):
|
||||
if error:
|
||||
raise TypeError('list value expected')
|
||||
logging.error('Ignoring non-list data for %s: %s', self, data)
|
||||
data = []
|
||||
for i, entry in enumerate(data):
|
||||
data[i] = self.d_value.filter_input(entry, error=error)
|
||||
return data
|
||||
|
||||
def prune_data(self, data: Any) -> bool:
|
||||
# We never prune individual values since that would fundamentally
|
||||
# change the list, but we can prune completely if empty (and allowed).
|
||||
return not data and not self._store_default
|
||||
|
||||
# When accessed on a FieldInspector we return a sub-field FieldInspector.
|
||||
# When accessed on an instance we return a BoundListField.
|
||||
|
||||
@overload
|
||||
def __get__(self, obj: None, cls: Any = None) -> FieldInspector:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __get__(self, obj: Any, cls: Any = None) -> BoundListField[T]:
|
||||
...
|
||||
|
||||
def __get__(self, obj: Any, cls: Any = None) -> Any:
|
||||
if obj is None:
|
||||
# When called on the type, we return the field.
|
||||
return self
|
||||
return BoundListField(self, obj.d_data[self.d_key])
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def __set__(self, obj: Any, value: List[T]) -> None:
|
||||
...
|
||||
|
||||
|
||||
class DictField(BaseField, Generic[TK, T]):
|
||||
"""A field of values in a dict with a specified index type."""
|
||||
|
||||
def __init__(self,
|
||||
d_key: str,
|
||||
keytype: Type[TK],
|
||||
field: 'TypedValue[T]',
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(d_key)
|
||||
self.d_value = field
|
||||
self._store_default = store_default
|
||||
self._keytype = keytype
|
||||
|
||||
def get_default_data(self) -> dict:
|
||||
return {}
|
||||
|
||||
# noinspection DuplicatedCode
|
||||
def filter_input(self, data: Any, error: bool) -> Any:
|
||||
if not isinstance(data, dict):
|
||||
if error:
|
||||
raise TypeError('dict value expected')
|
||||
logging.error('Ignoring non-dict data for %s: %s', self, data)
|
||||
data = {}
|
||||
data_out = {}
|
||||
for key, val in data.items():
|
||||
if not isinstance(key, self._keytype):
|
||||
if error:
|
||||
raise TypeError('invalid key type')
|
||||
logging.error('Ignoring invalid key type for %s: %s', self,
|
||||
data)
|
||||
continue
|
||||
data_out[key] = self.d_value.filter_input(val, error=error)
|
||||
return data_out
|
||||
|
||||
def prune_data(self, data: Any) -> bool:
|
||||
# We never prune individual values since that would fundamentally
|
||||
# change the dict, but we can prune completely if empty (and allowed)
|
||||
return not data and not self._store_default
|
||||
|
||||
@overload
|
||||
def __get__(self, obj: None, cls: Any = None) -> DictField[TK, T]:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __get__(self, obj: Any, cls: Any = None) -> BoundDictField[TK, T]:
|
||||
...
|
||||
|
||||
def __get__(self, obj: Any, cls: Any = None) -> Any:
|
||||
if obj is None:
|
||||
# When called on the type, we return the field.
|
||||
return self
|
||||
return BoundDictField(self._keytype, self, obj.d_data[self.d_key])
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def __set__(self, obj: Any, value: Dict[TK, T]) -> None:
|
||||
...
|
||||
|
||||
|
||||
class CompoundListField(BaseField, Generic[TC]):
|
||||
"""A field consisting of repeated instances of a compound-value.
|
||||
|
||||
Element access returns the sub-field, allowing nested field access.
|
||||
ie: mylist[10].fieldattr = 'foo'
|
||||
"""
|
||||
|
||||
def __init__(self, d_key: str, valuetype: TC,
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(d_key)
|
||||
self.d_value = valuetype
|
||||
|
||||
# This doesnt actually exist for us, but want the type-checker
|
||||
# to think it does (see TYPE_CHECKING note below).
|
||||
self.d_data: Any
|
||||
self._store_default = store_default
|
||||
|
||||
def filter_input(self, data: Any, error: bool) -> list:
|
||||
if not isinstance(data, list):
|
||||
if error:
|
||||
raise TypeError('list value expected')
|
||||
logging.error('Ignoring non-list data for %s: %s', self, data)
|
||||
data = []
|
||||
assert isinstance(data, list)
|
||||
|
||||
# Ok we've got a list; now run everything in it through validation.
|
||||
for i, subdata in enumerate(data):
|
||||
data[i] = self.d_value.filter_input(subdata, error=error)
|
||||
return data
|
||||
|
||||
def get_default_data(self) -> list:
|
||||
return []
|
||||
|
||||
def prune_data(self, data: Any) -> bool:
|
||||
# Run pruning on all individual entries' data through out child field.
|
||||
# However we don't *completely* prune values from the list since that
|
||||
# would change it.
|
||||
for subdata in data:
|
||||
self.d_value.prune_fields_data(subdata)
|
||||
|
||||
# We can also optionally prune the whole list if empty and allowed.
|
||||
return not data and not self._store_default
|
||||
|
||||
@overload
|
||||
def __get__(self, obj: None, cls: Any = None) -> CompoundListField[TC]:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __get__(self, obj: Any, cls: Any = None) -> BoundCompoundListField[TC]:
|
||||
...
|
||||
|
||||
def __get__(self, obj: Any, cls: Any = None) -> Any:
|
||||
# On access we simply provide a version of ourself
|
||||
# bound to our corresponding sub-data.
|
||||
if obj is None:
|
||||
# when called on the type, we return the field
|
||||
return self
|
||||
assert self.d_key in obj.d_data
|
||||
return BoundCompoundListField(self, obj.d_data[self.d_key])
|
||||
|
||||
# Note:
|
||||
# When setting the list, we tell the type-checker that we accept
|
||||
# a raw list of CompoundValue objects, but at runtime we actually
|
||||
# deal with BoundCompoundValue objects (see note in BoundCompoundListField)
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def __set__(self, obj: Any, value: List[TC]) -> None:
|
||||
...
|
||||
|
||||
else:
|
||||
|
||||
def __set__(self, obj, value):
|
||||
if not isinstance(value, list):
|
||||
raise TypeError(
|
||||
'CompoundListField expected list value on set.')
|
||||
|
||||
# Allow assigning only from a sequence of our existing children.
|
||||
# (could look into expanding this to other children if we can
|
||||
# be sure the underlying data will line up; for example two
|
||||
# CompoundListFields with different child_field values should not
|
||||
# be inter-assignable.
|
||||
if (not all(isinstance(i, BoundCompoundValue) for i in value)
|
||||
or not all(i.d_value is self.d_value for i in value)):
|
||||
raise ValueError('CompoundListField assignment must be a '
|
||||
'list containing only its existing children.')
|
||||
obj.d_data[self.d_key] = [i.d_data for i in value]
|
||||
|
||||
|
||||
class CompoundDictField(BaseField, Generic[TK, TC]):
|
||||
"""A field consisting of key-indexed instances of a compound-value.
|
||||
|
||||
Element access returns the sub-field, allowing nested field access.
|
||||
ie: mylist[10].fieldattr = 'foo'
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
d_key: str,
|
||||
keytype: Type[TK],
|
||||
valuetype: TC,
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(d_key)
|
||||
self.d_value = valuetype
|
||||
|
||||
# This doesnt actually exist for us, but want the type-checker
|
||||
# to think it does (see TYPE_CHECKING note below).
|
||||
self.d_data: Any
|
||||
self.d_keytype = keytype
|
||||
self._store_default = store_default
|
||||
|
||||
# noinspection DuplicatedCode
|
||||
def filter_input(self, data: Any, error: bool) -> dict:
|
||||
if not isinstance(data, dict):
|
||||
if error:
|
||||
raise TypeError('dict value expected')
|
||||
logging.error('Ignoring non-dict data for %s: %s', self, data)
|
||||
data = {}
|
||||
data_out = {}
|
||||
for key, val in data.items():
|
||||
if not isinstance(key, self.d_keytype):
|
||||
if error:
|
||||
raise TypeError('invalid key type')
|
||||
logging.error('Ignoring invalid key type for %s: %s', self,
|
||||
data)
|
||||
continue
|
||||
data_out[key] = self.d_value.filter_input(val, error=error)
|
||||
return data_out
|
||||
|
||||
def get_default_data(self) -> dict:
|
||||
return {}
|
||||
|
||||
def prune_data(self, data: Any) -> bool:
|
||||
# Run pruning on all individual entries' data through our child field.
|
||||
# However we don't *completely* prune values from the list since that
|
||||
# would change it.
|
||||
for subdata in data.values():
|
||||
self.d_value.prune_fields_data(subdata)
|
||||
|
||||
# We can also optionally prune the whole list if empty and allowed.
|
||||
return not data and not self._store_default
|
||||
|
||||
@overload
|
||||
def __get__(self, obj: None, cls: Any = None) -> CompoundDictField[TK, TC]:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __get__(self, obj: Any,
|
||||
cls: Any = None) -> BoundCompoundDictField[TK, TC]:
|
||||
...
|
||||
|
||||
def __get__(self, obj: Any, cls: Any = None) -> Any:
|
||||
# On access we simply provide a version of ourself
|
||||
# bound to our corresponding sub-data.
|
||||
if obj is None:
|
||||
# when called on the type, we return the field
|
||||
return self
|
||||
assert self.d_key in obj.d_data
|
||||
return BoundCompoundDictField(self, obj.d_data[self.d_key])
|
||||
|
||||
# In the type-checker's eyes we take CompoundValues but at runtime
|
||||
# we actually take BoundCompoundValues (see note in BoundCompoundDictField)
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def __set__(self, obj: Any, value: Dict[TK, TC]) -> None:
|
||||
...
|
||||
|
||||
else:
|
||||
|
||||
def __set__(self, obj, value):
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError(
|
||||
'CompoundDictField expected dict value on set.')
|
||||
|
||||
# Allow assigning only from a sequence of our existing children.
|
||||
# (could look into expanding this to other children if we can
|
||||
# be sure the underlying data will line up; for example two
|
||||
# CompoundListFields with different child_field values should not
|
||||
# be inter-assignable.
|
||||
print('val', value)
|
||||
if (not all(isinstance(i, self.d_keytype) for i in value.keys())
|
||||
or not all(
|
||||
isinstance(i, BoundCompoundValue)
|
||||
for i in value.values())
|
||||
or not all(i.d_value is self.d_value
|
||||
for i in value.values())):
|
||||
raise ValueError('CompoundDictField assignment must be a '
|
||||
'dict containing only its existing children.')
|
||||
obj.d_data[self.d_key] = {
|
||||
key: val.d_data
|
||||
for key, val in value.items()
|
||||
}
|
||||
448
assets/src/data/scripts/bafoundation/entity/_support.py
Normal file
448
assets/src/data/scripts/bafoundation/entity/_support.py
Normal file
@ -0,0 +1,448 @@
|
||||
# Synced from bamaster.
|
||||
# EFRO_SYNC_HASH=207162478257782519026483356805664558659
|
||||
#
|
||||
"""Various support classes for accessing data and info on fields and values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypeVar, Generic, overload
|
||||
|
||||
from bafoundation.entity._base import BaseField
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import (Optional, Tuple, Type, Any, Dict, List, Union)
|
||||
from bafoundation.entity._value import CompoundValue
|
||||
from bafoundation.entity._field import (ListField, DictField,
|
||||
CompoundListField,
|
||||
CompoundDictField)
|
||||
|
||||
T = TypeVar('T')
|
||||
TK = TypeVar('TK')
|
||||
TC = TypeVar('TC', bound='CompoundValue')
|
||||
TBL = TypeVar('TBL', bound='BoundCompoundListField')
|
||||
|
||||
|
||||
class BoundCompoundValue:
|
||||
"""Wraps a CompoundValue object and its entity data.
|
||||
|
||||
Allows access to its values through our own equivalent attributes.
|
||||
"""
|
||||
|
||||
def __init__(self, value: CompoundValue,
|
||||
d_data: Union[List[Any], Dict[str, Any]]):
|
||||
self.d_value: CompoundValue
|
||||
self.d_data: Union[List[Any], Dict[str, Any]]
|
||||
# need to use base setters to avoid triggering our own overrides
|
||||
object.__setattr__(self, 'd_value', value)
|
||||
object.__setattr__(self, 'd_data', d_data)
|
||||
|
||||
def __eq__(self, other: Any) -> Any:
|
||||
# allow comparing to compound and bound-compound objects
|
||||
from bafoundation.entity.util import compound_eq
|
||||
return compound_eq(self, other)
|
||||
|
||||
def __getattr__(self, name: str, default: Any = None) -> Any:
|
||||
# if this attribute corresponds to a field on our compound value's
|
||||
# unbound type, ask it to give us a value using our data
|
||||
field = getattr(type(object.__getattribute__(self, 'd_value')), name,
|
||||
None)
|
||||
if isinstance(field, BaseField):
|
||||
return field.get_with_data(self.d_data)
|
||||
raise AttributeError
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
# same deal as __getattr__ basically
|
||||
field = getattr(type(object.__getattribute__(self, 'd_value')), name,
|
||||
None)
|
||||
if isinstance(field, BaseField):
|
||||
field.set_with_data(self.d_data, value, error=True)
|
||||
return
|
||||
super().__setattr__(name, value)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset this field's data to defaults."""
|
||||
value = object.__getattribute__(self, 'd_value')
|
||||
data = object.__getattribute__(self, 'd_data')
|
||||
assert isinstance(data, dict)
|
||||
# Need to clear our dict in-place since we have no
|
||||
# access to our parent which we'd need to assign an empty one.
|
||||
data.clear()
|
||||
# now fill in default data
|
||||
value.apply_fields_to_data(data, error=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
fstrs: List[str] = []
|
||||
for field in self.d_value.get_fields():
|
||||
try:
|
||||
fstrs.append(str(field) + '=' + repr(getattr(self, field)))
|
||||
except Exception:
|
||||
fstrs.append('FAIL' + str(field) + ' ' + str(type(self)))
|
||||
return type(self.d_value).__name__ + '(' + ', '.join(fstrs) + ')'
|
||||
|
||||
|
||||
class FieldInspector:
|
||||
"""Used for inspecting fields."""
|
||||
|
||||
def __init__(self, root: Any, obj: Any, path: List[str],
|
||||
dbpath: List[str]) -> None:
|
||||
self._root = root
|
||||
self._obj = obj
|
||||
self._path = path
|
||||
self._dbpath = dbpath
|
||||
|
||||
def __repr__(self) -> str:
|
||||
path = '.'.join(self._path)
|
||||
typename = type(self._root).__name__
|
||||
if path == '':
|
||||
return f'<FieldInspector: {typename}>'
|
||||
return f'<FieldInspector: {typename}: {path}>'
|
||||
|
||||
def __getattr__(self, name: str, default: Any = None) -> Any:
|
||||
# pylint: disable=cyclic-import
|
||||
from bafoundation.entity._field import CompoundField
|
||||
|
||||
# If this attribute corresponds to a field on our obj's
|
||||
# unbound type, return a new inspector for it.
|
||||
if isinstance(self._obj, CompoundField):
|
||||
target = self._obj.d_value
|
||||
else:
|
||||
target = self._obj
|
||||
field = getattr(type(target), name, None)
|
||||
if isinstance(field, BaseField):
|
||||
newpath = list(self._path)
|
||||
newpath.append(name)
|
||||
newdbpath = list(self._dbpath)
|
||||
assert field.d_key is not None
|
||||
newdbpath.append(field.d_key)
|
||||
return FieldInspector(self._root, field, newpath, newdbpath)
|
||||
raise AttributeError
|
||||
|
||||
def get_root(self) -> Any:
|
||||
"""Return the root object this inspector is targeting."""
|
||||
return self._root
|
||||
|
||||
def get_path(self) -> List[str]:
|
||||
"""Return the python path components of this inspector."""
|
||||
return self._path
|
||||
|
||||
def get_db_path(self) -> List[str]:
|
||||
"""Return the database path components of this inspector."""
|
||||
return self._dbpath
|
||||
|
||||
|
||||
class BoundListField(Generic[T]):
|
||||
"""ListField bound to data; used for accessing field values."""
|
||||
|
||||
def __init__(self, field: ListField[T], d_data: List[Any]):
|
||||
self.d_field = field
|
||||
assert isinstance(d_data, list)
|
||||
self.d_data = d_data
|
||||
self._i = 0
|
||||
|
||||
def __eq__(self, other: Any) -> Any:
|
||||
# just convert us into a regular list and run a compare with that
|
||||
flattened = [
|
||||
self.d_field.d_value.filter_output(value) for value in self.d_data
|
||||
]
|
||||
return flattened == other
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '[' + ', '.join(
|
||||
repr(self.d_field.d_value.filter_output(i))
|
||||
for i in self.d_data) + ']'
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.d_data)
|
||||
|
||||
def __iter__(self) -> Any:
|
||||
self._i = 0
|
||||
return self
|
||||
|
||||
def append(self, val: T) -> None:
|
||||
"""Append the provided value to the list."""
|
||||
self.d_data.append(self.d_field.d_value.filter_input(val, error=True))
|
||||
|
||||
def __next__(self) -> T:
|
||||
if self._i < len(self.d_data):
|
||||
self._i += 1
|
||||
val: T = self.d_field.d_value.filter_output(self.d_data[self._i -
|
||||
1])
|
||||
return val
|
||||
raise StopIteration
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: int) -> T:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: slice) -> List[T]:
|
||||
...
|
||||
|
||||
def __getitem__(self, key: Any) -> Any:
|
||||
if isinstance(key, slice):
|
||||
dofilter = self.d_field.d_value.filter_output
|
||||
return [
|
||||
dofilter(self.d_data[i])
|
||||
for i in range(*key.indices(len(self)))
|
||||
]
|
||||
assert isinstance(key, int)
|
||||
return self.d_field.d_value.filter_output(self.d_data[key])
|
||||
|
||||
def __setitem__(self, key: int, value: T) -> None:
|
||||
if not isinstance(key, int):
|
||||
raise TypeError("Expected int index.")
|
||||
self.d_data[key] = self.d_field.d_value.filter_input(value, error=True)
|
||||
|
||||
|
||||
class BoundDictField(Generic[TK, T]):
|
||||
"""DictField bound to its data; used for accessing its values."""
|
||||
|
||||
def __init__(self, keytype: Type[TK], field: DictField[TK, T],
|
||||
d_data: Dict[TK, T]):
|
||||
self._keytype = keytype
|
||||
self.d_field = field
|
||||
assert isinstance(d_data, dict)
|
||||
self.d_data = d_data
|
||||
|
||||
def __eq__(self, other: Any) -> Any:
|
||||
# just convert us into a regular dict and run a compare with that
|
||||
flattened = {
|
||||
key: self.d_field.d_value.filter_output(value)
|
||||
for key, value in self.d_data.items()
|
||||
}
|
||||
return flattened == other
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '{' + ', '.join(
|
||||
repr(key) + ': ' + repr(self.d_field.d_value.filter_output(val))
|
||||
for key, val in self.d_data.items()) + '}'
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.d_data)
|
||||
|
||||
def __getitem__(self, key: TK) -> T:
|
||||
if not isinstance(key, self._keytype):
|
||||
raise TypeError(
|
||||
f'Invalid key type {type(key)}; expected {self._keytype}')
|
||||
assert isinstance(key, self._keytype)
|
||||
typedval: T = self.d_field.d_value.filter_output(self.d_data[key])
|
||||
return typedval
|
||||
|
||||
def get(self, key: TK, default: Optional[T] = None) -> Optional[T]:
|
||||
"""Get a value if present, or a default otherwise."""
|
||||
if not isinstance(key, self._keytype):
|
||||
raise TypeError(
|
||||
f'Invalid key type {type(key)}; expected {self._keytype}')
|
||||
assert isinstance(key, self._keytype)
|
||||
if key not in self.d_data:
|
||||
return default
|
||||
typedval: T = self.d_field.d_value.filter_output(self.d_data[key])
|
||||
return typedval
|
||||
|
||||
def __setitem__(self, key: TK, value: T) -> None:
|
||||
if not isinstance(key, self._keytype):
|
||||
raise TypeError("Expected str index.")
|
||||
self.d_data[key] = self.d_field.d_value.filter_input(value, error=True)
|
||||
|
||||
def __contains__(self, key: TK) -> bool:
|
||||
return key in self.d_data
|
||||
|
||||
def __delitem__(self, key: TK) -> None:
|
||||
del self.d_data[key]
|
||||
|
||||
def keys(self) -> List[TK]:
|
||||
"""Return a list of our keys."""
|
||||
return list(self.d_data.keys())
|
||||
|
||||
def values(self) -> List[T]:
|
||||
"""Return a list of our values."""
|
||||
return [
|
||||
self.d_field.d_value.filter_output(value)
|
||||
for value in self.d_data.values()
|
||||
]
|
||||
|
||||
def items(self) -> List[Tuple[TK, T]]:
|
||||
"""Return a list of item/value pairs."""
|
||||
return [(key, self.d_field.d_value.filter_output(value))
|
||||
for key, value in self.d_data.items()]
|
||||
|
||||
|
||||
class BoundCompoundListField(Generic[TC]):
|
||||
"""A CompoundListField bound to its entity sub-data."""
|
||||
|
||||
def __init__(self, field: CompoundListField[TC], d_data: List[Any]):
|
||||
self.d_field = field
|
||||
self.d_data = d_data
|
||||
self._i = 0
|
||||
|
||||
def __eq__(self, other: Any) -> Any:
|
||||
from bafoundation.entity.util import have_matching_fields
|
||||
|
||||
# We can only be compared to other bound-compound-fields
|
||||
if not isinstance(other, BoundCompoundListField):
|
||||
return NotImplemented
|
||||
|
||||
# If our compound values have differing fields, we're unequal.
|
||||
if not have_matching_fields(self.d_field.d_value,
|
||||
other.d_field.d_value):
|
||||
return False
|
||||
|
||||
# Ok our data schemas match; now just compare our data..
|
||||
return self.d_data == other.d_data
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.d_data)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '[' + ', '.join(
|
||||
repr(BoundCompoundValue(self.d_field.d_value, i))
|
||||
for i in self.d_data) + ']'
|
||||
|
||||
# Note: to the type checker our gets/sets simply deal with CompoundValue
|
||||
# objects so the type-checker can cleanly handle their sub-fields.
|
||||
# However at runtime we deal in BoundCompoundValue objects which use magic
|
||||
# to tie the CompoundValue object to its data but which the type checker
|
||||
# can't understand.
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: int) -> TC:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: slice) -> List[TC]:
|
||||
...
|
||||
|
||||
def __getitem__(self, key: Any) -> Any:
|
||||
...
|
||||
|
||||
def __next__(self) -> TC:
|
||||
...
|
||||
|
||||
def append(self) -> TC:
|
||||
"""Append and return a new field entry to the array."""
|
||||
...
|
||||
else:
|
||||
|
||||
def __getitem__(self, key: Any) -> Any:
|
||||
if isinstance(key, slice):
|
||||
return [
|
||||
BoundCompoundValue(self.d_field.d_value, self.d_data[i])
|
||||
for i in range(*key.indices(len(self)))
|
||||
]
|
||||
assert isinstance(key, int)
|
||||
return BoundCompoundValue(self.d_field.d_value, self.d_data[key])
|
||||
|
||||
def __next__(self):
|
||||
if self._i < len(self.d_data):
|
||||
self._i += 1
|
||||
return BoundCompoundValue(self.d_field.d_value,
|
||||
self.d_data[self._i - 1])
|
||||
raise StopIteration
|
||||
|
||||
def append(self) -> Any:
|
||||
"""Append and return a new field entry to the array."""
|
||||
# push the entity default into data and then let it fill in
|
||||
# any children/etc.
|
||||
self.d_data.append(
|
||||
self.d_field.d_value.filter_input(
|
||||
self.d_field.d_value.get_default_data(), error=True))
|
||||
return BoundCompoundValue(self.d_field.d_value, self.d_data[-1])
|
||||
|
||||
def __iter__(self: TBL) -> TBL:
|
||||
self._i = 0
|
||||
return self
|
||||
|
||||
|
||||
class BoundCompoundDictField(Generic[TK, TC]):
|
||||
"""A CompoundDictField bound to its entity sub-data."""
|
||||
|
||||
def __init__(self, field: CompoundDictField[TK, TC],
|
||||
d_data: Dict[Any, Any]):
|
||||
self.d_field = field
|
||||
self.d_data = d_data
|
||||
|
||||
def __eq__(self, other: Any) -> Any:
|
||||
from bafoundation.entity.util import have_matching_fields
|
||||
|
||||
# We can only be compared to other bound-compound-fields
|
||||
if not isinstance(other, BoundCompoundDictField):
|
||||
return NotImplemented
|
||||
|
||||
# If our compound values have differing fields, we're unequal.
|
||||
if not have_matching_fields(self.d_field.d_value,
|
||||
other.d_field.d_value):
|
||||
return False
|
||||
|
||||
# Ok our data schemas match; now just compare our data..
|
||||
return self.d_data == other.d_data
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '{' + ', '.join(
|
||||
repr(key) + ': ' +
|
||||
repr(BoundCompoundValue(self.d_field.d_value, value))
|
||||
for key, value in self.d_data.items()) + '}'
|
||||
|
||||
# In the typechecker's eyes, gets/sets on us simply deal in
|
||||
# CompoundValue object. This allows type-checking to work nicely
|
||||
# for its sub-fields.
|
||||
# However in real-life we return BoundCompoundValues which use magic
|
||||
# to tie the CompoundValue to its data (but which the typechecker
|
||||
# would not be able to make sense of)
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def __getitem__(self, key: TK) -> TC:
|
||||
pass
|
||||
|
||||
def values(self) -> List[TC]:
|
||||
"""Return a list of our values."""
|
||||
|
||||
def items(self) -> List[Tuple[TK, TC]]:
|
||||
"""Return key/value pairs for all dict entries."""
|
||||
|
||||
def add(self, key: TK) -> TC:
|
||||
"""Add an entry into the dict, returning it.
|
||||
|
||||
Any existing value is replaced."""
|
||||
|
||||
else:
|
||||
|
||||
def __getitem__(self, key):
|
||||
return BoundCompoundValue(self.d_field.d_value, self.d_data[key])
|
||||
|
||||
def values(self):
|
||||
"""Return a list of our values."""
|
||||
return list(
|
||||
BoundCompoundValue(self.d_field.d_value, i)
|
||||
for i in self.d_data.values())
|
||||
|
||||
def items(self):
|
||||
"""Return key/value pairs for all dict entries."""
|
||||
return [(key, BoundCompoundValue(self.d_field.d_value, value))
|
||||
for key, value in self.d_data.items()]
|
||||
|
||||
def add(self, key: TK) -> TC:
|
||||
"""Add an entry into the dict, returning it.
|
||||
|
||||
Any existing value is replaced."""
|
||||
if not isinstance(key, self.d_field.d_keytype):
|
||||
raise TypeError(f'expected key type {self.d_field.d_keytype};'
|
||||
f' got {type(key)}')
|
||||
# push the entity default into data and then let it fill in
|
||||
# any children/etc.
|
||||
self.d_data[key] = (self.d_field.d_value.filter_input(
|
||||
self.d_field.d_value.get_default_data(), error=True))
|
||||
return BoundCompoundValue(self.d_field.d_value, self.d_data[key])
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.d_data)
|
||||
|
||||
def __contains__(self, key: TK) -> bool:
|
||||
return key in self.d_data
|
||||
|
||||
def __delitem__(self, key: TK) -> None:
|
||||
del self.d_data[key]
|
||||
|
||||
def keys(self) -> List[TK]:
|
||||
"""Return a list of our keys."""
|
||||
return list(self.d_data.keys())
|
||||
531
assets/src/data/scripts/bafoundation/entity/_value.py
Normal file
531
assets/src/data/scripts/bafoundation/entity/_value.py
Normal file
@ -0,0 +1,531 @@
|
||||
# Synced from bsmaster.
|
||||
# EFRO_SYNC_HASH=158385720566816709798128360485086830759
|
||||
#
|
||||
"""Value types for the entity system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import inspect
|
||||
import logging
|
||||
from collections import abc
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, TypeVar, Tuple, Optional, Generic
|
||||
|
||||
from bafoundation.entity._base import DataHandler, BaseField
|
||||
from bafoundation.entity.util import compound_eq
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Optional, Set, List, Dict, Any, Type
|
||||
|
||||
T = TypeVar('T')
|
||||
TE = TypeVar('TE', bound=Enum)
|
||||
|
||||
_sanity_tested_types: Set[Type] = set()
|
||||
_type_field_cache: Dict[Type, Dict[str, BaseField]] = {}
|
||||
|
||||
|
||||
class TypedValue(DataHandler, Generic[T]):
|
||||
"""Base class for all value types dealing with a single data type."""
|
||||
|
||||
|
||||
class SimpleValue(TypedValue[T]):
|
||||
"""Standard base class for simple single-value types.
|
||||
|
||||
This class provides enough functionality to handle most simple
|
||||
types such as int/float/etc without too many subclass overrides.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
default: T,
|
||||
store_default: bool,
|
||||
target_type: Type = None,
|
||||
convert_source_types: Tuple[Type, ...] = (),
|
||||
allow_none: bool = False) -> None:
|
||||
"""Init the value field.
|
||||
|
||||
If store_default is False, the field value will not be included
|
||||
in final entity data if it is a default value. Be sure to set
|
||||
this to True for any fields that will be used for server-side
|
||||
queries so they are included in indexing.
|
||||
target_type and convert_source_types are used in the default
|
||||
filter_input implementation; if passed in data's type is present
|
||||
in convert_source_types, a target_type will be instantiated
|
||||
using it. (allows for simple conversions to bool, int, etc)
|
||||
Data will also be allowed through untouched if it matches target_type.
|
||||
(types needing further introspection should override filter_input).
|
||||
Lastly, the value of allow_none is also used in filter_input for
|
||||
whether values of None should be allowed.
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self._store_default = store_default
|
||||
self._target_type = target_type
|
||||
self._convert_source_types = convert_source_types
|
||||
self._allow_none = allow_none
|
||||
|
||||
# We store _default_data in our internal data format so need
|
||||
# to run user-facing value through our input filter.
|
||||
# Make sure we do this last since filter_input depends on above vals.
|
||||
self._default_data: T = self.filter_input(default, error=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if self._target_type is not None:
|
||||
return f'<Value of type {self._target_type.__name__}>'
|
||||
return f'<Value of unknown type>'
|
||||
|
||||
def get_default_data(self) -> Any:
|
||||
return self._default_data
|
||||
|
||||
def prune_data(self, data: Any) -> bool:
|
||||
return not self._store_default and data == self._default_data
|
||||
|
||||
def filter_input(self, data: Any, error: bool) -> Any:
|
||||
|
||||
# Let data pass through untouched if its already our target type
|
||||
if self._target_type is not None:
|
||||
if isinstance(data, self._target_type):
|
||||
return data
|
||||
|
||||
# ...and also if its None and we're into that sort of thing.
|
||||
if self._allow_none and data is None:
|
||||
return data
|
||||
|
||||
# If its one of our convertible types, convert.
|
||||
if (self._convert_source_types
|
||||
and isinstance(data, self._convert_source_types)):
|
||||
assert self._target_type is not None
|
||||
return self._target_type(data)
|
||||
if error:
|
||||
errmsg = (f'value of type {self._target_type} or None expected'
|
||||
if self._allow_none else
|
||||
f'value of type {self._target_type} expected')
|
||||
errmsg += f'; got {type(data)}'
|
||||
raise TypeError(errmsg)
|
||||
errmsg = f'Ignoring incompatible data for {self};'
|
||||
errmsg += (f' expected {self._target_type} or None;'
|
||||
if self._allow_none else f'expected {self._target_type};')
|
||||
errmsg += f' got {type(data)}'
|
||||
logging.error(errmsg)
|
||||
return self.get_default_data()
|
||||
|
||||
|
||||
class StringValue(SimpleValue[str]):
|
||||
"""Value consisting of a single string."""
|
||||
|
||||
def __init__(self, default: str = "", store_default: bool = False) -> None:
|
||||
super().__init__(default, store_default, str)
|
||||
|
||||
|
||||
class OptionalStringValue(SimpleValue[Optional[str]]):
|
||||
"""Value consisting of a single string or None."""
|
||||
|
||||
def __init__(self,
|
||||
default: Optional[str] = None,
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(default, store_default, str, allow_none=True)
|
||||
|
||||
|
||||
class BoolValue(SimpleValue[bool]):
|
||||
"""Value consisting of a single bool."""
|
||||
|
||||
def __init__(self, default: bool = False,
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(default, store_default, bool, (int, float))
|
||||
|
||||
|
||||
class OptionalBoolValue(SimpleValue[Optional[bool]]):
|
||||
"""Value consisting of a single bool or None."""
|
||||
|
||||
def __init__(self,
|
||||
default: Optional[bool] = None,
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(default,
|
||||
store_default,
|
||||
bool, (int, float),
|
||||
allow_none=True)
|
||||
|
||||
|
||||
def verify_time_input(data: Any, error: bool, allow_none: bool) -> Any:
|
||||
"""Checks input data for time values."""
|
||||
pytz_utc: Any
|
||||
|
||||
# We don't *require* pytz since it must be installed through pip
|
||||
# but it is used by firestore client for its date values
|
||||
# (in which case it should be installed as a dependency anyway).
|
||||
try:
|
||||
import pytz
|
||||
pytz_utc = pytz.utc
|
||||
except ModuleNotFoundError:
|
||||
pytz_utc = None
|
||||
|
||||
# Filter unallowed None values.
|
||||
if not allow_none and data is None:
|
||||
if error:
|
||||
raise ValueError("datetime value cannot be None")
|
||||
logging.error("ignoring datetime value of None")
|
||||
data = (None if allow_none else datetime.datetime.now(
|
||||
datetime.timezone.utc))
|
||||
|
||||
# Parent filter_input does what we need, but let's just make
|
||||
# sure we *only* accept datetime values that know they're UTC.
|
||||
elif (isinstance(data, datetime.datetime)
|
||||
and data.tzinfo is not datetime.timezone.utc
|
||||
and (pytz_utc is None or data.tzinfo is not pytz_utc)):
|
||||
if error:
|
||||
raise ValueError(
|
||||
"datetime values must have timezone set as timezone.utc")
|
||||
logging.error(
|
||||
"ignoring datetime value without timezone.utc set: %s %s",
|
||||
type(datetime.timezone.utc), type(data.tzinfo))
|
||||
data = (None if allow_none else datetime.datetime.now(
|
||||
datetime.timezone.utc))
|
||||
return data
|
||||
|
||||
|
||||
class DateTimeValue(SimpleValue[datetime.datetime]):
|
||||
"""Value consisting of a datetime.datetime object.
|
||||
|
||||
The default value for this is always the current time in UTC.
|
||||
"""
|
||||
|
||||
def __init__(self, store_default: bool = False) -> None:
|
||||
# Pass dummy datetime value as default just to satisfy constructor;
|
||||
# we override get_default_data though so this doesn't get used.
|
||||
dummy_default = datetime.datetime.now(datetime.timezone.utc)
|
||||
super().__init__(dummy_default, store_default, datetime.datetime)
|
||||
|
||||
def get_default_data(self) -> Any:
|
||||
# For this class we don't use a static default value;
|
||||
# default is always now.
|
||||
return datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
def filter_input(self, data: Any, error: bool) -> Any:
|
||||
data = verify_time_input(data, error, allow_none=False)
|
||||
return super().filter_input(data, error)
|
||||
|
||||
|
||||
class OptionalDateTimeValue(SimpleValue[Optional[datetime.datetime]]):
|
||||
"""Value consisting of a datetime.datetime object or None."""
|
||||
|
||||
def __init__(self, store_default: bool = False) -> None:
|
||||
super().__init__(None,
|
||||
store_default,
|
||||
datetime.datetime,
|
||||
allow_none=True)
|
||||
|
||||
def filter_input(self, data: Any, error: bool) -> Any:
|
||||
data = verify_time_input(data, error, allow_none=True)
|
||||
return super().filter_input(data, error)
|
||||
|
||||
|
||||
class IntValue(SimpleValue[int]):
|
||||
"""Value consisting of a single int."""
|
||||
|
||||
def __init__(self, default: int = 0, store_default: bool = False) -> None:
|
||||
super().__init__(default, store_default, int, (bool, float))
|
||||
|
||||
|
||||
class OptionalIntValue(SimpleValue[Optional[int]]):
|
||||
"""Value consisting of a single int or None"""
|
||||
|
||||
def __init__(self, default: int = None,
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(default,
|
||||
store_default,
|
||||
int, (bool, float),
|
||||
allow_none=True)
|
||||
|
||||
|
||||
class FloatValue(SimpleValue[float]):
|
||||
"""Value consisting of a single float."""
|
||||
|
||||
def __init__(self, default: float = 0.0,
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(default, store_default, float, (bool, int))
|
||||
|
||||
|
||||
class OptionalFloatValue(SimpleValue[Optional[float]]):
|
||||
"""Value consisting of a single float or None."""
|
||||
|
||||
def __init__(self, default: float = None,
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(default,
|
||||
store_default,
|
||||
float, (bool, int),
|
||||
allow_none=True)
|
||||
|
||||
|
||||
class Float3Value(SimpleValue[Tuple[float, float, float]]):
|
||||
"""Value consisting of 3 floats."""
|
||||
|
||||
def __init__(self,
|
||||
default: Tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(default, store_default)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<Value of type float3>'
|
||||
|
||||
def filter_input(self, data: Any, error: bool) -> Any:
|
||||
if (not isinstance(data, abc.Sequence) or len(data) != 3
|
||||
or any(not isinstance(i, (int, float)) for i in data)):
|
||||
if error:
|
||||
raise TypeError("Sequence of 3 float values expected.")
|
||||
logging.error('Ignoring non-3-float-sequence data for %s: %s',
|
||||
self, data)
|
||||
data = self.get_default_data()
|
||||
|
||||
# Actually store as list.
|
||||
return [float(data[0]), float(data[1]), float(data[2])]
|
||||
|
||||
def filter_output(self, data: Any) -> Any:
|
||||
"""Override."""
|
||||
assert len(data) == 3
|
||||
return tuple(data)
|
||||
|
||||
|
||||
class BaseEnumValue(TypedValue[T]):
|
||||
"""Value class for storing Python Enums.
|
||||
|
||||
Internally enums are stored as their corresponding int/str/etc. values.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
enumtype: Type[T],
|
||||
default: Optional[T] = None,
|
||||
store_default: bool = False,
|
||||
allow_none: bool = False) -> None:
|
||||
super().__init__()
|
||||
assert issubclass(enumtype, Enum)
|
||||
|
||||
vals: List[T] = list(enumtype)
|
||||
|
||||
# Bit of sanity checking: make sure this enum has at least
|
||||
# one value and that its underlying values are all of simple
|
||||
# json-friendly types.
|
||||
if not vals:
|
||||
raise TypeError(f'enum {enumtype} has no values')
|
||||
for val in vals:
|
||||
assert isinstance(val, Enum)
|
||||
if not isinstance(val.value, (int, bool, float, str)):
|
||||
raise TypeError(f'enum value {val} has an invalid'
|
||||
f' value type {type(val.value)}')
|
||||
self._enumtype: Type[Enum] = enumtype
|
||||
self._store_default: bool = store_default
|
||||
self._allow_none: bool = allow_none
|
||||
|
||||
# We store default data is internal format so need to run
|
||||
# user-provided value through input filter.
|
||||
# Make sure to set this last since it could depend on other
|
||||
# stuff we set here.
|
||||
if default is None and not self._allow_none:
|
||||
# Special case: we allow passing None as default even if
|
||||
# we don't support None as a value; in that case we sub
|
||||
# in the first enum value.
|
||||
default = vals[0]
|
||||
self._default_data: Enum = self.filter_input(default, error=True)
|
||||
|
||||
def get_default_data(self) -> Any:
|
||||
return self._default_data
|
||||
|
||||
def prune_data(self, data: Any) -> bool:
|
||||
return not self._store_default and data == self._default_data
|
||||
|
||||
def filter_input(self, data: Any, error: bool) -> Any:
|
||||
|
||||
# Allow passing in enum objects directly of course.
|
||||
if isinstance(data, self._enumtype):
|
||||
data = data.value
|
||||
elif self._allow_none and data is None:
|
||||
pass
|
||||
else:
|
||||
# At this point we assume its an enum value
|
||||
try:
|
||||
self._enumtype(data)
|
||||
except ValueError:
|
||||
if error:
|
||||
raise ValueError(
|
||||
f"Invalid value for {self._enumtype}: {data}")
|
||||
logging.error('Ignoring invalid value for %s: %s',
|
||||
self._enumtype, data)
|
||||
data = self._default_data
|
||||
return data
|
||||
|
||||
def filter_output(self, data: Any) -> Any:
|
||||
if self._allow_none and data is None:
|
||||
return None
|
||||
return self._enumtype(data)
|
||||
|
||||
|
||||
class EnumValue(BaseEnumValue[TE]):
|
||||
"""Value class for storing Python Enums.
|
||||
|
||||
Internally enums are stored as their corresponding int/str/etc. values.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
enumtype: Type[TE],
|
||||
default: TE = None,
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(enumtype, default, store_default, allow_none=False)
|
||||
|
||||
|
||||
class OptionalEnumValue(BaseEnumValue[Optional[TE]]):
|
||||
"""Value class for storing Python Enums (or None).
|
||||
|
||||
Internally enums are stored as their corresponding int/str/etc. values.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
enumtype: Type[TE],
|
||||
default: TE = None,
|
||||
store_default: bool = False) -> None:
|
||||
super().__init__(enumtype, default, store_default, allow_none=True)
|
||||
|
||||
|
||||
class CompoundValue(DataHandler):
|
||||
"""A value containing one or more named child fields of its own.
|
||||
|
||||
Custom classes can be defined that inherit from this and include
|
||||
any number of Field instances within themself.
|
||||
"""
|
||||
|
||||
def __init__(self, store_default: bool = False) -> None:
|
||||
super().__init__()
|
||||
self._store_default = store_default
|
||||
|
||||
# Run sanity checks on this type if we haven't.
|
||||
self.run_type_sanity_checks()
|
||||
|
||||
def __eq__(self, other: Any) -> Any:
|
||||
# Allow comparing to compound and bound-compound objects.
|
||||
return compound_eq(self, other)
|
||||
|
||||
def get_default_data(self) -> dict:
|
||||
return {}
|
||||
|
||||
# NOTE: once we've got bound-compound-fields working in mypy
|
||||
# we should get rid of this here.
|
||||
# For now it needs to be here though since bound-compound fields
|
||||
# come across as these in type-land.
|
||||
def reset(self) -> None:
|
||||
"""Resets data to default."""
|
||||
raise ValueError('Unbound CompoundValue cannot be reset.')
|
||||
|
||||
def filter_input(self, data: Any, error: bool) -> dict:
|
||||
if not isinstance(data, dict):
|
||||
if error:
|
||||
raise TypeError('dict value expected')
|
||||
logging.error('Ignoring non-dict data for %s: %s', self, data)
|
||||
data = {}
|
||||
assert isinstance(data, dict)
|
||||
self.apply_fields_to_data(data, error=error)
|
||||
return data
|
||||
|
||||
def prune_data(self, data: Any) -> bool:
|
||||
# Let all of our sub-fields prune themselves..
|
||||
self.prune_fields_data(data)
|
||||
|
||||
# Now we can optionally prune ourself completely if there's
|
||||
# nothing left in our data dict...
|
||||
return not data and not self._store_default
|
||||
|
||||
def prune_fields_data(self, d_data: Dict[str, Any]) -> None:
|
||||
"""Given a CompoundValue and data, prune any unnecessary data.
|
||||
will include those set to default values with store_default False.
|
||||
"""
|
||||
|
||||
# Allow all fields to take a pruning pass.
|
||||
assert isinstance(d_data, dict)
|
||||
for field in self.get_fields().values():
|
||||
assert isinstance(field.d_key, str)
|
||||
|
||||
# This is supposed to be valid data so there should be *something*
|
||||
# there for all fields.
|
||||
if field.d_key not in d_data:
|
||||
raise RuntimeError(f'expected to find {field.d_key} in data'
|
||||
f' for {self}; got data {d_data}')
|
||||
|
||||
# Now ask the field if this data is necessary. If not, prune it.
|
||||
if field.prune_data(d_data[field.d_key]):
|
||||
del d_data[field.d_key]
|
||||
|
||||
def apply_fields_to_data(self, d_data: Dict[str, Any],
|
||||
error: bool) -> None:
|
||||
"""Apply all of our fields to target data.
|
||||
|
||||
If error is True, exceptions will be raised for invalid data;
|
||||
otherwise it will be overwritten (with logging notices emitted).
|
||||
"""
|
||||
assert isinstance(d_data, dict)
|
||||
for field in self.get_fields().values():
|
||||
assert isinstance(field.d_key, str)
|
||||
|
||||
# First off, make sure *something* is there for this field.
|
||||
if field.d_key not in d_data:
|
||||
d_data[field.d_key] = field.get_default_data()
|
||||
|
||||
# Now let the field tweak the data as needed so its valid.
|
||||
d_data[field.d_key] = field.filter_input(d_data[field.d_key],
|
||||
error=error)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if not hasattr(self, 'd_data'):
|
||||
return f'<unbound {type(self).__name__} at {hex(id(self))}>'
|
||||
fstrs: List[str] = []
|
||||
assert isinstance(self, CompoundValue)
|
||||
for field in self.get_fields():
|
||||
fstrs.append(str(field) + '=' + repr(getattr(self, field)))
|
||||
return type(self).__name__ + '(' + ', '.join(fstrs) + ')'
|
||||
|
||||
@classmethod
|
||||
def get_fields(cls) -> Dict[str, BaseField]:
|
||||
"""Return all field instances for this type."""
|
||||
assert issubclass(cls, CompoundValue)
|
||||
|
||||
# If we haven't yet, calculate and cache a complete list of fields
|
||||
# for this exact type.
|
||||
if cls not in _type_field_cache:
|
||||
fields: Dict[str, BaseField] = {}
|
||||
for icls in inspect.getmro(cls):
|
||||
for name, field in icls.__dict__.items():
|
||||
if isinstance(field, BaseField):
|
||||
fields[name] = field
|
||||
_type_field_cache[cls] = fields
|
||||
retval: Dict[str, BaseField] = _type_field_cache[cls]
|
||||
assert isinstance(retval, dict)
|
||||
return retval
|
||||
|
||||
@classmethod
|
||||
def run_type_sanity_checks(cls) -> None:
|
||||
"""Given a type, run one-time sanity checks on it.
|
||||
|
||||
These tests ensure child fields are using valid
|
||||
non-repeating names/etc.
|
||||
"""
|
||||
if cls not in _sanity_tested_types:
|
||||
_sanity_tested_types.add(cls)
|
||||
|
||||
# Make sure all embedded fields have a key set and there are no
|
||||
# duplicates.
|
||||
field_keys: Set[str] = set()
|
||||
for field in cls.get_fields().values():
|
||||
assert isinstance(field.d_key, str)
|
||||
if field.d_key is None:
|
||||
raise RuntimeError(f'Child field {field} under {cls}'
|
||||
'has d_key None')
|
||||
if field.d_key == '':
|
||||
raise RuntimeError(f'Child field {field} under {cls}'
|
||||
'has empty d_key')
|
||||
|
||||
# Allow alphanumeric and underscore only.
|
||||
if not field.d_key.replace('_', '').isalnum():
|
||||
raise RuntimeError(
|
||||
f'Child field "{field.d_key}" under {cls}'
|
||||
f' contains invalid characters; only alphanumeric'
|
||||
f' and underscore allowed.')
|
||||
if field.d_key in field_keys:
|
||||
raise RuntimeError('Multiple child fields with key'
|
||||
f' "{field.d_key}" found in {cls}')
|
||||
field_keys.add(field.d_key)
|
||||
130
assets/src/data/scripts/bafoundation/entity/util.py
Normal file
130
assets/src/data/scripts/bafoundation/entity/util.py
Normal file
@ -0,0 +1,130 @@
|
||||
# Synced from bamaster.
|
||||
# EFRO_SYNC_HASH=151238242547824871848833808259117588767
|
||||
#
|
||||
"""Misc utility functionality related to the entity system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Union, Tuple, List
|
||||
from bafoundation.entity._value import CompoundValue
|
||||
from bafoundation.entity._support import BoundCompoundValue
|
||||
|
||||
|
||||
def diff_compound_values(obj1: Union[BoundCompoundValue, CompoundValue],
|
||||
obj2: Union[BoundCompoundValue, CompoundValue]
|
||||
) -> str:
|
||||
"""Generate a string showing differences between two compound values.
|
||||
|
||||
Both must be associated with data and have the same set of fields.
|
||||
"""
|
||||
|
||||
# Ensure fields match and both are attached to data...
|
||||
value1, data1 = get_compound_value_and_data(obj1)
|
||||
if data1 is None:
|
||||
raise ValueError(f'Invalid unbound compound value: {obj1}')
|
||||
value2, data2 = get_compound_value_and_data(obj2)
|
||||
if data2 is None:
|
||||
raise ValueError(f'Invalid unbound compound value: {obj2}')
|
||||
if not have_matching_fields(value1, value2):
|
||||
raise ValueError(
|
||||
f"Can't diff objs with non-matching fields: {value1} and {value2}")
|
||||
|
||||
# Ok; let 'er rip...
|
||||
diff = _diff(obj1, obj2, 2)
|
||||
return ' <no differences>' if diff == '' else diff
|
||||
|
||||
|
||||
class CompoundValueDiff:
|
||||
"""Wraps diff_compound_values() in an object for efficiency.
|
||||
|
||||
It is preferable to pass this to logging calls instead of the
|
||||
final diff string since the diff will never be generated if
|
||||
the associated logging level is not being emitted.
|
||||
"""
|
||||
|
||||
def __init__(self, obj1: Union[BoundCompoundValue, CompoundValue],
|
||||
obj2: Union[BoundCompoundValue, CompoundValue]):
|
||||
self._obj1 = obj1
|
||||
self._obj2 = obj2
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return diff_compound_values(self._obj1, self._obj2)
|
||||
|
||||
|
||||
def _diff(obj1: Union[BoundCompoundValue, CompoundValue],
|
||||
obj2: Union[BoundCompoundValue, CompoundValue], indent: int) -> str:
|
||||
from bafoundation.entity._support import BoundCompoundValue
|
||||
bits: List[str] = []
|
||||
indentstr = ' ' * indent
|
||||
vobj1, _data1 = get_compound_value_and_data(obj1)
|
||||
fields = sorted(vobj1.get_fields().keys())
|
||||
for field in fields:
|
||||
val1 = getattr(obj1, field)
|
||||
val2 = getattr(obj2, field)
|
||||
# for nested compounds, dive in and do nice piecewise compares
|
||||
if isinstance(val1, BoundCompoundValue):
|
||||
assert isinstance(val2, BoundCompoundValue)
|
||||
diff = _diff(val1, val2, indent + 2)
|
||||
if diff != '':
|
||||
bits.append(f'{indentstr}{field}:')
|
||||
bits.append(diff)
|
||||
# for all else just do a single line
|
||||
# (perhaps we could improve on this for other complex types)
|
||||
else:
|
||||
if val1 != val2:
|
||||
bits.append(f'{indentstr}{field}: {val1} -> {val2}')
|
||||
return '\n'.join(bits)
|
||||
|
||||
|
||||
def have_matching_fields(val1: CompoundValue, val2: CompoundValue) -> bool:
|
||||
"""Return whether two compound-values have matching sets of fields.
|
||||
|
||||
Note this just refers to the field configuration; not data.
|
||||
"""
|
||||
# quick-out: matching types will always have identical fields
|
||||
if type(val1) is type(val2):
|
||||
return True
|
||||
# otherwise do a full comparision
|
||||
return val1.get_fields() == val2.get_fields()
|
||||
|
||||
|
||||
def get_compound_value_and_data(obj: Union[BoundCompoundValue, CompoundValue]
|
||||
) -> Tuple[CompoundValue, Any]:
|
||||
"""Return value and data for bound or unbound compound values."""
|
||||
# pylint: disable=cyclic-import
|
||||
from bafoundation.entity._support import BoundCompoundValue
|
||||
from bafoundation.entity._value import CompoundValue
|
||||
if isinstance(obj, BoundCompoundValue):
|
||||
value = obj.d_value
|
||||
data = obj.d_data
|
||||
elif isinstance(obj, CompoundValue):
|
||||
value = obj
|
||||
data = getattr(obj, 'd_data', None) # may not exist
|
||||
else:
|
||||
raise TypeError(
|
||||
f'Expected a BoundCompoundValue or CompoundValue; got {type(obj)}')
|
||||
return value, data
|
||||
|
||||
|
||||
def compound_eq(obj1: Union[BoundCompoundValue, CompoundValue],
|
||||
obj2: Union[BoundCompoundValue, CompoundValue]) -> Any:
|
||||
"""Compare two compound value/bound-value objects for equality."""
|
||||
|
||||
# Criteria for comparison: both need to be a compound value
|
||||
# and both must have data (which implies they are either a entity
|
||||
# or bound to a subfield in a entity).
|
||||
value1, data1 = get_compound_value_and_data(obj1)
|
||||
if data1 is None:
|
||||
return NotImplemented
|
||||
value2, data2 = get_compound_value_and_data(obj2)
|
||||
if data2 is None:
|
||||
return NotImplemented
|
||||
|
||||
# Ok we can compare them. To consider them equal we look for
|
||||
# matching sets of fields and matching data. Note that there
|
||||
# could be unbound data causing inequality despite their field
|
||||
# values all matching; not sure if that's what we want.
|
||||
return have_matching_fields(value1, value2) and data1 == data2
|
||||
18
assets/src/data/scripts/bafoundation/err.py
Normal file
18
assets/src/data/scripts/bafoundation/err.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Synced from bamaster.
|
||||
# EFRO_SYNC_HASH=324606719817436157254454259763962378663
|
||||
#
|
||||
"""Error related functionality shared between all ba components."""
|
||||
|
||||
# Hmmmm - need to give this exception structure some thought...
|
||||
|
||||
|
||||
class CommunicationError(Exception):
|
||||
"""A communication-related error occurred."""
|
||||
|
||||
|
||||
class RemoteError(Exception):
|
||||
"""An error occurred on the other end of some connection."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
s = ''.join(str(arg) for arg in self.args) # pylint: disable=E1133
|
||||
return f'Remote Exception Follows:\n{s}'
|
||||
48
assets/src/data/scripts/bafoundation/executils.py
Normal file
48
assets/src/data/scripts/bafoundation/executils.py
Normal file
@ -0,0 +1,48 @@
|
||||
# Synced from bamaster.
|
||||
# EFRO_SYNC_HASH=43697789967751346220367938882574464737
|
||||
#
|
||||
"""Exec related functionality shared between all ba components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypeVar, Generic, Callable, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
T = TypeVar('T', bound=Callable)
|
||||
|
||||
|
||||
class _CallbackCall(Generic[T]):
|
||||
"""Descriptor for exposing a call with a type defined by a TypeVar."""
|
||||
|
||||
def __get__(self, obj: Any, type_in: Any = None) -> T:
|
||||
return cast(T, None)
|
||||
|
||||
|
||||
class CallbackSet(Generic[T]):
|
||||
"""Wrangles callbacks for a particular event."""
|
||||
|
||||
# In the type-checker's eyes, our 'run' attr is a CallbackCall which
|
||||
# returns a callable with the type we were created with. This lets us
|
||||
# type-check our run calls. (Is there another way to expose a function
|
||||
# with a signature defined by a generic?..)
|
||||
# At runtime, run() simply passes its args verbatim to its registered
|
||||
# callbacks; no types are checked.
|
||||
if TYPE_CHECKING:
|
||||
run: _CallbackCall[T] = _CallbackCall()
|
||||
else:
|
||||
|
||||
def run(self, *args, **keywds):
|
||||
"""Run all callbacks."""
|
||||
print("HELLO FROM RUN", *args, **keywds)
|
||||
|
||||
def __init__(self) -> None:
|
||||
print("CallbackSet()")
|
||||
|
||||
def __del__(self) -> None:
|
||||
print("~CallbackSet()")
|
||||
|
||||
def add(self, call: T) -> None:
|
||||
"""Add a callback to be run."""
|
||||
print("Would add call", call)
|
||||
74
assets/src/data/scripts/bafoundation/jsonutils.py
Normal file
74
assets/src/data/scripts/bafoundation/jsonutils.py
Normal file
@ -0,0 +1,74 @@
|
||||
# Synced from bsmaster.
|
||||
# EFRO_SYNC_HASH=303140082733449378022422119719823943963
|
||||
#
|
||||
"""Custom json compressor/decompressor with support for more data times/etc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
# Special attr we included for our extended type information
|
||||
# (extended-json-type)
|
||||
TYPE_TAG = '_xjtp'
|
||||
|
||||
_pytz_utc: Any
|
||||
|
||||
# We don't *require* pytz since it must be installed through pip
|
||||
# but it is used by firestore client for its utc tzinfos.
|
||||
# (in which case it should be installed as a dependency anyway)
|
||||
try:
|
||||
import pytz
|
||||
_pytz_utc = pytz.utc # pylint: disable=invalid-name
|
||||
except ModuleNotFoundError:
|
||||
_pytz_utc = None # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class ExtendedJSONEncoder(json.JSONEncoder):
|
||||
"""Custom json encoder supporting additional types."""
|
||||
|
||||
def default(self, obj: Any) -> Any: # pylint: disable=E0202, W0221
|
||||
if isinstance(obj, datetime.datetime):
|
||||
|
||||
# We only support timezone-aware utc times.
|
||||
if (obj.tzinfo is not datetime.timezone.utc
|
||||
and (_pytz_utc is None or obj.tzinfo is not _pytz_utc)):
|
||||
raise ValueError(
|
||||
'datetime values must have timezone set as timezone.utc')
|
||||
return {
|
||||
TYPE_TAG:
|
||||
"dt",
|
||||
"v": [
|
||||
obj.year, obj.month, obj.day, obj.hour, obj.minute,
|
||||
obj.second, obj.microsecond
|
||||
],
|
||||
}
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
class ExtendedJSONDecoder(json.JSONDecoder):
|
||||
"""Custom json decoder supporting extended types."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any):
|
||||
json.JSONDecoder.__init__( # type: ignore
|
||||
self,
|
||||
object_hook=self.object_hook,
|
||||
*args,
|
||||
**kwargs)
|
||||
|
||||
def object_hook(self, obj: Any) -> Any: # pylint: disable=E0202
|
||||
"""Custom hook."""
|
||||
if TYPE_TAG not in obj:
|
||||
return obj
|
||||
objtype = obj[TYPE_TAG]
|
||||
if objtype == 'dt':
|
||||
vals = obj.get('v', [])
|
||||
if len(vals) != 7:
|
||||
raise ValueError("malformed datetime value")
|
||||
return datetime.datetime( # type: ignore
|
||||
*vals, tzinfo=datetime.timezone.utc)
|
||||
return obj
|
||||
232
assets/src/data/scripts/bafoundation/util.py
Normal file
232
assets/src/data/scripts/bafoundation/util.py
Normal file
@ -0,0 +1,232 @@
|
||||
# Synced from bsmaster.
|
||||
# EFRO_SYNC_HASH=15008988795367952822112128932296326511
|
||||
#
|
||||
"""Small handy bits of functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import time
|
||||
from typing import TYPE_CHECKING, cast, TypeVar, Generic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncio
|
||||
from typing import Any, Dict, Callable, Optional
|
||||
|
||||
TVAL = TypeVar('TVAL')
|
||||
TARG = TypeVar('TARG')
|
||||
TRET = TypeVar('TRET')
|
||||
|
||||
|
||||
def utc_now() -> datetime.datetime:
|
||||
"""Get offset-aware current utc time."""
|
||||
return datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
|
||||
class DispatchMethodWrapper(Generic[TARG, TRET]):
|
||||
"""Type-aware standin for the dispatch func returned by dispatchmethod."""
|
||||
|
||||
def __call__(self, arg: TARG) -> TRET:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def register(func: Callable[[Any, Any], TRET]) -> Callable:
|
||||
"""Register a new dispatch handler for this dispatch-method."""
|
||||
|
||||
registry: Dict[Any, Callable]
|
||||
|
||||
|
||||
# noinspection PyTypeHints, PyProtectedMember
|
||||
def dispatchmethod(func: Callable[[Any, TARG], TRET]
|
||||
) -> DispatchMethodWrapper[TARG, TRET]:
|
||||
"""A variation of functools.singledispatch for methods."""
|
||||
from functools import singledispatch, update_wrapper
|
||||
origwrapper: Any = singledispatch(func)
|
||||
|
||||
# Pull this out so hopefully origwrapper can die,
|
||||
# otherwise we reference origwrapper in our wrapper.
|
||||
dispatch = origwrapper.dispatch
|
||||
|
||||
# All we do here is recreate the end of functools.singledispatch
|
||||
# where it returns a wrapper except instead of the wrapper using the
|
||||
# first arg to the function ours uses the second (to skip 'self').
|
||||
# This was made with Python 3.7; we should probably check up on
|
||||
# this in later versions in case anything has changed.
|
||||
# (or hopefully they'll add this functionality to their version)
|
||||
def wrapper(*args: Any, **kw: Any) -> Any:
|
||||
if not args or len(args) < 2:
|
||||
raise TypeError(f'{funcname} requires at least '
|
||||
'2 positional arguments')
|
||||
|
||||
return dispatch(args[1].__class__)(*args, **kw)
|
||||
|
||||
funcname = getattr(func, '__name__', 'dispatchmethod method')
|
||||
wrapper.register = origwrapper.register # type: ignore
|
||||
wrapper.dispatch = dispatch # type: ignore
|
||||
wrapper.registry = origwrapper.registry # type: ignore
|
||||
# pylint: disable=protected-access
|
||||
wrapper._clear_cache = origwrapper._clear_cache # type: ignore
|
||||
update_wrapper(wrapper, func)
|
||||
# pylint: enable=protected-access
|
||||
return cast(DispatchMethodWrapper, wrapper)
|
||||
|
||||
|
||||
class DirtyBit:
|
||||
"""Manages whether a thing is dirty and regulates attempts to clean it.
|
||||
|
||||
To use, simply set the 'dirty' value on this object to True when some
|
||||
action is needed, and then check the 'should_update' value to regulate
|
||||
when attempts to clean it should be made. Set 'dirty' back to False after
|
||||
a successful update.
|
||||
If 'use_lock' is True, an asyncio Lock will be created and incorporated
|
||||
into update attempts to prevent simultaneous updates (should_update will
|
||||
only return True when the lock is unlocked). Note that It is up to the user
|
||||
to lock/unlock the lock during the actual update attempt.
|
||||
If a value is passed for 'auto_dirty_seconds', the dirtybit will flip
|
||||
itself back to dirty after being clean for the given amount of time.
|
||||
'min_update_interval' can be used to enforce a minimum update
|
||||
interval even when updates are successful (retry_interval only applies
|
||||
when updates fail)
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
dirty: bool = False,
|
||||
retry_interval: float = 5.0,
|
||||
use_lock: bool = False,
|
||||
auto_dirty_seconds: float = None,
|
||||
min_update_interval: Optional[float] = None):
|
||||
curtime = time.time()
|
||||
self._retry_interval = retry_interval
|
||||
self._auto_dirty_seconds = auto_dirty_seconds
|
||||
self._min_update_interval = min_update_interval
|
||||
self._dirty = dirty
|
||||
self._next_update_time: Optional[float] = (curtime if dirty else None)
|
||||
self._last_update_time: Optional[float] = None
|
||||
self._next_auto_dirty_time: Optional[float] = (
|
||||
(curtime + self._auto_dirty_seconds) if
|
||||
(not dirty and self._auto_dirty_seconds is not None) else None)
|
||||
self._use_lock = use_lock
|
||||
self.lock: asyncio.Lock
|
||||
if self._use_lock:
|
||||
import asyncio
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def dirty(self) -> bool:
|
||||
"""Whether the target is currently dirty.
|
||||
|
||||
This should be set to False once an update is successful.
|
||||
"""
|
||||
return self._dirty
|
||||
|
||||
@dirty.setter
|
||||
def dirty(self, value: bool) -> None:
|
||||
|
||||
# If we're freshly clean, set our next auto-dirty time (if we have
|
||||
# one).
|
||||
if self._dirty and not value and self._auto_dirty_seconds is not None:
|
||||
self._next_auto_dirty_time = time.time() + self._auto_dirty_seconds
|
||||
|
||||
# If we're freshly dirty, schedule an immediate update.
|
||||
if not self._dirty and value:
|
||||
self._next_update_time = time.time()
|
||||
|
||||
# If they want to enforce a minimum update interval,
|
||||
# push out the next update time if it hasn't been long enough.
|
||||
if (self._min_update_interval is not None
|
||||
and self._last_update_time is not None):
|
||||
self._next_update_time = max(
|
||||
self._next_update_time,
|
||||
self._last_update_time + self._min_update_interval)
|
||||
|
||||
self._dirty = value
|
||||
|
||||
@property
|
||||
def should_update(self) -> bool:
|
||||
"""Whether an attempt should be made to clean the target now.
|
||||
|
||||
Always returns False if the target is not dirty.
|
||||
Takes into account the amount of time passed since the target
|
||||
was marked dirty or since should_update last returned True.
|
||||
"""
|
||||
curtime = time.time()
|
||||
|
||||
# Auto-dirty ourself if we're into that.
|
||||
if (self._next_auto_dirty_time is not None
|
||||
and curtime > self._next_auto_dirty_time):
|
||||
self.dirty = True
|
||||
self._next_auto_dirty_time = None
|
||||
if not self._dirty:
|
||||
return False
|
||||
if self._use_lock and self.lock.locked():
|
||||
return False
|
||||
assert self._next_update_time is not None
|
||||
if curtime > self._next_update_time:
|
||||
self._next_update_time = curtime + self._retry_interval
|
||||
self._last_update_time = curtime
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def valuedispatch(call: Callable[[TVAL], TRET]) -> ValueDispatcher[TVAL, TRET]:
|
||||
"""Decorator for functions to allow dispatching based on a value.
|
||||
|
||||
The 'register' method of a value-dispatch function can be used
|
||||
to assign new functions to handle particular values.
|
||||
Unhandled values wind up in the original dispatch function."""
|
||||
return ValueDispatcher(call)
|
||||
|
||||
|
||||
class ValueDispatcher(Generic[TVAL, TRET]):
|
||||
"""Used by the valuedispatch decorator"""
|
||||
|
||||
def __init__(self, call: Callable[[TVAL], TRET]) -> None:
|
||||
self._base_call = call
|
||||
self._handlers: Dict[TVAL, Callable[[], TRET]] = {}
|
||||
|
||||
def __call__(self, value: TVAL) -> TRET:
|
||||
handler = self._handlers.get(value)
|
||||
if handler is not None:
|
||||
return handler()
|
||||
return self._base_call(value)
|
||||
|
||||
def _add_handler(self, value: TVAL, call: Callable[[], TRET]) -> None:
|
||||
if value in self._handlers:
|
||||
raise RuntimeError(f'Duplicate handlers added for {value}')
|
||||
self._handlers[value] = call
|
||||
|
||||
def register(self, value: TVAL) -> Callable[[Callable[[], TRET]], None]:
|
||||
"""Add a handler to the dispatcher."""
|
||||
from functools import partial
|
||||
return partial(self._add_handler, value)
|
||||
|
||||
|
||||
def valuedispatch1arg(call: Callable[[TVAL, TARG], TRET]
|
||||
) -> ValueDispatcher1Arg[TVAL, TARG, TRET]:
|
||||
"""Like valuedispatch but for functions taking an extra argument."""
|
||||
return ValueDispatcher1Arg(call)
|
||||
|
||||
|
||||
class ValueDispatcher1Arg(Generic[TVAL, TARG, TRET]):
|
||||
"""Used by the valuedispatch1arg decorator"""
|
||||
|
||||
def __init__(self, call: Callable[[TVAL, TARG], TRET]) -> None:
|
||||
self._base_call = call
|
||||
self._handlers: Dict[TVAL, Callable[[TARG], TRET]] = {}
|
||||
|
||||
def __call__(self, value: TVAL, arg: TARG) -> TRET:
|
||||
handler = self._handlers.get(value)
|
||||
if handler is not None:
|
||||
return handler(arg)
|
||||
return self._base_call(value, arg)
|
||||
|
||||
def _add_handler(self, value: TVAL, call: Callable[[TARG], TRET]) -> None:
|
||||
if value in self._handlers:
|
||||
raise RuntimeError(f'Duplicate handlers added for {value}')
|
||||
self._handlers[value] = call
|
||||
|
||||
def register(self,
|
||||
value: TVAL) -> Callable[[Callable[[TARG], TRET]], None]:
|
||||
"""Add a handler to the dispatcher."""
|
||||
from functools import partial
|
||||
return partial(self._add_handler, value)
|
||||
3
assets/src/data/scripts/bastd/__init__.py
Normal file
3
assets/src/data/scripts/bastd/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""BallisticaCore standard library: games, UI, etc."""
|
||||
|
||||
# bs_meta require api 6
|
||||
0
assets/src/data/scripts/bastd/activity/__init__.py
Normal file
0
assets/src/data/scripts/bastd/activity/__init__.py
Normal file
183
assets/src/data/scripts/bastd/activity/coopjoinscreen.py
Normal file
183
assets/src/data/scripts/bastd/activity/coopjoinscreen.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""Functionality related to the co-op join screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
import ba
|
||||
from ba.internal import JoiningActivity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict, List, Optional, Sequence, Union
|
||||
|
||||
|
||||
class CoopJoiningActivity(JoiningActivity):
|
||||
"""Join-screen for co-op mode."""
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings)
|
||||
session = ba.getsession()
|
||||
|
||||
# Let's show a list of scores-to-beat for 1 player at least.
|
||||
assert session.campaign is not None
|
||||
level_name_full = (session.campaign.name + ":" +
|
||||
session.campaign_state['level'])
|
||||
config_str = (
|
||||
"1p" + session.campaign.get_level(session.campaign_state['level']).
|
||||
get_score_version_string().replace(' ', '_'))
|
||||
_ba.get_scores_to_beat(level_name_full, config_str,
|
||||
ba.WeakCall(self._on_got_scores_to_beat))
|
||||
|
||||
def on_transition_in(self) -> None:
|
||||
from bastd.actor.controlsguide import ControlsGuide
|
||||
from bastd.actor.text import Text
|
||||
super().on_transition_in()
|
||||
assert self.session.campaign
|
||||
Text(self.session.campaign.get_level(
|
||||
self.session.campaign_state['level']).displayname,
|
||||
scale=1.3,
|
||||
h_attach='center',
|
||||
h_align='center',
|
||||
v_attach='top',
|
||||
transition='fade_in',
|
||||
transition_delay=4.0,
|
||||
color=(1, 1, 1, 0.6),
|
||||
position=(0, -95)).autoretain()
|
||||
ControlsGuide(delay=1.0).autoretain()
|
||||
|
||||
def _on_got_scores_to_beat(self,
|
||||
scores: Optional[List[Dict[str, Any]]]) -> None:
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-statements
|
||||
from bastd.actor.text import Text
|
||||
from ba.internal import get_achievements_for_coop_level
|
||||
|
||||
# Sort by originating date so that the most recent is first.
|
||||
if scores is not None:
|
||||
scores.sort(reverse=True, key=lambda score: score['time'])
|
||||
|
||||
# We only show achievements and challenges for CoopGameActivities.
|
||||
session = self.session
|
||||
assert isinstance(session, ba.CoopSession)
|
||||
gameinstance = session.get_current_game_instance()
|
||||
if isinstance(gameinstance, ba.CoopGameActivity):
|
||||
score_type = gameinstance.get_score_type()
|
||||
if scores is not None:
|
||||
achievement_challenges = [
|
||||
a for a in scores if a['type'] == 'achievement_challenge'
|
||||
]
|
||||
score_challenges = [
|
||||
a for a in scores if a['type'] == 'score_challenge'
|
||||
]
|
||||
else:
|
||||
achievement_challenges = score_challenges = []
|
||||
|
||||
delay = 1.0
|
||||
vpos = -140.0
|
||||
spacing = 25
|
||||
delay_inc = 0.1
|
||||
|
||||
def _add_t(text: Union[str, ba.Lstr],
|
||||
h_offs: float = 0.0,
|
||||
scale: float = 1.0,
|
||||
color: Sequence[float] = (1.0, 1.0, 1.0, 0.46)) -> None:
|
||||
Text(text,
|
||||
scale=scale * 0.76,
|
||||
h_align='left',
|
||||
h_attach='left',
|
||||
v_attach='top',
|
||||
transition='fade_in',
|
||||
transition_delay=delay,
|
||||
color=color,
|
||||
position=(60 + h_offs, vpos)).autoretain()
|
||||
|
||||
if score_challenges:
|
||||
_add_t(ba.Lstr(value='${A}:',
|
||||
subs=[('${A}',
|
||||
ba.Lstr(resource='scoreChallengesText'))
|
||||
]),
|
||||
scale=1.1)
|
||||
delay += delay_inc
|
||||
vpos -= spacing
|
||||
for chal in score_challenges:
|
||||
_add_t(str(chal['value'] if score_type == 'points' else ba.
|
||||
timestring(int(chal['value']) * 10,
|
||||
timeformat=ba.TimeFormat.MILLISECONDS
|
||||
).evaluate()) + ' (1 player)',
|
||||
h_offs=30,
|
||||
color=(0.9, 0.7, 1.0, 0.8))
|
||||
delay += delay_inc
|
||||
vpos -= 0.6 * spacing
|
||||
_add_t(chal['player'],
|
||||
h_offs=40,
|
||||
color=(0.8, 1, 0.8, 0.6),
|
||||
scale=0.8)
|
||||
delay += delay_inc
|
||||
vpos -= 1.2 * spacing
|
||||
vpos -= 0.5 * spacing
|
||||
|
||||
if achievement_challenges:
|
||||
_add_t(ba.Lstr(
|
||||
value='${A}:',
|
||||
subs=[('${A}',
|
||||
ba.Lstr(resource='achievementChallengesText'))]),
|
||||
scale=1.1)
|
||||
delay += delay_inc
|
||||
vpos -= spacing
|
||||
for chal in achievement_challenges:
|
||||
_add_t(str(chal['value']),
|
||||
h_offs=30,
|
||||
color=(0.9, 0.7, 1.0, 0.8))
|
||||
delay += delay_inc
|
||||
vpos -= 0.6 * spacing
|
||||
_add_t(chal['player'],
|
||||
h_offs=40,
|
||||
color=(0.8, 1, 0.8, 0.6),
|
||||
scale=0.8)
|
||||
delay += delay_inc
|
||||
vpos -= 1.2 * spacing
|
||||
vpos -= 0.5 * spacing
|
||||
|
||||
# Now list our remaining achievements for this level.
|
||||
assert self.session.campaign is not None
|
||||
levelname = (self.session.campaign.name + ":" +
|
||||
self.session.campaign_state['level'])
|
||||
ts_h_offs = 60
|
||||
|
||||
if not ba.app.kiosk_mode:
|
||||
achievements = [
|
||||
a for a in get_achievements_for_coop_level(levelname)
|
||||
if not a.complete
|
||||
]
|
||||
have_achievements = bool(achievements)
|
||||
achievements = [a for a in achievements if not a.complete]
|
||||
vrmode = ba.app.vr_mode
|
||||
if have_achievements:
|
||||
Text(ba.Lstr(resource='achievementsRemainingText'),
|
||||
host_only=True,
|
||||
position=(ts_h_offs - 10, vpos),
|
||||
transition='fade_in',
|
||||
scale=1.1 * 0.76,
|
||||
h_attach="left",
|
||||
v_attach="top",
|
||||
color=(1, 1, 1.2, 1) if vrmode else (0.8, 0.8, 1, 1),
|
||||
shadow=1.0,
|
||||
flatness=1.0 if vrmode else 0.6,
|
||||
transition_delay=delay).autoretain()
|
||||
hval = ts_h_offs + 50
|
||||
vpos -= 35
|
||||
for ach in achievements:
|
||||
delay += 0.05
|
||||
ach.create_display(hval, vpos, delay, style='in_game')
|
||||
vpos -= 55
|
||||
if not achievements:
|
||||
Text(ba.Lstr(resource='noAchievementsRemainingText'),
|
||||
host_only=True,
|
||||
position=(ts_h_offs + 15, vpos + 10),
|
||||
transition='fade_in',
|
||||
scale=0.7,
|
||||
h_attach="left",
|
||||
v_attach="top",
|
||||
color=(1, 1, 1, 0.5),
|
||||
transition_delay=delay + 0.5).autoretain()
|
||||
1454
assets/src/data/scripts/bastd/activity/coopscorescreen.py
Normal file
1454
assets/src/data/scripts/bastd/activity/coopscorescreen.py
Normal file
File diff suppressed because it is too large
Load Diff
42
assets/src/data/scripts/bastd/activity/drawscreen.py
Normal file
42
assets/src/data/scripts/bastd/activity/drawscreen.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Functionality related to the draw screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
from bastd.activity.teamsscorescreen import TeamsScoreScreenActivity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class DrawScoreScreenActivity(TeamsScoreScreenActivity):
|
||||
"""Score screen shown after a draw."""
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings=settings)
|
||||
|
||||
# noinspection PyMethodOverriding
|
||||
def on_transition_in(self) -> None: # type: ignore
|
||||
# FIXME FIXME: unify args
|
||||
# pylint: disable=arguments-differ
|
||||
super().on_transition_in(music=None)
|
||||
|
||||
# noinspection PyMethodOverriding
|
||||
def on_begin(self) -> None: # type: ignore
|
||||
# FIXME FIXME: unify args
|
||||
# pylint: disable=arguments-differ
|
||||
from bastd.actor.zoomtext import ZoomText
|
||||
ba.set_analytics_screen('Draw Score Screen')
|
||||
super().on_begin()
|
||||
ZoomText(ba.Lstr(resource='drawText'),
|
||||
position=(0, 0),
|
||||
maxwidth=400,
|
||||
shiftposition=(-220, 0),
|
||||
shiftdelay=2.0,
|
||||
flash=False,
|
||||
trail=False,
|
||||
jitter=1.0).autoretain()
|
||||
ba.timer(0.35, ba.Call(ba.playsound, self._score_display_sound))
|
||||
self.show_player_scores(results=self.settings.get('results', None))
|
||||
126
assets/src/data/scripts/bastd/activity/dualteamscorescreen.py
Normal file
126
assets/src/data/scripts/bastd/activity/dualteamscorescreen.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""Functionality related to the end screen in dual-team mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
from bastd.activity.teamsscorescreen import TeamsScoreScreenActivity
|
||||
from bastd.actor.zoomtext import ZoomText
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class TeamVictoryScoreScreenActivity(TeamsScoreScreenActivity):
|
||||
"""Scorescreen between rounds of a dual-team session."""
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings=settings)
|
||||
|
||||
# noinspection PyMethodOverriding
|
||||
def on_begin(self) -> None: # type: ignore
|
||||
# FIXME: Unify args.
|
||||
# pylint: disable=arguments-differ
|
||||
from ba.deprecated import get_resource
|
||||
ba.set_analytics_screen('Teams Score Screen')
|
||||
super().on_begin()
|
||||
|
||||
height = 130
|
||||
active_team_count = len(self.teams)
|
||||
vval = (height * active_team_count) / 2 - height / 2
|
||||
i = 0
|
||||
shift_time = 2.5
|
||||
|
||||
# Usually we say 'Best of 7', but if the language prefers we can say
|
||||
# 'First to 4'.
|
||||
session = self.session
|
||||
assert isinstance(session, ba.TeamBaseSession)
|
||||
if get_resource('bestOfUseFirstToInstead'):
|
||||
best_txt = ba.Lstr(resource='firstToSeriesText',
|
||||
subs=[('${COUNT}',
|
||||
str(session.get_series_length() / 2 + 1))
|
||||
])
|
||||
else:
|
||||
best_txt = ba.Lstr(resource='bestOfSeriesText',
|
||||
subs=[('${COUNT}',
|
||||
str(session.get_series_length()))])
|
||||
|
||||
ZoomText(best_txt,
|
||||
position=(0, 175),
|
||||
shiftposition=(-250, 175),
|
||||
shiftdelay=2.5,
|
||||
flash=False,
|
||||
trail=False,
|
||||
h_align='center',
|
||||
scale=0.25,
|
||||
color=(0.5, 0.5, 0.5, 1.0),
|
||||
jitter=3.0).autoretain()
|
||||
for team in self.teams:
|
||||
ba.timer(
|
||||
i * 0.15 + 0.15,
|
||||
ba.WeakCall(self._show_team_name, vval - i * height, team,
|
||||
i * 0.2, shift_time - (i * 0.150 + 0.150)))
|
||||
ba.timer(i * 0.150 + 0.5,
|
||||
ba.Call(ba.playsound, self._score_display_sound_small))
|
||||
scored = (team is self.settings['winner'])
|
||||
delay = 0.2
|
||||
if scored:
|
||||
delay = 1.2
|
||||
ba.timer(
|
||||
i * 0.150 + 0.2,
|
||||
ba.WeakCall(self._show_team_old_score, vval - i * height,
|
||||
team, shift_time - (i * 0.15 + 0.2)))
|
||||
ba.timer(i * 0.15 + 1.5,
|
||||
ba.Call(ba.playsound, self._score_display_sound))
|
||||
|
||||
ba.timer(
|
||||
i * 0.150 + delay,
|
||||
ba.WeakCall(self._show_team_score, vval - i * height, team,
|
||||
scored, i * 0.2 + 0.1,
|
||||
shift_time - (i * 0.15 + delay)))
|
||||
i += 1
|
||||
self.show_player_scores()
|
||||
|
||||
def _show_team_name(self, pos_v: float, team: ba.Team, kill_delay: float,
|
||||
shiftdelay: float) -> None:
|
||||
del kill_delay # unused arg
|
||||
ZoomText(ba.Lstr(value='${A}:', subs=[('${A}', team.name)]),
|
||||
position=(100, pos_v),
|
||||
shiftposition=(-150, pos_v),
|
||||
shiftdelay=shiftdelay,
|
||||
flash=False,
|
||||
trail=False,
|
||||
h_align='right',
|
||||
maxwidth=300,
|
||||
color=team.color,
|
||||
jitter=1.0).autoretain()
|
||||
|
||||
def _show_team_old_score(self, pos_v: float, team: ba.Team,
|
||||
shiftdelay: float) -> None:
|
||||
ZoomText(str(team.sessiondata['score'] - 1),
|
||||
position=(150, pos_v),
|
||||
maxwidth=100,
|
||||
color=(0.6, 0.6, 0.7),
|
||||
shiftposition=(-100, pos_v),
|
||||
shiftdelay=shiftdelay,
|
||||
flash=False,
|
||||
trail=False,
|
||||
lifespan=1.0,
|
||||
h_align='left',
|
||||
jitter=1.0).autoretain()
|
||||
|
||||
def _show_team_score(self, pos_v: float, team: ba.Team, scored: bool,
|
||||
kill_delay: float, shiftdelay: float) -> None:
|
||||
del kill_delay # unused arg
|
||||
ZoomText(str(team.sessiondata['score']),
|
||||
position=(150, pos_v),
|
||||
maxwidth=100,
|
||||
color=(1.0, 0.9, 0.5) if scored else (0.6, 0.6, 0.7),
|
||||
shiftposition=(-100, pos_v),
|
||||
shiftdelay=shiftdelay,
|
||||
flash=scored,
|
||||
trail=scored,
|
||||
h_align='left',
|
||||
jitter=1.0,
|
||||
trailcolor=(1, 0.8, 0.0, 0)).autoretain()
|
||||
258
assets/src/data/scripts/bastd/activity/freeforallendscreen.py
Normal file
258
assets/src/data/scripts/bastd/activity/freeforallendscreen.py
Normal file
@ -0,0 +1,258 @@
|
||||
"""Functionality related to the final screen in free-for-all games."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
from bastd.activity.teamsscorescreen import TeamsScoreScreenActivity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
|
||||
class FreeForAllVictoryScoreScreenActivity(TeamsScoreScreenActivity):
|
||||
"""Score screen shown at the end of a free-for-all series."""
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings=settings)
|
||||
# keeps prev activity alive while we fade in
|
||||
self.transition_time = 0.5
|
||||
self._cymbal_sound = ba.getsound('cymbal')
|
||||
|
||||
# noinspection PyMethodOverriding
|
||||
def on_begin(self) -> None: # type: ignore
|
||||
# FIXME FIXME unify args
|
||||
# pylint: disable=arguments-differ
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-locals
|
||||
from bastd.actor.text import Text
|
||||
from bastd.actor import image
|
||||
ba.set_analytics_screen('FreeForAll Score Screen')
|
||||
super().on_begin()
|
||||
|
||||
y_base = 100.0
|
||||
ts_h_offs = -305.0
|
||||
tdelay = 1.0
|
||||
scale = 1.2
|
||||
spacing = 37.0
|
||||
|
||||
# we include name and previous score in the sort to reduce the amount
|
||||
# of random jumping around the list we do in cases of ties
|
||||
player_order_prev = list(self.players)
|
||||
player_order_prev.sort(
|
||||
reverse=True,
|
||||
key=lambda p:
|
||||
(p.team.sessiondata['previous_score'], p.get_name(full=True)))
|
||||
player_order = list(self.players)
|
||||
player_order.sort(reverse=True,
|
||||
key=lambda p:
|
||||
(p.team.sessiondata['score'], p.team.sessiondata[
|
||||
'score'], p.get_name(full=True)))
|
||||
|
||||
v_offs = -74.0 + spacing * len(player_order_prev) * 0.5
|
||||
delay1 = 1.3 + 0.1
|
||||
delay2 = 2.9 + 0.1
|
||||
delay3 = 2.9 + 0.1
|
||||
order_change = player_order != player_order_prev
|
||||
|
||||
if order_change:
|
||||
delay3 += 1.5
|
||||
|
||||
ba.timer(0.3, ba.Call(ba.playsound, self._score_display_sound))
|
||||
self.show_player_scores(delay=0.001,
|
||||
results=self.settings['results'],
|
||||
scale=1.2,
|
||||
x_offset=-110.0)
|
||||
|
||||
sound_times: Set[float] = set()
|
||||
|
||||
def _scoretxt(text: str,
|
||||
x_offs: float,
|
||||
y_offs: float,
|
||||
highlight: bool,
|
||||
delay: float,
|
||||
extrascale: float,
|
||||
flash: bool = False) -> Text:
|
||||
return Text(text,
|
||||
position=(ts_h_offs + x_offs * scale,
|
||||
y_base + (y_offs + v_offs + 2.0) * scale),
|
||||
scale=scale * extrascale,
|
||||
color=((1.0, 0.7, 0.3, 1.0) if highlight else
|
||||
(0.7, 0.7, 0.7, 0.7)),
|
||||
h_align='right',
|
||||
transition='in_left',
|
||||
transition_delay=tdelay + delay,
|
||||
flash=flash).autoretain()
|
||||
|
||||
v_offs -= spacing
|
||||
slide_amt = 0.0
|
||||
transtime = 0.250
|
||||
transtime2 = 0.250
|
||||
|
||||
session = self.session
|
||||
assert isinstance(session, ba.FreeForAllSession)
|
||||
title = Text(ba.Lstr(resource='firstToSeriesText',
|
||||
subs=[('${COUNT}',
|
||||
str(session.get_ffa_series_length()))]),
|
||||
scale=1.05 * scale,
|
||||
position=(ts_h_offs - 0.0 * scale,
|
||||
y_base + (v_offs + 50.0) * scale),
|
||||
h_align='center',
|
||||
color=(0.5, 0.5, 0.5, 0.5),
|
||||
transition='in_left',
|
||||
transition_delay=tdelay).autoretain()
|
||||
|
||||
v_offs -= 25
|
||||
v_offs_start = v_offs
|
||||
|
||||
ba.timer(
|
||||
tdelay + delay3,
|
||||
ba.WeakCall(
|
||||
self._safe_animate, title.position_combine, 'input0', {
|
||||
0.0: ts_h_offs - 0.0 * scale,
|
||||
transtime2: ts_h_offs - (0.0 + slide_amt) * scale
|
||||
}))
|
||||
|
||||
for i, player in enumerate(player_order_prev):
|
||||
v_offs_2 = v_offs_start - spacing * (player_order.index(player))
|
||||
ba.timer(tdelay + 0.3,
|
||||
ba.Call(ba.playsound, self._score_display_sound_small))
|
||||
if order_change:
|
||||
ba.timer(tdelay + delay2 + 0.1,
|
||||
ba.Call(ba.playsound, self._cymbal_sound))
|
||||
img = image.Image(player.get_icon(),
|
||||
position=(ts_h_offs - 72.0 * scale,
|
||||
y_base + (v_offs + 15.0) * scale),
|
||||
scale=(30.0 * scale, 30.0 * scale),
|
||||
transition='in_left',
|
||||
transition_delay=tdelay).autoretain()
|
||||
ba.timer(
|
||||
tdelay + delay2,
|
||||
ba.WeakCall(
|
||||
self._safe_animate, img.position_combine, 'input1', {
|
||||
0: y_base + (v_offs + 15.0) * scale,
|
||||
transtime: y_base + (v_offs_2 + 15.0) * scale
|
||||
}))
|
||||
ba.timer(
|
||||
tdelay + delay3,
|
||||
ba.WeakCall(
|
||||
self._safe_animate, img.position_combine, 'input0', {
|
||||
0: ts_h_offs - 72.0 * scale,
|
||||
transtime2: ts_h_offs - (72.0 + slide_amt) * scale
|
||||
}))
|
||||
txt = Text(ba.Lstr(value=player.get_name(full=True)),
|
||||
maxwidth=130.0,
|
||||
scale=0.75 * scale,
|
||||
position=(ts_h_offs - 50.0 * scale,
|
||||
y_base + (v_offs + 15.0) * scale),
|
||||
h_align='left',
|
||||
v_align='center',
|
||||
color=ba.safecolor(player.team.color + (1, )),
|
||||
transition='in_left',
|
||||
transition_delay=tdelay).autoretain()
|
||||
ba.timer(
|
||||
tdelay + delay2,
|
||||
ba.WeakCall(
|
||||
self._safe_animate, txt.position_combine, 'input1', {
|
||||
0: y_base + (v_offs + 15.0) * scale,
|
||||
transtime: y_base + (v_offs_2 + 15.0) * scale
|
||||
}))
|
||||
ba.timer(
|
||||
tdelay + delay3,
|
||||
ba.WeakCall(
|
||||
self._safe_animate, txt.position_combine, 'input0', {
|
||||
0: ts_h_offs - 50.0 * scale,
|
||||
transtime2: ts_h_offs - (50.0 + slide_amt) * scale
|
||||
}))
|
||||
|
||||
txt_num = Text('#' + str(i + 1),
|
||||
scale=0.55 * scale,
|
||||
position=(ts_h_offs - 95.0 * scale,
|
||||
y_base + (v_offs + 8.0) * scale),
|
||||
h_align='right',
|
||||
color=(0.6, 0.6, 0.6, 0.6),
|
||||
transition='in_left',
|
||||
transition_delay=tdelay).autoretain()
|
||||
ba.timer(
|
||||
tdelay + delay3,
|
||||
ba.WeakCall(
|
||||
self._safe_animate, txt_num.position_combine, 'input0', {
|
||||
0: ts_h_offs - 95.0 * scale,
|
||||
transtime2: ts_h_offs - (95.0 + slide_amt) * scale
|
||||
}))
|
||||
|
||||
s_txt = _scoretxt(str(player.team.sessiondata['previous_score']),
|
||||
80, 0, False, 0, 1.0)
|
||||
ba.timer(
|
||||
tdelay + delay2,
|
||||
ba.WeakCall(
|
||||
self._safe_animate, s_txt.position_combine, 'input1', {
|
||||
0: y_base + (v_offs + 2.0) * scale,
|
||||
transtime: y_base + (v_offs_2 + 2.0) * scale
|
||||
}))
|
||||
ba.timer(
|
||||
tdelay + delay3,
|
||||
ba.WeakCall(
|
||||
self._safe_animate, s_txt.position_combine, 'input0', {
|
||||
0: ts_h_offs + 80.0 * scale,
|
||||
transtime2: ts_h_offs + (80.0 - slide_amt) * scale
|
||||
}))
|
||||
|
||||
score_change = (player.team.sessiondata['score'] -
|
||||
player.team.sessiondata['previous_score'])
|
||||
if score_change > 0:
|
||||
xval = 113
|
||||
yval = 3.0
|
||||
s_txt_2 = _scoretxt('+' + str(score_change),
|
||||
xval,
|
||||
yval,
|
||||
True,
|
||||
0,
|
||||
0.7,
|
||||
flash=True)
|
||||
ba.timer(
|
||||
tdelay + delay2,
|
||||
ba.WeakCall(
|
||||
self._safe_animate, s_txt_2.position_combine, 'input1',
|
||||
{
|
||||
0: y_base + (v_offs + yval + 2.0) * scale,
|
||||
transtime: y_base + (v_offs_2 + yval + 2.0) * scale
|
||||
}))
|
||||
ba.timer(
|
||||
tdelay + delay3,
|
||||
ba.WeakCall(
|
||||
self._safe_animate, s_txt_2.position_combine, 'input0',
|
||||
{
|
||||
0: ts_h_offs + xval * scale,
|
||||
transtime2: ts_h_offs + (xval - slide_amt) * scale
|
||||
}))
|
||||
|
||||
def _safesetattr(node: Optional[ba.Node], attr: str,
|
||||
value: Any) -> None:
|
||||
if node:
|
||||
setattr(node, attr, value)
|
||||
|
||||
ba.timer(
|
||||
tdelay + delay1,
|
||||
ba.Call(_safesetattr, s_txt.node, 'color', (1, 1, 1, 1)))
|
||||
for j in range(score_change):
|
||||
ba.timer(
|
||||
0.001 * (tdelay + delay1 + 150 * j),
|
||||
ba.Call(
|
||||
_safesetattr, s_txt.node, 'text',
|
||||
str(player.team.sessiondata['previous_score'] + j +
|
||||
1)))
|
||||
tfin = tdelay + delay1 + 150 * j
|
||||
if tfin not in sound_times:
|
||||
sound_times.add(tfin)
|
||||
ba.timer(
|
||||
tfin,
|
||||
ba.Call(ba.playsound,
|
||||
self._score_display_sound_small))
|
||||
v_offs -= spacing
|
||||
|
||||
def _safe_animate(self, node: Optional[ba.Node], attr: str,
|
||||
keys: Dict[float, float]) -> None:
|
||||
if node:
|
||||
ba.animate(node, attr, keys)
|
||||
374
assets/src/data/scripts/bastd/activity/multiteamendscreen.py
Normal file
374
assets/src/data/scripts/bastd/activity/multiteamendscreen.py
Normal file
@ -0,0 +1,374 @@
|
||||
"""Functionality related to the final screen in multi-teams sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
from bastd.activity.teamsscorescreen import TeamsScoreScreenActivity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
|
||||
|
||||
class TeamSeriesVictoryScoreScreenActivity(TeamsScoreScreenActivity):
|
||||
"""Final score screen for a team series."""
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings=settings)
|
||||
self._min_view_time = 15.0
|
||||
self._is_ffa = isinstance(self.session, ba.FreeForAllSession)
|
||||
self._allow_server_restart = True
|
||||
self._tips_text = None
|
||||
|
||||
# noinspection PyMethodOverriding
|
||||
def on_transition_in(self) -> None: # type: ignore
|
||||
# FIXME: Unify args.
|
||||
# pylint: disable=arguments-differ
|
||||
# we don't yet want music and stuff..
|
||||
super().on_transition_in(music=None, show_tips=False)
|
||||
|
||||
# noinspection PyMethodOverriding
|
||||
def on_begin(self) -> None: # type: ignore
|
||||
# FIXME FIXME: args differ
|
||||
# pylint: disable=arguments-differ
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-statements
|
||||
from bastd.actor.text import Text
|
||||
from bastd.actor.image import Image
|
||||
from ba.deprecated import get_resource
|
||||
ba.set_analytics_screen('FreeForAll Series Victory Screen' if self.
|
||||
_is_ffa else 'Teams Series Victory Screen')
|
||||
if ba.app.interface_type == 'large':
|
||||
sval = ba.Lstr(resource='pressAnyKeyButtonPlayAgainText')
|
||||
else:
|
||||
sval = ba.Lstr(resource='pressAnyButtonPlayAgainText')
|
||||
super().on_begin(show_up_next=False, custom_continue_message=sval)
|
||||
winning_team = self.settings['winner']
|
||||
|
||||
# Pause a moment before playing victory music.
|
||||
ba.timer(0.6, ba.WeakCall(self._play_victory_music))
|
||||
ba.timer(4.4, ba.WeakCall(self._show_winner, self.settings['winner']))
|
||||
ba.timer(4.6, ba.Call(ba.playsound, self._score_display_sound))
|
||||
|
||||
# Score / Name / Player-record.
|
||||
player_entries: List[Tuple[int, str, ba.PlayerRecord]] = []
|
||||
|
||||
# Note: for ffa, exclude players who haven't entered the game yet.
|
||||
if self._is_ffa:
|
||||
for _pkey, prec in self.stats.get_records().items():
|
||||
if prec.player.in_game:
|
||||
player_entries.append(
|
||||
(prec.player.team.sessiondata['score'],
|
||||
prec.get_name(full=True), prec))
|
||||
player_entries.sort(reverse=True)
|
||||
else:
|
||||
for _pkey, prec in self.stats.get_records().items():
|
||||
player_entries.append((prec.score, prec.name_full, prec))
|
||||
player_entries.sort(reverse=True)
|
||||
|
||||
ts_height = 300.0
|
||||
ts_h_offs = -390.0
|
||||
tval = 6.4
|
||||
t_incr = 0.12
|
||||
|
||||
always_use_first_to = get_resource('bestOfUseFirstToInstead')
|
||||
|
||||
session = self.session
|
||||
if self._is_ffa:
|
||||
assert isinstance(session, ba.FreeForAllSession)
|
||||
txt = ba.Lstr(
|
||||
value='${A}:',
|
||||
subs=[('${A}',
|
||||
ba.Lstr(resource='firstToFinalText',
|
||||
subs=[('${COUNT}',
|
||||
str(session.get_ffa_series_length()))]))
|
||||
])
|
||||
else:
|
||||
assert isinstance(session, ba.TeamBaseSession)
|
||||
|
||||
# Some languages may prefer to always show 'first to X' instead of
|
||||
# 'best of X'.
|
||||
# FIXME: This will affect all clients connected to us even if
|
||||
# they're not using this language. Should try to come up
|
||||
# with a wording that works everywhere.
|
||||
if always_use_first_to:
|
||||
txt = ba.Lstr(
|
||||
value='${A}:',
|
||||
subs=[
|
||||
('${A}',
|
||||
ba.Lstr(resource='firstToFinalText',
|
||||
subs=[
|
||||
('${COUNT}',
|
||||
str(session.get_series_length() / 2 + 1))
|
||||
]))
|
||||
])
|
||||
else:
|
||||
txt = ba.Lstr(
|
||||
value='${A}:',
|
||||
subs=[('${A}',
|
||||
ba.Lstr(resource='bestOfFinalText',
|
||||
subs=[('${COUNT}',
|
||||
str(session.get_series_length()))]))
|
||||
])
|
||||
|
||||
Text(txt,
|
||||
v_align='center',
|
||||
maxwidth=300,
|
||||
color=(0.5, 0.5, 0.5, 1.0),
|
||||
position=(0, 220),
|
||||
scale=1.2,
|
||||
transition='inTopSlow',
|
||||
h_align='center',
|
||||
transition_delay=t_incr * 4).autoretain()
|
||||
|
||||
win_score = (session.get_series_length() - 1) / 2 + 1
|
||||
lose_score = 0
|
||||
for team in self.teams:
|
||||
if team.sessiondata['score'] != win_score:
|
||||
lose_score = team.sessiondata['score']
|
||||
|
||||
if not self._is_ffa:
|
||||
Text(ba.Lstr(resource='gamesToText',
|
||||
subs=[('${WINCOUNT}', str(win_score)),
|
||||
('${LOSECOUNT}', str(lose_score))]),
|
||||
color=(0.5, 0.5, 0.5, 1.0),
|
||||
maxwidth=160,
|
||||
v_align='center',
|
||||
position=(0, -215),
|
||||
scale=1.8,
|
||||
transition='in_left',
|
||||
h_align='center',
|
||||
transition_delay=4.8 + t_incr * 4).autoretain()
|
||||
|
||||
if self._is_ffa:
|
||||
v_extra = 120
|
||||
else:
|
||||
v_extra = 0
|
||||
|
||||
mvp: Optional[ba.PlayerRecord] = None
|
||||
mvp_name: Optional[str] = None
|
||||
|
||||
# Show game MVP.
|
||||
if not self._is_ffa:
|
||||
mvp, mvp_name = None, None
|
||||
for entry in player_entries:
|
||||
if entry[2].team == winning_team:
|
||||
mvp = entry[2]
|
||||
mvp_name = entry[1]
|
||||
break
|
||||
if mvp is not None:
|
||||
Text(ba.Lstr(resource='mostValuablePlayerText'),
|
||||
color=(0.5, 0.5, 0.5, 1.0),
|
||||
v_align='center',
|
||||
maxwidth=300,
|
||||
position=(180, ts_height / 2 + 15),
|
||||
transition='in_left',
|
||||
h_align='left',
|
||||
transition_delay=tval).autoretain()
|
||||
tval += 4 * t_incr
|
||||
|
||||
Image(mvp.get_icon(),
|
||||
position=(230, ts_height / 2 - 55 + 14 - 5),
|
||||
scale=(70, 70),
|
||||
transition='in_left',
|
||||
transition_delay=tval).autoretain()
|
||||
Text(ba.Lstr(value=mvp_name),
|
||||
position=(280, ts_height / 2 - 55 + 15 - 5),
|
||||
h_align='left',
|
||||
v_align='center',
|
||||
maxwidth=170,
|
||||
scale=1.3,
|
||||
color=ba.safecolor(mvp.team.color + (1, )),
|
||||
transition='in_left',
|
||||
transition_delay=tval).autoretain()
|
||||
tval += 4 * t_incr
|
||||
|
||||
# Most violent.
|
||||
most_kills = 0
|
||||
for entry in player_entries:
|
||||
if entry[2].kill_count >= most_kills:
|
||||
mvp = entry[2]
|
||||
mvp_name = entry[1]
|
||||
most_kills = entry[2].kill_count
|
||||
if mvp is not None:
|
||||
Text(ba.Lstr(resource='mostViolentPlayerText'),
|
||||
color=(0.5, 0.5, 0.5, 1.0),
|
||||
v_align='center',
|
||||
maxwidth=300,
|
||||
position=(180, ts_height / 2 - 150 + v_extra + 15),
|
||||
transition='in_left',
|
||||
h_align='left',
|
||||
transition_delay=tval).autoretain()
|
||||
Text(ba.Lstr(value='(${A})',
|
||||
subs=[('${A}',
|
||||
ba.Lstr(resource='killsTallyText',
|
||||
subs=[('${COUNT}', str(most_kills))]))
|
||||
]),
|
||||
position=(260, ts_height / 2 - 150 - 15 + v_extra),
|
||||
color=(0.3, 0.3, 0.3, 1.0),
|
||||
scale=0.6,
|
||||
h_align='left',
|
||||
transition='in_left',
|
||||
transition_delay=tval).autoretain()
|
||||
tval += 4 * t_incr
|
||||
|
||||
Image(mvp.get_icon(),
|
||||
position=(233, ts_height / 2 - 150 - 30 - 46 + 25 + v_extra),
|
||||
scale=(50, 50),
|
||||
transition='in_left',
|
||||
transition_delay=tval).autoretain()
|
||||
Text(ba.Lstr(value=mvp_name),
|
||||
position=(270, ts_height / 2 - 150 - 30 - 36 + v_extra + 15),
|
||||
h_align='left',
|
||||
v_align='center',
|
||||
maxwidth=180,
|
||||
color=ba.safecolor(mvp.team.color + (1, )),
|
||||
transition='in_left',
|
||||
transition_delay=tval).autoretain()
|
||||
tval += 4 * t_incr
|
||||
|
||||
# Most killed.
|
||||
most_killed = 0
|
||||
mkp, mkp_name = None, None
|
||||
for entry in player_entries:
|
||||
if entry[2].killed_count >= most_killed:
|
||||
mkp = entry[2]
|
||||
mkp_name = entry[1]
|
||||
most_killed = entry[2].killed_count
|
||||
if mkp is not None:
|
||||
Text(ba.Lstr(resource='mostViolatedPlayerText'),
|
||||
color=(0.5, 0.5, 0.5, 1.0),
|
||||
v_align='center',
|
||||
maxwidth=300,
|
||||
position=(180, ts_height / 2 - 300 + v_extra + 15),
|
||||
transition='in_left',
|
||||
h_align='left',
|
||||
transition_delay=tval).autoretain()
|
||||
Text(ba.Lstr(value='(${A})',
|
||||
subs=[('${A}',
|
||||
ba.Lstr(resource='deathsTallyText',
|
||||
subs=[('${COUNT}', str(most_killed))]))
|
||||
]),
|
||||
position=(260, ts_height / 2 - 300 - 15 + v_extra),
|
||||
h_align='left',
|
||||
scale=0.6,
|
||||
color=(0.3, 0.3, 0.3, 1.0),
|
||||
transition='in_left',
|
||||
transition_delay=tval).autoretain()
|
||||
tval += 4 * t_incr
|
||||
Image(mkp.get_icon(),
|
||||
position=(233, ts_height / 2 - 300 - 30 - 46 + 25 + v_extra),
|
||||
scale=(50, 50),
|
||||
transition='in_left',
|
||||
transition_delay=tval).autoretain()
|
||||
Text(ba.Lstr(value=mkp_name),
|
||||
position=(270, ts_height / 2 - 300 - 30 - 36 + v_extra + 15),
|
||||
h_align='left',
|
||||
v_align='center',
|
||||
color=ba.safecolor(mkp.team.color + (1, )),
|
||||
maxwidth=180,
|
||||
transition='in_left',
|
||||
transition_delay=tval).autoretain()
|
||||
tval += 4 * t_incr
|
||||
|
||||
# Now show individual scores.
|
||||
tdelay = tval
|
||||
Text(ba.Lstr(resource='finalScoresText'),
|
||||
color=(0.5, 0.5, 0.5, 1.0),
|
||||
position=(ts_h_offs, ts_height / 2),
|
||||
transition='in_right',
|
||||
transition_delay=tdelay).autoretain()
|
||||
tdelay += 4 * t_incr
|
||||
|
||||
v_offs = 0.0
|
||||
tdelay += len(player_entries) * 8 * t_incr
|
||||
for _score, name, prec in player_entries:
|
||||
tdelay -= 4 * t_incr
|
||||
v_offs -= 40
|
||||
Text(str(prec.team.sessiondata['score'])
|
||||
if self._is_ffa else str(prec.score),
|
||||
color=(0.5, 0.5, 0.5, 1.0),
|
||||
position=(ts_h_offs + 230, ts_height / 2 + v_offs),
|
||||
h_align='right',
|
||||
transition='in_right',
|
||||
transition_delay=tdelay).autoretain()
|
||||
tdelay -= 4 * t_incr
|
||||
|
||||
Image(prec.get_icon(),
|
||||
position=(ts_h_offs - 72, ts_height / 2 + v_offs + 15),
|
||||
scale=(30, 30),
|
||||
transition='in_left',
|
||||
transition_delay=tdelay).autoretain()
|
||||
Text(ba.Lstr(value=name),
|
||||
position=(ts_h_offs - 50, ts_height / 2 + v_offs + 15),
|
||||
h_align='left',
|
||||
v_align='center',
|
||||
maxwidth=180,
|
||||
color=ba.safecolor(prec.team.color + (1, )),
|
||||
transition='in_right',
|
||||
transition_delay=tdelay).autoretain()
|
||||
|
||||
ba.timer(15.0, ba.WeakCall(self._show_tips))
|
||||
|
||||
def _show_tips(self) -> None:
|
||||
from bastd.actor.tipstext import TipsText
|
||||
self._tips_text = TipsText(offs_y=70)
|
||||
|
||||
def _play_victory_music(self) -> None:
|
||||
|
||||
# Make sure we don't stomp on the next activity's music choice.
|
||||
if not self.is_transitioning_out():
|
||||
ba.setmusic('Victory')
|
||||
|
||||
def _show_winner(self, team: ba.Team) -> None:
|
||||
from bastd.actor.image import Image
|
||||
from bastd.actor.zoomtext import ZoomText
|
||||
if not self._is_ffa:
|
||||
offs_v = 0.0
|
||||
ZoomText(team.name,
|
||||
position=(0, 97),
|
||||
color=team.color,
|
||||
scale=1.15,
|
||||
jitter=1.0,
|
||||
maxwidth=250).autoretain()
|
||||
else:
|
||||
offs_v = -80.0
|
||||
if len(team.players) == 1:
|
||||
i = Image(team.players[0].get_icon(),
|
||||
position=(0, 143),
|
||||
scale=(100, 100)).autoretain()
|
||||
assert i.node
|
||||
ba.animate(i.node, 'opacity', {0.0: 0.0, 0.25: 1.0})
|
||||
ZoomText(ba.Lstr(
|
||||
value=team.players[0].get_name(full=True, icon=False)),
|
||||
position=(0, 97 + offs_v),
|
||||
color=team.color,
|
||||
scale=1.15,
|
||||
jitter=1.0,
|
||||
maxwidth=250).autoretain()
|
||||
|
||||
s_extra = 1.0 if self._is_ffa else 1.0
|
||||
|
||||
# Some languages say "FOO WINS" differently for teams vs players.
|
||||
if isinstance(self.session, ba.FreeForAllSession):
|
||||
wins_resource = 'seriesWinLine1PlayerText'
|
||||
else:
|
||||
wins_resource = 'seriesWinLine1TeamText'
|
||||
wins_text = ba.Lstr(resource=wins_resource)
|
||||
|
||||
# Temp - if these come up as the english default, fall-back to the
|
||||
# unified old form which is more likely to be translated.
|
||||
ZoomText(wins_text,
|
||||
position=(0, -10 + offs_v),
|
||||
color=team.color,
|
||||
scale=0.65 * s_extra,
|
||||
jitter=1.0,
|
||||
maxwidth=250).autoretain()
|
||||
ZoomText(ba.Lstr(resource='seriesWinLine2Text'),
|
||||
position=(0, -110 + offs_v),
|
||||
scale=1.0 * s_extra,
|
||||
color=team.color,
|
||||
jitter=1.0,
|
||||
maxwidth=250).autoretain()
|
||||
@ -0,0 +1,79 @@
|
||||
"""Functionality related to the join screen for multi-team sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
from ba.internal import JoiningActivity
|
||||
from bastd.actor import text as textactor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class TeamJoiningActivity(JoiningActivity):
|
||||
"""Join screen for teams sessions."""
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings)
|
||||
self._next_up_text: Optional[textactor.Text] = None
|
||||
|
||||
def on_transition_in(self) -> None:
|
||||
from bastd.actor.controlsguide import ControlsGuide
|
||||
from ba import TeamsSession
|
||||
super().on_transition_in()
|
||||
ControlsGuide(delay=1.0).autoretain()
|
||||
|
||||
session = self.session
|
||||
assert isinstance(session, ba.TeamBaseSession)
|
||||
|
||||
# Show info about the next up game.
|
||||
self._next_up_text = textactor.Text(ba.Lstr(
|
||||
value='${1} ${2}',
|
||||
subs=[('${1}', ba.Lstr(resource='upFirstText')),
|
||||
('${2}', session.get_next_game_description())]),
|
||||
h_attach='center',
|
||||
scale=0.7,
|
||||
v_attach='top',
|
||||
h_align='center',
|
||||
position=(0, -70),
|
||||
flash=False,
|
||||
color=(0.5, 0.5, 0.5, 1.0),
|
||||
transition='fade_in',
|
||||
transition_delay=5.0)
|
||||
|
||||
# In teams mode, show our two team names.
|
||||
# FIXME: Lobby should handle this.
|
||||
if isinstance(ba.getsession(), TeamsSession):
|
||||
team_names = [team.name for team in ba.getsession().teams]
|
||||
team_colors = [
|
||||
tuple(team.color) + (0.5, ) for team in ba.getsession().teams
|
||||
]
|
||||
if len(team_names) == 2:
|
||||
for i in range(2):
|
||||
textactor.Text(team_names[i],
|
||||
scale=0.7,
|
||||
h_attach='center',
|
||||
v_attach='top',
|
||||
h_align='center',
|
||||
position=(-200 + 350 * i, -100),
|
||||
color=team_colors[i],
|
||||
transition='fade_in').autoretain()
|
||||
|
||||
textactor.Text(ba.Lstr(resource='mustInviteFriendsText',
|
||||
subs=[
|
||||
('${GATHER}',
|
||||
ba.Lstr(resource='gatherWindow.titleText'))
|
||||
]),
|
||||
h_attach='center',
|
||||
scale=0.8,
|
||||
host_only=True,
|
||||
v_attach='center',
|
||||
h_align='center',
|
||||
position=(0, 0),
|
||||
flash=False,
|
||||
color=(0, 1, 0, 1.0),
|
||||
transition='fade_in',
|
||||
transition_delay=2.0,
|
||||
transition_out_delay=7.0).autoretain()
|
||||
214
assets/src/data/scripts/bastd/activity/teamsscorescreen.py
Normal file
214
assets/src/data/scripts/bastd/activity/teamsscorescreen.py
Normal file
@ -0,0 +1,214 @@
|
||||
"""Functionality related to teams mode score screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
from ba.internal import ScoreScreenActivity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from ba import PlayerRecord
|
||||
|
||||
|
||||
class TeamsScoreScreenActivity(ScoreScreenActivity):
|
||||
"""Base class for score screens."""
|
||||
|
||||
def __init__(self, settings: Dict[str, Any]):
|
||||
super().__init__(settings=settings)
|
||||
self._score_display_sound = ba.getsound("scoreHit01")
|
||||
self._score_display_sound_small = ba.getsound("scoreHit02")
|
||||
|
||||
def on_begin( # type: ignore
|
||||
self,
|
||||
show_up_next: bool = True,
|
||||
custom_continue_message: ba.Lstr = None) -> None:
|
||||
# FIXME FIXME unify args
|
||||
# pylint: disable=arguments-differ
|
||||
from bastd.actor.text import Text
|
||||
super().on_begin(custom_continue_message=custom_continue_message)
|
||||
session = self.session
|
||||
if show_up_next and isinstance(session, ba.TeamBaseSession):
|
||||
txt = ba.Lstr(value='${A} ${B}',
|
||||
subs=[
|
||||
('${A}',
|
||||
ba.Lstr(resource='upNextText',
|
||||
subs=[
|
||||
('${COUNT}',
|
||||
str(session.get_game_number() + 1))
|
||||
])),
|
||||
('${B}', session.get_next_game_description())
|
||||
])
|
||||
Text(txt,
|
||||
maxwidth=900,
|
||||
h_attach='center',
|
||||
v_attach='bottom',
|
||||
h_align='center',
|
||||
v_align='center',
|
||||
position=(0, 53),
|
||||
flash=False,
|
||||
color=(0.3, 0.3, 0.35, 1.0),
|
||||
transition='fade_in',
|
||||
transition_delay=2.0).autoretain()
|
||||
|
||||
def show_player_scores(self,
|
||||
delay: float = 2.5,
|
||||
results: Any = None,
|
||||
scale: float = 1.0,
|
||||
x_offset: float = 0.0,
|
||||
y_offset: float = 0.0) -> None:
|
||||
"""Show scores for individual players."""
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-statements
|
||||
from bastd.actor.text import Text
|
||||
from bastd.actor.image import Image
|
||||
from ba import FreeForAllSession
|
||||
|
||||
ts_v_offset = 150.0 + y_offset
|
||||
ts_h_offs = 80.0 + x_offset
|
||||
tdelay = delay
|
||||
spacing = 40
|
||||
|
||||
is_free_for_all = isinstance(self.session, FreeForAllSession)
|
||||
|
||||
def _get_prec_score(p_rec: PlayerRecord) -> int:
|
||||
if is_free_for_all and results is not None:
|
||||
assert isinstance(results, ba.TeamGameResults)
|
||||
val = results.get_team_score(p_rec.team)
|
||||
assert val is not None
|
||||
return val
|
||||
return p_rec.accumscore
|
||||
|
||||
def _get_prec_score_str(p_rec: PlayerRecord) -> Union[str, ba.Lstr]:
|
||||
if is_free_for_all and results is not None:
|
||||
assert isinstance(results, ba.TeamGameResults)
|
||||
val = results.get_team_score_str(p_rec.team)
|
||||
assert val is not None
|
||||
return val
|
||||
return str(p_rec.accumscore)
|
||||
|
||||
# get_records() can return players that are no longer in
|
||||
# the game.. if we're using results we have to filter those out
|
||||
# (since they're not in results and that's where we pull their
|
||||
# scores from)
|
||||
if results is not None:
|
||||
assert isinstance(results, ba.TeamGameResults)
|
||||
player_records = []
|
||||
assert self.stats
|
||||
valid_players = list(self.stats.get_records().items())
|
||||
|
||||
def _get_player_score_set_entry(player: ba.Player
|
||||
) -> Optional[PlayerRecord]:
|
||||
for p_rec in valid_players:
|
||||
# PyCharm incorrectly thinks valid_players is a List[str]
|
||||
# noinspection PyUnresolvedReferences
|
||||
if p_rec[1].player is player:
|
||||
# noinspection PyTypeChecker
|
||||
return p_rec[1]
|
||||
return None
|
||||
|
||||
# Results is already sorted; just convert it into a list of
|
||||
# score-set-entries.
|
||||
for winner in results.get_winners():
|
||||
for team in winner.teams:
|
||||
if len(team.players) == 1:
|
||||
player_entry = _get_player_score_set_entry(
|
||||
team.players[0])
|
||||
if player_entry is not None:
|
||||
player_records.append(player_entry)
|
||||
else:
|
||||
raise Exception('FIXME; CODE PATH NEEDS FIXING')
|
||||
# player_records = [[
|
||||
# _get_prec_score(p), name, p
|
||||
# ] for name, p in list(self.stats.get_records().items())]
|
||||
# player_records.sort(
|
||||
# reverse=(results is None
|
||||
# or not results.get_lower_is_better()))
|
||||
# # just want living player entries
|
||||
# player_records = [p[2] for p in player_records if p[2]]
|
||||
|
||||
v_offs = -140.0 + spacing * len(player_records) * 0.5
|
||||
|
||||
def _txt(x_offs: float,
|
||||
y_offs: float,
|
||||
text: ba.Lstr,
|
||||
h_align: str = 'right',
|
||||
extrascale: float = 1.0,
|
||||
maxwidth: Optional[float] = 120.0) -> None:
|
||||
Text(text,
|
||||
color=(0.5, 0.5, 0.6, 0.5),
|
||||
position=(ts_h_offs + x_offs * scale,
|
||||
ts_v_offset + (v_offs + y_offs + 4.0) * scale),
|
||||
h_align=h_align,
|
||||
v_align='center',
|
||||
scale=0.8 * scale * extrascale,
|
||||
maxwidth=maxwidth,
|
||||
transition='in_left',
|
||||
transition_delay=tdelay).autoretain()
|
||||
|
||||
session = self.session
|
||||
assert isinstance(session, ba.TeamBaseSession)
|
||||
tval = ba.Lstr(resource='gameLeadersText',
|
||||
subs=[('${COUNT}', str(session.get_game_number()))])
|
||||
_txt(180, 43, tval, h_align='center', extrascale=1.4, maxwidth=None)
|
||||
_txt(-15, 4, ba.Lstr(resource='playerText'), h_align='left')
|
||||
_txt(180, 4, ba.Lstr(resource='killsText'))
|
||||
_txt(280, 4, ba.Lstr(resource='deathsText'), maxwidth=100)
|
||||
|
||||
score_name = 'Score' if results is None else results.get_score_name()
|
||||
translated = ba.Lstr(translate=('scoreNames', score_name))
|
||||
|
||||
_txt(390, 0, translated)
|
||||
|
||||
topkillcount = 0
|
||||
topkilledcount = 99999
|
||||
top_score = 0 if not player_records else _get_prec_score(
|
||||
player_records[0])
|
||||
|
||||
for prec in player_records:
|
||||
topkillcount = max(topkillcount, prec.accum_kill_count)
|
||||
topkilledcount = min(topkilledcount, prec.accum_killed_count)
|
||||
|
||||
def _scoretxt(text: Union[str, ba.Lstr],
|
||||
x_offs: float,
|
||||
highlight: bool,
|
||||
delay2: float,
|
||||
maxwidth: float = 70.0) -> None:
|
||||
Text(text,
|
||||
position=(ts_h_offs + x_offs * scale,
|
||||
ts_v_offset + (v_offs + 15) * scale),
|
||||
scale=scale,
|
||||
color=(1.0, 0.9, 0.5, 1.0) if highlight else
|
||||
(0.5, 0.5, 0.6, 0.5),
|
||||
h_align='right',
|
||||
v_align='center',
|
||||
maxwidth=maxwidth,
|
||||
transition='in_left',
|
||||
transition_delay=tdelay + delay2).autoretain()
|
||||
|
||||
for playerrec in player_records:
|
||||
tdelay += 0.05
|
||||
v_offs -= spacing
|
||||
Image(playerrec.get_icon(),
|
||||
position=(ts_h_offs - 12 * scale,
|
||||
ts_v_offset + (v_offs + 15.0) * scale),
|
||||
scale=(30.0 * scale, 30.0 * scale),
|
||||
transition='in_left',
|
||||
transition_delay=tdelay).autoretain()
|
||||
Text(ba.Lstr(value=playerrec.get_name(full=True)),
|
||||
maxwidth=160,
|
||||
scale=0.75 * scale,
|
||||
position=(ts_h_offs + 10.0 * scale,
|
||||
ts_v_offset + (v_offs + 15) * scale),
|
||||
h_align='left',
|
||||
v_align='center',
|
||||
color=ba.safecolor(playerrec.team.color + (1, )),
|
||||
transition='in_left',
|
||||
transition_delay=tdelay).autoretain()
|
||||
_scoretxt(str(playerrec.accum_kill_count), 180,
|
||||
playerrec.accum_kill_count == topkillcount, 100)
|
||||
_scoretxt(str(playerrec.accum_killed_count), 280,
|
||||
playerrec.accum_killed_count == topkilledcount, 100)
|
||||
_scoretxt(_get_prec_score_str(playerrec), 390,
|
||||
_get_prec_score(playerrec) == top_score, 200)
|
||||
0
assets/src/data/scripts/bastd/actor/__init__.py
Normal file
0
assets/src/data/scripts/bastd/actor/__init__.py
Normal file
138
assets/src/data/scripts/bastd/actor/background.py
Normal file
138
assets/src/data/scripts/bastd/actor/background.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""Defines Actor(s)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Background(ba.Actor):
|
||||
"""Simple Fading Background Actor."""
|
||||
|
||||
def __init__(self,
|
||||
fade_time: float = 0.5,
|
||||
start_faded: bool = False,
|
||||
show_logo: bool = False):
|
||||
super().__init__()
|
||||
self._dying = False
|
||||
self.fade_time = fade_time
|
||||
# We're special in that we create our node in the session
|
||||
# scene instead of the activity scene.
|
||||
# This way we can overlap multiple activities for fades
|
||||
# and whatnot.
|
||||
session = ba.getsession()
|
||||
self._session = weakref.ref(session)
|
||||
with ba.Context(session):
|
||||
self.node = ba.newnode('image',
|
||||
delegate=self,
|
||||
attrs={
|
||||
'fill_screen': True,
|
||||
'texture': ba.gettexture('bg'),
|
||||
'tilt_translate': -0.3,
|
||||
'has_alpha_channel': False,
|
||||
'color': (1, 1, 1)
|
||||
})
|
||||
if not start_faded:
|
||||
ba.animate(self.node,
|
||||
'opacity', {
|
||||
0.0: 0.0,
|
||||
self.fade_time: 1.0
|
||||
},
|
||||
loop=False)
|
||||
if show_logo:
|
||||
logo_texture = ba.gettexture('logo')
|
||||
logo_model = ba.getmodel('logo')
|
||||
logo_model_transparent = ba.getmodel('logoTransparent')
|
||||
self.logo = ba.newnode(
|
||||
'image',
|
||||
owner=self.node,
|
||||
attrs={
|
||||
'texture': logo_texture,
|
||||
'model_opaque': logo_model,
|
||||
'model_transparent': logo_model_transparent,
|
||||
'scale': (0.7, 0.7),
|
||||
'vr_depth': -250,
|
||||
'color': (0.15, 0.15, 0.15),
|
||||
'position': (0, 0),
|
||||
'tilt_translate': -0.05,
|
||||
'absolute_scale': False
|
||||
})
|
||||
self.node.connectattr('opacity', self.logo, 'opacity')
|
||||
# add jitter/pulse for a stop-motion-y look unless we're in VR
|
||||
# in which case stillness is better
|
||||
if not ba.app.vr_mode:
|
||||
self.cmb = ba.newnode('combine',
|
||||
owner=self.node,
|
||||
attrs={'size': 2})
|
||||
for attr in ['input0', 'input1']:
|
||||
ba.animate(self.cmb,
|
||||
attr, {
|
||||
0.0: 0.693,
|
||||
0.05: 0.7,
|
||||
0.5: 0.693
|
||||
},
|
||||
loop=True)
|
||||
self.cmb.connectattr('output', self.logo, 'scale')
|
||||
cmb = ba.newnode('combine',
|
||||
owner=self.node,
|
||||
attrs={'size': 2})
|
||||
cmb.connectattr('output', self.logo, 'position')
|
||||
# Gen some random keys for that stop-motion-y look.
|
||||
keys = {}
|
||||
timeval = 0.0
|
||||
for _i in range(10):
|
||||
keys[timeval] = (random.random() - 0.5) * 0.0015
|
||||
timeval += random.random() * 0.1
|
||||
ba.animate(cmb, "input0", keys, loop=True)
|
||||
keys = {}
|
||||
timeval = 0.0
|
||||
for _i in range(10):
|
||||
keys[timeval] = (random.random() - 0.5) * 0.0015 + 0.05
|
||||
timeval += random.random() * 0.1
|
||||
ba.animate(cmb, "input1", keys, loop=True)
|
||||
|
||||
def __del__(self) -> None:
|
||||
# Normal actors don't get sent DieMessages when their
|
||||
# activity is shutting down, but we still need to do so
|
||||
# since our node lives in the session and it wouldn't die
|
||||
# otherwise.
|
||||
self._die()
|
||||
super().__del__()
|
||||
|
||||
def _die(self, immediate: bool = False) -> None:
|
||||
session = self._session()
|
||||
if session is None and self.node:
|
||||
# If session is gone, our node should be too,
|
||||
# since it was part of the session's scene.
|
||||
# Let's make sure that's the case.
|
||||
# (since otherwise we have no way to kill it)
|
||||
ba.print_error("got None session on Background _die"
|
||||
" (and node still exists!)")
|
||||
elif session is not None:
|
||||
with ba.Context(session):
|
||||
if not self._dying and self.node:
|
||||
self._dying = True
|
||||
if immediate:
|
||||
self.node.delete()
|
||||
else:
|
||||
ba.animate(self.node,
|
||||
"opacity", {
|
||||
0.0: 1.0,
|
||||
self.fade_time: 0.0
|
||||
},
|
||||
loop=False)
|
||||
ba.timer(self.fade_time + 0.1, self.node.delete)
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
if __debug__ is True:
|
||||
self._handlemessage_sanity_check()
|
||||
if isinstance(msg, ba.DieMessage):
|
||||
self._die(msg.immediate)
|
||||
else:
|
||||
super().handlemessage(msg)
|
||||
1050
assets/src/data/scripts/bastd/actor/bomb.py
Normal file
1050
assets/src/data/scripts/bastd/actor/bomb.py
Normal file
File diff suppressed because it is too large
Load Diff
464
assets/src/data/scripts/bastd/actor/controlsguide.py
Normal file
464
assets/src/data/scripts/bastd/actor/controlsguide.py
Normal file
@ -0,0 +1,464 @@
|
||||
"""Defines Actors related to controls guides."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Tuple, Optional, Sequence, Union
|
||||
|
||||
|
||||
class ControlsGuide(ba.Actor):
|
||||
"""A screen overlay of game controls.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
Shows button mappings based on what controllers are connected.
|
||||
Handy to show at the start of a series or whenever there might
|
||||
be newbies watching.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
position: Tuple[float, float] = (390.0, 120.0),
|
||||
scale: float = 1.0,
|
||||
delay: float = 0.0,
|
||||
lifespan: float = None,
|
||||
bright: bool = False):
|
||||
"""Instantiate an overlay.
|
||||
|
||||
delay: is the time in seconds before the overlay fades in.
|
||||
|
||||
lifespan: if not None, the overlay will fade back out and die after
|
||||
that long (in milliseconds).
|
||||
|
||||
bright: if True, brighter colors will be used; handy when showing
|
||||
over gameplay but may be too bright for join-screens, etc.
|
||||
"""
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-locals
|
||||
super().__init__()
|
||||
show_title = True
|
||||
scale *= 0.75
|
||||
image_size = 90.0 * scale
|
||||
offs = 74.0 * scale
|
||||
offs5 = 43.0 * scale
|
||||
ouya = False
|
||||
maxw = 50
|
||||
self._lifespan = lifespan
|
||||
self._dead = False
|
||||
self._bright = bright
|
||||
self._cancel_timer: Optional[ba.Timer] = None
|
||||
self._fade_in_timer: Optional[ba.Timer] = None
|
||||
self._update_timer: Optional[ba.Timer] = None
|
||||
self._title_text: Optional[ba.Node]
|
||||
clr: Sequence[float]
|
||||
if show_title:
|
||||
self._title_text_pos_top = (position[0],
|
||||
position[1] + 139.0 * scale)
|
||||
self._title_text_pos_bottom = (position[0],
|
||||
position[1] + 139.0 * scale)
|
||||
clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7)
|
||||
tval = ba.Lstr(value='${A}:',
|
||||
subs=[('${A}', ba.Lstr(resource='controlsText'))])
|
||||
self._title_text = ba.newnode('text',
|
||||
attrs={
|
||||
'text': tval,
|
||||
'host_only': True,
|
||||
'scale': 1.1 * scale,
|
||||
'shadow': 0.5,
|
||||
'flatness': 1.0,
|
||||
'maxwidth': 480,
|
||||
'v_align': 'center',
|
||||
'h_align': 'center',
|
||||
'color': clr
|
||||
})
|
||||
else:
|
||||
self._title_text = None
|
||||
pos = (position[0], position[1] - offs)
|
||||
clr = (0.4, 1, 0.4)
|
||||
self._jump_image = ba.newnode(
|
||||
'image',
|
||||
attrs={
|
||||
'texture': ba.gettexture('buttonJump'),
|
||||
'absolute_scale': True,
|
||||
'host_only': True,
|
||||
'vr_depth': 10,
|
||||
'position': pos,
|
||||
'scale': (image_size, image_size),
|
||||
'color': clr
|
||||
})
|
||||
self._jump_text = ba.newnode('text',
|
||||
attrs={
|
||||
'v_align': 'top',
|
||||
'h_align': 'center',
|
||||
'scale': 1.5 * scale,
|
||||
'flatness': 1.0,
|
||||
'host_only': True,
|
||||
'shadow': 1.0,
|
||||
'maxwidth': maxw,
|
||||
'position': (pos[0], pos[1] - offs5),
|
||||
'color': clr
|
||||
})
|
||||
pos = (position[0] - offs * 1.1, position[1])
|
||||
clr = (0.2, 0.6, 1) if ouya else (1, 0.7, 0.3)
|
||||
self._punch_image = ba.newnode(
|
||||
'image',
|
||||
attrs={
|
||||
'texture': ba.gettexture('buttonPunch'),
|
||||
'absolute_scale': True,
|
||||
'host_only': True,
|
||||
'vr_depth': 10,
|
||||
'position': pos,
|
||||
'scale': (image_size, image_size),
|
||||
'color': clr
|
||||
})
|
||||
self._punch_text = ba.newnode('text',
|
||||
attrs={
|
||||
'v_align': 'top',
|
||||
'h_align': 'center',
|
||||
'scale': 1.5 * scale,
|
||||
'flatness': 1.0,
|
||||
'host_only': True,
|
||||
'shadow': 1.0,
|
||||
'maxwidth': maxw,
|
||||
'position': (pos[0], pos[1] - offs5),
|
||||
'color': clr
|
||||
})
|
||||
pos = (position[0] + offs * 1.1, position[1])
|
||||
clr = (1, 0.3, 0.3)
|
||||
self._bomb_image = ba.newnode(
|
||||
'image',
|
||||
attrs={
|
||||
'texture': ba.gettexture('buttonBomb'),
|
||||
'absolute_scale': True,
|
||||
'host_only': True,
|
||||
'vr_depth': 10,
|
||||
'position': pos,
|
||||
'scale': (image_size, image_size),
|
||||
'color': clr
|
||||
})
|
||||
self._bomb_text = ba.newnode('text',
|
||||
attrs={
|
||||
'h_align': 'center',
|
||||
'v_align': 'top',
|
||||
'scale': 1.5 * scale,
|
||||
'flatness': 1.0,
|
||||
'host_only': True,
|
||||
'shadow': 1.0,
|
||||
'maxwidth': maxw,
|
||||
'position': (pos[0], pos[1] - offs5),
|
||||
'color': clr
|
||||
})
|
||||
pos = (position[0], position[1] + offs)
|
||||
clr = (1, 0.8, 0.3) if ouya else (0.8, 0.5, 1)
|
||||
self._pickup_image = ba.newnode(
|
||||
'image',
|
||||
attrs={
|
||||
'texture': ba.gettexture('buttonPickUp'),
|
||||
'absolute_scale': True,
|
||||
'host_only': True,
|
||||
'vr_depth': 10,
|
||||
'position': pos,
|
||||
'scale': (image_size, image_size),
|
||||
'color': clr
|
||||
})
|
||||
self._pick_up_text = ba.newnode('text',
|
||||
attrs={
|
||||
'v_align': 'top',
|
||||
'h_align': 'center',
|
||||
'scale': 1.5 * scale,
|
||||
'flatness': 1.0,
|
||||
'host_only': True,
|
||||
'shadow': 1.0,
|
||||
'maxwidth': maxw,
|
||||
'position':
|
||||
(pos[0], pos[1] - offs5),
|
||||
'color': clr
|
||||
})
|
||||
clr = (0.9, 0.9, 2.0, 1.0) if bright else (0.8, 0.8, 2.0, 1.0)
|
||||
self._run_text_pos_top = (position[0], position[1] - 135.0 * scale)
|
||||
self._run_text_pos_bottom = (position[0], position[1] - 172.0 * scale)
|
||||
sval = (1.0 * scale if ba.app.vr_mode else 0.8 * scale)
|
||||
self._run_text = ba.newnode(
|
||||
'text',
|
||||
attrs={
|
||||
'scale': sval,
|
||||
'host_only': True,
|
||||
'shadow': 1.0 if ba.app.vr_mode else 0.5,
|
||||
'flatness': 1.0,
|
||||
'maxwidth': 380,
|
||||
'v_align': 'top',
|
||||
'h_align': 'center',
|
||||
'color': clr
|
||||
})
|
||||
clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7)
|
||||
self._extra_text = ba.newnode('text',
|
||||
attrs={
|
||||
'scale': 0.8 * scale,
|
||||
'host_only': True,
|
||||
'shadow': 0.5,
|
||||
'flatness': 1.0,
|
||||
'maxwidth': 380,
|
||||
'v_align': 'top',
|
||||
'h_align': 'center',
|
||||
'color': clr
|
||||
})
|
||||
self._nodes = [
|
||||
self._bomb_image, self._bomb_text, self._punch_image,
|
||||
self._punch_text, self._jump_image, self._jump_text,
|
||||
self._pickup_image, self._pick_up_text, self._run_text,
|
||||
self._extra_text
|
||||
]
|
||||
if show_title:
|
||||
assert self._title_text
|
||||
self._nodes.append(self._title_text)
|
||||
|
||||
# Start everything invisible.
|
||||
for node in self._nodes:
|
||||
node.opacity = 0.0
|
||||
|
||||
# Don't do anything until our delay has passed.
|
||||
ba.timer(delay, ba.WeakCall(self._start_updating))
|
||||
|
||||
def _start_updating(self) -> None:
|
||||
|
||||
# Ok, our delay has passed. Now lets periodically see if we can fade
|
||||
# in (if a touch-screen is present we only want to show up if gamepads
|
||||
# are connected, etc).
|
||||
# Also set up a timer so if we haven't faded in by the end of our
|
||||
# duration, abort.
|
||||
if self._lifespan is not None:
|
||||
self._cancel_timer = ba.Timer(
|
||||
self._lifespan,
|
||||
ba.WeakCall(self.handlemessage, ba.DieMessage(immediate=True)))
|
||||
self._fade_in_timer = ba.Timer(1.0,
|
||||
ba.WeakCall(self._check_fade_in),
|
||||
repeat=True)
|
||||
self._check_fade_in() # Do one check immediately.
|
||||
|
||||
def _check_fade_in(self) -> None:
|
||||
from ba.internal import get_device_value
|
||||
|
||||
# If we have a touchscreen, we only fade in if we have a player with
|
||||
# an input device that is *not* the touchscreen.
|
||||
touchscreen = _ba.get_input_device('TouchScreen', '#1', doraise=False)
|
||||
|
||||
if touchscreen is not None:
|
||||
# We look at the session's players; not the activity's.
|
||||
# We want to get ones who are still in the process of
|
||||
# selecting a character, etc.
|
||||
input_devices = [
|
||||
p.get_input_device() for p in ba.getsession().players
|
||||
]
|
||||
input_devices = [
|
||||
i for i in input_devices if i and i is not touchscreen
|
||||
]
|
||||
fade_in = False
|
||||
if input_devices:
|
||||
# Only count this one if it has non-empty button names
|
||||
# (filters out wiimotes, the remote-app, etc).
|
||||
for device in input_devices:
|
||||
for name in ('buttonPunch', 'buttonJump', 'buttonBomb',
|
||||
'buttonPickUp'):
|
||||
if device.get_button_name(
|
||||
get_device_value(device, name)) != '':
|
||||
fade_in = True
|
||||
break
|
||||
if fade_in:
|
||||
break # No need to keep looking.
|
||||
else:
|
||||
# No touch-screen; fade in immediately.
|
||||
fade_in = True
|
||||
if fade_in:
|
||||
self._cancel_timer = None # Didn't need this.
|
||||
self._fade_in_timer = None # Done with this.
|
||||
self._fade_in()
|
||||
|
||||
def _fade_in(self) -> None:
|
||||
for node in self._nodes:
|
||||
ba.animate(node, 'opacity', {0: 0.0, 2.0: 1.0})
|
||||
|
||||
# If we were given a lifespan, transition out after it.
|
||||
if self._lifespan is not None:
|
||||
ba.timer(self._lifespan,
|
||||
ba.WeakCall(self.handlemessage, ba.DieMessage()))
|
||||
self._update()
|
||||
self._update_timer = ba.Timer(1.0,
|
||||
ba.WeakCall(self._update),
|
||||
repeat=True)
|
||||
|
||||
def _update(self) -> None:
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-locals
|
||||
from ba.internal import get_device_value, get_remote_app_name
|
||||
if self._dead:
|
||||
return
|
||||
punch_button_names = set()
|
||||
jump_button_names = set()
|
||||
pickup_button_names = set()
|
||||
bomb_button_names = set()
|
||||
|
||||
# We look at the session's players; not the activity's - we want to
|
||||
# get ones who are still in the process of selecting a character, etc.
|
||||
input_devices = [p.get_input_device() for p in ba.getsession().players]
|
||||
input_devices = [i for i in input_devices if i]
|
||||
|
||||
# If there's no players with input devices yet, try to default to
|
||||
# showing keyboard controls.
|
||||
if not input_devices:
|
||||
kbd = _ba.get_input_device('Keyboard', '#1', doraise=False)
|
||||
if kbd is not None:
|
||||
input_devices.append(kbd)
|
||||
|
||||
# We word things specially if we have nothing but keyboards.
|
||||
all_keyboards = (input_devices
|
||||
and all(i.name == 'Keyboard' for i in input_devices))
|
||||
only_remote = (len(input_devices) == 1
|
||||
and all(i.name == 'Amazon Fire TV Remote'
|
||||
for i in input_devices))
|
||||
|
||||
right_button_names = set()
|
||||
left_button_names = set()
|
||||
up_button_names = set()
|
||||
down_button_names = set()
|
||||
|
||||
# For each player in the game with an input device,
|
||||
# get the name of the button for each of these 4 actions.
|
||||
# If any of them are uniform across all devices, display the name.
|
||||
for device in input_devices:
|
||||
# We only care about movement buttons in the case of keyboards.
|
||||
if all_keyboards:
|
||||
right_button_names.add(
|
||||
device.get_button_name(
|
||||
get_device_value(device, 'buttonRight')))
|
||||
left_button_names.add(
|
||||
device.get_button_name(
|
||||
get_device_value(device, 'buttonLeft')))
|
||||
down_button_names.add(
|
||||
device.get_button_name(
|
||||
get_device_value(device, 'buttonDown')))
|
||||
up_button_names.add(
|
||||
device.get_button_name(get_device_value(
|
||||
device, 'buttonUp')))
|
||||
|
||||
# Ignore empty values; things like the remote app or
|
||||
# wiimotes can return these.
|
||||
bname = device.get_button_name(
|
||||
get_device_value(device, 'buttonPunch'))
|
||||
if bname != '':
|
||||
punch_button_names.add(bname)
|
||||
bname = device.get_button_name(
|
||||
get_device_value(device, 'buttonJump'))
|
||||
if bname != '':
|
||||
jump_button_names.add(bname)
|
||||
bname = device.get_button_name(
|
||||
get_device_value(device, 'buttonBomb'))
|
||||
if bname != '':
|
||||
bomb_button_names.add(bname)
|
||||
bname = device.get_button_name(
|
||||
get_device_value(device, 'buttonPickUp'))
|
||||
if bname != '':
|
||||
pickup_button_names.add(bname)
|
||||
|
||||
# If we have no values yet, we may want to throw out some sane
|
||||
# defaults.
|
||||
if all(not lst for lst in (punch_button_names, jump_button_names,
|
||||
bomb_button_names, pickup_button_names)):
|
||||
# Otherwise on android show standard buttons.
|
||||
if ba.app.platform == 'android':
|
||||
punch_button_names.add('X')
|
||||
jump_button_names.add('A')
|
||||
bomb_button_names.add('B')
|
||||
pickup_button_names.add('Y')
|
||||
|
||||
run_text = ba.Lstr(
|
||||
value='${R}: ${B}',
|
||||
subs=[('${R}', ba.Lstr(resource='runText')),
|
||||
('${B}',
|
||||
ba.Lstr(resource='holdAnyKeyText'
|
||||
if all_keyboards else 'holdAnyButtonText'))])
|
||||
|
||||
# If we're all keyboards, lets show move keys too.
|
||||
if (all_keyboards and len(up_button_names) == 1
|
||||
and len(down_button_names) == 1 and len(left_button_names) == 1
|
||||
and len(right_button_names) == 1):
|
||||
up_text = list(up_button_names)[0]
|
||||
down_text = list(down_button_names)[0]
|
||||
left_text = list(left_button_names)[0]
|
||||
right_text = list(right_button_names)[0]
|
||||
run_text = ba.Lstr(value='${M}: ${U}, ${L}, ${D}, ${R}\n${RUN}',
|
||||
subs=[('${M}', ba.Lstr(resource='moveText')),
|
||||
('${U}', up_text), ('${L}', left_text),
|
||||
('${D}', down_text), ('${R}', right_text),
|
||||
('${RUN}', run_text)])
|
||||
|
||||
self._run_text.text = run_text
|
||||
w_text: Union[ba.Lstr, str]
|
||||
if only_remote and self._lifespan is None:
|
||||
w_text = ba.Lstr(resource='fireTVRemoteWarningText',
|
||||
subs=[('${REMOTE_APP_NAME}',
|
||||
get_remote_app_name())])
|
||||
else:
|
||||
w_text = ''
|
||||
self._extra_text.text = w_text
|
||||
if len(punch_button_names) == 1:
|
||||
self._punch_text.text = list(punch_button_names)[0]
|
||||
else:
|
||||
self._punch_text.text = ''
|
||||
|
||||
if len(jump_button_names) == 1:
|
||||
tval = list(jump_button_names)[0]
|
||||
else:
|
||||
tval = ''
|
||||
self._jump_text.text = tval
|
||||
if tval == '':
|
||||
self._run_text.position = self._run_text_pos_top
|
||||
self._extra_text.position = (self._run_text_pos_top[0],
|
||||
self._run_text_pos_top[1] - 50)
|
||||
else:
|
||||
self._run_text.position = self._run_text_pos_bottom
|
||||
self._extra_text.position = (self._run_text_pos_bottom[0],
|
||||
self._run_text_pos_bottom[1] - 50)
|
||||
if len(bomb_button_names) == 1:
|
||||
self._bomb_text.text = list(bomb_button_names)[0]
|
||||
else:
|
||||
self._bomb_text.text = ''
|
||||
|
||||
# Also move our title up/down depending on if this is shown.
|
||||
if len(pickup_button_names) == 1:
|
||||
self._pick_up_text.text = list(pickup_button_names)[0]
|
||||
if self._title_text is not None:
|
||||
self._title_text.position = self._title_text_pos_top
|
||||
else:
|
||||
self._pick_up_text.text = ''
|
||||
if self._title_text is not None:
|
||||
self._title_text.position = self._title_text_pos_bottom
|
||||
|
||||
def _die(self) -> None:
|
||||
for node in self._nodes:
|
||||
node.delete()
|
||||
self._nodes = []
|
||||
self._update_timer = None
|
||||
self._dead = True
|
||||
|
||||
def exists(self) -> bool:
|
||||
return not self._dead
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
if __debug__ is True:
|
||||
self._handlemessage_sanity_check()
|
||||
if isinstance(msg, ba.DieMessage):
|
||||
if msg.immediate:
|
||||
self._die()
|
||||
else:
|
||||
# If they don't need immediate,
|
||||
# fade out our nodes and die later.
|
||||
for node in self._nodes:
|
||||
ba.animate(node, 'opacity', {0: node.opacity, 3.0: 0.0})
|
||||
ba.timer(3.1, ba.WeakCall(self._die))
|
||||
return None
|
||||
return super().handlemessage(msg)
|
||||
350
assets/src/data/scripts/bastd/actor/flag.py
Normal file
350
assets/src/data/scripts/bastd/actor/flag.py
Normal file
@ -0,0 +1,350 @@
|
||||
"""Implements a flag used for marking bases, capture-the-flag games, etc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Sequence, Optional
|
||||
|
||||
|
||||
class FlagFactory:
|
||||
"""Wraps up media and other resources used by ba.Flags.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
A single instance of this is shared between all flags
|
||||
and can be retrieved via bastd.actor.flag.get_factory().
|
||||
|
||||
Attributes:
|
||||
|
||||
flagmaterial
|
||||
The ba.Material applied to all ba.Flags.
|
||||
|
||||
impact_sound
|
||||
The ba.Sound used when a ba.Flag hits the ground.
|
||||
|
||||
skid_sound
|
||||
The ba.Sound used when a ba.Flag skids along the ground.
|
||||
|
||||
no_hit_material
|
||||
A ba.Material that prevents contact with most objects;
|
||||
applied to 'non-touchable' flags.
|
||||
|
||||
flag_texture
|
||||
The ba.Texture for flags.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Instantiate a FlagFactory.
|
||||
|
||||
You shouldn't need to do this; call bastd.actor.flag.get_factory() to
|
||||
get a shared instance.
|
||||
"""
|
||||
|
||||
self.flagmaterial = ba.Material()
|
||||
self.flagmaterial.add_actions(
|
||||
conditions=(('we_are_younger_than', 100),
|
||||
'and', ('they_have_material',
|
||||
ba.sharedobj('object_material'))),
|
||||
actions=('modify_node_collision', 'collide', False))
|
||||
|
||||
self.flagmaterial.add_actions(
|
||||
conditions=('they_have_material',
|
||||
ba.sharedobj('footing_material')),
|
||||
actions=(('message', 'our_node', 'at_connect', 'footing', 1),
|
||||
('message', 'our_node', 'at_disconnect', 'footing', -1)))
|
||||
|
||||
self.impact_sound = ba.getsound('metalHit')
|
||||
self.skid_sound = ba.getsound('metalSkid')
|
||||
self.flagmaterial.add_actions(
|
||||
conditions=('they_have_material',
|
||||
ba.sharedobj('footing_material')),
|
||||
actions=(('impact_sound', self.impact_sound, 2, 5),
|
||||
('skid_sound', self.skid_sound, 2, 5)))
|
||||
|
||||
self.no_hit_material = ba.Material()
|
||||
self.no_hit_material.add_actions(
|
||||
conditions=(('they_have_material',
|
||||
ba.sharedobj('pickup_material')),
|
||||
'or', ('they_have_material',
|
||||
ba.sharedobj('attack_material'))),
|
||||
actions=('modify_part_collision', 'collide', False))
|
||||
|
||||
# We also don't want anything moving it.
|
||||
self.no_hit_material.add_actions(
|
||||
conditions=(('they_have_material',
|
||||
ba.sharedobj('object_material')), 'or',
|
||||
('they_dont_have_material',
|
||||
ba.sharedobj('footing_material'))),
|
||||
actions=(('modify_part_collision', 'collide', False),
|
||||
('modify_part_collision', 'physical', False)))
|
||||
|
||||
self.flag_texture = ba.gettexture('flagColor')
|
||||
|
||||
|
||||
# noinspection PyTypeHints
|
||||
def get_factory() -> FlagFactory:
|
||||
"""Get/create a shared bastd.actor.flag.FlagFactory object."""
|
||||
activity = ba.getactivity()
|
||||
factory: FlagFactory
|
||||
try:
|
||||
# FIXME: Find elegant way to handle shared data like this.
|
||||
factory = activity.shared_flag_factory # type: ignore
|
||||
except Exception:
|
||||
factory = activity.shared_flag_factory = FlagFactory() # type: ignore
|
||||
assert isinstance(factory, FlagFactory)
|
||||
return factory
|
||||
|
||||
|
||||
class FlagPickedUpMessage:
|
||||
"""A message saying a ba.Flag has been picked up.
|
||||
|
||||
category: Message Classes
|
||||
|
||||
Attributes:
|
||||
|
||||
flag
|
||||
The ba.Flag that has been picked up.
|
||||
|
||||
node
|
||||
The ba.Node doing the picking up.
|
||||
"""
|
||||
|
||||
def __init__(self, flag: Flag, node: ba.Node):
|
||||
"""Instantiate with given values."""
|
||||
self.flag = flag
|
||||
self.node = node
|
||||
|
||||
|
||||
class FlagDeathMessage:
|
||||
"""A message saying a ba.Flag has died.
|
||||
|
||||
category: Message Classes
|
||||
|
||||
Attributes:
|
||||
|
||||
flag
|
||||
The ba.Flag that died.
|
||||
"""
|
||||
|
||||
def __init__(self, flag: Flag):
|
||||
"""Instantiate with given values."""
|
||||
self.flag = flag
|
||||
|
||||
|
||||
class FlagDroppedMessage:
|
||||
"""A message saying a ba.Flag has been dropped.
|
||||
|
||||
category: Message Classes
|
||||
|
||||
Attributes:
|
||||
|
||||
flag
|
||||
The ba.Flag that was dropped.
|
||||
|
||||
node
|
||||
The ba.Node that was holding it.
|
||||
"""
|
||||
|
||||
def __init__(self, flag: Flag, node: ba.Node):
|
||||
"""Instantiate with given values."""
|
||||
self.flag = flag
|
||||
self.node = node
|
||||
|
||||
|
||||
class Flag(ba.Actor):
|
||||
"""A flag; used in games such as capture-the-flag or king-of-the-hill.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
Can be stationary or carry-able by players.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
position: Sequence[float] = (0.0, 1.0, 0.0),
|
||||
color: Sequence[float] = (1.0, 1.0, 1.0),
|
||||
materials: Sequence[ba.Material] = None,
|
||||
touchable: bool = True,
|
||||
dropped_timeout: int = None):
|
||||
"""Instantiate a flag.
|
||||
|
||||
If 'touchable' is False, the flag will only touch terrain;
|
||||
useful for things like king-of-the-hill where players should
|
||||
not be moving the flag around.
|
||||
|
||||
'materials can be a list of extra ba.Materials to apply to the flag.
|
||||
|
||||
If 'dropped_timeout' is provided (in seconds), the flag will die
|
||||
after remaining untouched for that long once it has been moved
|
||||
from its initial position.
|
||||
"""
|
||||
|
||||
super().__init__()
|
||||
|
||||
self._initial_position: Optional[Sequence[float]] = None
|
||||
self._has_moved = False
|
||||
factory = get_factory()
|
||||
|
||||
if materials is None:
|
||||
materials = []
|
||||
elif not isinstance(materials, list):
|
||||
# In case they passed a tuple or whatnot.
|
||||
materials = list(materials)
|
||||
if not touchable:
|
||||
materials = [factory.no_hit_material] + materials
|
||||
|
||||
finalmaterials = (
|
||||
[ba.sharedobj('object_material'), factory.flagmaterial] +
|
||||
materials)
|
||||
self.node = ba.newnode("flag",
|
||||
attrs={
|
||||
'position':
|
||||
(position[0], position[1] + 0.75,
|
||||
position[2]),
|
||||
'color_texture': factory.flag_texture,
|
||||
'color': color,
|
||||
'materials': finalmaterials
|
||||
},
|
||||
delegate=self)
|
||||
|
||||
if dropped_timeout is not None:
|
||||
dropped_timeout = int(dropped_timeout)
|
||||
self._dropped_timeout = dropped_timeout
|
||||
self._counter: Optional[ba.Node]
|
||||
if self._dropped_timeout is not None:
|
||||
self._count = self._dropped_timeout
|
||||
self._tick_timer = ba.Timer(1.0,
|
||||
call=ba.WeakCall(self._tick),
|
||||
repeat=True)
|
||||
self._counter = ba.newnode('text',
|
||||
owner=self.node,
|
||||
attrs={
|
||||
'in_world': True,
|
||||
'color': (1, 1, 1, 0.7),
|
||||
'scale': 0.015,
|
||||
'shadow': 0.5,
|
||||
'flatness': 1.0,
|
||||
'h_align': 'center'
|
||||
})
|
||||
else:
|
||||
self._counter = None
|
||||
|
||||
self._held_count = 0
|
||||
self._score_text: Optional[ba.Node] = None
|
||||
self._score_text_hide_timer: Optional[ba.Timer] = None
|
||||
|
||||
def _tick(self) -> None:
|
||||
if self.node:
|
||||
|
||||
# Grab our initial position after one tick (in case we fall).
|
||||
if self._initial_position is None:
|
||||
self._initial_position = self.node.position
|
||||
|
||||
# Keep track of when we first move; we don't count down
|
||||
# until then.
|
||||
if not self._has_moved:
|
||||
nodepos = self.node.position
|
||||
if (max(
|
||||
abs(nodepos[i] - self._initial_position[i])
|
||||
for i in list(range(3))) > 1.0):
|
||||
self._has_moved = True
|
||||
|
||||
if self._held_count > 0 or not self._has_moved:
|
||||
assert self._dropped_timeout is not None
|
||||
assert self._counter
|
||||
self._count = self._dropped_timeout
|
||||
self._counter.text = ''
|
||||
else:
|
||||
self._count -= 1
|
||||
if self._count <= 10:
|
||||
nodepos = self.node.position
|
||||
assert self._counter
|
||||
self._counter.position = (nodepos[0], nodepos[1] + 1.3,
|
||||
nodepos[2])
|
||||
self._counter.text = str(self._count)
|
||||
if self._count < 1:
|
||||
self.handlemessage(ba.DieMessage())
|
||||
else:
|
||||
assert self._counter
|
||||
self._counter.text = ''
|
||||
|
||||
def _hide_score_text(self) -> None:
|
||||
assert self._score_text is not None
|
||||
assert isinstance(self._score_text.scale, float)
|
||||
ba.animate(self._score_text, 'scale', {
|
||||
0: self._score_text.scale,
|
||||
0.2: 0
|
||||
})
|
||||
|
||||
def set_score_text(self, text: str) -> None:
|
||||
"""Show a message over the flag; handy for scores."""
|
||||
if not self.node:
|
||||
return
|
||||
if not self._score_text:
|
||||
start_scale = 0.0
|
||||
math = ba.newnode('math',
|
||||
owner=self.node,
|
||||
attrs={
|
||||
'input1': (0, 1.4, 0),
|
||||
'operation': 'add'
|
||||
})
|
||||
self.node.connectattr('position', math, 'input2')
|
||||
self._score_text = ba.newnode('text',
|
||||
owner=self.node,
|
||||
attrs={
|
||||
'text': text,
|
||||
'in_world': True,
|
||||
'scale': 0.02,
|
||||
'shadow': 0.5,
|
||||
'flatness': 1.0,
|
||||
'h_align': 'center'
|
||||
})
|
||||
math.connectattr('output', self._score_text, 'position')
|
||||
else:
|
||||
assert isinstance(self._score_text.scale, float)
|
||||
start_scale = self._score_text.scale
|
||||
self._score_text.text = text
|
||||
self._score_text.color = ba.safecolor(self.node.color)
|
||||
ba.animate(self._score_text, 'scale', {0: start_scale, 0.2: 0.02})
|
||||
self._score_text_hide_timer = ba.Timer(
|
||||
1.0, ba.WeakCall(self._hide_score_text))
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
self._handlemessage_sanity_check()
|
||||
if isinstance(msg, ba.DieMessage):
|
||||
if self.node:
|
||||
self.node.delete()
|
||||
if not msg.immediate:
|
||||
self.activity.handlemessage(FlagDeathMessage(self))
|
||||
elif isinstance(msg, ba.HitMessage):
|
||||
assert self.node
|
||||
assert msg.force_direction is not None
|
||||
self.node.handlemessage(
|
||||
"impulse", msg.pos[0], msg.pos[1], msg.pos[2], msg.velocity[0],
|
||||
msg.velocity[1], msg.velocity[2], msg.magnitude,
|
||||
msg.velocity_magnitude, msg.radius, 0, msg.force_direction[0],
|
||||
msg.force_direction[1], msg.force_direction[2])
|
||||
elif isinstance(msg, ba.OutOfBoundsMessage):
|
||||
# We just kill ourselves when out-of-bounds.. would we ever not
|
||||
# want this?..
|
||||
self.handlemessage(ba.DieMessage(how='fall'))
|
||||
elif isinstance(msg, ba.PickedUpMessage):
|
||||
self._held_count += 1
|
||||
if self._held_count == 1 and self._counter is not None:
|
||||
self._counter.text = ''
|
||||
activity = self.getactivity()
|
||||
if activity is not None:
|
||||
activity.handlemessage(FlagPickedUpMessage(self, msg.node))
|
||||
elif isinstance(msg, ba.DroppedMessage):
|
||||
self._held_count -= 1
|
||||
if self._held_count < 0:
|
||||
print('Flag held count < 0')
|
||||
self._held_count = 0
|
||||
activity = self.getactivity()
|
||||
if activity is not None:
|
||||
activity.handlemessage(FlagDroppedMessage(self, msg.node))
|
||||
else:
|
||||
super().handlemessage(msg)
|
||||
151
assets/src/data/scripts/bastd/actor/image.py
Normal file
151
assets/src/data/scripts/bastd/actor/image.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""Defines Actor(s)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Tuple, Sequence, Union, Dict, Optional
|
||||
|
||||
|
||||
class Image(ba.Actor):
|
||||
"""Just a wrapped up image node with a few tricks up its sleeve."""
|
||||
|
||||
def __init__(self,
|
||||
texture: Union[ba.Texture, Dict[str, Any]],
|
||||
position: Tuple[float, float] = (0, 0),
|
||||
transition: str = None,
|
||||
transition_delay: float = 0.0,
|
||||
attach: str = 'center',
|
||||
color: Sequence[float] = (1.0, 1.0, 1.0, 1.0),
|
||||
scale: Tuple[float, float] = (100.0, 100.0),
|
||||
transition_out_delay: float = None,
|
||||
model_opaque: ba.Model = None,
|
||||
model_transparent: ba.Model = None,
|
||||
vr_depth: float = 0.0,
|
||||
host_only: bool = False,
|
||||
front: bool = False):
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-locals
|
||||
super().__init__()
|
||||
# if they provided a dict as texture, assume its an icon..
|
||||
# otherwise its just a texture value itself
|
||||
mask_texture: Optional[ba.Texture]
|
||||
if isinstance(texture, dict):
|
||||
tint_color = texture['tint_color']
|
||||
tint2_color = texture['tint2_color']
|
||||
tint_texture = texture['tint_texture']
|
||||
texture = texture['texture']
|
||||
mask_texture = ba.gettexture('characterIconMask')
|
||||
else:
|
||||
tint_color = (1, 1, 1)
|
||||
tint2_color = None
|
||||
tint_texture = None
|
||||
mask_texture = None
|
||||
|
||||
self.node = ba.newnode('image',
|
||||
attrs={
|
||||
'texture': texture,
|
||||
'tint_color': tint_color,
|
||||
'tint_texture': tint_texture,
|
||||
'position': position,
|
||||
'vr_depth': vr_depth,
|
||||
'scale': scale,
|
||||
'mask_texture': mask_texture,
|
||||
'color': color,
|
||||
'absolute_scale': True,
|
||||
'host_only': host_only,
|
||||
'front': front,
|
||||
'attach': attach
|
||||
},
|
||||
delegate=self)
|
||||
|
||||
if model_opaque is not None:
|
||||
self.node.model_opaque = model_opaque
|
||||
if model_transparent is not None:
|
||||
self.node.model_transparent = model_transparent
|
||||
if tint2_color is not None:
|
||||
self.node.tint2_color = tint2_color
|
||||
if transition == 'fade_in':
|
||||
keys = {transition_delay: 0, transition_delay + 0.5: color[3]}
|
||||
if transition_out_delay is not None:
|
||||
keys[transition_delay + transition_out_delay] = color[3]
|
||||
keys[transition_delay + transition_out_delay + 0.5] = 0
|
||||
ba.animate(self.node, 'opacity', keys)
|
||||
cmb = self.position_combine = ba.newnode('combine',
|
||||
owner=self.node,
|
||||
attrs={'size': 2})
|
||||
if transition == 'in_right':
|
||||
keys = {
|
||||
transition_delay: position[0] + 1200,
|
||||
transition_delay + 0.2: position[0]
|
||||
}
|
||||
o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0}
|
||||
ba.animate(cmb, 'input0', keys)
|
||||
cmb.input1 = position[1]
|
||||
ba.animate(self.node, 'opacity', o_keys)
|
||||
elif transition == 'in_left':
|
||||
keys = {
|
||||
transition_delay: position[0] - 1200,
|
||||
transition_delay + 0.2: position[0]
|
||||
}
|
||||
o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0}
|
||||
if transition_out_delay is not None:
|
||||
keys[transition_delay + transition_out_delay] = position[0]
|
||||
keys[transition_delay + transition_out_delay +
|
||||
200] = -position[0] - 1200
|
||||
o_keys[transition_delay + transition_out_delay + 0.15] = 1.0
|
||||
o_keys[transition_delay + transition_out_delay + 0.2] = 0.0
|
||||
ba.animate(cmb, 'input0', keys)
|
||||
cmb.input1 = position[1]
|
||||
ba.animate(self.node, 'opacity', o_keys)
|
||||
elif transition == 'in_bottom_slow':
|
||||
keys = {
|
||||
transition_delay: -400,
|
||||
transition_delay + 3.5: position[1]
|
||||
}
|
||||
o_keys = {transition_delay: 0.0, transition_delay + 2.0: 1.0}
|
||||
cmb.input0 = position[0]
|
||||
ba.animate(cmb, 'input1', keys)
|
||||
ba.animate(self.node, 'opacity', o_keys)
|
||||
elif transition == 'in_bottom':
|
||||
keys = {
|
||||
transition_delay: -400,
|
||||
transition_delay + 0.2: position[1]
|
||||
}
|
||||
o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0}
|
||||
if transition_out_delay is not None:
|
||||
keys[transition_delay + transition_out_delay] = position[1]
|
||||
keys[transition_delay + transition_out_delay + 0.2] = -400
|
||||
o_keys[transition_delay + transition_out_delay + 0.15] = 1.0
|
||||
o_keys[transition_delay + transition_out_delay + 0.2] = 0.0
|
||||
cmb.input0 = position[0]
|
||||
ba.animate(cmb, 'input1', keys)
|
||||
ba.animate(self.node, 'opacity', o_keys)
|
||||
elif transition == 'inTopSlow':
|
||||
keys = {transition_delay: 400, transition_delay + 3.5: position[1]}
|
||||
o_keys = {transition_delay: 0.0, transition_delay + 1.0: 1.0}
|
||||
cmb.input0 = position[0]
|
||||
ba.animate(cmb, 'input1', keys)
|
||||
ba.animate(self.node, 'opacity', o_keys)
|
||||
else:
|
||||
cmb.input0 = position[0]
|
||||
cmb.input1 = position[1]
|
||||
cmb.connectattr('output', self.node, 'position')
|
||||
|
||||
# if we're transitioning out, die at the end of it
|
||||
if transition_out_delay is not None:
|
||||
ba.timer(transition_delay + transition_out_delay + 1.0,
|
||||
ba.WeakCall(self.handlemessage, ba.DieMessage()))
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
if __debug__ is True:
|
||||
self._handlemessage_sanity_check()
|
||||
if isinstance(msg, ba.DieMessage):
|
||||
if self.node:
|
||||
self.node.delete()
|
||||
return None
|
||||
return super().handlemessage(msg)
|
||||
99
assets/src/data/scripts/bastd/actor/onscreencountdown.py
Normal file
99
assets/src/data/scripts/bastd/actor/onscreencountdown.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""Defines Actor Type(s)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
class OnScreenCountdown(ba.Actor):
|
||||
"""A Handy On-Screen Timer.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
Useful for time-based games that count down to zero.
|
||||
"""
|
||||
|
||||
def __init__(self, duration: int, endcall: Callable[[], Any] = None):
|
||||
"""Duration is provided in seconds."""
|
||||
super().__init__()
|
||||
self._timeremaining = duration
|
||||
self._ended = False
|
||||
self._endcall = endcall
|
||||
self.node = ba.newnode('text',
|
||||
attrs={
|
||||
'v_attach': 'top',
|
||||
'h_attach': 'center',
|
||||
'h_align': 'center',
|
||||
'color': (1, 1, 0.5, 1),
|
||||
'flatness': 0.5,
|
||||
'shadow': 0.5,
|
||||
'position': (0, -70),
|
||||
'scale': 1.4,
|
||||
'text': ''
|
||||
})
|
||||
self.inputnode = ba.newnode('timedisplay',
|
||||
attrs={
|
||||
'time2': duration * 1000,
|
||||
'timemax': duration * 1000,
|
||||
'timemin': 0
|
||||
})
|
||||
self.inputnode.connectattr('output', self.node, 'text')
|
||||
self._countdownsounds = {
|
||||
10: ba.getsound('announceTen'),
|
||||
9: ba.getsound('announceNine'),
|
||||
8: ba.getsound('announceEight'),
|
||||
7: ba.getsound('announceSeven'),
|
||||
6: ba.getsound('announceSix'),
|
||||
5: ba.getsound('announceFive'),
|
||||
4: ba.getsound('announceFour'),
|
||||
3: ba.getsound('announceThree'),
|
||||
2: ba.getsound('announceTwo'),
|
||||
1: ba.getsound('announceOne')
|
||||
}
|
||||
self._timer: Optional[ba.Timer] = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the timer."""
|
||||
globalsnode = ba.sharedobj('globals')
|
||||
globalsnode.connectattr('time', self.inputnode, 'time1')
|
||||
self.inputnode.time2 = (globalsnode.time +
|
||||
(self._timeremaining + 1) * 1000)
|
||||
self._timer = ba.Timer(1.0, self._update, repeat=True)
|
||||
|
||||
def on_expire(self) -> None:
|
||||
super().on_expire()
|
||||
# release callbacks/refs
|
||||
self._endcall = None
|
||||
|
||||
def _update(self, forcevalue: int = None) -> None:
|
||||
if forcevalue is not None:
|
||||
tval = forcevalue
|
||||
else:
|
||||
self._timeremaining = max(0, self._timeremaining - 1)
|
||||
tval = self._timeremaining
|
||||
|
||||
# if there's a countdown sound for this time that we
|
||||
# haven't played yet, play it
|
||||
if tval == 10:
|
||||
assert self.node
|
||||
assert isinstance(self.node.scale, float)
|
||||
self.node.scale *= 1.2
|
||||
cmb = ba.newnode('combine', owner=self.node, attrs={'size': 4})
|
||||
cmb.connectattr('output', self.node, 'color')
|
||||
ba.animate(cmb, "input0", {0: 1.0, 0.15: 1.0}, loop=True)
|
||||
ba.animate(cmb, "input1", {0: 1.0, 0.15: 0.5}, loop=True)
|
||||
ba.animate(cmb, "input2", {0: 0.1, 0.15: 0.0}, loop=True)
|
||||
cmb.input3 = 1.0
|
||||
if tval <= 10 and not self._ended:
|
||||
ba.playsound(ba.getsound('tick'))
|
||||
if tval in self._countdownsounds:
|
||||
ba.playsound(self._countdownsounds[tval])
|
||||
if tval <= 0 and not self._ended:
|
||||
self._ended = True
|
||||
if self._endcall is not None:
|
||||
self._endcall()
|
||||
106
assets/src/data/scripts/bastd/actor/onscreentimer.py
Normal file
106
assets/src/data/scripts/bastd/actor/onscreentimer.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""Defines Actor(s)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Optional, Union, Any
|
||||
|
||||
|
||||
class OnScreenTimer(ba.Actor):
|
||||
"""A handy on-screen timer.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
Useful for time-based games where time increases.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._starttime: Optional[int] = None
|
||||
self.node = ba.newnode('text',
|
||||
attrs={
|
||||
'v_attach': 'top',
|
||||
'h_attach': 'center',
|
||||
'h_align': 'center',
|
||||
'color': (1, 1, 0.5, 1),
|
||||
'flatness': 0.5,
|
||||
'shadow': 0.5,
|
||||
'position': (0, -70),
|
||||
'scale': 1.4,
|
||||
'text': ''
|
||||
})
|
||||
self.inputnode = ba.newnode('timedisplay',
|
||||
attrs={
|
||||
'timemin': 0,
|
||||
'showsubseconds': True
|
||||
})
|
||||
self.inputnode.connectattr('output', self.node, 'text')
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the timer."""
|
||||
tval = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
|
||||
assert isinstance(tval, int)
|
||||
self._starttime = tval
|
||||
self.inputnode.time1 = self._starttime
|
||||
ba.sharedobj('globals').connectattr('time', self.inputnode, 'time2')
|
||||
|
||||
def hasstarted(self) -> bool:
|
||||
"""Return whether this timer has started yet."""
|
||||
return self._starttime is not None
|
||||
|
||||
def stop(self,
|
||||
endtime: Union[int, float] = None,
|
||||
timeformat: ba.TimeFormat = ba.TimeFormat.SECONDS) -> None:
|
||||
"""End the timer.
|
||||
|
||||
If 'endtime' is not None, it is used when calculating
|
||||
the final display time; otherwise the current time is used.
|
||||
|
||||
'timeformat' applies to endtime and can be SECONDS or MILLISECONDS
|
||||
"""
|
||||
if endtime is None:
|
||||
endtime = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
|
||||
timeformat = ba.TimeFormat.MILLISECONDS
|
||||
|
||||
if self._starttime is None:
|
||||
print('Warning: OnScreenTimer.stop() called without start() first')
|
||||
else:
|
||||
endtime_ms: int
|
||||
if timeformat is ba.TimeFormat.SECONDS:
|
||||
endtime_ms = int(endtime * 1000)
|
||||
elif timeformat is ba.TimeFormat.MILLISECONDS:
|
||||
assert isinstance(endtime, int)
|
||||
endtime_ms = endtime
|
||||
else:
|
||||
raise Exception(f'invalid timeformat: {timeformat}')
|
||||
|
||||
self.inputnode.timemax = endtime_ms - self._starttime
|
||||
|
||||
def getstarttime(self, timeformat: ba.TimeFormat = ba.TimeFormat.SECONDS
|
||||
) -> Union[int, float]:
|
||||
"""Return the sim-time when start() was called.
|
||||
|
||||
Time will be returned in seconds if timeformat is SECONDS or
|
||||
milliseconds if it is MILLISECONDS.
|
||||
"""
|
||||
val_ms: Any
|
||||
if self._starttime is None:
|
||||
print('WARNING: getstarttime() called on un-started timer')
|
||||
val_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
|
||||
else:
|
||||
val_ms = self._starttime
|
||||
assert isinstance(val_ms, int)
|
||||
if timeformat is ba.TimeFormat.SECONDS:
|
||||
return 0.001 * val_ms
|
||||
if timeformat is ba.TimeFormat.MILLISECONDS:
|
||||
return val_ms
|
||||
raise Exception(f'invalid timeformat: {timeformat}')
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
# if we're asked to die, just kill our node/timer
|
||||
if isinstance(msg, ba.DieMessage):
|
||||
if self.node:
|
||||
self.node.delete()
|
||||
288
assets/src/data/scripts/bastd/actor/playerspaz.py
Normal file
288
assets/src/data/scripts/bastd/actor/playerspaz.py
Normal file
@ -0,0 +1,288 @@
|
||||
"""Functionality related to player-controlled Spazzes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
from bastd.actor import spaz as basespaz
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
class PlayerSpazDeathMessage:
|
||||
"""A message saying a ba.PlayerSpaz has died.
|
||||
|
||||
category: Message Classes
|
||||
|
||||
Attributes:
|
||||
|
||||
spaz
|
||||
The ba.PlayerSpaz that died.
|
||||
|
||||
killed
|
||||
If True, the spaz was killed;
|
||||
If False, they left the game or the round ended.
|
||||
|
||||
killerplayer
|
||||
The ba.Player that did the killing, or None.
|
||||
|
||||
how
|
||||
The particular type of death.
|
||||
"""
|
||||
|
||||
def __init__(self, spaz: PlayerSpaz, was_killed: bool,
|
||||
killerplayer: Optional[ba.Player], how: str):
|
||||
"""Instantiate a message with the given values."""
|
||||
self.spaz = spaz
|
||||
self.killed = was_killed
|
||||
self.killerplayer = killerplayer
|
||||
self.how = how
|
||||
|
||||
|
||||
class PlayerSpazHurtMessage:
|
||||
"""A message saying a ba.PlayerSpaz was hurt.
|
||||
|
||||
category: Message Classes
|
||||
|
||||
Attributes:
|
||||
|
||||
spaz
|
||||
The ba.PlayerSpaz that was hurt
|
||||
"""
|
||||
|
||||
def __init__(self, spaz: PlayerSpaz):
|
||||
"""Instantiate with the given ba.Spaz value."""
|
||||
self.spaz = spaz
|
||||
|
||||
|
||||
class PlayerSpaz(basespaz.Spaz):
|
||||
"""A ba.Spaz subclass meant to be controlled by a ba.Player.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
When a PlayerSpaz dies, it delivers a ba.PlayerSpazDeathMessage
|
||||
to the current ba.Activity. (unless the death was the result of the
|
||||
player leaving the game, in which case no message is sent)
|
||||
|
||||
When a PlayerSpaz is hurt, it delivers a ba.PlayerSpazHurtMessage
|
||||
to the current ba.Activity.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
color: Sequence[float] = (1.0, 1.0, 1.0),
|
||||
highlight: Sequence[float] = (0.5, 0.5, 0.5),
|
||||
character: str = "Spaz",
|
||||
player: ba.Player = None,
|
||||
powerups_expire: bool = True):
|
||||
"""Create a spaz for the provided ba.Player.
|
||||
|
||||
Note: this does not wire up any controls;
|
||||
you must call connect_controls_to_player() to do so.
|
||||
"""
|
||||
|
||||
basespaz.Spaz.__init__(self,
|
||||
color=color,
|
||||
highlight=highlight,
|
||||
character=character,
|
||||
source_player=player,
|
||||
start_invincible=True,
|
||||
powerups_expire=powerups_expire)
|
||||
self.last_player_attacked_by: Optional[ba.Player] = None
|
||||
self.last_attacked_time = 0.0
|
||||
self.last_attacked_type: Optional[Tuple[str, str]] = None
|
||||
self.held_count = 0
|
||||
self.last_player_held_by: Optional[ba.Player] = None
|
||||
self._player = player
|
||||
|
||||
# Grab the node for this player and wire it to follow our spaz
|
||||
# (so players' controllers know where to draw their guides, etc).
|
||||
if player:
|
||||
assert self.node
|
||||
assert player.node
|
||||
self.node.connectattr('torso_position', player.node, 'position')
|
||||
|
||||
@property
|
||||
def player(self) -> ba.Player:
|
||||
"""The ba.Player associated with this Spaz.
|
||||
|
||||
If the player no longer exists, raises an Exception.
|
||||
"""
|
||||
player = self._player
|
||||
if not player:
|
||||
raise Exception("player no longer exists")
|
||||
return player
|
||||
|
||||
def getplayer(self) -> Optional[ba.Player]:
|
||||
"""Get the ba.Player associated with this Spaz.
|
||||
|
||||
Note that this may return None or an invalidated ba.Player,
|
||||
so always test it with 'if playerval' before using it to
|
||||
cover both cases.
|
||||
"""
|
||||
return self._player
|
||||
|
||||
def connect_controls_to_player(self,
|
||||
enable_jump: bool = True,
|
||||
enable_punch: bool = True,
|
||||
enable_pickup: bool = True,
|
||||
enable_bomb: bool = True,
|
||||
enable_run: bool = True,
|
||||
enable_fly: bool = True) -> None:
|
||||
"""Wire this spaz up to the provided ba.Player.
|
||||
|
||||
Full control of the character is given by default
|
||||
but can be selectively limited by passing False
|
||||
to specific arguments.
|
||||
"""
|
||||
player = self.getplayer()
|
||||
assert player
|
||||
|
||||
# Reset any currently connected player and/or the player we're
|
||||
# wiring up.
|
||||
if self._connected_to_player:
|
||||
if player != self._connected_to_player:
|
||||
player.reset_input()
|
||||
self.disconnect_controls_from_player()
|
||||
else:
|
||||
player.reset_input()
|
||||
|
||||
player.assign_input_call('upDown', self.on_move_up_down)
|
||||
player.assign_input_call('leftRight', self.on_move_left_right)
|
||||
player.assign_input_call('holdPositionPress',
|
||||
self._on_hold_position_press)
|
||||
player.assign_input_call('holdPositionRelease',
|
||||
self._on_hold_position_release)
|
||||
if enable_jump:
|
||||
player.assign_input_call('jumpPress', self.on_jump_press)
|
||||
player.assign_input_call('jumpRelease', self.on_jump_release)
|
||||
if enable_pickup:
|
||||
player.assign_input_call('pickUpPress', self.on_pickup_press)
|
||||
player.assign_input_call('pickUpRelease', self.on_pickup_release)
|
||||
if enable_punch:
|
||||
player.assign_input_call('punchPress', self.on_punch_press)
|
||||
player.assign_input_call('punchRelease', self.on_punch_release)
|
||||
if enable_bomb:
|
||||
player.assign_input_call('bombPress', self.on_bomb_press)
|
||||
player.assign_input_call('bombRelease', self.on_bomb_release)
|
||||
if enable_run:
|
||||
player.assign_input_call('run', self.on_run)
|
||||
if enable_fly:
|
||||
player.assign_input_call('flyPress', self.on_fly_press)
|
||||
player.assign_input_call('flyRelease', self.on_fly_release)
|
||||
|
||||
self._connected_to_player = player
|
||||
|
||||
def disconnect_controls_from_player(self) -> None:
|
||||
"""
|
||||
Completely sever any previously connected
|
||||
ba.Player from control of this spaz.
|
||||
"""
|
||||
if self._connected_to_player:
|
||||
self._connected_to_player.reset_input()
|
||||
self._connected_to_player = None
|
||||
|
||||
# Send releases for anything in case its held.
|
||||
self.on_move_up_down(0)
|
||||
self.on_move_left_right(0)
|
||||
self._on_hold_position_release()
|
||||
self.on_jump_release()
|
||||
self.on_pickup_release()
|
||||
self.on_punch_release()
|
||||
self.on_bomb_release()
|
||||
self.on_run(0.0)
|
||||
self.on_fly_release()
|
||||
else:
|
||||
print('WARNING: disconnect_controls_from_player() called for'
|
||||
' non-connected player')
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
# FIXME: Tidy this up.
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-nested-blocks
|
||||
self._handlemessage_sanity_check()
|
||||
|
||||
# Keep track of if we're being held and by who most recently.
|
||||
if isinstance(msg, ba.PickedUpMessage):
|
||||
super().handlemessage(msg) # Augment standard behavior.
|
||||
self.held_count += 1
|
||||
picked_up_by = msg.node.source_player
|
||||
if picked_up_by:
|
||||
self.last_player_held_by = picked_up_by
|
||||
elif isinstance(msg, ba.DroppedMessage):
|
||||
super().handlemessage(msg) # Augment standard behavior.
|
||||
self.held_count -= 1
|
||||
if self.held_count < 0:
|
||||
print("ERROR: spaz held_count < 0")
|
||||
|
||||
# Let's count someone dropping us as an attack.
|
||||
try:
|
||||
picked_up_by = msg.node.source_player
|
||||
except Exception:
|
||||
picked_up_by = None
|
||||
if picked_up_by:
|
||||
self.last_player_attacked_by = picked_up_by
|
||||
self.last_attacked_time = ba.time()
|
||||
self.last_attacked_type = ('picked_up', 'default')
|
||||
elif isinstance(msg, ba.DieMessage):
|
||||
|
||||
# Report player deaths to the game.
|
||||
if not self._dead:
|
||||
|
||||
# Immediate-mode or left-game deaths don't count as 'kills'.
|
||||
killed = (not msg.immediate and msg.how != 'leftGame')
|
||||
|
||||
activity = self._activity()
|
||||
|
||||
if not killed:
|
||||
killerplayer = None
|
||||
else:
|
||||
# If this player was being held at the time of death,
|
||||
# the holder is the killer.
|
||||
if self.held_count > 0 and self.last_player_held_by:
|
||||
killerplayer = self.last_player_held_by
|
||||
else:
|
||||
# Otherwise, if they were attacked by someone in the
|
||||
# last few seconds, that person is the killer.
|
||||
# Otherwise it was a suicide.
|
||||
# FIXME: Currently disabling suicides in Co-Op since
|
||||
# all bot kills would register as suicides; need to
|
||||
# change this from last_player_attacked_by to
|
||||
# something like last_actor_attacked_by to fix that.
|
||||
if (self.last_player_attacked_by
|
||||
and ba.time() - self.last_attacked_time < 4.0):
|
||||
killerplayer = self.last_player_attacked_by
|
||||
else:
|
||||
# ok, call it a suicide unless we're in co-op
|
||||
if (activity is not None and not isinstance(
|
||||
activity.session, ba.CoopSession)):
|
||||
killerplayer = self.getplayer()
|
||||
else:
|
||||
killerplayer = None
|
||||
|
||||
# Convert dead-refs to None.
|
||||
if not killerplayer:
|
||||
killerplayer = None
|
||||
|
||||
# Only report if both the player and the activity still exist.
|
||||
if killed and activity is not None and self.getplayer():
|
||||
activity.handlemessage(
|
||||
PlayerSpazDeathMessage(self, killed, killerplayer,
|
||||
msg.how))
|
||||
|
||||
super().handlemessage(msg) # Augment standard behavior.
|
||||
|
||||
# Keep track of the player who last hit us for point rewarding.
|
||||
elif isinstance(msg, ba.HitMessage):
|
||||
if msg.source_player:
|
||||
self.last_player_attacked_by = msg.source_player
|
||||
self.last_attacked_time = ba.time()
|
||||
self.last_attacked_type = (msg.hit_type, msg.hit_subtype)
|
||||
super().handlemessage(msg) # Augment standard behavior.
|
||||
activity = self._activity()
|
||||
if activity is not None:
|
||||
activity.handlemessage(PlayerSpazHurtMessage(self))
|
||||
else:
|
||||
super().handlemessage(msg)
|
||||
111
assets/src/data/scripts/bastd/actor/popuptext.py
Normal file
111
assets/src/data/scripts/bastd/actor/popuptext.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Defines Actor(s)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Union, Sequence
|
||||
|
||||
|
||||
class PopupText(ba.Actor):
|
||||
"""Text that pops up above a position to denote something special.
|
||||
|
||||
category: Gameplay Classes
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
text: Union[str, ba.Lstr],
|
||||
position: Sequence[float] = (0.0, 0.0, 0.0),
|
||||
color: Sequence[float] = (1.0, 1.0, 1.0, 1.0),
|
||||
random_offset: float = 0.5,
|
||||
offset: Sequence[float] = (0.0, 0.0, 0.0),
|
||||
scale: float = 1.0):
|
||||
"""Instantiate with given values.
|
||||
|
||||
random_offset is the amount of random offset from the provided position
|
||||
that will be applied. This can help multiple achievements from
|
||||
overlapping too much.
|
||||
"""
|
||||
super().__init__()
|
||||
if len(color) == 3:
|
||||
color = (color[0], color[1], color[2], 1.0)
|
||||
pos = (position[0] + offset[0] + random_offset *
|
||||
(0.5 - random.random()), position[1] + offset[0] +
|
||||
random_offset * (0.5 - random.random()), position[2] +
|
||||
offset[0] + random_offset * (0.5 - random.random()))
|
||||
|
||||
self.node = ba.newnode('text',
|
||||
attrs={
|
||||
'text': text,
|
||||
'in_world': True,
|
||||
'shadow': 1.0,
|
||||
'flatness': 1.0,
|
||||
'h_align': 'center'
|
||||
},
|
||||
delegate=self)
|
||||
|
||||
lifespan = 1.5
|
||||
|
||||
# scale up
|
||||
ba.animate(
|
||||
self.node, 'scale', {
|
||||
0: 0.0,
|
||||
lifespan * 0.11: 0.020 * 0.7 * scale,
|
||||
lifespan * 0.16: 0.013 * 0.7 * scale,
|
||||
lifespan * 0.25: 0.014 * 0.7 * scale
|
||||
})
|
||||
|
||||
# translate upward
|
||||
self._tcombine = ba.newnode('combine',
|
||||
owner=self.node,
|
||||
attrs={
|
||||
'input0': pos[0],
|
||||
'input2': pos[2],
|
||||
'size': 3
|
||||
})
|
||||
ba.animate(self._tcombine, 'input1', {
|
||||
0: pos[1] + 1.5,
|
||||
lifespan: pos[1] + 2.0
|
||||
})
|
||||
self._tcombine.connectattr('output', self.node, 'position')
|
||||
|
||||
# fade our opacity in/out
|
||||
self._combine = ba.newnode('combine',
|
||||
owner=self.node,
|
||||
attrs={
|
||||
'input0': color[0],
|
||||
'input1': color[1],
|
||||
'input2': color[2],
|
||||
'size': 4
|
||||
})
|
||||
for i in range(4):
|
||||
ba.animate(
|
||||
self._combine, 'input' + str(i), {
|
||||
0.13 * lifespan: color[i],
|
||||
0.18 * lifespan: 4.0 * color[i],
|
||||
0.22 * lifespan: color[i]
|
||||
})
|
||||
ba.animate(self._combine, 'input3', {
|
||||
0: 0,
|
||||
0.1 * lifespan: color[3],
|
||||
0.7 * lifespan: color[3],
|
||||
lifespan: 0
|
||||
})
|
||||
self._combine.connectattr('output', self.node, 'color')
|
||||
|
||||
# kill ourself
|
||||
self._die_timer = ba.Timer(
|
||||
lifespan, ba.WeakCall(self.handlemessage, ba.DieMessage()))
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
if __debug__ is True:
|
||||
self._handlemessage_sanity_check()
|
||||
if isinstance(msg, ba.DieMessage):
|
||||
if self.node:
|
||||
self.node.delete()
|
||||
else:
|
||||
super().handlemessage(msg)
|
||||
309
assets/src/data/scripts/bastd/actor/powerupbox.py
Normal file
309
assets/src/data/scripts/bastd/actor/powerupbox.py
Normal file
@ -0,0 +1,309 @@
|
||||
"""Defines Actor(s)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import List, Any, Optional, Sequence
|
||||
|
||||
DEFAULT_POWERUP_INTERVAL = 8.0
|
||||
|
||||
|
||||
class _TouchedMessage:
|
||||
pass
|
||||
|
||||
|
||||
class PowerupBoxFactory:
|
||||
"""A collection of media and other resources used by ba.Powerups.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
A single instance of this is shared between all powerups
|
||||
and can be retrieved via ba.Powerup.get_factory().
|
||||
|
||||
Attributes:
|
||||
|
||||
model
|
||||
The ba.Model of the powerup box.
|
||||
|
||||
model_simple
|
||||
A simpler ba.Model of the powerup box, for use in shadows, etc.
|
||||
|
||||
tex_bomb
|
||||
Triple-bomb powerup ba.Texture.
|
||||
|
||||
tex_punch
|
||||
Punch powerup ba.Texture.
|
||||
|
||||
tex_ice_bombs
|
||||
Ice bomb powerup ba.Texture.
|
||||
|
||||
tex_sticky_bombs
|
||||
Sticky bomb powerup ba.Texture.
|
||||
|
||||
tex_shield
|
||||
Shield powerup ba.Texture.
|
||||
|
||||
tex_impact_bombs
|
||||
Impact-bomb powerup ba.Texture.
|
||||
|
||||
tex_health
|
||||
Health powerup ba.Texture.
|
||||
|
||||
tex_land_mines
|
||||
Land-mine powerup ba.Texture.
|
||||
|
||||
tex_curse
|
||||
Curse powerup ba.Texture.
|
||||
|
||||
health_powerup_sound
|
||||
ba.Sound played when a health powerup is accepted.
|
||||
|
||||
powerup_sound
|
||||
ba.Sound played when a powerup is accepted.
|
||||
|
||||
powerdown_sound
|
||||
ba.Sound that can be used when powerups wear off.
|
||||
|
||||
powerup_material
|
||||
ba.Material applied to powerup boxes.
|
||||
|
||||
powerup_accept_material
|
||||
Powerups will send a ba.PowerupMessage to anything they touch
|
||||
that has this ba.Material applied.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Instantiate a PowerupBoxFactory.
|
||||
|
||||
You shouldn't need to do this; call ba.Powerup.get_factory()
|
||||
to get a shared instance.
|
||||
"""
|
||||
from ba.internal import get_default_powerup_distribution
|
||||
self._lastpoweruptype: Optional[str] = None
|
||||
self.model = ba.getmodel("powerup")
|
||||
self.model_simple = ba.getmodel("powerupSimple")
|
||||
self.tex_bomb = ba.gettexture("powerupBomb")
|
||||
self.tex_punch = ba.gettexture("powerupPunch")
|
||||
self.tex_ice_bombs = ba.gettexture("powerupIceBombs")
|
||||
self.tex_sticky_bombs = ba.gettexture("powerupStickyBombs")
|
||||
self.tex_shield = ba.gettexture("powerupShield")
|
||||
self.tex_impact_bombs = ba.gettexture("powerupImpactBombs")
|
||||
self.tex_health = ba.gettexture("powerupHealth")
|
||||
self.tex_land_mines = ba.gettexture("powerupLandMines")
|
||||
self.tex_curse = ba.gettexture("powerupCurse")
|
||||
self.health_powerup_sound = ba.getsound("healthPowerup")
|
||||
self.powerup_sound = ba.getsound("powerup01")
|
||||
self.powerdown_sound = ba.getsound("powerdown01")
|
||||
self.drop_sound = ba.getsound("boxDrop")
|
||||
|
||||
# Material for powerups.
|
||||
self.powerup_material = ba.Material()
|
||||
|
||||
# Material for anyone wanting to accept powerups.
|
||||
self.powerup_accept_material = ba.Material()
|
||||
|
||||
# Pass a powerup-touched message to applicable stuff.
|
||||
self.powerup_material.add_actions(
|
||||
conditions=("they_have_material", self.powerup_accept_material),
|
||||
actions=(("modify_part_collision", "collide",
|
||||
True), ("modify_part_collision", "physical", False),
|
||||
("message", "our_node", "at_connect", _TouchedMessage())))
|
||||
|
||||
# We don't wanna be picked up.
|
||||
self.powerup_material.add_actions(
|
||||
conditions=("they_have_material", ba.sharedobj('pickup_material')),
|
||||
actions=("modify_part_collision", "collide", False))
|
||||
|
||||
self.powerup_material.add_actions(
|
||||
conditions=("they_have_material",
|
||||
ba.sharedobj('footing_material')),
|
||||
actions=("impact_sound", self.drop_sound, 0.5, 0.1))
|
||||
|
||||
self._powerupdist: List[str] = []
|
||||
for powerup, freq in get_default_powerup_distribution():
|
||||
for _i in range(int(freq)):
|
||||
self._powerupdist.append(powerup)
|
||||
|
||||
def get_random_powerup_type(self,
|
||||
forcetype: str = None,
|
||||
excludetypes: List[str] = None) -> str:
|
||||
"""Returns a random powerup type (string).
|
||||
|
||||
See ba.Powerup.poweruptype for available type values.
|
||||
|
||||
There are certain non-random aspects to this; a 'curse' powerup,
|
||||
for instance, is always followed by a 'health' powerup (to keep things
|
||||
interesting). Passing 'forcetype' forces a given returned type while
|
||||
still properly interacting with the non-random aspects of the system
|
||||
(ie: forcing a 'curse' powerup will result
|
||||
in the next powerup being health).
|
||||
"""
|
||||
if excludetypes is None:
|
||||
excludetypes = []
|
||||
if forcetype:
|
||||
ptype = forcetype
|
||||
else:
|
||||
# If the last one was a curse, make this one a health to
|
||||
# provide some hope.
|
||||
if self._lastpoweruptype == 'curse':
|
||||
ptype = 'health'
|
||||
else:
|
||||
while True:
|
||||
ptype = self._powerupdist[random.randint(
|
||||
0,
|
||||
len(self._powerupdist) - 1)]
|
||||
if ptype not in excludetypes:
|
||||
break
|
||||
self._lastpoweruptype = ptype
|
||||
return ptype
|
||||
|
||||
|
||||
def get_factory() -> PowerupBoxFactory:
|
||||
"""Return a shared ba.PowerupBoxFactory object, creating if necessary."""
|
||||
activity = ba.getactivity()
|
||||
if activity is None:
|
||||
raise Exception("no current activity")
|
||||
try:
|
||||
# FIXME: et better way to store stuff with activity
|
||||
# pylint: disable=protected-access
|
||||
# noinspection PyProtectedMember
|
||||
return activity._shared_powerup_factory # type: ignore
|
||||
except Exception:
|
||||
factory = activity._shared_powerup_factory = ( # type: ignore
|
||||
PowerupBoxFactory())
|
||||
return factory
|
||||
|
||||
|
||||
class PowerupBox(ba.Actor):
|
||||
"""A box that grants a powerup.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
This will deliver a ba.PowerupMessage to anything that touches it
|
||||
which has the ba.PowerupBoxFactory.powerup_accept_material applied.
|
||||
|
||||
Attributes:
|
||||
|
||||
poweruptype
|
||||
The string powerup type. This can be 'triple_bombs', 'punch',
|
||||
'ice_bombs', 'impact_bombs', 'land_mines', 'sticky_bombs', 'shield',
|
||||
'health', or 'curse'.
|
||||
|
||||
node
|
||||
The 'prop' ba.Node representing this box.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
position: Sequence[float] = (0.0, 1.0, 0.0),
|
||||
poweruptype: str = 'triple_bombs',
|
||||
expire: bool = True):
|
||||
"""Create a powerup-box of the requested type at the given position.
|
||||
|
||||
see ba.Powerup.poweruptype for valid type strings.
|
||||
"""
|
||||
|
||||
super().__init__()
|
||||
|
||||
factory = get_factory()
|
||||
self.poweruptype = poweruptype
|
||||
self._powersgiven = False
|
||||
|
||||
if poweruptype == 'triple_bombs':
|
||||
tex = factory.tex_bomb
|
||||
elif poweruptype == 'punch':
|
||||
tex = factory.tex_punch
|
||||
elif poweruptype == 'ice_bombs':
|
||||
tex = factory.tex_ice_bombs
|
||||
elif poweruptype == 'impact_bombs':
|
||||
tex = factory.tex_impact_bombs
|
||||
elif poweruptype == 'land_mines':
|
||||
tex = factory.tex_land_mines
|
||||
elif poweruptype == 'sticky_bombs':
|
||||
tex = factory.tex_sticky_bombs
|
||||
elif poweruptype == 'shield':
|
||||
tex = factory.tex_shield
|
||||
elif poweruptype == 'health':
|
||||
tex = factory.tex_health
|
||||
elif poweruptype == 'curse':
|
||||
tex = factory.tex_curse
|
||||
else:
|
||||
raise Exception("invalid poweruptype: " + str(poweruptype))
|
||||
|
||||
if len(position) != 3:
|
||||
raise Exception("expected 3 floats for position")
|
||||
|
||||
self.node = ba.newnode(
|
||||
'prop',
|
||||
delegate=self,
|
||||
attrs={
|
||||
'body': 'box',
|
||||
'position': position,
|
||||
'model': factory.model,
|
||||
'light_model': factory.model_simple,
|
||||
'shadow_size': 0.5,
|
||||
'color_texture': tex,
|
||||
'reflection': 'powerup',
|
||||
'reflection_scale': [1.0],
|
||||
'materials': (factory.powerup_material,
|
||||
ba.sharedobj('object_material'))
|
||||
}) # yapf: disable
|
||||
|
||||
# Animate in.
|
||||
curve = ba.animate(self.node, "model_scale", {0: 0, 0.14: 1.6, 0.2: 1})
|
||||
ba.timer(0.2, curve.delete)
|
||||
|
||||
if expire:
|
||||
ba.timer(DEFAULT_POWERUP_INTERVAL - 2.5,
|
||||
ba.WeakCall(self._start_flashing))
|
||||
ba.timer(DEFAULT_POWERUP_INTERVAL - 1.0,
|
||||
ba.WeakCall(self.handlemessage, ba.DieMessage()))
|
||||
|
||||
def _start_flashing(self) -> None:
|
||||
if self.node:
|
||||
self.node.flashing = True
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
# pylint: disable=too-many-branches
|
||||
self._handlemessage_sanity_check()
|
||||
|
||||
if isinstance(msg, ba.PowerupAcceptMessage):
|
||||
factory = get_factory()
|
||||
assert self.node
|
||||
if self.poweruptype == 'health':
|
||||
ba.playsound(factory.health_powerup_sound,
|
||||
3,
|
||||
position=self.node.position)
|
||||
ba.playsound(factory.powerup_sound, 3, position=self.node.position)
|
||||
self._powersgiven = True
|
||||
self.handlemessage(ba.DieMessage())
|
||||
|
||||
elif isinstance(msg, _TouchedMessage):
|
||||
if not self._powersgiven:
|
||||
node = ba.get_collision_info("opposing_node")
|
||||
if node:
|
||||
node.handlemessage(
|
||||
ba.PowerupMessage(self.poweruptype,
|
||||
source_node=self.node))
|
||||
|
||||
elif isinstance(msg, ba.DieMessage):
|
||||
if self.node:
|
||||
if msg.immediate:
|
||||
self.node.delete()
|
||||
else:
|
||||
ba.animate(self.node, "model_scale", {0: 1, 0.1: 0})
|
||||
ba.timer(0.1, self.node.delete)
|
||||
|
||||
elif isinstance(msg, ba.OutOfBoundsMessage):
|
||||
self.handlemessage(ba.DieMessage())
|
||||
|
||||
elif isinstance(msg, ba.HitMessage):
|
||||
# Don't die on punches (that's annoying).
|
||||
if msg.hit_type != 'punch':
|
||||
self.handlemessage(ba.DieMessage())
|
||||
else:
|
||||
super().handlemessage(msg)
|
||||
152
assets/src/data/scripts/bastd/actor/respawnicon.py
Normal file
152
assets/src/data/scripts/bastd/actor/respawnicon.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""Implements respawn icon actor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class RespawnIcon:
|
||||
"""An icon with a countdown that appears alongside the screen.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
This is used to indicate that a ba.Player is waiting to respawn.
|
||||
"""
|
||||
|
||||
def __init__(self, player: ba.Player, respawn_time: float):
|
||||
"""
|
||||
Instantiate with a given ba.Player and respawn_time (in seconds)
|
||||
"""
|
||||
# FIXME; tidy up
|
||||
# pylint: disable=too-many-locals
|
||||
activity = ba.getactivity()
|
||||
self._visible = True
|
||||
if isinstance(ba.getsession(), ba.TeamsSession):
|
||||
on_right = player.team.get_id() % 2 == 1
|
||||
# store a list of icons in the team
|
||||
try:
|
||||
respawn_icons = (
|
||||
player.team.gamedata['_spaz_respawn_icons_right'])
|
||||
except Exception:
|
||||
respawn_icons = (
|
||||
player.team.gamedata['_spaz_respawn_icons_right']) = {}
|
||||
offs_extra = -20
|
||||
else:
|
||||
on_right = False
|
||||
# Store a list of icons in the activity.
|
||||
# FIXME: Need an elegant way to store our
|
||||
# shared stuff with the activity.
|
||||
try:
|
||||
respawn_icons = activity.spaz_respawn_icons_right
|
||||
except Exception:
|
||||
respawn_icons = activity.spaz_respawn_icons_right = {}
|
||||
if isinstance(activity.session, ba.FreeForAllSession):
|
||||
offs_extra = -150
|
||||
else:
|
||||
offs_extra = -20
|
||||
|
||||
try:
|
||||
mask_tex = (player.team.gamedata['_spaz_respawn_icons_mask_tex'])
|
||||
except Exception:
|
||||
mask_tex = player.team.gamedata['_spaz_respawn_icons_mask_tex'] = (
|
||||
ba.gettexture('characterIconMask'))
|
||||
|
||||
# now find the first unused slot and use that
|
||||
index = 0
|
||||
while (index in respawn_icons and respawn_icons[index]() is not None
|
||||
and respawn_icons[index]().visible):
|
||||
index += 1
|
||||
respawn_icons[index] = weakref.ref(self)
|
||||
|
||||
offs = offs_extra + index * -53
|
||||
icon = player.get_icon()
|
||||
texture = icon['texture']
|
||||
h_offs = -10
|
||||
ipos = (-40 - h_offs if on_right else 40 + h_offs, -180 + offs)
|
||||
self._image: Optional[ba.Actor] = ba.Actor(
|
||||
ba.newnode('image',
|
||||
attrs={
|
||||
'texture': texture,
|
||||
'tint_texture': icon['tint_texture'],
|
||||
'tint_color': icon['tint_color'],
|
||||
'tint2_color': icon['tint2_color'],
|
||||
'mask_texture': mask_tex,
|
||||
'position': ipos,
|
||||
'scale': (32, 32),
|
||||
'opacity': 1.0,
|
||||
'absolute_scale': True,
|
||||
'attach': 'topRight' if on_right else 'topLeft'
|
||||
}))
|
||||
|
||||
assert self._image.node
|
||||
ba.animate(self._image.node, 'opacity', {0.0: 0, 0.2: 0.7})
|
||||
|
||||
npos = (-40 - h_offs if on_right else 40 + h_offs, -205 + 49 + offs)
|
||||
self._name: Optional[ba.Actor] = ba.Actor(
|
||||
ba.newnode('text',
|
||||
attrs={
|
||||
'v_attach': 'top',
|
||||
'h_attach': 'right' if on_right else 'left',
|
||||
'text': ba.Lstr(value=player.get_name()),
|
||||
'maxwidth': 100,
|
||||
'h_align': 'center',
|
||||
'v_align': 'center',
|
||||
'shadow': 1.0,
|
||||
'flatness': 1.0,
|
||||
'color': ba.safecolor(icon['tint_color']),
|
||||
'scale': 0.5,
|
||||
'position': npos
|
||||
}))
|
||||
|
||||
assert self._name.node
|
||||
ba.animate(self._name.node, 'scale', {0: 0, 0.1: 0.5})
|
||||
|
||||
tpos = (-60 - h_offs if on_right else 60 + h_offs, -192 + offs)
|
||||
self._text: Optional[ba.Actor] = ba.Actor(
|
||||
ba.newnode('text',
|
||||
attrs={
|
||||
'position': tpos,
|
||||
'h_attach': 'right' if on_right else 'left',
|
||||
'h_align': 'right' if on_right else 'left',
|
||||
'scale': 0.9,
|
||||
'shadow': 0.5,
|
||||
'flatness': 0.5,
|
||||
'v_attach': 'top',
|
||||
'color': ba.safecolor(icon['tint_color']),
|
||||
'text': ''
|
||||
}))
|
||||
|
||||
assert self._text.node
|
||||
ba.animate(self._text.node, 'scale', {0: 0, 0.1: 0.9})
|
||||
|
||||
self._respawn_time = ba.time() + respawn_time
|
||||
self._update()
|
||||
self._timer: Optional[ba.Timer] = ba.Timer(1.0,
|
||||
ba.WeakCall(self._update),
|
||||
repeat=True)
|
||||
|
||||
@property
|
||||
def visible(self) -> bool:
|
||||
"""Is this icon still visible?"""
|
||||
return self._visible
|
||||
|
||||
def _update(self) -> None:
|
||||
remaining = int(
|
||||
round(self._respawn_time -
|
||||
ba.time(timeformat=ba.TimeFormat.MILLISECONDS)) / 1000.0)
|
||||
if remaining > 0:
|
||||
assert self._text is not None
|
||||
if self._text.node:
|
||||
self._text.node.text = str(remaining)
|
||||
else:
|
||||
self._clear()
|
||||
|
||||
def _clear(self) -> None:
|
||||
self._visible = False
|
||||
self._image = self._text = self._timer = self._name = None
|
||||
383
assets/src/data/scripts/bastd/actor/scoreboard.py
Normal file
383
assets/src/data/scripts/bastd/actor/scoreboard.py
Normal file
@ -0,0 +1,383 @@
|
||||
"""Defines ScoreBoard Actor and related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Optional, Sequence, Dict, Union
|
||||
|
||||
# This could use some tidying up when I get a chance..
|
||||
# pylint: disable=too-many-statements
|
||||
|
||||
|
||||
class _Entry:
|
||||
|
||||
def __init__(self, scoreboard: Scoreboard, team: ba.Team, do_cover: bool,
|
||||
scale: float, label: Optional[ba.Lstr], flash_length: float):
|
||||
self._scoreboard = weakref.ref(scoreboard)
|
||||
self._do_cover = do_cover
|
||||
self._scale = scale
|
||||
self._flash_length = flash_length
|
||||
self._width = 140.0 * self._scale
|
||||
self._height = 32.0 * self._scale
|
||||
self._bar_width = 2.0 * self._scale
|
||||
self._bar_height = 32.0 * self._scale
|
||||
self._bar_tex = self._backing_tex = ba.gettexture('bar')
|
||||
self._cover_tex = ba.gettexture('uiAtlas')
|
||||
self._model = ba.getmodel('meterTransparent')
|
||||
self._pos: Optional[Sequence[float]] = None
|
||||
self._flash_timer: Optional[ba.Timer] = None
|
||||
self._flash_counter: Optional[int] = None
|
||||
self._flash_colors: Optional[bool] = None
|
||||
self._score: Optional[int] = None
|
||||
|
||||
safe_team_color = ba.safecolor(team.color, target_intensity=1.0)
|
||||
|
||||
# FIXME: Should not do things conditionally for vr-mode, as there may
|
||||
# be non-vr clients connected.
|
||||
vrmode = ba.app.vr_mode
|
||||
|
||||
if self._do_cover:
|
||||
if vrmode:
|
||||
self._backing_color = [0.1 + c * 0.1 for c in safe_team_color]
|
||||
else:
|
||||
self._backing_color = [
|
||||
0.05 + c * 0.17 for c in safe_team_color
|
||||
]
|
||||
else:
|
||||
self._backing_color = [0.05 + c * 0.1 for c in safe_team_color]
|
||||
|
||||
opacity = (0.8 if vrmode else 0.8) if self._do_cover else 0.5
|
||||
self._backing = ba.Actor(
|
||||
ba.newnode('image',
|
||||
attrs={
|
||||
'scale': (self._width, self._height),
|
||||
'opacity': opacity,
|
||||
'color': self._backing_color,
|
||||
'vr_depth': -3,
|
||||
'attach': 'topLeft',
|
||||
'texture': self._backing_tex
|
||||
}))
|
||||
|
||||
self._barcolor = safe_team_color
|
||||
self._bar = ba.Actor(
|
||||
ba.newnode('image',
|
||||
attrs={
|
||||
'opacity': 0.7,
|
||||
'color': self._barcolor,
|
||||
'attach': 'topLeft',
|
||||
'texture': self._bar_tex
|
||||
}))
|
||||
|
||||
self._bar_scale = ba.newnode('combine',
|
||||
owner=self._bar.node,
|
||||
attrs={
|
||||
'size': 2,
|
||||
'input0': self._bar_width,
|
||||
'input1': self._bar_height
|
||||
})
|
||||
assert self._bar.node
|
||||
self._bar_scale.connectattr('output', self._bar.node, 'scale')
|
||||
self._bar_position = ba.newnode('combine',
|
||||
owner=self._bar.node,
|
||||
attrs={
|
||||
'size': 2,
|
||||
'input0': 0,
|
||||
'input1': 0
|
||||
})
|
||||
self._bar_position.connectattr('output', self._bar.node, 'position')
|
||||
self._cover_color = safe_team_color
|
||||
if self._do_cover:
|
||||
self._cover = ba.Actor(
|
||||
ba.newnode('image',
|
||||
attrs={
|
||||
'scale':
|
||||
(self._width * 1.15, self._height * 1.6),
|
||||
'opacity': 1.0,
|
||||
'color': self._cover_color,
|
||||
'vr_depth': 2,
|
||||
'attach': 'topLeft',
|
||||
'texture': self._cover_tex,
|
||||
'model_transparent': self._model
|
||||
}))
|
||||
|
||||
clr = safe_team_color
|
||||
maxwidth = 130.0 * (1.0 - scoreboard.score_split)
|
||||
flatness = ((1.0 if vrmode else 0.5) if self._do_cover else 1.0)
|
||||
self._score_text = ba.Actor(
|
||||
ba.newnode('text',
|
||||
attrs={
|
||||
'h_attach': 'left',
|
||||
'v_attach': 'top',
|
||||
'h_align': 'right',
|
||||
'v_align': 'center',
|
||||
'maxwidth': maxwidth,
|
||||
'vr_depth': 2,
|
||||
'scale': self._scale * 0.9,
|
||||
'text': '',
|
||||
'shadow': 1.0 if vrmode else 0.5,
|
||||
'flatness': flatness,
|
||||
'color': clr
|
||||
}))
|
||||
|
||||
clr = safe_team_color
|
||||
|
||||
team_name_label: Union[str, ba.Lstr]
|
||||
if label is not None:
|
||||
team_name_label = label
|
||||
else:
|
||||
team_name_label = team.name
|
||||
|
||||
# we do our own clipping here; should probably try to tap into some
|
||||
# existing functionality
|
||||
if isinstance(team_name_label, ba.Lstr):
|
||||
|
||||
# hmmm; if the team-name is a non-translatable value lets go
|
||||
# ahead and clip it otherwise we leave it as-is so
|
||||
# translation can occur..
|
||||
if team_name_label.is_flat_value():
|
||||
val = team_name_label.evaluate()
|
||||
if len(val) > 10:
|
||||
team_name_label = ba.Lstr(value=val[:10] + '...')
|
||||
else:
|
||||
if len(team_name_label) > 10:
|
||||
team_name_label = team_name_label[:10] + '...'
|
||||
team_name_label = ba.Lstr(value=team_name_label)
|
||||
|
||||
flatness = ((1.0 if vrmode else 0.5) if self._do_cover else 1.0)
|
||||
self._name_text = ba.Actor(
|
||||
ba.newnode('text',
|
||||
attrs={
|
||||
'h_attach': 'left',
|
||||
'v_attach': 'top',
|
||||
'h_align': 'left',
|
||||
'v_align': 'center',
|
||||
'vr_depth': 2,
|
||||
'scale': self._scale * 0.9,
|
||||
'shadow': 1.0 if vrmode else 0.5,
|
||||
'flatness': flatness,
|
||||
'maxwidth': 130 * scoreboard.score_split,
|
||||
'text': team_name_label,
|
||||
'color': clr + (1.0, )
|
||||
}))
|
||||
|
||||
def flash(self, countdown: bool, extra_flash: bool) -> None:
|
||||
"""Flash momentarily."""
|
||||
self._flash_timer = ba.Timer(0.1,
|
||||
ba.WeakCall(self._do_flash),
|
||||
repeat=True)
|
||||
if countdown:
|
||||
self._flash_counter = 10
|
||||
else:
|
||||
self._flash_counter = int(20.0 * self._flash_length)
|
||||
if extra_flash:
|
||||
self._flash_counter *= 4
|
||||
self._set_flash_colors(True)
|
||||
|
||||
def set_position(self, position: Sequence[float]) -> None:
|
||||
"""Set the entry's position."""
|
||||
# abort if we've been killed
|
||||
if not self._backing.node:
|
||||
return
|
||||
self._pos = tuple(position)
|
||||
self._backing.node.position = (position[0] + self._width / 2,
|
||||
position[1] - self._height / 2)
|
||||
if self._do_cover:
|
||||
assert self._cover.node
|
||||
self._cover.node.position = (position[0] + self._width / 2,
|
||||
position[1] - self._height / 2)
|
||||
self._bar_position.input0 = self._pos[0] + self._bar_width / 2
|
||||
self._bar_position.input1 = self._pos[1] - self._bar_height / 2
|
||||
assert self._score_text.node
|
||||
self._score_text.node.position = (self._pos[0] + self._width -
|
||||
7.0 * self._scale,
|
||||
self._pos[1] - self._bar_height +
|
||||
16.0 * self._scale)
|
||||
assert self._name_text.node
|
||||
self._name_text.node.position = (self._pos[0] + 7.0 * self._scale,
|
||||
self._pos[1] - self._bar_height +
|
||||
16.0 * self._scale)
|
||||
|
||||
def _set_flash_colors(self, flash: bool) -> None:
|
||||
self._flash_colors = flash
|
||||
|
||||
def _safesetattr(node: Optional[ba.Node], attr: str, val: Any) -> None:
|
||||
if node:
|
||||
setattr(node, attr, val)
|
||||
|
||||
if flash:
|
||||
scale = 2.0
|
||||
_safesetattr(
|
||||
self._backing.node, "color",
|
||||
(self._backing_color[0] * scale, self._backing_color[1] *
|
||||
scale, self._backing_color[2] * scale))
|
||||
_safesetattr(self._bar.node, "color",
|
||||
(self._barcolor[0] * scale, self._barcolor[1] * scale,
|
||||
self._barcolor[2] * scale))
|
||||
if self._do_cover:
|
||||
_safesetattr(
|
||||
self._cover.node, "color",
|
||||
(self._cover_color[0] * scale, self._cover_color[1] *
|
||||
scale, self._cover_color[2] * scale))
|
||||
else:
|
||||
_safesetattr(self._backing.node, "color", self._backing_color)
|
||||
_safesetattr(self._bar.node, "color", self._barcolor)
|
||||
if self._do_cover:
|
||||
_safesetattr(self._cover.node, "color", self._cover_color)
|
||||
|
||||
def _do_flash(self) -> None:
|
||||
assert self._flash_counter is not None
|
||||
if self._flash_counter <= 0:
|
||||
self._set_flash_colors(False)
|
||||
else:
|
||||
self._flash_counter -= 1
|
||||
self._set_flash_colors(not self._flash_colors)
|
||||
|
||||
def set_value(self,
|
||||
score: int,
|
||||
max_score: int = None,
|
||||
countdown: bool = False,
|
||||
flash: bool = True,
|
||||
show_value: bool = True) -> None:
|
||||
"""Set the value for the scoreboard entry."""
|
||||
|
||||
# if we have no score yet, just set it.. otherwise compare
|
||||
# and see if we should flash
|
||||
if self._score is None:
|
||||
self._score = score
|
||||
else:
|
||||
if score > self._score or (countdown and score < self._score):
|
||||
extra_flash = (max_score is not None and score >= max_score
|
||||
and not countdown) or (countdown and score == 0)
|
||||
if flash:
|
||||
self.flash(countdown, extra_flash)
|
||||
self._score = score
|
||||
|
||||
if max_score is None:
|
||||
self._bar_width = 0.0
|
||||
else:
|
||||
if countdown:
|
||||
self._bar_width = max(
|
||||
2.0 * self._scale,
|
||||
self._width * (1.0 - (float(score) / max_score)))
|
||||
else:
|
||||
self._bar_width = max(
|
||||
2.0 * self._scale,
|
||||
self._width * (min(1.0,
|
||||
float(score) / max_score)))
|
||||
|
||||
cur_width = self._bar_scale.input0
|
||||
ba.animate(self._bar_scale, 'input0', {
|
||||
0.0: cur_width,
|
||||
0.25: self._bar_width
|
||||
})
|
||||
self._bar_scale.input1 = self._bar_height
|
||||
cur_x = self._bar_position.input0
|
||||
assert self._pos is not None
|
||||
ba.animate(self._bar_position, 'input0', {
|
||||
0.0: cur_x,
|
||||
0.25: self._pos[0] + self._bar_width / 2
|
||||
})
|
||||
self._bar_position.input1 = self._pos[1] - self._bar_height / 2
|
||||
assert self._score_text.node
|
||||
if show_value:
|
||||
self._score_text.node.text = str(score)
|
||||
else:
|
||||
self._score_text.node.text = ''
|
||||
|
||||
|
||||
class _EntryProxy:
|
||||
"""Encapsulates adding/removing of a scoreboard Entry."""
|
||||
|
||||
def __init__(self, scoreboard: Scoreboard, team: ba.Team):
|
||||
self._scoreboard = weakref.ref(scoreboard)
|
||||
# have to store ID here instead of a weak-ref since the team will be
|
||||
# dead when we die and need to remove it
|
||||
self._team_id = team.get_id()
|
||||
|
||||
def __del__(self) -> None:
|
||||
scoreboard = self._scoreboard()
|
||||
# remove our team from the scoreboard if its still around
|
||||
if scoreboard is not None:
|
||||
scoreboard.remove_team(self._team_id)
|
||||
|
||||
|
||||
class Scoreboard:
|
||||
"""A display for player or team scores during a game.
|
||||
|
||||
category: Gameplay Classes
|
||||
"""
|
||||
|
||||
def __init__(self, label: ba.Lstr = None, score_split: float = 0.7):
|
||||
"""Instantiate a score-board.
|
||||
|
||||
Label can be something like 'points' and will
|
||||
show up on boards if provided.
|
||||
"""
|
||||
self._flat_tex = ba.gettexture("null")
|
||||
self._entries: Dict[int, _Entry] = {}
|
||||
self._label = label
|
||||
self.score_split = score_split
|
||||
|
||||
# for free-for-all we go simpler since we have one per player
|
||||
self._pos: Sequence[float]
|
||||
if isinstance(ba.getsession(), ba.FreeForAllSession):
|
||||
self._do_cover = False
|
||||
self._spacing = 35.0
|
||||
self._pos = (17.0, -65.0)
|
||||
self._scale = 0.8
|
||||
self._flash_length = 0.5
|
||||
else:
|
||||
self._do_cover = True
|
||||
self._spacing = 50.0
|
||||
self._pos = (20.0, -70.0)
|
||||
self._scale = 1.0
|
||||
self._flash_length = 1.0
|
||||
|
||||
def set_team_value(self,
|
||||
team: ba.Team,
|
||||
score: int,
|
||||
max_score: int = None,
|
||||
countdown: bool = False,
|
||||
flash: bool = True,
|
||||
show_value: bool = True) -> None:
|
||||
"""Update the score-board display for the given ba.Team."""
|
||||
if not team.get_id() in self._entries:
|
||||
self._add_team(team)
|
||||
# create a proxy in the team which will kill
|
||||
# our entry when it dies (for convenience)
|
||||
if '_scoreboard_entry' in team.gamedata:
|
||||
raise Exception("existing _EntryProxy found")
|
||||
team.gamedata['_scoreboard_entry'] = _EntryProxy(self, team)
|
||||
# now set the entry..
|
||||
self._entries[team.get_id()].set_value(score=score,
|
||||
max_score=max_score,
|
||||
countdown=countdown,
|
||||
flash=flash,
|
||||
show_value=show_value)
|
||||
|
||||
def _add_team(self, team: ba.Team) -> None:
|
||||
if team.get_id() in self._entries:
|
||||
raise Exception('Duplicate team add')
|
||||
self._entries[team.get_id()] = _Entry(self,
|
||||
team,
|
||||
do_cover=self._do_cover,
|
||||
scale=self._scale,
|
||||
label=self._label,
|
||||
flash_length=self._flash_length)
|
||||
self._update_teams()
|
||||
|
||||
def remove_team(self, team_id: int) -> None:
|
||||
"""Remove the team with the given id from the scoreboard."""
|
||||
del self._entries[team_id]
|
||||
self._update_teams()
|
||||
|
||||
def _update_teams(self) -> None:
|
||||
pos = list(self._pos)
|
||||
for entry in list(self._entries.values()):
|
||||
entry.set_position(pos)
|
||||
pos[1] -= self._spacing * self._scale
|
||||
105
assets/src/data/scripts/bastd/actor/spawner.py
Normal file
105
assets/src/data/scripts/bastd/actor/spawner.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""Defines some lovely Actor(s)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Sequence, Callable
|
||||
|
||||
|
||||
# FIXME: Should make this an Actor.
|
||||
class Spawner:
|
||||
"""Utility for delayed spawning of objects.
|
||||
|
||||
category: Gameplay Classes
|
||||
|
||||
Creates a light flash and sends a ba.Spawner.SpawnMessage
|
||||
to the current activity after a delay.
|
||||
"""
|
||||
|
||||
class SpawnMessage:
|
||||
"""Spawn message sent by a ba.Spawner after its delay has passed.
|
||||
|
||||
category: Message Classes
|
||||
|
||||
Attributes:
|
||||
|
||||
spawner
|
||||
The ba.Spawner we came from.
|
||||
|
||||
data
|
||||
The data object passed by the user.
|
||||
|
||||
pt
|
||||
The spawn position.
|
||||
"""
|
||||
|
||||
def __init__(self, spawner: Spawner, data: Any, pt: Sequence[float]):
|
||||
"""Instantiate with the given values."""
|
||||
self.spawner = spawner
|
||||
self.data = data
|
||||
self.pt = pt # pylint: disable=invalid-name
|
||||
|
||||
def __init__(self,
|
||||
data: Any = None,
|
||||
pt: Sequence[float] = (0, 0, 0),
|
||||
spawn_time: float = 1.0,
|
||||
send_spawn_message: bool = True,
|
||||
spawn_callback: Callable[[], Any] = None):
|
||||
"""Instantiate a Spawner.
|
||||
|
||||
Requires some custom data, a position,
|
||||
and a spawn-time in seconds.
|
||||
"""
|
||||
self._spawn_callback = spawn_callback
|
||||
self._send_spawn_message = send_spawn_message
|
||||
self._spawner_sound = ba.getsound('swip2')
|
||||
self._data = data
|
||||
self._pt = pt
|
||||
# create a light where the spawn will happen
|
||||
self._light = ba.newnode('light',
|
||||
attrs={
|
||||
'position': tuple(pt),
|
||||
'radius': 0.1,
|
||||
'color': (1.0, 0.1, 0.1),
|
||||
'lights_volumes': False
|
||||
})
|
||||
scl = float(spawn_time) / 3.75
|
||||
min_val = 0.4
|
||||
max_val = 0.7
|
||||
ba.playsound(self._spawner_sound, position=self._light.position)
|
||||
ba.animate(
|
||||
self._light, 'intensity', {
|
||||
0.0: 0.0,
|
||||
0.25 * scl: max_val,
|
||||
0.500 * scl: min_val,
|
||||
0.750 * scl: max_val,
|
||||
1.000 * scl: min_val,
|
||||
1.250 * scl: 1.1 * max_val,
|
||||
1.500 * scl: min_val,
|
||||
1.750 * scl: 1.2 * max_val,
|
||||
2.000 * scl: min_val,
|
||||
2.250 * scl: 1.3 * max_val,
|
||||
2.500 * scl: min_val,
|
||||
2.750 * scl: 1.4 * max_val,
|
||||
3.000 * scl: min_val,
|
||||
3.250 * scl: 1.5 * max_val,
|
||||
3.500 * scl: min_val,
|
||||
3.750 * scl: 2.0,
|
||||
4.000 * scl: 0.0
|
||||
})
|
||||
ba.timer(spawn_time, self._spawn)
|
||||
|
||||
def _spawn(self) -> None:
|
||||
ba.timer(1.0, self._light.delete)
|
||||
if self._spawn_callback is not None:
|
||||
self._spawn_callback()
|
||||
if self._send_spawn_message:
|
||||
# only run if our activity still exists
|
||||
activity = ba.getactivity()
|
||||
if activity is not None:
|
||||
activity.handlemessage(
|
||||
self.SpawnMessage(self, self._data, self._pt))
|
||||
1407
assets/src/data/scripts/bastd/actor/spaz.py
Normal file
1407
assets/src/data/scripts/bastd/actor/spaz.py
Normal file
File diff suppressed because it is too large
Load Diff
947
assets/src/data/scripts/bastd/actor/spazappearance.py
Normal file
947
assets/src/data/scripts/bastd/actor/spazappearance.py
Normal file
@ -0,0 +1,947 @@
|
||||
"""Appearance functionality for spazzes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
def get_appearances(include_locked: bool = False) -> List[str]:
|
||||
"""Get the list of available spaz appearances."""
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
disallowed = []
|
||||
if not include_locked:
|
||||
# hmm yeah this'll be tough to hack...
|
||||
if not _ba.get_purchased('characters.santa'):
|
||||
disallowed.append('Santa Claus')
|
||||
if not _ba.get_purchased('characters.frosty'):
|
||||
disallowed.append('Frosty')
|
||||
if not _ba.get_purchased('characters.bones'):
|
||||
disallowed.append('Bones')
|
||||
if not _ba.get_purchased('characters.bernard'):
|
||||
disallowed.append('Bernard')
|
||||
if not _ba.get_purchased('characters.pixie'):
|
||||
disallowed.append('Pixel')
|
||||
if not _ba.get_purchased('characters.pascal'):
|
||||
disallowed.append('Pascal')
|
||||
if not _ba.get_purchased('characters.actionhero'):
|
||||
disallowed.append('Todd McBurton')
|
||||
if not _ba.get_purchased('characters.taobaomascot'):
|
||||
disallowed.append('Taobao Mascot')
|
||||
if not _ba.get_purchased('characters.agent'):
|
||||
disallowed.append('Agent Johnson')
|
||||
if not _ba.get_purchased('characters.jumpsuit'):
|
||||
disallowed.append('Lee')
|
||||
if not _ba.get_purchased('characters.assassin'):
|
||||
disallowed.append('Zola')
|
||||
if not _ba.get_purchased('characters.wizard'):
|
||||
disallowed.append('Grumbledorf')
|
||||
if not _ba.get_purchased('characters.cowboy'):
|
||||
disallowed.append('Butch')
|
||||
if not _ba.get_purchased('characters.witch'):
|
||||
disallowed.append('Witch')
|
||||
if not _ba.get_purchased('characters.warrior'):
|
||||
disallowed.append('Warrior')
|
||||
if not _ba.get_purchased('characters.superhero'):
|
||||
disallowed.append('Middle-Man')
|
||||
if not _ba.get_purchased('characters.alien'):
|
||||
disallowed.append('Alien')
|
||||
if not _ba.get_purchased('characters.oldlady'):
|
||||
disallowed.append('OldLady')
|
||||
if not _ba.get_purchased('characters.gladiator'):
|
||||
disallowed.append('Gladiator')
|
||||
if not _ba.get_purchased('characters.wrestler'):
|
||||
disallowed.append('Wrestler')
|
||||
if not _ba.get_purchased('characters.operasinger'):
|
||||
disallowed.append('Gretel')
|
||||
if not _ba.get_purchased('characters.robot'):
|
||||
disallowed.append('Robot')
|
||||
if not _ba.get_purchased('characters.cyborg'):
|
||||
disallowed.append('B-9000')
|
||||
if not _ba.get_purchased('characters.bunny'):
|
||||
disallowed.append('Easter Bunny')
|
||||
if not _ba.get_purchased('characters.kronk'):
|
||||
disallowed.append('Kronk')
|
||||
if not _ba.get_purchased('characters.zoe'):
|
||||
disallowed.append('Zoe')
|
||||
if not _ba.get_purchased('characters.jackmorgan'):
|
||||
disallowed.append('Jack Morgan')
|
||||
if not _ba.get_purchased('characters.mel'):
|
||||
disallowed.append('Mel')
|
||||
if not _ba.get_purchased('characters.snakeshadow'):
|
||||
disallowed.append('Snake Shadow')
|
||||
return [
|
||||
s for s in list(ba.app.spaz_appearances.keys()) if s not in disallowed
|
||||
]
|
||||
|
||||
|
||||
class Appearance:
|
||||
"""Create and fill out one of these suckers to define a spaz appearance"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
if self.name in ba.app.spaz_appearances:
|
||||
raise Exception("spaz appearance name \"" + self.name +
|
||||
"\" already exists.")
|
||||
ba.app.spaz_appearances[self.name] = self
|
||||
self.color_texture = ""
|
||||
self.color_mask_texture = ""
|
||||
self.icon_texture = ""
|
||||
self.icon_mask_texture = ""
|
||||
self.head_model = ""
|
||||
self.torso_model = ""
|
||||
self.pelvis_model = ""
|
||||
self.upper_arm_model = ""
|
||||
self.forearm_model = ""
|
||||
self.hand_model = ""
|
||||
self.upper_leg_model = ""
|
||||
self.lower_leg_model = ""
|
||||
self.toes_model = ""
|
||||
self.jump_sounds: List[str] = []
|
||||
self.attack_sounds: List[str] = []
|
||||
self.impact_sounds: List[str] = []
|
||||
self.death_sounds: List[str] = []
|
||||
self.pickup_sounds: List[str] = []
|
||||
self.fall_sounds: List[str] = []
|
||||
self.style = 'spaz'
|
||||
self.default_color: Optional[Tuple[float, float, float]] = None
|
||||
self.default_highlight: Optional[Tuple[float, float, float]] = None
|
||||
|
||||
|
||||
def register_appearances() -> None:
|
||||
"""Register our builtin spaz appearances."""
|
||||
|
||||
# this is quite ugly but will be going away so not worth cleaning up
|
||||
# pylint: disable=invalid-name
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-statements
|
||||
|
||||
# Spaz #######################################
|
||||
t = Appearance("Spaz")
|
||||
t.color_texture = "neoSpazColor"
|
||||
t.color_mask_texture = "neoSpazColorMask"
|
||||
t.icon_texture = "neoSpazIcon"
|
||||
t.icon_mask_texture = "neoSpazIconColorMask"
|
||||
t.head_model = "neoSpazHead"
|
||||
t.torso_model = "neoSpazTorso"
|
||||
t.pelvis_model = "neoSpazPelvis"
|
||||
t.upper_arm_model = "neoSpazUpperArm"
|
||||
t.forearm_model = "neoSpazForeArm"
|
||||
t.hand_model = "neoSpazHand"
|
||||
t.upper_leg_model = "neoSpazUpperLeg"
|
||||
t.lower_leg_model = "neoSpazLowerLeg"
|
||||
t.toes_model = "neoSpazToes"
|
||||
t.jump_sounds = ["spazJump01", "spazJump02", "spazJump03", "spazJump04"]
|
||||
t.attack_sounds = [
|
||||
"spazAttack01", "spazAttack02", "spazAttack03", "spazAttack04"
|
||||
]
|
||||
t.impact_sounds = [
|
||||
"spazImpact01", "spazImpact02", "spazImpact03", "spazImpact04"
|
||||
]
|
||||
t.death_sounds = ["spazDeath01"]
|
||||
t.pickup_sounds = ["spazPickup01"]
|
||||
t.fall_sounds = ["spazFall01"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Zoe #####################################
|
||||
t = Appearance("Zoe")
|
||||
t.color_texture = "zoeColor"
|
||||
t.color_mask_texture = "zoeColorMask"
|
||||
t.default_color = (0.6, 0.6, 0.6)
|
||||
t.default_highlight = (0, 1, 0)
|
||||
t.icon_texture = "zoeIcon"
|
||||
t.icon_mask_texture = "zoeIconColorMask"
|
||||
t.head_model = "zoeHead"
|
||||
t.torso_model = "zoeTorso"
|
||||
t.pelvis_model = "zoePelvis"
|
||||
t.upper_arm_model = "zoeUpperArm"
|
||||
t.forearm_model = "zoeForeArm"
|
||||
t.hand_model = "zoeHand"
|
||||
t.upper_leg_model = "zoeUpperLeg"
|
||||
t.lower_leg_model = "zoeLowerLeg"
|
||||
t.toes_model = "zoeToes"
|
||||
t.jump_sounds = ["zoeJump01", "zoeJump02", "zoeJump03"]
|
||||
t.attack_sounds = [
|
||||
"zoeAttack01", "zoeAttack02", "zoeAttack03", "zoeAttack04"
|
||||
]
|
||||
t.impact_sounds = [
|
||||
"zoeImpact01", "zoeImpact02", "zoeImpact03", "zoeImpact04"
|
||||
]
|
||||
t.death_sounds = ["zoeDeath01"]
|
||||
t.pickup_sounds = ["zoePickup01"]
|
||||
t.fall_sounds = ["zoeFall01"]
|
||||
t.style = 'female'
|
||||
|
||||
# Ninja ##########################################
|
||||
t = Appearance("Snake Shadow")
|
||||
t.color_texture = "ninjaColor"
|
||||
t.color_mask_texture = "ninjaColorMask"
|
||||
t.default_color = (1, 1, 1)
|
||||
t.default_highlight = (0.55, 0.8, 0.55)
|
||||
t.icon_texture = "ninjaIcon"
|
||||
t.icon_mask_texture = "ninjaIconColorMask"
|
||||
t.head_model = "ninjaHead"
|
||||
t.torso_model = "ninjaTorso"
|
||||
t.pelvis_model = "ninjaPelvis"
|
||||
t.upper_arm_model = "ninjaUpperArm"
|
||||
t.forearm_model = "ninjaForeArm"
|
||||
t.hand_model = "ninjaHand"
|
||||
t.upper_leg_model = "ninjaUpperLeg"
|
||||
t.lower_leg_model = "ninjaLowerLeg"
|
||||
t.toes_model = "ninjaToes"
|
||||
ninja_attacks = ['ninjaAttack' + str(i + 1) + '' for i in range(7)]
|
||||
ninja_hits = ['ninjaHit' + str(i + 1) + '' for i in range(8)]
|
||||
ninja_jumps = ['ninjaAttack' + str(i + 1) + '' for i in range(7)]
|
||||
t.jump_sounds = ninja_jumps
|
||||
t.attack_sounds = ninja_attacks
|
||||
t.impact_sounds = ninja_hits
|
||||
t.death_sounds = ["ninjaDeath1"]
|
||||
t.pickup_sounds = ninja_attacks
|
||||
t.fall_sounds = ["ninjaFall1"]
|
||||
t.style = 'ninja'
|
||||
|
||||
# Barbarian #####################################
|
||||
t = Appearance("Kronk")
|
||||
t.color_texture = "kronk"
|
||||
t.color_mask_texture = "kronkColorMask"
|
||||
t.default_color = (0.4, 0.5, 0.4)
|
||||
t.default_highlight = (1, 0.5, 0.3)
|
||||
t.icon_texture = "kronkIcon"
|
||||
t.icon_mask_texture = "kronkIconColorMask"
|
||||
t.head_model = "kronkHead"
|
||||
t.torso_model = "kronkTorso"
|
||||
t.pelvis_model = "kronkPelvis"
|
||||
t.upper_arm_model = "kronkUpperArm"
|
||||
t.forearm_model = "kronkForeArm"
|
||||
t.hand_model = "kronkHand"
|
||||
t.upper_leg_model = "kronkUpperLeg"
|
||||
t.lower_leg_model = "kronkLowerLeg"
|
||||
t.toes_model = "kronkToes"
|
||||
kronk_sounds = [
|
||||
"kronk1", "kronk2", "kronk3", "kronk4", "kronk5", "kronk6", "kronk7",
|
||||
"kronk8", "kronk9", "kronk10"
|
||||
]
|
||||
t.jump_sounds = kronk_sounds
|
||||
t.attack_sounds = kronk_sounds
|
||||
t.impact_sounds = kronk_sounds
|
||||
t.death_sounds = ["kronkDeath"]
|
||||
t.pickup_sounds = kronk_sounds
|
||||
t.fall_sounds = ["kronkFall"]
|
||||
t.style = 'kronk'
|
||||
|
||||
# Chef ###########################################
|
||||
t = Appearance("Mel")
|
||||
t.color_texture = "melColor"
|
||||
t.color_mask_texture = "melColorMask"
|
||||
t.default_color = (1, 1, 1)
|
||||
t.default_highlight = (0.1, 0.6, 0.1)
|
||||
t.icon_texture = "melIcon"
|
||||
t.icon_mask_texture = "melIconColorMask"
|
||||
t.head_model = "melHead"
|
||||
t.torso_model = "melTorso"
|
||||
t.pelvis_model = "kronkPelvis"
|
||||
t.upper_arm_model = "melUpperArm"
|
||||
t.forearm_model = "melForeArm"
|
||||
t.hand_model = "melHand"
|
||||
t.upper_leg_model = "melUpperLeg"
|
||||
t.lower_leg_model = "melLowerLeg"
|
||||
t.toes_model = "melToes"
|
||||
mel_sounds = [
|
||||
"mel01", "mel02", "mel03", "mel04", "mel05", "mel06", "mel07", "mel08",
|
||||
"mel09", "mel10"
|
||||
]
|
||||
t.attack_sounds = mel_sounds
|
||||
t.jump_sounds = mel_sounds
|
||||
t.impact_sounds = mel_sounds
|
||||
t.death_sounds = ["melDeath01"]
|
||||
t.pickup_sounds = mel_sounds
|
||||
t.fall_sounds = ["melFall01"]
|
||||
t.style = 'mel'
|
||||
|
||||
# Pirate #######################################
|
||||
t = Appearance("Jack Morgan")
|
||||
t.color_texture = "jackColor"
|
||||
t.color_mask_texture = "jackColorMask"
|
||||
t.default_color = (1, 0.2, 0.1)
|
||||
t.default_highlight = (1, 1, 0)
|
||||
t.icon_texture = "jackIcon"
|
||||
t.icon_mask_texture = "jackIconColorMask"
|
||||
t.head_model = "jackHead"
|
||||
t.torso_model = "jackTorso"
|
||||
t.pelvis_model = "kronkPelvis"
|
||||
t.upper_arm_model = "jackUpperArm"
|
||||
t.forearm_model = "jackForeArm"
|
||||
t.hand_model = "jackHand"
|
||||
t.upper_leg_model = "jackUpperLeg"
|
||||
t.lower_leg_model = "jackLowerLeg"
|
||||
t.toes_model = "jackToes"
|
||||
hit_sounds = [
|
||||
"jackHit01", "jackHit02", "jackHit03", "jackHit04", "jackHit05",
|
||||
"jackHit06", "jackHit07"
|
||||
]
|
||||
sounds = ["jack01", "jack02", "jack03", "jack04", "jack05", "jack06"]
|
||||
t.attack_sounds = sounds
|
||||
t.jump_sounds = sounds
|
||||
t.impact_sounds = hit_sounds
|
||||
t.death_sounds = ["jackDeath01"]
|
||||
t.pickup_sounds = sounds
|
||||
t.fall_sounds = ["jackFall01"]
|
||||
t.style = 'pirate'
|
||||
|
||||
# Santa ######################################
|
||||
t = Appearance("Santa Claus")
|
||||
t.color_texture = "santaColor"
|
||||
t.color_mask_texture = "santaColorMask"
|
||||
t.default_color = (1, 0, 0)
|
||||
t.default_highlight = (1, 1, 1)
|
||||
t.icon_texture = "santaIcon"
|
||||
t.icon_mask_texture = "santaIconColorMask"
|
||||
t.head_model = "santaHead"
|
||||
t.torso_model = "santaTorso"
|
||||
t.pelvis_model = "kronkPelvis"
|
||||
t.upper_arm_model = "santaUpperArm"
|
||||
t.forearm_model = "santaForeArm"
|
||||
t.hand_model = "santaHand"
|
||||
t.upper_leg_model = "santaUpperLeg"
|
||||
t.lower_leg_model = "santaLowerLeg"
|
||||
t.toes_model = "santaToes"
|
||||
hit_sounds = ['santaHit01', 'santaHit02', 'santaHit03', 'santaHit04']
|
||||
sounds = ['santa01', 'santa02', 'santa03', 'santa04', 'santa05']
|
||||
t.attack_sounds = sounds
|
||||
t.jump_sounds = sounds
|
||||
t.impact_sounds = hit_sounds
|
||||
t.death_sounds = ["santaDeath"]
|
||||
t.pickup_sounds = sounds
|
||||
t.fall_sounds = ["santaFall"]
|
||||
t.style = 'santa'
|
||||
|
||||
# Snowman ###################################
|
||||
t = Appearance("Frosty")
|
||||
t.color_texture = "frostyColor"
|
||||
t.color_mask_texture = "frostyColorMask"
|
||||
t.default_color = (0.5, 0.5, 1)
|
||||
t.default_highlight = (1, 0.5, 0)
|
||||
t.icon_texture = "frostyIcon"
|
||||
t.icon_mask_texture = "frostyIconColorMask"
|
||||
t.head_model = "frostyHead"
|
||||
t.torso_model = "frostyTorso"
|
||||
t.pelvis_model = "frostyPelvis"
|
||||
t.upper_arm_model = "frostyUpperArm"
|
||||
t.forearm_model = "frostyForeArm"
|
||||
t.hand_model = "frostyHand"
|
||||
t.upper_leg_model = "frostyUpperLeg"
|
||||
t.lower_leg_model = "frostyLowerLeg"
|
||||
t.toes_model = "frostyToes"
|
||||
frosty_sounds = [
|
||||
'frosty01', 'frosty02', 'frosty03', 'frosty04', 'frosty05'
|
||||
]
|
||||
frosty_hit_sounds = ['frostyHit01', 'frostyHit02', 'frostyHit03']
|
||||
t.attack_sounds = frosty_sounds
|
||||
t.jump_sounds = frosty_sounds
|
||||
t.impact_sounds = frosty_hit_sounds
|
||||
t.death_sounds = ["frostyDeath"]
|
||||
t.pickup_sounds = frosty_sounds
|
||||
t.fall_sounds = ["frostyFall"]
|
||||
t.style = 'frosty'
|
||||
|
||||
# Skeleton ################################
|
||||
t = Appearance("Bones")
|
||||
t.color_texture = "bonesColor"
|
||||
t.color_mask_texture = "bonesColorMask"
|
||||
t.default_color = (0.6, 0.9, 1)
|
||||
t.default_highlight = (0.6, 0.9, 1)
|
||||
t.icon_texture = "bonesIcon"
|
||||
t.icon_mask_texture = "bonesIconColorMask"
|
||||
t.head_model = "bonesHead"
|
||||
t.torso_model = "bonesTorso"
|
||||
t.pelvis_model = "bonesPelvis"
|
||||
t.upper_arm_model = "bonesUpperArm"
|
||||
t.forearm_model = "bonesForeArm"
|
||||
t.hand_model = "bonesHand"
|
||||
t.upper_leg_model = "bonesUpperLeg"
|
||||
t.lower_leg_model = "bonesLowerLeg"
|
||||
t.toes_model = "bonesToes"
|
||||
bones_sounds = ['bones1', 'bones2', 'bones3']
|
||||
bones_hit_sounds = ['bones1', 'bones2', 'bones3']
|
||||
t.attack_sounds = bones_sounds
|
||||
t.jump_sounds = bones_sounds
|
||||
t.impact_sounds = bones_hit_sounds
|
||||
t.death_sounds = ["bonesDeath"]
|
||||
t.pickup_sounds = bones_sounds
|
||||
t.fall_sounds = ["bonesFall"]
|
||||
t.style = 'bones'
|
||||
|
||||
# Bear ###################################
|
||||
t = Appearance("Bernard")
|
||||
t.color_texture = "bearColor"
|
||||
t.color_mask_texture = "bearColorMask"
|
||||
t.default_color = (0.7, 0.5, 0.0)
|
||||
t.icon_texture = "bearIcon"
|
||||
t.icon_mask_texture = "bearIconColorMask"
|
||||
t.head_model = "bearHead"
|
||||
t.torso_model = "bearTorso"
|
||||
t.pelvis_model = "bearPelvis"
|
||||
t.upper_arm_model = "bearUpperArm"
|
||||
t.forearm_model = "bearForeArm"
|
||||
t.hand_model = "bearHand"
|
||||
t.upper_leg_model = "bearUpperLeg"
|
||||
t.lower_leg_model = "bearLowerLeg"
|
||||
t.toes_model = "bearToes"
|
||||
bear_sounds = ['bear1', 'bear2', 'bear3', 'bear4']
|
||||
bear_hit_sounds = ['bearHit1', 'bearHit2']
|
||||
t.attack_sounds = bear_sounds
|
||||
t.jump_sounds = bear_sounds
|
||||
t.impact_sounds = bear_hit_sounds
|
||||
t.death_sounds = ["bearDeath"]
|
||||
t.pickup_sounds = bear_sounds
|
||||
t.fall_sounds = ["bearFall"]
|
||||
t.style = 'bear'
|
||||
|
||||
# Penguin ###################################
|
||||
t = Appearance("Pascal")
|
||||
t.color_texture = "penguinColor"
|
||||
t.color_mask_texture = "penguinColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "penguinIcon"
|
||||
t.icon_mask_texture = "penguinIconColorMask"
|
||||
t.head_model = "penguinHead"
|
||||
t.torso_model = "penguinTorso"
|
||||
t.pelvis_model = "penguinPelvis"
|
||||
t.upper_arm_model = "penguinUpperArm"
|
||||
t.forearm_model = "penguinForeArm"
|
||||
t.hand_model = "penguinHand"
|
||||
t.upper_leg_model = "penguinUpperLeg"
|
||||
t.lower_leg_model = "penguinLowerLeg"
|
||||
t.toes_model = "penguinToes"
|
||||
penguin_sounds = ['penguin1', 'penguin2', 'penguin3', 'penguin4']
|
||||
penguin_hit_sounds = ['penguinHit1', 'penguinHit2']
|
||||
t.attack_sounds = penguin_sounds
|
||||
t.jump_sounds = penguin_sounds
|
||||
t.impact_sounds = penguin_hit_sounds
|
||||
t.death_sounds = ["penguinDeath"]
|
||||
t.pickup_sounds = penguin_sounds
|
||||
t.fall_sounds = ["penguinFall"]
|
||||
t.style = 'penguin'
|
||||
|
||||
# Ali ###################################
|
||||
t = Appearance("Taobao Mascot")
|
||||
t.color_texture = "aliColor"
|
||||
t.color_mask_texture = "aliColorMask"
|
||||
t.default_color = (1, 0.5, 0)
|
||||
t.default_highlight = (1, 1, 1)
|
||||
t.icon_texture = "aliIcon"
|
||||
t.icon_mask_texture = "aliIconColorMask"
|
||||
t.head_model = "aliHead"
|
||||
t.torso_model = "aliTorso"
|
||||
t.pelvis_model = "aliPelvis"
|
||||
t.upper_arm_model = "aliUpperArm"
|
||||
t.forearm_model = "aliForeArm"
|
||||
t.hand_model = "aliHand"
|
||||
t.upper_leg_model = "aliUpperLeg"
|
||||
t.lower_leg_model = "aliLowerLeg"
|
||||
t.toes_model = "aliToes"
|
||||
ali_sounds = ['ali1', 'ali2', 'ali3', 'ali4']
|
||||
ali_hit_sounds = ['aliHit1', 'aliHit2']
|
||||
t.attack_sounds = ali_sounds
|
||||
t.jump_sounds = ali_sounds
|
||||
t.impact_sounds = ali_hit_sounds
|
||||
t.death_sounds = ["aliDeath"]
|
||||
t.pickup_sounds = ali_sounds
|
||||
t.fall_sounds = ["aliFall"]
|
||||
t.style = 'ali'
|
||||
|
||||
# cyborg ###################################
|
||||
t = Appearance("B-9000")
|
||||
t.color_texture = "cyborgColor"
|
||||
t.color_mask_texture = "cyborgColorMask"
|
||||
t.default_color = (0.5, 0.5, 0.5)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "cyborgIcon"
|
||||
t.icon_mask_texture = "cyborgIconColorMask"
|
||||
t.head_model = "cyborgHead"
|
||||
t.torso_model = "cyborgTorso"
|
||||
t.pelvis_model = "cyborgPelvis"
|
||||
t.upper_arm_model = "cyborgUpperArm"
|
||||
t.forearm_model = "cyborgForeArm"
|
||||
t.hand_model = "cyborgHand"
|
||||
t.upper_leg_model = "cyborgUpperLeg"
|
||||
t.lower_leg_model = "cyborgLowerLeg"
|
||||
t.toes_model = "cyborgToes"
|
||||
cyborg_sounds = ['cyborg1', 'cyborg2', 'cyborg3', 'cyborg4']
|
||||
cyborg_hit_sounds = ['cyborgHit1', 'cyborgHit2']
|
||||
t.attack_sounds = cyborg_sounds
|
||||
t.jump_sounds = cyborg_sounds
|
||||
t.impact_sounds = cyborg_hit_sounds
|
||||
t.death_sounds = ["cyborgDeath"]
|
||||
t.pickup_sounds = cyborg_sounds
|
||||
t.fall_sounds = ["cyborgFall"]
|
||||
t.style = 'cyborg'
|
||||
|
||||
# Agent ###################################
|
||||
t = Appearance("Agent Johnson")
|
||||
t.color_texture = "agentColor"
|
||||
t.color_mask_texture = "agentColorMask"
|
||||
t.default_color = (0.3, 0.3, 0.33)
|
||||
t.default_highlight = (1, 0.5, 0.3)
|
||||
t.icon_texture = "agentIcon"
|
||||
t.icon_mask_texture = "agentIconColorMask"
|
||||
t.head_model = "agentHead"
|
||||
t.torso_model = "agentTorso"
|
||||
t.pelvis_model = "agentPelvis"
|
||||
t.upper_arm_model = "agentUpperArm"
|
||||
t.forearm_model = "agentForeArm"
|
||||
t.hand_model = "agentHand"
|
||||
t.upper_leg_model = "agentUpperLeg"
|
||||
t.lower_leg_model = "agentLowerLeg"
|
||||
t.toes_model = "agentToes"
|
||||
agent_sounds = ['agent1', 'agent2', 'agent3', 'agent4']
|
||||
agent_hit_sounds = ['agentHit1', 'agentHit2']
|
||||
t.attack_sounds = agent_sounds
|
||||
t.jump_sounds = agent_sounds
|
||||
t.impact_sounds = agent_hit_sounds
|
||||
t.death_sounds = ["agentDeath"]
|
||||
t.pickup_sounds = agent_sounds
|
||||
t.fall_sounds = ["agentFall"]
|
||||
t.style = 'agent'
|
||||
|
||||
# Jumpsuit ###################################
|
||||
t = Appearance("Lee")
|
||||
t.color_texture = "jumpsuitColor"
|
||||
t.color_mask_texture = "jumpsuitColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "jumpsuitIcon"
|
||||
t.icon_mask_texture = "jumpsuitIconColorMask"
|
||||
t.head_model = "jumpsuitHead"
|
||||
t.torso_model = "jumpsuitTorso"
|
||||
t.pelvis_model = "jumpsuitPelvis"
|
||||
t.upper_arm_model = "jumpsuitUpperArm"
|
||||
t.forearm_model = "jumpsuitForeArm"
|
||||
t.hand_model = "jumpsuitHand"
|
||||
t.upper_leg_model = "jumpsuitUpperLeg"
|
||||
t.lower_leg_model = "jumpsuitLowerLeg"
|
||||
t.toes_model = "jumpsuitToes"
|
||||
jumpsuit_sounds = ['jumpsuit1', 'jumpsuit2', 'jumpsuit3', 'jumpsuit4']
|
||||
jumpsuit_hit_sounds = ['jumpsuitHit1', 'jumpsuitHit2']
|
||||
t.attack_sounds = jumpsuit_sounds
|
||||
t.jump_sounds = jumpsuit_sounds
|
||||
t.impact_sounds = jumpsuit_hit_sounds
|
||||
t.death_sounds = ["jumpsuitDeath"]
|
||||
t.pickup_sounds = jumpsuit_sounds
|
||||
t.fall_sounds = ["jumpsuitFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# ActionHero ###################################
|
||||
t = Appearance("Todd McBurton")
|
||||
t.color_texture = "actionHeroColor"
|
||||
t.color_mask_texture = "actionHeroColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "actionHeroIcon"
|
||||
t.icon_mask_texture = "actionHeroIconColorMask"
|
||||
t.head_model = "actionHeroHead"
|
||||
t.torso_model = "actionHeroTorso"
|
||||
t.pelvis_model = "actionHeroPelvis"
|
||||
t.upper_arm_model = "actionHeroUpperArm"
|
||||
t.forearm_model = "actionHeroForeArm"
|
||||
t.hand_model = "actionHeroHand"
|
||||
t.upper_leg_model = "actionHeroUpperLeg"
|
||||
t.lower_leg_model = "actionHeroLowerLeg"
|
||||
t.toes_model = "actionHeroToes"
|
||||
action_hero_sounds = [
|
||||
'actionHero1', 'actionHero2', 'actionHero3', 'actionHero4'
|
||||
]
|
||||
action_hero_hit_sounds = ['actionHeroHit1', 'actionHeroHit2']
|
||||
t.attack_sounds = action_hero_sounds
|
||||
t.jump_sounds = action_hero_sounds
|
||||
t.impact_sounds = action_hero_hit_sounds
|
||||
t.death_sounds = ["actionHeroDeath"]
|
||||
t.pickup_sounds = action_hero_sounds
|
||||
t.fall_sounds = ["actionHeroFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Assassin ###################################
|
||||
t = Appearance("Zola")
|
||||
t.color_texture = "assassinColor"
|
||||
t.color_mask_texture = "assassinColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "assassinIcon"
|
||||
t.icon_mask_texture = "assassinIconColorMask"
|
||||
t.head_model = "assassinHead"
|
||||
t.torso_model = "assassinTorso"
|
||||
t.pelvis_model = "assassinPelvis"
|
||||
t.upper_arm_model = "assassinUpperArm"
|
||||
t.forearm_model = "assassinForeArm"
|
||||
t.hand_model = "assassinHand"
|
||||
t.upper_leg_model = "assassinUpperLeg"
|
||||
t.lower_leg_model = "assassinLowerLeg"
|
||||
t.toes_model = "assassinToes"
|
||||
assassin_sounds = ['assassin1', 'assassin2', 'assassin3', 'assassin4']
|
||||
assassin_hit_sounds = ['assassinHit1', 'assassinHit2']
|
||||
t.attack_sounds = assassin_sounds
|
||||
t.jump_sounds = assassin_sounds
|
||||
t.impact_sounds = assassin_hit_sounds
|
||||
t.death_sounds = ["assassinDeath"]
|
||||
t.pickup_sounds = assassin_sounds
|
||||
t.fall_sounds = ["assassinFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Wizard ###################################
|
||||
t = Appearance("Grumbledorf")
|
||||
t.color_texture = "wizardColor"
|
||||
t.color_mask_texture = "wizardColorMask"
|
||||
t.default_color = (0.2, 0.4, 1.0)
|
||||
t.default_highlight = (0.06, 0.15, 0.4)
|
||||
t.icon_texture = "wizardIcon"
|
||||
t.icon_mask_texture = "wizardIconColorMask"
|
||||
t.head_model = "wizardHead"
|
||||
t.torso_model = "wizardTorso"
|
||||
t.pelvis_model = "wizardPelvis"
|
||||
t.upper_arm_model = "wizardUpperArm"
|
||||
t.forearm_model = "wizardForeArm"
|
||||
t.hand_model = "wizardHand"
|
||||
t.upper_leg_model = "wizardUpperLeg"
|
||||
t.lower_leg_model = "wizardLowerLeg"
|
||||
t.toes_model = "wizardToes"
|
||||
wizard_sounds = ['wizard1', 'wizard2', 'wizard3', 'wizard4']
|
||||
wizard_hit_sounds = ['wizardHit1', 'wizardHit2']
|
||||
t.attack_sounds = wizard_sounds
|
||||
t.jump_sounds = wizard_sounds
|
||||
t.impact_sounds = wizard_hit_sounds
|
||||
t.death_sounds = ["wizardDeath"]
|
||||
t.pickup_sounds = wizard_sounds
|
||||
t.fall_sounds = ["wizardFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Cowboy ###################################
|
||||
t = Appearance("Butch")
|
||||
t.color_texture = "cowboyColor"
|
||||
t.color_mask_texture = "cowboyColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "cowboyIcon"
|
||||
t.icon_mask_texture = "cowboyIconColorMask"
|
||||
t.head_model = "cowboyHead"
|
||||
t.torso_model = "cowboyTorso"
|
||||
t.pelvis_model = "cowboyPelvis"
|
||||
t.upper_arm_model = "cowboyUpperArm"
|
||||
t.forearm_model = "cowboyForeArm"
|
||||
t.hand_model = "cowboyHand"
|
||||
t.upper_leg_model = "cowboyUpperLeg"
|
||||
t.lower_leg_model = "cowboyLowerLeg"
|
||||
t.toes_model = "cowboyToes"
|
||||
cowboy_sounds = ['cowboy1', 'cowboy2', 'cowboy3', 'cowboy4']
|
||||
cowboy_hit_sounds = ['cowboyHit1', 'cowboyHit2']
|
||||
t.attack_sounds = cowboy_sounds
|
||||
t.jump_sounds = cowboy_sounds
|
||||
t.impact_sounds = cowboy_hit_sounds
|
||||
t.death_sounds = ["cowboyDeath"]
|
||||
t.pickup_sounds = cowboy_sounds
|
||||
t.fall_sounds = ["cowboyFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Witch ###################################
|
||||
t = Appearance("Witch")
|
||||
t.color_texture = "witchColor"
|
||||
t.color_mask_texture = "witchColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "witchIcon"
|
||||
t.icon_mask_texture = "witchIconColorMask"
|
||||
t.head_model = "witchHead"
|
||||
t.torso_model = "witchTorso"
|
||||
t.pelvis_model = "witchPelvis"
|
||||
t.upper_arm_model = "witchUpperArm"
|
||||
t.forearm_model = "witchForeArm"
|
||||
t.hand_model = "witchHand"
|
||||
t.upper_leg_model = "witchUpperLeg"
|
||||
t.lower_leg_model = "witchLowerLeg"
|
||||
t.toes_model = "witchToes"
|
||||
witch_sounds = ['witch1', 'witch2', 'witch3', 'witch4']
|
||||
witch_hit_sounds = ['witchHit1', 'witchHit2']
|
||||
t.attack_sounds = witch_sounds
|
||||
t.jump_sounds = witch_sounds
|
||||
t.impact_sounds = witch_hit_sounds
|
||||
t.death_sounds = ["witchDeath"]
|
||||
t.pickup_sounds = witch_sounds
|
||||
t.fall_sounds = ["witchFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Warrior ###################################
|
||||
t = Appearance("Warrior")
|
||||
t.color_texture = "warriorColor"
|
||||
t.color_mask_texture = "warriorColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "warriorIcon"
|
||||
t.icon_mask_texture = "warriorIconColorMask"
|
||||
t.head_model = "warriorHead"
|
||||
t.torso_model = "warriorTorso"
|
||||
t.pelvis_model = "warriorPelvis"
|
||||
t.upper_arm_model = "warriorUpperArm"
|
||||
t.forearm_model = "warriorForeArm"
|
||||
t.hand_model = "warriorHand"
|
||||
t.upper_leg_model = "warriorUpperLeg"
|
||||
t.lower_leg_model = "warriorLowerLeg"
|
||||
t.toes_model = "warriorToes"
|
||||
warrior_sounds = ['warrior1', 'warrior2', 'warrior3', 'warrior4']
|
||||
warrior_hit_sounds = ['warriorHit1', 'warriorHit2']
|
||||
t.attack_sounds = warrior_sounds
|
||||
t.jump_sounds = warrior_sounds
|
||||
t.impact_sounds = warrior_hit_sounds
|
||||
t.death_sounds = ["warriorDeath"]
|
||||
t.pickup_sounds = warrior_sounds
|
||||
t.fall_sounds = ["warriorFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Superhero ###################################
|
||||
t = Appearance("Middle-Man")
|
||||
t.color_texture = "superheroColor"
|
||||
t.color_mask_texture = "superheroColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "superheroIcon"
|
||||
t.icon_mask_texture = "superheroIconColorMask"
|
||||
t.head_model = "superheroHead"
|
||||
t.torso_model = "superheroTorso"
|
||||
t.pelvis_model = "superheroPelvis"
|
||||
t.upper_arm_model = "superheroUpperArm"
|
||||
t.forearm_model = "superheroForeArm"
|
||||
t.hand_model = "superheroHand"
|
||||
t.upper_leg_model = "superheroUpperLeg"
|
||||
t.lower_leg_model = "superheroLowerLeg"
|
||||
t.toes_model = "superheroToes"
|
||||
superhero_sounds = ['superhero1', 'superhero2', 'superhero3', 'superhero4']
|
||||
superhero_hit_sounds = ['superheroHit1', 'superheroHit2']
|
||||
t.attack_sounds = superhero_sounds
|
||||
t.jump_sounds = superhero_sounds
|
||||
t.impact_sounds = superhero_hit_sounds
|
||||
t.death_sounds = ["superheroDeath"]
|
||||
t.pickup_sounds = superhero_sounds
|
||||
t.fall_sounds = ["superheroFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Alien ###################################
|
||||
t = Appearance("Alien")
|
||||
t.color_texture = "alienColor"
|
||||
t.color_mask_texture = "alienColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "alienIcon"
|
||||
t.icon_mask_texture = "alienIconColorMask"
|
||||
t.head_model = "alienHead"
|
||||
t.torso_model = "alienTorso"
|
||||
t.pelvis_model = "alienPelvis"
|
||||
t.upper_arm_model = "alienUpperArm"
|
||||
t.forearm_model = "alienForeArm"
|
||||
t.hand_model = "alienHand"
|
||||
t.upper_leg_model = "alienUpperLeg"
|
||||
t.lower_leg_model = "alienLowerLeg"
|
||||
t.toes_model = "alienToes"
|
||||
alien_sounds = ['alien1', 'alien2', 'alien3', 'alien4']
|
||||
alien_hit_sounds = ['alienHit1', 'alienHit2']
|
||||
t.attack_sounds = alien_sounds
|
||||
t.jump_sounds = alien_sounds
|
||||
t.impact_sounds = alien_hit_sounds
|
||||
t.death_sounds = ["alienDeath"]
|
||||
t.pickup_sounds = alien_sounds
|
||||
t.fall_sounds = ["alienFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# OldLady ###################################
|
||||
t = Appearance("OldLady")
|
||||
t.color_texture = "oldLadyColor"
|
||||
t.color_mask_texture = "oldLadyColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "oldLadyIcon"
|
||||
t.icon_mask_texture = "oldLadyIconColorMask"
|
||||
t.head_model = "oldLadyHead"
|
||||
t.torso_model = "oldLadyTorso"
|
||||
t.pelvis_model = "oldLadyPelvis"
|
||||
t.upper_arm_model = "oldLadyUpperArm"
|
||||
t.forearm_model = "oldLadyForeArm"
|
||||
t.hand_model = "oldLadyHand"
|
||||
t.upper_leg_model = "oldLadyUpperLeg"
|
||||
t.lower_leg_model = "oldLadyLowerLeg"
|
||||
t.toes_model = "oldLadyToes"
|
||||
old_lady_sounds = ['oldLady1', 'oldLady2', 'oldLady3', 'oldLady4']
|
||||
old_lady_hit_sounds = ['oldLadyHit1', 'oldLadyHit2']
|
||||
t.attack_sounds = old_lady_sounds
|
||||
t.jump_sounds = old_lady_sounds
|
||||
t.impact_sounds = old_lady_hit_sounds
|
||||
t.death_sounds = ["oldLadyDeath"]
|
||||
t.pickup_sounds = old_lady_sounds
|
||||
t.fall_sounds = ["oldLadyFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Gladiator ###################################
|
||||
t = Appearance("Gladiator")
|
||||
t.color_texture = "gladiatorColor"
|
||||
t.color_mask_texture = "gladiatorColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "gladiatorIcon"
|
||||
t.icon_mask_texture = "gladiatorIconColorMask"
|
||||
t.head_model = "gladiatorHead"
|
||||
t.torso_model = "gladiatorTorso"
|
||||
t.pelvis_model = "gladiatorPelvis"
|
||||
t.upper_arm_model = "gladiatorUpperArm"
|
||||
t.forearm_model = "gladiatorForeArm"
|
||||
t.hand_model = "gladiatorHand"
|
||||
t.upper_leg_model = "gladiatorUpperLeg"
|
||||
t.lower_leg_model = "gladiatorLowerLeg"
|
||||
t.toes_model = "gladiatorToes"
|
||||
gladiator_sounds = ['gladiator1', 'gladiator2', 'gladiator3', 'gladiator4']
|
||||
gladiator_hit_sounds = ['gladiatorHit1', 'gladiatorHit2']
|
||||
t.attack_sounds = gladiator_sounds
|
||||
t.jump_sounds = gladiator_sounds
|
||||
t.impact_sounds = gladiator_hit_sounds
|
||||
t.death_sounds = ["gladiatorDeath"]
|
||||
t.pickup_sounds = gladiator_sounds
|
||||
t.fall_sounds = ["gladiatorFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Wrestler ###################################
|
||||
t = Appearance("Wrestler")
|
||||
t.color_texture = "wrestlerColor"
|
||||
t.color_mask_texture = "wrestlerColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "wrestlerIcon"
|
||||
t.icon_mask_texture = "wrestlerIconColorMask"
|
||||
t.head_model = "wrestlerHead"
|
||||
t.torso_model = "wrestlerTorso"
|
||||
t.pelvis_model = "wrestlerPelvis"
|
||||
t.upper_arm_model = "wrestlerUpperArm"
|
||||
t.forearm_model = "wrestlerForeArm"
|
||||
t.hand_model = "wrestlerHand"
|
||||
t.upper_leg_model = "wrestlerUpperLeg"
|
||||
t.lower_leg_model = "wrestlerLowerLeg"
|
||||
t.toes_model = "wrestlerToes"
|
||||
wrestler_sounds = ['wrestler1', 'wrestler2', 'wrestler3', 'wrestler4']
|
||||
wrestler_hit_sounds = ['wrestlerHit1', 'wrestlerHit2']
|
||||
t.attack_sounds = wrestler_sounds
|
||||
t.jump_sounds = wrestler_sounds
|
||||
t.impact_sounds = wrestler_hit_sounds
|
||||
t.death_sounds = ["wrestlerDeath"]
|
||||
t.pickup_sounds = wrestler_sounds
|
||||
t.fall_sounds = ["wrestlerFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# OperaSinger ###################################
|
||||
t = Appearance("Gretel")
|
||||
t.color_texture = "operaSingerColor"
|
||||
t.color_mask_texture = "operaSingerColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "operaSingerIcon"
|
||||
t.icon_mask_texture = "operaSingerIconColorMask"
|
||||
t.head_model = "operaSingerHead"
|
||||
t.torso_model = "operaSingerTorso"
|
||||
t.pelvis_model = "operaSingerPelvis"
|
||||
t.upper_arm_model = "operaSingerUpperArm"
|
||||
t.forearm_model = "operaSingerForeArm"
|
||||
t.hand_model = "operaSingerHand"
|
||||
t.upper_leg_model = "operaSingerUpperLeg"
|
||||
t.lower_leg_model = "operaSingerLowerLeg"
|
||||
t.toes_model = "operaSingerToes"
|
||||
opera_singer_sounds = [
|
||||
'operaSinger1', 'operaSinger2', 'operaSinger3', 'operaSinger4'
|
||||
]
|
||||
opera_singer_hit_sounds = ['operaSingerHit1', 'operaSingerHit2']
|
||||
t.attack_sounds = opera_singer_sounds
|
||||
t.jump_sounds = opera_singer_sounds
|
||||
t.impact_sounds = opera_singer_hit_sounds
|
||||
t.death_sounds = ["operaSingerDeath"]
|
||||
t.pickup_sounds = opera_singer_sounds
|
||||
t.fall_sounds = ["operaSingerFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Pixie ###################################
|
||||
t = Appearance("Pixel")
|
||||
t.color_texture = "pixieColor"
|
||||
t.color_mask_texture = "pixieColorMask"
|
||||
t.default_color = (0, 1, 0.7)
|
||||
t.default_highlight = (0.65, 0.35, 0.75)
|
||||
t.icon_texture = "pixieIcon"
|
||||
t.icon_mask_texture = "pixieIconColorMask"
|
||||
t.head_model = "pixieHead"
|
||||
t.torso_model = "pixieTorso"
|
||||
t.pelvis_model = "pixiePelvis"
|
||||
t.upper_arm_model = "pixieUpperArm"
|
||||
t.forearm_model = "pixieForeArm"
|
||||
t.hand_model = "pixieHand"
|
||||
t.upper_leg_model = "pixieUpperLeg"
|
||||
t.lower_leg_model = "pixieLowerLeg"
|
||||
t.toes_model = "pixieToes"
|
||||
pixie_sounds = ['pixie1', 'pixie2', 'pixie3', 'pixie4']
|
||||
pixie_hit_sounds = ['pixieHit1', 'pixieHit2']
|
||||
t.attack_sounds = pixie_sounds
|
||||
t.jump_sounds = pixie_sounds
|
||||
t.impact_sounds = pixie_hit_sounds
|
||||
t.death_sounds = ["pixieDeath"]
|
||||
t.pickup_sounds = pixie_sounds
|
||||
t.fall_sounds = ["pixieFall"]
|
||||
t.style = 'pixie'
|
||||
|
||||
# Robot ###################################
|
||||
t = Appearance("Robot")
|
||||
t.color_texture = "robotColor"
|
||||
t.color_mask_texture = "robotColorMask"
|
||||
t.default_color = (0.3, 0.5, 0.8)
|
||||
t.default_highlight = (1, 0, 0)
|
||||
t.icon_texture = "robotIcon"
|
||||
t.icon_mask_texture = "robotIconColorMask"
|
||||
t.head_model = "robotHead"
|
||||
t.torso_model = "robotTorso"
|
||||
t.pelvis_model = "robotPelvis"
|
||||
t.upper_arm_model = "robotUpperArm"
|
||||
t.forearm_model = "robotForeArm"
|
||||
t.hand_model = "robotHand"
|
||||
t.upper_leg_model = "robotUpperLeg"
|
||||
t.lower_leg_model = "robotLowerLeg"
|
||||
t.toes_model = "robotToes"
|
||||
robot_sounds = ['robot1', 'robot2', 'robot3', 'robot4']
|
||||
robot_hit_sounds = ['robotHit1', 'robotHit2']
|
||||
t.attack_sounds = robot_sounds
|
||||
t.jump_sounds = robot_sounds
|
||||
t.impact_sounds = robot_hit_sounds
|
||||
t.death_sounds = ["robotDeath"]
|
||||
t.pickup_sounds = robot_sounds
|
||||
t.fall_sounds = ["robotFall"]
|
||||
t.style = 'spaz'
|
||||
|
||||
# Bunny ###################################
|
||||
t = Appearance("Easter Bunny")
|
||||
t.color_texture = "bunnyColor"
|
||||
t.color_mask_texture = "bunnyColorMask"
|
||||
t.default_color = (1, 1, 1)
|
||||
t.default_highlight = (1, 0.5, 0.5)
|
||||
t.icon_texture = "bunnyIcon"
|
||||
t.icon_mask_texture = "bunnyIconColorMask"
|
||||
t.head_model = "bunnyHead"
|
||||
t.torso_model = "bunnyTorso"
|
||||
t.pelvis_model = "bunnyPelvis"
|
||||
t.upper_arm_model = "bunnyUpperArm"
|
||||
t.forearm_model = "bunnyForeArm"
|
||||
t.hand_model = "bunnyHand"
|
||||
t.upper_leg_model = "bunnyUpperLeg"
|
||||
t.lower_leg_model = "bunnyLowerLeg"
|
||||
t.toes_model = "bunnyToes"
|
||||
bunny_sounds = ['bunny1', 'bunny2', 'bunny3', 'bunny4']
|
||||
bunny_hit_sounds = ['bunnyHit1', 'bunnyHit2']
|
||||
t.attack_sounds = bunny_sounds
|
||||
t.jump_sounds = ['bunnyJump']
|
||||
t.impact_sounds = bunny_hit_sounds
|
||||
t.death_sounds = ["bunnyDeath"]
|
||||
t.pickup_sounds = bunny_sounds
|
||||
t.fall_sounds = ["bunnyFall"]
|
||||
t.style = 'bunny'
|
||||
1033
assets/src/data/scripts/bastd/actor/spazbot.py
Normal file
1033
assets/src/data/scripts/bastd/actor/spazbot.py
Normal file
File diff suppressed because it is too large
Load Diff
224
assets/src/data/scripts/bastd/actor/spazfactory.py
Normal file
224
assets/src/data/scripts/bastd/actor/spazfactory.py
Normal file
@ -0,0 +1,224 @@
|
||||
"""Provides a factory object from creating Spazzes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
import ba
|
||||
from bastd.actor import spaz as basespaz
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class SpazFactory:
|
||||
"""Wraps up media and other resources used by ba.Spaz instances.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
Generally one of these is created per ba.Activity and shared
|
||||
between all spaz instances. Use ba.Spaz.get_factory() to return
|
||||
the shared factory for the current activity.
|
||||
|
||||
Attributes:
|
||||
|
||||
impact_sounds_medium
|
||||
A tuple of ba.Sounds for when a ba.Spaz hits something kinda hard.
|
||||
|
||||
impact_sounds_hard
|
||||
A tuple of ba.Sounds for when a ba.Spaz hits something really hard.
|
||||
|
||||
impact_sounds_harder
|
||||
A tuple of ba.Sounds for when a ba.Spaz hits something really
|
||||
really hard.
|
||||
|
||||
single_player_death_sound
|
||||
The sound that plays for an 'important' spaz death such as in
|
||||
co-op games.
|
||||
|
||||
punch_sound
|
||||
A standard punch ba.Sound.
|
||||
|
||||
punch_sound_strong
|
||||
A tuple of stronger sounding punch ba.Sounds.
|
||||
|
||||
punch_sound_stronger
|
||||
A really really strong sounding punch ba.Sound.
|
||||
|
||||
swish_sound
|
||||
A punch swish ba.Sound.
|
||||
|
||||
block_sound
|
||||
A ba.Sound for when an attack is blocked by invincibility.
|
||||
|
||||
shatter_sound
|
||||
A ba.Sound for when a frozen ba.Spaz shatters.
|
||||
|
||||
splatter_sound
|
||||
A ba.Sound for when a ba.Spaz blows up via curse.
|
||||
|
||||
spaz_material
|
||||
A ba.Material applied to all of parts of a ba.Spaz.
|
||||
|
||||
roller_material
|
||||
A ba.Material applied to the invisible roller ball body that
|
||||
a ba.Spaz uses for locomotion.
|
||||
|
||||
punch_material
|
||||
A ba.Material applied to the 'fist' of a ba.Spaz.
|
||||
|
||||
pickup_material
|
||||
A ba.Material applied to the 'grabber' body of a ba.Spaz.
|
||||
|
||||
curse_material
|
||||
A ba.Material applied to a cursed ba.Spaz that triggers an explosion.
|
||||
"""
|
||||
|
||||
def _preload(self, character: str) -> None:
|
||||
"""Preload media needed for a given character."""
|
||||
self.get_media(character)
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Instantiate a factory object."""
|
||||
self.impact_sounds_medium = (ba.getsound('impactMedium'),
|
||||
ba.getsound('impactMedium2'))
|
||||
self.impact_sounds_hard = (ba.getsound('impactHard'),
|
||||
ba.getsound('impactHard2'),
|
||||
ba.getsound('impactHard3'))
|
||||
self.impact_sounds_harder = (ba.getsound('bigImpact'),
|
||||
ba.getsound('bigImpact2'))
|
||||
self.single_player_death_sound = ba.getsound('playerDeath')
|
||||
self.punch_sound = ba.getsound('punch01')
|
||||
self.punch_sound_strong = (ba.getsound('punchStrong01'),
|
||||
ba.getsound('punchStrong02'))
|
||||
self.punch_sound_stronger = ba.getsound('superPunch')
|
||||
self.swish_sound = ba.getsound('punchSwish')
|
||||
self.block_sound = ba.getsound('block')
|
||||
self.shatter_sound = ba.getsound('shatter')
|
||||
self.splatter_sound = ba.getsound('splatter')
|
||||
self.spaz_material = ba.Material()
|
||||
self.roller_material = ba.Material()
|
||||
self.punch_material = ba.Material()
|
||||
self.pickup_material = ba.Material()
|
||||
self.curse_material = ba.Material()
|
||||
|
||||
footing_material = ba.sharedobj('footing_material')
|
||||
object_material = ba.sharedobj('object_material')
|
||||
player_material = ba.sharedobj('player_material')
|
||||
region_material = ba.sharedobj('region_material')
|
||||
|
||||
# send footing messages to spazzes so they know when they're on solid
|
||||
# ground.
|
||||
# eww this should really just be built into the spaz node
|
||||
self.roller_material.add_actions(
|
||||
conditions=('they_have_material', footing_material),
|
||||
actions=(('message', 'our_node', 'at_connect', 'footing', 1),
|
||||
('message', 'our_node', 'at_disconnect', 'footing', -1)))
|
||||
|
||||
self.spaz_material.add_actions(
|
||||
conditions=('they_have_material', footing_material),
|
||||
actions=(('message', 'our_node', 'at_connect', 'footing', 1),
|
||||
('message', 'our_node', 'at_disconnect', 'footing', -1)))
|
||||
# punches
|
||||
self.punch_material.add_actions(
|
||||
conditions=('they_are_different_node_than_us', ),
|
||||
actions=(('modify_part_collision', 'collide',
|
||||
True), ('modify_part_collision', 'physical', False),
|
||||
('message', 'our_node', 'at_connect',
|
||||
basespaz.PunchHitMessage())))
|
||||
# pickups
|
||||
self.pickup_material.add_actions(
|
||||
conditions=(('they_are_different_node_than_us', ), 'and',
|
||||
('they_have_material', object_material)),
|
||||
actions=(('modify_part_collision', 'collide',
|
||||
True), ('modify_part_collision', 'physical', False),
|
||||
('message', 'our_node', 'at_connect',
|
||||
basespaz.PickupMessage())))
|
||||
# curse
|
||||
self.curse_material.add_actions(
|
||||
conditions=(('they_are_different_node_than_us', ), 'and',
|
||||
('they_have_material', player_material)),
|
||||
actions=('message', 'our_node', 'at_connect',
|
||||
basespaz.CurseExplodeMessage()))
|
||||
|
||||
self.foot_impact_sounds = (ba.getsound('footImpact01'),
|
||||
ba.getsound('footImpact02'),
|
||||
ba.getsound('footImpact03'))
|
||||
|
||||
self.foot_skid_sound = ba.getsound('skid01')
|
||||
self.foot_roll_sound = ba.getsound('scamper01')
|
||||
|
||||
self.roller_material.add_actions(
|
||||
conditions=('they_have_material', footing_material),
|
||||
actions=(('impact_sound', self.foot_impact_sounds, 1,
|
||||
0.2), ('skid_sound', self.foot_skid_sound, 20, 0.3),
|
||||
('roll_sound', self.foot_roll_sound, 20, 3.0)))
|
||||
|
||||
self.skid_sound = ba.getsound('gravelSkid')
|
||||
|
||||
self.spaz_material.add_actions(
|
||||
conditions=('they_have_material', footing_material),
|
||||
actions=(('impact_sound', self.foot_impact_sounds, 20,
|
||||
6), ('skid_sound', self.skid_sound, 2.0, 1),
|
||||
('roll_sound', self.skid_sound, 2.0, 1)))
|
||||
|
||||
self.shield_up_sound = ba.getsound('shieldUp')
|
||||
self.shield_down_sound = ba.getsound('shieldDown')
|
||||
self.shield_hit_sound = ba.getsound('shieldHit')
|
||||
|
||||
# we don't want to collide with stuff we're initially overlapping
|
||||
# (unless its marked with a special region material)
|
||||
self.spaz_material.add_actions(
|
||||
conditions=((('we_are_younger_than', 51), 'and',
|
||||
('they_are_different_node_than_us', )), 'and',
|
||||
('they_dont_have_material', region_material)),
|
||||
actions=('modify_node_collision', 'collide', False))
|
||||
|
||||
self.spaz_media: Dict[str, Any] = {}
|
||||
|
||||
# lets load some basic rules (allows them to be tweaked from the
|
||||
# master server)
|
||||
self.shield_decay_rate = _ba.get_account_misc_read_val('rsdr', 10.0)
|
||||
self.punch_cooldown = _ba.get_account_misc_read_val('rpc', 400)
|
||||
self.punch_cooldown_gloves = (_ba.get_account_misc_read_val(
|
||||
'rpcg', 300))
|
||||
self.punch_power_scale = _ba.get_account_misc_read_val('rpp', 1.2)
|
||||
self.punch_power_scale_gloves = (_ba.get_account_misc_read_val(
|
||||
'rppg', 1.4))
|
||||
self.max_shield_spillover_damage = (_ba.get_account_misc_read_val(
|
||||
'rsms', 500))
|
||||
|
||||
def get_style(self, character: str) -> str:
|
||||
"""Return the named style for this character.
|
||||
|
||||
(this influences subtle aspects of their appearance, etc)
|
||||
"""
|
||||
return ba.app.spaz_appearances[character].style
|
||||
|
||||
def get_media(self, character: str) -> Dict[str, Any]:
|
||||
"""Return the set of media used by this variant of spaz."""
|
||||
char = ba.app.spaz_appearances[character]
|
||||
if character not in self.spaz_media:
|
||||
media = self.spaz_media[character] = {
|
||||
'jump_sounds': [ba.getsound(s) for s in char.jump_sounds],
|
||||
'attack_sounds': [ba.getsound(s) for s in char.attack_sounds],
|
||||
'impact_sounds': [ba.getsound(s) for s in char.impact_sounds],
|
||||
'death_sounds': [ba.getsound(s) for s in char.death_sounds],
|
||||
'pickup_sounds': [ba.getsound(s) for s in char.pickup_sounds],
|
||||
'fall_sounds': [ba.getsound(s) for s in char.fall_sounds],
|
||||
'color_texture': ba.gettexture(char.color_texture),
|
||||
'color_mask_texture': ba.gettexture(char.color_mask_texture),
|
||||
'head_model': ba.getmodel(char.head_model),
|
||||
'torso_model': ba.getmodel(char.torso_model),
|
||||
'pelvis_model': ba.getmodel(char.pelvis_model),
|
||||
'upper_arm_model': ba.getmodel(char.upper_arm_model),
|
||||
'forearm_model': ba.getmodel(char.forearm_model),
|
||||
'hand_model': ba.getmodel(char.hand_model),
|
||||
'upper_leg_model': ba.getmodel(char.upper_leg_model),
|
||||
'lower_leg_model': ba.getmodel(char.lower_leg_model),
|
||||
'toes_model': ba.getmodel(char.toes_model)
|
||||
}
|
||||
else:
|
||||
media = self.spaz_media[character]
|
||||
return media
|
||||
180
assets/src/data/scripts/bastd/actor/text.py
Normal file
180
assets/src/data/scripts/bastd/actor/text.py
Normal file
@ -0,0 +1,180 @@
|
||||
"""Defines Actor(s)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Union, Tuple, Sequence
|
||||
|
||||
|
||||
class Text(ba.Actor):
|
||||
""" Text with some tricks """
|
||||
|
||||
def __init__(self,
|
||||
text: Union[str, ba.Lstr],
|
||||
position: Tuple[float, float] = (0.0, 0.0),
|
||||
h_align: str = 'left',
|
||||
v_align: str = 'none',
|
||||
color: Sequence[float] = (1.0, 1.0, 1.0, 1.0),
|
||||
transition: str = None,
|
||||
transition_delay: float = 0.0,
|
||||
flash: bool = False,
|
||||
v_attach: str = 'center',
|
||||
h_attach: str = 'center',
|
||||
scale: float = 1.0,
|
||||
transition_out_delay: float = None,
|
||||
maxwidth: float = None,
|
||||
shadow: float = 0.5,
|
||||
flatness: float = 0.0,
|
||||
vr_depth: float = 0.0,
|
||||
host_only: bool = False,
|
||||
front: bool = False):
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-locals
|
||||
super().__init__()
|
||||
self.node = ba.newnode(
|
||||
'text',
|
||||
delegate=self,
|
||||
attrs={
|
||||
'text': text,
|
||||
'color': color,
|
||||
'position': position,
|
||||
'h_align': h_align,
|
||||
'vr_depth': vr_depth,
|
||||
'v_align': v_align,
|
||||
'h_attach': h_attach,
|
||||
'v_attach': v_attach,
|
||||
'shadow': shadow,
|
||||
'flatness': flatness,
|
||||
'maxwidth': 0.0 if maxwidth is None else maxwidth,
|
||||
'host_only': host_only,
|
||||
'front': front,
|
||||
'scale': scale
|
||||
})
|
||||
|
||||
if transition == 'fade_in':
|
||||
if flash:
|
||||
raise Exception("fixme: flash and fade-in"
|
||||
" currently cant both be on")
|
||||
cmb = ba.newnode('combine',
|
||||
owner=self.node,
|
||||
attrs={
|
||||
'input0': color[0],
|
||||
'input1': color[1],
|
||||
'input2': color[2],
|
||||
'size': 4
|
||||
})
|
||||
keys = {transition_delay: 0.0, transition_delay + 0.5: color[3]}
|
||||
if transition_out_delay is not None:
|
||||
keys[transition_delay + transition_out_delay] = color[3]
|
||||
keys[transition_delay + transition_out_delay + 0.5] = 0.0
|
||||
ba.animate(cmb, "input3", keys)
|
||||
cmb.connectattr('output', self.node, 'color')
|
||||
|
||||
if flash:
|
||||
mult = 2.0
|
||||
tm1 = 0.15
|
||||
tm2 = 0.3
|
||||
cmb = ba.newnode('combine', owner=self.node, attrs={'size': 4})
|
||||
ba.animate(cmb,
|
||||
"input0", {
|
||||
0.0: color[0] * mult,
|
||||
tm1: color[0],
|
||||
tm2: color[0] * mult
|
||||
},
|
||||
loop=True)
|
||||
ba.animate(cmb,
|
||||
"input1", {
|
||||
0.0: color[1] * mult,
|
||||
tm1: color[1],
|
||||
tm2: color[1] * mult
|
||||
},
|
||||
loop=True)
|
||||
ba.animate(cmb,
|
||||
"input2", {
|
||||
0.0: color[2] * mult,
|
||||
tm1: color[2],
|
||||
tm2: color[2] * mult
|
||||
},
|
||||
loop=True)
|
||||
cmb.input3 = color[3]
|
||||
cmb.connectattr('output', self.node, 'color')
|
||||
|
||||
cmb = self.position_combine = ba.newnode('combine',
|
||||
owner=self.node,
|
||||
attrs={'size': 2})
|
||||
if transition == 'in_right':
|
||||
keys = {
|
||||
transition_delay: position[0] + 1.3,
|
||||
transition_delay + 0.2: position[0]
|
||||
}
|
||||
o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0}
|
||||
ba.animate(cmb, 'input0', keys)
|
||||
cmb.input1 = position[1]
|
||||
ba.animate(self.node, 'opacity', o_keys)
|
||||
elif transition == 'in_left':
|
||||
keys = {
|
||||
transition_delay: position[0] - 1.3,
|
||||
transition_delay + 0.2: position[0]
|
||||
}
|
||||
o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0}
|
||||
if transition_out_delay is not None:
|
||||
keys[transition_delay + transition_out_delay] = position[0]
|
||||
keys[transition_delay + transition_out_delay +
|
||||
0.2] = position[0] - 1300.0
|
||||
o_keys[transition_delay + transition_out_delay + 0.15] = 1.0
|
||||
o_keys[transition_delay + transition_out_delay + 0.2] = 0.0
|
||||
ba.animate(cmb, 'input0', keys)
|
||||
cmb.input1 = position[1]
|
||||
ba.animate(self.node, 'opacity', o_keys)
|
||||
elif transition == 'in_bottom_slow':
|
||||
keys = {
|
||||
transition_delay: -100.0,
|
||||
transition_delay + 1.0: position[1]
|
||||
}
|
||||
o_keys = {transition_delay: 0.0, transition_delay + 0.2: 1.0}
|
||||
cmb.input0 = position[0]
|
||||
ba.animate(cmb, 'input1', keys)
|
||||
ba.animate(self.node, 'opacity', o_keys)
|
||||
elif transition == 'in_bottom':
|
||||
keys = {
|
||||
transition_delay: -100.0,
|
||||
transition_delay + 0.2: position[1]
|
||||
}
|
||||
o_keys = {transition_delay: 0.0, transition_delay + 0.05: 1.0}
|
||||
if transition_out_delay is not None:
|
||||
keys[transition_delay + transition_out_delay] = position[1]
|
||||
keys[transition_delay + transition_out_delay + 0.2] = -100.0
|
||||
o_keys[transition_delay + transition_out_delay + 0.15] = 1.0
|
||||
o_keys[transition_delay + transition_out_delay + 0.2] = 0.0
|
||||
cmb.input0 = position[0]
|
||||
ba.animate(cmb, 'input1', keys)
|
||||
ba.animate(self.node, 'opacity', o_keys)
|
||||
elif transition == 'inTopSlow':
|
||||
keys = {transition_delay: 0.4, transition_delay + 3.5: position[1]}
|
||||
o_keys = {transition_delay: 0.0, transition_delay + 1.0: 1.0}
|
||||
cmb.input0 = position[0]
|
||||
ba.animate(cmb, 'input1', keys)
|
||||
ba.animate(self.node, 'opacity', o_keys)
|
||||
else:
|
||||
cmb.input0 = position[0]
|
||||
cmb.input1 = position[1]
|
||||
cmb.connectattr('output', self.node, 'position')
|
||||
|
||||
# if we're transitioning out, die at the end of it
|
||||
if transition_out_delay is not None:
|
||||
ba.timer(transition_delay + transition_out_delay + 1.0,
|
||||
ba.WeakCall(self.handlemessage, ba.DieMessage()))
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
if __debug__ is True:
|
||||
self._handlemessage_sanity_check()
|
||||
if isinstance(msg, ba.DieMessage):
|
||||
if self.node:
|
||||
self.node.delete()
|
||||
return None
|
||||
return super().handlemessage(msg)
|
||||
93
assets/src/data/scripts/bastd/actor/tipstext.py
Normal file
93
assets/src/data/scripts/bastd/actor/tipstext.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""Provides tip related Actor(s)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TipsText(ba.Actor):
|
||||
"""A bit of text showing various helpful game tips."""
|
||||
|
||||
def __init__(self, offs_y: float = 100.0):
|
||||
super().__init__()
|
||||
self._tip_scale = 0.8
|
||||
self._tip_title_scale = 1.1
|
||||
self._offs_y = offs_y
|
||||
self.node = ba.newnode('text',
|
||||
delegate=self,
|
||||
attrs={
|
||||
'text': '',
|
||||
'scale': self._tip_scale,
|
||||
'h_align': 'left',
|
||||
'maxwidth': 800,
|
||||
'vr_depth': -20,
|
||||
'v_align': 'center',
|
||||
'v_attach': 'bottom'
|
||||
})
|
||||
tval = ba.Lstr(value='${A}:',
|
||||
subs=[('${A}', ba.Lstr(resource='tipText'))])
|
||||
self.title_node = ba.newnode('text',
|
||||
delegate=self,
|
||||
attrs={
|
||||
'text': tval,
|
||||
'scale': self._tip_title_scale,
|
||||
'maxwidth': 122,
|
||||
'h_align': 'right',
|
||||
'vr_depth': -20,
|
||||
'v_align': 'center',
|
||||
'v_attach': 'bottom'
|
||||
})
|
||||
self._message_duration = 10000
|
||||
self._message_spacing = 3000
|
||||
self._change_timer = ba.Timer(
|
||||
0.001 * (self._message_duration + self._message_spacing),
|
||||
ba.WeakCall(self.change_phrase),
|
||||
repeat=True)
|
||||
self._combine = ba.newnode("combine",
|
||||
owner=self.node,
|
||||
attrs={
|
||||
'input0': 1.0,
|
||||
'input1': 0.8,
|
||||
'input2': 1.0,
|
||||
'size': 4
|
||||
})
|
||||
self._combine.connectattr('output', self.node, 'color')
|
||||
self._combine.connectattr('output', self.title_node, 'color')
|
||||
self.change_phrase()
|
||||
|
||||
def change_phrase(self) -> None:
|
||||
"""Switch the visible tip phrase."""
|
||||
from ba.internal import get_remote_app_name, get_next_tip
|
||||
next_tip = ba.Lstr(translate=('tips', get_next_tip()),
|
||||
subs=[('${REMOTE_APP_NAME}', get_remote_app_name())
|
||||
])
|
||||
spc = self._message_spacing
|
||||
assert self.node
|
||||
self.node.position = (-200, self._offs_y)
|
||||
self.title_node.position = (-220, self._offs_y + 3)
|
||||
keys = {
|
||||
spc: 0,
|
||||
spc + 1000: 1.0,
|
||||
spc + self._message_duration - 1000: 1.0,
|
||||
spc + self._message_duration: 0.0
|
||||
}
|
||||
ba.animate(self._combine,
|
||||
"input3", {k: v * 0.5
|
||||
for k, v in list(keys.items())},
|
||||
timeformat=ba.TimeFormat.MILLISECONDS)
|
||||
self.node.text = next_tip
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
if __debug__ is True:
|
||||
self._handlemessage_sanity_check()
|
||||
if isinstance(msg, ba.DieMessage):
|
||||
if self.node:
|
||||
self.node.delete()
|
||||
self.title_node.delete()
|
||||
return None
|
||||
return super().handlemessage(msg)
|
||||
196
assets/src/data/scripts/bastd/actor/zoomtext.py
Normal file
196
assets/src/data/scripts/bastd/actor/zoomtext.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""Defined Actor(s)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Union, Tuple, Sequence
|
||||
|
||||
|
||||
class ZoomText(ba.Actor):
|
||||
"""Big Zooming Text.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
Used for things such as the 'BOB WINS' victory messages.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
text: Union[str, ba.Lstr],
|
||||
position: Tuple[float, float] = (0.0, 0.0),
|
||||
shiftposition: Tuple[float, float] = None,
|
||||
shiftdelay: float = None,
|
||||
lifespan: float = None,
|
||||
flash: bool = True,
|
||||
trail: bool = True,
|
||||
h_align: str = "center",
|
||||
color: Sequence[float] = (0.9, 0.4, 0.0),
|
||||
jitter: float = 0.0,
|
||||
trailcolor: Sequence[float] = (1.0, 0.35, 0.1, 0.0),
|
||||
scale: float = 1.0,
|
||||
project_scale: float = 1.0,
|
||||
tilt_translate: float = 0.0,
|
||||
maxwidth: float = None):
|
||||
# pylint: disable=too-many-locals
|
||||
super().__init__()
|
||||
self._dying = False
|
||||
positionadjusted = (position[0], position[1] - 100)
|
||||
if shiftdelay is None:
|
||||
shiftdelay = 2.500
|
||||
if shiftdelay < 0.0:
|
||||
ba.print_error('got shiftdelay < 0')
|
||||
shiftdelay = 0.0
|
||||
self._project_scale = project_scale
|
||||
self.node = ba.newnode(
|
||||
'text',
|
||||
delegate=self,
|
||||
attrs={
|
||||
'position': positionadjusted,
|
||||
'big': True,
|
||||
'text': text,
|
||||
'trail': trail,
|
||||
'vr_depth': 0,
|
||||
'shadow': 0.0 if trail else 0.3,
|
||||
'scale': scale,
|
||||
'maxwidth': maxwidth if maxwidth is not None else 0.0,
|
||||
'tilt_translate': tilt_translate,
|
||||
'h_align': h_align,
|
||||
'v_align': 'center'
|
||||
})
|
||||
|
||||
# we never jitter in vr mode..
|
||||
if ba.app.vr_mode:
|
||||
jitter = 0.0
|
||||
|
||||
# if they want jitter, animate its position slightly...
|
||||
if jitter > 0.0:
|
||||
self._jitter(positionadjusted, jitter * scale)
|
||||
|
||||
# if they want shifting, move to the shift position and
|
||||
# then resume jittering
|
||||
if shiftposition is not None:
|
||||
positionadjusted2 = (shiftposition[0], shiftposition[1] - 100)
|
||||
ba.timer(
|
||||
shiftdelay,
|
||||
ba.WeakCall(self._shift, positionadjusted, positionadjusted2))
|
||||
if jitter > 0.0:
|
||||
ba.timer(
|
||||
shiftdelay + 0.25,
|
||||
ba.WeakCall(self._jitter, positionadjusted2,
|
||||
jitter * scale))
|
||||
color_combine = ba.newnode('combine',
|
||||
owner=self.node,
|
||||
attrs={
|
||||
'input2': color[2],
|
||||
'input3': 1.0,
|
||||
'size': 4
|
||||
})
|
||||
if trail:
|
||||
trailcolor_n = ba.newnode('combine',
|
||||
owner=self.node,
|
||||
attrs={
|
||||
'size': 3,
|
||||
'input0': trailcolor[0],
|
||||
'input1': trailcolor[1],
|
||||
'input2': trailcolor[2]
|
||||
})
|
||||
trailcolor_n.connectattr('output', self.node, 'trailcolor')
|
||||
basemult = 0.85
|
||||
ba.animate(
|
||||
self.node, 'trail_project_scale', {
|
||||
0: 0 * project_scale,
|
||||
basemult * 0.201: 0.6 * project_scale,
|
||||
basemult * 0.347: 0.8 * project_scale,
|
||||
basemult * 0.478: 0.9 * project_scale,
|
||||
basemult * 0.595: 0.93 * project_scale,
|
||||
basemult * 0.748: 0.95 * project_scale,
|
||||
basemult * 0.941: 0.95 * project_scale
|
||||
})
|
||||
if flash:
|
||||
mult = 2.0
|
||||
tm1 = 0.15
|
||||
tm2 = 0.3
|
||||
ba.animate(color_combine,
|
||||
'input0', {
|
||||
0: color[0] * mult,
|
||||
tm1: color[0],
|
||||
tm2: color[0] * mult
|
||||
},
|
||||
loop=True)
|
||||
ba.animate(color_combine,
|
||||
'input1', {
|
||||
0: color[1] * mult,
|
||||
tm1: color[1],
|
||||
tm2: color[1] * mult
|
||||
},
|
||||
loop=True)
|
||||
ba.animate(color_combine,
|
||||
'input2', {
|
||||
0: color[2] * mult,
|
||||
tm1: color[2],
|
||||
tm2: color[2] * mult
|
||||
},
|
||||
loop=True)
|
||||
else:
|
||||
color_combine.input0 = color[0]
|
||||
color_combine.input1 = color[1]
|
||||
color_combine.connectattr('output', self.node, 'color')
|
||||
ba.animate(self.node, 'project_scale', {
|
||||
0: 0,
|
||||
0.27: 1.05 * project_scale,
|
||||
0.3: 1 * project_scale
|
||||
})
|
||||
|
||||
# if they give us a lifespan, kill ourself down the line
|
||||
if lifespan is not None:
|
||||
ba.timer(lifespan, ba.WeakCall(self.handlemessage,
|
||||
ba.DieMessage()))
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
if __debug__ is True:
|
||||
self._handlemessage_sanity_check()
|
||||
if isinstance(msg, ba.DieMessage):
|
||||
if not self._dying and self.node:
|
||||
self._dying = True
|
||||
if msg.immediate:
|
||||
self.node.delete()
|
||||
else:
|
||||
ba.animate(
|
||||
self.node, 'project_scale', {
|
||||
0.0: 1 * self._project_scale,
|
||||
0.6: 1.2 * self._project_scale
|
||||
})
|
||||
ba.animate(self.node, 'opacity', {0.0: 1, 0.3: 0})
|
||||
ba.animate(self.node, 'trail_opacity', {0.0: 1, 0.6: 0})
|
||||
ba.timer(0.7, self.node.delete)
|
||||
return None
|
||||
return super().handlemessage(msg)
|
||||
|
||||
def _jitter(self, position: Tuple[float, float],
|
||||
jitter_amount: float) -> None:
|
||||
if not self.node:
|
||||
return
|
||||
cmb = ba.newnode('combine', owner=self.node, attrs={'size': 2})
|
||||
for index, attr in enumerate(['input0', 'input1']):
|
||||
keys = {}
|
||||
timeval = 0.0
|
||||
# gen some random keys for that stop-motion-y look
|
||||
for _i in range(10):
|
||||
keys[timeval] = (position[index] +
|
||||
(random.random() - 0.5) * jitter_amount * 1.6)
|
||||
timeval += random.random() * 0.1
|
||||
ba.animate(cmb, attr, keys, loop=True)
|
||||
cmb.connectattr('output', self.node, 'position')
|
||||
|
||||
def _shift(self, position1: Tuple[float, float],
|
||||
position2: Tuple[float, float]) -> None:
|
||||
if not self.node:
|
||||
return
|
||||
cmb = ba.newnode('combine', owner=self.node, attrs={'size': 2})
|
||||
ba.animate(cmb, 'input0', {0.0: position1[0], 0.25: position2[0]})
|
||||
ba.animate(cmb, 'input1', {0.0: position1[1], 0.25: position2[1]})
|
||||
cmb.connectattr('output', self.node, 'position')
|
||||
28
assets/src/data/scripts/bastd/appdelegate.py
Normal file
28
assets/src/data/scripts/bastd/appdelegate.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""Provide our delegate for high level app functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Type, Any, Dict, Callable, Optional
|
||||
|
||||
|
||||
class AppDelegate(ba.AppDelegate):
|
||||
"""Defines handlers for high level app functionality."""
|
||||
|
||||
def create_default_game_config_ui(
|
||||
self, gameclass: Type[ba.GameActivity],
|
||||
sessionclass: Type[ba.Session], config: Optional[Dict[str, Any]],
|
||||
completion_call: Callable[[Optional[Dict[str, Any]]], Any]
|
||||
) -> None:
|
||||
"""(internal)"""
|
||||
|
||||
# Replace the main window once we come up successfully.
|
||||
from bastd.ui.playlist.editgame import PlaylistEditGameWindow
|
||||
prev_window = ba.app.main_menu_window
|
||||
ba.app.main_menu_window = (PlaylistEditGameWindow(
|
||||
gameclass, sessionclass, config,
|
||||
completion_call=completion_call).get_root_widget())
|
||||
ba.containerwidget(edit=prev_window, transition='out_left')
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user