Kruhy v Jave

Raz som na webe našiel otázku: Ako v Jave nakresliť kruhy, ktoré sa dajú posúvať pomocou myši? Pôvodná otázka bola o čosi zložitejšia - autor chcel vytvoriť akýsi editor diagramov - ale toto bola podstata.

Priamo v Jave neexistuje žiaden grafický komponent typu "kruh", ktorý by sme si mohli vytvoriť, vložiť do dialógového okna, a nastaviť mu ťahanie myšou. (Prinajmenšom nie vo verzii Java SE 1.7.) Azda by sme si ho mohli vytvoriť, ale na jednoduchý program to nepotrebujeme. Stačí si do okna vložiť kresliacu plochu, a do nej potom kresliť kruhy príkazom "drawOval".

Ako ich budeme ťahať myšou? Pri stlačení tlačidla myši pozrieme, či kurzor ukazuje na niektorý z kruhov. Ak áno, zapamätáme si ho, a potom pri každom pohybe myši pohneme o rovnaký vektor aj daný kruh, až do pustenia tlačidla myši. Ako zistíme, či je kurzor myši v niektorom z kruhov? Pytagorova veta; x,y kliknutého bodu, x,y stredu kruhu, polomer kruhu.

Pridajme pár technických detailov a uvedomil som si, že toto je ten typ programu, ktorý sa najlepšie vysvetľuje tak, že ho najprv celý napíšete, pretože inak ho pri vysvetľovaní aj tak musíte prakticky celý napísať. A keď ho napíšete vopred, máte istotu, že ste na nič podstatné nezabudli, a že nemáte nikde preklep.

Napísať program od podlahy (bez kopírovania existujúceho kódu alebo používania iných než štandardných knižníc) mi trvalo asi pätnásť minút, a bola to celkom fajn rozcvička ukazujúca, že už mám v Jave pekných pár kilometrov najazdených. Pred zverejnením som program upravil iba mierne: 1) kód na vytvorenie okna, ktorý bol pôvodne v samostatnej funkcii, som vložil priamo do funkcie "main", čím sa zrušilo sedem zbytočných riadkov; 2) prehľadnejšie som pomenoval premenné súvisiace s ťahaním kruhu; a 3) pridal som pár riadkov na zobrazenie programu vo webovej stránke, aby som ho mohol vložiť priamo do tejto stránky. Zvyšok viacmenej zostal taký, ako som ho za tých pätnásť minút napísal v snahe mať to čo najskôr hotové.

DOPLNENÉ: Dodatočne som zistil, že v programe bola chyba, ktorá spôsobila, že sa okno aplikácie niekedy zobrazilo úzke, bez obsahu. Chyba je už opravená, vysvetlenie je nižšie.

Keby to mal byť nejaký ukážkový kód, zrejme by som tam pár vecí pomenil: Niektoré časti kódu by som urobil ako samostatné funkcie, aby som nemal kód odsadený cez pol obrazovky (podmienka v podmienke v metóde v anonymnej triede v metóde v triede v triede). A určite by som nemal všetko v jednej triede: rozdelil by som to na viacero tried a tie možno na viacero balíkov; oddelil by som dvojité vykresľovanie od samotnej logiky vykresľovania; možno by som spočítal, ktoré časti obrazovky stačí pri každej zmene prekresliť, aby to bolo rýchlejšie; zaviedol by som hierarchiu objektov, aby sa okrem kruhov dali vykresľovať aj iné objekty; upravil by som kód, aby sa po kliknutí na prekrývajúce sa objekty vymazal ten horný a nie ten dolný... a výsledný kód by bol potom dvakrát či trikrát taký dlhý, a veru neviem, či by to niektorého čitateľa potešilo. Nechajme to teda tak, ako to je, však to nie je až také hrozné. A keď je všetko v jednej triede, aspoň si to rýchlejšie skopírujete z webu.

package sk.bur.blog;

import java.applet.Applet;
import java.awt.*;
import java.awt.event.*;
import java.util.*;

public class Kruhy {

	public static void main(String[] args) {
		SwingUtilities.invokeLater(new Runnable() {

			@Override
			public void run() {
				Obraz obraz = new Obraz();
				obraz.init();
				final Frame okno = new Frame("Kruhy");
				okno.add(obraz);
				okno.setResizable(false);
				okno.pack();
				okno.addWindowListener(new WindowAdapter() {

					@Override
					public void windowClosing(WindowEvent e) {
						okno.dispose();
					}

				});
				okno.setVisible(true);

		});
	}

	public static class KruhyApplet extends Applet {

		@Override
		public void init() {
			SwingUtilities.invokeLater(new Runnable() {

				@Override
				public void run() {
					Obraz obraz = new Obraz();
					obraz.init();
					add(obraz);
				}

			});
		}

	}

	static class Obraz extends Canvas {

		private Collection<Kruh> kruhy = new ArrayList<Kruh>();
		Kruh tahanyKruh = null;
		int tahanyPovodnyStredX;
		int tahanyPovodnyStredY;
		int tahanyZaciatokX;
		int tahanyZaciatokY;
		Random nahoda = new Random();
		private Image buffer = null;

		Kruh najdiKruh(int x, int y) {
			for (Kruh kruh : kruhy) {
				if (kruh.obsahuje(x, y)) {
					return kruh;
				}
			}
			return null;
		}

		void init() {
			setSize(400, 300);
			addMouseListener(new MouseAdapter() {

				@Override
				public void mousePressed(MouseEvent e) {
					Kruh kliknutyKruh = najdiKruh(e.getX(), e.getY());
					if (MouseEvent.BUTTON1 == e.getButton()) {
						// zacni tahat
						if (null != kliknutyKruh) {
							tahanyKruh = kliknutyKruh;
							tahanyPovodnyStredX = tahanyKruh.stredX;
							tahanyPovodnyStredY = tahanyKruh.stredY;
							tahanyZaciatokX = e.getX();
							tahanyZaciatokY = e.getY();
						}
					}
					if (MouseEvent.BUTTON3 == e.getButton()) {
						// pridaj alebo odober kruh
						if (null != kliknutyKruh) {
							kruhy.remove(kliknutyKruh);
						} else {
							Kruh kruh = new Kruh();
							kruh.stredX = e.getX();
							kruh.stredY = e.getY();
							kruh.polomer = 10 + nahoda.nextInt(10);
							// nejake pekne farby
							kruh.farbaVyplne = Color.getHSBColor(
							  nahoda.nextFloat(),
							  (1 + nahoda.nextFloat()) / 2,
							  (1 + nahoda.nextFloat()) / 2
							);
							kruhy.add(kruh);
						}
						repaint();
					}
				}

				@Override
				public void mouseReleased(MouseEvent e) {
					if (MouseEvent.BUTTON1 == e.getButton()) {
						// prestan tahat
						tahanyKruh = null;
					}
				}

			});
			addMouseMotionListener(new MouseMotionAdapter() {

				@Override
				public void mouseDragged(MouseEvent e) {
					// tahanie
					if (null != tahanyKruh) {
						tahanyKruh.stredX =
						  tahanyPovodnyStredX - tahanyZaciatokX + e.getX();
						tahanyKruh.stredY =
						  tahanyPovodnyStredY - tahanyZaciatokY + e.getY();
						repaint();
					}
				}

			});
		}

		@Override
		public void paint(Graphics g) {
			if (null == buffer) {
				buffer = createImage(getWidth(), getHeight());
			}
			Graphics g2 = buffer.getGraphics();
			g2.setColor(Color.WHITE);
			g2.fillRect(0, 0, getWidth(), getHeight());
			for (Kruh kruh : kruhy) {
				g2.setColor(kruh.farbaVyplne);
				g2.fillOval(
				  kruh.stredX - kruh.polomer, kruh.stredY - kruh.polomer,
				  2 * kruh.polomer, 2 * kruh.polomer
				);
			}
			g.drawImage(buffer, 0, 0, null);
		}

		@Override
		public void update(Graphics g) {
			// vykreslujeme celu obrazovku
			paint(g);
		}

	}

	static class Kruh {

		public int stredX;
		public int stredY;
		public int polomer;
		public Color farbaVyplne;

		int naDruhu(int i) {
			return i * i;
		}

		boolean obsahuje(int x, int y) {
			return naDruhu(x - stredX) + naDruhu(y - stredY) <= naDruhu(polomer);
		}

	}

}

PRAVÉ tlačidlo myši vytvorí alebo vymaže kruh.
ĽAVÉ tlačidlo myši ťahá kruh.

Java applet s kruhmi.

Stiahnite si program, vrátane zdrojového kódu.

A teraz si postupne prejdime jednotlivé časti programu.

Program sa skladá zo štyroch neanonymných tried. Hlavná trieda "Kruhy" obsahuje metódu "main", pomocou ktorej sa program spúšťa (kliknutím na ikonu programu alebo z príkazového riadku). Trieda "KruhyApplet" umožňuje spustiť program ako applet, čiže ako časť webovej stránky. Trieda "Obraz" predstavuje plochu, na ktorej sa zobrazujú kruhy, a v rámci ktorej tieto kruhy môžeme ťahať. (V prípade appletu je to celá plocha appletu, ale v prípade aplikácie by okolo tejto plochy ešte bol rám okna.) Trieda "Kruh" obsahuje údaje o jednom kruhu: poloha stredu, polomer, farba. Ďalej máme dve anonymné triedy na zavretie okna a spracovanie činnosti myši.

Metóda "main" a trieda "KruhyApplet" slúžia na spustenie programu v rôznych prostrediach: ako aplikácia na počítači alebo ako applet na webovej stránke. Použije sa jedno alebo druhé, nikdy nie obidvoje zároveň. Vložil som ich však do jedného programu ako ukážku, aké ľahké je v Jave vytvoriť program, ktorý beží aj ako aplikácia, aj na webovej stránke. (Keby sme chceli iba aplikáciu, môžeme vymazať triedu "KruhyApplet", a keby sme chceli iba applet, môžeme vymazať metódu "main".) V oboch prípadoch sa vytvorí nový objekt "Obraz", ktorý sa vloží do aplikácie alebo do appletu, a tam už v oboch prípadoch funguje rovnakým spôsobom. V prípade aplikácie sa ešte pridá kód na zatvorenie okna. Okno je nastavené tak, aby nemohlo meniť veľkosť, čo trochu zjednoduší niektoré časti programu; applet má veľkosť okna danú vo webovej stránke.

Pozrime sa teraz na objekt "Kruh". Obsahuje štyri údaje, ktoré tvoria jeden logický celok. Ďalej je tam funkcia "obsahuje", ktorá povie, či sa daný bod nachádza vnútri kruhu alebo nie. Kto dával na matematike pozor, pozná definíciu kruhu (množina bodov v rovine, ktoré nie sú od stredu ďalej ako polomer) a Pytagorovu vetu (druhá mocnina prepony rovná sa súčtu druhých mocnín odvesien; v grafike sa však často používa ako: druhá mocnina vzdialenosti dvoch bodov rovná sa súčtu druhých mocnín rozdielov ich zodpovedajúcich súradníc). Čiže máme stred kruhu a polohu kurzora myši -- jedno X mínus druhé X, to celé na druhú; jedno Y mínus druhé Y, to celé na druhú; a keď to spolu nie je väčšie ako polomer na druhú, tak sa kurzor myši nachádza v danom kruhu. Aby bol vzorec v programe čitateľnejší, máme "naDruhu" ako pomocnú funkciu.

A teraz hlavná časť programu, trieda "Obraz". Po prvé, obsahuje zbierku existujúcich kruhov, "kruhy". (V programe je napísané "Collection", ale rovnako dobre tam mohlo byť aj "List" alebo "Set".) Po druhé, ktorý kruh je práve ťahaný, "tahanyKruh". Ak je to null, tak momentálne neťaháme žiaden. Po tretie, kde bol pred začiatkom ťahania stred tohto kruhu, "tahanyPovodnyStredX" a "tahanyPovodnyStredY"; a od ktorého bodu ťaháme, "tahanyZaciatokX" a "tahanyZaciatokY". (Tieto čísla by boli rovnaké, keby sme kruh ťahali za stred, ale takto si program môže pamätať, ak kruh ťaháme za iné miesto.) Tieto štyri čísla majú zmysel iba vtedy, ak premenná "tahanyKruh" neobsahuje null. Ďalej máme generátor náhodných čísel, "nahoda", ktorý použijeem na spestrenie programu. A nakoniec pomocný obrázok "buffer", v ktorom si pripravíme všetko, čo má byť nakreslené na obrazovke, aby sme to potom vykreslili naraz.

Táto časť s vykresľovaním do pomocného obrázku je trochu zložitá. Ak chcete vedieť, čo by to robilo bez nej, vymažte z programu riadky obsahujúce slovo "buffer", a v metóde "paint" kreslite priamo do premennej "g". Obrazovka vám počas kreslenie bude blikať. Prečo? Pretože v každom kroku vymažeme všetky kruhy a nakreslíme ich znovu; a práve toto opakované vymazávanie a vykresľovanie spôsobuje blikanie. Využitím pomocného obrázku "buffer" toto blikanie odstránime tým, že si najprv pripravíme celý obrázok, a až potom ho vykreslíme na skutočnú obrazovku; a v ďalšom kroku zase pripravíme celý obrázok a až ten vykreslíme na skutočnú obrazovku -- skutočná obrazovka sa teda nikdy nevymazáva, iba aktualizuje.

Je toto jediný spôsob, ako vyriešiť blikanie obrazovky? Asi nie, ale napadol ma ako prvý, pomerne rýchlo sa programuje, a na dnešných počítačoch aj pomerne rýchlo funguje. Pri práci nevidno žiadne spomalenie, hoci sa prekresľujú časti obrázku, ktoré by sa v princípe nemuseli. (A ak raz budete robiť hru, kde sa pohybuje veľa vecí v rôznych častiach obrazovky, najjednoduchšie bude prekresliť všetko.) Pri vytvorení objektu "Obraz" necháme premennú "buffer" prázdnu a vytvoríme ju až pri prvom volaní metódy "paint". Príkaz "createImage" použitý na vytvorenie tohto pomocného obrázku totiž funguje iba vtedy, keď už je daný grafický komponent, čiže objekt "Obraz", naozaj zobrazený na obrazovke. (Takýto postup sa nazýva lenivá inicializácia. Existujú aj iné príkazy na vytvorenie pomocného obrázku, ale na toto som si spomenul ako prvé.) Ďalší postup je ten, že všetko kreslíme do pomocného obrázku, pomocou premennej "g2", a až na konci celý tento pomocný obrázok vykreslíme na skutočnú obrazovku pomocou premennej "g".

Ešte pár slov o tom, ako funguje trieda "Canvas", čiže kresliaca plocha. Ak chceme voľne kresliť na časť obrazovky, musíme si odvodiť podtriedu triedy "Canvas", a predefinovať jej metódu "paint". Táto metóda sa volá vtedy, keď niečo treba prekresliť; a to sa môže stať z dvoch dôvodov. Po prvé, keď nám operačný systém prekreslil časť obrazovky; napríklad keď nejaké iné okno na chvíľu zakrylo to naše a teraz sa odsunulo nabok. Vtedy sa zavolá metóda "paint". Po druhé, keď my chceme niečo prekresliť; napríklad keď sme posunuli niektorý z kruhov. Vtedy zavoláme metódu "repaint", ktorá zavolá metódu "update" a tá zavolá metódu "paint". (Áno, je to trochu komplikované z dôvodov, ktoré teraz nebudeme rozoberať. Pamätajte, že zo zvyšku programu vždy voláme "repaint", nikdy nie priamo "paint" alebo "update".) Metóda "update" štandardne vymaže danú časť obrazovky a potom na ňu zavolá "paint"; my však nič vymazávať nepotrebujeme, lebo v metóde "paint" prekresľujeme všetko; preto sme si upravili aj metódu "update", nech volá "paint" priamo.

Samotné vykreslenie kruhov je jednoduché: cyklus cez všetky kruhy; nastaviť farbu, zavolať príkaz "drawOval". Pozor, príkaz "drawOval" má trochu iné parametre, ako by sme možno čakali; je to v podstate obdĺžnik, ktorý obsahuje náš kruh (alebo vo všeobecnosti elipsu), preto ako začiatok obdĺžnika dáme "stredX - polomer" a "stredY - polomer", a ako oba rozmery obdĺžnika zadáme "2 * polomer".

No a teraz k samotnej logike programu, reagovaniu na myš. 1) Ak stlačíme ľavé tlačidlo myši, a pod kurzorom myši sa nachádza kruh, zapamätáme si do premenných: tento kruh, jeho pôvodný stred, a polohu kurzora myši pri stlačení. Či sa pod kurzorom myší nachádza kruh, zistíme metódou "najdiKruh", ktorá sa v cykle pýta všetkých kruhov, či obsahujú daný bod. 2) Ak pustíme ľavé tlačidlo myši, do premennej "tahanyKruh" uložíme "null", čo znamená, že už neťaháme žiaden kruh. 3) Ak potiahneme myš, a ak pritom ťaháme nejaký kruh, potom danému nastavíme nové súradnice a zavoláme prekreslenie obrazovky. 4) Toto nebolo súčasťou zadania, ale kruhy musia nejako vzniknúť, takže: Ak stlačíme pravé tlačidlo myši, a pod kurzorom myši sa nenachádza kruh, vytvoríme tam nový kruh s náhodným polomerom a farbou; ak sa pod kurzorom myši kruh nachádza, tak ho vymažeme.

Čo ešte? V programe je pár detailov, ktoré som zatiaľ nespomenul. 1) Metóda "pack" nastaví veľkosť okna tak, aby sa doň práve zmestili jeho súčasti. Ak do okna aplikácie pridáme objekt "Obraz", ktorému sme predtým natvrdo nastavili veľkosť na 400×300, volaním metódy "pack" nastavíme veľkosť okna na 400×300 plus rám okna; metódou "setResizable(false)" zabránime užívateľovi zmeniť veľkosť okna. Správna aplikácia by mala dovoliť zmeniť veľkosť okna, ale program sa tým trochu komplikuje. Nie veľmi, ale... takýchto vylepšení by sa dalo urobiť veľa a každé z nich by tento článok o čosi predĺžilo. 2) Premennú s oknom deklarujeme ako "final Frame okno", kde slovo "final" znamená: sľubujem, že obsah tejto premennej už nebudem meniť. Je to podmienka pre to, aby sme premennú "okno" mohli použiť vnútri anonymného objektu. 3) Prečo som vytvorenie obrazu rozdelil na konštruktor "new Obraz()" a metódu "obraz.init()"? Prečo to nedať všetko do konštruktora? V tomto prípade je to skôr otázka štýlu, ale vo všeobecnosti je lepšie nerobiť konštruktory príliš zložité; najmä nevkladať referenciu na daný objekt do iných objektov, lebo keď iné objekty pristupujú k objektu, ktorého konštruktor ešte neskončil, môže to viesť k podivným chybám. 4) Čo sú to za vzorce s tými náhodnými číslami? Nuž, "10 + nahoda.nextInt(10)" je náhodné celé číslo od 10 do 19, a "(1 + nahoda.nextFloat()) / 2" je náhodné desatinné číslo od 1/2 do 1. To prvé dávam ako polomer kruhu, nech nemáme drobné kruhy veľkosti jeden pixel; to druhé dávam ako sýtosť a intenzitu farby kruhu, nech nemáme biele, sivé a čierne kruhy.

DOPLNENÉ: Ešte tá chyba, ktorú som dodatočne našiel... súvisí to s multithreadingom a bolo by to na dlhšie vysvetľovanie. Skrátka, veci súvisiace s užívateľským rozhraním (je jedno, či AWT alebo Swing) by ste nemali volať priamo z metódy "main" (ani z metódy "init" appletu), ale treba ich zabaliť do niečoho ako je "SwingUtilities.invokeLater". To ich spustí o čosi neskôr, v čase, keď to užívateľskému rozhraniu bude vyhovovať. To sa netýka metód, ktoré sú sami volané z užívateľského rozhrania, ako napríklad "mousePressed" alebo "paint".

Nuž, dnes toho bolo veru dosť, ale na druhej strane... máte tu úplný program, s grafickým rozhraním, ovládaný myšou -- prakticky grafický editor... a to všetko iba na cca 150 riadkov kódu, čo pri takomto type programov vôbec nie je veľa. Aspoň máte nad čím rozmýšľať! ;-)

viliam@bur.sk