~/home of geeks

Evolution einer Text-Animation

· 1631 Wörter · 8 Minute(n) Lesedauer

Vor einiger Zeit hatte ich die Idee, auf einigen meiner Domains, die parken und nur eine leere Seite anzeigen, diese Seite mit einer kleinen versteckten Spielerei zu verzieren. Dabei sollte der Text im Title der Seite animiert werden.

Dabei sollte ein Text, als würde ihn jemand eintippen, Zeichenweise aufgebaut werden.

In der ersten Version wollte ich ein Proof of Concept erstellen und löste den Großteil der Logik in einer if-Bedingung.

Ich ergänzte den auszugebenden Text mit Steuerbefehlen, mit denen ich z. B. Pausen beim Tippen einlegen oder die Ausgabe leeren konnte.

Parallel animierte ich einen Cursor, der bei Ausgaben ein dunkler Block und beim Warten zwischen einem Block und einem Underline wechselte (blinken).

// Einleitendes Zeichen für Steuerbefehle
var cmdChar = ';';
var cursor = '\\u2588';
var cursorBlinkTimeout = 500;
var intChars = "0123456789"
// Anzuzeigender Text mit Steuerbefehlen angereichert
var fullText = ";;p3000;;c;;p500nice to meet you!;;p3000;;c;;p500how are you?;;p3000;;c;;l";
var pos = 0;
var timeout = 300;
// Ob letztes ausgegebenes Textzeichen ein Leerzeichen war
var lastSpace = false;
var onPause = false;
function animate() {
    if (pos < fullText.length) {
        var hasOutput = true;
        var nextTimeout = timeout;
        var c = fullText.charAt(pos);
        // console.log("pos=" + pos + ", c='" + c + "'");
        // Steuerzeichen, also ";;"
        if (pos + 2 < fullText.length) {
            if (c == cmdChar && fullText.charAt(pos + 1) == cmdChar) {
                var cmd = fullText.charAt(pos + 2);
                // Steuerbefehl, z. B: "p" für Pause
                if (cmd == 'c' || cmd == 'l') {
                    // CLS
                    document.title = cursor;
                    pos += 2;
                    hasOutput = false;
                    nextTimeout = 0;
                } else if (cmd == 'p') {
                    // PAUSE
                    var time = nextTimeout;
                    if (pos + 3 < fullText.length) {
                        // Parameter (Zeit) auswerten
                        var tmpTime = parseInt(fullText.substring(pos + 3,
                                fullText.length));
                        if (!isNaN(tmpTime)) {
                            time = tmpTime;
                            pos += tmpTime.toString().length;
                        }
                    }
                    pos += 2;
                    hasOutput = false;
                    nextTimeout = time;
                }
                if (cmd == 'l') {
                    // LOOP
                    pos = -1;
                    hasOutput = false;
                    nextTimeout = 0;
                }
            }
        }

        if (hasOutput) {
            document.title = document.title.substring(0,
                    document.title.length - 1)
                    + c + cursor;
            onPause = false;
            // console.log("document.title='" + document.title +"'");
        } else {
            onPause = true;
        }
        pos++;
        checkBlink();
        setTimeout(function() {
            creep();
        }, nextTimeout);
    }
    // FIN
}

// Prüft den Cursor-Wechsel zwischen Underline und Block
function checkBlink() {
    if (onPause) {
        if (cursor == '\\u2588') {
            cursor = '_';
        } else {
            cursor = '\\u2588';
        }
        document.title = document.title.substring(0,
                document.title.length - 1)
                + cursor;
        setTimeout(function() {
            checkBlink();
        }, cursorBlinkTimeout);
    } else {
        cursor = '\\u2588';
    }
}

Nach dem mein Proof of Concept so gut funktionierte, führte ich ein Refactoring durch und baute das Ganze modularer auf, für die Version 2. Dabei wurden die Steuerbefehle mithilfe des Command-Musters modularisiert, wodurch ich recht einfach neue Befehle ergänzen konnte. Das Konzept, den Ausgabetext mit Befehlen anzureichern, behielt ich bei und nannte das Ganze ein “Programm”, da es im Prinzip ja eine Kette von Anweisungen enthielt, die interpretiert wurden. Auch passte ich die Syntax der speziellen Befehle an und definierte diese als Blöcke mit geschweiften Klammern, bei denen das erste Zeichen den Befehl und die restlichen Zeichen als Parameter interpretiert wurden, wodurch die Pause zu einem {p500} wurde.

Die Cursor-Animation habe ich in die neue Version nicht mit aufgenommen, da mir hierzu noch etwas Zeit für die Umsetzung einer richtigen Timing-Behandlung für die Hauptschleife fehlte.

/*
 * SciCr Engine
 * 
 * A text animation engine for the browser.
 * Usage:
 * Create a function to configure and fire the engine.
 * E.g.:
 * 
 * function SciCrAction() {
 *   var eng = new SciCrEngine();
 *   eng.program = '{p3000}{c}{p500}\\\\{\\\\}escape{p500}{c}{p500}nice to meet you!{#comment, aka noop}{<}{<}{<}{<}me!{p3000}{o And now?}{p500}{c}{p500}how are you?{p3000}{c}{l}';
 *   eng.start();
 * }
 * 
 * You can overwrite the methods setTargetText and getTargetText to change the target of the animation.
 * Default is document.title.
 * 
 * Finally add that method to any trigger:
 * 
 * <!DOCTYPE html>
 * <html>
 *   <head>
 *     <title>hi there!</title>
 *     <script src="scicr-core.js"></script>
 *   </head>
 *   <body onLoad="SciCrAction();">&nbsp;</body>
 * </html>
 * 
 * And do also more complex things:
 * 
 * 
 * <script src="scicr-core.js"></script>
 * <script>
 *   window.onload = function () {
 *     var target = document.evaluate("//*[@id='mainsearchform']//input[@name='s']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
 *     EMPTY_TEXT_REPLACEMENT = '';
 *     var eng = new SciCrEngine();
 *     eng.setTargetText = function(text) {
 *       target.placeholder = text;
 *     };
 *     eng.getTargetText = function() {
 *       return target.placeholder;
 *     };
 *     eng.program = '{c}{oSuche ...}{p500}{<}{<}{<}nach dem Sinn des Lebens?{p1000}{c}{l}';
 *     eng.start();
 *     // Deactivate / activate engine on typing 
 *     target.onkeyup = function() {
 *       if (target.value.length > 0){
 *         eng.stop();
 *       }
 *       else {
 *         eng.start();
 *       }
 *     }
 *   }
 * </script>
 * <input type="search" id="myinput" placeholder=""/>
 * 
 * @author Serhat Cinar
 * 
 */
var CMD_START_CHAR = '{';
var CMD_END_CHAR = '}';
var CMD_ESCAPE_CHAR = '\\\\';
// Non-breaking space as normal space is removed if it is a trailing one.
var VISIBLE_SPACE = '\\u00a0';
// This one is used as a replacement for the empty text, as the empty text will be replaced by a default one through the browser. 
var EMPTY_TEXT_REPLACEMENT = '.';

function SciCrEngine() {
    this.programCounter = 0;
    this.program = '';
    // Default speed while typing
    this.typeTimeout = 300;
    this.nextTimeout = 0;
    this.text = '';
    this.running = false;
    this.initialised = false;
    this.commands = {
        'c' : new SciCrCmdCls(),
        'p' : new SciCrCmdPause(),
        'l' : new SciCrCmdLoop(),
        '#' : new SciCrCmdNoop(),
        '<' : new SciCrCmdBackspace(),
        't' : new SciCrCmdTimeout(),
        'o' : new SciCrCmdOutput()
    };
}
// Changes the text. Manipulates it to fit requirements.
SciCrEngine.prototype.setText = function(text) {
    // Actual, unmodified text
    this.text = text;
    
    // Modifications to make text readable
    if (text.length <= 0){
        // Empty text will lead to some default value. So use the nonbreaking space
        text = EMPTY_TEXT_REPLACEMENT;
    }
    else{
        var posLastChar = text.length - 1;
        // Replace trailing spaces with nonbreaking space
        // Normal space would be removed as trailing space
        while (text.charAt(posLastChar) == ' '){
            text = text.substring(0, posLastChar) + VISIBLE_SPACE;
        }
    }
    this.setTargetText(text);
};
SciCrEngine.prototype.getText = function() {
    return this.text;
};
// Changes the text. This method is just for encapsulating where the text resides.
SciCrEngine.prototype.setTargetText = function(text) {
    document.title = text;
};
SciCrEngine.prototype.getTargetText = function() {
    return document.title;
};
// Initializes engine
SciCrEngine.prototype.init = function() {
    if (!this.initialised) {
        this.text = this.getTargetText();
        this.initialised = true;
    }
};
// Starts the engine.
SciCrEngine.prototype.start = function() {
    if (!this.running) {
        // Enshure initialised state
        this.init();
        this.running = true;
        this.progress();
    }
};
// Stops the engine.
SciCrEngine.prototype.stop = function() {
    this.running = false;
};
// Evaluates next step
SciCrEngine.prototype.progress = function() {
    // Enshure initialised state
    this.init();
    
    // Crazy JS stuff to call this function
    var that = this;
    var callProgress = function () {
        that.progress();
    }
    
    // Avoids IndexOutOfBounds. Work only as long as there is work. 
    if (this.running && this.programCounter < this.program.length) {
        // increment position in program?
        var movePC = true;
        
        // Set default timeout
        this.nextTimeout = this.typeTimeout;
        
        // Get actual work-char
        var currentChar = this.program.charAt(this.programCounter);
        
        if (currentChar == CMD_START_CHAR){
            // Start of a command.
            
            // There has to be at least 2 more characters for a command: {x}
            if (this.programCounter + 2 < this.program.length) {
                // Find end of command
                var cmdStartPos = this.programCounter;
                var cmdEndPos = this.program.indexOf(CMD_END_CHAR, cmdStartPos + 1);
                var cmdName = this.program.charAt(cmdStartPos + 1);
                var cmd = this.commands[cmdName];
                if (cmd) {
                    movePC = !cmd.modifiesPC;
                    this.programCounter = cmdEndPos;
                    // There was a command, no current char
                    currentChar = '';
                    // Check if this command doesn't need a type-timeout
                    if (!cmd.consumesTime) {
                        this.nextTimeout = 0;
                    }
                    
                    // Check if there was a parameter
                    if ( (cmdEndPos - 1) - (cmdStartPos + 2) > 0 && cmd.hasParameter) {
                        var parameter = this.program.substring(cmdStartPos + 2, cmdEndPos);
                        // Command execution with parameters
                        cmd.execute(that, parameter);
                    }
                    else {
                        // Command execution without parameters
                        cmd.execute(that);
                    }
                }
                else{
                    // No command found with required name, so treat as text
                }
            }
            else{
                // Not a command, because length of program is too short to contain a command end character
                // So treat as text
            }
        }
        else{
            if (currentChar == CMD_ESCAPE_CHAR){
                // Escape eines Zeichens
                if (this.programCounter + 1 < this.program.length) {
                    this.programCounter++;
                    currentChar = this.program.charAt(this.programCounter);
                }
                // else: nächstes char gibt es nicht, also das Escape-Char ausspielen
            }
        }
        if (movePC) {
            this.programCounter++;
        }
        
        if (currentChar.length > 0) {
            this.setText(this.getText() + currentChar);
        }
        
        // Get some sleep and then continue
        window.setTimeout(callProgress, this.nextTimeout);
    }
};

/*
 * Clear screen command. Sets the text to empty.
 */
function SciCrCmdCls() {
    this.hasParameter = false;
    this.consumesTime = true;
    this.modifiesPC = false;
}
SciCrCmdCls.prototype.execute = function(engine) {
    engine.setText('');
};

/*
 * Applies a pause. Parameter may be an int for millisecs to sleep.
 */
function SciCrCmdPause() {
    this.hasParameter = true;
    this.consumesTime = true;
    this.modifiesPC = false;
}
SciCrCmdPause.prototype.execute = function(engine, parameter) {
    var tmpTimeout = parseInt(parameter);
    if (!isNaN(tmpTimeout)){
        engine.nextTimeout = tmpTimeout;
    }
};

/*
 * Loops to the beginning of the program.
 */
function SciCrCmdLoop() {
    this.hasParameter = false;
    this.consumesTime = false;
    this.modifiesPC = true;
}
SciCrCmdLoop.prototype.execute = function(engine) {
    engine.programCounter = 0;
};

/*
 * Noop. Just continues. Usefull for comments
 */
function SciCrCmdNoop() {
    this.hasParameter = false;
    this.consumesTime = false;
    this.modifiesPC = false;
}
SciCrCmdNoop.prototype.execute = function(engine) {
    // Nothing to do here
};

/*
 * Applies a backspace.
 */
function SciCrCmdBackspace() {
    this.hasParameter = false;
    this.consumesTime = true;
    this.modifiesPC = false;
}
SciCrCmdBackspace.prototype.execute = function(engine) {
    if (engine.getText().length > 0){
        engine.setText(engine.getText().substring(0, engine.getText().length-1));
    }
};

/*
 * Changes type-speed
 */
function SciCrCmdTimeout() {
    this.hasParameter = true;
    this.consumesTime = false;
    this.modifiesPC = false;
}
SciCrCmdTimeout.prototype.execute = function(engine) {
    var tmpTimeout = parseInt(parameter);
    if (!isNaN(tmpTimeout)){
        engine.typeTimeout = tmpTimeout;
    }
};

/*
 * Pushes complete contents to output
 */
function SciCrCmdOutput() {
    this.hasParameter = true;
    this.consumesTime = true;
    this.modifiesPC = false;
}
SciCrCmdOutput.prototype.execute = function(engine, parameter) {
    var output = parameter;
    if (engine.getText()) {
        output = engine.getText() + output;
    }
    engine.setText(output);
};