Malleus' Javascript Tutorials:
Spieleprogrammierung in Javascript

Version: 0.72 (29.05.2006)
© Frank Hammerschmidt

Javascript Spiele - Forum  - Download  - Impressum

Wenn Du ein Javascript Tutorial suchst, das Dich an der Entstehung eines komplexen Javascript-Spiels teilhaben läßt, dann bist Du hier an der richtigen Stelle.

Einige Leser argumentieren zwar, daß es sich um gar kein richtiges Tutorial sondern vielmehr um eine Programm-Dokumention handelt. Okay, vielleicht ist es eine, aber ich denke mal, das jeder, der bis zum Ende "durchhält", einige Aspekte der Javascript-Programmierung "mit nach Hause nimmt" und diese dann in eigene Projekte integrieren wird. Somit hat ihm das "Tutorial" etwas Neues gebracht und es wurde wenigstens ansatzweise seines Namens gerecht.

Wie Du Dir bestimmt schon denken kannst, wende ich mich primär an "fortgeschrittene" Javascript-Entwickler. Ich werde Themen wie z.B.

  • Dynamischer Aufbau einer HTML-Table,
  • Browserweiche,
  • Rekursive Funktionen,
  • Prototyping und
  • Komprimierung

behandeln. Dies werde ich aber nicht mit trockenen Grundlagenartikel tun, sondern vielmehr aufzeigen, wie man sie im "richtigen" Leben einsetzt. Mit dieser Vorgehensweise möchte ich mich bewußt von den typischen "Anfänger"-Tutorials abgrenzen, die alle nur ein bestimmtes Thema behandeln.

Interesse? Dann bleibt doch einfach hier und schau Dir das ganze einfach 'mal "unverbindlich" an.

Viel Spaß mit meinem ersten Tutorial bzw. meiner ersten Programm-Dokumentation!



Du kannst von einer beliebigen Stelle des Tutorials zur Inhaltsangabe springen, indem Du einfach in die Seite klickst.

Spung zur Inhaltsangabe: 

Hintergrundfarbe:

Schriftgröße:



Inhaltsangabe


1. Konventionen

Im Laufe des Tutorials wirst Du verschiedene Arten "Scripting-Code" sehen. Diese werden der Übersicht halber verschiedenfarbig angezeigt:

  • HTML-Quelltext des Wörterlabyrinthes ( "words.html" )
  • Javascript-Code, der nur zur Implementierung des Wörterlabyrinthes dient ( "word.js" )
  • Allgemeine verwendbare Javascript-Code aus meiner Javascript-Bibliotek ( "standard.js" )
  • Sonstiger Beispiel- bzw. Erklärungscode, der eigentlich nichts mit dem Spiel zu tun hat.

2. Spielidee:

Das Ziel eines Wörterlabyrinthes ist es, ein Lösungswort innerhalb eines Buchstabengitters zu finden. Zu beachten ist hierbei, daß die Buchstaben des Wortes horizontal bzw. vertikal mit den vorherigen verbunden sind.
w01 (7K)

Links siehst Du zum Beispiel die Auflösung eines Rätsels, bei dem das Wort "Eisenbahn" gesucht wird:

  • Vom Startpunkt "E(1)" gehst Du zuerst einen Schritt nach rechts zu "I(2)",
  • dann einen Schritt runter zu "S(3)",
  • nach rechts zu "E(4)",
  • runter zu "N(5)",
  • nach rechts zu "B(6)" und dann
  • solange hoch bis Du "N(9)" erreichst.
Lust bekommen? Dann versuch's doch einfach 'mal selbst: Das Wörterlabyrinth


3. Komprimierung:

Die Javascript Implementierung eines Spieles ist meistens sehr komplex. Dies bedeutet aber, daß das dazugehörige Script sehr umfangreich werden kann, was wiederum gleichbedeutend mit langer Ladezeit und großem Traffic ist. Aus diesem Grunde habe ich mich dazu entschlossen, die entsprechenden Dateien vor dem Hochladen zu komprimieren.

Da die "vorbereitete" Komprimierung ein wichtiger Bestandteil meiner Implementierung ist, möchte ich noch kurz darauf eingehen: ( Überspringen )

Nachdem ich mehrere HTML-Packer getestet habe, habe ich mich für ein 2-Phasen Modell entschieden:

  1. Phase: ESC (ECMAScript Cruncher)

    Dieses kostenlose Kommandozeilen-Tool löscht Kommentare, unnötige Leerzeichen, Tabulatoren und Zeilenumbrüche. Gleichzeitig wird noch eine Variablenersetzung durchgeführt, d.h. lange Namen werden durch kürzere ersetzt.

  2. Phase: Eigene "Windows Scripting Host"-Routine

    Alle langen Variablennamen, die noch nicht in Phase 1 ersetzt wurden, werden nun "von Hand" verkürzt.

Wenn Du sehen willst, was am Ende dabei herauskommt, hier ist das Resultat. Der Code ist "kompakt" und auf eine gewisse Weise sogar "kopiergeschützt" ;-)

Um den Quellcode weiter zu verkleinern, werde ich noch zusätzlich viele Anweisungsblöcke stark verkürzen.

Beispiel:

var value1 = 0 ;
var value2 = 0 ;
var value3 = 1 ;
var value4 = 2 ;
wird zu
var value1 = value2 = 0 ,
     value3 = 1 ,
     value4 = 2 ;

Des Weiteren werde ich Anweisungen, die den CSS-Style bzw. die HTML-DOM eines Elementes beeinflussen, durch entsprechende Funktionen implementieren. Diese befinden sich in der Scriptdatei "standard.js", die in all meinen Web-Projekten eingebunden ist.

Beispiele:


setBackgroundColor( oBox , "black" );


=
function setBackgroundColor( o , v )
{
  o.style.backgroundColor = v;
};

setHTML( oBox , "<DIV>Hallo</DIV> ");


=
function setHTML( o , v )
{
  o.innerHTML = v;
};

setSelectedIndex( oSelectLevel , 1);


=
function setSelectedIndex( o , v )
{
  o.selectedIndex = v;
};
Beachte: Infolge der späteren Komprimierung werden die verwendeten "langen" Funktionsnamen durch "2-Zeichen"-Aliase ersetzt, d.h. das Script wird dadurch merklich kleiner und für den Menschen "unverständlicher" ;-)

Achtung: Solltest Du im Laufe des Tutorials auf eine Funktion/Methode treffen, die Du nicht kennst, wirst Du sie mit sehr großer Wahrscheinlichkeit in der bereits erwähnten "standard.js" finden!

Auch bezüglich CSS-Formatierung wird "gespart": Anstelle der "üblichen" inline-Styles verwende ich "multiple class" -Zuweisungen:

Beispiel:

<DIV style="height:100%;width:100%;background-color:#000000"></DIV>

wird zu

<style type="text/css">
.H { height : 100% }
.W { width : 100% }
.BC000 { background-color : #000000 }
</style>

<DIV class="H W BC000"></DIV>

Wird das CSS-Property nur einmal gebraucht, macht das ganze natürlich keinen Sinn. Haben aber mehrere HTML-Elemente die gleiche Eigenschaft, sieht das schon ganz anders aus.

Zusammenfassung: "Kleinvieh macht auch Mist!"

Durch meine verwendete Komprimierung wird das Script "word.js" von 10.029 auf 3.291 Bytes verkleinert, d.h. um ca. 67 % geschrumpft!


4. Implementierung


4.1 Lösungswörter

Damit das Spiel über längere Zeit interessant bleibt, werden verschiedene Wortbereiche angeboten. Diese werden mit Hilfe eines zweidimensionalen Arrays in der HTML-Seite definiert. Durch diese Ausgliederung der "Daten" ist es ein Leichtes, das Spiel individuell anzupassen.

var words = new Array();
words[ 0 ] = new Array( "Karlsberg", "Lyoner", "Schwenkbraten", "Rotwein" );
words[ 1 ] = new Array( "Jack Nicholson", "Sandra Bullock", "Brad Pitt", "Nicole Kidmann" );
words[ 2 ] = new Array( "Casablanca", "Alien", "Der Herr der Ringe", "Lauras Stern" );
words[ 3 ] = new Array( "Ridley Scott", "Alfred Hitchcock", "Steven Spielberg" );
words[ 4 ] = new Array( "Abraham Lincoln", "Konrad Adenauer", "Richard von Weizsäcker" );
<select id="SelectWordRange">
<option selected>Allgemein</option>
<option>Schauspieler</option>
<option>Filme</option>
<option>Regisseure</option>
<option>Politiker</option>
<option>Kreuz und Quer</option>
</select>

Wählt der Spieler den Bereich "Filme" aus, wird der "selectedIndex" der Selectbox auf "2" gesetzt. Die Lösungswörter erhalten wir somit über "words[ 2 ]". Beachte hierbei, daß Javascript Arrays immer mit "0" als erstem Index beginnen!

Hast Du bemerkt, daß es keine Einträge für den Wortbereich "Kreuz und Quer" gibt? Dies ist kein Fehler! Bei dieser Einstellung wird ein Wort aus einem zufälligen Bereich ausgewählt.

Wenn Du einen weiteren Bereich einfügen willst, muß Du also nur den entsprechenden "words"-Eintrag setzen und die dazugehörige "Option" vor "Kreuz und Quer" stellen. Das war's!


4.2 Inititalisierung: initPage()

Um "Timingproblemen" aus dem Weg zu gehen, definiere ich am "BODY" einen "onload"-Eventhandler. Sobald die HTML-Seite komplett geladen wurde, ruft dieser die Funktion "initPage" auf. Erst zu diesem Zeitpunkt ist es möglich, fehlerfrei auf alle HTML-Elemente der Seite zuzugreifen. Würde ich früher ein HTML-Element ansprechen, könnte es passieren, daß das Element noch nicht in die HTML-DOM eingebunden wurde: Das Script würde einen Fehler "Object not found" melden und die Verarbeitung abbrechen.

<body onload="initPage()">

Was passiert nun beim Programmstart?

function initPage()
{
    initCrossBrowser();

    oBody = getObj( "theBody" );
    oBox = getObj( "Box" );
    oBoxCol = getObj( "BoxCol" );
    oLevelOptions = getObj( "LevelOptions" );
    oStatusLine = getObj( "StatusLine" );
    oHeaderLine = getObj( "HeaderLine" );

    oSelectLevel = getObj( "SelectLevel" );
    oSelectWordRange = getObj( "SelectWordRange" );

    oCheckBoxHideSpace = getObj( "CheckBoxHideSpace" );
    oCheckBoxFakeChars = getObj( "CheckBoxFakeChars" );
    oSelectHideChars = getObj( "SelectHideChars" );
    oSelectEnlargeGrid = getObj( "SelectEnlargeGrid" );

    oButtonShowAnswer = getObj( "ButtonShowAnswer" );
    oButtonTest = getObj( "ButtonTest" );
    oButtonShowTip = getObj( "ButtonShowTip" );

    oInputAnswer = getObj( "InputAnswer" );

    for (var i = 0 ; i < words.length ; i++)
        randomWords[i] = words[i].slice();

    setOptions();

    startGame();
};
Zuerst werden mit Hilfe der Funktion "initCrossBrowser" verschiedene browserabhängige "Flags" gesetzt, z.B:
  • isMSIE
  • isMozilla
  • isO7
Danach erzeuge ich mit "getObj" Referenzen auf HTML-Elemente, auf die ich später noch zugreifen werde. Durch diese Vorgehensweise wird die Scriptausführung schneller, da die HTML-DOM nicht mehr bei jedem Zugriff auf ein HTML-Element durchsucht werden muß.

Jetzt lege ich noch eine Kopie des Wörterarrays "words" an. Aus dieser Kopie werde ich später die Lösungswörter "ausschneiden", d.h. ich wähle ein Wort zufällig aus und lösche es aus dem dazugehörigen Array. Durch diese Vorgehensweise ist gewährleistet, daß kein Wort zweimal ausgewählt wird.

Für den "unwahrscheinlichen" Fall, daß ein Spieler alle Lösungswörter eines Bereiches "B" durchgespielt hätte, d.h. das Array "randomWords[ B ]" also leer wäre, würde ich mit Hilfe des "Original"-Arrays "words" eine neue Kopie des betreffenden Bereiches anlegen. Der Spieler könnte dann mit den bereits gespielten Wörtern noch einmal spielen.

Beachte: Alle hier aufgeführten Variablen wurden bereits zu Beginn der Scriptdatei "words.js" global deklariert!


 
Einschub: Browserweiche

Normalerweise identifiziert man einen Browser anhand seines Namens ( "navigator.appName" ) und seiner Versionsnummer ( "navigator.appVersion" ).

Du benutzt zum Beispiel gerade folgenden Browser:


Nun ist es aber bei einigen Browern möglich, diese Kennung abzuändern, d.h. der Browser gibt sich als ein anderer aus: Die "Browserweiche" wäre somit hinfällig!

Aus diesem Grund gehe ich einen anderen Weg: Ich überprüfe, ob der verwendete Browser bestimmte Eigenschaften unterstützt und bestimme dann dessen "Typ": [ mehr dazu hier ]

  • Opera gibt sich gerne als "Internet Explorer" aus. Da er aber als einziger Browser die "window.opera"-Eigenschaft unterstützt, ist sein "Tarnversuch" sehr leicht zu erkennen. Um das "Alter" einer Opera-Version bestimmen zu können, muß man dann nur noch die Eigenschaft "document.createElement" überprüfen: Wird diese unterstützt, handelt es sich mindestens um eine 7-er Version.

  • Da der MSIE sehr "betriebssystemnah" implementiert wurde, hat er per "window.clipboardData" Zugriff auf die Zwischenablage, d.h er kann sie auslesen ( Sicherheitsrisiko! ) und befüllen.

Anhand dieser Merkmale kann man nun relativ einfach eine "Browserweiche" implementieren. Diese wird nie zu "100%" passen, aber bisher habe ich damit nur gute Erfahrungen gemacht.

function initCrossBrowser( o )
{
	var isO7 = ( window.opera && document.createElement ) != null,
		isMozilla = ( !document.all || window.sidebar ) !=null,
		isMSIE = ( window.clipboardData != null );
}

Eine ganz andere Art Browserweiche bietet Microsoft selbst an: conditional comments. Diese "erweiterten Kommentare werden nur vom MSIE (Version: 5.0 und höher) ausgewertet. Ähnlich den "Serverside Includes" kann hiermit die HTML-Seite direkt verändert werden,d.h. es kann z.B. ein anderes Stylesheet bzw. sogar eine andere Scriptdatei eingebunden werden. Des Weiteren könnte man allen MSIE-Nutzer spezielle Microsoft Funktionen wie z.B. "AddFavorite" anbieten.

<!--[if IE]>
<DIV>Ich bin ein MSIE</DIV>
<![endif]-->

<!--[if  IE 6]>
<DIV>Ich bin ein MSIE 6.0</DIV>
<![endif]-->

<!--[if  ! IE 6]>
<DIV>Ich bin kein MSIE 6.0</DIV>
<![endif]-->

<!--[if  ! lt IE 6]>
<DIV>Ich bin ein MSIE 6.0</DIV>
<![endif]-->
<!--[if lt IE 6]>
<DIV>
Ich bin ein MSIE 5.5 bzw. 5.0
</DIV>
<![endif]-->

<!--[if  gte IE 5.5]>
<DIV>
Ich bin mindestens ein  MSIE 5.5
</DIV>
<![endif]-->

<!--[if  ! lte IE 6]>
<DIV>Bin ich ein MSIE 7.0 ?</DIV>
<![endif]-->
<!--[if IE]>

<A href="javascript:window.external.AddFavorite(
   'http://www.javascript-spielereien.de',
   'Malleus\' Javascript Spielereien' )">
Alle MSIE-Benutzer können hier einen Bookmark setzen
</A>

<![endif]-->

Einschub-Ende


Einschub: Element-Referenzierung

function getObj( o )
{
    return document.getElementById( o );
}

Jedes HTML-Element, das eine "Id" besitzt, kann über diese Id angesprochen werden. Dies geht beim MSIE relativ einfach, da dieser die Eigenschaft "document.all" unterstützt, d.h. bei Angabe eine Id wird das betreffende Element direkt im "document.all"-Container gesucht.

Achtung:

Diese Vorgehensweise ist nicht "W3C"-konform! Will man sicher stellen, daß "alle" neueren Browser auf das spezifizierte HTML-Element zugreifen können, muß man stattdessen die vom "W3C" eingeführte Methode "document.getElementById" benutzen.

Beim Internet Explorer wären z.B. folgende Anweisungen äquivalent:

  • box.style.width="100px"

  • document.all("box").style.width="100px"

  • document.getElementById("box").style.width="100px"

Interessanterweise hat sogar Opera, der eigentlich sehr auf "W3C"-Kompatibilität achtet, dieses "Verhalten" implementiert.

Einschub-Ende


Nun werden die Spielparameter gesetzt:

Zu diesem Zweck vergleiche ich den "selectedIndex" der "SelectLevel"-Selectbox mit dem Wert "5" ( = Freies Spiel ). Die versteckten Einstellungen werden daraufhin entweder sichtbar oder versteckt. Danach werden die entsprechenden Einstellungen geändert:

Beispiel: Level "3" ( levelindex = 2 )

  1. Aktiviere die CheckBox "Leerzeichen verstecken":

  2. Aktiviere "Falsche Buchstaben anzeigen":

  3. Wähle den ersten Wert "0" der SelectBox "Buchstaben raten" aus, d.h. es werden keine Buchstaben versteckt.

  4. Wähle den ersten Wert "0" der SelectBox "Gitter vergrößern" aus, d.h. das Gitternetz wird nicht "unnötig" vergrößert.

Wie Du bestimmt schon bemerkt hast, sind "setSelectedIndex" und "setChecked" bzw. "setNotChecked" eigentlich nur Abkürzungen für die entsprechenden Anweisungen und dienen nur dazu, den QuellCode später gut komprimieren zu können.

function setOptions()
{
  var levelIndex = getSelectedIndex( oSelectLevel );

  if ( levelIndex == 5 )
      return setDisplayBlock( oLevelOptions );

  if ( isDisplayed( oLevelOptions ) )
     setDisplayNone( oLevelOptions );

  switch ( levelIndex )
  {
      case 0 :

          setNotChecked( oCheckBoxHideSpace );
          setNotChecked( oCheckBoxFakeChars );
          setSelectedIndex( oSelectHideChars , 0 );
          setSelectedIndex( oSelectEnlargeGrid , 0 );
          break;

      case 1 :

          setChecked( oCheckBoxHideSpace );
          setNotChecked( oCheckBoxFakeChars );
          setSelectedIndex( oSelectHideChars , 0 );
          setSelectedIndex( oSelectEnlargeGrid , 0 );
          break;

      case 2: // Beispiel: Level "3"

          setChecked( oCheckBoxHideSpace );
          setChecked( oCheckBoxFakeChars );
          setSelectedIndex( oSelectHideChars , 0 );
          setSelectedIndex( oSelectEnlargeGrid , 0 );
          break;

      case 3 :

          setChecked( oCheckBoxHideSpace );
          setNotChecked( oCheckBoxFakeChars );
          setSelectedIndex( oSelectHideChars , 2 );
          setSelectedIndex( oSelectEnlargeGrid , 0 );
          break;

      case 4 :

          setChecked( oCheckBoxHideSpace );
          setChecked( oCheckBoxFakeChars );
          setSelectedIndex( oSelectHideChars , 3 );
          setSelectedIndex( oSelectEnlargeGrid , 1 );
          break;
 };
 return true;
}

4.3 Start: startGame()

Die Funktion "startGame" wird sowohl innerhalb "initPage" als auch durch Drücken des Buttons "Neues Spiel" aufgerufen.

function startGame()
{
    var row , col , maxCols ,
    wordRange = getSelectedIndex( oSelectWordRange );

    if ( !words[ wordRange ] )
       wordRange = getRandom( words.length );

    if ( randomWords[ wordRange ].isEmpty() )
       randomWords[ wordRange ] = words[ wordRange ].slice();

    word = orgWord = randomWords[ mode ].popRandom();

Nachdem ein paar Hilfsvariablen deklariert wurden, überprüfe ich den eingestellten Wortbereich:

Gibt es keine Wörter im dazugehörigen "Original"-Wörterarray "words", wurde der Wortbereich "Kreuz und Quer" ausgewählt und ich bestimme einen zufälligen Wortbereich.

Exisitieren in meiner Wörterkopie "randomWords" keine Wörter mehr, d.h. der Spieler hat alle Wörter durchgespielt, lege ich eine neue Kopie des entsprechenden Bereiches an.

Nun passiert etwas Interessantes: Mit Hilfe der "popRandom"-Methode des "Array"-Objektes wähle ich mir ein Wort zufällig aus und lösche es dann aus dem dazugehörigen "Array"-Element der Wörterkopie.


Einschub: Prototype
Array.prototype.isEmpty = function()
{
    return ( this.length == 0 )
}

Array.prototype.popRandom = function()
{
    return this.deleteIndex( getRandom( this.length ) );
}

Array.prototype.deleteIndex = function( v )
{
    var T = this ,
    o = T[ v ];

    for ( var i = v ; i < T.length ; i++ )
        T[ i ] = T[ i + 1 ];

    T.length -= 1;
    return o;
}

Sind Dir die "Array"-Methoden "isEmpty" und "popRandom" aufgefallen?

Laut Javascript Referenz gibt es die doch überhaupt nicht!

Diese Methoden wurden durch ein sogenanntes "Prototyping" dem "Array"-Objekt hinzugefügt, d.h. alle Arrays verfügen nun über diese Methoden.

Durch das Schlüsselwort "prototype" wird eine neue Eigenschaft des "Array"-Objekt erzeugt: hier z.B. eine Funktion mit Namen "isEmpty" bzw. "popRandom".

Um jetzt auf die "echten" Daten des instanzierten Arrays zugreifen zu können, bedient man sich des Schlüsselwortes "this".

Im Falle der Methode "isEmpty", die überprüft, ob ein Array leer ist, reicht also ein Vergleich auf "this.length == 0".

"popRandom" ist etwas komplexer:

Zuerst wird mit "getRandom( this.length )" eine Zufallszahl aus dem Bereich "0" bis "Anzahl der Elemente im Array" ermittelt. Dieser als "Array"-Index genutzter Wert wird dann der "Prototype"-Methode "deleteIndex" übergeben, die wiederum diesen Index aus dem Array löscht und den dazugehörenden Wert an "popRandom" zurückgibt.

Wenn die entsprechende Option aktiviert ist, entferne ich mit Hilfe der Funktion "trim" alle Leerzeichen, d.h. aus

"Mein Name ist Hase" wird "MeinNameIstHase".

Aus diesem Grund wird das Lösungswort auch in zwei Variablen gespeichert:

    if ( isChecked( oCheckBoxHideSpace ) )
       word = trim( word );

    word = getUpperCase( word ).split( "" );
  • "orgWord", das später als Lösungswort ausgeben wird, und

  • das Buchstaben-Array "word", das bei der Lösungspfadsberechnung benutzt wird.

Einschub: Reguläre Ausdrücke

Wenn es sich um String-Manipulationen handelt, greife ich immer wieder gerne auf "Reguläre Ausdrücke" zurück, so auch bei "Trim":

function trim( s )
{
    return s.replace( / /g , "" )
}

Es wird die "replace"-Methode des "String"-Objektes aufgerufen.

Der erste Parameter gibt den "Suchbereich" an, der durch zwei Slashs "/" umschlossen wird. Da ich "alle" Leerzeichen ersetzen will, gebe ich als Such-Option "g" für global an. Würde ich das "g" hinter dem abschließenden Slash weglassen, würde nur das erste Leerzeichen ersetzt werden.

Im zweiten Parameter wird der Text angegeben, der den gefundenen Teilstring ersetzen soll: Hier also ein Leerstring "".

Somit werden alle Leerzeichen im String durch einen Leerstring ersetzt und damit entfernt.

    maxCols = getMathCeil( getMathSqrt( word.length ) ) +
              getSelectedIndex( oSelectEnlargeGrid );

    if ( maxCols * maxCols - word.length < 5 )
       maxCols++;

    if ( !isChecked( oCheckBoxHideSpace ) )
       CHARS.push( " " );

Nun bestimme ich die Größe der quadratischen Rätselmatrix:

Zu diesem Zweck berechne ich die Wurzel aus der Wortlänge und runde diese auf.

Ist die Option "Gittergröße verändern" aktiviert, wird der Wert noch entsprechend erhöht.

Das Buchstaben-Array "CHARS", das zur Anzeige falsche Buchstaben herangezogen wird, wird um ein Leerzeichen vergrößert, wenn Leerzeichen im Lösungswort zugelassen sind. Somit ist es möglich, daß auch falsche Leerzeichen angezeigt werden.

Jetzt wird's langsam interessant: Die Buchstabenmatrix wird generiert.

Zu diesem Zweck erzeuge ich zuerst die dazugehörige Tabellenstruktur mit Hilfe der String-Variablen "sTable". Alle darin enthaltenen "TD"-Elemente bekommen eine "Id" der Form "r"+Zeilennummer+"c"+Spaltenummer, d.h. "r0c0" wäre z.B. die obere linke Zelle.
function getCId(r,c)
{
 return "r" + r + "c" + c;
}
    validMoves = newArray();

    var sTable = "< table id='grid' border='1' cellpadding='2' cellspacing='2' class='TLf'>";
    for ( row = 0 ; row < maxCols ; row++ )
    {
        sTable += "<tr>";

        for ( col = 0 ; col < maxCols ; col++ )
        {
            var cId = getCId( row , col );
            sTable += "<td id='" + cId +
                      "' style='text-align:center;width:80px;background-color:" +
                      cellColor + "' ";

Wenn "falsche Buchstaben" angezeigt werden, wird pro Zelle ein Zeichen aus dem Buchstaben-Array "CHARS" ausgewählt und anstelle eines Leerzeichens angezeigt. Zu beachten ist hierbei, daß ein Leerzeichen innerhalb eines "TD"-Elementes als "&nbsp;" angegeben werden sollte, da sonst die Zelle von einigen Browsern nicht "richtig" angezeigt wird.

function check4Space( v )
{
 return ( v == " " ) ? " " : v ;
}
            if ( isChecked( oCheckBoxFakeChars ) )
            {
              sTable += ">" + check4Space( CHARS[ getRandom( CHARS.length ) ]  ) + "</td>";
            }
            else sTable += "> </td>";

Das Gitter ist jetzt entweder komplett leer oder es zeigt zufällige Buchstaben an.

            validMoves[ cId ] = newArray();

            if ( row < 0 )
                    validMoves[ cId ].push( UP );

            if ( col < maxCols - 1 )
                   validMoves[ cId ].push( RIGHT );

            if ( row < maxCols - 1 )
                   validMoves[ cId ].push( DOWN );

            if ( col > 0)
                    validMoves[ cId ].push( LEFT );

Nun kommt das Array "validMoves" ins Spiel:

Für jede Zelle der Tabelle werden die möglichen Bewegungsrichtungen gespeichert:

Beispiel:

  • Befinde ich mich in der oberen linken Ecke, kann ich nur nach "rechts" und nach "unten" gehen:

    validMoves[ "r0c0" ] = new Array( RIGHT , DOWN )
  • Für "r1c1" gilt:

    validMoves[ "r1c1" ] = new Array( UP , RIGHT , DOWN , LEFT )

Wenn alle Zeilen und Spalten abgearbeitet wurden, wird das "TABLE"-Element geschlossen und angezeigt.

        };
    	sTable += "</tr>";
    };
    sTable += "</TABLE>";

    if ( !isMSIE )
       setDisplayNone( oBody );

    setHTML( oBox, sTable ); // oBox.innerHTML = sTable

    var cols = ( maxCols < 4 ) ? 4 : maxCols;
    setWidth( oBoxCol, cols * 84 +15 );

    if ( !isMSIE )
       setDisplayBlock( oBody );

    getObj( "grid" ).onclick = check4Answer;

Beachte:

Wird bei Mozilla und Opera die Breite einer Spalte dynamisch verändert, erfolgt keine Neuzentrierung des Browserinhaltes.

Aus diesem Grund muß man folgenden kleinen Trick anwenden:

  • Verstecke zuerst das "BODY"-Element.

  • Nehme alle Änderungen an der HTML-DOM vor.

  • Zeige das "BODY"-Element wieder an.

Die etwas unscheinbar wirkende Funktion "setHTML" erzeugt in der "Unsichtbarkeitsphase" aus der Stringvariablen "sTable" ein "TABLE"-Element mit der Id "grid" und fügt dieses Element dann unterhalb des HTML-Elementes "oBox" in die HTML-DOM ein.

"Wie geht denn so etwas", wirst Du Dich jetzt vielleicht fragen?

Ganz einfach: "oBox" ist ein "DIV"-Element mit der Id "Box", das in der HTML-Seite "words.html" definiert wurde. Diesem HTML-Element wird nun über die Zuweisung "oBox.innerHTML = sTable" ein neuer Inhalt gegeben. Hier also die neue "TABLE". Ein bestehender Inhalt wird dabei gelöscht, d.h. aus der HTML-DOM entfernt.

Nachdem das neu erzeugten "TABLE"-Element noch einen "onclick"-Eventhandler "check4Answer" bekommen hat, kümmere ich mich nun um die "versteckten Buchstaben" des Lösungswortes:

    var maxHidedChars = getSelectedIndex( oSelectHideChars );

    if ( word.length - maxHidedChars < 5 )
       maxHidedChars = 1;

    var randomIndices = newArray();

    for ( i = 0 ; i < word.length ; i++)
        randomIndices.push( i );

    hidedChars = newArray();

    for ( i = 0 ; i < maxHidedChars ; i++ )
    {
        var charPosition = getRandom( randomIndices.length );
        hidedChars.push( randomIndices[ charPosition ] );
        randomIndices.deleteIndex( charPosition );
    };

Zuerst stelle ich sicher, daß mindestens fünf Buchstaben nicht versteckt werden.

Dann wähle ich mir aus dem neu erstellten Hilfsarray "randomIndices" zufällig "maxHideChars" Indices aus und füge diese dem Array "hidedChars" hinzu. Dieses Array enthält nun alle Positionen innerhalb des Lösungswortes, die versteckt werden:

Wird später das "i"-te Zeichen des Lösungswort ausgegeben und "i" ist in "hidedChars" enthalten, wird anstelle des richtigen Buchstabens ein Fragezeichen ausgegeben: Der Buchstabe wurde versteckt.

Nun wird der Lösungspfad "solutionPath" mit Hilfe der rekursiven Funktion "findNextCell" berechnet und dann mit "showPath" angezeigt.

    charsInSolutionPath = 0;
    solutionPath = newArray();
    findNextCell( getRandom( maxCols ) , getRandom( maxCols ) );
    showPath();

    for ( i = 0 ; i < solutionPath.length ; i++ )
        solutionPath[ i ].inSolutionPath = ( i + 1 );

Beachte:

Das "i"-te Element des "solutionPath"-Arrays enthält eine Referenz auf das "TD"-Element, das den "i"-ten Buchstaben des Lösungswortes anzeigt.

Zur besseren Übersicht wird jetzt noch an jedes "TD"-Element des Lösungspfads die Position des Buchstabens innerhalb des Lösungswortes gespeichert.

Jetzt erzeuge ich noch ein Hilfsarray für spätere Lösungstips und gebe ein paar Texte aus:

    charTips = solutionPath.makeRandomIndex();
    setHTML( oHeaderLine , MSG[ 0 ].replace( /X/ , solutionPath.length ) );
    setHTML( oStatusLine , MSG[ 1 ] );
    setValue( oInputAnswer , "" );

Zu beachten ist hierbei, daß keine "echten" Texte ausgegeben werden, sondern vielmehr auf eine Variable "MSG" zugegriffen wird. Warum tue ich das? Ganz einfach: Da ich alle Texte in die HTML-Seite ausgelagert habe, kann ich das Spiel später einfach in eine andere Sprache übersetzen:

var MSG=new Array("Das gesuchte Wort hat X Buchstaben.", // 0
"Wie lautet das Lösungswort?", // 1
"Das gesuchte Wort lautet:", // 2
"Das Spiel ist aus!\n\nDu hast dreimal einen falschen Buchstaben angeklickt.", // 3
"Gratulation,\ndas ist die richtige Antwort", // 4
"Schade,\n leider ist diese Antwort falsch" // 5

Hast Du den "regulären Ausdruck" bei "MSG[ 0 ]" schon bemerkt? Er ersetzt das "X" im Text "Das gesuchte Wort hat X Buchstaben" durch die Lösungswortlänge.

Da das Spiel gleich beginnen kann, werden nun auch die "Lösungs"-Buttons aktiviert:

    enable( oButtonShowAnswer, oButtonShowTip, oTest );

Einschub: Variable Funktionsparameteranzahl

function enable()
{
    var o = enable.arguments;

    for (var i = 0 ; i < o.length ; i++ )
        o[ i ].disabled = false;
}

Bei der Implementation der "enable"-Funktion gibt es eine kleine Besonderheit zu beachten: Die Funktion kann mit beliebig vielen Parametern aufgerufen werden.

Dies ist ganz einfach: Eine Javascript-Funktion ist ein Objekt. Dieses Objekt hat wie jedes andere Objekt Methoden und Eigenschaften. Eine Eigenschaft ist z.B. das Array "arguments", welches alle übergebenen Parameter enthält. Somit kann ich auf den ersten Parameter mit "enable.arguments[0]" bzw. auf den "i"-ten mit "enable.arguments[i]" zugreifen. Die Anzahl übergebener Parameter erhalte ich jetzt ganz normal über "enable.arguments.length".

Jetzt wird noch der "Error-Anklick"-Counter auf "0" gesetzt und das Spiel ist gestartet:

   badClicks = 0 ;
}

4.4 Lösungspfadberechnung: findNextCell

Hast Du bisher alles auf Anhieb verstanden? Ja? Das wird sich jetzt ändern ;-)

Der Lösungspfad wird nämlich mit Hilfe einer rekursiven Funktion berechnet, also einer Funktion, die sich solange selbst aufruft bis sie terminiert.

Die zu implementierende Funktion "findNextCell" wird innerhalb "startGame" mit einer zufälligen Zeile und Spalte initial aufgerufen. Da noch keine Buchstaben des Lösungswortes gesetzt wurden, ist das Array "solutionPath" leer.

Es geht los:

function findNextCell( row , col)
{
    var cId = getCId( row , col ) ,
    cell = getObj( cId );

    if ( !cell.used)
    {
        cell.used=true;
        solutionPath.push( cell );

        // FNC-01
        if ( ++charsInSolutionPath == word.length )
           return true;

Anhand der Zeilen- und Spaltennummer wird zuerst die Id der Zelle, in der ich mich gerade befinde, berechnet:

"r0c0" wäre z.B. die Id der oberen linken Ecke.

Dann referenziere ich das entsprechende TD-Element und überprüfe, ob ich diese Zelle bereits besucht habe.

Da "used" kein Standardattribut eines "TD"-Elementes ist, wird die If-Bedingung (!null) beim ersten "Besuch" mit "true" ausgewertet:

Die Zelle wird in den Lösungspfad aufgenommen und als "besucht" gekennzeichnet. Wenn alle Buchstaben des Lösungswortes verteilt wurden, wird die rekursive Funktion erfolgreich beendet.

Ansonsten suche ich mir eine neue Zelle:

        var validDirection = false ,
        testMoves = validMoves[ cId ],
        copyCell = testMoves.slice();

        while ( testMoves.length > 0 && !validDirection )
        {   // FNC-02
            var newRow = row ,
            newCol = col;

            switch ( testMoves.popRandom() )
            {
                case UP :

                     newRow--;
                     break;

                case RIGHT :

                     newCol++;
                     break;

                case DOWN :

                     newRow++;
                     break;

                case LEFT :

                     newCol--;
                     break;
            };

            if ( findNextCell( newRow , newCol ) )
               validDirection = true;

        };


        if ( testMoves.isEmpty() && !validDirection )
        {   // FNC-03
            testMoves = validMoves[ cId ] = copyCell.slice();
            cell.used = false;
            solutionPath.pop();
            charsInSolutionPath--;
            return false; // FNC-04
        }
        else return true; // FNC-05
   }
   else return false; // FNC-06
};
 

Da getestete Zugmöglichkeiten durch "popRandom" aus dem Array-Element "validMoves[cId]" gelöscht werden, lege ich mir vorher eine Kopie der aktuellen Zugmöglichkeiten an. Des Weiteren setze ich das Flag "validDirection" auf "false", d.h. es wurde noch kein Lösungspfad gefunden.

Nun gehe ich in zufälliger Reihenfolge alle Möglichkeiten durch. Dies tue ich solange es noch Zugmöglichkeiten gibt bzw. der Lösungspfad noch nicht gefunden wurde.

Für jeden ausgewählten Zug berechne ich mir zuerst die neue Zeilen- bzw. Spaltennummer. Mit diesen neuen Werten rufe ich mich dann "selbst" noch einmal auf: der erste rekursive Aufruf ist vollbracht!

Im Idealfall werden nur diese Schritte bis zur erfolgreichen Terminierung durchgeführt.

Wurde der Lösungspfad gefunden (FNC-01), liefert "findNextCell" "true" zurück und "validDirection" wird auf "true" gesetzt:

Die "while"-Schleife wird verlassen und "findNextCell" kehrt in die aufrufende "findNextCell"-Instanz zurück (FNC-05).

Das ganze läuft solange ab, bis man sich wieder in der "startGame"-Funktion befindet:

Der Lösungspfad wurde generiert.

Was passiert aber, wenn die Funktion "false" zurückliefert und vor allen Dingen: Wann liefert sie "false" zurück?

Zuerst einmal nehmen wir an, daß "findNextCell" "false" zurückliefert: Wenn es weitere Zugmöglichkeiten (testMoves.length > 0) gibt, wird die "while"-Schleife (FNC-02) noch einmal durchlaufen, da "validDirection" immer noch "false" ist.

Sind keine Zugmöglichkeiten mehr vorhanden, kann das Lösungswort, ausgehend von der aktuellen Zelle, nicht plaziert werden, d.h. ein anderer Lösungsweg muß gesucht werden: Die Ursprungswerte der Zelle müssen wieder hergestellt werden (FNC-03). Ein "Rollback" wird ausgeführt:

  • "validMoves[cId]" wird wieder mit den Ursprungsdaten befüllt.

  • Die Zelle wird als "nicht besucht" gekennzeichnet.

  • Der Zelle wird aus dem Lösungspfad entfernt.

  • Der Buchstabenzähler wird um 1 verringert.

Die Funktion wird als "nicht erfolgreich" beendet (FNC-04) und es erfolgt ein Rücksprung zur "while"-Schleife (FNC-02) der aufrufende "findNextCell"-Instanz: Die Funktion sucht weiter.

Eine weitere Möglichkeit, warum die Funktion "findNextCell" "false" zurück liefert, kann die Tatsache sein, daß die Zelle bereits besucht wurde (FNC-06): Die Schlange würde sich in den eigenen Schwanz beißen.

Wir sind am Ende der Funktion angelangt. Hast Du alles verstanden? Nein? Kein Problem! Für meine erste rekursive Funktion hab' ich auch "etwas" länger gebraucht, aber dann hat's Spaß gemacht.


4.5 Lösungspfadausgabe: showPath, showSolution

Jetzt muß nur noch der Lösungspfad ausgegeben werden und dann kann's losgehen:

function showPath(help)
{
    for (var i = 0 ; i < solutionPath.length ; i++ )
    {
        var cell = solutionPath[ i ],
        c = check4Space( word[ i ] ); // SP-01

        if ( help )
        {
    		  c += "(<span style='color:black'>" + ( i + 1 ) + "</span>)";
     		  setBackgroundColor( cell , validColor );
        }
        else setBackgroundColor( cell , cellColor );

        if ( !help && hidedChars.getIndex( i ) != -1 )
          c = "???";

        setHTML( cell , c );
    }
}

Wie bereits erwähnt, steht im "i"-ten Element des "solutionPath"-Arrays die "i"-te Referenz eines "TD"-Elementes. Diese Zelle wird nun mit dem "i"-ten Buchstaben des Lösungswortes "befüllt".

Hier gibt es nur noch ein paar Sonderfälle zu beachten:

  • Anstelle eines Leerzeichens muß ein "&nbsp;" ausgegeben werden, da manche Browser eine "TD" ohne Inhalt nicht richtig "rendern" (SP-01).

  • Wenn es sich um einen versteckten Buchstaben handelt, gebe ich drei Fragezeichen "???" aus.

    Hier ist vor allem die "prototype"-Methode "getIndex" von Interesse: Sie liefert die Position eines Wertes innerhalb des Array zurück. Ist der Wert nicht im Array enthalten, wird eine "-1" ausgegeben.

    Beispiel:

    Array.prototype.getIndex = function( v )
    {
        for (var i = 0 ; i < this.length ; i++ )
            if (this[ i ] == v )
               return i;
    
        return -1;
    }
    

    Sei "hidedChars" ein Array mit den Werten "2" und "5", d.h. der dritte und der sechste Buchstabe werden versteckt.

    Es gilt daher:

    hidedChars[0]="2"; hideChars[1]="5".

    Bearbeite ich nun das sechste "TD"-Element, liefert "hidedChars.getIndex(5)" als Resultat "1" zurück, d.h. "5" ist das zweite Element im Array. Da "5" ungleich "-1" ist, handelt es sich um einen versteckten Buchstaben. Beachte in diesem Beispiel, daß ein Array immer mit dem Index "0" beginnt!

Wurde beim Aufruf der Funktion "showPath" der "help"-Parameter auf "true" gesetzt, wird zusätzlich noch die Position des Buchstabens innerhalb des Lösungswortes angezeigt. Diese Eigenschaft wird z.B. von der Funktion "showSolution" gebraucht, die das Lösungswort mit seinem zugehörigen Lösungspfad anzeigt.

function showSolution()
{
    setHTML( oStatusLine , MSG[ 2 ] );
    setValue( oInputAnswer , orgWord );
    disable( oButtonShowAnswer, oButtonShowTip, oTest );
    showPath( true );
    badClicks = 3;
}

Da das Rätsel aufgelöst wurde, werden noch einige Buttons deaktiviert und der "badClicks" - Counter wird auf "3" gesetzt.

Was sind "badClicks" ???


4.6 Lösungstips: check4Answer

Beim Spielen ist es möglich, einzelne Buchstaben anzuklicken.

  • Wurde ein Buchstabe im Lösungspfad angeklickt, wird dieser eingefärbt und die Position innerhalb des Lösungspfades ausgegeben.

  • Ist der Buchstabe nicht im Lösungspfad vorhanden, wird die Zelle rot eingefärbt und der "badClicks"-Counter um eins erhöht. Wurden drei Zellen falsch angeklickt, ist das Spiel beendet.

Was passiert nun im Detail, wenn ich einen Buchstaben anklicke?

function check4Answer(evt)
{
    if ( !evt )
       evt = window.event;

    var cell = getEventTarget( evt );

Bei der Funktion "check4Answer" handelt es sich um einen sogenannten "Event"-Handler, also um eine Funktion, die ein "Event" abarbeitet. Diese Funktion wurde innerhalb "startGame" aktiviert:

getObj( "grid" ).onclick = check4Answer;

Bei jedem "Click" ins Buchstabengitter (Id="grid") wird nun automatisch die Funktion "check4Answer" aufzurufen.

Beim "Event"-Handling gibt es einen großen Unterschied zwischen den Browsern:

  • Firefox, Opera und andere W3C- kompatiblen Browser übergeben das Event als Parameter.

  • MSIE stellt stattdessen das Property "window.event" zur Verfügung.

In unserem Fall bedeutet das folgendes:

Wird der Parameter "evt" nicht übergeben, wird "evt" als "false" bzw. "null" ausgewertet, d.h. der verwendete Browser ist der MSIE und ich muß mir das Event über "window.event" holen.

Jetzt muß ich wissen, in welches HTML-Element geklicked wurde: Ich rufe daher die "Crossbrowser"-Funktion "getEventTarget" mit dem abzuarbeitenden Event "evt" auf:

function getEventTarget( e )
{
    if ( !e )
       return null;

    if ( e.target )
       return e.target;

    return e.srcElement;
}

Wenn ein "gültiges" Event übergeben wurde, überprüfe ich, ob das "Event"-Objekt des verwendeten Browers das W3C-Property "target" "kennt".

  • Wird diese Eigenschaft unterstützt, zeigt "event.target" auf das HTML-Element, auf das geklickt wurde und ich gebe dieses Element der aufrufenden Funktion zurück.

  • Der MSIE verwendet das Property "srcElement" anstelle von "target", daher liefert die "If"-Bedingung "e.target" "false" bzw. "null" zurück und ich muß den Wert "e.srcElement" zurückgeben.


    if ( cell.wasClicked || badClicks == 3 )
       return true;

     cell.wasClicked = true;

Handelt es sich um einen "ungültigen" Klick, d.h. die "gefundene" Zelle wurde bereits "angeklickt" bzw. es wurden schon drei falschen Zellen ausgewählt, wird das "Event"-Handling abgebrochen. Ansonsten wird die Zelle als "angeklickt" gekennzeichnet und die Zelle wird überprüft.

Wenn eine Zelle im Lösungspfad angeklickt wurde, wird diese entsprechend eingefärbt. Nachdem der dazugehörige Buchstabe ans Ende des Antwortfeldes angefügt wurde, wird die komplette Antwort noch mit dem Lösungswort verglichen. Im Falle einer Übereinstimmung wird eine "Erfolgsmeldung" ausgegeben.

    if ( cell.inSolutionPath )
    {
        setBackgroundColor( cell , validColor );
        setValue( oInputAnswer , getValue( oInputAnswer ) + cell.innerHTML.split( "(" )[ 0 ] );

        if ( getUpperCase( getValue( oInputAnswer ) ) == getUpperCase( orgWord ) )
           checkWord();

    }
    else
    {
        if ( isTag( cell , "TABLE" ) )
           return true;

        else
        {
            setHTML( cell );
            setBackgroundColor( cell , invalidColor );
            if ( ++badClicks == 3 )
            {
                alert( MSG[ 3 ] );
                showSolution();
            }
        };
    };

    return true;
}

Wenn das angeklickte Element keine Buchstabenzelle ist, wird das "Event"-Handling abgebrochen. Ansonsten wird sie "rot" eingefährt und der darin enthaltene Buchstabe entfernt.

Nachdem der Error-Counter "badClicks" um eins erhöht wurde, wird überprüft, ob der Spieler bereits drei falsche Zellen markiert hat: Das Spiel würde in diesem Fall beendet und die Lösung angezeigt werden.

Die bereits oben erwähnte Funktion "checkWord" wird auch durch den Button "Auflösung" aufgerufen und wurde wie folgt implementiert:

function checkWord()
{
    alert(

    MSG[ ( getUpperCase( getValue( oInputAnswer ) ) == getUpperCase( orgWord ) ) ? 4 : 5 ]

    );
}

Nun müssen wir nur noch den "Lösungstip" ausprogrammieren und es wäre geschafft:

function showTip()
{
    if ( !charTips.isEmpty() )
    {
        var tipIndex = charTips.popRandom();
        cell = solutionPath[ tipIndex ];

        setHTML( cell , check4Space( word[ tipIndex ] ) + "(" + ( 1 + tipIndex ) + ")" );
        setBackgroundColor( cell , validColor );
    }
    else
    {
        setValue( oInputAnswer , orgWord );
        disable( oButtonShowAnswer, oButtonShowTip, oTest );
        badClicks = 3;
    }
}

Das war's!

Wenn Du Dich weiter mit dem Spiel beschäftigen willst, findest Du hier die dazugehörigen Quelltext-Dateien.


5. Weiterführende externe Links

Kristof Lipferts Browsererkennung durch JavaScript
 - Detaillierte Aufstellung: "Methode/Objekt" - Browser

CSS 4 You
 - Meiner Meinung nach "die" deutsche CSS-Referenz !

CSS/EDGE
 - Verblüffende CSS-Demos des bekannten Buchauthors "Eric A. Meyer" (en)

Speed Up Your Site: Web Site Optimization
 - Optimizing JavaScript for Execution Speed (en)


6. Disclaimer:

Alle Scripte und Dokumente sind urheberrechtlich geschützt und dürfen nicht ohne meine Zustimmung kopiert, vervielfältigt oder übernommen werden.

Alle Informationen auf meiner WebSite wurden von mir selbst mit bestem Wissen und Gewissen zusammengestellt. Einen Anspruch auf Fehlerfreiheit bzw. Vollständigkeit kann ich aber leider nicht erheben. Auch übernehme ich keinerlei Haftung für Fehler, die auf die Verwendung der Informationen zurückzuführen sind.


Vielleicht sehen wir uns ja bald beim "Apfelmännchen-Tutorial" bzw. bei einem meiner anderen Javascript Spielereien wieder.

Mahjongg Professional - Generierung lösbare Spiele in sechs Layouts

Solitaire Professional - ca. 50 verschiedene Spielregeln wie z.B. Spider, Freecell, Skorpion, usw.

Jokoban - Das waren noch die guten alten Zeiten, oder?

Die Flucht - Einfach und schlicht, aber trotzdem "ganz nett" ;-)

Tschau!

Frank Hammerschmidt ( aka Malleus )

P.S. Über einen Eintrag in mein Gästebuch bzw. einen Link auf meine Seite würde ich mich sehr freuen. Danke !

w3c-html (1K)  Valid CSS!