Kuidas luua vaheklippe (cutscene)

Sissejuhatus

Vaheklipid (cutscene) katkestavad mängu tavapärase kulgemise, et näidata lugu, tutvustada tegelasi või esitleda sündmusi. Need võivad olla täielikult automaatsed või osaliselt interaktiivsed.

Näide: Mängija jõuab lossi väravani, kus algab automaatne stseen: kaamera liigub aeglaselt üles, NPC ilmub, räägib midagi ja väravad avanevad.

Vaheklipid sobivad:

  • Oluliste süžeepunktide tutvustamiseks

  • Tegelaste arenguks või suhte loomiseks

  • Mängumaailma selgitamiseks ilma mängijaga otsese interaktsioonita

Vaheklipi loomise etapid

Selles peatükis vaatleme vaheklippide loomise põhimõtteid, tuginedes järgmisele näitele:

Näide vaheklipist

Selle vaheklippi saab jagada mitmeks etapiks:

  • Suumi kaamerat sujuvalt sisse.

  • Oota 1 sekund.

  • Alusta tegelase liikumist NPC poole.

  • Oota 1 sekund pärast liikumise lõppu.

  • Suumi kaamerat välja ja taasta algne asend.

Üldloogika

Vaheklipp koosneb selgelt määratletud etappidest, mida hallatakse olekumasina (state machine) abil. Igal hetkel on vaheklipp ühes kindlas olekus ja täidab vastavat tegevust: suumib kaamerat, ootab, liigutab tegelast või taastab algasendi. See tagab hästi juhitud ja etteaimatava kulgemise.

Kuidas seda teha?

Kõigepealt defineerime võimalikud olekud, mida vaheklipp võib läbida. Iga olek vastab konkreetsele tegevusele või ajutisele pausile.

private enum State {
    ZOOMING_IN,
    WAIT_BEFORE_MOVE,
    MOVING_TO_NPC,
    WAIT_AFTER_MOVE,
    ZOOMING_OUT,
    FINISHED
}

private State state = State.ZOOMING_IN;

Vaikimisi alustame ZOOMING_IN -olekust, kus toimub sujuv kaamera suumimine. Iga olek täidab oma eesmärgi ja seejärel liigub edasi järgmisesse. Näiteks pärast suumimist (ZOOMING_IN) alustame ajutist pausi (WAIT_BEFORE_MOVE), et anda kasutajale visuaalne hetk sündmuse tajumiseks. Kui see aeg läbi saab, liigutatakse tegelast (MOVING_TO_NPC) ja nii edasi. Kui olek jõuab FINISHED-ni, märgime vaheklipi lõppenuks.

Selline struktuur muudab loogika kergesti hallatavaks.

public void update(float delta) {
    switch (state) {
        case ZOOMING_IN:
            zoomIn(delta);
            break;

        case WAIT_BEFORE_MOVE:
            waitTimer -= delta;
            if (waitTimer <= 0) {
                state = State.MOVING_TO_NPC;
            }
            break;

        case MOVING_TO_NPC:
            moveCharacter(delta);
            break;

        case WAIT_AFTER_MOVE:
            waitTimer -= delta;
            if (waitTimer <= 0) {
                state = State.ZOOMING_OUT;
            }
            break;

        case ZOOMING_OUT:
            returnCameraToOriginal(delta);
            break;

        case FINISHED:
            isFinished = true;
            break;
    }

    clampCameraToBounds();
    camera.update();
}

Kaamera suum

Vaheklipi visuaalse mõju suurendamiseks on oluline suumida kaamerat tegevusele lähemale ja seejärel tagasi algasendisse. Alguses toimub sujuv suum sisse (zoom-in), et rõhutada näiteks dialoogi või olulist sündmust. Kui vaheklipp on lõppenud, siis kaamera suumib tagasi ja taastab oma algse asukoha.

Kuidas seda teha?

Kaamera suumi loogika koosneb kahest osast: suum sisse (zoomIn()) ja suum välja (returnCameraToOriginal()).

Funktsioon zoomIn() vähendab kaamera suumi sujuvalt kuni määratud TARGET_ZOOM väärtuseni. Suumimist piiratakse MathUtils.clamp() abil, et vältida ülemäärast lähenemist. Samal ajal võib kaamera jälgida tegelase liikumist (followCharacter()). Kui sihtsuurendus on saavutatud, käivitub ajutine paus.

private void zoomIn(float delta) {
    if (camera.zoom > TARGET_ZOOM) {
        camera.zoom -= ZOOM_SPEED * delta;
        camera.zoom = MathUtils.clamp(camera.zoom, TARGET_ZOOM, originalZoom);
        followCharacter(delta);
    } else {
        waitTimer = 1f;
        state = State.WAIT_BEFORE_MOVE;
    }
}

Funktsioon returnCameraToOriginal() liigutab kaamera tagasi algasendisse. Kaamera liigub ja suum muutub aeglaselt, kuni need jõuavad algväärtusteni. Kui kaamera on piisavalt lähedal algasendile, pannakse ta täpselt paika ja vaheklipp saab läbi (FINISHED).

private void returnCameraToOriginal(float delta) {
    camera.position.x += (originalCameraPosition.x - camera.position.x) * 2f * delta;
    camera.position.y += (originalCameraPosition.y - camera.position.y) * 2f * delta;
    camera.zoom += (originalZoom - camera.zoom) * 2f * delta;

    if (Math.abs(camera.position.x - originalCameraPosition.x) < 0.5f &&
        Math.abs(camera.position.y - originalCameraPosition.y) < 0.5f &&
        Math.abs(camera.zoom - originalZoom) < 0.01f) {
        camera.position.set(originalCameraPosition.x, originalCameraPosition.y, 0);
        camera.zoom = originalZoom;
        state = State.FINISHED;
    }
}

Kaamera korrektne asendamine

See funktsioon tagab, et kaamera ei liiguks mängumaailma piiridest välja. Kui kaamera liigub liiga kaugele, piiratakse tema asukoht nii, et kogu vaade jääks mänguala sisse.

Kuidas seda teha?

Funktsioon arvutab nähtava ala suuruse vastavalt suumile ja veendub, et kaamera keskpunkt ei liiguks väljapoole maailma piire.

Siin MathUtils.clamp() piirab kaamera x ja y koordinaate nii, et need ei läheks maailmaruumist välja, arvestades suumi tõttu muutunud vaateulatust.

private void clampCameraToBounds() {
    float viewportWidth = camera.viewportWidth * camera.zoom;
    float viewportHeight = camera.viewportHeight * camera.zoom;
    float halfWidth = viewportWidth / 2f;
    float halfHeight = viewportHeight / 2f;

    camera.position.x = MathUtils.clamp(camera.position.x, halfWidth, WORLD_WIDTH - halfWidth);
    camera.position.y = MathUtils.clamp(camera.position.y, halfHeight, WORLD_HEIGHT - halfHeight);
}

Tegelase liigutamine

Selles etapis liigub tegelane etteantud punkti suunas (näiteks NPC juurde). Liikumine toimub sujuvalt ning kaamera järgib tegelast. Kui sihtpunkt on saavutatud, läheb seisund järgmisele sammule.

Kuidas seda teha?

Kontrollime, kas tegelane on jõudnud sihtkohta. Kui ei ole, liigutame teda edasi ja paneme kaamera teda jälgima. Kui on, peatame liikumise.

Siin liigutatakse tegelast paremale kuni ta jõuab NPC lähedusse. Kui kaugus on väiksem kui 60 ühikut, peetakse liikumine lõppenuks. Samal ajal uuendatakse liikumissuunda (facingLeft) ja pannakse kaamera tegelast jälgima.

private void moveCharacter(float delta) {
    if (characterPosition.x < npcCollider.x - 60f) {
        characterPosition.x += MOVE_SPEED * delta;
        facingLeft = false;
        moving = true;
        followCharacter(delta);
    } else {
        moving = false;
        waitTimer = 1f;
        state = State.WAIT_AFTER_MOVE;
    }
}

Kaamera liikumine tegelase järel

Selleks, et vaheklipp oleks visuaalselt sujuv ja tähelepanu oleks tegelase tegevusel, peab kaamera liikuma koos tegelasega. Kaamera ei hüppa kohe õigesse kohta, vaid liigub järk-järgult, et tekitada sujuv efekt.

Kuidas seda teha?

Arvutame soovitud kaamerapositsiooni, mis asub tegelasest veidi kõrgemal ja keskel. Seejärel nihutame kaamerat järk-järgult selle positsiooni suunas.

Siin targetX ja targetY määravad kaamera sihtpositsiooni, mis on veidi nihkes, et hoida tegelane kaadri keskel. Kaamera liigub selle poole sujuvalt ajas, korrutades erinevuse delta ja kiirusteguriga (2f).

private void followCharacter(float delta) {
    float targetX = characterPosition.x + 32;
    float targetY = characterPosition.y + 64;
    camera.position.x += (targetX - camera.position.x) * 2f * delta;
    camera.position.y += (targetY - camera.position.y) * 2f * delta;
}

Mänguloogika vaheklipi ajal

Mängija ei tohi vaheklipi ajal tegelase üle kontrolli omada.

Vaheklipi ajal peab kogu tegevus olema eelnevalt määratud – mängija ei tohi sekkuda ega tegelast liigutada. See aitab säilitada narratiivi voolavust ja tagab, et kõik toimuks soovitud järjekorras.

Kuidas seda teha?

Selleks tuleb mänguloogikas kontrollida, kas vaheklipp on lõppenud. Kui ei ole, siis käivitatakse ainult vaheklipi uuendamine. Alles pärast vaheklipi lõppu lubatakse kasutajal tegelase üle taas kontroll.

Siin kontrollitakse cutsceneFinished muutujat – kui vaheklipp pole lõppenud, uuendatakse ainult selle loogikat. Kui see on läbi, aktiveeritakse handleInput(), mis taastab mängija kontrolli.

public void render() {
    Gdx.gl.glClearColor(0, 0, 0, 1);
    Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

    ...

    if (!cutsceneFinished) {
        cutscene.update(delta);
        moving = cutscene.isMoving();
        facingLeft = cutscene.isFacingLeft();
        if (cutscene.isFinished()) {
            cutsceneFinished = true;
        }
    } else {
        handleInput();
    }

    ...
}

Võimalikud täiendused

Vaheklipi vahelejätmine

Mõnikord soovib mängija vaheklipi vahele jätta, eriti kui ta on seda juba varem näinud. Selleks saab lisada nupu või klahvi, millele vajutades vaheklipp katkestatakse ja mäng jätkub kohe.

Kuidas seda teha?

Kontrolli iga kaadri ajal, kas mängija vajutas kindlat klahvi (näiteks SPACE või Enter). Kui jah, siis vii vaheklipp kohe lõppseisundisse ja taasta mänguloogika.

Sellisel viisil saab mängija igal hetkel vaheklipi katkestada ja mäng jätkub justkui vaheklipp oleks lõpuni esitatud.

public void update(float delta) {
    if (Gdx.input.isKeyJustPressed(Input.Keys.ENTER)) {
        skipCutscene();
        return;
    }

    switch (state) {
        ...
    }

    ...
}

private void skipCutscene() {
    camera.position.set(originalCameraPosition.x, originalCameraPosition.y, 0);
    camera.zoom = originalZoom;
    isFinished = true;
    state = State.FINISHED;
}

Dialoogid vaheklippides

Vaheklippides saab kuvada dialooge, et edasi anda loo infot või tegelastevahelist suhtlust.

Kuidas seda teha?

Lisa dialoogitekstide hoidja ja kuvamisloogika. Dialoogide vahetamiseks kasuta ajastust või mängija sisendit (nt. klahvivajutust).

private String[] dialogues = {
    "Tere tulemast, kangelane!",
    "Mul on sulle tähtis ülesanne.",
    "Ole ettevaatlik teel."
};

private int currentDialogueIndex = 0;
private boolean waitingForInput = false;

public void update(float delta) {
    if (waitingForInput) {
        if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE)) {
            currentDialogueIndex++;
            if (currentDialogueIndex >= dialogues.length) {
                waitingForInput = false;
                state = State.NEXT_CUTSCENE_PHASE; // või muu sobiv staatus
            }
        }
    } else {
        // muu vaheklipi loogika
        // kui jõuad dialoogi faasi, siis:
        waitingForInput = true;
        displayDialogue(dialogues[currentDialogueIndex]);
    }
}

private void displayDialogue(String text) {
    // siin kuva tekst ekraanile, nt kasutades SpriteBatch ja BitmapFont
}

Heliefektid

Heliefektide lisamine aitab muuta vaheklipi elavamaks ja meeleolukamaks.

Kuidas seda teha?

Laadi helifailid mängu sisse, käivita need soovitud hetkel ja vajadusel sulge või peata, kui vaheklipp lõpeb.

private Sound stepSound;

public void loadSounds() {
    stepSound = Gdx.audio.newSound(Gdx.files.internal("step.wav"));
}

private void playStepSound() {
    stepSound.play(1.0f); // helitugevus 1.0 = maksimaalne
}

public void update(float delta) {
    ...
    switch (state) {
        case MOVING_TO_NPC:
            moveCharacter(delta);
            if (moving) {
                playStepSound();
            }
            break;
        ...
    }
    ...
}

public void dispose() {
    stepSound.dispose();
}

Lähtekood

  • Vaata näidisprojekti GitHubis: GitHub