12.05.2026 · blog

Pełna rozbiórka gry przeglądarkowej w czystym HTML/CSS/JS
Dyrektor i kanapki

Tile-based gra w stylu Pac-Man — rozkład 360 linii kodu na czynniki pierwsze. Materiał dla uczniów INF.02 / INF.03.

00 Po ludzku — zanim zaczniemy

Ta sekcja jest dla osób, które właśnie zaczynają z programowaniem. Wszystkie pojęcia tłumaczę przez analogie. Jeśli już wiesz, czym jest HTML i co robi przeglądarka — możesz to spokojnie pominąć i skoczyć do sekcji 01.

Wyobraź sobie, że budujesz dom

Strona internetowa działa trochę jak prawdziwy dom. W prawdziwym domu masz:

  • Konstrukcję — ściany, dach, podłoga. To, co trzyma wszystko w miejscu. Bez tego nie ma domu.
  • Wystrój — kolory ścian, meble, dywany, zasłony. To, jak dom wygląda. Bez tego dom jest brzydki, ale wciąż jest domem.
  • Instalacje — prąd, woda, gaz, internet. To, co sprawia, że dom działa — światło się zapala, kran leci, lodówka chłodzi.

Strona internetowa ma dokładnie te same trzy rzeczy, tylko inaczej się nazywają:

DomStrona internetowaCo to jest
Konstrukcja (ściany, dach)HTMLSzkielet strony — co jest na stronie. Tytuł, akapit, obrazek, przycisk.
Wystrój (kolory, meble)CSSWygląd strony — jakiego koloru tło, jak duża czcionka, gdzie co stoi.
Instalacje (prąd, woda)JavaScript (JS)Co strona robi — gdy klikasz przycisk, gdy ruszasz strzałkami, gdy zmienia się czas.

Nasza gra to wszystkie trzy rzeczy razem w jednym pliku. Plik nazywa się .html, ale w środku siedzą wszystkie trzy: HTML (co jest), CSS (jak wygląda), JavaScript (co robi).

💡 Prosta analogia

HTML mówi „tu jest przycisk”. CSS mówi „przycisk jest żółty i okrągły”. JavaScript mówi „gdy ktoś kliknie przycisk, gra ma się zacząć”. Każdy z nich robi co innego, ale razem tworzą całość.

Co to jest przeglądarka?

Przeglądarka (Chrome, Firefox, Edge, Safari) to program, który czyta plik HTML i pokazuje ci stronę. Sama z siebie przeglądarka nic nie wie o naszej grze — dopiero gdy otworzysz w niej plik z grą, ona przeczyta tekst pliku i zrobi co napisane.

To trochę jak czytanie przepisu kucharskiego. Przepis nie jest jedzeniem — jest tylko listą instrukcji. Dopiero gdy kucharz (przeglądarka) przeczyta przepis (plik HTML) i wykona instrukcje, powstanie potrawa (strona).

📄 plik.html tekst z instrukcjami: HTML (co jest) CSS (jak wygląda) JS (co robi) 🌐 Przeglądarka czyta plik i: • rysuje stronę • reaguje na klawisze • gra dźwięki 👁️ To, co widzisz gra na ekranie: 🧑‍🏫 🥪 🌰 i działa! otwiera wyświetla Co się dzieje, gdy otwierasz grę? Trzy etapy od pliku do działającej gry na ekranie Plik to tylko tekst. Dopiero przeglądarka zamienia go w obraz, dźwięk i interakcję.
Plik HTML → przeglądarka → gra na ekranie. Tak działa każda strona internetowa.

Co to jest gra na siatce?

„Dyrektor i kanapki” to gra na siatce. Brzmi technicznie — w rzeczywistości to bardzo proste pojęcie.

Wyobraź sobie szachownicę. Albo planszę do gry „w klasy” na chodniku. Albo arkusz w zeszycie w kratkę. Cały świat takiej gry to siatka kratek. Każda postać zajmuje dokładnie jedną kratkę — nigdy nie stoi „w połowie” między dwiema. Ruch polega na przeskakiwaniu z kratki na kratkę: lewo, prawo, góra, dół.

Nasza plansza ma 20 kratek szerokości i 22 kratki wysokości — czyli łącznie 440 kratek. Każda kratka to coś:

Mapa gry — każda kratka coś znaczy Fragment 8×6 z naszej mapy, kratka po kratce 0 0 0 0 0 0 0 0 0 0 0 🧑‍🏫 0 0 0 0 🌰 0 0 0 0 0 0 0 0 0 ściana (0) kanapka (1) kawa (3) 🧑‍🏫 dyrektor 🌰 kasztanek Cyfry pod kratkami to „kody” w kodzie gry: 0 = ściana, 1 = kanapka, 3 = kawa. Cała mapa to po prostu tabelka takich cyfr — 20 w poziomie × 22 w pionie.
Fragment mapy. Każda kratka ma swój „typ”. Komputer wie, że 0 to ściana, więc dyrektor nie może tam wejść.

Co właściwie robi „komputer” gdy gra działa?

Przeglądarka, gdy uruchomi naszą grę, robi to samo 60 razy na sekundę. Tak. Sześćdziesiąt. Co 16 i pół tysięcznej sekundy (16,67 ms) wykonuje całą procedurę:

  1. Sprawdza, czy gracz wcisnął jakiś klawisz.
  2. Jeśli tak — zapamiętuje, w którą stronę gracz chce iść.
  3. Co kilka „klatek” fizycznie przesuwa postać o jedną kratkę.
  4. Sprawdza, czy postać nie weszła na ścianę.
  5. Sprawdza, czy nie zjadła kanapki lub kawy.
  6. Każe kasztankom myśleć — gdzie iść?
  7. Sprawdza, czy któryś kasztanek nie złapał dyrektora.
  8. Maluje całą scenę od nowa — ściany, kanapki, kasztanki, dyrektora.
  9. Wraca do kroku 1.

Dla nas, ludzi, to gigantyczna lista zadań. Dla komputera — pestka. Procesor wykonuje miliardy operacji na sekundę, więc te dziewięć kroków zajmują mu mniej niż 1 milisekundę. Resztę czasu po prostu czeka aż przyjdzie pora na kolejną klatkę.

Pętla gry — kółko zamknięte, kręci się 60 razy na sekundę 1 sprawdź 2 ruszaj 3 rysuj 1. Sprawdź klawisze Czy wcisnięto strzałkę? Czy M na wyciszenie? 2. Aktualizuj stan Przesuń dyrektora. Przesuń kasztanki. 3. Narysuj scenę Wyczyść ekran. Namaluj wszystko od nowa. 60 razy / sekundę = co 16,67 ms
Trzy kroki w nieskończonej pętli. Komputer wykonuje to tak szybko, że widzimy płynny ruch.

Po co liczba „60” razy na sekundę?

Bo tyle razy na sekundę odświeża się typowy ekran komputera. Twoje oczy widzą jako „płynny ruch” wszystko powyżej 24 klatek na sekundę (filmy w kinie). 30 fps to wystarczająco. 60 fps daje wyraźnie gładszy ruch — standard w grach. Niektóre monitory mają 120 lub 144 Hz, ale 60 to złoty środek.

To trochę jak animacja flipbook — pamiętasz książeczki, w których na każdej kartce była lekko inna rysunkowa scena? Gdy szybko kartkujesz, postać porusza się płynnie. Komputer robi dokładnie to samo, tylko zamiast kartek ma klatki, a zamiast kartkowania — pętla gry.

Co to jest „kod” i jak on wygląda?

Kod to tekst, który mówi komputerowi co ma robić. Niczym przepis kucharski, ale wymaga absolutnej precyzji — gdy w przepisie pomylisz „cukier” z „sól”, ciasto będzie do wyrzucenia. W kodzie podobnie — jedna literówka i program przestaje działać.

Oto przykład króciuteńkiego fragmentu kodu z naszej gry:

if (tile === 1){
  this.score += 10;
}

Po polsku: „jeśli kafelek to 1 (kanapka), dodaj 10 do wyniku”. To dwie linijki, ale każdy element ma znaczenie:

  • if — angielskie „jeśli”. Słowo kluczowe języka JavaScript.
  • (tile === 1) — warunek. tile to nazwa, którą nadaliśmy „kafelkowi pod dyrektorem”. === oznacza „jest równe” (trzy znaki równości — to nie błąd, tak się pisze w JS).
  • { ... } — klamry. Co jest w środku, wykona się gdy warunek jest spełniony.
  • this.score += 10 — „weź this.score (czyli wynik) i dodaj do niego 10”.

Cały kod naszej gry to ~360 takich linijek ułożonych w sensowną całość. Każda linijka to jeden malutki krok. Razem tworzą działającą grę.

🤔 Pytanie

Skąd komputer wie, co znaczy if? Bo czyta plik HTML, znajduje w nim sekcję <script>, i przekazuje jej zawartość do silnika JavaScriptu wbudowanego w przeglądarce. Silnik (w Chrome to V8) zamienia tekst na instrukcje procesora. To naprawdę dzieje się kilka razy na sekundę i nie zauważamy.

Co to jest „pac-manowa gra”?

Pac-Man to gra z 1980 roku. Wymyśliła ją japońska firma Namco. Stała się jedną z najpopularniejszych gier wszech czasów. Zasady są tak proste, że zrozumie je nawet pięciolatek:

  1. Bohater (żółta kulka z paszczą) biega po labiryncie.
  2. Zbiera kropki, które znajdują się w korytarzach.
  3. Czterej duszki próbują go złapać.
  4. Gdy zje wszystkie kropki — przechodzi na następny poziom.
  5. W rogach mapy są power pellety — duże kropki. Po ich zjedzeniu na chwilę duszki uciekają, a Pac-Man może je zjadać.

„Dyrektor i kanapki” to ta sama gra, tylko z polskim klimatem szkolnym:

Pac-Man (1980)Dyrektor i kanapki (2026)
Pac-Man (żółta kulka)Dyrektor 🧑‍🏫
KropkiKanapki 🥪
Power pelletyKawa ☕
Cztery duszki: Blinky/Pinky/Inky/ClydeCztery kasztanki 🌰
LabiryntSzkolna kuchnia (labirynt)

Mechanika identyczna, klimat polski, można zagrać każdemu uczniowi. Jakość edukacyjna — wysoka.

Co się dzieje, gdy klikasz „Start”?

Opowiem ci historię. To, co się dzieje w kilka sekund.

Sekunda 0,0: klikasz przycisk „▶ Start”. Przeglądarka rejestruje kliknięcie i sprawdza, czy gdzieś w naszym kodzie napisano „co robić gdy ktoś kliknie ten przycisk”. Znajduje. Wykonuje.

Sekunda 0,01: kod gry uruchamia funkcję jingleStart — to mała melodyjka radosna. Komputer generuje cztery dźwięki (do-mi-sol-do), używając wbudowanego syntezatora. Brzmi „fanfarowo”.

Sekunda 0,02: panel „Start” znika z ekranu (klasa CSS show zostaje usunięta — CSS widzi, że nie ma już show, więc nie wyświetla panelu).

Sekunda 0,03: kod gry „resetuje” stan — kopiuje mapę z bazowego wzorca, ustawia dyrektora w lewym górnym rogu, każdemu z czterech kasztanków przydziela pozycję startową w klatce na środku planszy.

Sekunda 0,04 i dalej: pętla gry działa. Co 16 milisekund:

  • Sprawdza, czy wciskasz coś na klawiaturze.
  • Jeśli wciskasz strzałkę — zapamiętuje, w którą stronę chcesz iść.
  • Co 8 takich „16 ms” (czyli co 130 ms ≈ 7,5 razy na sekundę) dyrektor faktycznie przesuwa się o jedną kratkę.
  • Kasztanki same wybierają swoje kierunki (na podstawie odległości do dyrektora — chcą cię złapać!).
  • Po każdym ruchu — przerysowanie ekranu.

I tak w nieskończoność, aż albo zjesz wszystkie kanapki (level up!) albo stracisz wszystkie 3 życia (gameOver).

Co zobaczysz w reszcie tego artykułu?

Resztę tekstu poświęcę na rozbiórkę całego kodu gry — linijka po linijce. Pokażę ci:

  • Jak wygląda preambuła dokumentu HTML (sekcje 2–4).
  • Jak CSS układa wszystko na ekranie i jak działa „pikselowy” canvas (sekcje 5–9).
  • Jak JavaScript buduje stan gry (sekcje 10–18).
  • Jak działa „logika gry” — kolizje, ruch, AI kasztanków (sekcje 19–27).
  • Jak komputer rysuje obraz w 60 fps (sekcja 28).
  • Jak sterowanie radzi sobie z trudnymi sytuacjami (sekcja 29).
  • I co można byłoby zrobić lepiej (sekcje 31–32).

Każdą sekcję poprzedzę krótkim akapitem „o czym tu jest mowa”. Pokażę kod, potem wyjaśnię go po polsku, potem dam analogię z życia. Czytaj w tempie, które ci pasuje. Nie ma egzaminu na końcu.

🎓 Cel

Po przeczytaniu tego artykułu powinieneś móc otworzyć kod gry, przeczytać go, zrozumieć, i — co najważniejsze — zmodyfikować. Dodać własnego wroga. Zmienić mapę. Wymienić dyrektora na innego bohatera. Dorobić ekran wyboru poziomu. To jest cel: nauka przez czytanie i eksperymentowanie.

Słowniczek — pojęcia, które jeszcze użyję

Tych słów będę używał często. Lepiej, żebyś teraz znał je z grubsza, niż żebyś się pomylił w trakcie:

kod / kod źródłowy
Tekst napisany w języku programowania (np. JavaScript). To, co programista pisze.
plik HTML
Plik z rozszerzeniem .html. Zawiera strukturę strony internetowej. Można go otworzyć w przeglądarce.
tag / znacznik
Element HTML, np. <p> (akapit) lub <div> (kontener). Zaczyna się znacznikiem otwierającym, kończy zamykającym.
funkcja
Mały „program w programie” — kawałek kodu wykonywany na żądanie. Np. jingleStart() to funkcja grająca fanfary.
zmienna
Nazwa dla wartości w pamięci komputera. Np. score przechowuje aktualny wynik.
pętla
Konstrukcja, która powtarza coś wielokrotnie. Pętla gry powtarza się 60 razy na sekundę.
warunek
Sprawdzenie, czy coś jest prawdą. if (lives === 0) — „jeśli liczba żyć to 0”.
kratka / kafelek (tile)
Pojedyncze pole na siatce gry. U nas — kwadrat 32×32 piksele.
siatka / mapa
Cała plansza gry, podzielona na kratki. U nas 20×22 = 440 kratek.
renderowanie / rysowanie
Wyświetlenie obrazu na ekranie. „Renderer” to fragment kodu odpowiadający za rysowanie.
collision / kolizja
Wykrycie, że dwa obiekty się dotykają. U nas: gracz weszedł na ścianę, albo gracz dotknął kasztanka.
canvas
Specjalny element HTML do rysowania grafiki. Wygląda jak płótno malarza — komputer może na nim malować linie, koła, kwadraty, obrazy.
frame / klatka
Pojedynczy obraz w animacji. 60 fps = 60 klatek na sekundę.
obiekt
Zbiór powiązanych danych w kodzie. Np. dyrektor to obiekt z polami: pozycja x, pozycja y, czy żyje, w którą stronę idzie.
tablica
Lista wartości w kodzie. Np. chestnuts to tablica czterech obiektów-kasztanków.

No to lecimy. Sekcja 01 zaczyna prawdziwą analizę.

01 Wprowadzenie i koncepcja

Zanim wejdziemy w kod, ustalmy wspólnie co właściwie czytamy. „Dyrektor i kanapki” to klasyczny tile‑based game — gra na siatce, w której wszystko porusza się od kratki do kratki. Konwencja ma 40 lat i nadal jest fundamentem dziesiątek tytułów.

Czym jest gra na siatce?

W grach kafelkowych świat jest podzielony na dyskretną sieć kratek (ang. tiles). Postacie nie mogą stać „pomiędzy” kratkami — w danym momencie każda zajmuje dokładnie jedną. Ruch polega na płynnym (lub natychmiastowym) przeskakiwaniu między sąsiednimi kratkami w czterech kierunkach: góra, dół, lewo, prawo. Przekątna jest niedozwolona — to charakterystyczne dla gatunku.

Plansza to dwuwymiarowa tablica liczb lub znaków. Każda komórka tablicy mówi nam, czym jest dane miejsce w świecie: ścianą, korytarzem, przedmiotem, klatką wroga. Renderer zamienia te liczby na grafikę — a logika gry sprawdza je przy każdym ruchu, żeby wiedzieć, co wolno, a czego nie.

Dlaczego jeden plik?

Cała gra zmieściła się w jednym pliku .html. Brzmi staroszkolnie, ale ma to wymierne korzyści dydaktyczne i praktyczne:

  • Brak buildu — uczniowie mogą otworzyć plik w edytorze i od razu zobaczyć efekt. Nie ma npm install, nie ma webpacka, nie ma transpilacji.
  • Łatwy hosting — wystarczy wgrać jeden plik na serwer. Działa na każdym hostingu od FTP w góre.
  • Wbudowane CSS i JS — wszystko w jednym kontekście, nie trzeba przeglądać trzech różnych plików, żeby zrozumieć jak coś działa.
  • Można osadzić jako iframe — pojedynczy URL daje pełną grę.

Wadą jest to, że pliku ponad pewien rozmiar (powiedzmy 1500 linii) staje się trudny w utrzymaniu. Dla naszej gry — 360 linii — to sweet spot.

Konwencja Pac‑Manowa

Gra wprost cytuje kilka klasycznych mechanik z Pac‑Mana z 1980 r.:

  • Zbieranie kropek — w oryginale „dots”, u nas „kanapki”;
  • Power pellets — u nas „kawa” () — daje 6 sekund nieśmiertelności i zamienia drapieżników w ofiary;
  • Czterech wrogów — w Pac‑Manie zwanych Blinky/Pinky/Inky/Clyde, u nas: cztery kasztanki z różnymi kolorami;
  • Bufor kierunku — gracz może wcisnąć skręt zanim dotrze do skrzyżowania, a postać sama go wykona w pierwszym możliwym miejscu.

Różnice są kosmetyczne (emoji zamiast sprite'ów, polski klimat szkolny zamiast amerykańskiej arkady), ale rdzeń mechaniki jest identyczny — i to dzięki temu gra jest natychmiast czytelna dla każdego, kto kiedykolwiek dotknął oryginału.

📚 Kontekst dydaktyczny

Ta gra jest świetnym materiałem na lekcję, bo łączy w sobie cztery niezależne obszary: strukturę dokumentu HTML, layout CSS, programowanie obiektowe i graficzne w JS, oraz interakcję z multimediami przez Web Audio API. Każdy z tych obszarów można omawiać osobno, używając tego samego pliku jako kanwy.

02 HTML — preambuła i metadane

Pierwsze sześć linii pliku to deklaracja, że dokument jest nowoczesnym HTML5 napisanym po polsku, kodowanym w UTF‑8 i przygotowanym do wyświetlenia na każdym ekranie. Wbrew pozorom każda z tych linii ma znaczenie — pominięcie którejkolwiek powoduje konkretne, zauważalne problemy.

<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<title>Dyrektor i kanapki</title>
<meta name="viewport" content="width=device-width,initial-scale=1">

<!doctype html>

To deklaracja typu dokumentu. W HTML5 została maksymalnie skrócona — wcześniejsze wersje wymagały długich linii ze ścieżką do DTD. Dziś wystarczy te 15 znaków. Brak doctype wpędza przeglądarki w tzw. quirks mode — tryb zgodności wstecznej, w którym CSS zachowuje się dziwnie (np. inny model pudełkowy, ignorowane pewne właściwości). Dla naszej gry brak doctype objawiałby się m.in. nieprawidłowymi marginesami i tłem.

lang="pl"

Atrybut języka na elemencie <html> jest wskazówką dla:

  • czytników ekranu — wybierają polskiego syntezatora mowy;
  • silników tłumaczących (Google Translate, DeepL) — wiedzą, że nie muszą tłumaczyć z polskiego na polski;
  • silników wyszukiwarek — pozycjonują stronę w wynikach polskich;
  • CSS — selektor :lang(pl) oraz pseudoklasy językowe;
  • fontów systemowych — niektóre rodziny mają warianty językowe (np. polskie ogonki).

<meta charset="utf-8">

To najważniejszy meta tag w polskim dokumencie. UTF‑8 to uniwersalne kodowanie, które obejmuje wszystkie polskie znaki diakrytyczne (ąćęłńóśźż), znaki cyrylicy, japońskie kanji oraz emoji — w naszej grze: 🧑‍🏫, 🥪, 🌰, ☕, 😱.

Bez tego meta taga przeglądarka próbowałaby zgadnąć kodowanie na podstawie nagłówków HTTP, BOM lub heurystyki. Efekt: wszystkie polskie znaki zamieniają się w „krzaki” typu Ä‚â€, a emoji w pytajniki. Co więcej — meta charset musi być w pierwszych 1024 bajtach dokumentu, dlatego tradycyjnie umieszcza się go zaraz po <head>.

⚠️ Pułapka

Sam plik HTML też musi być fizycznie zapisany w kodowaniu UTF‑8. W Notepadzie Windows klasycznie problem: zapisujesz „domyślnie” jako Windows‑1250, a meta deklaruje UTF‑8 → konflikt. W VS Code można sprawdzić kodowanie w prawym dolnym rogu paska statusu.

<title>

Tytuł wyświetla się w pasku karty przeglądarki, w wynikach wyszukiwania, oraz przy dodawaniu strony do zakładek. Dla pojedynczej gry to drobny detal, ale w aplikacji wieloekranowej tytuł jest istotnym elementem nawigacji i SEO.

<meta name="viewport" ...>

To linia kluczowa dla mobilnych urządzeń. Bez niej iPhone czy Android renderowałyby stronę tak, jakby miała szerokość 980 px (domyślny viewport „desktopowy”), a potem zmniejszały całość żeby się zmieściła. Efekt: drobne literki, paski przewijania w obie strony, niedotykalne przyciski.

Z atrybutem width=device-width mówimy: „użyj rzeczywistej szerokości fizycznego ekranu jako szerokości viewportu CSS”. Dodatkowy initial-scale=1 wymusza, że 1 px CSS = 1 dp (device pixel) na początek — bez automatycznego zoomu.

Inline <style> vs zewnętrzny CSS

W naszym pliku style są wbudowane w <head>:

<style>
  /* … cały CSS gry … */
</style>

To wybór świadomy. Style inline mają zaletę natychmiastowego ładowania (brak dodatkowego żądania HTTP) i braku Flash of Unstyled Content (FOUC — moment kiedy widać niestylowaną treść, zanim CSS dotrze). Wadą jest brak cache'owania między stronami — ale my mamy jedną stronę.

03 HTML — szkielet sceny

Cały interfejs gry mieści się w jednym wrapperze z dwoma warstwami: pasek statusu (HUD) i scena z canvasem. Struktura jest płaska — żadnego zagnieżdżenia ponad to, co konieczne.

<div class="game-wrap">
  <div class="hud">
    <span><span class="label">KANAPKI:</span>
          <span class="value" id="score">0</span> / <span id="total">0</span></span>
    <span><span class="label">ŻYCIA:</span><span class="value" id="lives">3</span></span>
    <span><span class="label">POZIOM:</span><span class="value" id="level">1</span></span>
    <span><span class="label">KAWA:</span><span class="value" id="power">0</span>s</span>
    <span class="label" id="muteHint">M — wycisz</span>
  </div>

  <div class="stage">
    <canvas id="board" width="640" height="704" tabindex="0"></canvas>
    <!-- panele intro/gameOver -->
  </div>
</div>

.game-wrap — kontener‑korzeń

Jeden div zewnętrzny ma zadanie: zorganizować dwa wiersze (HUD i scenę) w pionie i zająć cały dostępny ekran. Klasa, nie ID — bo gdyby ktoś chciał umieścić dwie gry na jednej stronie, ID byłoby przeszkodą.

.hud — pasek statusu

HUD (Heads‑Up Display) to w grach umowna nazwa nakładki informacyjnej. U nas zawiera 5 elementów: licznik kanapek (zebrane / ze startu), liczbę żyć, numer poziomu, czas pozostały kawy, oraz wskazówkę „M — wycisz”.

Każda informacja jest opakowana w <span> żeby flexbox mógł ułożyć je obok siebie z odstępem (gap). Zauważ podział wewnątrz każdego span:

  • <span class="label"> — etykieta tekstowa (np. „KANAPKI:”), wyświetlana mniej intensywnie;
  • <span class="value" id="..."> — sama liczba, wyróżniona żółtym kolorem.

Dwa różne klasyczne rozwiązania, których tu nie użyto:

  • Tabela — kiedyś standard układania informacji w wierszach. Dziś semantycznie nieprawidłowe (HUD to nie tabela danych), nieelastyczne i kłopotliwe na mobile.
  • data-* atrybuty — można by zamiast id użyć data-stat="score". To bardziej elastyczne, gdy chcemy mieć kilka pasków HUD jednocześnie. Tutaj jednak id jest prostsze i wystarcza.
🎯 Konwencja nazewnicza

Skrypt później pobiera te elementy przez document.getElementById('score'). Gdyby były w klasach, trzeba by używać querySelector('.score') lub iterować po liście — wolniejsze i mniej czytelne. id jest najszybszym selektorem w DOM, bo przeglądarka trzyma go w hashmapie.

.stage — kontener canvasa

Wewnętrzny div opakowujący canvas i panele. Dlaczego potrzebny? Bo panele muszą leżeć nad canvasem (position: absolute), a żeby pozycjonowanie absolutne odnosiło się do canvasa, najbliższy przodek musi mieć position: relative. To właśnie robi .stage.

<canvas> — serce gry

<canvas id="board" width="640" height="704" tabindex="0"></canvas>

Atrybuty width i height

To nie są rozmiary CSS. To rozmiar wewnętrznej rastrowej powierzchni canvasa — buforu pikseli, na którym JS rysuje. Wynosi on 640 × 704 px, co daje 20 × 22 kratki po 32 px.

Liczby pochodzą bezpośrednio ze stałych w JS:

const TILE = 32;
const COLS = 20;
const ROWS = 22;
// width  = COLS × TILE = 20 × 32 = 640
// height = ROWS × TILE = 22 × 32 = 704

Rozmiar wyświetlany na ekranie kontroluje CSS (aspect-ratio + height: 100%). Gdy okno jest większe niż 640 × 704, canvas jest powiększany — i tu wkracza image-rendering: pixelated z CSS, żeby upskalowanie nie wygładzało pikseli (o tym za chwilę).

⚠️ Klasyczny błąd

Częsty błąd początkujących to ustawianie width i height przez CSS bez jednoczesnego ustawienia atrybutów HTML. Wtedy canvas ma domyślny bufor 300 × 150 px, ale jest rozciągany do rozmiaru CSS. Efekt: rozmazana, niewyraźna grafika. Reguła: bufor (atrybuty HTML) ustawiamy na fizyczne rozmiary roboczyche, CSS może go skalować.

tabindex="0" — fokusowalność

Domyślnie <canvas> nie może otrzymać fokusa klawiatury. To znaczy, że bez tego atrybutu zdarzenia keydown dispatchowane na canvasie nie zachodzą — bo focus przeskakuje do document.body.

Atrybut tabindex="0" czyni element fokusowalnym i włącza go w naturalną kolejność tabowania (Tab cyklicznie przechodzi po fokusowalnych elementach strony). Wartość 0 oznacza „naturalna kolejność DOM”; wartość -1 oznacza „programowo fokusowalny ale nie przez Tab”; wartość dodatnia ustala niestandardową kolejność (silnie odradzana).

Bez tego canvas wciąż mógłby dostać klawisze, gdyby listenery wisiały na document lub window — i właśnie tak jest w naszej grze (na trzech poziomach). Ale dodanie tabindex daje dodatkowy bezpiecznik: jeśli z jakiegoś powodu listenery na window'ie nie zadziałają (np. iframe sandbox), canvas też zareaguje.

04 HTML — panele modalne

Gra ma dwa stany meta — przed startem i po końcu. Każdy z nich pokazuje pełnoekranową nakładkę z tekstem i przyciskiem akcji. To panele #introPanel i #gameOverPanel.

Panel intro

<div class="panel show" id="introPanel">
  <div class="panel-inner">
    <h2>🧑‍🏫 Dyrektor i kanapki 🥪</h2>
    <p>Dyrektor zbiera kanapki ze szkolnej kuchni.</p>
    <p>Uciekaj przed kasztankami 🌰. Kawa ☕ daje moc na 6 sek. — wtedy
       <strong>Dyrektor</strong> goni kasztanki!</p>
    <p class="footnote">Strzałki / WASD — ruch · M — wycisz</p>
    <button class="btn" id="startBtn">▶ Start</button>
  </div>
</div>

Strukturalnie: zewnętrzny .panel jest „ciemnym tłem” pokrywającym całą scenę, a wewnętrzny .panel-inner to prawdziwa karta modala. Taka warstwowa konstrukcja pozwala kliknąć w tło bez zamykania modala — jeśli klik na tło ma zamykać modal, dodajemy listener; jeśli nie, jest neutralne.

Klasa show przy panelu intro jest obecna od początku — gra startuje z widocznym ekranem startowym. Dopiero kliknięcie „▶ Start” usuwa tę klasę.

Panel końca gry

<div class="panel" id="gameOverPanel">
  <div class="panel-inner">
    <h2 id="goTitle">Koniec gry</h2>
    <p>Wynik: <strong id="finalScore">0</strong></p>
    <p id="goMsg"></p>
    <button class="btn" id="restartBtn">⟳ Zagraj ponownie</button>
  </div>
</div>

Tu klasy show nie ma — panel jest niewidoczny do momentu, aż JS doda mu tę klasę przy przegranej lub wygranej. Trzy elementy mają identyfikatory, bo skrypt podmienia ich treść:

  • #goTitle — „🎉 Wygrana!” albo „💀 Koniec gry”;
  • #finalScore — finalna liczba punktów;
  • #goMsg — komunikat „Dyrektor zjadł wszystkie kanapki!” albo „Kasztanki dorwały Dyrektora.”.

Dlaczego nie <dialog>?

HTML5 ma natywny element <dialog> z metodami showModal() i close(). Kuszące, ale w grze niepraktyczne — modal natywny:

  • blokuje całą stronę za sobą (trapuje fokus, zamyka się na Esc);
  • wymusza określone style i tła systemowe;
  • pozycjonuje się względem viewportu, nie kontenera;
  • na starszych przeglądarkach (Safari < 15.4) wymaga polyfilla.

Zwykły div z position: absolute daje pełną kontrolę i działa wszędzie.

Semantyka i dostępność

Przyciski są zwykłymi <button>‑ami — nie <div onclick>. To istotne: <button> jest naturalnie fokusowalny, można go aktywować spacją i Enterem, czytniki ekranu wiedzą, że to przycisk. Dla div'a trzeba by dorabiać role="button", tabindex="0" i obsługę klawiszy.

Czego brakuje, gdyby chcieć być w pełni dostępnym:

  • aria-modal="true" i role="dialog" na panelach;
  • aria-labelledby wskazujące nagłówek modala;
  • fokus przenoszący się do panelu po jego otwarciu;
  • aria-live="polite" na liczbach HUD, żeby czytnik czytał zmiany punktacji.

W grze przeglądarkowej dla widzących uczniów to nadmiar; w produktach komercyjnych — nieodzowne.

05 CSS — reset i tło globalne

CSS gry zaczyna się od trzech zwięzłych selektorów, które dają nam czyste, pełnoekranowe płótno. Bez nich na samym starcie pojawia się drobny biały margines wokół body i scrollbar po prawej.

html,body{
  margin:0; padding:0; height:100%;
  background:#0d0f14; color:#eee;
  font-family: system-ui, -apple-system, sans-serif;
  overflow:hidden;
}

Dlaczego selektor zbiorczy html, body?

To dwa różne elementy. <html> jest korzeniem dokumentu i wyznacza viewport, <body> jest jego dzieckiem. Domyślne style przeglądarki zostawiają na obu marginesy:

  • html ma wysokość zależną od treści, nie viewportu;
  • body ma margin: 8px w większości przeglądarek.

Żeby height: 100vh w grze działało (canvas musi wypełnić ekran), oba muszą mieć height: 100% bez marginesów.

height: 100% kontra 100vh

Dlaczego nie 100vh, skoro chcemy całego ekranu? Subtelność: 100vh na mobilkach z dynamicznymi paskami przeglądarki (URL, dolne menu) jest niestabilne — wartość zmienia się gdy paski się chowają/pokazują, powodując jumping content. height: 100% na html, body oraz height: 100vh tylko na .game-wrap daje stabilniejszy efekt.

Tło i kolor tekstu

Tło #0d0f14 to bardzo ciemny niebieskoszary — celowo nie czyste czerni, bo czerń wygląda „matowo” obok jaskrawych elementów gry. Subtelny niebieski podton dodaje głębi.

Kolor tekstu #eee (jasnoszary) zamiast czystej bieli z tego samego powodu — pełny biały na ciemnym tle męczy oczy.

font-family: system-ui

To genialny mechanizm CSS: deklarujemy „użyj fontu systemowego”, a przeglądarka wybiera natywny font interfejsu na danym OS:

  • Windows 10/11 → Segoe UI;
  • macOS → SF Pro Text / SF Pro Display;
  • iOS → San Francisco;
  • Android → Roboto;
  • Ubuntu → Ubuntu;
  • ChromeOS → Roboto.

Korzyści: zerowy koszt sieciowy (nie ładujemy pliku fontu), spójność z systemem operacyjnym (gra wygląda „naturalnie”), idealny rendering bez subpiksela. Drugi argument — -apple-system — to historyczny fallback dla starszych Safari (przed wsparciem system-ui).

overflow: hidden

Wyłącza scrollbary na dokumencie. Dla strony‑gry to konieczne, bo canvas zawsze zajmuje pełen ekran i nie ma czego scrollować. Pasek przewijania zabierałby ~17 px po prawej, psując centrowanie.

🎨 Filozofia

Cała paleta gry to ciemny niebieskoszary + żółty akcent + niebieski kontrast w przyciskach. Wybór celowy — żółto‑niebieski to jedna z najczystszych par komplementarnych w teorii barw. Daje wysoki kontrast i czytelność, a przy tym jest „cieplejsza” niż klasyczna para czerwono‑zielona, więc nie męczy.

06 CSS — układ siatkowy

Layout gry to dwurzędowa siatka CSS Grid. Górny wiersz zajmuje tyle, ile potrzebuje (HUD), dolny — całą resztę miejsca (scena). To jeden z najczęstszych wzorców „aplikacja z paskiem statusu”.

.game-wrap{
  display:grid;
  grid-template-rows: auto 1fr;
  height:100vh;
  gap:6px;
  padding:6px;
  background: radial-gradient(circle at 50% 30%, #2c3340 0%, #0a0c10 100%);
}

display: grid kontra flex

Tutaj można by użyć obu — flex z flex-direction: column dałby ten sam efekt. Wybrano grid, bo:

  • Jasna deklaracja proporcjiauto 1fr czyta się jak instrukcja: „pierwszy wiersz wedle treści, drugi cała reszta”;
  • Łatwa rozbudowa — gdy będziemy chcieli dodać stopkę, dopisujemy trzecie auto: grid-template-rows: auto 1fr auto;
  • Możliwość dodania kolumn — w grid można równocześnie kontrolować kolumny (np. boczny panel ustawień).

Jednostka 1fr

Skrót od fraction. Mówi: „weź całą pozostałą wolną przestrzeń po przydzieleniu wszystkim wierszom auto/stałym tego, czego potrzebują”.

Gdyby było grid-template-rows: 1fr 1fr, oba wiersze byłyby równe (po połowie ekranu). auto 1fr daje pierwszemu wierszowi tyle, ile pasek HUD potrzebuje, a drugi pochłania resztę.

height: 100vh tutaj jest celowy

Wcześniej wspominałem, że vh bywa niestabilne na mobile. Jednak na .game-wrap używamy 100vh, bo:

  • nadrzędne html, body mają już height: 100%, więc 100vh jest tożsame z 100%;
  • w razie czego, jeśli body przestałby być pełnowysokie (np. jakaś modyfikacja), 100vh wciąż gwarantuje pełen ekran.

gap: 6px i padding: 6px

Drobne odstępy: 6 px wewnątrz wrappera (padding) i 6 px między wierszami siatki (gap). Łącznie 18 px „oddechu” wokół canvasa od góry. Wartości celowo małe — gra ma być pełnoekranowa, nie pływać w pustce.

Tło z gradientem radialnym

background: radial-gradient(
  circle at 50% 30%,
  #2c3340 0%,
  #0a0c10 100%
);

Gradient radialny tworzy efekt „reflektora” — jaśniejszy punkt na środku w górnej trzeciej i ciemne brzegi. Punkt skupienia 50% 30% oznacza: środek poziomo, wysoko (30% od góry). To naturalne miejsce wzroku odbiorcy — oko najpierw idzie do górnej części obrazu.

Składnia gradientu radialnego:

  1. circle — kształt; alternatywnie ellipse;
  2. at 50% 30% — punkt środka;
  3. #2c3340 0% — kolor w środku (jaśniejszy szary);
  4. #0a0c10 100% — kolor na brzegach (prawie czerń).
✨ Detal artystyczny

Różnica między tłami html, body (płaskie #0d0f14) a .game-wrap (gradient) jest bardzo subtelna, ale dla oka istotna. Gdy okno jest większe niż gra, widać delikatne ramki w innym kolorze — co podkreśla, że tu jest gra, a tam tło.

07 CSS — pasek HUD

Pasek statusu to elastyczny pasek z monospaced fontem, ostro stylizowany na „pigułkę”. Każdy detal celowy: czytelność, przewidywalna szerokość liczb, brak skoków przy zmianie punktacji.

.hud{
  display:flex;
  gap:14px;
  flex-wrap:wrap;
  justify-content:center;
  align-items:center;
  font-family: 'Consolas','Courier New', monospace;
  font-size:14px;
  background: rgba(0,0,0,.55);
  padding:6px 14px;
  border-radius:999px;
  border:1px solid #333a48;
  justify-self:center;
}

display: flex i flex-wrap: wrap

Flex układa elementy w wiersz. flex-wrap: wrap mówi: „jeśli się nie mieszczą, łam do następnego wiersza zamiast wystawać”. Bez tego na wąskim ekranie (np. iPhone SE w pionie) HUD wystawałby poza kontener i pojawiał się scroll poziomy.

justify-content: center i align-items: center

Wyrównanie wzdłuż obu osi flexa. justify wzdłuż osi głównej (poziomo, bo flex‑direction domyślnie row), align wzdłuż osi poprzecznej (pionowo). Efekt: każdy element jest zarówno wycentrowany w poziomie, jak i wyśrodkowany pionowo wewnątrz paska.

justify-self: center — w gridzie, nie w flexie

Ta właściwość nie wpływa na elementy wewnątrz .hud — wpływa na sam .hud względem rodzica (.game-wrap, który jest gridem). Bez tego pasek HUD rozciągnąłby się na całą szerokość siatki. Z tym: ma szerokość treści i jest wycentrowany w wierszu.

Font monospaced w HUD

Dlaczego nie używamy system-ui jak w reszcie strony? Bo monospaced (równoszerokie) fonty mają kluczową właściwość: każda cyfra jest dokładnie tak samo szeroka jak inna. Dzięki temu liczba „0” i „99” mają stałą szerokość rezerwy w HUD i nie powodują przesuwania innych elementów gdy punktacja rośnie z 90 do 100.

Stack fontów: 'Consolas','Courier New', monospace:

  • Consolas — preferowany, bo jest piękny i obecny na Windows/macOS;
  • Courier New — fallback, prawie wszędzie;
  • monospace — generic family, gdyby żaden z powyższych nie był dostępny, system wybierze swój domyślny mono.
💡 CSS bonus

Nowsze przeglądarki wspierają font-variant-numeric: tabular-nums, który nawet w fontach proporcjonalnych wymusza równe szerokości cyfr. Można byłoby użyć tego z fontem systemowym i mieć i estetykę nowoczesnego sansa, i stabilną szerokość liczb. W naszej grze celowo zostawiono klasyczny monospaced — pasuje do retro charakteru.

border-radius: 999px — pigułka

Klasyczny trik: ekstremalnie duża wartość promienia zaokrąglenia daje kształt pigułki (kapsułki) niezależnie od rozmiaru elementu. CSS kapuje wartość do połowy mniejszej krawędzi — większa nie zaszkodzi, da maksymalne zaokrąglenie. Dlaczego nie border-radius: 50%? Bo procenty są względem szerokości i wysokości osobno — przy prostokątnym elemencie dałoby to elipsę, nie pigułkę.

rgba(0,0,0,.55) — przezroczystość

Tło HUD jest pół‑przezroczyste — przez nie prześwituje gradient z .game-wrap. Daje to wrażenie „lekkiego nakładkowego paska”, nie ciężkiej belki. Wartość 55% to balans: dość mocno, by tekst był czytelny, dość lekko, by tło prześwitywało.

Etykiety i wartości

.hud .label{ color:#8aa0c0; font-size:.8em; margin-right:4px; }
.hud .value{ color:#ffd84a; font-weight:bold; }

Etykiety („KANAPKI:”, „ŻYCIA:”) są stłumione kolorem, mniejsze (80% rozmiaru bazowego). Wartości — żółte i pogrubione. To zasada hierarchii wzrokowej: liczby przyciągają uwagę, etykiety są tylko podpisem.

Jednostka em w .8em jest względna do font‑size rodzica. Gdyby kiedyś zwiększyć HUD do 16 px, etykiety automatycznie urosłyby do 12.8 px. Z px trzeba by było aktualizować ręcznie.

08 CSS — canvas i pikselizacja

Stylowanie canvasa to chyba najbardziej techniczny fragment CSS w tej grze. Dwa rzadkie property — image-rendering i aspect-ratio — robią tu robotę, której nie da się osiągnąć inaczej.

#board{
  background:#000;
  border:2px solid #1f2530;
  border-radius:8px;
  image-rendering: pixelated;
  outline: none;
  cursor: pointer;
  box-shadow: 0 8px 32px rgba(0,0,0,.6);
  width:auto;
  height:100%;
  max-width:100%;
  max-height:100%;
  aspect-ratio: 20 / 22;
}
#board:focus{ border-color:#ffd84a; }

image-rendering: pixelated

To kluczowe property dla gier rastrowych. Wyjaśnijmy o co chodzi.

Gdy canvas o rozmiarze 640 × 704 px (atrybuty HTML) jest wyświetlany przez CSS w rozmiarze np. 880 × 968 px (większe okno), przeglądarka musi upskalować obraz. Domyślnie używa interpolacji bilinearnej — uśrednia kolory sąsiednich pikseli, żeby wynik wyglądał gładko. Efekt: rozmycie, szczególnie widoczne na ostrych krawędziach ścian gry.

pixelated mówi: „nie interpoluj, kopiuj piksele 1:1” — algorytm nearest neighbor. Każdy piksel źródłowy staje się większym kwadratem na ekranie, ale jest ostry. To efekt znajomy z emulatorów retro gier — celowy „pixel art look”.

WartośćAlgorytmEfekt
auto (domyślne)bilinearnegładko, rozmazane na zoomie
pixelatednearest neighborostre piksele, schodki
crisp-edgesalgorytm chroniący krawędziekompromis (rzadziej używane)
smoothwymuszona interpolacjagwarancja rozmycia

aspect-ratio: 20 / 22

Stosunek szerokości do wysokości. Wartość 20 / 22 bezpośrednio koresponduje z liczbą kolumn i wierszy mapy. Gdy rodzic ogranicza wysokość, canvas oblicza swoją szerokość jako height × 20/22. Gdy ogranicza szerokość, oblicza wysokość jako width × 22/20.

Bez aspect-ratio trzeba by było ręcznie skryptem korygować jeden z wymiarów po każdej zmianie rozmiaru okna — dawniej standardowy zabieg w gamedevie webowym.

Ważny kontekst: aspect-ratio działa razem z innymi wymiarami, nie zastępuje ich. W naszym kodzie:

  • height: 100% — chcemy tyle wysokości ile dostępne;
  • width: auto — szerokość niech wynika z aspect ratio;
  • max-width: 100% — ale nie szerzej niż rodzic;
  • max-height: 100% — ani wyższy niż rodzic.

Te cztery właściwości razem dają algorytm: „zajmij maksymalnie miejsce, zachowując proporcje 20:22, nie wystawaj poza rodzica”. Gdy rodzic jest szeroki — canvas dotyka góry i dołu. Gdy wąski — dotyka boków.

border i :focus

Domyślne ramka canvasa to 2px solid #1f2530 — ledwo widoczna ciemnoniebieska. Gdy canvas dostaje fokus klawiatury, ramka zmienia kolor na żółty (#ffd84a) — wizualny sygnał, że klawiatura jest „aktywna”.

Ten szczegół ma kluczowe znaczenie UX. Bez niego użytkownik nie wie, czy gra reaguje na klawiaturę. Z nim — od razu widzi, że tak (po kliknięciu canvasa lub po naciśnięciu Tab dla nawigacji klawiaturą).

outline: none — niebezpieczna optymalizacja

Domyślnie elementy w fokusie pokazują żółty/niebieski outline (zależnie od OS). My go wyłączamy, bo to brzydkie i nakłada się na naszą ramkę. Ale w zamian dajemy własny wskaźnik fokusa (zmiana koloru bordera). To zasada dostępności: outline można usunąć tylko jeśli zastąpisz go innym widocznym wskaźnikiem.

box-shadow — głębia

Cień 0 8px 32px rgba(0,0,0,.6) rzuca canvas „w górę” — sprawia, że gra wygląda jak fizyczna płyta unosząca się nad tłem. Składowe:

  • 0 — przesunięcie poziome (brak);
  • 8px — przesunięcie pionowe (cień opada w dół);
  • 32px — promień rozmycia (miękki, rozciągnięty cień);
  • rgba(0,0,0,.6) — czarny, 60% nieprzezroczysty.

09 CSS — panele i przyciski

Modale używają patternu „absolute fill + grid centering” — krótkiego, czystego i działającego wszędzie. Przyciski wykorzystują kontrastową paletę żółto‑niebieską z efektem hover.

Panel jako overlay

.panel{
  position:absolute;
  inset:0;
  display:none;
  place-items:center;
  background: rgba(0,0,0,.78);
  z-index:10;
  padding:20px;
  text-align:center;
}
.panel.show{ display:grid; }

position: absolute + inset: 0

inset to nowoczesny shorthand dla top: 0; right: 0; bottom: 0; left: 0. Mówi: „przylegaj do wszystkich krawędzi rodzica”. Razem z position: absolute daje element pokrywający cały rodzic (.stage). Element jest „wycięty” z normalnego flow — nie wpływa na pozycję canvasa.

Stary sposób (przed inset): top:0; right:0; bottom:0; left:0; — cztery linie. inset: 0 — jedna. Wszystkie nowoczesne przeglądarki to wspierają.

display: none kontra display: grid

Trick: display: none nie tylko ukrywa element, ale też całkowicie usuwa go z układu i drzewa renderowania. Element ukryty w ten sposób:

  • nie zajmuje miejsca;
  • nie odbiera kliknięć ani fokusa;
  • nie jest renderowany — zero kosztów GPU;
  • ciągle jest w DOM, więc JS może go znaleźć i ożywić.

Klasa .show zmienia display na grid. Dlaczego grid? Bo wtedy place-items: center magicznie centruje .panel-inner w obu osiach — bez liczenia marginesów ani transformacji.

place-items: center

Shorthand dla align-items: center; justify-items: center. W kontekście grida bez explicit kolumn/rzędów (czyli z domyślną siatką jednokomórkową) działa to jak „centruj wszystkie dzieci”. Najkrótszy sposób na centrowanie w CSS, jaki kiedykolwiek istniał.

z-index: 10

Panel ma być nad canvasem. Bez z-index kolejność warstw wynika z kolejności w HTML — canvas jest pierwszy, panele drugie, więc panele i tak byłyby na wierzchu. z-index: 10 to zabezpieczenie na wypadek, gdyby później ktoś dodał inne elementy z własnym z-indexem.

Wartość 10, a nie 1, daje rezerwę. Można dodać kolejne warstwy (3, 5, 7) między canvasem a panelem.

rgba(0,0,0,.78)

Tło panelu jest 78% nieprzezroczyste. Przez nie ledwie widać grę — wystarczająco, żeby gracz pamiętał, gdzie jest, ale nie tak mocno, żeby modal nie był czytelny. Pełna nieprzezroczystość zerwałaby kontekst wizualny.

Wewnętrzna karta

.panel-inner{
  background:#1a1f2a;
  padding:24px 32px;
  border-radius:12px;
  border:1px solid #333a48;
  max-width:480px;
}

max-width: 480px sprawia, że na szerokich ekranach modal nie rozciąga się przesadnie — pozostaje czytelnym blokiem tekstu. Klasyczna szerokość zalecana dla czytelnego tekstu to 45–75 znaków, a 480 px przy fontcie 16 px daje około 60 znaków.

padding: 24px 32px — większy padding poziomy niż pionowy. To estetyczny standard: tekst „oddycha” bardziej z boków, mniej z góry.

Przyciski

.btn{
  margin-top:14px;
  padding:10px 22px;
  background:#ffd84a;
  color:#1F4E79;
  border:none;
  border-radius:6px;
  font-weight:bold;
  cursor:pointer;
  font-size:15px;
}
.btn:hover{ background:#fff; }

Paleta kontrastowa: żółte tło (#ffd84a), niebieski tekst (#1F4E79). Przyciemniony niebieski na żółtym daje wysoki kontrast WCAG AAA — czytelny dla wszystkich.

border: none usuwa domyślną ramkę przycisku, którą przeglądarka rysuje na różowo/szaro. cursor: pointer sygnalizuje, że to klikalny element (domyślnie button ma cursor: default).

Hover: tło staje się czysto białe. To subtelna zmiana — ale wystarczająca, by gracz miał feedback wzrokowy. Brak transition oznacza, że zmiana jest natychmiastowa; można by dodać transition: background .15s dla płynności.

10 JS — IIFE i izolacja zmiennych

Cały JavaScript gry jest opakowany w Immediately Invoked Function Expression — funkcję, która wywołuje samą siebie. To klasyczny wzorzec izolacji namespace'u, znany od czasów ES5.

(function(){
  // … cały kod gry …
})();

Po co?

JavaScript w przeglądarce ma globalny obiekt window. Każda zmienna zadeklarowana poza jakąkolwiek funkcją staje się jego property. Czyli jeśli napiszesz:

const game = { /* … */ };

na top‑level, to window.game staje się dostępny z każdego skryptu na stronie. To dwa problemy:

  1. Konflikty nazw — jeśli inna biblioteka też używa zmiennej game, jedna nadpisze drugą. W naszej grze takich zmiennych jest pełno: TILE, COLS, game, audioCtx, muted, KEYS, ...
  2. Wyciek implementacji — z konsoli przeglądarki ktoś może podejrzeć i modyfikować game.lives = 999, oszukując grę.

IIFE rozwiązuje oba: wszystkie deklaracje wewnątrz są lokalne dla anonimowej funkcji, niedostępne z zewnątrz. window pozostaje czysty.

Składnia w detalu

(function(){ /* ciało */ })();
// ↑                       ↑↑
// nawiasy zewnętrzne      wywołanie
// (definiują wyrażenie)

Klucz: nawiasy zewnętrzne. Bez nich JavaScript próbowałby zinterpretować function(){…} jako deklarację funkcji, ale deklaracja musi mieć nazwę. Otoczenie nawiasem zmienia kontekst na function expression — wyrażenie, które zwraca obiekt funkcji. A wyrażenie można od razu wywołać przez dopisanie ().

IIFE vs ES6 modules

W nowoczesnym kodzie IIFE jest często zastępowane przez ES6 moduły:

<script type="module" src="game.js"></script>

Moduły mają automatycznie izolowany scope — każda zmienna jest lokalna chyba że ją explicit wyeksportujesz. Plus: native lazy loading, plus: ścisły tryb domyślnie, plus: top‑level await.

Dlaczego nasza gra nie korzysta? Bo:

  • cały JS jest w jednym pliku — moduły są dla wielu plików;
  • moduły wymagają hostingu HTTP (CORS), nie działają z file://;
  • uczniowie często otwierają plik podwójnym kliknięciem — z modułami nie zadziała;
  • IIFE działa od ES5 (od 2009 roku) — to zerowe ryzyko kompatybilności.
📚 Drobnostka historyczna

IIFE bywa też nazywane „samorozumiejącą funkcją” (self‑executing function). Termin został spopularyzowany przez Bena Almana w 2010 r. w słynnym wpisie na blogu. Wcześniej używano nazwy „self‑invoking anonymous function” — krócej, ale niedokładnie (funkcja siebie nie wywołuje, wywołuje ją kod, który ją otacza).

11 JS — stałe konfiguracyjne

Trzy liczby otwierające moduł gry to wszystko, czego potrzeba do zdefiniowania jej wymiarów. Zmiana którejkolwiek wymaga równoczesnej aktualizacji rozmiaru canvasa w HTML.

const TILE = 32;
const COLS = 20;
const ROWS = 22;

Konwencja UPPER_CASE

Stałe pisane wielkimi literami z podkreślnikami to konwencja zaczerpnięta z C/Java. W JS nie ma technicznej różnicy między const tile i const TILE — obie są niemodyfikowalnymi referencjami. Ale konwencja niesie informację dla czytelnika: „to jest magic number, którego nie zmieniasz w trakcie działania”.

Dlaczego const, nie let ani var?

SłowoScopeReassignmentHoisting
varfunkcyjnytakdo góry funkcji, jako undefined
letblokowytakjest, ale „temporal dead zone”
constblokowyNIEjak let

Domyślnie używamy const. Dopiero gdy potrzebujemy reasignment, sięgamy po let. var jest już praktycznie martwe — jego specyficzny scope (funkcyjny) bywał przyczyną wielu bugów.

Liczby konkretne — dlaczego takie?

TILE = 32

Standard w grach 2D. Liczby będące potęgami dwójki (16, 32, 64) są historycznie preferowane, bo:

  • tekstury w GPU są efektywniej teksturowane przy potęgach 2;
  • operacje typu „która kratka pod współrzędną” to bit shift zamiast dzielenia (na poziomie kompilatora JIT);
  • 32 px to wygodny rozmiar dla emoji w grach (nie za małe, czytelne).

Można by równie dobrze użyć 24 lub 40 — gra by działała. 32 to tradycja, nie konieczność.

COLS = 20, ROWS = 22

Wymiary mapy. 20 × 22 = 440 kratek łącznie. Dlaczego prostokąt, a nie kwadrat?

  • Standardowy stosunek ekranów dawnego Pac‑Mana to 28 × 36 — wyraźnie pionowy. Nasza wersja jest „bardziej kwadratowa”, ale wciąż nieco pionowa.
  • Pionowy układ pasuje do iframe na stronach blogowych, gdzie szerokość bywa ograniczona.
  • 22 wiersze dają miejsce na klatkę kasztanków na środku oraz dwie korytarze obwodowe (góra i dół).

Zmiana którejkolwiek wartości wymaga równoległej zmiany w HTML:

<canvas id="board" width="640" height="704" ... >
<!-- 640 = COLS × TILE = 20 × 32 -->
<!-- 704 = ROWS × TILE = 22 × 32 -->

oraz w CSS:

aspect-ratio: 20 / 22;

To dobra okazja do refaktoringu — wartości można by wstawić przez JS na starcie:

board.width  = COLS * TILE;
board.height = ROWS * TILE;
board.style.aspectRatio = `${COLS} / ${ROWS}`;

Ale celowo tego nie robimy — chcemy, żeby HTML „wyglądał” sensownie też przed wykonaniem JS (np. gdy ktoś widzi widok źródła).

12 JS — mapa labiryntu

Mapa to tablica 22 stringów po 20 znaków każdy. Każdy znak to typ kafelka. Format jest brzydki obliczeniowo, ale pięknie czytelny dla człowieka — w edytorze widać kształt labiryntu.

const RAW_MAP = [
  "00000000000000000000",   //  0 — górna ściana
  "01111111111111111110",   //  1 — pierwszy korytarz z kanapkami
  "01001111101011111110",   //  2
  "03001110001000111030",   //  3 — kawy w narożnikach
  "01111110101011111110",   //  4
  "01001110101010011110",   //  5
  "01111110101011111110",   //  6
  "00000010001000100000",   //  7 — wąski przesmyk
  "11111110111011111111",   //  8 — korytarz teleportacyjny? (krawędzie)
  "00000010101010100000",   //  9
  "11111110144441111111",   // 10 — klatka kasztanków (lewa krawędź)
  "00000010144441000000",   // 11 — klatka kasztanków (prawa krawędź)
  "11111110111011111111",   // 12
  "00000010101010100000",   // 13
  "11111110101011111111",   // 14
  "01111111110011111110",   // 15
  "01001110101010011110",   // 16
  "03101110101011101030",   // 17 — kawy
  "01111110101011111110",   // 18
  "01000111101011110010",   // 19
  "01111111111111111110",   // 20 — dolny korytarz
  "00000000000000000000",   // 21 — dolna ściana
];

Kodowanie kafelków

KodZnaczenieKolizja?Po zjedzeniu staje się
0ścianatak — blokuje ruch
1kanapka (kropka)nie — można wejść, +10 pkt2
2pusta ścieżkanie — można wejść
3kawa (power‑up)nie, +50 pkt + scared‑mode2
4klatka kasztankównie

Dlaczego stringi?

Można by trzymać mapę jako const RAW_MAP = [[0,0,0,...], [0,1,1,...], ...] — czysta tablica liczb. Działałoby identycznie. Ale wersja stringowa daje:

  • Czytelność wizualną — patrząc na kod widzisz kształt labiryntu. To gigantyczne ułatwienie przy edycji.
  • Krótszy zapis — string „01001” to 5 znaków; tablica [0,1,0,0,1] to 17 znaków razem z przecinkami.
  • Łatwiejsze kopiowanie — mapę można wkleić z innego edytora, np. ASCII art tool.

Wadą jest konieczność konwersji na liczby przy starcie — co dzieje się w jednej linii:

this.map = RAW_MAP.map(row => row.split('').map(c => +c));

Anatomia tej linii

Trzy zagnieżdżone wywołania .map() i jedno .split(). Krok po kroku:

  1. RAW_MAP.map(row => ...) — iteracja po wszystkich 22 wierszach. row to string typu "00000000000000000000".
  2. row.split('') — rozbicie stringa na pojedyncze znaki. "010" staje się ["0", "1", "0"].
  3. .map(c => +c) — konwersja każdego znaku na liczbę. "0" staje się 0.

Operator unarny +

+c to skrócona forma Number(c). JavaScript ma kilka sposobów konwersji string → liczba:

+"5"              // 5
Number("5")       // 5
parseInt("5", 10) // 5
parseFloat("5.0") // 5.0
"5" * 1           // 5  (wymuszenie kontekstu numerycznego)
"5" - 0           // 5  (j.w.)

+ jest najkrótszy i najczytelniejszy. parseInt ma swoje uroki (parsuje „5px” na 5), ale tutaj przeszkadza — chcemy ścisłej konwersji.

Klonowanie mapy

Operacja RAW_MAP.map(...) tworzy nową tablicę zewnętrzną i nowe tablice wewnętrzne. To ważne, bo gra modyfikuje mapę (zjedzona kanapka 12). Gdyby this.map była tylko referencją do RAW_MAP, modyfikacje psułyby oryginał i przy reset() mapa nie wracałaby do stanu początkowego.

Dlaczego .map() wystarcza, a nie trzeba np. JSON.parse(JSON.stringify(...))? Bo RAW_MAP to tablica stringów (pierwotne wartości). RAW_MAP.map(row => ...) daje nową tablicę zewnętrzną, a wewnątrz każdego wiersza row.split('').map(c => +c) tworzy zupełnie nową tablicę liczb. Żadna referencja nie jest dzielona z oryginałem.

Adresowanie

Po konwersji mapa jest dwuwymiarową tablicą liczb. Adresacja: this.map[y][x] — najpierw wiersz (y), potem kolumna (x). Łatwo to pomylić — w matematyce piszemy najpierw x, potem y, w macierzach najpierw wiersz, potem kolumnę. Tu pasujemy do macierzy, bo „wiersz wcześniej” odzwierciedla kolejność linii w pliku źródłowym.

Po ludzku: skąd komputer wie, gdzie co jest?

Wyobraź sobie szafę z szufladami. Szafa ma 22 poziomy (półki), a na każdej półce jest 20 szufladek. Żeby znaleźć konkretną szufladkę, musisz powiedzieć: „druga półka od góry, piąta szufladka od lewej”. To dokładnie to samo co map[2][5] w naszym kodzie — pierwsza liczba mówi „która półka” (wiersz), druga „która szufladka” (kolumna).

Układ współrzędnych — uwaga, Y rośnie w dół! Tak jak w piśmie: zaczynasz od góry-lewej, czytasz w prawo, potem schodzisz w dół 🧑‍🏫 🌰 0 1 2 3 4 5 0 1 2 3 4 X (kolumna) → Y (wiersz) Dyrektor 🧑‍🏫 jest w (x=1, y=1) map[1][1] — najpierw Y, potem X! Kasztanek 🌰 jest w (x=4, y=3) map[3][4] — wiersz 3, kolumna 4 ⚠️ Uwaga: w grach Y rośnie W DÓŁ, nie w górę jak w matematyce!
Współrzędne (x, y) — X w prawo, Y w dół. To inaczej niż na lekcji matematyki, ale typowo dla ekranu komputera.

Liczenie kanapek na starcie

this.totalSandwiches = 0;
this.map.forEach(r => r.forEach(c => { if (c===1) this.totalSandwiches++; }));

Dwie zagnieżdżone pętle forEach przebiegające całą siatkę i zliczające kafelki typu 1 (kanapki). Można by zwięźlej:

this.totalSandwiches = this.map.flat().filter(c => c === 1).length;

.flat() spłaszcza tablicę 2D do 1D. .filter() zwraca tylko jedynki. .length daje liczbę. Eleganckie, ale tworzy dwie tymczasowe tablice — minimalnie wolniejsze. Dla 440 elementów to nieistotne, ale w grze 1000×1000 byłaby różnica.

Wartość totalSandwiches przyda się do wyświetlenia w HUD — „zebrałeś X z Y kanapek”.

13 JS — Web Audio API: podstawy

Cały dźwięk w grze jest syntezowany w locie — nie ma żadnego pliku .mp3 ani .wav. To 15 linii kodu i kilka koncepcji z Web Audio API. Pora wytłumaczyć, jak to w ogóle działa.

Czym jest Web Audio API?

To natywny API przeglądarki do generowania, modyfikowania i odtwarzania dźwięku. W przeciwieństwie do prostego <audio> (który tylko odtwarza pliki), Web Audio API pozwala budować graf węzłów audio — łańcuch przetwarzania sygnału, znany z prawdziwych syntezatorów i mikserów.

Model grafu

Graf audio to skierowany graf, w którym każdy węzeł albo generuje sygnał (oscylator, źródło z pliku), albo przetwarza go (filtr, kontrola głośności, panorama, opóźnienie), albo odbiera (głośniki, czyli destination).

Oscillator freq, type GainNode obwiednia Destination głośniki źródło sygnału kontrola głośności wyjście audio o.connect(g) g.connect(destination)
Graf audio dla pojedynczego beepa: oscylator → gain → wyjście.

W naszej grze graf jest minimalny: oscylator → gain → destination. Tylko trzy węzły. Można rozbudowywać — dodać filtr biquad dla ciemnego brzmienia, chór dla pogrubienia, reverb dla przestrzenności. Ale nasz „chip‑tune” charakter zyskuje na surowości.

AudioContext — fundament

let audioCtx = null;
let muted = false;
let wakaToggle = false;

function ensureAudio(){
  if (!audioCtx) {
    try {
      audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    } catch(e){}
  }
  if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}

Lazy initialization

audioCtx jest tworzony dopiero na żądanie (lazy), nie od razu na starcie. Dlaczego?

  • Polityka autoplay — wszystkie nowoczesne przeglądarki blokują automatyczne odtwarzanie audio przed pierwszą interakcją użytkownika. Twórca AudioContext przy pierwszym kliknięciu jest „kosherną” praktyką.
  • Oszczędność zasobów — AudioContext alokuje pamięć i podpina się do systemu audio OS. Jeśli użytkownik nie kliknie „Start”, nie ma sensu tego rezerwować.

Konstrukcja warunkowa window.AudioContext || window.webkitAudioContext

Stara historia kompatybilności. Web Audio API miał prefiks webkit w starszych Safari (do 2017). Dziś prefiksowane API jest praktycznie niespotykane, ale dwie linie kodu więcej nie szkodzą — dają wsparcie nawet dla starych iPadów.

Składnia (A || B) w JS znaczy: jeśli A jest „prawdziwe” (truthy), zwróć A; w przeciwnym razie zwróć B. Dla nieistniejącej property dostajemy undefined (falsy), więc fallback działa.

Try / catch dookoła

Próba utworzenia AudioContext może rzucić wyjątek na bardzo starych lub egzotycznych przeglądarkach. try/catch zabezpiecza nas — gdy się nie uda, gra po prostu działa bez dźwięku, zamiast się wykrzaczyć.

Stan suspended i resume()

AudioContext ma cykl życia z trzema stanami:

  • suspended — utworzony, ale „zatrzymany” (autoplay policy);
  • running — działa normalnie;
  • closed — zwolniony, nie używalny.

Po pierwszym utworzeniu może być w stanie suspended — wywołanie resume() przenosi go w running. To metoda zwracająca Promise, ale my jej nie awaitujemy — fire and forget. Najgorszy scenariusz: pierwszy beep się nie uda. Drugi już zadziała.

Stan muted i wakaToggle

Dwie pomocnicze flagi:

  • muted — true, gdy gracz wcisnął M. Każdy beep sprawdza tę flagę i jeśli true, wraca natychmiast bez generowania dźwięku.
  • wakaToggle — naprzemiennie true/false. Funkcja waka() zmienia ją przy każdym wywołaniu, dzięki czemu naprzemienne tony 440 / 330 Hz dają charakterystyczny „mlask” pacmanowy.

14 JS — generator beep()

Funkcja generująca pojedynczy ton — 9 linii, ale każda istotna. To miniaturowy syntezator z obwiednią głośności.

function beep(freq, dur, type='square', vol=0.08){
  if (muted || !audioCtx) return;
  const o = audioCtx.createOscillator();
  const g = audioCtx.createGain();
  o.type = type;
  o.frequency.value = freq;
  g.gain.value = vol;
  o.connect(g);
  g.connect(audioCtx.destination);

  const t = audioCtx.currentTime;
  o.start(t);
  g.gain.setValueAtTime(vol, t);
  g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
  o.stop(t + dur + 0.02);
}

Parametry funkcji

  • freq — częstotliwość w hercach (Hz). 440 Hz to środkowa „A” (la);
  • dur — czas trwania w sekundach (np. 0.07 = 70 ms);
  • type — kształt fali (default 'square');
  • vol — głośność szczytowa (0.0 do 1.0; default 0.08, czyli 8% maksymalnej).

Wczesny return

if (muted || !audioCtx) return;

Klasyczny wzorzec „guard clause”. Trzy scenariusze blokujące:

  • gracz wyciszył (M);
  • kontekst audio nie istnieje (np. nie wywołano jeszcze ensureAudio());
  • kontekst nie udało się stworzyć (stara przeglądarka).

Tworzenie węzłów

const o = audioCtx.createOscillator();
const g = audioCtx.createGain();

Każdy beep ma nowy oscylator i nowy gain. To obowiązkowe — w Web Audio API OscillatorNode jest jednorazowy: można go wystartować i zatrzymać dokładnie raz. Drugi start() rzuca wyjątek.

Brzmi marnotrawnie? W rzeczywistości GC (garbage collector) JS zwija nieużywane węzły bardzo efektywnie. Web Audio API jest do tego zaprojektowany — tworzenie tysięcy oscylatorów na sekundę nie wpływa na wydajność.

Kształty fali

o.type = type;  // 'square', 'sawtooth', 'triangle', 'sine'
TypBrzmienieCharakter widmowyZastosowanie w grze
sineczyste, łagodnetylko podstawowa
squareelektroniczne, „chip‑tune”nieparzyste harmonicznewaka, jingleStart
sawtoothostre, agresywnewszystkie harmonicznejingleDeath
trianglełagodne ale z barwąnieparzyste, słabszejinglePower

Kwadratowa fala to symbol gier 8‑bitowych — Atari, NES, Commodore 64 generowały dźwięk falami prostokątnymi. Stąd „chiptune” — gatunek muzyki imitujący tamtą estetykę.

Częstotliwość

o.frequency.value = freq;

Property frequency nie jest zwykłą liczbą — jest AudioParam, specjalnym obiektem reprezentującym wartość zmienną w czasie. Dlatego ustawiamy ją przez .value = (natychmiastowa wartość) albo przez metody jak setValueAtTime(), linearRampToValueAtTime() itd.

Tu używamy najprostszej formy — wartość stała.

Łączenie węzłów

o.connect(g);
g.connect(audioCtx.destination);

Sygnał płynie z oscylatora przez gain do wyjścia (głośniki/słuchawki). audioCtx.destination to specjalny węzeł docelowy — wbudowane wyjście kontekstu audio.

Można by jeszcze dodać BiquadFilterNode dla filtrowania, StereoPannerNode dla pozycjonowania w stereo, czy ConvolverNode dla pogłosu. Każdy nowy węzeł wkłada się do łańcucha przez kolejne connect().

Obwiednia głośności (envelope)

const t = audioCtx.currentTime;
o.start(t);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.stop(t + dur + 0.02);

To najciekawszy fragment. Obwiednia (envelope, ADSR) to sposób, w jaki głośność zmienia się w czasie. Bez obwiedni dźwięk byłby „klik!”/szum/„klik!” na końcu — bo nagłe pojawienie się i zniknięcie sygnału tworzy tzw. trzaski (DC click, na widmie szerokie pasmo nieprzyjemne dla ucha).

Nasza obwiednia jest najprostsza możliwa: pełna głośność na początku, eksponencjalny zanik do prawie zera. Brak ataku ani sustaina — to nie miękki ton fortepianu, to ostry beep gry retro.

Krok po kroku:

  1. const t = audioCtx.currentTime — pobranie aktualnego czasu kontekstu (w sekundach od jego utworzenia, nie od epoki). Wszystkie operacje audio operują na tej osi czasu.
  2. o.start(t) — uruchomienie oscylatora dokładnie w chwili t. Faktycznie jest to „natychmiast”, ale używamy explicit czasu, żeby synchronizować z gainem.
  3. g.gain.setValueAtTime(vol, t) — ustaw głośność na vol w chwili t. Dlaczego nie g.gain.value = vol? Bo setValueAtTime jest pierwszym krokiem na osi czasu — kolejne ramp będą interpolować od tej wartości.
  4. g.gain.exponentialRampToValueAtTime(0.0001, t + dur) — eksponencjalna zmiana głośności do prawie zera (0.0001) w czasie od t do t + dur. Eksponencjalna, nie liniowa, bo ucho ludzkie odbiera głośność logarytmicznie — eksponencjalny zanik brzmi „naturalnie”, jak gasnący dzwon.
  5. o.stop(t + dur + 0.02) — zatrzymanie oscylatora 20 ms po końcu obwiedni. Dlaczego z marginesem? Żeby mieć pewność, że gain dotarł do zera zanim oscylator się wyłączy. Inaczej dostalibyśmy malutki trzask na końcu.
⚠️ Pułapka eksponencjalnej rampy

exponentialRampToValueAtTime nie działa do dokładnej wartości 0 — eksponencjalna funkcja nigdy nie osiąga zera (asymptota). Próba podania 0 rzuca wyjątek. Dlatego używamy 0.0001 — wartość niesłyszalna, ale technicznie niezerowa. Klasyczny gotcha Web Audio API.

15 JS — dżingle i melodyjki

Pojedyncze beepy są nudne. Sekwencja kilku beepów w odpowiednich częstotliwościach to już melodyjka. W kodzie gry jest pięć takich dżingli — każdy buduje skojarzenie z konkretnym wydarzeniem.

Sekwencja przez setTimeout

function jingleStart(){
  [523, 659, 784, 1046].forEach((f,i) =>
    setTimeout(() => beep(f, 0.12, 'square', 0.1), i*100));
}

Cztery częstotliwości w tablicy, każda gra po 100 ms później niż poprzednia. Efekt: arpeggio z 4 nut. Cóż za dziwne liczby? Spójrzmy:

Częstotliwość (Hz)NutaPozycja w skali
523C5do
659E5mi
784G5sol
1046C6do (oktawa wyżej)

To akord C‑dur w pierwszej inwersji, zakończony skokiem o oktawę. Brzmi „radośnie, optymistycznie” — idealne na rozpoczęcie gry. Klasyczna „upbeat fanfare”.

Skala chromatyczna i równomierne temperowanie

Skąd te konkretne liczby? Współczesna muzyka używa równomiernie temperowanej skali chromatycznej — 12 półtonów w oktawie, z tym że każdy kolejny półton jest 2^(1/12) razy wyższy od poprzedniego. Wzór:

// f_n = f_0 × 2^(n/12)
// gdzie f_0 to A4 = 440 Hz, n to liczba półtonów od A4

const A4 = 440;
const halfStepsFromA4 = 3;            // np. C5 jest 3 półtony nad A4
const C5 = A4 * Math.pow(2, 3/12);    // ≈ 523.25 Hz

Stąd liczba 523 (zaokrąglone). Można byłoby napisać sobie helper:

const noteFreq = (n) => 440 * Math.pow(2, n/12);
// noteFreq(3)  → C5
// noteFreq(15) → C6 (oktawa wyżej)

W kodzie gry jednak hardkodowano liczby — krócej, czytelniej dla nieprogramistów muzyki.

Dżingiel śmierci

function jingleDeath(){
  [659, 587, 523, 440, 330].forEach((f,i) =>
    setTimeout(() => beep(f, 0.18, 'sawtooth', 0.1), i*120));
}

Pięć tonów malejących: E5 → D5 → C5 → A4 → E4. Skala opadająca brzmi „smutno”, „zniechęcająco” — idealne na przegraną. Dłuższe nuty (180 ms) i piłozębna fala ('sawtooth') dają chrupkie, „zepsute” brzmienie.

Inne dżingle

function jingleEat(){
  [880, 1175].forEach((f,i) =>
    setTimeout(() => beep(f, 0.1, 'square', 0.12), i*60));
}

function jinglePower(){
  for(let i=0; i<6; i++)
    setTimeout(() => beep(523 + i*40, 0.08, 'triangle', 0.08), i*50);
}

jingleEat — szybkie „ding‑ding”, dwa wysokie tony A5+D6. Sygnał „zjedzono kasztanka”.

jinglePower — wstępujący glissando, sześć tonów co +40 Hz. Brzmienie wzlotu. Notabene — częstotliwości 523, 563, 603, 643... nie są nutami muzycznymi (skok 40 Hz nie odpowiada żadnemu interwałowi), więc dżingiel ma „obcy”, „magiczny” charakter — wzbudzenie zamiast melodii.

Funkcja waka() — rytm pochłaniania

let wakaToggle = false;

function waka(){
  wakaToggle = !wakaToggle;
  beep(wakaToggle ? 440 : 330, 0.07, 'square', 0.06);
}

Naprzemiennie 440 Hz (A4) i 330 Hz (E4). Skok kwarty czystej między nimi to charakterystyczny interwał Pac‑Mana. Funkcja jest wywoływana co 16 klatek w trakcie ruchu, co przy 60 fps daje ~3.75 wywołań na sekundę. Ucho odbiera to jako rytmiczne „waka‑waka” — stąd nazwa.

Operator ! przed wakaToggle negouje wartość. Klasyczny pattern „flag toggle” w jednej linii.

Dlaczego setTimeout, a nie precyzyjny scheduling Web Audio?

Web Audio API ma własny scheduler — można powiedzieć: „zagraj ton w czasie t, drugi w t+0.1, trzeci w t+0.2”. Byłoby to perfekcyjnie precyzyjne (< 1 ms), bo działa w wątku audio, niezależnie od głównego wątku JS.

Nasze setTimeout jest mniej precyzyjne — może się opóźnić o 5–20 ms. Dla dżingli to jednak nieistotne, bo:

  • nie potrzebujemy idealnej synchronizacji (to nie sequencer, tylko UX feedback);
  • setTimeout jest prostsze do napisania;
  • nie ma ryzyka „kolejkowania” dżingli w schedulerze, gdy gracz spamuje akcje.

Dla precyzyjnej muzyki w grach (np. rytmicznych typu Guitar Hero) zdecydowanie używa się Web Audio scheduling — ale to inny temat.

16 JS — obiekt game

Cała logika gry żyje w jednym obiekcie. Bez klas, bez frameworków, bez Reduxa. Po prostu literał obiektowy z polami danych i metodami. To świadomy wybór architektoniczny, nie naiwność.

const game = {
  map: [],
  totalSandwiches: 0,
  director: null,
  chestnuts: [],
  score: 0,
  lives: 3,
  level: 1,
  powerTimer: 0,
  frame: 0,
  running: false,
  paused: false,
  deathAnim: 0,

  reset(){ /* … */ },
  spawnChestnuts(){ /* … */ },
  softReset(){ /* … */ },
  isWall(x, y){ /* … */ },
  canMove(entity, dx, dy){ /* … */ },
  tick(){ /* … */ },
  draw(){ /* … */ },
  updateHud(){ /* … */ },
  gameOver(won){ /* … */ },
};

Pola danych

map — bieżący stan planszy

Tablica 2D liczb (22 wiersze × 20 kolumn). Klonowana z RAW_MAP przy starcie i przy każdym ukończeniu poziomu. Modyfikowana w trakcie gry (zjedzona kanapka 12).

totalSandwiches — liczba kanapek na starcie poziomu

Stała w obrębie poziomu, używana do obliczenia maksymalnego wyniku (HUD pokazuje „Wynik / Maksimum”).

director — gracz

Obiekt z polami: x, y, dx, dy, nextDx, nextDy, alive, mouthAnim. Inicjalizowany w reset().

chestnuts — wrogowie

Tablica 4 obiektów. Każdy: {x, y, dx, dy, color, scared}. Spawn w spawnChestnuts().

score, lives, level

Liczby. score resetuje się na nowej grze, lives spada przy kolizji, level rośnie po zebraniu wszystkich kanapek.

powerTimer — odliczanie kawy

Liczba ticków pozostałych w trybie „kasztanki przestraszone”. Maleje co tick. Kiedy spadnie do 0, kasztanki wracają do trybu agresywnego.

frame — globalny licznik klatek

Inkrementowany w każdym wywołaniu tick(). Używany do:

  • bramki modulo (frame % 8) — ruch siatkowy;
  • animacji pulsacji (Math.sin(frame/6));
  • częstotliwości waka (frame % 16);
  • okresowej zmiany decyzji AI (frame % 32).

running i paused

Flagi kontroli pętli. running = false oznacza, że gra nie działa (ekran intro lub po końcu). paused jest zarezerwowany — w obecnej wersji nie ma pauzy, ale framework jest gotowy.

deathAnim — licznik animacji śmierci

Gdy gracz dotyka kasztanka (nie scared), deathAnim ustawia się na 30. Maleje co tick. Przez 30 klatek dyrektor zanika (alfa od 1 do 0). Gdy spadnie do 0, gra resetuje pozycje albo kończy się (jeśli lives = 0).

Dlaczego nie klasa ES6?

// Wersja klasowa wyglądałaby tak:
class Game {
  constructor(){
    this.map = [];
    this.score = 0;
    // …
  }
  reset(){ /* … */ }
  tick(){ /* … */ }
}
const game = new Game();

Funkcjonalnie identyczne. Powody, dla których wybrano literał:

  • Jeden egzemplarz — w grze jest tylko jeden game. Klasa byłaby przereklamowaniem.
  • Dosłowna inicjalizacja — wartości pól widać od razu, bez czytania konstruktora.
  • Mniejsza ceremonia — bez class, constructor, new.
  • Mniej linii — funkcje wewnątrz literału mogą być pisane skrótem ES6: tick(){…} zamiast tick: function(){…}.

Klasa byłaby uzasadniona, gdybyśmy chcieli dziedziczenie (np. EnemyGame extends Game) lub wiele instancji (kilka gier równocześnie). Tu — niepotrzebna.

Skrócona składnia metod

// ES5:
const game = {
  tick: function(){ /* … */ },
  draw: function(){ /* … */ },
};

// ES6 (krócej i identycznie):
const game = {
  tick(){ /* … */ },
  draw(){ /* … */ },
};

Dla obiektowych metod różnica wizualna duża, semantyczna żadna. Wszędzie poza arrow functions w property — gdzie this zachowuje się inaczej.

this w metodach

W każdej metodzie this wskazuje na obiekt game. To pozwala metodom czytać i modyfikować pola: this.score += 10. Trzeba uważać tylko z arrow functions wewnątrz callbacków:

tick(){
  // tu this == game ✓
  this.chestnuts.forEach(c => {
    // tu też this == game ✓ (arrow nie przerywa kontekstu)
    if (c.x === this.director.x) { /* … */ }
  });

  this.chestnuts.forEach(function(c){
    // tu this == undefined (strict) lub window ✗
    // klasyczna pułapka starszego JS
  });
}

W naszym kodzie wszystkie callbacki to arrow functions, więc this dziedziczy z otaczającej metody. Dlatego można używać this.director wewnątrz forEach.

17 JS — reset() i klonowanie mapy

Funkcja resetująca grę do stanu początkowego. Wywoływana przy starcie (po kliknięciu „▶ Start”) i po zakończeniu gry (klikiem „⟳ Zagraj ponownie”). 13 linii czystej inicjalizacji.

reset(){
  // klon mapy
  this.map = RAW_MAP.map(row => row.split('').map(c => +c));
  this.totalSandwiches = 0;
  this.map.forEach(r => r.forEach(c => { if (c===1) this.totalSandwiches++; }));

  // dyrektor — start gdzieś w korytarzu
  this.director = {
    x: 1, y: 1,
    dx: 0, dy: 0,
    nextDx: 0, nextDy: 0,
    alive: true,
    mouthAnim: 0
  };
  this.score = 0;
  this.spawnChestnuts();
  this.powerTimer = 0;
  this.frame = 0;
  this.running = true;
  this.paused = false;
  this.deathAnim = 0;
  this.updateHud();
},

Krok 1 — klon mapy i zliczenie kanapek

Dwie pierwsze instrukcje klonują mapę z RAW_MAP i liczą jedynki. Mówiliśmy o tym wcześniej (sekcja 12). Kluczowe: nigdy nie modyfikujemy RAW_MAP — to read‑only wzorzec. Modyfikujemy tylko klon w this.map.

Krok 2 — inicjalizacja dyrektora

this.director = {
  x: 1, y: 1,
  dx: 0, dy: 0,
  nextDx: 0, nextDy: 0,
  alive: true,
  mouthAnim: 0
};

Pozycja startowa (1, 1) — pierwsza nieścianowa kratka w lewym górnym rogu. Patrz na RAW_MAP:

  • Wiersz 0: "00000000000000000000" — same ściany;
  • Wiersz 1: "01111111111111111110" — korytarz, na pozycji x=1 jest jedynka.

Dyrektor staje na pierwszej kanapce. Po pierwszym wywołaniu tick() ją zje (jeśli się ruszy).

Pola kierunkowe

  • dx, dy — aktualny wektor ruchu. (0, 0) oznacza „nie ruszam się”. (1, 0) — w prawo. (0, -1) — w górę.
  • nextDx, nextDy — bufor: kierunek, w który gracz chce skręcić, ale jeszcze się to nie powiodło (np. ściana w drodze).

To jeden z najmilszych trików w grach kafelkowych. Klasycznie problem wygląda tak: gracz chce skręcić w lewo na zbliżającym się skrzyżowaniu, wciska strzałkę za wcześnie. W naiwnym sterowaniu strzałka „nic nie robi” — bo w lewo jest ściana w danej chwili. Z buforem: strzałka zapamiętuje się, a gdy postać dotrze do skrzyżowania i lewa droga jest wolna, automatycznie tam skręca. To wybacza nieprecyzyjny timing. UX zysk ogromny.

mouthAnim

Pole z mojej obserwacji nieużywane w renderze (sprawdziłem draw()) — ale obecne. To „przygotowane miejsce” na animację otwierania paszczy (jak w klasycznym Pac‑Manie). Można w przyszłości dodać klatki rysowania, gdzie pyszczek emoji 🧑‍🏫 zmienia się synchronicznie z polem.

Krok 3 — wyzerowanie liczników

this.score = 0;
this.spawnChestnuts();
this.powerTimer = 0;
this.frame = 0;
this.running = true;
this.paused = false;
this.deathAnim = 0;
this.updateHud();

Kolejność istotna w kilku miejscach:

  • spawnChestnuts() przed updateHud() — żeby HUD pokazał aktualny stan;
  • running = true ostatnia wartość przed updateHud() — żeby pętla zaczęła działać dopiero po pełnej inicjalizacji;
  • frame = 0 — wszystkie cykle (modulo) zaczynają od czystego licznika.

softReset() — reset bez utraty postępu

softReset(){
  this.director.x = 1; this.director.y = 1;
  this.director.dx = 0; this.director.dy = 0;
  this.director.nextDx = 0; this.director.nextDy = 0;
  this.spawnChestnuts();
  this.powerTimer = 0;
  this.deathAnim = 0;
},

Wywoływane po śmierci dyrektora (gdy są jeszcze życia). Resetuje pozycje, ale zachowuje mapę z dotychczas zjedzonymi kanapkami, score, lives, level. Gracz nie traci postępu w poziomie — tylko cofa się na start.

Porównaj z reset(): ten klonuje świeżą mapę z RAW_MAP (kanapki znowu wszystkie), zeruje wszystko. To „nowa gra”. softReset to „nowe życie w bieżącej grze”.

Awansowanie poziomu

W tick() jest jeszcze trzeci wariant resetu — gdy gracz zje wszystkie kanapki:

if (left === 0){
  this.level++;
  this.map = RAW_MAP.map(row => row.split('').map(c => +c));
  this.softReset();
  this.updateHud();
  return;
}

Ten klonuje świeżą mapę (kanapki wracają), ale przez softReset() zachowuje score i lives. To kontynuacja na trudniejszym poziomie (w obecnej wersji bez zmiany trudności — ale framework jest gotowy: można byłoby przyspieszyć kasztanki na wyższych poziomach).

🔄 Trzy rodzaje resetu

reset() — pełen restart (nowa gra: świeża mapa, zerowy score, 3 życia, poziom 1).
softReset() — restart pozycji (po śmierci: zachowuje score/lives/level/mapę).
Awans poziomu (inline w tick()) — świeża mapa + softReset (zachowuje score/lives, inkrementuje level).

18 JS — spawn kasztanków

Czterech wrogów, każdy w klatce na środku planszy. Cztery linie kodu, ale kilka istotnych decyzji projektowych zaszytych w detalu.

spawnChestnuts(){
  this.chestnuts = [
    { x: 9,  y: 10, dx: 1,  dy: 0, color:'#ff4444', scared:false },
    { x: 10, y: 10, dx:-1,  dy: 0, color:'#ffb6c1', scared:false },
    { x: 9,  y: 11, dx: 0,  dy: 1, color:'#22d3ee', scared:false },
    { x: 10, y: 11, dx: 0,  dy:-1, color:'#fbbf24', scared:false },
  ];
},

Pozycje startowe

Klatka kasztanków to obszar 2 × 2 kratek na środku mapy:

  • (9, 10) i (10, 10) — górny rząd klatki;
  • (9, 11) i (10, 11) — dolny rząd klatki.

Spójrzmy na RAW_MAP w okolicach wierszy 10–11:

// indeks       0123456789012345678901
//                       1111111111
RAW_MAP[10] = "11111110144441111111"
RAW_MAP[11] = "00000010144441000000"

Czwórki na pozycjach 8–11 to klatka. Cztery kasztanki są na pozycjach 9 i 10 (czyli środkowe dwie z czterech). To celowe — pierwsze i ostatnie miejsce w klatce zostawiamy puste, żeby kasztanki miały miejsce do „wyjścia”.

Kierunki startowe

KasztanekPozycjaKierunekKolorSkojarzenie
1(9, 10)→ prawo (1, 0)#ff4444 — czerwonyBlinky
2(10, 10)← lewo (-1, 0)#ffb6c1 — różowyPinky
3(9, 11)↓ dół (0, 1)#22d3ee — cyjanInky
4(10, 11)↑ góra (0, -1)#fbbf24 — pomarańczClyde

Cztery różne kierunki — żeby kasztanki natychmiast się rozproszyły, zamiast iść kolumną. Cztery kolory — bezpośredni cytat z oryginalnego Pac‑Mana (Blinky/Pinky/Inky/Clyde to kanoniczne imiona duszków).

System współrzędnych

Drobny ale istotny detal: oś Y rośnie w dół. dy = 1 oznacza ruch w dół, dy = -1 — w górę. To konwencja ekranowych systemów współrzędnych — pozycja (0, 0) jest w lewym górnym rogu, a Y rośnie w stronę dolnej krawędzi.

To inaczej niż w matematyce (gdzie Y rośnie do góry). Dla osoby przyzwyczajonej do układu kartezjańskiego — początkowo niewygodne. Po kilku godzinach gamedevu — naturalne.

Pole scared

Domyślnie false. Gdy gracz zje kawę, wszystkim kasztankom ustawiamy scared = true. AI sprawdza tę flagę i decyduje, czy uciekać (gdy true), czy gonić (gdy false).

Pole jest indywidualne dla każdego kasztanka — ale w obecnej grze zmienia się je wszystkim na raz. W bardziej rozbudowanej wersji można byłoby implementować, że kasztanek złapany w trybie scared traci scared i wraca do agresywnego stanu po teleporcie do klatki — co właśnie robi nasz kod:

if (c.scared){
  c.x = 9; c.y = 10; c.dx = 1; c.dy = 0;
  c.scared = false;       // ← reset stanu po „złapaniu”
  this.score += 200;
  // …
}

Dlaczego nie klasa Chestnut?

Bo to płaski rekord danych. Klasa miałaby sens, gdyby każdy kasztanek miał własne metody (chooseDirection(), render()). U nas logika dla wszystkich kasztanków jest w obiekcie game, w pętli this.chestnuts.forEach. To data‑oriented design — separujemy dane (rekordy) od logiki (funkcje).

Plus tego podejścia: łatwo dodać kasztanka — wystarczy dopisać element do tablicy. Bez konstruowania, bez new.

19 JS — kolizje ze ścianą

Najprostsza możliwa logika kolizji: zanim ruszysz, sprawdź czy docelowa kratka nie jest ścianą. Dwie funkcje, sześć linii kodu — i fundament całej fizyki gry.

isWall(x, y){
  if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return true;
  return this.map[y][x] === 0;
},
canMove(entity, dx, dy){
  const nx = entity.x + dx, ny = entity.y + dy;
  return !this.isWall(nx, ny);
},

isWall(x, y)

Zwraca true jeśli na pozycji (x, y) jest ściana. Dwa przypadki:

  1. Pozycja poza siatką — traktujemy jako ścianę. Zabezpieczenie: nawet gdyby ktoś próbował wyjść poza mapę, zostanie zatrzymany. To jednocześnie eleganckie obsłużenie brzegów.
  2. Wartość kafelka === 0 — kafelki kodowane 0 to ściany.

Operator porównania ===

Trzy znaki równości — strict equality. Sprawdza zarówno wartość, jak i typ. Krótka różnica:

"0" == 0    // true (luźne, konwersja typu)
"0" === 0   // false (ścisłe, różne typy)
0 === 0     // true

Po konwersji mapy na liczby (przez +c), wartości w this.map są liczbami. Porównanie z 0 przez === jest jednoznaczne.

Dlaczego === zawsze, gdy się da? Bo == ma masę dziwnych reguł konwersji ("" == 0 to true, null == undefined też). === nie zaskakuje.

Krótkie spięcie operatora ||

if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return true;

Cztery warunki połączone przez OR. JS sprawdza je od lewej do prawej i przerywa gdy znajdzie pierwszy true — to short‑circuit evaluation. Jeśli x < 0 jest true, x >= COLS już się nie sprawdza. Drobny zysk wydajnościowy + zabezpieczenie przed bugami (np. przed sprawdzaniem nieistniejącego pola).

canMove(entity, dx, dy)

Wyższa abstrakcja: czy encja (gracz lub kasztanek) może ruszyć w kierunku (dx, dy)?

canMove(entity, dx, dy){
  const nx = entity.x + dx, ny = entity.y + dy;
  return !this.isWall(nx, ny);
},

Liczymy nową pozycję (nx, ny) i sprawdzamy, czy nie jest ścianą. Negacja przez ! — bo „can move” to przeciwieństwo „is wall”.

Polimorfizm strukturalny

Funkcja akceptuje jakikolwiek obiekt z polami x i y. Nie sprawdza typu, nie wymaga klasy. JavaScript ma duck typing — „jeśli wygląda jak kaczka i kwacze jak kaczka, to jest kaczka”. Tu: jeśli ma .x i .y, to można dla niego sprawdzić ruch.

Dlatego ta sama funkcja działa zarówno dla dyrektora:

this.canMove(this.director, this.director.dx, this.director.dy);

jak i dla każdego kasztanka:

this.canMove(c, c.dx, c.dy);

Mocny argument za podejściem proceduralnym/obiektowym — nie ma potrzeby dziedziczenia Director extends Entity i Chestnut extends Entity. Po prostu obiekty mają wspólne pola, więc współdzielą logikę.

Co byłoby alternatywą?

Można by zrobić koliziję pikselową — sprawdzanie pikseli sprite'ów postaci i tła. To podejście używane w grach platformowych z fizyką (Mario, Sonic). Kosztowne i niepotrzebne dla siatkowych gier — tu kolizja kratek wystarcza i jest trywialna.

Inna alternatywa: lista zajętości — set ścian {„0,0”, „1,0”, …}. Sprawdzenie kolizji to walls.has(\`${x},${y}\`). Bardziej elegancki gdy mapa jest rzadka (mało ścian wśród dużej powierzchni). Dla naszej gęstej mapy 2D tablica jest szybsza — bezpośredni dostęp do indeksu.

🎯 Wzorzec

Funkcje isWall i canMove to przykład pure functions w stylu — choć formalnie używają this.map (czyli stanu globalnego), nie modyfikują niczego, dają deterministyczny wynik dla tych samych argumentów. Łatwo je testować: podaj wejście, sprawdź wyjście, koniec. Dobry style.

20 JS — tick() z lotu ptaka

To najważniejsza funkcja w grze. Wywoływana 60 razy na sekundę z pętli animacji. Jeden „klatkę‑plus‑logikę” cykl. 50+ linii kodu, które w sobie zawierają: animację śmierci, ruch gracza i AI, kolizje, zbieranie, warunek wygranej. Rozłożymy ją na pięć kawałków w kolejnych sekcjach — tutaj omawiamy strukturę.

tick(){
  if (!this.running) return;
  this.frame++;

  // 1. Animacja śmierci — ma priorytet, blokuje resztę
  if (this.deathAnim > 0) { /* … */ return; }

  // 2. Bramka modulo — ruch siatkowy co N klatek
  if (this.frame % 8 !== 0) return;

  // 3. Ruch gracza (z buforem kierunku)
  if (this.director.alive) { /* … */ }

  // 4. Zbieranie kafelków (kanapki, kawa)
  // 5. Warunek wygranej (zebrano wszystko)

  // 6. AI kasztanków (co 32 klatki)
  if (this.frame % 32 === 0) this.chestnuts.forEach(/* … */);

  // 7. Ruch kasztanków
  this.chestnuts.forEach(/* … */);

  // 8. Kolizja gracz‑kasztanek
  this.chestnuts.forEach(/* … */);

  // 9. Maleje powerTimer (jeśli > 0)
  if (this.powerTimer > 0) { /* … */ }
},

Hierarchia priorytetów

Kolejność akcji w funkcji tick() nie jest losowa — odzwierciedla priorytet rzeczy do wykonania:

  1. Najpierw stop‑warunki — gra nie działa? wracamy bez nic.
  2. Animacja śmierci — gdy idzie, blokuje cały świat. Klatki dziejące się w tym czasie nie posuwają gry, tylko upływ animacji.
  3. Bramka czasowa — ruch nie dzieje się co klatkę, tylko co 8 klatek. Mniej ruchów = wolniejsza, czytelniejsza gra.
  4. Logika gracza — najpierw porusz gracza, bo to jego wybór ma pierwszeństwo.
  5. Zbieranie i sprawdzenie wygranej — natychmiast po ruchu gracza, bo mogło zaowocować zebraniem kanapki.
  6. AI kasztanków — wybór kierunku co 32 klatki (rzadziej niż ruch).
  7. Ruch kasztanków — co 8 klatek, jak gracz.
  8. Kolizja — na końcu, po wszystkich ruchach.
  9. Liczniki — powerTimer maleje na końcu cyklu.

Dlaczego liczniki na końcu?

Bo logika powyżej operuje na aktualnej wartości powerTimer. Gdyby maleć na początku, mogłoby się zdarzyć że gracz zje kasztanka „w ostatniej klatce kawy”, ale w tej samej klatce powerTimer spadł do 0 i kasztanek już nie liczy się jako scared. Wtedy zamiast zjeść — ginie. Niesprawiedliwe.

Z licznikiem na końcu: scared liczy się dla całej klatki, dopiero potem maleje.

Tabela bramek czasowych

AkcjaCzęstotliwośćCo ile klatekPrzy 60 fps
tick wywołanykażda klatka160 razy/s
Ruch postaciframe % 8 === 087.5 razy/s
Dźwięk wakaframe % 16 === 0163.75 razy/s
Decyzja AIframe % 32 === 032~1.9 razy/s

Czemu różne wartości? Każda akcja ma naturalne tempo:

  • Ruch — 7.5/s daje czas na zorientowanie się, ale jest dynamiczny;
  • Waka — co drugi ruch (16 = 8 × 2), inaczej brzmiałoby chaotycznie;
  • AI — co 32 klatki = co 4 ruchy. Inaczej kasztanki zmieniałyby kierunek przy każdym kroku, co wyglądałoby drgawkowo. Trzymanie kierunku przez 4 kroki daje im „cel” i bardziej naturalny ruch.

Stała szybkość vs delta time

W naszej grze tick zakłada stałe 60 fps. To fixed timestep — najprostsze i wystarczające dla gry kafelkowej.

Bardziej zaawansowane gry używają delta time — mierzą rzeczywisty czas między klatkami i skalują ruch proporcjonalnie. Pozwala to na jednolitą szybkość niezależnie od wydajności urządzenia. Ale dla siatki, gdzie ruch jest dyskretny (od kratki do kratki), delta time nie ma sensu — albo wykonujesz krok, albo nie.

Co się dzieje, gdy urządzenie nie da rady 60 fps? requestAnimationFrame wywoła pętlę rzadziej (np. 30 fps), więc gra będzie po prostu wolniejsza. Akceptowalny kompromis.

21 JS — animacja śmierci

Gdy dyrektor zostaje złapany, zaczyna się 30‑klatkowa animacja jego znikania. W tym czasie nic innego się nie dzieje — świat jest „zamrożony”, a my widzimy moment porażki w zwolnionym tempie.

if (this.deathAnim > 0){
  this.deathAnim--;
  if (this.deathAnim === 0){
    if (this.lives > 0){
      this.softReset();
    } else {
      this.gameOver(false);
    }
  }
  return;
}

Decrement i sprawdzenie

this.deathAnim-- — zmniejszenie o 1. Gdy dotrze do 0, podejmujemy decyzję:

  • lives > 0 — gracz ma jeszcze życia → softReset, gra leci dalej z nowym życiem;
  • lives === 0 — koniec → wywołanie gameOver(false) (false = przegrana).

Wczesny return

Po decremcie i sprawdzeniu — return. To sprawia, że cała reszta tick() się nie wykonuje. Świat stoi.

Konsekwencje:

  • Kasztanki nie ruszają się (nie ma logiki AI ani ruchu);
  • Frame się zwiększył, ale nic z niego nie wynika;
  • Kawa nie odlicza dalej (powerTimer nie maleje);
  • Klatka się rysuje (renderer w draw() rysuje tylko stan, nie zmienia logiki).

Skąd początek animacji?

Wartość deathAnim = 30 ustawiana jest w innym miejscu — przy kolizji z kasztankiem (gdy ten nie jest scared):

this.deathAnim = 30;
this.lives--;
this.director.alive = false;
jingleDeath();

Razem z deathAnim ustawiamy:

  • lives-- — odejmujemy życie;
  • director.alive = false — flaga, że dyrektor „nie żyje” (renderer to wykrywa i rysuje go inaczej, np. z malejącą alfą);
  • jingleDeath() — sygnał dźwiękowy.

Czas trwania — 30 klatek przy 60 fps = 0.5 s

Pół sekundy to świetny balans: wystarczająco długo, żeby gracz zrozumiał co się stało, wystarczająco krótko, żeby nie nudzić. Krócej (10 klatek = 0.16 s) — śmierć by uciekała oczom. Dłużej (60 klatek = 1 s) — frustracja przy częstych zgonach.

30 klatek to też ładnie pasuje pod efekty wizualne — alfa od 1 do 0 w 30 krokach daje płynny zanik.

⏱️ Wzorzec licznika

To klasyczny countdown timer. Pole inicjalizowane wartością startową, decrement co tick, akcja gdy dotrze do zera. Ten sam wzorzec można użyć dla: czasów odnowienia (cooldown), animacji powerupów, opóźnionych efektów. W grach jest wszechobecny.

22 JS — bramka modulo (ruch siatkowy)

Gracz biega po kratkach, a nie pikselach. To znaczy, że ruch nie jest płynny — dzieje się skokami co 8 klatek. Implementacja: dwie linie z operatorem modulo.

if (this.frame % 8 !== 0) return;

Operator % (modulo)

Reszta z dzielenia. 10 % 3 === 1, bo 10 ÷ 3 = 3 reszta 1. 16 % 8 === 0, bo 16 dzieli się przez 8 bez reszty.

Gdy używasz frame % 8, dostajesz cyklicznie wartości 0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, .... Sprawdzenie === 0 jest prawdziwe co 8 klatek (na klatkach 0, 8, 16, 24, …).

Dlaczego co 8 klatek?

To eksperymentalnie dobrany kompromis:

  • Co klatkę (1) — postać przeskakuje 60 kratek na sekundę. Niesterowalne, oczy nie nadążają.
  • Co 4 klatki — 15 kratek/s. Szybkie, ale chaotyczne.
  • Co 8 klatek — 7.5 kratki/s. Dynamiczne i czytelne. ✓
  • Co 16 klatek — 3.75 kratki/s. Wolne, ale kontemplacyjne. Można byłoby tak grać, ale za nudno.

Wartość 8 to też potęga 2 — ładnie się dzieli przez 16 (waka co 16 = co 2 ruchy) i 32 (AI co 32 = co 4 ruchy).

Konsekwencje wizualne

Bramka modulo oznacza, że pomiędzy klatką 0 a klatką 8 postać nie rusza się w ogóle. Renderer przez 8 klatek rysuje ją w tej samej pozycji. Nic się nie animuje.

To może wyglądać dziwnie — ruch w stylu „klatka po klatce” zamiast płynnego. Ale dla emoji‑sprite'ów (jak nasze 🧑‍🏫 🌰) to nawet pasuje — emoji są dyskretne, nie mają animacji wewnętrznej, więc przeskoki kratkowe są spójne z estetyką.

Wariant z płynnym ruchem

Można by zrobić tweening — interpolację pozycji między dwoma kratkami. Algorytm:

// pseudo-kod
director.subPos += speed; // np. 0.125 = 1/8 kratki na klatkę
if (director.subPos >= 1.0){
  director.x += director.dx;
  director.subPos = 0;
}
// renderer:
const px = (director.x + director.subPos * director.dx) * TILE;

Postać rusza się płynnie od kratki do kratki, ale logika kolizji nadal jest dyskretna (sprawdzana tylko gdy subPos === 0). To podejście używane w klasycznym Pac‑Manie i 99% gier kafelkowych.

Nasza gra zrezygnowała z tweeningu dla prostoty — koszt: bardziej „roboczy” ruch, korzyść: 50 linii kodu mniej.

Gracz i kasztanki tym samym tempem

Jedna bramka frame % 8 === 0 obejmuje i ruch gracza, i ruch kasztanków (oba w obrębie tego samego tick()). Konsekwencja: wrogowie i gracz poruszają się w tej samej chwili, w tym samym tempie.

W bardziej zaawansowanych grach kasztanki bywają wolniejsze od gracza w trybie scared (uciekają ślamazarnie). U nas tego nie ma — można dodać:

// szybciej dla zwykłego ruchu, wolniej dla scared
const interval = c.scared ? 12 : 8;
if (this.frame % interval === 0) {
  // ruch kasztanka
}

Ale wymagałoby to oddzielnej bramki dla każdego kasztanka i osobnej pętli ruchu kasztanków.

23 JS — ruch gracza i bufor kierunku

Gdy bramka modulo przepuściła nas dalej, czas poruszyć dyrektorem. Magia tego fragmentu polega na buforze kierunku — postać sama skręca w pierwszym możliwym miejscu, gdzie się da.

if (this.director.alive){
  // próba zastosowania nextDir (bufor)
  if (this.director.nextDx !== 0 || this.director.nextDy !== 0){
    if (this.canMove(this.director, this.director.nextDx, this.director.nextDy)){
      this.director.dx = this.director.nextDx;
      this.director.dy = this.director.nextDy;
    }
  }
  // właściwy ruch
  if (this.canMove(this.director, this.director.dx, this.director.dy)){
    this.director.x += this.director.dx;
    this.director.y += this.director.dy;
    if (this.frame % 16 === 0 && (this.director.dx !== 0 || this.director.dy !== 0)) waka();
  }
}

Krok 1 — sprawdzenie bufora

if (this.director.nextDx !== 0 || this.director.nextDy !== 0){
  if (this.canMove(this.director, this.director.nextDx, this.director.nextDy)){
    this.director.dx = this.director.nextDx;
    this.director.dy = this.director.nextDy;
  }
}

Logika:

  1. Czy bufor zawiera kierunek (nextDx lub nextDy nie jest zerem)?
  2. Jeśli tak, czy w tym kierunku można się ruszyć (nie ma ściany)?
  3. Jeśli można — zastosuj bufor jako aktualny kierunek.

Jeśli bufor nie pasuje (jest ściana), nextDx/nextDy zostają — będą próbowane w następnych klatkach. To pozwala graczowi z wyprzedzeniem wcisnąć skręt, a postać sama wykona go w pierwszej możliwej chwili.

Krok 2 — właściwy ruch

if (this.canMove(this.director, this.director.dx, this.director.dy)){
  this.director.x += this.director.dx;
  this.director.y += this.director.dy;
  if (this.frame % 16 === 0 && (this.director.dx !== 0 || this.director.dy !== 0)) waka();
}

Sprawdzamy aktualny kierunek (dx, dy). Jeśli można — przemieszczamy się o jeden kafelek. Notabene — jeśli nie można (jest ściana), nic się nie dzieje. Postać po prostu stoi w miejscu, czekając aż gracz zmieni kierunek.

Dźwięk waka

if (this.frame % 16 === 0 && (this.director.dx !== 0 || this.director.dy !== 0)) waka();

Co 16 klatek (czyli co drugi ruch — 8 × 2 = 16) i tylko gdy postać faktycznie się rusza (kierunek niezerowy). Bez warunku „dx !== 0 || dy !== 0” waka grałoby też gdy gracz stoi przed ścianą — co byłoby drażniące.

Operator && łączy dwa warunki w AND. Drugi nawias (... || ...) sprawdza „jakikolwiek z dwóch kierunków niezerowy”.

Warto: kierunek wektorowy

Kierunek to wektor (dx, dy) o wartościach z {-1, 0, 1}:

Kierunek(dx, dy)
stop(0, 0)
w prawo →(1, 0)
w lewo ←(-1, 0)
w dół ↓(0, 1)
w górę ↑(0, -1)

Kombinacja jak (1, 1) — przekątna w prawo‑dół — teoretycznie możliwa, ale w naszej grze nigdy nie ustawiana. Sterowanie zerujе drugą oś przy każdej zmianie kierunku.

Bufor kierunku w detalach

Wracajmy do magii. Wyobraź sobie korytarz biegnący w prawo, ze skrętem w dół na pozycji x=10:

..........↓.....
>>>>>>>>>>>>>>>
##########.#####

Gracz biegnie z lewej w prawo (dx=1, dy=0). Chce skręcić w dół na pozycji 10. Wciska strzałkę „w dół” na pozycji 7. Co dzieje się w naiwnym sterowaniu (bez bufora)?

Po ludzku: dlaczego to magia?

Wyobraź sobie, że jedziesz samochodem ulicą. Widzisz, że za chwilę będzie skręt w prawo i chcesz tam pojechać. Co robisz? Wcześniej, przed skrętem, włączasz kierunkowskaz. Nie czekasz aż dojedziesz do dokładnej linii skrętu — sygnalizujesz z wyprzedzeniem. To dokładnie ten sam mechanizm. Bufor kierunku = kierunkowskaz. Mówisz grze „za chwilę chcę skręcić”, a ona sama wykona skręt w pierwszym możliwym miejscu.

Bufor kierunku — krok po kroku Gracz wciska strzałkę „w dół” gdy jest na kratce 7. Skręt w dół możliwy dopiero na kratce 10. Krok 1: gracz na kratce 7 Wciska strzałkę „w dół”. Skrętu nie można wykonać (ściana w dół). 5 6 7 8 9 10 11 🧑‍🏫 Wektor ruchu: dx=1, dy=0 (w prawo) Bufor: nextDx=0, nextDy=1 (↓) → ściana w dół, czekamy Krok 2: gracz na kratce 8 Idzie dalej w prawo. Bufor wciąż „w dół”, ale ściana. 5 6 7 8 9 10 11 🧑‍🏫 Wektor ruchu: dx=1, dy=0 (dalej w prawo) Bufor: nextDx=0, nextDy=1 (↓) → wciąż czeka, nadal ściana Krok 3: gracz na kratce 10 — skręt! Otwarty korytarz w dół. Bufor zostaje aktywowany, postać skręca! 5 6 7 8 9 10 11 🧑‍🏫 Wektor ruchu: dx=0, dy=1 (W DÓŁ ✓) Bufor: (zastosowano i wyzerowano) → SKRĘT SIĘ UDAŁ!
Trzy klatki gry. Gracz wcisnął strzałkę w kroku 1, ale postać skręciła dopiero w kroku 3 — tam, gdzie się dało.

W naiwnym sterowaniu (bez bufora) wymaga to idealnego trafienia w klatkę 10. Z buforem — możesz wcisnąć strzałkę z dużym wyprzedzeniem, a postać sama wybierze odpowiedni moment.

  1. Klatka, w której gracz wcisnął — postać na pozycji 7. dy=1 ustawione, ale w dół jest ściana, więc canMove zwraca false. Ruch w prawo dalej (poprzedni kierunek).
  2. Niemożliwe — w naiwnym sterowaniu zmiana kierunku jest natychmiastowa, a jeśli się nie da, gracz stoi.
  3. Albo: gracz musi idealnie trafić w kratkę 10 z timingiem przyciśnięcia. Frustracja.

Z buforem:

  1. Klatka — gracz na pozycji 7. Wciska „w dół”. nextDx=0, nextDy=1 ustawiane.
  2. Klatka 8 — sprawdzenie bufora: czy w dół można? Nie (ściana). nextDy zostaje na 1. Ruch w prawo zgodnie z dx=1.
  3. Klatka 9 — j.w.
  4. Klatka 10 — sprawdzenie bufora: czy w dół można? Tak! dy=1, dx=0. Ruch w dół.

Gracz nie musiał trafiać w idealny moment — wystarczyło, że wcisnął kiedyś przed skrzyżowaniem. Postać sama skręciła w pierwszej możliwej chwili. To input forgiveness — wybaczanie nieprecyzji.

🎯 Klasyka gamedevu

Bufor kierunku to standard od czasów Pac‑Mana. Nazywa się też directional buffering albo input queueing. Można rozszerzać: bufor na N akcji (kombinacje, comba), bufor z czasem życia (po 200 ms wyparuje), bufor priorytetów. W bijatykach (Street Fighter) komendy specjalne tylko dzięki temu działają — gracz nie wstrzeli się w idealny timing 6 klatek.

24 JS — zbieranie kafelków

Po ruchu gracza sprawdzamy, na czym stanął. Jeśli na kanapce — punkty. Jeśli na kawie — kawa działa. W obu przypadkach kafelek staje się pustą ścieżką.

// w kontekście tick(), tuż po ruchu gracza:
const tile = this.map[this.director.y][this.director.x];
if (tile === 1){
  this.score += 10;
  this.map[this.director.y][this.director.x] = 2;
  if (this.frame % 16 === 0) waka();
}
if (tile === 3){
  this.score += 50;
  this.map[this.director.y][this.director.x] = 2;
  this.powerTimer = 360; // 6 sekund × 60 fps
  this.chestnuts.forEach(c => c.scared = true);
  jinglePower();
}

Pobranie wartości kafelka

const tile = this.map[this.director.y][this.director.x];

Najpierw wiersz (y), potem kolumna (x) — bo mapa to [[wiersz0], [wiersz1], …]. Wartość zapisana w lokalnej zmiennej tile — po dwa razy nie czytamy z mapy.

Małe optymalizacja: gdyby tile czytane wielokrotnie z dwuwymiarowej tablicy, JS by za każdym razem szukało this.map, potem [y], potem [x]. Z lokalną zmienną — jeden lookup.

Kanapka (tile === 1)

  • +10 punktów — klasyczna wartość Pac‑Mana;
  • Mapa 1 → 2 — kafelek staje się pustą ścieżką (kod 2). Renderer już go nie narysuje jako kanapki.
  • Waka — co 16 klatek dźwięk pochłaniania. Tu nadmiarowy z poprzedniej linii (waka już było wywołane w tick dla samego ruchu) — w obecnym kodzie zdublowane, ale dźwięk jest na tyle krótki, że się nie słychać.

Kawa (tile === 3)

  • +50 punktów — premia 5x większa niż za kanapkę.
  • Mapa 3 → 2 — kawa znika.
  • powerTimer = 360 — uruchomienie 6‑sekundowego efektu (przy 60 fps). Liczba 360 to magic number, ale czytelnie skomentowany.
  • Wszystkim kasztankom scared = true — pętla po tablicy, każdy z nich zostaje przestraszony. AI w trybie scared ucieka, a kolizja zamiast śmierci daje punkty.
  • jinglePower() — wstępujący glissando jako sygnał potęgi.

Liczenie kanapek pozostałych — wygrana

// po zbieraniu, w tym samym tick():
let left = 0;
this.map.forEach(r => r.forEach(c => { if (c===1) left++; }));
if (left === 0){
  this.level++;
  this.map = RAW_MAP.map(row => row.split('').map(c => +c));
  this.softReset();
  this.updateHud();
  return;
}

Zliczanie pozostałych kanapek — analogicznie do totalSandwiches przy starcie, ale teraz w trakcie gry. Gdy ostatnia zniknie, lewy się zeruje i wpadamy w blok awansu poziomu.

Czemu liczymy za każdym tickiem? To O(440) operacji co tick — wydaje się rozrzutne. Można było inkrementalnie maintainować licznik this.sandwichesLeft i decrementować przy zjedzeniu. Wzór ten jest bardziej wydajny:

// optymalizacja:
if (tile === 1){
  this.score += 10;
  this.map[y][x] = 2;
  this.sandwichesLeft--;        // ← zamiast liczenia za każdym razem
}
if (this.sandwichesLeft === 0){ /* awans */ }

Dla 440 kratek nieistotne (440 × 60 fps = 26 400 sprawdzeń/s — to nic dla nowoczesnego CPU). Dla mapy 1000 × 1000 byłoby konieczne. Tutaj wybrano czytelność nad wydajność — kod jest „nieoptymalny celowo”, dla edukacyjności.

25 JS — warunek wygranej

Wygrana w tej grze to specjalny rodzaj zwycięstwa: zebrałeś wszystkie kanapki na poziomie i… zaczynasz nowy poziom. Nieskończona gra — póki nie zginiesz.

if (left === 0){
  this.level++;
  this.map = RAW_MAP.map(row => row.split('').map(c => +c));
  this.softReset();
  this.updateHud();
  return;
}

Krok po kroku

  1. this.level++ — inkrementacja poziomu. HUD pokaże nowy numer.
  2. this.map = RAW_MAP.map(...) — świeży klon mapy. Wszystkie kanapki znowu są.
  3. this.softReset() — restart pozycji (gracz na (1,1), kasztanki w klatce). Nie używamy tu reset(), bo reset wyzerowałby score i lives.
  4. this.updateHud() — odświeżenie wyświetlacza.
  5. return — wczesny wyjście z tick(). Reszta logiki klatki (AI, kolizje) się nie wykonuje. To bezpieczne — postacie są w klatce, gracz na starcie, nie ma sensu naliczać im ruchów.

Co znaczy level++?

Operator post‑inkrementacji. level++ zwraca poprzednią wartość, ale najpierw zwiększa pole. ++level zwraca już nową wartość. W naszym kontekście wynik nie jest używany (level++ jako wyrażenie samo w sobie), więc oba są równoważne.

Pisanie ++level bywa zalecane w niektórych konwencjach (uniknięcie semantycznej różnicy), ale w JS w 99% przypadków x++ jest wystarczające.

Brak ekranu „Poziom 2!”

W obecnej grze przejście między poziomami jest natychmiastowe — bez przerwy, bez fanfar. Gracz dosłownie znajdzie się znowu na pozycji startowej, w pełnej mapie. Może być dezorientujące.

Usprawnienie: można dodać levelTransition = 60 (sekunda), w trakcie której:

if (this.levelTransition > 0){
  this.levelTransition--;
  // renderer rysuje napis „POZIOM X”
  return;
}

I po tej sekundzie startuje normalna gra. Drobny detal, znaczna różnica UX.

Dlaczego nie ma „końca gry przez wygraną”?

Niektóre gry mają finalny ekran „Wygrałeś!” po pokonaniu ostatniego poziomu. U nas — nie. Dlaczego?

  • Brak „ostatniego poziomu” — projekt jest endless. Każdy zwycięski cykl odnawia mapę.
  • Wyzwanie polega na osiągnięciu wysokiego wyniku — gracz konkuruje sam ze sobą.
  • Implementacja prostsza — brak warunku „end of game by victory” do oprogramowania.

To filozofia oryginalnego Pac‑Mana — gra ma kontynuować w nieskończoność, aż do śmierci. Wygrana = przejście jak najdalej.

Funkcja gameOver

gameOver(won){
  this.running = false;
  document.getElementById('finalScore').textContent = this.score;
  document.getElementById('goTitle').textContent = won ? '🎉 Wygrana!' : '💀 Koniec gry';
  document.getElementById('goMsg').textContent = won
    ? 'Dyrektor zjadł wszystkie kanapki!'
    : 'Kasztanki dorwały Dyrektora.';
  gameOverPanel.classList.add('show');
},

Wywoływana tylko z jednym kontekstem — gdy lives === 0 przy końcu animacji śmierci. Wartość won jest tu zarezerwowana na przyszłość — w obecnej grze zawsze false. Można ją wywołać z true, jeśli kiedyś dodać warunek finalnego zwycięstwa (np. „przejdź 10 poziomów”).

Co robi:

  • this.running = false — pętla logiki przestaje działać.
  • Trzy wywołania document.getElementById — pobranie elementów DOM i podmiana tekstu. textContent, nie innerHTML — bezpieczniej (brak ryzyka XSS, choć tu nie istotne).
  • Conditional ternary won ? 'X' : 'Y' — jednolinijkowy warunek.
  • gameOverPanel.classList.add('show') — dodanie klasy „show” do panelu, co wyzwala CSS display: grid i panel staje się widoczny.

gameOverPanel tu jest globalną zmienną z elementu DOM (gdzieś wcześniej w kodzie zapisana przez const gameOverPanel = document.getElementById('gameOverPanel')).

26 JS — AI kasztanków

Sztuczna inteligencja w grze ogranicza się do jednej funkcji wyboru kierunku, wywoływanej co 32 klatki. Algorytm jest greedy — kasztanek wybiera ten z możliwych kierunków, który zbliża go najbardziej do gracza (lub oddala, gdy scared). Prymitywne, ale efektywne.

if (this.frame % 32 === 0){
  this.chestnuts.forEach(c => {
    const dirs = [
      {dx: 1, dy: 0}, {dx:-1, dy: 0},
      {dx: 0, dy: 1}, {dx: 0, dy:-1},
    ].filter(d => this.canMove(c, d.dx, d.dy))
     .filter(d => !(d.dx === -c.dx && d.dy === -c.dy)); // anty‑zawracanie

    if (dirs.length){
      const target = c.scared ? null : this.director;
      let chosen;
      if (target){
        chosen = dirs.reduce((best, d) => {
          const dist = Math.abs((c.x + d.dx) - target.x)
                     + Math.abs((c.y + d.dy) - target.y);
          return (best === null || dist < best.dist) ? {...d, dist} : best;
        }, null);
      } else {
        chosen = dirs[Math.floor(Math.random() * dirs.length)];
      }
      c.dx = chosen.dx; c.dy = chosen.dy;
    }
  });
}

Częstotliwość decyzji

Decyzja co 32 klatki = co 4 ruchy (bo ruch dzieje się co 8 klatek). Kasztanek po wybraniu kierunku idzie nim 4 kratki, dopiero potem znowu „myśli”.

Gdyby wybierał kierunek co tick (60 razy na sekundę), kasztanki migotałyby — co klatkę inny kierunek, gdy obie opcje równie dobre. Z 32‑klatkową przerwą mają „cel” na chwilę.

Lista możliwych kierunków

const dirs = [
  {dx: 1, dy: 0}, {dx:-1, dy: 0},
  {dx: 0, dy: 1}, {dx: 0, dy:-1},
];

Cztery kardynalne kierunki w postaci tablicy obiektów. Każdy obiekt to wektor jednostkowy. Kolejność tu nie ma znaczenia (kierunek zostanie i tak ostatecznie posortowany przez odległość) — ale gdyby AI był losowy z preferencją, kolejność byłaby pierwszym tie‑breakerem.

Filtr 1 — odrzucenie ścian

.filter(d => this.canMove(c, d.dx, d.dy))

Z czterech kierunków zostawiamy tylko te, w które kasztanek może się ruszyć (nie ma ściany). Method chain: .filter() zwraca nową tablicę bez modyfikacji oryginalnej.

Po filtrze pozostają np. 1, 2 lub 3 kierunki — w zależności od tego, gdzie kasztanek stoi (korytarz, skrzyżowanie, ślepa uliczka).

Filtr 2 — anty‑zawracanie

.filter(d => !(d.dx === -c.dx && d.dy === -c.dy))

Wykluczenie kierunku przeciwnego do aktualnego. Przykład: kasztanek idzie w prawo (dx=1, dy=0). Kierunek przeciwny to lewy (dx=-1, dy=0). Filtrujemy go z opcji.

Logika porównania: d.dx === -c.dx i d.dy === -c.dy jednocześnie. Negacja całości (!(...)) — „NIE odwrotny”.

Po co to?

Bez tego filtra kasztanki w korytarzach kołysałyby się — w jednej chwili patrzyłyby się w stronę gracza, w następnej z powrotem. To efekt równo‑optymalnych decyzji: oba kierunki dają tę samą odległość, więc reduce zwraca pierwszy z brzegu, a ten się zmienia.

Z anty‑zawracaniem kasztanek kontynuuje ruch w kierunku, w którym szedł, dopóki nie zostanie zmuszony do skrętu (np. ściana z przodu).

Wybór celu

const target = c.scared ? null : this.director;

Cel zależy od stanu kasztanka:

  • scared = false → cel to director, AI „goni”;
  • scared = true → cel to null, AI „losowy ruch” (przepraszający za uproszczenie).

Pościg — odległość Manhattan

chosen = dirs.reduce((best, d) => {
  const dist = Math.abs((c.x + d.dx) - target.x)
             + Math.abs((c.y + d.dy) - target.y);
  return (best === null || dist < best.dist) ? {...d, dist} : best;
}, null);

Wzór odległości

Liczymy odległość Manhattan — sumę bezwzględnych różnic w obu osiach. To naturalna miara dla siatki, gdzie ruch jest 4‑kierunkowy.

Wzór:

// odległość Manhattan między (x1, y1) a (x2, y2):
const dist = Math.abs(x1 - x2) + Math.abs(y1 - y2);

U nas: liczymy odległość z miejsca, w które kasztanek się ruszy (czyli c.x + d.dx) do gracza. Jeśli ruszenie się w danym kierunku zbliża nas do celu — odległość będzie mniejsza.

Inne miary:

  • EuklidesowaMath.sqrt((x1-x2)² + (y1-y2)²). Linia prosta. Niedostosowana dla siatki.
  • CzebyszewaMath.max(|x1-x2|, |y1-y2|). Dla ruchu 8‑kierunkowego (z przekątnymi).
  • Manhattan — dla ruchu 4‑kierunkowego. Idealne dla nas.

reduce — pętla z akumulatorem

array.reduce((accumulator, current) => newAccumulator, initialValue);

reduce przechodzi przez każdy element i akumuluje wynik. U nas akumulatorem jest najlepszy do tej pory kierunek.

Krok po kroku:

  1. Start: best = null.
  2. Pierwszy element d = {dx:1, dy:0}. dist dla niego np. 5. best === null, więc nadpisz: best = {dx:1, dy:0, dist:5}.
  3. Kolejny d = {dx:0, dy:1}. dist np. 3. 3 < 5 → nadpisz: best = {dx:0, dy:1, dist:3}.
  4. Itd. Na końcu best trzyma kierunek z najmniejszą odległością.

Spread operator {...d, dist}

Tworzy nowy obiekt z polami z d ({dx, dy}) plus dodatkowo dist. Skrócona wersja:

{dx: d.dx, dy: d.dy, dist: dist}
// jest równe:
{...d, dist}

Zarówno krócej, jak i bardziej idiomatyczne. Spread operator (z ES2018) działa w obiektach (rozsypuje pola) i tablicach (rozsypuje elementy).

Tryb scared — losowy ruch

chosen = dirs[Math.floor(Math.random() * dirs.length)];

Klasyczna losowa indeksacja. Math.random() daje liczbę z [0, 1). Mnożymy przez dirs.length (np. 3) i wyciągamy część całkowitą — dostajemy 0, 1 lub 2.

Czy to dobre AI w trybie scared?

Nie do końca — losowy ruch oznacza, że kasztanek może iść w stronę gracza nawet w trybie scared, jeśli akurat „rzuci się” w jego kierunku. Bardziej sensowne byłoby:

// dystans od gracza — chcemy MAKSYMALIZOWAĆ
const dist = Math.abs((c.x + d.dx) - this.director.x)
           + Math.abs((c.y + d.dy) - this.director.y);
return (best === null || dist > best.dist) ? {...d, dist} : best; // >, nie <

Czyli ta sama logika co pościg, ale szukamy najdalszego kierunku. Tu pewnie celowo zostawiono prostotę — losowość daje grze element nieprzewidywalności.

Ruch kasztanka — analogiczny do gracza

this.chestnuts.forEach(c => {
  if (this.canMove(c, c.dx, c.dy)) {
    c.x += c.dx; c.y += c.dy;
  }
});

Identyczna logika jak gracz, tylko bez bufora kierunku. Kasztanek po wyborze kierunku po prostu w nim idzie. Jeśli się nie da (między decyzjami stała się ściana — w naszym statycznym labiryncie nie powinna, ale na wszelki wypadek), stoi w miejscu.

🤖 Złożoność AI

Klasyczny Pac‑Man miał indywidualne osobowości dla każdego z 4 duszków: Blinky goni bezpośrednio, Pinky celuje 4 kratki przed graczem (zasadzka), Inky używa pozycji Blinky'ego jako referencji (zaskakująco), Clyde goni gdy daleko a ucieka gdy blisko (tchórz). Dzięki temu duszki kooperują bez explicit komunikacji. Nasza gra ma jeden algorytm dla wszystkich — ślad oryginału, ale spłaszczony. To pierwsza rzecz, jaką warto byłoby rozbudować.

27 JS — kolizja gracz‑wróg

Po ruchach wszystkich postaci sprawdzamy, czy któryś z kasztanków stoi na tej samej kratce co gracz. Jeśli tak — albo dyrektor traci życie, albo zjada kasztanka. Wszystko zależy od stanu scared.

this.chestnuts.forEach(c => {
  if (c.x === this.director.x && c.y === this.director.y){
    if (c.scared){
      c.x = 9; c.y = 10; c.dx = 1; c.dy = 0;
      c.scared = false;
      this.score += 200;
      jingleEat();
    } else {
      this.deathAnim = 30;
      this.lives--;
      this.director.alive = false;
      jingleDeath();
    }
  }
});

Test kolizji

if (c.x === this.director.x && c.y === this.director.y){

Kratkowy AABB — jeśli oba (gracz i kasztanek) zajmują tę samą kratkę, to kolizja. Najprostsza możliwa fizyka.

Subtelność: porównanie dzieje się po ruchach obu encji. To znaczy, że jeśli gracz wszedł na kratkę, na której był kasztanek zanim ten zdążył zrobić ruch, to obu mamy w tej samej pozycji = kolizja. Ale jeśli kasztanek przeskoczył przez kratkę gracza i wylądował dwie kratki dalej, kolizja nie zostanie wykryta.

Klasyczny problem: tunneling — szybkie obiekty przelatują przez siebie. W naszej grze ruchy są dyskretne i jednoczesne, więc ryzyko niewielkie, ale jest. Dla przykładu:

  • Klatka N: gracz na (5,5), kasztanek na (4,5).
  • Gracz idzie w lewo (do (4,5)), kasztanek idzie w prawo (do (5,5)).
  • Klatka N+1: gracz na (4,5), kasztanek na (5,5). Wymijają się.
  • Test kolizji: 5 ≠ 4 || 5 ≠ 5 → false.

Czyli mogą się minąć w przesmyku! W praktyce trudne do wykorzystania (timing musi być idealny), ale teoretycznie bug. Pełne rozwiązanie wymagałoby swept collision: sprawdzanie nie tylko końcowych pozycji, ale całej trasy ruchu.

Kasztanek scared — gracz zjada

c.x = 9; c.y = 10; c.dx = 1; c.dy = 0;
c.scared = false;
this.score += 200;
jingleEat();
  • Teleport do klatki — kasztanek wraca na pozycję (9, 10). Tam, gdzie startował przy spawn. Po krótkim okresie wyjdzie z klatki.
  • Reset kierunku na prawo — domyślny kierunek wyjścia z klatki.
  • scared = false — kasztanek przestaje być przestraszony, mimo że powerTimer jeszcze leci. To oznacza, że już nie zostanie zjedzony ponownie, dopóki nie wjedzie na nową kawę.
  • +200 punktów — Pac‑Man miał system 200/400/800/1600 (eskalacja przy łapaniu wielu duszków pod jedną kawą). U nas zawsze 200. Można byłoby rozbudować — uproszczona implementacja.
  • jingleEat() — sygnał dźwiękowy zjedzenia.

Kasztanek niesproszony — śmierć dyrektora

this.deathAnim = 30;
this.lives--;
this.director.alive = false;
jingleDeath();
  • deathAnim = 30 — uruchomienie 30‑klatkowej animacji śmierci. W kolejnych tickach tick() wpada w if (this.deathAnim > 0) i zwraca, blokując resztę logiki.
  • lives-- — odejmujemy życie. HUD się zaktualizuje przy następnym updateHud().
  • director.alive = false — flaga „dyrektor martwy”. Renderer (draw()) sprawdza tę flagę i rysuje go z malejącą alfą.
  • jingleDeath() — opadająca melodyjka, sygnał porażki.

Co się dzieje po deathAnim?

Gdy deathAnim dotrze do 0:

if (this.deathAnim === 0){
  if (this.lives > 0){
    this.softReset();
  } else {
    this.gameOver(false);
  }
}

Albo softReset (jeszcze życia są), albo gameOver (zero żyć).

W softReset() zauważ, że director.alive nie jest jawnie ustawiane na true! Na pierwszy rzut oka błąd. Sprawdźmy softReset:

softReset(){
  this.director.x = 1; this.director.y = 1;
  this.director.dx = 0; this.director.dy = 0;
  this.director.nextDx = 0; this.director.nextDy = 0;
  this.spawnChestnuts();
  this.powerTimer = 0;
  this.deathAnim = 0;
},

Faktycznie — flaga alive nie jest resetowana. To może być bug — po pierwszej śmierci dyrektor pozostaje „martwy” mimo że gra leci dalej. Skutek wizualny: rysuje się go z malejącą alfą (przez deathAnim) ale skoro deathAnim wynosi 0, to alfa = 1 — czyli normalnie wygląda. Ale logicznie — flaga niespójna.

Lepsza wersja:

softReset(){
  // …
  this.director.alive = true;  // ← brakuje tej linii w oryginale
  this.deathAnim = 0;
},

Drobny szczegół, niewidoczny dla gracza — ale dobry temat na zadanie domowe: znajdź potencjalny bug i napraw.

28 JS — pełen pipeline renderowania

Funkcja draw() wywoływana co klatkę z pętli animacji. Tworzy obraz gry „od zera” na canvasie. Renderowane są w kolejności: tło, ściany, kanapki, kawy, kasztanki, dyrektor. Kolejność istotna — co rysowane później, jest „nad” poprzednim.

draw(){
  // 1. Wyczyść canvas
  ctx.fillStyle = '#0d0f14';
  ctx.fillRect(0, 0, board.width, board.height);

  // 2. Rysuj kafelki
  for(let y=0; y<ROWS; y++){
    for(let x=0; x<COLS; x++){
      const tile = this.map[y][x];
      const px = x * TILE, py = y * TILE;

      if (tile === 0){
        // ściana
        ctx.fillStyle = '#1F4E79';
        ctx.fillRect(px+2, py+2, TILE-4, TILE-4);
      } else if (tile === 1){
        // kanapka
        ctx.fillStyle = '#fef3c7';
        ctx.beginPath();
        ctx.arc(px + TILE/2, py + TILE/2, 4, 0, Math.PI*2);
        ctx.fill();
      } else if (tile === 3){
        // kawa pulsująca
        const r = 8 + Math.sin(this.frame/6) * 2;
        ctx.fillStyle = '#92400e';
        ctx.beginPath();
        ctx.arc(px + TILE/2, py + TILE/2, r, 0, Math.PI*2);
        ctx.fill();
      } else if (tile === 4){
        // klatka kasztanków (drobny obrys)
        ctx.strokeStyle = '#444';
        ctx.lineWidth = 1;
        ctx.strokeRect(px+1, py+1, TILE-2, TILE-2);
      }
    }
  }

  // 3. Rysuj kasztanki
  this.chestnuts.forEach(c => { /* … */ });

  // 4. Rysuj dyrektora
  if (this.director){ /* … */ }
},

Krok 1 — Wyczyszczenie tła

ctx.fillStyle = '#0d0f14';
ctx.fillRect(0, 0, board.width, board.height);

Każda klatka zaczyna się od czystego pola — wymalowany cały canvas kolorem tła. To obowiązkowe. Bez tego poprzednie obrazy pozostawałyby na ekranie, dając efekt „smużenia”.

Alternatywa: ctx.clearRect(0, 0, w, h) — wyczyszczenie do przezroczystości. Działa, ale daje czerń (canvas domyślnie nie ma tła). U nas wolimy konkretny ciemny niebieskoszary, więc fillRect.

Krok 2 — Pętla po wszystkich kafelkach

for(let y=0; y<ROWS; y++){
  for(let x=0; x<COLS; x++){
    // …
  }
}

Klasyczna podwójna pętla. 22 × 20 = 440 iteracji co klatkę. Brzmi dużo, ale dla GPU to jakieś 0.1 ms — niewidoczne dla wydajności.

Konwersja kratki na piksele

const px = x * TILE, py = y * TILE;

Kratka (x, y) zostaje przetłumaczona na pozycję pikselową (px, py). To lewy górny róg kratki.

Rysowanie ścian

if (tile === 0){
  ctx.fillStyle = '#1F4E79';
  ctx.fillRect(px+2, py+2, TILE-4, TILE-4);
}

Ściana to ciemnoniebieski kwadrat (#1F4E79) o rozmiarze TILE - 4 px (28 px) z 2 px paddingu na każdej stronie. Dlaczego nie wypełnić całej kratki?

Bo wtedy ściany sąsiadujące byłyby zlepione w jeden duży blok bez wizualnej granicy. 2 px padding tworzy „mortar” — szczeliny między ścianami, dające efekt mozaiki kafli.

Rysowanie kanapek

ctx.beginPath();
ctx.arc(px + TILE/2, py + TILE/2, 4, 0, Math.PI*2);
ctx.fill();

Małe kółko (promień 4 px) w środku kratki. Kolor #fef3c7 (delikatny jasny żółty — chleb).

API arc

Składnia: ctx.arc(x, y, radius, startAngle, endAngle, [counterclockwise]).

  • x, y — środek koła;
  • radius — promień;
  • startAngle — kąt początkowy w radianach (0 = oś X);
  • endAngle — kąt końcowy;
  • counterclockwise — czy w przeciwną stronę (default false).

Pełne koło: od 0 do , czyli Math.PI * 2 ≈ 6.283 radianów.

Wzorzec beginPatharcfill

Canvas API używa state machine. Najpierw rozpoczynasz nową ścieżkę (beginPath), potem dodajesz do niej kształty (arc, moveTo, lineTo), na końcu rysujesz wypełnienie (fill) lub obrys (stroke).

Bez beginPath, kolejne wywołania arc dodawałyby się do tej samej ścieżki — i wszystkie zostałyby narysowane przy każdym fill. Klasyczny bug początkujących.

Rysowanie kawy z pulsacją

const r = 8 + Math.sin(this.frame/6) * 2;

Promień kawy nie jest stały — pulsuje! Math.sin(this.frame/6) daje wartość sinusoidalną (od -1 do 1) zmieniającą się w czasie. Pomnożona przez 2, daje oscylację w zakresie -2 do +2. Plus stałe 8 = promień zmieniający się w zakresie [6, 10] px.

Frekwencja oscylacji: pełen okres sinusa to 2π ≈ 6.28 rad. Argument frame/6 rośnie o 1/6 na klatkę. Przy 60 fps daje to 10 jednostek na sekundę — czyli 10 / (2π) = ~1.6 oscylacji na sekundę. Dwa razy w sekundzie kawa „pulsuje” — przyjemny rytm.

Można sterować szybkością przez dzielnik:

  • frame/3 — szybsze pulsowanie (3.2/s)
  • frame/12 — wolniejsze (0.8/s)

Rysowanie kasztanków

this.chestnuts.forEach(c => {
  const px = c.x * TILE + TILE/2;
  const py = c.y * TILE + TILE/2;
  let color = c.color;
  if (c.scared){
    // przy końcu kawy mruga
    if (this.powerTimer < 60 && this.frame % 12 < 6) color = '#fff';
    else color = '#3344aa';
  }
  ctx.font = `${TILE-4}px serif`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillStyle = color;
  ctx.fillText(c.scared ? '😱' : '🌰', px, py);
});

Konwersja na środek kratki

Pozycja pikselowa to środek kratki: x * TILE + TILE/2. Inaczej niż przy ścianach (lewy górny róg). To dlatego, że fillText z textAlign='center' i textBaseline='middle' oczekuje punktu środkowego tekstu.

Logika koloru

  • Domyślnie: color = c.color — własny kolor kasztanka (czerwony, różowy, cyjan, pomarańcz).
  • Gdy scared: kolor zmienia się na granatowy #3344aa.
  • Gdy scared i kawa zaraz się skończy (mniej niż 60 klatek = 1 s): mruga białym co 12 klatek.

Mruganie to wskazówka dla gracza: „pospiesz się, kawa zaraz się skończy”. Klasyczne UX z Pac‑Mana.

this.frame % 12 < 6

Ten warunek jest true przez połowę czasu (klatki 0–5), false przez drugą połowę (6–11). Cykl 12 klatek = 0.2 s. To daje 5 mrugnięć na sekundę — dynamiczne, ale nie męczące.

Rysowanie emoji

ctx.font = `${TILE-4}px serif`;
ctx.fillText(c.scared ? '😱' : '🌰', px, py);

Tak, canvas potrafi rysować tekst — w tym emoji. font ustawia rozmiar i rodzinę. TILE - 4 = 28 px. Rodzina serif w praktyce nieistotna — emoji są renderowane przez specjalny font systemowy (Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji).

Trick: choć określamy fillStyle, to nie wpłynie na kolor emoji (te mają swoje wbudowane kolory). Wpływa tylko na brak‑emoji tekst. Czy fillStyle ma znaczenie tutaj? Trochę — przy mocnym tle emoji może być przyciemnione przez globalCompositeOperation, ale w naszym kodzie nie używamy tego mechanizmu, więc fillStyle dla emoji jest praktycznie ignorowane.

Rysowanie dyrektora

if (this.director){
  const px = this.director.x * TILE + TILE/2;
  const py = this.director.y * TILE + TILE/2;
  ctx.font = `${TILE-2}px serif`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  // aura przy power-mode
  if (this.powerTimer > 0){
    ctx.fillStyle = 'rgba(255,216,74,.3)';
    ctx.beginPath();
    ctx.arc(px, py, TILE*0.7, 0, Math.PI*2);
    ctx.fill();
  }

  // animacja śmierci
  if (this.deathAnim > 0){
    ctx.globalAlpha = this.deathAnim / 30;
  }
  ctx.fillText('🧑‍🏫', px, py);
  ctx.globalAlpha = 1;
}

Aura przy power‑mode

Gdy gracz ma kawę aktywną, rysujemy żółtą półprzezroczystą poświatę pod nim. rgba(255,216,74,.3) = żółty z 30% nieprzezroczystością. Promień TILE * 0.7 = ~22 px (większy niż sama postać).

globalAlpha dla zaniku

ctx.globalAlpha = this.deathAnim / 30;

Gdy deathAnim startuje od 30 i maleje do 0, globalAlpha zmienia się od 1.0 do 0.0. Każde rysowanie po tym ustawieniu jest mnożone przez tę wartość — efekt zaniku.

Po narysowaniu dyrektora resetujemy: ctx.globalAlpha = 1. Inaczej kolejne wywołania draw() rysowałyby z domyślną przezroczystością — kasztanki w następnej klatce byłyby zmętnione.

globalAlpha to globalny modyfikator kontekstu canvasa — działa na wszystko, co rysujesz aż do zmiany. Dlatego to ważne, żeby zawsze go przywracać po użyciu.

🎨 Pipeline graficzny

Kolejność rysowania to z‑order: później = wyżej. Gdyby zmienić, najpierw rysować kasztanki a potem ściany, kasztanki zniknęłyby pod ścianami w korytarzach. Konwencja w grach 2D: tło → tile mapa → kolizje (ściany) → enemy → player → particles → UI.

29 JS — sterowanie wieloźródłowe

Najbardziej rozbudowana część infrastruktury: gra ma odbierać sterowanie z trzech różnych miejsc (canvas, document, window) i być odporna na osadzenie w iframe sandbox. To 60 linii kodu, z których większość to defensywne programowanie — „a co jeśli...”.

Mapowanie klawiszy

const KEYS = {
  ArrowUp:    {dx: 0, dy:-1},
  ArrowDown:  {dx: 0, dy: 1},
  ArrowLeft:  {dx:-1, dy: 0},
  ArrowRight: {dx: 1, dy: 0},
  KeyW:       {dx: 0, dy:-1},
  KeyS:       {dx: 0, dy: 1},
  KeyA:       {dx:-1, dy: 0},
  KeyD:       {dx: 1, dy: 0},
};

const KEYS_FALLBACK = {
  'ArrowUp':    {dx: 0, dy:-1},
  // … powtórzone z ArrowUp etc.
  'w': {dx: 0, dy:-1}, 'W': {dx: 0, dy:-1},
  'a': {dx:-1, dy: 0}, 'A': {dx:-1, dy: 0},
  // …
};

Dwa różne słowniki — kluczowy detal techniczny.

KeyboardEvent.code kontra KeyboardEvent.key

Klawiatura w przeglądarce daje dwa różne identyfikatory dla tego samego klawisza:

PropertyCo reprezentujePrzykład dla „W” na klawiaturze QWERTYWpływ układu
event.codefizyczne położenie klawisza"KeyW"nie zależy od układu
event.keylogiczny znak po przetworzeniu układu"w" lub "W"zależy od układu

Przykład: na układzie AZERTY (francuskim) klawisz w lokalizacji „W” (gdzie jest na QWERTY) generuje literę „Z”. event.code jest dalej "KeyW", event.key to "z".

Dla gry: chcemy, żeby klawisz „w lewym górnym rogu pod cyfrą 1” zawsze ruszał w górę, niezależnie od układu. Dlatego primarny słownik używa event.code:

function onKey(e){
  const m = KEYS[e.code] || KEYS_FALLBACK[e.key];
  // …
}

Najpierw próbujemy code (preferowane), potem key (fallback). Operator || działa: jeśli pierwsze jest undefined, weź drugie.

Dlaczego mamy oba? Bo code nie istnieje na bardzo starych przeglądarkach (sprzed 2016). Fallback dla key to ratunek dla 5% użytkowników.

Obsługa naciśnięcia

function onKey(e){
  ensureAudio();

  // przechwytywanie strzałek żeby nie przewijały strony
  if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)){
    e.preventDefault();
  }

  // wycisz
  if (e.code === 'KeyM' || e.key === 'm' || e.key === 'M'){
    muted = !muted;
    return;
  }

  if (!game.running) return;

  const m = KEYS[e.code] || KEYS_FALLBACK[e.key];
  if (m){
    game.director.nextDx = m.dx;
    game.director.nextDy = m.dy;
  }
}

ensureAudio() przy każdej akcji

Wywołanie go przy każdym przyciśnięciu klawisza zapewnia, że AudioContext jest stworzony i odzyskany — nawet jeśli wcześniej był suspendowany. Polityka autoplay przeglądarek wymaga, żeby tworzenie kontekstu było reakcją na user gesture — keydown jak najbardziej tym jest.

preventDefault dla strzałek

Strzałki na klawiaturze mają domyślną akcję w przeglądarce: przewijają stronę. To znaczy, że bez preventDefault, strzałka „w dół” nie tylko poruszyłaby dyrektora, ale też przeskrolowałaby cały dokument w dół. Spacja podobnie — domyślnie skroluje stronę o jeden ekran.

Wywołanie e.preventDefault() blokuje tę domyślną akcję. Dla nas — strzałki działają tylko w grze.

['ArrowUp', ...].includes(e.code) — sprawdzenie, czy klawisz jest jednym z naszych. Array.includes zwraca true gdy element jest w tablicy.

Wycisz na M

Toggle flagi muted. Trzy warianty rozpoznawania klawisza M:

  • e.code === 'KeyM' — fizyczne położenie;
  • e.key === 'm' — mała litera (gdy CapsLock off);
  • e.key === 'M' — wielka (gdy CapsLock on lub Shift wciśnięty).

Bufor kierunku — semantyka

game.director.nextDx = m.dx;
game.director.nextDy = m.dy;

Klawisz nie zmienia aktualnego kierunku (dx, dy). Tylko nextDx, nextDy. Pamiętasz omówienie bufora w sekcji 23? To tu.

Konsekwencja: jeśli gracz wciska klawisz, ale w danym momencie kierunek jest blokowany ścianą, postać się nie ruszy w tej klatce. Ale przy najbliższym ruchu (po przejściu bramki modulo 8 i znalezieniu otwartego kierunku z bufora) skręci.

Trzy warstwy listenerów

window.addEventListener('keydown', onKey, true);
document.addEventListener('keydown', onKey, true);
board.addEventListener('keydown', onKey);
board.addEventListener('click', () => board.focus());

Trzy poziomy listenerów dla tego samego eventu — defensive programming. Każdy zabezpiecza przed inną sytuacją.

Capture phase (true jako trzeci argument)

Eventy w DOM mają trzy fazy:

  1. Capture — od korzenia (window) w dół do celu;
  2. Target — na samym celu;
  3. Bubble — od celu z powrotem w górę.

Domyślnie addEventListener nasłuchuje w fazie bubble (drugi argument false lub pominięty). Przekazując true, nasłuchujemy w fazie capture — czyli wcześniej.

Dlaczego to ważne? Jeśli ktoś osadzi naszą grę w iframe, a parent strona też nasłuchuje strzałek (np. blog ma „strzałki przewijają komentarze”), to:

  • W bubble — parent dostałby event jako pierwszy i mógł go pochłonąć (stopPropagation);
  • W capture — my dostajemy go jako pierwsi, możemy zareagować i preventDefault.

Listener na window kontra document kontra board

  • window — najwyższy poziom; dostaje wszystkie eventy klawiatury w karcie.
  • document — poniżej window, ale powyżej elementów. Niektóre stare przeglądarki gubiły eventy na window.
  • board (canvas) — najniższy poziom; dostaje event jeśli canvas ma fokus. Bez tabindex="0" ten listener byłby martwy.

Trzy poziomy dają redundancję. W typowym przypadku event łapie się raz (na pierwszym listenerze, który go obsłużył). W edge cases — choćby jeden zadziała.

board.addEventListener('click', () => board.focus())

Klik w canvas → ustaw fokus na canvas. Bez tego użytkownik musiałby świadomie tabować do canvasa. Po kliknięciu na grę intuicyjnie oczekuje, że klawiatura zacznie sterować — i tak się dzieje.

postMessage z parent

window.addEventListener('message', e => {
  if (e.data && e.data.type === 'key' && e.data.code){
    onKey({code: e.data.code, key: e.data.code, preventDefault:()=>{}});
  }
});

Ostatnia warstwa: gdy gra jest osadzona jako iframe, czasami nie dostaje fokusa w ogóle (przeglądarka nie pozwala). Wtedy parent strona może wysłać do nas wiadomość:

// w parent stronie:
const iframe = document.querySelector('iframe.game');
iframe.contentWindow.postMessage({type: 'key', code: 'ArrowUp'}, '*');

I my w grze odbieramy:

  1. Sprawdzamy, czy wiadomość ma poprawny format (type === 'key');
  2. Konstruujemy „sztuczny event” o tej samej strukturze co prawdziwy KeyboardEvent;
  3. Wywołujemy onKey() z tym sztucznym obiektem.

preventDefault: () => {}

Sztuczny event musi mieć metodę preventDefault — bo nasz onKey ją wywołuje. Tu daje pustą funkcję — preventDefault dla zdarzenia z postMessage nie ma sensu (nie ma „domyślnej akcji”), ale wywołanie nie może rzucić wyjątku.

Bezpieczeństwo postMessage

Drobny niepokój: nie sprawdzamy e.origin. To znaczy, że jakakolwiek strona, która wstawi nasze iframe, może wysyłać nam komendy klawiatury. W naszym przypadku — nieszkodliwe (najgorzej, ktoś nam steruje grą). Ale w aplikacji wrażliwej (np. bankowość):

if (e.origin !== 'https://nasz-trusted-parent.com') return;

Sprawdzenie domeny przed reakcją.

Auto‑fokus na canvas

setTimeout(() => { try { board.focus(); } catch(e){} }, 100);

Po 100 ms od załadowania, próba ustawienia fokusa na canvas. Try/catch — bo w iframe czasem przeglądarka odmawia (security). Wtedy gracz musi sam kliknąć canvas, żeby zacząć grać. Ale w 95% przypadków fokus dochodzi automatycznie — UX jest płynne.

Dlaczego z opóźnieniem? Bo niektóre przeglądarki w pierwszej chwili po załadowaniu nie pozwalają na focus() programowy. 100 ms to bezpieczny czas.

⚠️ Iframe sandboxing

Gdy gra jest osadzona w iframe z atrybutem sandbox, niektóre uprawnienia są zablokowane. sandbox="allow-scripts" pozwala na wykonanie JS, ale bez allow-same-origin nie ma dostępu do document.cookie, bez allow-modals brak alert(), itd. Trzy warstwy listenerów + postMessage + auto‑focus to zestaw, który radzi sobie z większością konfiguracji sandboxingu — co jest istotne, gdy chcesz wstawiać grę na blog (Medium, WordPress, Notion).

30 JS — pętla animacji i bootstrap

Serce każdej gry przeglądarkowej to pętla animacji oparta o requestAnimationFrame. Plus inicjalizacja przy starcie i obsługa kliknięć w przyciski Start/Restart.

requestAnimationFrame

function loop(){
  if (game.running && !game.paused) game.tick();
  game.draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

requestAnimationFrame (rAF) to API przeglądarki do synchronizowania animacji z odświeżaniem ekranu. Działa lepiej niż setInterval z trzech powodów:

AspektsetIntervalrequestAnimationFrame
Synchronizacja z ekranemnietak (V‑sync)
Działa w nieaktywnej karcietak (wolniej)nie (pauzuje)
Stałe FPSnie zawszetak (60/120/144 = monitor)
Energia bateria laptopamarnotrawioszczędza

Co znaczy „synchronizacja z ekranem”?

Monitor odświeża się typowo co 16.67 ms (60 Hz). Gdy rysujesz częściej, niektóre klatki są zgubione (przeglądarka nie zdąży ich pokazać). Gdy rzadziej — widać szarpane animacje.

rAF wywołuje twoją funkcję dokładnie przed kolejnym odświeżeniem ekranu. Dzięki temu każda narysowana klatka jest widoczna. Na monitorach 144 Hz — 144 klatki/s. Na 60 Hz — 60 klatek/s. Twój kod nie musi wiedzieć — po prostu rysuje gdy mu pętla mówi.

Pauzowanie w nieaktywnej karcie

Gdy użytkownik przełączy się na inną kartę, rAF przestaje wywoływać twoją funkcję. To istotne — bez tego gra dalej by działała w tle, marnując CPU i baterię.

Konsekwencja: gdy wrócisz do karty, gra wznowi się tam, gdzie była. Bez przewijania w przyspieszonym tempie. Dlatego w grach internetowych nie ma „złodziei czasu”.

Struktura pętli

function loop(){
  if (game.running && !game.paused) game.tick();
  game.draw();
  requestAnimationFrame(loop);
}

Klasyczny układ:

  1. Update — modyfikacja stanu gry (tick);
  2. Render — narysowanie aktualnego stanu (draw);
  3. Schedule next — zarezerwowanie kolejnej klatki.

Render dzieje się zawsze

Zauważ: tick jest pod warunkiem (game.running), ale draw zawsze się wykonuje. Dlaczego?

Bo gdy gra nie działa (np. ekran intro lub gameOver), wciąż musimy coś rysować. Stan jest „zatrzymany”, ale obraz musi się odświeżać — choćby dlatego, że pulsujące kawy używają this.frame i bez wywołania draw() ich animacja by zniknęła.

Hmm, ale w naszym kodzie frame rośnie tylko w tick(). Więc gdy running = false, frame stoi — pulsacja kawy też. Renderer rysuje klatkę z tym samym frame'em raz po raz. Ekran „zatrzymany” — wszystko stoi, ale pixele się odświeżają.

Można byłoby zoptymalizować: gdy running = false, w ogóle nie wywoływać draw(). Mała oszczędność CPU. Ale dla tak prostej gry niepotrzebne.

Bootstrap — uruchomienie

game.draw();          // pre-render: pokażmy intro tło
requestAnimationFrame(loop);

Ostatnie linie skryptu. game.draw() wywołane raz, przed uruchomieniem pętli, żeby canvas nie był czarny przy pierwszym wyświetleniu (zanim pierwszy rAF się odpali — ~16 ms później).

Potem requestAnimationFrame(loop) rezerwuje pierwsze wywołanie. Pętla się uruchamia.

Ten pre‑render to drobny detal jakości — bez niego gracz zobaczyłby przez kilka klatek czarny prostokąt (default tło canvasa). Z nim — od razu narysowana scena, czeka tylko na klik „▶ Start”.

Obsługa przycisków

document.getElementById('startBtn').addEventListener('click', () => {
  ensureAudio();
  jingleStart();
  introPanel.classList.remove('show');
  game.reset();
  setTimeout(() => { try { board.focus(); } catch(e){} }, 50);
});

document.getElementById('restartBtn').addEventListener('click', () => {
  ensureAudio();
  jingleStart();
  gameOverPanel.classList.remove('show');
  game.reset();
  setTimeout(() => { try { board.focus(); } catch(e){} }, 50);
});

Bardzo podobna logika dla obu przycisków — różnią się tylko nazwą panelu do ukrycia.

Sekwencja akcji

  1. ensureAudio() — utworzenie/odzyskanie kontekstu audio.
  2. jingleStart() — fanfary startu.
  3. Ukrycie panelu (usunięcie klasy „show” → CSS display: none).
  4. game.reset() — pełen reset stanu gry.
  5. board.focus() z opóźnieniem 50 ms — przekierowanie fokusu na canvas, żeby klawiatura działała od razu.

Dlaczego DRY (Don't Repeat Yourself) tu wzgardzono?

Można było wyodrębnić wspólną funkcję:

function startGame(panelToHide){
  ensureAudio();
  jingleStart();
  panelToHide.classList.remove('show');
  game.reset();
  setTimeout(() => board.focus(), 50);
}
document.getElementById('startBtn').onclick = () => startGame(introPanel);
document.getElementById('restartBtn').onclick = () => startGame(gameOverPanel);

Bardziej DRY, ale dodatkowa funkcja. W kodzie produkcyjnym warto. W edukacyjnym — repetycja jest czasem czytelniejsza, bo każdy listener „widać w całości”.

31 Architektura całości

Krok wstecz. Zobaczmy grę jako całość — jakie wzorce architektoniczne zostały tu użyte, świadomie czy nie.

Data‑oriented design

Cała gra ma jeden centralny obiekt game, który zawiera dane (mapa, postacie, score) oraz metody (logika ruchu, kolizji, AI). Postacie (dyrektor, kasztanki) to płaskie rekordy bez własnych metod — działania na nich wykonują metody z game.

To podejście różni się od klasycznego OOP, gdzie każda klasa encje miałaby własne metody:

// OOP:
chestnut.move();
chestnut.chooseDirection(target);
director.eat(chestnut);

// Data-oriented (nasze):
game.tick(); // jedna funkcja operuje na wszystkim

Plusy DOD:

  • Mniej cermonii — brak konstruktorów, dziedziczenia, polimorfizmu;
  • Łatwo dodać postać — wystarczy dopisać do tablicy;
  • Świadoma centralizacja logiki — wszystko w jednym miejscu;
  • Wydajność — operacje na tablicach prostych obiektów są szybsze niż na grafie obiektów z metodami.

Minusy:

  • Mniej intuicyjne dla osób przyzwyczajonych do OOP;
  • Trudniej testować poszczególne fragmenty — wszystko zazębione;
  • Skala: dla dużych gier (10000+ encji) wymaga ECS (Entity‑Component‑System).

Brak frameworków

Ani React, ani Vue, ani Phaser, ani PixiJS. Czyste HTML/CSS/JS. Why?

  • Edukacyjne — uczeń widzi wszystko. Żadnej magii ukrytej w bibliotece.
  • Prostota wdrożenia — jeden plik wgrany na serwer. Bez build, bez dependencies.
  • Mały rozmiar — ~10 KB. Phaser sam waży 1.2 MB.
  • Niezależność — żadne API nie będzie deprecated. Czysty JS z 2020 r. działa w 2030.

Wadą jest brak abstrakcji nad rendererem — gdy gra urośnie do 1000 encji, trzeba samemu napisać sortowanie z‑order, pooling obiektów, etc. Phaser to robi za darmo.

Single‑file deployment

Cały kod gry — HTML, CSS, JS, audio (syntezowane!) — w jednym pliku .html. Brak żadnego pliku zewnętrznego do pobrania.

Konsekwencje:

  • Pierwsze wczytanie strony to jedno żądanie HTTP — szybkie;
  • Brak problemów z CORS — żadne fetchy zewnętrzne;
  • Można otworzyć z file:// (po lokalu, bez serwera);
  • Łatwo udostępnić — kopiujesz plik i dajesz komuś.

State machine (uproszczone)

Gra ma trzy główne stany:

  1. INTRO — przed startem, panel intro widoczny;
  2. RUNNING — gra się toczy;
  3. GAME_OVER — po końcu, panel widoczny.
Stany gry — trzy etapy, ze ścisłymi przejściami INTRO running = false introPanel widoczny czeka na klik RUNNING running = true tick() działa grasz! GAME_OVER running = false gameOverPanel widoczny koniec klik „Start” reset() lives = 0 gameOver() level++ (poziom ukończony) klik „Zagraj ponownie” (z reset() — wszystko od nowa)
Trzy stany gry i przejścia między nimi. Każdy stan ma jasno zdefiniowane: kto widoczny, co działa, co ma się stać.

Reprezentacja: flaga running (true w RUNNING, false w pozostałych). Plus widoczność paneli: introPanel ma klasę „show” w INTRO, gameOverPanel ma klasę „show” w GAME_OVER.

Można byłoby formalniej:

const state = { value: 'intro' };
function setState(newState){
  state.value = newState;
  // pokaż/ukryj panele
  // start/stop tick
}

Pełniej, ale dla 3 stanów to już over‑engineering. Flagi wystarczają.

Pamięć i wydajność

ZasóbRozmiarKomentarz
Tabela mapy (this.map)~3.5 KB440 liczb
4 kasztanki~600 Bobiekty z 6 polami
1 dyrektor~150 Bobiekt z 7 polami
Kontekst audio~16 KBbuforu wyjściowego
Bufor canvasa (640×704×4)~1.7 MBRGBA

Łącznie ~2 MB pamięci RAM. Niezauważalne na nowoczesnym PC, akceptowalne nawet na starym smartfonie.

Cykl klatki — czas i operacje

Przy 60 fps mamy 16.67 ms na klatkę.

  • tick() — zwykle < 0.1 ms (różowy, nawet z AI);
  • draw() — ~1‑2 ms (głównie 440 fillRect dla kafelków + 5 fillText);
  • Reszta to przerwy oddane przeglądarce na compositing i input.

Margines wydajności jest ogromny — gra ma rezerwę 5x. Można byłoby dodać znacznie więcej efektów (cząsteczki, shadery, parallax tła) zanim zauważysz spowolnienie.

32 Możliwe ulepszenia

Gra jest skończona, ale nie idealna. Lista pomysłów, którymi można byłoby ją rozbudować — od kosmetycznych po architekturalne. Większość to tematy na osobne lekcje.

Skala trudności na poziomach

Obecnie poziomy 1, 2, 3 są identyczne — tylko liczba w HUD się zmienia. Można dodać:

// W tick():
const moveInterval = Math.max(4, 8 - Math.floor(this.level / 2));
if (this.frame % moveInterval !== 0) return;

Z każdym 2. poziomem ruch o 1 klatkę szybszy (do minimum 4). Poziom 1 — co 8 klatek, poziom 3 — co 7, poziom 5 — co 6, ... poziom 9+ — co 4. Maksymalna trudność.

Inne pomysły:

  • Krótszy czas kawy na wyższych poziomach;
  • Więcej kasztanków od poziomu 5 (5 zamiast 4);
  • Bonusowe owoce pojawiające się czasowo (klasyczny Pac‑Manowy mechanizm).

Indywidualne osobowości kasztanków

Wspomniałem to w sekcji o AI. Można rozbudować:

const personalities = {
  'red':    (c, dir) => ({x: dir.x, y: dir.y}),                   // bezpośredni pościg
  'pink':   (c, dir) => ({x: dir.x + dir.dx*4, y: dir.y + dir.dy*4}),  // 4 kratki przed graczem
  'cyan':   (c, dir, red) => ({                                   // używa pozycji red
    x: dir.x + (dir.x - red.x), y: dir.y + (dir.y - red.y)
  }),
  'orange': (c, dir) => {                                          // tchórz
    const dist = Math.abs(c.x - dir.x) + Math.abs(c.y - dir.y);
    return dist < 8 ? {x: 0, y: 21} : {x: dir.x, y: dir.y};
  },
};

Każdy kasztanek liczy swój własny target tile, do którego dąży. AI wybiera kierunek najbliższy do tego tile'a.

Animacja postaci

Pole mouthAnim w obiekcie director jest nieużywane — pierwotny zamysł to zapewne animacja paszczy (otwieranie/zamykanie). Można to dorobić:

// w draw():
const mouthOpen = this.director.mouthAnim < 8;
const emoji = mouthOpen ? '🤩' : '🧑‍🏫';
ctx.fillText(emoji, px, py);
// w tick():
this.director.mouthAnim = (this.director.mouthAnim + 1) % 16;

Co 8 klatek pyszczek się otwiera/zamyka. Subtelna animacja, ale zauważalna.

Tweening ruchu (płynny)

Dyskretny ruch po kratkach jest „kanciasty”. Można dodać interpolację:

// pole director:
prevX: 1, prevY: 1, lerpT: 0,
// w tick przed ruchem:
this.director.prevX = this.director.x;
this.director.prevY = this.director.y;
this.director.lerpT = 0;
// po ruchu:
this.director.lerpT = Math.min(1, this.director.lerpT + 1/8);

// w draw:
const px = (this.director.prevX + (this.director.x - this.director.prevX) * this.director.lerpT) * TILE;
// analogicznie dla py

Postać porusza się od starej pozycji do nowej w czasie 8 klatek. Płynny ruch, dyskretna kolizja.

Cząsteczki i efekty

Gdy gracz zje kanapkę — niech rozpryskają się malutkie żółte kropki. Gdy zje kasztanka — efekt eksplozji. Implementacja: tablica obiektów particles, każdy z pozycją, prędkością, czasem życia. W tick() aktualizacja, w draw() rysowanie.

Prawdziwe pliki audio

Aktualnie cały dźwięk jest syntezowany. Plus: brak plików, brak rozmiaru. Minus: ograniczona barwa (4 kształty fali, brak instrumentów). Można dodać plik sounds.mp3 z prawdziwymi efektami:

const eatSound = new Audio('eat.mp3');
function playEat(){ eatSound.currentTime = 0; eatSound.play(); }

Lepsze brzmienie, ale rezygnacja z idei „jeden plik”.

Zapis najlepszego wyniku

localStorage do trwałego zapisu rekordu:

const highScore = +(localStorage.getItem('dyrektor-highscore') || 0);
// po końcu gry:
if (this.score > highScore) localStorage.setItem('dyrektor-highscore', this.score);

HUD pokazuje „REKORD: X”. Motywuje do pobicia.

Sterowanie dotykiem

Na mobile klawiatura nie działa. Można dodać 4 strzałki na ekranie:

<div class="touch-controls">
  <button data-dx="0" data-dy="-1">↑</button>
  <button data-dx="-1" data-dy="0">←</button>
  <button data-dx="1" data-dy="0">→</button>
  <button data-dx="0" data-dy="1">↓</button>
</div>

JS odczytuje data‑atrybuty i ustawia nextDx, nextDy identycznie jak w klawiaturze.

Dostępność (a11y)

Dla niewidomych gra jest niedostępna (canvas to czarna skrzynka dla czytników ekranu). Można dodać:

  • aria-live="polite" na HUD — czytnik czyta zmiany punktacji;
  • aria-modal="true" i role="dialog" na panelach;
  • Obsługę „klawiszy szybkich” — np. liczba dla kierunku, P na pauzę.

Pauza

Pole this.paused już jest! Ale nigdy nie ustawiane na true. Wystarczy dorobić handler na P:

if (e.code === 'KeyP'){
  game.paused = !game.paused;
}

Tick i draw są na to przygotowane (sprawdzają !this.paused). Jedna linia, gotowe.

33 Glosariusz pojęć

Krótka, alfabetyczna lista pojęć, których używałem w tym artykule. Dla łatwego powrotu, gdy coś zapomnisz.

aspect-ratio
Property CSS, która ustala stosunek szerokości do wysokości elementu. Zwalnia z konieczności synchronizacji width/height.
ADSR
Attack‑Decay‑Sustain‑Release — czteroskładnikowy model obwiedni głośności w syntezie dźwięku. Najprostsza forma to AR (atak‑zanik) — używana u nas.
AudioContext
Centralny obiekt Web Audio API. Zarządza grafem węzłów audio i generuje sygnał.
bramka modulo
Trick: if (frame % N !== 0) return — wykonanie kodu tylko co N klatek. Stosowane do regulacji szybkości.
bufor kierunku (input buffering)
Klasyczny pattern w grach kafelkowych. Klawisz nie zmienia kierunku natychmiast, tylko wskazuje „chcę skręcić, gdy się da”.
capture phase
Faza eventów DOM — od korzenia w dół do celu. Listener zarejestrowany w tej fazie dostaje event jako pierwszy.
data-oriented design
Filozofia: dane (rekordy) oddzielone od logiki (funkcji). Przeciwieństwo klasycznego OOP.
delta time
Czas między klatkami. W zaawansowanych grach skaluje ruch proporcjonalnie do delty, dając stałą szybkość niezależnie od FPS.
fixed timestep
Założenie, że klatki dzieją się co stały czas (np. 60 fps). Prosta implementacja.
FOUC (Flash of Unstyled Content)
Moment, w którym widać niestylowaną treść, bo CSS jeszcze nie został załadowany. Inline CSS to eliminuje.
game loop
Główna pętla gry: update → render → next frame. U nas oparty o requestAnimationFrame.
greedy algorithm
Algorytm wybierający lokalnie najlepszą opcję. Używany w naszym AI — kasztanek wybiera kierunek, który zbliża go najbardziej do gracza w danej chwili.
HUD (Heads‑Up Display)
Nakładka informacyjna w grze — wynik, życia, czas. U nas pasek nad canvasem.
IIFE (Immediately Invoked Function Expression)
Funkcja, która wywołuje samą siebie natychmiast. Pattern izolacji namespace'u: (function(){…})();
image-rendering: pixelated
Property CSS wyłączające interpolację przy skalowaniu. Daje efekt „pixel art” zamiast rozmycia.
Manhattan distance
Odległość siatkowa: |x1-x2| + |y1-y2|. Naturalna miara dla ruchu 4‑kierunkowego.
oscillator
Generator fali w Web Audio API. Wytwarza sygnał o danej częstotliwości i kształcie (sine/square/sawtooth/triangle).
postMessage
API komunikacji między oknami/iframe. Pozwala parent stronie wysyłać dane do dziecka, omijając same‑origin policy.
preventDefault()
Metoda na evencie, blokująca domyślną akcję przeglądarki (np. przewijanie strzałkami).
requestAnimationFrame (rAF)
API przeglądarki do synchronizowania animacji z odświeżaniem ekranu. Kluczowe dla płynnych gier.
scared mode
Tryb przestraszonych wrogów po zjedzeniu kawy. AI ucieka zamiast gonić; kolizja daje punkty zamiast śmierci.
short‑circuit evaluation
Skracanie obliczeń logicznych. A && B nie sprawdza B jeśli A jest false. A || B nie sprawdza B jeśli A jest true.
spread operator (...)
Operator rozsypujący elementy tablicy lub pola obiektu. {...obj, extra: 1} to nowy obiekt z polami z obj plus extra.
tabindex
Atrybut HTML czyniący element fokusowalnym. tabindex="0" — naturalna kolejność, "-1" — programowo, dodatnia — niestandardowa.
tile-based game
Gra, w której świat jest podzielony na siatkę kratek. Postacie poruszają się dyskretnie od kratki do kratki.
tweening
Interpolacja między dwoma stanami w czasie. W grze: płynny ruch postaci między kratkami.
UTF-8
Uniwersalne kodowanie znaków obsługujące polski alfabet i emoji. Standard współczesnego HTML.
z-index
Property CSS określająca kolejność warstw. Wyższy z‑index = bliżej widza, czyli nad innymi elementami.

To koniec! Jeśli dotarłeś tutaj — gratuluję. Masz teraz naprawdę głębokie zrozumienie tej gry. Każdy fragment kodu, który ją tworzy, masz „przerobiony” w głowie.

Następny krok: napisz swoją grę. Skopiuj ten plik, zmień bohatera, zmień mapę, dodaj coś własnego. Najlepsza nauka to praktyka.

Marek Spurek · marekuczy.pl · technikum informatyczne

Materiał edukacyjny dla uczniów INF.02 / INF.03

← Wszystkie wpisy opublikowano 12.05.2026 12:13