Permalink
Browse files

Adds support for changing paces to experiental FFA implementation.

  * We add a 'PaceManager' that keeps track of tempos, and switches between them at configured intervals.
  * Instead of pushing controller state to the game loop, the Player object computes deltas, and sends
    'events' like BUTTON_DOWN.

Based on 'top', the new game mode consumes about 1/4 as much CPU as the old FFA.
  • Loading branch information...
mbabinski-at-google committed Oct 1, 2017
1 parent 1188b0d commit a1f41c60dd673ee6b4642e48ad1f9270b5e1e955
Showing with 354 additions and 23 deletions.
  1. +30 −0 audio_tool.py
  2. +37 −0 common.py
  3. +40 −12 games/ffa.py
  4. +72 −0 pacemanager.py
  5. +65 −0 pacemanager_test.py
  6. +30 −5 piaudio.py
  7. +80 −6 player.py
@@ -0,0 +1,30 @@
import asyncio
import sys
sys.path.append('/home/pi/psmoveapi/build')
import piaudio
def Main():
music = piaudio.Music('audio/Joust/music/classical.wav')
music.start_audio_loop()
loop = asyncio.get_event_loop()
print("Enter a FP number:")
async def ProcessInput():
while True:
line = await loop.run_in_executor(None, sys.stdin.readline)
try:
ratio = float(line)
except ValueError:
print("invalid value: %s" % line)
await music.transition_ratio(ratio)
print("OK.")
loop.run_until_complete(ProcessInput())
music.stop_audio()
if __name__ == '__main__':
Main()
@@ -1,7 +1,10 @@
import asyncio
import colorsys
import enum
import functools
import psmove
import time
import traceback
color_range = 255
@@ -128,3 +131,37 @@ def rgb_bytes(self):
# Red is reserved for warnings/knockouts.
PLAYER_COLORS = [ c for c in Color if c not in (Color.RED, Color.WHITE, Color.BLACK) ]
def async_print_exceptions(f):
"""Wraps a coroutine to print exceptions (other than cancellations)."""
@functools.wraps(f)
async def wrapper(*args, **kwargs):
try:
await f(*args, **kwargs)
except asyncio.CancelledError:
raise
except:
traceback.print_exc()
raise
return wrapper
# Represents a pace the game is played at, encapsulating the tempo of the music as well
# as controller sensitivity.
class GamePace:
__slots__ = ['tempo', 'warn_threshold', 'death_threshold']
def __init__(self, tempo, warn_threshold, death_threshold):
self.tempo = tempo
self.warn_threshold = warn_threshold
self.death_threshold = death_threshold
def __str__(self):
return '<GamePace tempo=%s, warn=%s, death=%s>' % (self.tempo, self.warn_threshold, self.death_threshold)
# TODO: These are placeholder values.
# We can't take the values from joust.py, since those are compared to the sum of the
# three accelerometer dimensions, whereas we compute the magnitude of the acceleration
# vector.
SLOW_PACE = GamePace(tempo=0.4, warn_threshold=2, death_threshold=4)
MEDIUM_PACE = GamePace(tempo=1.0, warn_threshold=3, death_threshold=5)
FAST_PACE = GamePace(tempo=1.5, warn_threshold=5, death_threshold=9)
FREEZE_PACE = GamePace(tempo=0, warn_threshold=1.1, death_threshold=1.2)
@@ -3,17 +3,19 @@
import time
import common
from player import Player, PlayerCollection
import pacemanager
from player import Player, PlayerCollection, EventType
# Hertz
UPDATE_FREQUENCY=30
# TODO: These are placeholder values.
# We can't take the values from joust.py, since those are compared to the sum of the
# three accelerometer dimensions, whereas we compute the magnitude of the acceleration
# vector.
DEATH_THRESHOLD=7
WARN_THRESHOLD=2
# Values are (weight, min_duration, max_duration)
PACE_TIMING = {
common.MEDIUM_PACE: (1.0, 10, 23),
common.FAST_PACE: (1.0, 5, 15),
}
INITIAL_PACE_DURATION=18
class FreeForAll:
## Note, the "Player" objects should probably get created (and assigned colors) by the core game code, not here.
@@ -23,31 +25,56 @@ def __init__(self, controllers, music):
player.set_player_color(color)
self.players = PlayerCollection(players)
self.music = music
self.pace_ = common.MEDIUM_PACE
self.rainbow_duration_ = 6
def has_winner_(self):
if len(self.players.active_players) == 0:
raise ValueError("Can't have zero players!")
return len(self.players.active_players) == 1
def build_pace_manager_(self):
pm = pacemanager.PaceManager(self.pace_change_callback_, self.pace_, INITIAL_PACE_DURATION)
for pace, timing in PACE_TIMING.items():
pm.add_or_update_pace(pace, *timing)
return pm
def pace_change_callback_(self, new_pace):
@common.async_print_exceptions
async def change_pace():
print("Changing pace to %s..." % new_pace)
transition_future = self.music.transition_ratio(new_pace.tempo)
# If we're slowing down the pace, give players a grace period to respond.
if new_pace.tempo < self.pace_.tempo:
await transition_future
await asyncio.sleep(0.5)
self.pace_ = new_pace
print(".... Done.")
asyncio.ensure_future(change_pace())
def game_tick_(self):
"""Implements a game tick.
Polls controllers for input, and issues warnings/deaths to players."""
# Make a copy of the active players, as we may modify it during iteration.
for player, state in self.players.active_player_events():
if state.acceleration_magnitude > DEATH_THRESHOLD:
self.players.kill_player(player)
pace = self.pace_
for event in self.players.active_player_events(EventType.ACCELEROMETER):
if event.acceleration_magnitude > pace.death_threshold:
self.players.kill_player(event.player)
# Cut out early if we have a winner, so we don't accidentally kill all remaining players.
if self.has_winner_():
break
elif state.acceleration_magnitude > WARN_THRESHOLD:
player.warn()
elif event.acceleration_magnitude > pace.warn_threshold:
event.player.warn()
async def run(self):
"""Main loop for this game."""
# TODO: Countdown/Intro.
self.music.start_audio_loop()
# TODO: Vary pace with player deaths.
pm = self.build_pace_manager_()
pm.start()
try:
while not self.has_winner_():
self.game_tick_()
@@ -57,6 +84,7 @@ def game_tick_(self):
winner = list(self.players.active_players)[0]
await winner.show_rainbow(self.rainbow_duration_)
finally:
pm.stop()
self.music.stop_audio()
self.players.cancel_effects()
@@ -0,0 +1,72 @@
import asyncio
import collections
import random
import typing
import common
PaceSettings_ = collections.namedtuple('PaceSettings_', ['weight', 'min_duration', 'max_duration'])
class PaceManager:
"""Manages transitions between game paces, and notifies users of changes via a callback.
The game starts out in the initial pace, then switches pace according to parameters
passed in. The actual pace is treated as an opaque object -- this class does not care
what the pace represents, it is just in charge of timing transitions.
Sample usage:
pm = PaceManager(cb, pace1, 10)
pm.add_or_update_pace(pace2, 1.0, 10, 20)
pm.add_or_update_pace(pace3, 2.0, 5, 10)
pm.start()
....
pm.stop()
Here, we start off with pace1 for 10 seconds. After that, we will switch to either pace2, or pace3,
with pace3 being twice as likely. If pace2 is chosen, it will be kept for 10-20 seconds. pace3 will
be kept for 5-10 seconds.
"""
def __init__(self, callback, initial_pace, initial_pace_time: float, rng=random.uniform):
self.initial_pace_ = initial_pace
self.initial_pace_time_ = initial_pace_time
self.available_paces_ = {}
self.task_ = None
self.rng_ = rng
self.callback_ = callback
def add_or_update_pace(self, pace, weight: float, min_duration: float, max_duration: float):
self.available_paces_[pace] = PaceSettings_(weight, min_duration, max_duration)
def start(self):
self.task_ = asyncio.ensure_future(self.run_())
return self.task_
def stop(self):
self.task_.cancel()
def set_pace_(self, pace):
self.callback_(pace)
def choose_new_pace_(self, old_pace) -> typing.Tuple[object, float]:
if len(self.available_paces_) == 0:
raise RuntimeError("No paces registered.")
candidates = self.available_paces_
total_weight = sum([ params.weight for params in candidates.values() ])
index = self.rng_(0, total_weight)
cumulative_weight = 0
for pace, params in candidates.items():
cumulative_weight += params.weight
if cumulative_weight >= index:
return pace, self.rng_(params.min_duration, params.max_duration)
raise ValueError("Couldn't find pace with index %s/%s!?" % (index, total_weight))
@common.async_print_exceptions
async def run_(self):
await asyncio.sleep(self.initial_pace_time_)
pace = self.initial_pace_
while True:
pace, duration_secs = self.choose_new_pace_(pace)
self.set_pace_(pace)
await asyncio.sleep(duration_secs)
@@ -0,0 +1,65 @@
import asyncio
import collections
import time
import unittest
import pacemanager
PACE1 = object()
PACE2 = object()
PACE3 = object()
class PaceManagerTest(unittest.TestCase):
def assertPrettyClose(self, a, b, error=0.1):
self.assertGreater(error, abs(a - b))
def test_distribution(self):
pm = pacemanager.PaceManager(lambda x: True, PACE1, 5)
pm.add_or_update_pace(PACE1, 1.0, 1, 2)
# Test to make sure we don't double up on PACE2
pm.add_or_update_pace(PACE2, 1.0, 1, 2)
pm.add_or_update_pace(PACE2, 1.0, 1, 2)
pm.add_or_update_pace(PACE3, 2.0, 1, 2)
results = collections.defaultdict(int)
num_trials = 1000
for i in range(num_trials):
pace, duration = pm.choose_new_pace_(PACE1)
results[pace] += 1
self.assertLessEqual(1, duration)
self.assertGreater(2, duration)
self.assertEqual(3, len(results))
self.assertPrettyClose(1/4, results[PACE1]/num_trials)
self.assertPrettyClose(1/4, results[PACE2]/num_trials)
self.assertPrettyClose(1/2, results[PACE3]/num_trials)
def test_async(self):
Entry = collections.namedtuple('Entry', ['pace', 'time'])
results = []
begin = time.time()
def UpdatePace(pace):
results.append(Entry(pace, time.time() - begin))
uniform = lambda a, b: a
DELTA = 0.1
pm = pacemanager.PaceManager(UpdatePace, PACE1, DELTA, rng=uniform)
pm.add_or_update_pace(PACE1, 1.0, DELTA, 1 + DELTA)
pm.add_or_update_pace(PACE2, 1.0, DELTA, 1 + DELTA)
loop = asyncio.get_event_loop()
try:
# This should get us 4 events.
timeout = DELTA * 4.1
loop.run_until_complete(asyncio.wait_for(pm.start(), timeout=timeout))
except asyncio.TimeoutError:
pass
# We should have registered a new pace 4 times, about DELTA seconds apart.
self.assertEqual(4, len(results))
for i in range(4):
self.assertPrettyClose(results[i].time, DELTA * (i+1))
if __name__ == '__main__':
unittest.main()
@@ -1,3 +1,4 @@
import asyncio
import wave
import functools
import io
@@ -11,6 +12,8 @@
import threading
from pydub import AudioSegment
import common
def audio_loop(wav_data, ratio, stop_proc):
# TODO: As a future improvment, we could precompute resampled versions of the track
# at the "steady" playback rates, and only do dynamic resampling when transitioning
@@ -115,13 +118,14 @@ def start_effect_and_wait(self):
@functools.lru_cache(maxsize=16)
class Music:
def __init__(self, fname):
self.load_thread_ = threading.Thread(target=lambda: self.load_sample_(fname))
self.load_thread_.start()
self.load_thread_ = threading.Thread(target=lambda: self.load_sample_(fname))
self.load_thread_.start()
self.transition_future_ = asyncio.Future()
def wait_for_sample_(self):
if self.load_thread_:
self.load_thread_.join()
self.load_thread_ = None
if self.load_thread_:
self.load_thread_.join()
self.load_thread_ = None
def load_sample_(self, fname):
try:
@@ -149,13 +153,34 @@ def stop_audio(self):
self.stop_proc.value = 1
time.sleep(0.1)
self.p.terminate()
self.transition_future_.cancel()
def change_ratio(self, ratio):
self.ratio.value = ratio
def transition_ratio(self, new_ratio, transition_duration=1.0):
"""Smoothly transitions between the current sampling ratio and the given one.
Returns a task that completes once the transition is finished."""
async def do_transition():
num_steps = 20
old_ratio = self.ratio.value
for i in range(num_steps):
t = (i+1) / 20
ratio = common.lerp(old_ratio, new_ratio, t)
ratio = old_ratio * (1-t) + new_ratio * t
self.change_ratio(ratio)
await asyncio.sleep(transition_duration / num_steps)
self.transition_future_.cancel()
self.transition_future_ = asyncio.ensure_future(do_transition())
return self.transition_future_
class DummyMusic:
def start_audio_loop(self): pass
def stop_audio(self): pass
def change_ratio(self): pass
def transition_ratio(self, new_ratio, transition_duration=None):
async def do_nothing(): pass
return asyncio.ensure_future(do_nothing())
def InitAudio():
pygame.mixer.init(47000, -16, 2 , 4096)
Oops, something went wrong.

0 comments on commit a1f41c6

Please sign in to comment.