Pour ce faire, il nous faut une bonne Sandbox bien rapide, avec 200 prims disponibles (Je vous conseille chaleureusement la Fermi Sandbox).

  • Dans cette première partie, on va se contenter de construire la grille de jeu, avec un script de démo qui empilera des pieces en les faisant tomber au hasard.
  • Dans la deuxième partie (prochain épisode...) on ajoutera les commandes au clavier avec un vrai moteur de jeu.

Ingrédients pour faire un jeu de Tetris

Ce qu'il nous faut :

  • Une grille composée de 200 petits cubes (20 lignes de 10 colonnes)
  • Un (gros) script pour la démo

En gros, le principe de la grille est que chaque cube pourra être coloré ou transparent : c'est le script qui s'occupera d'"allumer" et d'"éteindre" chacun de ces cubes pour donner l'illusion de mouvement aux pieces.

Première étape : construction de la grille

Empiler 20 lignes de 10 prims bien alignés représente un boulot de titan. C'est pourquoi je vous propose un script qui va faire le boulot à votre place.

Comment procéder :

  • Rezzez un cube;
  • Donnez-lui la taille X=0.050, Y=0.050, Z=0.050 : très important;
  • Renommez-le en l'appelant Cube.
  • Copiez Cube, et donnez un nom à la copie. Appelez-là SLTRis, par exemple;
  • Prenez Cube pour l'intégrer à votre inventaire
  • Copier Cube depuis votre inventaire dans celui de SLTRis:
    Pour ce faire, faites glisser Cube vers SLTRis depuis votre inventaire en maintenant la touche CTRL enfoncée, puis quand le cube SLTRis apparait encadré de rouge, relâchez le bouton;
  • A présent, utilisez l'onglet Contenu de l'objet SLTRis pour vérifier si Cube s'y trouve.
  • Parfait ! A présent, cliquez sur "Nouveau Script" pour créer un script;
  • Puis copier le code ci-dessous dans ce nouveau script.
// Position en cours de création dans la grille (X de 0 à 9, Y de 0 à 19)
integer X; integer Z;

// Position et taille du cube d'origine
vector Pos; vector Scale;

// Rezze un cube
RezCube()
{
    llRezObject("Cube", Pos + <Scale.x * X, 0, Scale.z * Z>, ZERO_VECTOR, ZERO_ROTATION, 0);
}

default
{
    touch_start(integer total_number)
    {
        // Demande la permission de toucher aux liaisons
        llRequestPermissions(llGetOwner(), PERMISSION_CHANGE_LINKS);
    }

    // Des que la permission est accordée
    run_time_permissions(integer perm)
    {
        if (perm && PERMISSION_CHANGE_LINKS)
        {
            state build;
        }
    }
}

state build
{
    state_entry()
    {
        // Recupere position et taille du cube d'origine
        Pos = llGetPos();
        Scale = llGetScale();

        X = 0;
        Z = 0;
       
        // Deplace-le a sa position definitive : ce sera le prim 1
        llSetPos(Pos + <Scale.x * 9, 0, Scale.z * 19>);
       
        RezCube();
    }

    // Cet evenement se declenche quand un cube a été rezzé
    object_rez(key id)
    {
        // Le nouvel objet rezzé est lié à celui-ci, mais celui-ci reste le père. Le nouvel objet rezzé prendra donc le numéro 2. Les objets déja attachés suivront : 3, 4, 5...
        llCreateLink(id, TRUE);
       
        ++X;
       
        // Il faut bien s'arrêter un jour !
        if (X == 9 && Z == 19)
            state fin;

        if (X > 9)
        {
            X = 0;
            ++Z;
        }

        // Et on rezze le cube suivant...
        RezCube();
    }
}

state fin
{
    state_entry()
    {
        llOwnerSay("C'est fini !");
    }
}

Voilà... A présent, touchez SLTRis, accordez-lui la permission qu'il demande, puis patientez deux à trois bonnes minutes : SLTRis est en train de construire tout seul la grille de 200 cubes, alignés en 20 lignes de 10 colonnes. Le temps pour vous de lire la suite...

Deuxième étape : le moteur de jeu

Pour le moteur du jeu, on va regarder attentivement :

  • Comment sont représentées chaque pièces;
  • Comment est géré le remplissage de la grille;
  • La logique de la démo.

Comment sont "rangées" les pièces ?

Tetris se joue avec 7 pieces identifiées par une lettre de l'alphabet. Ces pièces sont représentée sous la forme d'un tableau.

  • Chaque piece occupe exactement quatre cellules;
  • Elles ont toutes un centre de rotation;
  • Une couleur différente;

Le format du tableau sera, pour chaque pièces :

  • sa couleur (au format LSL : un vecteur <rouge, vert, bleu>),
  • les coordonnées des quatre cubes qui la composent par rapport au centre de rotation de la pièce (c'est à dire quatre fois X,Y).
list PIECES = [
<1.0, 0.0, 0.0>,  0, -1,  0,  0,  0,  1,  0,  2,    // "I" Rouge
<1.0, 1.0, 0.0>,  0, -1,  0,  0,  0,  1, -1,  1,    // "J" Jaune
<1.0, 0.0, 1.0>,  0, -1,  0,  0,  0,  1,  1,  1,    // "L" Magenta
<0.0, 0.0, 1.0>,  0,  0,  0,  1,  1,  0,  1,  1,    // "O" Bleu
<0.0, 1.0, 0.0>, -1, -1, -1,  0,  0,  0,  0,  1,    // "S" Vert
<0.5, 0.5, 1.0>, -1,  0,  1,  0,  0,  0,  0,  1,    // "T" Brun
<0.5, 1.0, 1.0>, -1, -1,  0, -1,  0,  0,  1,  0     // "Z" Cyan
];

Observez le I rouge dans le tableau qui suit :

tetrominossltris.png

Il est représenté dans la première ligne du tableau, ses coordonnées sont (0,-1); (0, 0); (0, 1) et (0, 2), et sa couleur est <1, 0, 0>. Donc :

<1.0, 0.0, 0.0>,  0, -1,  0,  0,  0,  1,  0,  2,    // "I" Rouge

Comment est représentée la grille et son contenu ?

La grille, c'est 200 cellules (ou cubes) rangées par vingt lignes de dix. Comme chaque cellule peut être vide ou "remplie" par un bout de piece, une chaîne de 200 caractère servira à représenter son contenu.

Par exemple, le tableau suivant montre une grille dans une situation analogue à celle du screenshot. Chaque emplacement occupé par un bout de piece est marqué par son index dans le tableau PIECES :

sltrisgameboard.png

Si on imagine toutes les vingt lignes bout à bout, on a meilleure idée de ce qui peut se trouver dans la chaîne de caractère gPlayGroundContent.

La logique du script, c'est quoi ?

En code traduit, la logique est, dans les grande lignes :

  • Descendre la piece, ou la déplacer suivant le précédent mouvement
  • Calculer la position suivante;
  • Si la position suivante est autorisée (pas d'obstacle), alors
    • On affiche la piece à la position suivante;
    • On retient l'ancienne position;
  • Sinon
    • On retrouve l'ancienne position;
    • On fige la piece dans la grille;
    • On place une nouvelle piece;
    • Si la nouvelle piece n'a plus de place, alors
      • On nettoie toute la grille;
      • On recommence du début...
  • On affiche la piece à la nouvelle position
  • On retient la position en cours : elle deviendra l'ancienne position
  • On choisit un mouvement au hasard;
  • Et c'est reparti pour un tour !

Variables, fonctions...

Variables

La position de la pièce en cours de descente est donnée par les variables globales gPieceX et gPieceY, en référence à notre tableau : gPieceX de 0 à 9, et gPieceY de 0 à 19. Cette position indique le centre de rotation de la pièce.

gPieceAngle contient un nombre de 0 à 3 pour définir la rotation de la pièce en cours : 0, 90, 180, 270 degrés.

gPieceCoordinates contient les coordonnées des quatres cubes qui composent la piece. La position de chacun des cubes est calculée en fonction de la rotation et de la position de la piece.

enfin, gPieceType est un chiffre de 0 à 6 qui donne le type de la pièce, conformément au tableau PIECES vu précédemment.

Le calcul de la position

Il est pris en charge par la fonction SetRotatedPieceParams. Le principe est de calculer les coordonnées de chacuns des petits cubes qui composent la piece. Connaissant gPieceX et gPieceY, il suffit d'y ajouter la position trouvée dans le tableau PIECE, en tenant compte de la rotation en cours.

SetRotatedPieceParams()
{
    gPieceCoordinates = [];

    integer i;
    for (i = 0; i < 8; i += 2)
    {
        integer OffsetX = llList2Integer(gPieceParams, i);
        integer OffsetY = llList2Integer(gPieceParams, i + 1);

        // Neutralise la rotation pour les pieces symétriques comme "O" qui ne tournent pas.
        // Ce genre de pieces est repéré par les premieres coordonnees toujours à 0,0
        if (i == 0)
            if (OffsetX == 0 && OffsetY == 0)
                gPieceAngle = 0;

        if (gPieceAngle == 0)
            gPieceCoordinates += [gPieceX + OffsetX, gPieceY + OffsetY];
        else if (gPieceAngle == 1)
            gPieceCoordinates += [gPieceX - OffsetY, gPieceY + OffsetX];
        else if (gPieceAngle == 2)
            gPieceCoordinates += [gPieceX - OffsetX, gPieceY - OffsetY];
        else
            gPieceCoordinates += [gPieceX + OffsetY, gPieceY - OffsetX];
    }
}
L'affichage d'une piece

Pour afficher une pièce, il suffit d'effacer la pièce à la position précédente, puis de l'afficher à la position suivante. Ici, cette partie a été optimisée pour restreindre les mises à jour inutiles. C'est DisplayMovedPiece qui s'en occupe.

// Affiche la piece en cours en effacant la position precedente
// On ne touche pas aux cellules qui "restent"
DisplayMovedPiece()
{
    list OldXYList = gPieceOldCoordinates;
    list NewXYList = gPieceCoordinates;

    integer Count = llGetListLength(OldXYList);

    integer i = 0;
    do
    {
        integer Idx = llListFindList(NewXYList, llList2List(OldXYList, i, i+1));
        if (Idx % 2)
        {
            i += 2;
        }
        else
        {
            OldXYList = llDeleteSubList(OldXYList, i, i+1);
            NewXYList = llDeleteSubList(NewXYList, Idx, Idx+1);
            Count -= 2;
        }
    }
    while(i < Count);

    // On efface seulement les cubes qui disparaissent...
    EraseCubes(OldXYList);
    // Pour n'afficher que les nouveaux
    DisplayCubes(NewXYList);
}

Au bout du compte, OldXYList contient la liste des coordonnées des cubes qui seront effacés, et NewXYList la liste des coordonnées des cubes qui seront affichés ! Il n'y a plus qu'à les transmettre à EraseCubes et DisplayCubes qui sont respectivement chargés d'effacer et d'afficher des cubes dont les coordonnées sont passés dans une liste en paramètre.

Ici, effacer un cube revient à le rendre entièrement invisible en lui donnant un alpha et une couleur neutre. A titre de variante vous pouvez rendre ce cube complètement transparent comme indiqué dans le code :

DisplayCubes(list coordinates)
{
    integer i; integer Count = llGetListLength(coordinates);
    for (i = 0; i < Count; i += 2)
        DisplayCube(llList2Integer(coordinates, i), llList2Integer(coordinates, i + 1), gPieceColor);
}

// Efface les cubes qui composent une piece
EraseCubes(list coordinates)
{
    integer i; integer Count = llGetListLength(coordinates);
    for (i = 0; i < Count; i += 2)
        EraseCube(llList2Integer(coordinates, i), llList2Integer(coordinates, i + 1));
}

EraseCube(integer x, integer y)
{
    integer Idx = y * PLAYGROUNDWIDTH + FIRSTCUBELINKNUMBER + x;
    llSetLinkColor(Idx, <1, 1, 1>, ALL_SIDES);
    llSetLinkAlpha(Idx, .25, ALL_SIDES);
    // Pour rendre le cibe complètement transparent, remplacer la ligne du haut par
    // llSetLinkAlpha(Idx, 0, ALL_SIDES);
    // et commentez la ligne llSetLinkColor(...
}

DisplayCube(integer x, integer y, vector color)
{
    integer Idx = y * PLAYGROUNDWIDTH + FIRSTCUBELINKNUMBER + x;
    llSetLinkColor(Idx, color, ALL_SIDES);
    llSetLinkAlpha(Idx, 1, ALL_SIDES);
}

Le script ! Le script !

Oui, vous êtes pressés. Nous y voilà. Si votre grille est prête :

  • Créer un nouveau script
  • Copier/coller dans le script le code ci-dessous
// Temporisation en seconde pour la chute d'une piece
float       INTERVALTIMERSTART           = 1.5;
float       INTERVALTIMERDEMO            = 0.5;

// Largeur et hauteur du terrain de jeu
integer     PLAYGROUNDWIDTH              = 10;
integer     PLAYGROUNDHEIGHT             = 20;

// Coordonnées relatives des carres occupes par les pieces par rapport au centre de rotation.
// Si 0,0 est specifie en premier, la piece ne doit pas tourner (le "O" par exemple")
list PIECES = [
<1.0, 0.0, 0.0>,  0, -1,  0,  0,  0,  1,  0,  2,    // "I" Rouge
<1.0, 1.0, 0.0>,  0, -1,  0,  0,  0,  1, -1,  1,    // "J" Jaune
<1.0, 0.0, 1.0>,  0, -1,  0,  0,  0,  1,  1,  1,    // "L" Magenta
<0.0, 0.0, 1.0>,  0,  0,  0,  1,  1,  0,  1,  1,    // "O" Bleu        <- Celle-ci ne tourne pas !
<0.0, 1.0, 0.0>, -1, -1, -1,  0,  0,  0,  0,  1,    // "S" Vert
<0.5, 0.5, 1.0>, -1,  0,  1,  0,  0,  0,  0,  1,    // "T" Brun
<0.5, 1.0, 1.0>, -1, -1,  0, -1,  0,  0,  1,  0     // "Z" Cyan
];

// Pieces
integer  gPieceType;
list     gPieceParams;
vector   gPieceColor;

// Caractéristiques de la piece en cours
integer  gPieceX;
integer  gPieceY;
integer  gPieceAngle;
list     gPieceCoordinates;

// Anciennes caractéristiques de la piece en cours
integer  gPieceOldX;
integer  gPieceOldY;
integer  gPieceOldAngle;
list     gPieceOldCoordinates;

// Grille
string   gPlayGroundContent;
string   gEmptyRow;

// Prisme plat
integer  FIRSTCUBELINKNUMBER             = 1;

DisplayCubes(list coordinates)
{
    integer i; integer Count = llGetListLength(coordinates);
    for (i = 0; i < Count; i += 2)
        DisplayCube(llList2Integer(coordinates, i), llList2Integer(coordinates, i + 1), gPieceColor);
}

// Efface les cubes qui composent une piece
EraseCubes(list coordinates)
{
    integer i; integer Count = llGetListLength(coordinates);
    for (i = 0; i < Count; i += 2)
        EraseCube(llList2Integer(coordinates, i), llList2Integer(coordinates, i + 1));
}

EraseCube(integer x, integer y)
{
    integer Idx = y * PLAYGROUNDWIDTH + FIRSTCUBELINKNUMBER + x;
    llSetLinkColor(Idx, <1, 1, 1>, ALL_SIDES);
    llSetLinkAlpha(Idx, .25, ALL_SIDES);
}

DisplayCube(integer x, integer y, vector color)
{
    integer Idx = y * PLAYGROUNDWIDTH + FIRSTCUBELINKNUMBER + x;
    llSetLinkColor(Idx, color, ALL_SIDES);
    llSetLinkAlpha(Idx, 1, ALL_SIDES);
}

// Initialise le jeu
InitGame()
{
    // Construit une ligne vide
    gEmptyRow = "";

    integer Idx;
    for (Idx = 0; Idx < PLAYGROUNDWIDTH; Idx += 1)
        gEmptyRow += "0";
}

// Initialise la grille
InitPlay()
{
    // Construit le playground
    gPlayGroundContent = "";

    integer Idx;
    for (Idx = 0; Idx < PLAYGROUNDHEIGHT; Idx += 1)
        gPlayGroundContent += gEmptyRow;

    RefreshPlayGround(0);
}

// Envoie un message sur le debug channel
Debug(list msg)
{
    llSay(DEBUG_CHANNEL, llList2CSV(msg));
}

// Affiche la piece en cours en effacant la position precedente
// On ne touche pas aux cellules qui "restent"
DisplayMovedPiece()
{
    list OldXYList = gPieceOldCoordinates;
    list NewXYList = gPieceCoordinates;

    integer Count = llGetListLength(OldXYList);

    integer i = 0;
    do
    {
        integer Idx = llListFindList(NewXYList, llList2List(OldXYList, i, i+1));
        if (Idx % 2)
        {
            i += 2;
        }
        else
        {
            OldXYList = llDeleteSubList(OldXYList, i, i+1);
            NewXYList = llDeleteSubList(NewXYList, Idx, Idx+1);
            Count -= 2;
        }
    }
    while(i < Count);

    // On efface seulement les cubes qui disparaissent...
    EraseCubes(OldXYList);
    // Pour n'afficher que les nouveaux
    DisplayCubes(NewXYList);
}

// Rotation
SetRotatedPieceParams()
{
    gPieceCoordinates = [];

    integer i;
    for (i = 0; i < 8; i += 2)
    {
        integer OffsetX = llList2Integer(gPieceParams, i);
        integer OffsetY = llList2Integer(gPieceParams, i + 1);

        // Neutralise la rotation pour les pieces symétriques comme "O" qui ne tournent pas.
        // Ce genre de pieces est repéré par les premieres coordonnees toujours à 0,0
        if (i == 0)
            if (OffsetX == 0 && OffsetY == 0)
                gPieceAngle = 0;

        if (gPieceAngle == 0)
            gPieceCoordinates += [gPieceX + OffsetX, gPieceY + OffsetY];
        else if (gPieceAngle == 1)
            gPieceCoordinates += [gPieceX - OffsetY, gPieceY + OffsetX];
        else if (gPieceAngle == 2)
            gPieceCoordinates += [gPieceX - OffsetX, gPieceY - OffsetY];
        else
            gPieceCoordinates += [gPieceX + OffsetY, gPieceY - OffsetX];
    }
}

// retourne TRUE si la la piece peut occuper la position suivante
integer IsPositionFree()
{
    integer i;
    for (i = 0; i < 8; i += 2)
    {
        integer X = llList2Integer(gPieceCoordinates, i);
        integer Y = llList2Integer(gPieceCoordinates, i + 1);

        if (X < 0  || Y < 0 || X >= PLAYGROUNDWIDTH || Y >= PLAYGROUNDHEIGHT)
            return FALSE;

        integer Idx = X + Y * PLAYGROUNDWIDTH;

        if (llGetSubString(gPlayGroundContent, Idx, Idx) != "0")
            return FALSE;
    }

    return TRUE;
}

// Valide la nouvelle position
StoreOldPosition()
{
    gPieceOldX = gPieceX;
    gPieceOldY = gPieceY;
    gPieceOldAngle = gPieceAngle;
    gPieceOldCoordinates = gPieceCoordinates;
}

// Invalide la nouvelle position
RetrieveOldPosition()
{
    gPieceX = gPieceOldX;
    gPieceY = gPieceOldY;
    gPieceAngle = gPieceOldAngle;
    gPieceCoordinates = gPieceOldCoordinates;
}

// Fige la piece dans sa position actuelle : elle ne bougera plus
FreezePiece()
{
    string Piece = (string) (gPieceType + 1);
    integer i;
    for (i = 0; i < 8; i += 2)
    {
        integer Idx = llList2Integer(gPieceCoordinates, i) + llList2Integer(gPieceCoordinates, i + 1) * PLAYGROUNDWIDTH;
        gPlayGroundContent = llDeleteSubString(llInsertString(gPlayGroundContent, Idx, Piece), Idx + 1, Idx + 1);
    }
}

// Redessine la grille à partir de la ligne rowStart
RefreshPlayGround(integer rowStart)
{
    integer X; integer Y;
    for (X = 0; X < PLAYGROUNDWIDTH; X += 1)
        for (Y = rowStart; Y < PLAYGROUNDHEIGHT; Y += 1)
        {
            integer Idx = X + Y * PLAYGROUNDWIDTH;
            integer CodeCube = (integer) llGetSubString(gPlayGroundContent, Idx, Idx);

            if (CodeCube == 0)
                EraseCube(X,Y);
            else
                DisplayCube(X,Y, llList2Vector(PIECES, (CodeCube - 1) * 9));
        }
}

// Retourne un nombre aleatoire entre min et max
integer Random(integer min, integer max)
{
    return (integer) llFrand((float) max + 1.0 - (float) min) + min;
}

// Affiche une nouvelle piece
PlaceNewPiece()
{
    gPieceType = Random(0, llGetListLength(PIECES) / 9 - 1);
    gPieceX = Random(4, PLAYGROUNDWIDTH - 4);
    gPieceY = 2;
    gPieceAngle = Random(0, 3);

    gPieceParams = llList2List(PIECES, gPieceType * 9 + 1, gPieceType * 9 + 8);
    gPieceColor = llList2Vector(PIECES, gPieceType * 9);
    SetRotatedPieceParams();
}

// Apres le deplacement d'un joueur
AfterPlayerMove()
{
    SetRotatedPieceParams();

    if (IsPositionFree())
    {
        DisplayMovedPiece();
        StoreOldPosition();
    }
    else
    {
        RetrieveOldPosition();
    }
}

integer gDemoMove;

default
{
    state_entry()
    {
        InitGame();
        state demo;
    }
}

state demo
{
    state_entry()
    {
        InitPlay();

        PlaceNewPiece();
        DisplayCubes(gPieceCoordinates);
        StoreOldPosition();

        llSetTimerEvent(INTERVALTIMERDEMO);
    }

    timer()
    {
        gDemoMove = !gDemoMove;

        if (gDemoMove)
        {
            gPieceY += 1;
            SetRotatedPieceParams();

            if (IsPositionFree())
            {
                DisplayMovedPiece();
                StoreOldPosition();
            }
            else
            {
                RetrieveOldPosition();
                FreezePiece();

                PlaceNewPiece();

                if (!IsPositionFree())
                {
                    llSetTimerEvent(0);
                    InitPlay();
                    PlaceNewPiece();
                    llSetTimerEvent(INTERVALTIMERDEMO);
                }

                DisplayCubes(gPieceCoordinates);
                StoreOldPosition();
            }
        }
        else
        {
            integer Move = Random(0, 4);

            if (Move == 0)
                gPieceX += 1;
            else if (Move == 1)
                gPieceX -= 1;
            else if (Move == 2)
                gPieceY += 1;
            else if (++gPieceAngle > 3)
                gPieceAngle = 0;

            AfterPlayerMove();
        }
    }

    state_exit()
    {
        llSetTimerEvent(0);
    }
}

Vous y voilà ! Aussitôt le script compilé et sauvegardé, regardez votre Tetris executer une démo. La grille est effacée, puis les pieces commencent à tomber, à tourner, puis à s'empiler jusqu'en haut. Une fois le haut de la grille atteint, la grille est de nouveau effacée et la démo recommence...

Nous verrons dans la deuxième et dernière partie de Construisez votre Tetris dans Second Life, comment ajouter les contrôles au clavier, la détection des lignes pleines et comment décaler les briques vers le bas.


Si vous êtes pressés, vous pouvez acheter SLTRis 3D en version complète sur SLExchange pour 250 malheureux L$. Attention: il utilise quand même 201 prims. En revanche, cette version affiche les 5 premiers scores, et distribue des cadeaux aux meilleurs !

Si 201 prims vous font peur, il existe une version plate : SLTRis 2D, qui ne prend que 41 prims, multilangue, avec le code source entièrement disponible !
Si cette version spéciale vous interesse, vous la trouverez ici, sur SLExchange, pour 300 pauvres L$. Hormis l'affichage un peu spécial (200 cellules pour 41 prims, c'est un petit miracle technique...), le moteur de jeu est rigoureusement identique à celui de SLTRis 3D.