Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions docs/zero-gravity.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,23 @@ The zero gravity mini-game asks the player to copy various poses, and awards poi
- Sprites for each pose
- Text explanations for each pose


## Adaptive difficulty

Zero gravity has no adaptive difficulty. The poses, time limits and so on are the same for every game.
Zero gravity has no adaptive difficulty. The poses, time limits and so on are the same for every game.

## Save data

Data is saved to `ZeroGravityScores.csv`, with one row per pose. This means there may be _multiple_ rows per played game. Values are:

- `gameNumber`: a unique id per played zero gravity game
- `sessionNumber`: the session this game was played in (corresponds to sessionNumber in [`SessionSummary.csv`](./session-summary.md))
- `poseNumber`: a unique id per pose. This resets each time the game is played, so the first pose of each game has poseNumber=1.
- `date`: the date of the game session in format YYYY-MM-DD
- `startTime`: the game start time in format HH:MM:ss. This is the local time (e.g. if your computer is set to UK time - this is UK time).
- `endTime`: the game end time in format HH:MM:ss (local time - see startTime description)
- `gameCompleted`: whether this game was completed. If they exited early, this will be false.
- `poseType`: the name of the pose (this is the same as the filename of the displayed pose image png)
- `poseTimeLimitSeconds`: the time limit in seconds for each pose
- `poseDurationSeconds`: the number of seconds the player tried this pose (rounded to the nearest second). If they completed the pose, this will be equal to poseTimeLimitSeconds; if they exited early, it will be less.
- `balanceStabilityScore`: the balance stability score for this pose (this is equal to the displayed score increase shown while playing the game). Currently, 5 points are awarded per second the player stays in bounds. If the player exited before scoring for this pose started, this value will be left blank.
- `falls`: the number of falls while holding this pose. A 'fall' is counted each time the player's head moves out of bounds - for example, if they move too far left or right (in the game, this causes the sway line to change colour from white to black), or the tracker can no longer detect the player (for example, if they step backwards too far). If the player exited before scoring for this pose started, this value will be left blank.
22 changes: 11 additions & 11 deletions projects/AstroBalance/Assets/Scripts/RocketLaunch/LaunchControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@ public class LaunchControl : MonoBehaviour
private float timeToLaunch;

// head speed parameters
private HeadAngleBuffer headPitchBuffer;
private HeadAngleBuffer headYawBuffer;
private HeadPoseBuffer headPoseBuffer;
private bool usePitch; //true if we're using pitch speed, false if we're using yaw speed.
private RocketLaunchData gameData;
private float rocketSpeed;
Expand Down Expand Up @@ -141,8 +140,7 @@ void Start()
{
usePitch = !lastGameData.Last().pitch;
}
headPitchBuffer = new HeadAngleBuffer(headPoseBufferCapacity, minDataRequired);
headYawBuffer = new HeadAngleBuffer(headPoseBufferCapacity, minDataRequired);
headPoseBuffer = new HeadPoseBuffer(headPoseBufferCapacity, minDataRequired);
instructionsText.text = usePitch
? "Nod your head and repeat the code to launch the rocket!"
: "Shake your head and repeat the code to launch the rocket!";
Expand Down Expand Up @@ -176,13 +174,17 @@ void Update()

if (usePitch)
{
headSpeed = headPitchBuffer.getSpeed(speedTime) - headYawBuffer.getSpeed(speedTime);
headSpeed =
headPoseBuffer.getSpeed(speedTime, HeadPoseAxis.Pitch)
- headPoseBuffer.getSpeed(speedTime, HeadPoseAxis.Yaw);
}
else
{
headSpeed =
(headYawBuffer.getSpeed(speedTime) - headPitchBuffer.getSpeed(speedTime))
* shakeSpeedReduction;
(
headPoseBuffer.getSpeed(speedTime, HeadPoseAxis.Yaw)
- headPoseBuffer.getSpeed(speedTime, HeadPoseAxis.Pitch)
) * shakeSpeedReduction;
}
headSpeed = Mathf.Max(0, headSpeed); // Clamp to zero to avoid negative speeds

Expand Down Expand Up @@ -274,10 +276,8 @@ private GazeItem AddToBuffers()
gazeItem.gazePoint.X = worldGaze.x;
gazeItem.gazePoint.Y = worldGaze.y;
}
HeadPitchItem headPitch = new HeadPitchItem(headPose);
HeadYawItem headYaw = new HeadYawItem(headPose);
headPitchBuffer.addIfNew(headPitch);
headYawBuffer.addIfNew(headYaw);

headPoseBuffer.addIfNew(new HeadPoseItem(headPose));
gazeBuffer.addIfNew(gazeItem);

return gazeItem;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public class StarCollectorManager : MonoBehaviour

private float bufferWindowStart; // start time of buffer update window
private bool outOfRangeInWindow = false; // whether the player went out of range of the tracker during this window
private HeadAngleBuffer headYawBuffer;
private HeadPoseBuffer headPoseBuffer;
private List<float> headYawSpeeds = new List<float>();

// Start is called once before the first execution of Update after the MonoBehaviour is created
Expand All @@ -94,7 +94,7 @@ void Start()
score = 0;
scoreText.text = score.ToString();

headYawBuffer = new HeadAngleBuffer(maxNItemsInBuffer, minNItemsForSpeed);
headPoseBuffer = new HeadPoseBuffer(maxNItemsInBuffer, minNItemsForSpeed);
timer.StartCountdown(timeLimit);

speedWindowStart = Time.time;
Expand Down Expand Up @@ -155,8 +155,8 @@ void Update()
return;
}

// Keep track of head yaw angles on every update
UpdateHeadYawBuffer();
// Keep track of head poses on every update
UpdateHeadPoseBuffer();

// Every 'samplingIntervalSeconds', record the speed averaged over that time period
if (Time.time - bufferWindowStart >= samplingIntervalSeconds)
Expand All @@ -179,9 +179,9 @@ void Update()
}

/// <summary>
/// Add latest head yaw angle to buffer
/// Add latest head pose to buffer
/// </summary>
private void UpdateHeadYawBuffer()
private void UpdateHeadPoseBuffer()
{
// If the player goes out of range of the tracker
if (!tracker.isPlayerDetected())
Expand All @@ -191,8 +191,7 @@ private void UpdateHeadYawBuffer()
else
{
HeadPose headPose = tracker.getHeadPose();
HeadYawItem headYaw = new HeadYawItem(headPose);
headYawBuffer.addIfNew(headYaw);
headPoseBuffer.addIfNew(new HeadPoseItem(headPose));
}
}

Expand All @@ -205,7 +204,7 @@ private void RecordHeadSpeed()
// (otherwise, if they've been out of range for a while, we may be calculating the speed of quite old data in the buffer)
if (!outOfRangeInWindow)
{
float headSpeed = headYawBuffer.getSpeed(samplingIntervalSeconds);
float headSpeed = headPoseBuffer.getSpeed(samplingIntervalSeconds, HeadPoseAxis.Yaw);

// If there aren't enough recorded head yaw angles yet, the returned speed is zero.
// We don't want to include these readings in the overall averages.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// <summary>
/// Save data for a single star map session
/// Save data for a single star map trial
/// </summary>
[System.Serializable]
public class StarMapData : GameData
Expand Down
92 changes: 47 additions & 45 deletions projects/AstroBalance/Assets/Scripts/TrackerBuffers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,53 +4,71 @@
using Tobii.GameIntegration.Net;

/// <summary>
/// Holds a buffer for a head angle (pitch, yaw or roll).
/// Head pose rotation and position axes
/// </summary>
class HeadAngleBuffer : TobiiBuffer<HeadAngleItem>
enum HeadPoseAxis
{
Roll,
Pitch,
Yaw,
X,
Y,
Z,
}

/// <summary>
/// Holds a buffer for a head pose (position and rotation).
/// </summary>
class HeadPoseBuffer : TobiiBuffer<HeadPoseItem>
{
/// <summary>
/// Initializes a new instance of the HeadAngleBuffer class.
/// Initializes a new instance of the HeadPoseBuffer class.
/// </summary>
/// <param name="capacity">The maximum number of items that can be stored in the buffer.</param>
/// <param name="minDataRequired">The minimum number of data points required to calculate a speed.</param>
public HeadAngleBuffer(int capacity, int minDataRequired)
public HeadPoseBuffer(int capacity, int minDataRequired)
: base(capacity, minDataRequired) { }

/// <summary>
/// Calculates the average speed of the buffer over a given time period.
/// Speed is calculated as the average change in angle returned by GetAngle
/// Divided by the total change in time returned by TimeStampMicroSeconds
/// Speed is calculated as the average change in angle (for roll / pitch / yaw) or position (X, Y, Z)
/// divided by the total change in time based on TimeStampMicroSeconds
/// </summary>
/// <param name="speedTime">The time period in seconds over which to calculate the average speed.</param>
/// <returns>The average speed of the buffer over the given time period (in degrees per second)</returns>
public float getSpeed(float speedTime)
/// <returns>
/// The average speed of the buffer over the given time period. For Roll / Pitch / Yaw: in degrees per second.
/// For X / Y / Z: in mm per second.
/// </returns>
public float getSpeed(float speedTime, HeadPoseAxis axis)
{
float averageSpeed = 0f;
if (!hasEnoughData)
return averageSpeed;

int timeInMicroseconds = (int)(speedTime * 1e6);
List<HeadAngleItem> headAngles = GetItems(timeInMicroseconds);
List<HeadPoseItem> headPoses = GetItems(timeInMicroseconds);

return calculateAverageSpeed(headAngles);
return calculateAverageSpeed(headPoses, axis);
}

private float calculateAverageSpeed(List<HeadAngleItem> headAngles)
private float calculateAverageSpeed(List<HeadPoseItem> headPoses, HeadPoseAxis axis)
{
if (headAngles.Count() < minDataRequired)
if (headPoses.Count() < minDataRequired)
{
return 0f;
}
float totalDistance = 0f;
for (int i = 0; i < headAngles.Count() - 1; i++)
for (int i = 0; i < headPoses.Count() - 1; i++)
{
totalDistance += Math.Abs(headAngles[i + 1].GetAngle() - headAngles[i].GetAngle());
totalDistance += Math.Abs(
headPoses[i + 1].GetValue(axis) - headPoses[i].GetValue(axis)
);
}

double totalTime =
(
headAngles[0].TimeStampMicroSeconds()
- headAngles[headAngles.Count() - 1].TimeStampMicroSeconds()
headPoses[0].TimeStampMicroSeconds()
- headPoses[headPoses.Count() - 1].TimeStampMicroSeconds()
) / 1e6;
float averageSpeed = (float)(totalDistance / totalTime);

Expand Down Expand Up @@ -172,45 +190,29 @@ class GazeItem : ITimeStampMicroSeconds
/// <summary>
/// Wrapper for Tobii headpose data, implementing timestamp interface.
/// </summary>
abstract class HeadAngleItem : ITimeStampMicroSeconds
class HeadPoseItem : ITimeStampMicroSeconds
{
protected HeadPose headPose;

public HeadAngleItem(HeadPose headPose)
public HeadPoseItem(HeadPose headPose)
{
this.headPose = headPose;
}

public long TimeStampMicroSeconds() => headPose.TimeStampMicroSeconds;

public abstract float GetAngle();
}

/// <summary>
/// Wrapper for Tobii head pose pitch data, returning pitch angle.
/// </summary>
class HeadPitchItem : HeadAngleItem
{
public HeadPitchItem(HeadPose headPose)
: base(headPose) { }

public override float GetAngle()
{
return headPose.Rotation.PitchDegrees;
}
}

/// <summary>
/// Wrapper for Tobii head pose yaw data, returning yaw angle.
/// </summary>
class HeadYawItem : HeadAngleItem
{
public HeadYawItem(HeadPose headPose)
: base(headPose) { }

public override float GetAngle()
public float GetValue(HeadPoseAxis axis)
{
return headPose.Rotation.YawDegrees;
return axis switch
{
HeadPoseAxis.Roll => headPose.Rotation.RollDegrees,
HeadPoseAxis.Pitch => headPose.Rotation.PitchDegrees,
HeadPoseAxis.Yaw => headPose.Rotation.YawDegrees,
HeadPoseAxis.X => headPose.Position.X,
HeadPoseAxis.Y => headPose.Position.Y,
HeadPoseAxis.Z => headPose.Position.Z,
_ => throw new InvalidOperationException("Unknown head pose axis"),
};
}
}

Expand Down
21 changes: 21 additions & 0 deletions projects/AstroBalance/Assets/Scripts/ZeroGravity/PoseAvatar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,27 @@ public bool ShowNextSprite()
return true;
}

public int GetCurrentSpriteIndex()
{
return currentIndex;
}

/// <summary>
/// Get name of the currently shown sprite.
/// This name is the same as the source png filename.
/// </summary>
public string GetCurrentSpriteName()
{
if (currentIndex >= 0)
{
return sprites[currentIndex].texture.name;
}
else
{
return null;
}
}

public void HideExplanationText()
{
poseText.gameObject.SetActive(false);
Expand Down
25 changes: 25 additions & 0 deletions projects/AstroBalance/Assets/Scripts/ZeroGravity/SwayLine.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using Tobii.GameIntegration.Net;
using UnityEngine;

Expand Down Expand Up @@ -25,6 +27,7 @@ public class SwayLine : MonoBehaviour
private float timeIncrement; // time increment required to score
private float timeOfNextScoreIncrease; // time remaining on pose hold timer at next score increase
private bool headOutOfRange = false;
private int nTimesOutOfRange = 0; // number of times the player's head has gone out of range while scoring is active

// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
Expand All @@ -41,7 +44,9 @@ void Start()
/// <param name="timeIncrement">Time in seconds head must stay in range to score</param>
public void ActivateScoring(int timeLimit, float timeIncrement)
{
nTimesOutOfRange = 0;
this.timeIncrement = timeIncrement;

// We base scoring on the pose hold timer so that everything stays in sync,
// and exactly matches the displayed countdown times
poseHoldTimer.StartCountdown(timeLimit);
Expand All @@ -62,6 +67,16 @@ public void DeactivateScoring()
scoringActive = false;
}

/// <summary>
/// Get the number of times the player's head went out of range while scoring
/// was active.
/// This value is reset each time scoring is activated.
/// </summary>
public int GetNTimesOutOfRange()
{
return nTimesOutOfRange;
}

// Update is called once per frame
void Update()
{
Expand All @@ -73,6 +88,11 @@ void Update()
else
{
spriteRenderer.enabled = false;

if (!headOutOfRange && scoringActive)
{
nTimesOutOfRange++;
}
headOutOfRange = true;
}
}
Expand All @@ -88,6 +108,11 @@ private void UpdateLinePosition()
if (outOfRange)
{
spriteRenderer.color = outRangeColor;

if (!headOutOfRange && scoringActive)
{
nTimesOutOfRange++;
}
headOutOfRange = true;
}
else
Expand Down
Loading