Skip to content

Commit

Permalink
Adds support for changing paces to experiental FFA implementation.
Browse files Browse the repository at this point in the history
  * 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 4, 2017
1 parent 1188b0d commit a1f41c6
Show file tree
Hide file tree
Showing 7 changed files with 354 additions and 23 deletions.
30 changes: 30 additions & 0 deletions audio_tool.py
Original file line number Original file line Diff line number Diff line change
@@ -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()
37 changes: 37 additions & 0 deletions common.py
Original file line number Original file line Diff line number Diff line change
@@ -1,7 +1,10 @@
import asyncio
import colorsys import colorsys
import enum import enum
import functools
import psmove import psmove
import time import time
import traceback


color_range = 255 color_range = 255


Expand Down Expand Up @@ -128,3 +131,37 @@ def rgb_bytes(self):


# Red is reserved for warnings/knockouts. # Red is reserved for warnings/knockouts.
PLAYER_COLORS = [ c for c in Color if c not in (Color.RED, Color.WHITE, Color.BLACK) ] 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)
52 changes: 40 additions & 12 deletions games/ffa.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@
import time import time


import common import common
from player import Player, PlayerCollection import pacemanager
from player import Player, PlayerCollection, EventType


# Hertz # Hertz
UPDATE_FREQUENCY=30 UPDATE_FREQUENCY=30


# TODO: These are placeholder values. # Values are (weight, min_duration, max_duration)
# We can't take the values from joust.py, since those are compared to the sum of the PACE_TIMING = {
# three accelerometer dimensions, whereas we compute the magnitude of the acceleration common.MEDIUM_PACE: (1.0, 10, 23),
# vector. common.FAST_PACE: (1.0, 5, 15),
DEATH_THRESHOLD=7 }
WARN_THRESHOLD=2
INITIAL_PACE_DURATION=18


class FreeForAll: class FreeForAll:
## Note, the "Player" objects should probably get created (and assigned colors) by the core game code, not here. ## Note, the "Player" objects should probably get created (and assigned colors) by the core game code, not here.
Expand All @@ -23,31 +25,56 @@ def __init__(self, controllers, music):
player.set_player_color(color) player.set_player_color(color)
self.players = PlayerCollection(players) self.players = PlayerCollection(players)
self.music = music self.music = music
self.pace_ = common.MEDIUM_PACE
self.rainbow_duration_ = 6 self.rainbow_duration_ = 6


def has_winner_(self): def has_winner_(self):
if len(self.players.active_players) == 0: if len(self.players.active_players) == 0:
raise ValueError("Can't have zero players!") raise ValueError("Can't have zero players!")
return len(self.players.active_players) == 1 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): def game_tick_(self):
"""Implements a game tick. """Implements a game tick.
Polls controllers for input, and issues warnings/deaths to players.""" Polls controllers for input, and issues warnings/deaths to players."""
# Make a copy of the active players, as we may modify it during iteration. # Make a copy of the active players, as we may modify it during iteration.
for player, state in self.players.active_player_events(): pace = self.pace_
if state.acceleration_magnitude > DEATH_THRESHOLD: for event in self.players.active_player_events(EventType.ACCELEROMETER):
self.players.kill_player(player) 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. # Cut out early if we have a winner, so we don't accidentally kill all remaining players.
if self.has_winner_(): if self.has_winner_():
break break
elif state.acceleration_magnitude > WARN_THRESHOLD: elif event.acceleration_magnitude > pace.warn_threshold:
player.warn() event.player.warn()


async def run(self): async def run(self):
"""Main loop for this game.""" """Main loop for this game."""
# TODO: Countdown/Intro. # TODO: Countdown/Intro.
self.music.start_audio_loop() self.music.start_audio_loop()

# TODO: Vary pace with player deaths.
pm = self.build_pace_manager_()
pm.start()
try: try:
while not self.has_winner_(): while not self.has_winner_():
self.game_tick_() self.game_tick_()
Expand All @@ -57,6 +84,7 @@ async def run(self):
winner = list(self.players.active_players)[0] winner = list(self.players.active_players)[0]
await winner.show_rainbow(self.rainbow_duration_) await winner.show_rainbow(self.rainbow_duration_)
finally: finally:
pm.stop()
self.music.stop_audio() self.music.stop_audio()
self.players.cancel_effects() self.players.cancel_effects()


Expand Down
72 changes: 72 additions & 0 deletions pacemanager.py
Original file line number Original file line Diff line number Diff line change
@@ -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)
65 changes: 65 additions & 0 deletions pacemanager_test.py
Original file line number Original file line Diff line number Diff line change
@@ -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()
35 changes: 30 additions & 5 deletions piaudio.py
Original file line number Original file line Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import wave import wave
import functools import functools
import io import io
Expand All @@ -11,6 +12,8 @@
import threading import threading
from pydub import AudioSegment from pydub import AudioSegment


import common

def audio_loop(wav_data, ratio, stop_proc): def audio_loop(wav_data, ratio, stop_proc):
# TODO: As a future improvment, we could precompute resampled versions of the track # 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 # at the "steady" playback rates, and only do dynamic resampling when transitioning
Expand Down Expand Up @@ -115,13 +118,14 @@ def start_effect_and_wait(self):
@functools.lru_cache(maxsize=16) @functools.lru_cache(maxsize=16)
class Music: class Music:
def __init__(self, fname): def __init__(self, fname):
self.load_thread_ = threading.Thread(target=lambda: self.load_sample_(fname)) self.load_thread_ = threading.Thread(target=lambda: self.load_sample_(fname))
self.load_thread_.start() self.load_thread_.start()
self.transition_future_ = asyncio.Future()


def wait_for_sample_(self): def wait_for_sample_(self):
if self.load_thread_: if self.load_thread_:
self.load_thread_.join() self.load_thread_.join()
self.load_thread_ = None self.load_thread_ = None


def load_sample_(self, fname): def load_sample_(self, fname):
try: try:
Expand Down Expand Up @@ -149,13 +153,34 @@ def stop_audio(self):
self.stop_proc.value = 1 self.stop_proc.value = 1
time.sleep(0.1) time.sleep(0.1)
self.p.terminate() self.p.terminate()
self.transition_future_.cancel()
def change_ratio(self, ratio): def change_ratio(self, ratio):
self.ratio.value = 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: class DummyMusic:
def start_audio_loop(self): pass def start_audio_loop(self): pass
def stop_audio(self): pass def stop_audio(self): pass
def change_ratio(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(): def InitAudio():
pygame.mixer.init(47000, -16, 2 , 4096) pygame.mixer.init(47000, -16, 2 , 4096)
Loading

0 comments on commit a1f41c6

Please sign in to comment.