Sign in to follow this  
Panzorfork

[Dev Log] Rewind

Recommended Posts

Did you ever wish you could rewind time, a dev log

 

ZK8haw0.gif

 

----Released! http://panzorfork.itch.io/rewind


---A note about the dashes at the beginning of lines: '-' denotes the comment pass number that the comment represents. (E.X. '---' denotes the third comment pass, '--' the second, '-' the first, etc.)

----You'll work it out. You can do it, you're smart (pronounced with a Boston accent).

-Hi, I wrote the first draft of this about a week ago, so things have changed, lessons have been learned, clones have been made, and clones have been destroyed.

-I'm going to read through this and comment about each section.

-Let's go time travelling!

--It's me, from the current present. I failed to post this dev log again, so what you're about to read is an exercise in document time travelling.
--I'll try to keep formatting distinct for each time period the text represents, but things are going to get confusing...
--I've been trying to stay as minimally invasive of the past text (besides typos, light formatting etc).

----So here we are, the final comment pass, the end of the jam, and the game is finished. In the spirit of things, how about we start at the end, and do something of a post-mortem (if you want context, I'd suggest reading this document a section at a time, and come back to this post-mortem, or don't - it'll be odd either way)? I had a lot plans for this game, inspired in part by the 2 week timeline of the jam, but not all of them came to pass:

  • Rewind mechanic!
  • "WereDunkin'" backstory for the game.
  • Dunkin' based gameplay in which you try and avoid coffee zombies.
  • Galcon-like Dunkin' Donuts themed game, in which there are periodic rounds during which you can reorganize your Dunkin' Donuts locations (aka a double blind portion of time in Galcon during which you get to move your planets).
  • Ghost dad
  • "Falling platforms" or a "limited runway" level.
  • Overzealous AI buddy, facilitating use of rewind
  • Enemies

----Here's what ended up in the game:

  • Rewind mechanic!
  • Ghost dad
  • "Falling platforms" or a "limited runway" level.
  • Overzealous AI buddy, facilitating use of rewind
  • Meteors!

----"Known shippables":

  • While the meteors hit their targets perfectly fine, they seem to either continue to live or don't properly track. After a meteor hits, and time is rewound, the meteor will rewind with the correct amount of time, but it'll be beyond the terrain... This doesn't create much of a gameplay impact, so I'm going to let it slide.
  • Your AI buddies will get stuck on the terrain while falling to their deaths sometimes. I'm ok with this, just rewind

----"Time to make the donuts", or not. So obviously, Dunkin' Donuts is out. I came up with the "WereDunkin'" theme too far in to pivot (or so I thought), and was still struggling to get the rewind mechanic working at the time. I really do like the idea of a Rampart meets Galcon game, and might explore it in the future. I'm glad I stuck with the 2D platformer gameplay, as it allowed for 2 things: a low effort terrain plugin "Ferr2D Terrain", which really improved the looks and made it easier to create aesthetically pleasing platforms, and a low cost solution to background art in the form of switching the camera to perspective and using a heightmap, water, and skybox in the distance. Using the mix of 2D and 3D also allowed me to combine 3D projectiles with the 2D gameplay.

----I'm somewhat ambivalent towards the default character I used. This is one of the areas where I probably would have spent more time, had I either been a bit more sensible with my iterations on the rewind mechanic, or had gone with Chronos from the start. While exploring the WhereDunkin' theme, I drew a preliminary sprite sheet for the enemies, and somewhat regret not finishing it / integrating it into the final game.

HH5T66ol.png

----One of the things that felt really good from the beginning of this game's timeline was the music. ElektrikWizard did a wonderful job producing a variety of tracks to pick from, and Unity's `AudioSource.pitch` property being able to go negative basically did the rest. The audio rewinding is not perfect; while it does seek exactly to where you tell it, it then enters its "resume" state and slowly ramps pitch back up to 1. The result of this slow ramp ends up being euphonic (Rick was right about the slow ramp), although also means the pitch remains below zero for about 1/3 seconds, thus the inaccuracy.

----The final thing that I'd like to call attention to is the design of the game's single and only level. I don't really want to spoil it or spell it out, so if people are curious about what I had in mind, I can elaborate in a reply to this thread. While making this game, I've been listening to the book "Kraken" by China Miéville, so that may be informative of why I chose the theme I did.



----I've immensely enjoyed working on this game and getting back into Unity. Had I the ability to rewind time, I would do this game jam again :tup: .

Unity, Unity never changes.

Still as fun as I remember, Unity also crashes as much as I remember. I can let it sit and not touch it, and it’ll crash. Before I started to compulsively save, I started to wish I could rewind time and choose another engine.

-Dinosaurs let me know that running editor scripts may cause those crashes, which is immensely helpful. Had I the ability to rewind time, I would let myself know.

--Unity continues to crash, but much less frequently!

----After tearing out Unity Serializer, it stopped crashing in toto, minus a couple crashes that maaaay have been my fault entirely.


Speaking of rewinding time

http://www.gdcvault.com/play/1012210/The-Implementation-of-Rewind-in

Jonathan Blow covers a few implementation approaches for rewinding time, but the winning argument is to store a diff of properties between frames as binary, and (possibly) compress them. This is a key detail for implementing rewind time, but it comes with some possible problems: memory consumption, non-deterministic objects rule out optimizations, reference destruction, decisions about what is effected by time travel. One of his big optimization suggestions is to use close form solutions for things like particle effects that can have predictable algorithms written.

As to the application of diff between frames, Jonathan Blow suggests storing whole values, and not attempting to using delta encoding (reducing storage by only storing deltas). This is great news, as I could see this being a huge time sink in implementation time, and storing whole values makes the diffing algorithm requirements, frankly, a lot easier. I’m grateful that speed of implementation time was a goal for Jonathan Blow when he was working on Braid.

Jonathan Blow talks at some length about memory usage by the frame diff storage, and suggests using the concept of “base frames”, or to only store the diffs from every N frames.

-These are great ideas, and mostly accurately recounted! They just don't really make sense in Unity, since really, we can just store the whole dang object. It turns out that Moore's Law and a different target (PC vs consoles) makes this sort of a non-issue. Still, I should limit the time frame storage.

--Game design to the rescue! I ended up making the level break up into pieces so that you need to run along it or fall and die. I have also limited time frame storage, but now I really don't think it's going to be much of an issue.


Base Frames

This takes the form of ([ ] = frame, [R] = diff to next recorded frame):

[R] [ ] [ ] [R] [ ] [ ] [R] [ ] [ ] [R] [ ] [ ] [R] [ ] [ ] [R] [ ] [ ]

Where only the [R] frames are stored, and then, only the diff between that and the last frame. This lets us reduce the amount of frames we store, thus reducing memory requirements, however it comes with the caveat that it also increases the rewind granularity. I'll play with the values in this regard to see what's best.

-Yeah! Now we're getting somewhere, this idea has stuck, and I implemented a slightly odd variation of it. Currently it's just
--Yup, that's an unfinished sentence. I got distracted by an idea and went to "fix" the time frames. Long story short,

This makes the timing per base frame function:

int getFrameIndex(double rewindTimeDelta, Frames[] frames, double frameDT = 16, double baseFrameGranuality = 2) {
    int getFrameIndex(double rewindTimeDelta, Frames[] frames, double frameDT = 16, double baseFrameGranuality = 2) {
    var startTime = frames.Last().GameTime;
    var first = frames.First().GameTime;

    var rewindTo = startTime - rewindTimeDelta;
    var diffFromStart = rewindTo - first;

    diffFromStart = Math.max(0, diffFromStart);

    return (int)(diffFromStart / (frameDT * baseFrameGranuality)); //note integer truncations means we don't need a Math.floor()
}

var frames = CharacterFrame [
    { GameTime = 0, Data = CharacterParams { ... }},
    { GameTime = 16, Data = CharacterParams { ... }},
    { GameTime = 32, Data = CharacterParams { ... }},
    { GameTime = 48, Data = CharacterParams { ... }},
    { GameTime = 64, Data = CharacterParams { ... }},
    { GameTime = 80, Data = CharacterParams { ... }},
    { GameTime = 96, Data = CharacterParams { ... }},
    { GameTime = 112, Data = CharacterParams { ... }}
];

assert(getFrameIndex<CharacterFrame>(32, frames) == 5); //gametime 80
assert(getFrameIndex<CharacterFrame>(10, frames) == 5); //gametime 80

-This is a nice notion, but one that is flawed for this context, or at least for my implementation.


Frame Serialization

You might have noticed the generic frames object being used. This is a work around for the fact that we're working with Unity entities, rather than custom models we're implementing ourselves. The Frame<T> interface allows us to implement flexible storage, and gives us different Apply method implementations (definitely not YAGNI since state transitions can be sticky and pre-written components I'm using weren't written with rewinding time in mind).

-I dropped generic frame objects like they were hot! I don't even have a class called Frame now! It's been replace with a TimeFrame class, and it doesn't get serialized. Rather it represents a bucket of serialized TimeTravellers and provides

--This has changed as well! Had generic frame objects been hot, then serialization was scorching.

----No regrets about dropping serialization.

I'm using https://github.com/TheSniperFan/unityserializer-ng to do the serialization of GameObjects, which is a pretty huge boon. It also features compression
 

interface Frame {
double GameTime { get; set; }


void Persist(GameObject target);
...

-There was some code here that ultimately was broken, and kind of bad. You can see by the unfinished sentence, "It also features compression" that I stopped here. That's partially because I was writing untested statements (see: bullshit), and wanted to validate my assertions.

--Yeaaaah, I was right about the broken part. Taking out serialization has made my life easier and the game not as taxing to work on in some respects.


Music

Elektrikwizard has produced a number of tracks from his collection that might fit the game to help get a sense for what I'm thinking about. Among them were a few that had a pseudo retro feel, and one in particular that was reminiscent of Spyro the dragon. I'm excited. You should be excited. You should hear them. You can't hear them yet.

-"Games need good sound, otherwise, you wouldn't be able to hear them." -Erika N

-This all remains as I wrote. I've talked to Elektrikwizard, and he's advised me on some finer points of creating a rewind effect without making the listener's ear vomit (my words, not his). Elektrikwizard recommended fading the forward playing track fully to 0 then doing the opposite for the reverse track to make sure that it sounds decent. Maybe if we ask him nicely, he'll post some links to the sample tracks we have in mind.

--Implemented! I just adjust pitch back to -2f until I'm at the part in the track I want, then I adjust it back to 1. You end up going about a 3rd second before you were, but that's not very noticeable. It's been a bit squirrelly on the WebGL build output, but switching to FixedUpdate seemed to help some.

-As for sound effects. I'm going to be super negligent, and hope the Unity Asset Store will provide.

--This is still the case.

----This remains the case, as the music is currently the only sound Rewind emits.

//V1ish
using UnityEngine;
using System.Collections;
using System.Linq;
using System.Linq.Expressions;
using System.Runtime.Serialization;
...

-Then I had just copied a bunch of my code into this file because I hadn't been working with source control, in a single file. I ran a `git init`, and have been working still from a single file for the TimeTravellerBehavior, but now much more sanely.

-Ok, now we're up to the present.

--Ok, now we're actually up to the present.

---Ok, now we're also up to the present.

----Alright, it is currently the present, with significantly less typos.

--I've been working on trying to develop some mechanics that feel "funish" with the rewind. Standard platforming mechanics definitely lend themselves to the rewind mechanic, but I've actually been charmed by the jankiness of the AI component I wrote, and think I'm going to expand on that. Essentially the game I'm thinking this will turn into is something close to a time attack runner with enemies that are a huge nuisance and should precipitate the use of the rewind mechanic.

--Also, I wasn't feeling the game play a few days ago, and decided to explore options for a backstory.


--One thing led to another, and... well, Dunkin' Donuts fan fiction:

Quote

You're a little drunk coming home from a wicked pisser party at Harvard, the soft yellow-white glow of the street lights bathing your surroundings. Looking up at the street light above you, it begins to wobble back and forth -- wait, no, that's your vision, because you drank maybe a bit too many Sams. You know what will set you right: a Dunkin' Donuts coffee and beautiful, greasy, sticky, sweet donut. Yes! There's one across the street! I mean, you didn't notice it there before, and boy, but that moon is full, but ok, no one ever really listens to those stories about staying out of DD's on a full moon... what's the worst that could happen?

As you stagger towards the counter, you notice an odd sheen on the face of the lone employee. There's an emptiness about their eyes that doesn't leave you feeling totally clean, but maybe that's just working retail - you can't be sure. You ask for a regular coffee (regular in this case contains 4 heaping spoonfuls of sugar and a bunch of creamer), and two chocolate glazed donuts. Everything is fine, you tell yourself, nothing weird about how the cashier is lurching to get the donut, nothing weird about how their feet seem to stick to the floor, a part of the floor, and definitely nothing weird about them distending their mouth, farther and farther, and no, wait that is definitely wei-

You come to on the sidewalk, dressed in your Dunkin' Donut uniform. Something isn't right, and oh god, you realize, where did your yellow polo go, and why is it replaced with a yellow polo with "DD" emblazoned upon it. You realize you don't wear a Dunkin' Donuts uniform because you don't work for Dunkin' Donuts. With that realization and a sinking feeling, you look up at the moon and come to terms with what's happened to you: you've become what every Massachusetts citizen fears, but knows is their ultimate destiny; you've become a WereDunkin! You know things are bad, you know you're doomed to a life of seething hatred of Starbucks, and are not looking forward to having coffee udders, but you also know you need not resist your baser instincts now. You wipe the frosted sugar from your lips, and a look of resolve sets on your face. If this is your fate, you decide, you’re going to this freakin’ right. You MUST find your roost. You MUST find that spot that is equidistant from all other nearby Dunkin’ Donuts. You MUST fulfill your need to maximize profits, and minimize the distance between Dunkin' Donuts!


---A note about this story: Ultimately, it would fit a game like Galcon meets Rampart, in which there would be a phase for reorganizing planets in the blind. Think real time Risk, where you'd get to move a country or two between turns. Now, layer on top of that the preceding Dunkin' Donuts backstory.

---I've reworked the TimeTravellerBehavior almost entirely, and it functions much better now.

It's much simpler, and the model is as follows:

if (player is holding z) {
    rewindToLastTimeFrame();
} else {
    pushTimeFrame();
}

---There's a bit more to it, but that's the gist of it. I'm using a List<Frame> instead of a Stack<Frame> only because I trim the front off of it - were I smart, I'd use a ring buffer or some such.

----Speaking of gist, here's the code for the TimeTravellerBehavior: https://gist.github.com/john-holland/ec4df73d5215291d5e18

----Keep in mind that there's still a bit of cruft left in there (OnDestroy and related variables).

---I'd like to share a handy little tool for implementing comparison interfaces in cases where the type you need them for doesn't / shouldn't need them folded in, and you are too lazy, or in a context like Unity, don't want to muddy your namespaces with utility classes.

public class Lambda
{
    public static IComparer<T> comparer<T>(Func<T, T, int> compareTo)
    {
        return new Comparer<T>(compareTo);
    }


    public static Equatable<T> equals<T>(Predicate<T> equals)
    {
        return new Equatable<T>(equals);
    }


    public class Comparer<T> : IComparer<T>
    {
        private Func<T, T, int> compareTo;


        public Comparer(Func<T, T, int> compareTo)
        {
            this.compareTo = compareTo;
        }


        public int Compare(T x, T y)
        {
            return compareTo(x, y);
        }
    }


    public class Equatable<T> : IEquatable<T>
    {
        private Predicate<T> equals;


        public Equatable(Predicate<T> equals1)
        {
            this.equals = equals1;
        }


        public bool Equals(T other)
        {
            return equals(other);
        }
    }
}


//Usage is as follows:
IComparer<float> reverseComparer = Lambda.comparer(a, b => a > b ? -1 : a < b ? 1 : 0);

var descendingList = new SortedList<float, string>(reverseComparer);

---Whheellp, I just found http://ludiq.io/chronos/, which seems like it's the defacto standard library / plugin to use for time control in Unity and looks slick as hell. Were I to make this game again, or remake it in some form to sell to people, I'd probably just use Chronos. That being said, I've had fun learning how to implement rewind, and jumping back into Unity with a fun problem to solve. I guess what I'm trying to say is, if I could rewind time, I'd go back and use Chronos... Even for the price of $40, I spent nearly 8 days solidly on the rewinding mechanic and messing with serialization problems and time keeping models. $40 is worth having that time wasted removed and gaining extra cool functionality such as slow time, well implemented undo redo commands, etc.

----I still might buy Chronos to read through the code, as the docs are very interesting and the author seems to have developed quite a few useful patterns.

Edited by Panzorfork

Share this post


Link to post
Share on other sites

Just for kicks, I grabbed the top down survival shooter from the asset store and tried using my TimeTravellerBehavior with it, and it worked!

 

I had to edit a few components to remove their caching of gameobject references, removed references to classes that were specific to Rewind, edited the character / enemy prefabs to include the TimeTravellerBehavior, and made a small edit to the camera so that it looked for the player tag each time.

 

The performance turns a bit to poo after a while, and the characters jitter while rewinding, however I'm happy that the rewind mechanic works as a plug and play component.

 

https://dl.dropboxusercontent.com/u/1371497/JustForKicks/JustForKicks_PC.zip

https://dl.dropboxusercontent.com/u/1371497/JustForKicks/JustForKicks.app.zip

 

The code is a bit sloppy, and there are about 1000 optimizations to be made, but if you're interested, here's the "plug and play" version:

 

 

using UnityEngine;
using System.Collections;
using System.Linq;
using System.Linq.Expressions;
using System.Runtime.Serialization;
using System;
using System.Collections.Generic;
using UnityEngine.Assertions;


public class TimeTravellerBehaviour : MonoBehaviour
{
    const string PRIMARY = "primary";
    // Use this for initialization
    void Start()
    {
        RegisterTimeStream(PRIMARY);
    }


    // Update is called once per frame
    void Update()
    {
    }


    [serializeField]
    public string TimeStream = PRIMARY;
    
    class TimeTraveller
    {
        public GameObject Target { get; set; }
        public float GameTime { get; set; }
        public string Name { get; set; }
        public bool Active { get; set; }
        public string TimeStream { get; set; }


        public TimeTraveller()
        {
        }


        public TimeTraveller(float gameTime, GameObject target, string timeStream)
        {
            GameTime = gameTime;
            Target = target;
            this.TimeStream = timeStream;
            Name = target.name;


            //if they don't have a name, we'll make a unique one up
            if (string.IsNullOrEmpty(Name))
            {
                Name = "TimeTraveller" + GetAndIncrementNextFreeId();
                Target.name = Name;
            }
        }


        static long nextFreeId = 0;
        private long GetAndIncrementNextFreeId()
        {
            return nextFreeId++;
        }


        public void Persist()
        {
            Target = GameObject.Instantiate(Target);
            Target.name = null;
            Deactivate();
        }


        public void Create()
        {
            //give them back their name...
            Revive();
        }


        private void Reactivate()
        {
            Target.SetActive(Active);
        }


        private void Deactivate()
        {
            Active = Target.activeSelf;


            Target.SetActive(false);
        }


        public void Destroy()
        {
            if (Target != null && string.IsNullOrEmpty(Target.name))
            {
                Name = Target.name;
            }


            if (Target != null)
            {
                Target.name = null;
                GameObject.DestroyImmediate(Target);
                Target = null;
                Name = "Robert Paulson";
            }
        }


        public void Apply()
        {
            var existingObject = GameObject.Find(Name);


            if (existingObject != null)
            {
                existingObject.name = "";
                GameObject.Destroy(existingObject);
            }


            Revive();
        }


        protected GameObject Revive()
        {
            Target.name = Name;
            Reactivate();


            return Target;
        }
    }
   
    static HashSet<string> livingTravellers = new HashSet<string>();


    private void OnDestroy()
    {
        //if we're being destroyed, we should remove our name from the living travellers list.
        if (gameObject.name != null)
        {
            livingTravellers.Remove(name);
        }
    }


    static bool isRewinding = false;
    static float framesLeftToRewind = 0;
    static Dictionary<string, List<Frame>> timeStreams = new Dictionary<string, List<Frame>>();
    const int FRAME_CACHE_SIZE = 150;


    public void OnLevelWasLoaded()
    {
        Debug.Log("Level loaded!");
        isRewinding = false;
        framesLeftToRewind = 0;
        Dictionary<string, List<Frame>> timeStreams = new Dictionary<string, List<Frame>>();
    }


    public static void RegisterTimeStream(string timestream)
    {
        if (!timeStreams.ContainsKey(timestream))
        {
            timeStreams[timestream] = new List<Frame>();
        }
    }


    static float lastFixedTime = 0;


    public void FixedUpdate()
    {
        if (lastFixedTime != Time.fixedTime)
        {
            if (Input.GetKey(KeyCode.Z))
            {
                isRewinding = true;
            }
            else
            {
                isRewinding = false;
            }


            if (isRewinding)
            {
                rewind(TimeStream);
            }
            else
            {
                var currentTimeTravellers = FindObjectsOfType<TimeTravellerBehaviour>()
                    .Select(ttb => new TimeTraveller(Time.timeSinceLevelLoad, ttb.gameObject, ttb.TimeStream)).ToList();
                
                foreach (var timeTraveller in currentTimeTravellers)
                {
                    push(timeTraveller.TimeStream, lastFixedTime, timeTraveller);
                }
            }


            lastFixedTime = Time.fixedTime;
        }
    }


    class Frame
    {
        public float GameTime { get; set; }
        public Dictionary<string, TimeTraveller> TimeTravellers { get; set; }


        public Frame(float gameTime)
        {
            GameTime = gameTime;
            TimeTravellers = new Dictionary<string, TimeTraveller>();
        }


        public void Destroy()
        {
            foreach (var tt in TimeTravellers.Values)
            {
                tt.Destroy();
            }


            TimeTravellers.Clear();
        }
    }


    private static void push(string timeStream, float fixedTime, TimeTraveller timeTraveller)
    {
        List<Frame> timeTravellers = GetTimeStream(timeStream);
        Frame frame = null;
        if (!timeTravellers.Any())
        {
            frame = new Frame(fixedTime);
            timeTravellers.Add(frame);
        }


        frame = timeTravellers.Last();


        if (frame.GameTime != fixedTime)
        {
            frame = new Frame(fixedTime);
            timeTravellers.Add(frame);
        }


        if (frame.TimeTravellers.ContainsKey(timeTraveller.Name))
        {
            Debug.Log("Warning duplicate name! " + timeTraveller.Name);
        }
        else
        {
            timeTraveller.Persist();
            frame.TimeTravellers.Add(timeTraveller.Name, timeTraveller);
        }


        if (timeTravellers.Count > FRAME_CACHE_SIZE && timeTravellers.Count > 1)
        {
            var tt = timeTravellers.First();


            tt.Destroy();


            timeTravellers.RemoveAt(0);
        }
    }


    private static void rewind(string timeStream)
    {
        List<Frame> timeTravellers = GetTimeStream(timeStream);
        if (timeTravellers.Count < 2)
        {
            return;
        }


        var audio = (Behaviour)GameObject.FindObjectOfType(Type.GetType("AudioSourceRewinder"));


        if (audio != null)
        {
            audio.SendMessage("Rewind", 1f);
        }


        //think about who we were
        var mostRecentFrame = timeTravellers.Last();
        timeTravellers.RemoveAt(timeTravellers.Count - 1);


        var currentTimeTravellers = FindObjectsOfType<TimeTravellerBehaviour>()
            .Where(ttb => ttb.gameObject.GetComponent<TimeTravellerBehaviour>().TimeStream == timeStream)
            .Select(ttb => new TimeTraveller(Time.timeSinceLevelLoad, ttb.gameObject, timeStream)).ToList();


        var currentTimeTravellerNames = currentTimeTravellers.Select(tt => tt.Name);
        var previousTimeTravellerNames = mostRecentFrame.TimeTravellers.Keys.ToList();


        var survivors = currentTimeTravellerNames.Intersect(previousTimeTravellerNames);
        var timeTravellersToDestroy = currentTimeTravellerNames.Except(survivors);
        var timeTravellersToCreate = previousTimeTravellerNames.Except(currentTimeTravellerNames);


        foreach (string timeTraveller in timeTravellersToDestroy)
        {
            var toDestroy = currentTimeTravellers.Find(tt => tt.Name == timeTraveller);


            if (toDestroy != null)
            {
                toDestroy.Destroy();
            }
        }


        foreach (string timeTraveller in timeTravellersToCreate)
        {
            var toCreate = mostRecentFrame.TimeTravellers[timeTraveller];


            if (toCreate != null)
            {
                toCreate.Create();
            }
        }


        foreach (string timeTraveller in survivors)
        {
            var toApply = mostRecentFrame.TimeTravellers[timeTraveller];


            if (toApply != null)
            {
                toApply.Apply();
            }
        }
    }


    private static List<Frame> GetTimeStream(string timeStream)
    {
        List<Frame> timeTravellers = null;
        if (!timeStreams.ContainsKey(timeStream))
        {
            Debug.LogWarning("Unknown timestream: " + timeStream);
            RegisterTimeStream(timeStream);
        }


        timeTravellers = timeStreams[timeStream];
        return timeTravellers;
    }
} 

 

 

 

 

Here is the AudioSourceRewinder if anyone is interested (one pretty big caveat is that setting the pitch below zero requires an uncompressed audio source):

 

 

 

using UnityEngine;
using System.Collections;


public class AudioSourceRewinder : MonoBehaviour {


    private float desiredSoundTime = 0;
    private float desiredPitch = 0;
    private bool playing = true;
    private bool rewinding = false;
    private bool resuming = false;
    private new AudioSource audio;


    //If the clip has some empty space at the end, that won't sound wonderful when rewound, so instead of wrapping around to the end, we can wrap to a bit short of it.
    public float EndPadding = 0;


    void Awake()
    {
        audio = this.gameObject.GetComponent<AudioSource>();
    }


    void OnLevelWasLoaded()
    {
        if (audio == null)
        {
            audio = this.gameObject.GetComponent<AudioSource>();
        }


        audio.pitch = 1;
        audio.time = 0;
    }


    void FixedUpdate()
    {
        Debug.Log(GetType().FullName);
        if (!playing)
        {
            if (rewinding)
            {
                audio.pitch = Mathf.Max(-2f, audio.pitch - Time.fixedDeltaTime);


                if (audio.time < desiredSoundTime)
                {
                    rewinding = false;
                    resuming = true;
                }
            }
            if (resuming)
            {
                audio.pitch = Mathf.Min(1f, audio.pitch + Time.fixedDeltaTime);


                if (audio.pitch >= 1f)
                {
                    resuming = false;
                    playing = true;
                }
            }
        }
        else
        {
            audio.pitch = 1;


            if (audio.time > audio.clip.length - EndPadding)
            {
                audio.time = 0;
            }
        }
    }


    public void Rewind(float rewindAmount)
    {
        desiredSoundTime = audio.time - rewindAmount;
        if (desiredSoundTime < 0)
        {
            desiredSoundTime = audio.clip.length + desiredSoundTime;


            audio.time = audio.clip.length - EndPadding - 0.5f;
        }


        desiredPitch = -2f;
        playing = false;
        rewinding = true;
        resuming = false;
    }
} 

 

Edited by Panzorfork

Share this post


Link to post
Share on other sites

For note about using the TimeTravellerBehavior, edit FRAME_CACHE_SIZE to control the amount of time you're able to rewind. 150 is about 5 seconds with FixedUpdate running 30 frames a second. It might be wise to OnLevelWasLoaded adjust FRAME_CACHE_SIZE to be Math.min(SECONDS_YOU_WANT_TO_REWIND / Time.fixedTimeDelta, SOME_REASONABLE_NUMBER_OF_FRAMES_LIKE_300).

 

Also, as I mentioned before there's massive room for improvement (serialization / compression, pooling, frame buffer instead of list manipulation, etc).

Share this post


Link to post
Share on other sites

A Post-Post Mortem

 

Hey everyone,

 

Listening to Idle Weekend and hearing Danielle and Rob talk about their take on the increasing importance given to spoilers in our society made me reconsider the section of my post mortem that talked (or didn’t) about my game.

 

That being said, they also acknowledged people’s preferences for such things, so:

SPOILER WARNING!?

http://panzorfork.itch.io/rewind

 

The basic structure of the game has you play a half transparent robot, running away from disappearing platforms while being bombarded with meteors and chased (?) by 3 smaller AI robots. You can hold ‘Z’ to rewind time, and undo any mistakes you make. Once you reach the end, you encounter a steep wall which you are unable to scale.

 

If you’ve made it to the end in a reasonable amount of time, you’ll see the final, larger, meteor hit, and the words, “Did you ever wish you could rewind time?” carried with it, which then stay on the screen. At this point, you might be slightly annoyed with the game, and may rewind out of spite.

 

As for what the game means… I’m not going to claim I had any particular direction or overriding theme in mind when I started, other than the quote, “Did you ever wish you could rewind time?” The theme that I think the game is about, and what I had in mind when I put that unscalable wall at the end, is primarily living for the present, and not spending too much time in the past. There are some situations which no amount of hindsight will resolve. In the end, we all die (sans Ray Kurzweil’s brain), so live each chapter like it may be the last, etc etc.

 

In my dev log, I talk about the meteors being a “known shippable”. They didn’t work because I had them nested inside a GameObject with an XYZ of not Vector3.zero, and the clones the TimeTravellingBehavior makes get placed into the root level. The initial placement relying on an offset which is no longer present  Long story short, now that I know that, I can easily work around it. I issued a final update to the game sprucing up the instructions, introducing a credits sequence and fixed the rewind mechanic for the meteors.

 

My only real regret is being bullish about some design choices when the rewrite didn't actually take that long and was highly rewarding. Overall I had a lot of fun, and appreciated the opportunity to make something weird.

 

Here's the final version of the TimeTravellerBehavior:

 

using UnityEngine;
using System.Collections;
using System.Linq;
using System.Linq.Expressions;
using System.Runtime.Serialization;
using System;
using System.Collections.Generic;
using UnityEngine.Assertions;

public class TimeTravellerBehaviour : MonoBehaviour
{
    public const string PRIMARY = "primary";
    // Use this for initialization
    void Start()
    {
        RegisterTimeStream(PRIMARY);
    }

    // Update is called once per frame
    void Update()
    {
    }

    [serializeField]
    public string TimeStream = PRIMARY;

    //We are but homes to organic processes, and will be remade. In the remaking we lose our soul, the connection of things.
    //In this way, time travel is dangerous. Be warned.
    class TimeTraveller
    {
        public GameObject Target { get; set; }
        public float GameTime { get; set; }
        public string Name { get; set; }
        public bool Active { get; set; }
        public string TimeStream { get; set; }

        public TimeTraveller()
        {
        }

        public TimeTraveller(float gameTime, GameObject target, string timeStream)
        {
            GameTime = gameTime;
            Target = target;
            this.TimeStream = timeStream;
            Name = target.name;

            //if they don't have a name, we'll make a unique one up
            if (string.IsNullOrEmpty(Name))
            {
                Name = "TimeTraveller" + GetAndIncrementNextFreeId();
                Target.name = Name;
            }
        }

        static long nextFreeId = 0;
        private long GetAndIncrementNextFreeId()
        {
            return nextFreeId++;
        }

        public void Persist()
        {
            Target = GameObject.Instantiate(Target);
            Target.name = null;
            Deactivate();
        }

        public void Create()
        {
            //give them back their name...
            Revive();
        }

        private void Reactivate()
        {
            Target.SetActive(Active);
            Target.SendMessage("TravellerReactivated", SendMessageOptions.DontRequireReceiver);
        }

        private void Deactivate()
        {
            Active = Target.activeSelf;
            Target.SendMessage("TravellerDeactivated", SendMessageOptions.DontRequireReceiver);
            Target.SetActive(false);
        }

        public void Destroy()
        {
            if (Target != null && string.IsNullOrEmpty(Target.name))
            {
                Name = Target.name;
            }

            if (Target != null)
            {
                Target.name = null;
                GameObject.DestroyImmediate(Target);
                Target = null;
                Name = "Robert Paulson";
            }
        }

        public void Apply()
        {
            var existingObject = GameObject.Find(Name);

            if (existingObject != null)
            {
                existingObject.name = "";
                GameObject.Destroy(existingObject);
            }

            Revive();
        }

        protected GameObject Revive()
        {
            Target.name = Name;
            Reactivate();

            return Target;
        }
    }
   
    static HashSet<string> livingTravellers = new HashSet<string>();

    private void OnDestroy()
    {
        //if we're being destroyed, we should remove our name from the living travellers list.
        if (gameObject.name != null)
        {
            livingTravellers.Remove(name);
        }
    }

    static bool isRewinding = false;
    static float framesLeftToRewind = 0;
    static Dictionary<string, List<Frame>> timeStreams = new Dictionary<string, List<Frame>>();
    static Dictionary<string, int> timeStreamFrameCacheOverride = new Dictionary<string, int>();
    const int FRAME_CACHE_SIZE = 300;

    public void OnLevelWasLoaded()
    {
        Debug.Log("Level loaded!");
        isRewinding = false;
        framesLeftToRewind = 0;
        timeStreams = new Dictionary<string, List<Frame>>();
    }

    public static void RegisterTimeStream(string timestream)
    {
        if (!timeStreams.ContainsKey(timestream))
        {
            timeStreams[timestream] = new List<Frame>();
        }
    }

    static float lastFixedTime = 0;
    static bool firstFrame = true;
    public void FixedUpdate()
    {
        if (lastFixedTime != Time.fixedTime)
        {
            if (firstFrame)
            {
                //just skip the first frame... initialization ends up being non-deterministic in the first frame of the game because we can't make sure we are the last entity to update.
                //this would likely be more sainly implemented with a manager.
                lastFixedTime = Time.fixedTime;
                firstFrame = false;
                return;
            }

            if (Input.GetKey(KeyCode.Z))
            {
                isRewinding = true;
            }
            else
            {
                isRewinding = false;
            }

            if (isRewinding)
            {
                rewind(TimeStream);
                Debug.Log("rewinding" + lastFixedTime);

                var c = GameObject.Find("GameManager");

                if (c != null)
                {
                    var cc = c.GetComponent<RewindGameManager>();
                    if (cc != null)
                    {
                        cc.SimulateUpdate();
                    }
                }
            }
            else
            {
                var currentTimeTravellers = FindObjectsOfType<TimeTravellerBehaviour>()
                    .Select(ttb => new TimeTraveller(Time.timeSinceLevelLoad, ttb.gameObject, ttb.TimeStream)).ToList();
                
                foreach (var timeTraveller in currentTimeTravellers)
                {
                    push(timeTraveller.TimeStream, lastFixedTime, timeTraveller);
                }
            }

            lastFixedTime = Time.fixedTime;
        }
    }

    class Frame
    {
        public float GameTime { get; set; }
        public Dictionary<string, TimeTraveller> TimeTravellers { get; set; }

        public Frame(float gameTime)
        {
            GameTime = gameTime;
            TimeTravellers = new Dictionary<string, TimeTraveller>();
        }

        public void Destroy()
        {
            foreach (var tt in TimeTravellers.Values)
            {
                tt.Destroy();
            }

            TimeTravellers.Clear();
        }
    }

    public static void setTimeStreamFrameOverride(string timestream, int frameOverride)
    {
        if (timeStreams.ContainsKey(timestream))
        {
            timeStreamFrameCacheOverride[timestream] = frameOverride;
        }
        else
        {
            Debug.Log("unknown timestream: " + timestream);
        }
    }

    public static void removeTimeStreamFrameOverride(string timestream)
    {
        timeStreamFrameCacheOverride.Remove(timestream);
    }

    private static void push(string timeStream, float fixedTime, TimeTraveller timeTraveller)
    {
        List<Frame> timeTravellers = GetTimeStream(timeStream);
        Frame frame = null;
        if (!timeTravellers.Any())
        {
            frame = new Frame(fixedTime);
            timeTravellers.Add(frame);
        }

        frame = timeTravellers.Last();

        if (frame.GameTime != fixedTime)
        {
            frame = new Frame(fixedTime);
            timeTravellers.Add(frame);
        }

        if (frame.TimeTravellers.ContainsKey(timeTraveller.Name))
        {
            Debug.Log("Warning duplicate name! " + timeTraveller.Name);
        }
        else
        {
            timeTraveller.Persist();
            frame.TimeTravellers.Add(timeTraveller.Name, timeTraveller);
        }

        int cacheSize;

        if (!timeStreamFrameCacheOverride.TryGetValue(timeStream, out cacheSize))
        {
            cacheSize = FRAME_CACHE_SIZE;
        }

        if (timeTravellers.Count > FRAME_CACHE_SIZE && timeTravellers.Count > 1)
        {
            var tt = timeTravellers.First();

            tt.Destroy();

            timeTravellers.RemoveAt(0);
        }
    }

    private static void rewind(string timeStream)
    {
        List<Frame> timeTravellers = GetTimeStream(timeStream);
        if (timeTravellers.Count < 3)
        {
            return;
        }

        var music = GameObject.Find("music");
        if (music != null)
        {
            music.GetComponent<AudioSourceFader>().Rewind(1f);
        }

        //think about who we were
        var mostRecentFrame = timeTravellers.Last();
        timeTravellers.RemoveAt(timeTravellers.Count - 1);

        var currentTimeTravellers = FindObjectsOfType<TimeTravellerBehaviour>()
            .Where(ttb => ttb.gameObject.GetComponent<TimeTravellerBehaviour>().TimeStream == timeStream)
            .Select(ttb => new TimeTraveller(Time.timeSinceLevelLoad, ttb.gameObject, timeStream)).ToList();

        var currentTimeTravellerNames = currentTimeTravellers.Select(tt => tt.Name);
        var previousTimeTravellerNames = mostRecentFrame.TimeTravellers.Keys.ToList();

        var survivors = currentTimeTravellerNames.Intersect(previousTimeTravellerNames);
        var timeTravellersToDestroy = currentTimeTravellerNames.Except(survivors);
        var timeTravellersToCreate = previousTimeTravellerNames.Except(currentTimeTravellerNames);

        foreach (string timeTraveller in timeTravellersToDestroy)
        {
            var toDestroy = currentTimeTravellers.Find(tt => tt.Name == timeTraveller);

            if (toDestroy != null)
            {
                toDestroy.Destroy();
            }
        }

        foreach (string timeTraveller in timeTravellersToCreate)
        {
            var toCreate = mostRecentFrame.TimeTravellers[timeTraveller];

            if (toCreate != null)
            {
                toCreate.Create();
            }
        }

        foreach (string timeTraveller in survivors)
        {
            var toApply = mostRecentFrame.TimeTravellers[timeTraveller];

            if (toApply != null)
            {
                toApply.Apply();
            }
        }
    }

    private static List<Frame> GetTimeStream(string timeStream)
    {
        List<Frame> timeTravellers = null;
        if (!timeStreams.ContainsKey(timeStream))
        {
            Debug.LogWarning("Unknown timestream: " + timeStream);
            RegisterTimeStream(timeStream);
        }

        timeTravellers = timeStreams[timeStream];
        return timeTravellers;
    }
}

Share this post


Link to post
Share on other sites

I screwed up the Mac distro by naming the zip archive "rewind.app.zip", so that it unpacked as an app instead of a folder.

Fixed now!

Share this post


Link to post
Share on other sites

I adore what you've done with the devlog, and I think it's pretty damn cool to make such a general concept as rewindable behaviour in a time-limited jam window.

Share this post


Link to post
Share on other sites

Thanks! I appreciate the kind words :)

The dev log was hella fun to write (after I made peace with not posting it until the end), I'm glad you liked it!

Share this post


Link to post
Share on other sites

Bold moves made with trying to implement the kind of mechanic you did, so congratulations on that part!

Share this post


Link to post
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
Sign in to follow this