Mängitavuse parandamine ja disainitrikid (Game Feel)

Mängu arendamisel tehakse sageli viga, kus mehaanikaid realiseeritakse puhtalt matemaatilise ja füüsikalise täpsusega. Praktikas võib liiga range reeglite järgimine muuta mängu kohmakaks või tekitada olukordi, kus mängija tunneb, et mäng ei reageerinud tema tegevusele õigesti. Selleks, et mäng tunduks dünaamiline ja alluks hästi kontrollile, kasutavad mängudisainerid varjatud tehnikaid, mis mängijale märkamatult "kaasa aitavad".

Käesolevas peatükis vaatleme nelja levinud disainitrikki ja nende loogilist realiseerimist LibGDX raamistikus.

1. Coyote Time

Nimetus pärineb tuntud joonisfilmist, kus tegelane jookseb üle kaljuserva, kuid ei kuku kohe alla, vaid jääb hetkeks õhku rippuma.

Probleem

Platvormerites juhtub tihti, et mängija vajutab hüppenuppu mõni millisekund pärast seda, kui tegelase kokkupõrkeala (collider) on platvormi servalt lahkunud. Mängu loogika järgi on tegelane juba õhus ning hüpet ei lubata enam sooritada. Mängijale tundub see aga sisendi hilinemisena või mängu veana ("Aga ma ju vajutasin!").

Lahendus

Tegelasele antakse lühike "andestusaeg" (tavaliselt umbes 0.1–0.15 sekundit) pärast platvormilt lahkumist, mille jooksul on hüppamine õhust endiselt lubatud.

Realiseerimine loogikas

Selle asemel, et kontrollida hüppel ainult maapinna olekut, juurutatakse taimer, mis loendab aega pärast platvormilt lahkumist.

private float coyoteTimer = 0f;
private final float COYOTE_DURATION = 0.12f; // Andestusaeg sekundites

public void update(float delta) {
    if (player.isGrounded()) {
        coyoteTimer = COYOTE_DURATION; // Platvormil olles on taimer täis
    } else {
        coyoteTimer -= delta; // Õhus olles hakkab aeg jooksma
    }
    // Hüppe sisendi kontroll
    if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE)) {
        if (coyoteTimer > 0f) {
            player.jump();
            coyoteTimer = 0f; // Ennetame topelthüpet õhust
        }
    }
}

2. Input Buffering

Sisendi puhverdamine tagab, et mängija käsud ei lähe kaduma olukordades, kus tegelane on parajasti hõivatud teise animatsiooni või tegevusega.

Probleem

Mängija langeb suurelt kõrguselt ja vajutab hüppenuppu näiteks 0.05 sekundit enne maapinnale maandumist. Kuna maandumise hetkel on tegelane veel tehniliselt õhus, siis vajutust ei arvestata. Tegelane maandub, kuid hüpet ei toimu, mis võib mängijale tunduda ebaõiglane või kohmakas.

Lahendus

Kui hüppenuppu vajutatakse vahetult enne maandumist (või praeguse rünnaku animatsiooni lõppu), salvestatakse see sisend lühiajalisse puhvrisse. Niipea kui tegelane puudutab maad, loetakse puhvrist käsk ja hüpe sooritatakse viivituseta.

Realiseerimine loogikas

Sisendi olekut kontrollitakse pidevalt ning nupuvajutus jäetakse meelde määratud ajaks, mis kaadriuuenduse käigus väheneb.

private float jumpBufferTimer = 0f;
private final float BUFFER_DURATION = 0.15f; // Kui kaua vajutust meeles peetakse

public void update(float delta) {
    // Kui nuppu vajutatakse, aktiveeritakse puhvri taimer
    if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE)) {
        jumpBufferTimer = BUFFER_DURATION;
    } else {
        jumpBufferTimer -= delta;
    }

    // Kontrollime, kas tegelane on maas JA puhvris on aktiivne hüppesoov
    if (player.isGrounded() && jumpBufferTimer > 0f) {
        player.jump();
        jumpBufferTimer = 0f; // Puhver tühjendatakse pärast täitmist
    }
}

3. Tolerantsed kokkupõrkealad (Hurtboxes vs Hitboxes)

Mängija hindab kaugusi ja kokkupõrkeid selle järgi, mida ta ekraanil näeb, mitte nähtamatute kokkupõrkealade järgi. Seda eripära tuleb kokkupõrgete tuvastamisel arvesse võtta.

Probleem

Mängudes nagu Flappy Bird või tulistamismängudes (bullet hell) tähendab mängija kokkupõrkeala ja takistuse vaheline kasvõi ühe piksli suurune kattuvus kohest kaotust. Kui tegelase kokkupõrkeraam ühtib täpselt pildiga, satub mängija olukorda, kus ta visuaalselt arvas end takistusest mööda lendavat, kuid mäng registreeris surma.

Lahendus

Mängu disainimisel eristatakse kahte tüüpi raame:

1. Hurtbox: Mängija ala, mis võtab kahjustusi vastu. Sageli tehakse see visuaalsest tegelasest veidi väiksemaks, et vältida ebaõiglasi tabamusi.

2. Hitbox / Obstacle Box: Takistuse ala. Selle nurki võib nihutada sissepoole või kasutada teravate nurkade asemel ümaramaid kokkupõrkealasid, mis võimaldab tegelasel takistuse servast "mööda libiseda", mitte järsult peatuda.

Realiseerimine loogikas

Selle asemel, et luua kokkupõrkeala otse tegelase pildi täissuuruses, jäetakse äärtest turvaala, nihutades ristküliku alguspunkti ja vähendades selle mõõtmeid.

// Tavaline lähenemine (liiga range):
// Rectangle playerCollider = new Rectangle(x, y, sprite.getWidth(), sprite.getHeight());

// Tolerantne lähenemine (andestav):
float paddingX = 12f; // 12 pikslit igast küljest sisestust
float paddingY = 8f;

Rectangle playerHurtbox = new Rectangle(playerX + paddingX,
                                        playerY + paddingY,
                                        sprite.getWidth() - (paddingX * 2),
                                        sprite.getHeight() - (paddingY * 2));

// Kokkupõrke kontroll toimub nüüd muudetud Hurtboxi baasil
if (obstacleCollider.overlaps(playerHurtbox)) {
    game.playerDied();
}

4. Diagonaalse liikumise normaliseerimine (Vector Normalization)

Probleem

Top-down (ülaltvaates) või isomeetrilistes 2D mängudes liigub tegelane sageli neljas suunas: nooleklahvidega või WASD abil. Kui mängija vajutab korraga kahte klahvi (näiteks UP ja RIGHT), liigub tegelane diagonaalis. Kui koodis liidetakse x- ja y-telje kiirused lihtsalt kokku, siis tekib diagonaalne liikumisvektor, mille pikkus on √2 korda suurem kui ühe telje kiirus, ning hakkab tegelane diagonaalis liikuma umbes 41% kiiremini kui otse liikudes. See rikub mängu tasakaalu, sest diagonaalis jooksmine muutub ebaloomulikult kiireks ja efektiivseks.

Lahendus

Sisendsuunad tuleb koondada üheks kahemõõtmeliseks vektoriks (suunavektoriks). Kui selle vektori pikkus on suurem kui 1, siis vektor normaliseeritakse (selle pikkus viiakse väärtuseni 1), säilitades samal ajal liikumissuuna. LibGDX raamistikus on selleks olemas sisseehitatud klass Vector2 ja meetod nor().

Realiseerimine loogikas

Enne tegelase asukoha muutmist arvutatakse sisendi kogusuund ja normaliseeritakse see, tagades ühtlase liikumiskiiruse igas suunas.

Vector2 movement = new Vector2(0, 0);

// Kogume sisendid suunavektorisse
if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) movement.x = -1;
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) movement.x = 1;
if (Gdx.input.isKeyPressed(Input.Keys.UP)) movement.y = 1;
if (Gdx.input.isKeyPressed(Input.Keys.DOWN)) movement.y = -1;

// Kui liigutakse diagonaalis, on vektori pikkus suurem kui 1
if (movement.len() > 1.0f) {
    movement.nor(); // Normaliseerimine: muudab vektori pikkuse üheks, säilitades suuna
}

// Rakendame kiiruse ja delta aja
playerX += movement.x * SPEED * Gdx.graphics.getDeltaTime();
playerY += movement.y * SPEED * Gdx.graphics.getDeltaTime();

Kokkuvõte

Oluline on mõista, et mängitavuse parandamise tehnikad ei muuda mängu tingimata realistlikumaks. Vastupidi – sageli tehakse mängija kasuks väikeseid erandeid, et juhtimine tunduks loomulikum ja õiglasem. Mängija ei märka tavaliselt nende süsteemide olemasolu, kuid märkab kohe nende puudumist. Nende nelja mehaanika lisamine ei nõua keerulist matemaatikat ega muuda projekti arhitektuuri koormavaks, kuid määrab suuresti selle, kui sujuv ja kvaliteetne mäng mängijale tundub.