Pulling initial set of stuff from private

This commit is contained in:
Eric Froemling 2019-10-05 05:01:46 -07:00
parent 16d87a4a67
commit 827e0f5dc9
259 changed files with 82860 additions and 0 deletions

25
.editorconfig Normal file
View 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
View 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
View File

@ -0,0 +1,4 @@
+/tools
+/src/ballistica
+/src/generated_src
+/assets/src/data/scripts

1398
Makefile Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

View 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 = []

File diff suppressed because it is too large Load Diff

View 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)

View 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

View 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

View 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'))

View 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()

View 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")

View 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.

View 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)

View 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)

View 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

View 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()

View 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()

View 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

View 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()

View 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}))

File diff suppressed because it is too large Load Diff

View 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]

View 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

View 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()))))

View 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)

View 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

View 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

View 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

View 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()

View 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

View 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)

View 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."""

View 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()

View 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)

View 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
})

View 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()

View 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

View 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))

View 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

View 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

View 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

View 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')

View 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

View 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)

View 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

View 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)

View 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}))

View 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

View 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

View 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

View 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

View 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

View File

@ -0,0 +1,4 @@
# Synced from bsmaster.
# EFRO_SYNC_HASH=47258835994253322418493299167560392753
#
"""Functionality shared between Ballistica client and server components."""

View 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

View 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

View 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)

View 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.
"""

View 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()
}

View 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())

View 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)

View 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

View 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}'

View 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)

View 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

View 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)

View File

@ -0,0 +1,3 @@
"""BallisticaCore standard library: games, UI, etc."""
# bs_meta require api 6

View 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()

File diff suppressed because it is too large Load Diff

View 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))

View 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()

View 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)

View 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()

View File

@ -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()

View 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)

View 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)

File diff suppressed because it is too large Load Diff

View 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)

View 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)

View 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)

View 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()

View 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()

View 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)

View 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)

View 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)

View 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

View 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

View 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))

File diff suppressed because it is too large Load Diff

View 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'

File diff suppressed because it is too large Load Diff

View 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

View 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)

View 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)

View 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')

View 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