Tile-based gra w stylu Pac-Man — rozkład 360 linii kodu na czynniki pierwsze. Materiał dla uczniów INF.02 / INF.03.
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.
Strona internetowa działa trochę jak prawdziwy dom. W prawdziwym domu masz:
Strona internetowa ma dokładnie te same trzy rzeczy, tylko inaczej się nazywają:
| Dom | Strona internetowa | Co to jest |
|---|---|---|
| Konstrukcja (ściany, dach) | HTML | Szkielet strony — co jest na stronie. Tytuł, akapit, obrazek, przycisk. |
| Wystrój (kolory, meble) | CSS | Wyglą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).
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ść.
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).
„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ś:
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ę:
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ę.
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.
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ę.
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.
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:
„Dyrektor i kanapki” to ta sama gra, tylko z polskim klimatem szkolnym:
| Pac-Man (1980) | Dyrektor i kanapki (2026) |
|---|---|
| Pac-Man (żółta kulka) | Dyrektor 🧑🏫 |
| Kropki | Kanapki 🥪 |
| Power pellety | Kawa ☕ |
| Cztery duszki: Blinky/Pinky/Inky/Clyde | Cztery kasztanki 🌰 |
| Labirynt | Szkolna kuchnia (labirynt) |
Mechanika identyczna, klimat polski, można zagrać każdemu uczniowi. Jakość edukacyjna — wysoka.
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:
I tak w nieskończoność, aż albo zjesz wszystkie kanapki (level up!) albo stracisz wszystkie 3 życia (gameOver).
Resztę tekstu poświęcę na rozbiórkę całego kodu gry — linijka po linijce. Pokażę ci:
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.
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.
Tych słów będę używał często. Lepiej, żebyś teraz znał je z grubsza, niż żebyś się pomylił w trakcie:
.html. Zawiera strukturę strony internetowej. Można go otworzyć w przeglądarce.<p> (akapit) lub <div> (kontener). Zaczyna się znacznikiem otwierającym, kończy zamykającym.jingleStart() to funkcja grająca fanfary.score przechowuje aktualny wynik.if (lives === 0) — „jeśli liczba żyć to 0”.chestnuts to tablica czterech obiektów-kasztanków.No to lecimy. Sekcja 01 zaczyna prawdziwą analizę.
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.
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.
Cała gra zmieściła się w jednym pliku .html. Brzmi staroszkolnie, ale ma to wymierne korzyści dydaktyczne i praktyczne:
npm install, nie ma webpacka, nie ma transpilacji.Wadą jest to, że pliku ponad pewien rozmiar (powiedzmy 1500 linii) staje się trudny w utrzymaniu. Dla naszej gry — 360 linii — to sweet spot.
Gra wprost cytuje kilka klasycznych mechanik z Pac‑Mana z 1980 r.:
☕) — daje 6 sekund nieśmiertelności i zamienia drapieżników w ofiary;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.
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.
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:
:lang(pl) oraz pseudoklasy językowe;<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>.
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.
<style> vs zewnętrzny CSSW 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ę.
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 statusuHUD (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:
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.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 canvasaWewnę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>
width i heightTo 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ę).
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.
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.
<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ę.
<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.”.<dialog>?HTML5 ma natywny element <dialog> z metodami showModal() i close(). Kuszące, ale w grze niepraktyczne — modal natywny:
Zwykły div z position: absolute daje pełną kontrolę i działa wszędzie.
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;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.
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;
}
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 100vhDlaczego 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 #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-uiTo genialny mechanizm CSS: deklarujemy „użyj fontu systemowego”, a przeglądarka wybiera natywny font interfejsu na danym OS:
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: hiddenWyłą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.
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.
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 flexTutaj można by użyć obu — flex z flex-direction: column dałby ten sam efekt. Wybrano grid, bo:
auto 1fr czyta się jak instrukcja: „pierwszy wiersz wedle treści, drugi cała reszta”;auto: grid-template-rows: auto 1fr auto;1frSkró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 celowyWcześniej wspominałem, że vh bywa niestabilne na mobile. Jednak na .game-wrap używamy 100vh, bo:
html, body mają już height: 100%, więc 100vh jest tożsame z 100%;gap: 6px i padding: 6pxDrobne 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.
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:
circle — kształt; alternatywnie ellipse;at 50% 30% — punkt środka;#2c3340 0% — kolor w środku (jaśniejszy szary);#0a0c10 100% — kolor na brzegach (prawie czerń).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.
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: wrapFlex 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: centerWyró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 flexieTa 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.
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:
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łkaKlasyczny 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.
.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.
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: pixelatedTo 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ść | Algorytm | Efekt |
|---|---|---|
auto (domyślne) | bilinearne | gładko, rozmazane na zoomie |
pixelated | nearest neighbor | ostre piksele, schodki |
crisp-edges | algorytm chroniący krawędzie | kompromis (rzadziej używane) |
smooth | wymuszona interpolacja | gwarancja rozmycia |
aspect-ratio: 20 / 22Stosunek 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 :focusDomyś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 optymalizacjaDomyś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łębiaCień 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.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{
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: 0inset 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: gridTrick: display: none nie tylko ukrywa element, ale też całkowicie usuwa go z układu i drzewa renderowania. Element ukryty w ten sposób:
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: centerShorthand 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: 10Panel 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.
.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.
.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.
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 …
})();
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:
game, jedna nadpisze drugą. W naszej grze takich zmiennych jest pełno: TILE, COLS, game, audioCtx, muted, KEYS, ...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.
(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 ().
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:
file://;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).
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;
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”.
const, nie let ani var?| Słowo | Scope | Reassignment | Hoisting |
|---|---|---|---|
var | funkcyjny | tak | do góry funkcji, jako undefined |
let | blokowy | tak | jest, ale „temporal dead zone” |
const | blokowy | NIE | jak 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.
TILE = 32Standard w grach 2D. Liczby będące potęgami dwójki (16, 32, 64) są historycznie preferowane, bo:
Można by równie dobrze użyć 24 lub 40 — gra by działała. 32 to tradycja, nie konieczność.
COLS = 20, ROWS = 22Wymiary mapy. 20 × 22 = 440 kratek łącznie. Dlaczego prostokąt, a nie kwadrat?
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).
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
];
| Kod | Znaczenie | Kolizja? | Po zjedzeniu staje się |
|---|---|---|---|
0 | ściana | tak — blokuje ruch | — |
1 | kanapka (kropka) | nie — można wejść, +10 pkt | 2 |
2 | pusta ścieżka | nie — można wejść | — |
3 | kawa (power‑up) | nie, +50 pkt + scared‑mode | 2 |
4 | klatka kasztanków | nie | — |
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:
[0,1,0,0,1] to 17 znaków razem z przecinkami.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));
Trzy zagnieżdżone wywołania .map() i jedno .split(). Krok po kroku:
RAW_MAP.map(row => ...) — iteracja po wszystkich 22 wierszach. row to string typu "00000000000000000000".
row.split('') — rozbicie stringa na pojedyncze znaki. "010" staje się ["0", "1", "0"].
.map(c => +c) — konwersja każdego znaku na liczbę. "0" staje się 0.
++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.
Operacja RAW_MAP.map(...) tworzy nową tablicę zewnętrzną i nowe tablice wewnętrzne. To ważne, bo gra modyfikuje mapę (zjedzona kanapka 1 → 2). 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.
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.
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).
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”.
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.
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.
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).
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.
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();
}
audioCtx jest tworzony dopiero na żądanie (lazy), nie od razu na starcie. Dlaczego?
AudioContext przy pierwszym kliknięciu jest „kosherną” praktyką.window.AudioContext || window.webkitAudioContextStara 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.
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ć.
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.
muted i wakaToggleDwie 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.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);
}
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).if (muted || !audioCtx) return;
Klasyczny wzorzec „guard clause”. Trzy scenariusze blokujące:
ensureAudio());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ść.
o.type = type; // 'square', 'sawtooth', 'triangle', 'sine'
| Typ | Brzmienie | Charakter widmowy | Zastosowanie w grze |
|---|---|---|---|
sine | czyste, łagodne | tylko podstawowa | — |
square | elektroniczne, „chip‑tune” | nieparzyste harmoniczne | waka, jingleStart |
sawtooth | ostre, agresywne | wszystkie harmoniczne | jingleDeath |
triangle | łagodne ale z barwą | nieparzyste, słabsze | jinglePower |
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ę.
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.
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().
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.
const t = audioCtx.currentTime — pobranie aktualnego czasu kontekstu (w sekundach od jego utworzenia, nie od epoki). Wszystkie operacje audio operują na tej osi czasu.o.start(t) — uruchomienie oscylatora dokładnie w chwili t. Faktycznie jest to „natychmiast”, ale używamy explicit czasu, żeby synchronizować z gainem.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.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.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.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.
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.
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) | Nuta | Pozycja w skali |
|---|---|---|
| 523 | C5 | do |
| 659 | E5 | mi |
| 784 | G5 | sol |
| 1046 | C6 | do (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”.
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.
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.
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.
waka() — rytm pochłanianialet 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.
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:
Dla precyzyjnej muzyki w grach (np. rytmicznych typu Guitar Hero) zdecydowanie używa się Web Audio scheduling — ale to inny temat.
gameCał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){ /* … */ },
};
map — bieżący stan planszyTablica 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 1 → 2).
totalSandwiches — liczba kanapek na starcie poziomuStała w obrębie poziomu, używana do obliczenia maksymalnego wyniku (HUD pokazuje „Wynik / Maksimum”).
director — graczObiekt z polami: x, y, dx, dy, nextDx, nextDy, alive, mouthAnim. Inicjalizowany w reset().
chestnuts — wrogowieTablica 4 obiektów. Każdy: {x, y, dx, dy, color, scared}. Spawn w spawnChestnuts().
score, lives, levelLiczby. score resetuje się na nowej grze, lives spada przy kolizji, level rośnie po zebraniu wszystkich kanapek.
powerTimer — odliczanie kawyLiczba ticków pozostałych w trybie „kasztanki przestraszone”. Maleje co tick. Kiedy spadnie do 0, kasztanki wracają do trybu agresywnego.
frame — globalny licznik klatekInkrementowany w każdym wywołaniu tick(). Używany do:
frame % 8) — ruch siatkowy;Math.sin(frame/6));frame % 16);frame % 32).running i pausedFlagi 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 śmierciGdy 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).
// 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ł:
game. Klasa byłaby przereklamowaniem.class, constructor, new.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.
// 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 metodachW 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.
reset() i klonowanie mapyFunkcja 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();
},
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.
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:
"00000000000000000000" — same ściany;"01111111111111111110" — korytarz, na pozycji x=1 jest jedynka.Dyrektor staje na pierwszej kanapce. Po pierwszym wywołaniu tick() ją zje (jeśli się ruszy).
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.
mouthAnimPole 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.
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ępusoftReset(){
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”.
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).
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).
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 },
];
},
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”.
| Kasztanek | Pozycja | Kierunek | Kolor | Skojarzenie |
|---|---|---|---|---|
| 1 | (9, 10) | → prawo (1, 0) | #ff4444 — czerwony | Blinky |
| 2 | (10, 10) | ← lewo (-1, 0) | #ffb6c1 — różowy | Pinky |
| 3 | (9, 11) | ↓ dół (0, 1) | #22d3ee — cyjan | Inky |
| 4 | (10, 11) | ↑ góra (0, -1) | #fbbf24 — pomarańcz | Clyde |
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).
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.
scaredDomyś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;
// …
}
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.
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:
0 to ściany.===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.
||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”.
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ę.
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.
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.
tick() z lotu ptakaTo 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) { /* … */ }
},
Kolejność akcji w funkcji tick() nie jest losowa — odzwierciedla priorytet rzeczy do wykonania:
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.
| Akcja | Częstotliwość | Co ile klatek | Przy 60 fps |
|---|---|---|---|
| tick wywołany | każda klatka | 1 | 60 razy/s |
| Ruch postaci | frame % 8 === 0 | 8 | 7.5 razy/s |
| Dźwięk waka | frame % 16 === 0 | 16 | 3.75 razy/s |
| Decyzja AI | frame % 32 === 0 | 32 | ~1.9 razy/s |
Czemu różne wartości? Każda akcja ma naturalne tempo:
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.
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;
}
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).returnPo decremcie i sprawdzeniu — return. To sprawia, że cała reszta tick() się nie wykonuje. Świat stoi.
Konsekwencje:
draw() rysuje tylko stan, nie zmienia logiki).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.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.
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.
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;
% (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, …).
To eksperymentalnie dobrany kompromis:
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).
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ą.
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.
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.
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();
}
}
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:
nextDx lub nextDy nie jest zerem)?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.
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.
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”.
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.
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)?
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.
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.
dy=1 ustawione, ale w dół jest ściana, więc canMove zwraca false. Ruch w prawo dalej (poprzedni kierunek).Z buforem:
nextDx=0, nextDy=1 ustawiane.nextDy zostaje na 1. Ruch w prawo zgodnie z dx=1.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.
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.
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();
}
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.
tile === 1)1 → 2 — kafelek staje się pustą ścieżką (kod 2). Renderer już go nie narysuje jako kanapki.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ć.tile === 3)3 → 2 — kawa znika.powerTimer = 360 — uruchomienie 6‑sekundowego efektu (przy 60 fps). Liczba 360 to magic number, ale czytelnie skomentowany.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.// 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.
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;
}
this.level++ — inkrementacja poziomu. HUD pokaże nowy numer.this.map = RAW_MAP.map(...) — świeży klon mapy. Wszystkie kanapki znowu są.this.softReset() — restart pozycji (gracz na (1,1), kasztanki w klatce). Nie używamy tu reset(), bo reset wyzerowałby score i lives.this.updateHud() — odświeżenie wyświetlacza.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.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.
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.
Niektóre gry mają finalny ekran „Wygrałeś!” po pokonaniu ostatniego poziomu. U nas — nie. Dlaczego?
To filozofia oryginalnego Pac‑Mana — gra ma kontynuować w nieskończoność, aż do śmierci. Wygrana = przejście jak najdalej.
gameOvergameOver(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”).
this.running = false — pętla logiki przestaje działać.document.getElementById — pobranie elementów DOM i podmiana tekstu. textContent, nie innerHTML — bezpieczniej (brak ryzyka XSS, choć tu nie istotne).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')).
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;
}
});
}
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ę.
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.
.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).
.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”.
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).
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).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);
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:
Math.sqrt((x1-x2)² + (y1-y2)²). Linia prosta. Niedostosowana dla siatki.Math.max(|x1-x2|, |y1-y2|). Dla ruchu 8‑kierunkowego (z przekątnymi).reduce — pętla z akumulatoremarray.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:
best = null.d = {dx:1, dy:0}. dist dla niego np. 5. best === null, więc nadpisz: best = {dx:1, dy:0, dist:5}.d = {dx:0, dy:1}. dist np. 3. 3 < 5 → nadpisz: best = {dx:0, dy:1, dist:3}.best trzyma kierunek z najmniejszą odległością.{...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).
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.
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.
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.
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ć.
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();
}
}
});
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:
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.
c.x = 9; c.y = 10; c.dx = 1; c.dy = 0;
c.scared = false;
this.score += 200;
jingleEat();
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ę.jingleEat() — sygnał dźwiękowy zjedzenia.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.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.
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){ /* … */ }
},
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.
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.
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.
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.
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).
arcSkładnia: ctx.arc(x, y, radius, startAngle, endAngle, [counterclockwise]).
Pełne koło: od 0 do 2π, czyli Math.PI * 2 ≈ 6.283 radianów.
beginPath → arc → fillCanvas 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.
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)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);
});
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.
color = c.color — własny kolor kasztanka (czerwony, różowy, cyjan, pomarańcz).#3344aa.Mruganie to wskazówka dla gracza: „pospiesz się, kawa zaraz się skończy”. Klasyczne UX z Pac‑Mana.
this.frame % 12 < 6Ten 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.
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.
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;
}
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 zanikuctx.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.
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.
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...”.
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.keyKlawiatura w przeglądarce daje dwa różne identyfikatory dla tego samego klawisza:
| Property | Co reprezentuje | Przykład dla „W” na klawiaturze QWERTY | Wpływ układu |
|---|---|---|---|
event.code | fizyczne położenie klawisza | "KeyW" | nie zależy od układu |
event.key | logiczny 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.
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 akcjiWywoł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łekStrzał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.
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).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.
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ą.
true jako trzeci argument)Eventy w DOM mają trzy fazy:
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:
stopPropagation);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.
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:
type === 'key');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.
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ą.
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.
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).
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.
requestAnimationFramefunction 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:
| Aspekt | setInterval | requestAnimationFrame |
|---|---|---|
| Synchronizacja z ekranem | nie | tak (V‑sync) |
| Działa w nieaktywnej karcie | tak (wolniej) | nie (pauzuje) |
| Stałe FPS | nie zawsze | tak (60/120/144 = monitor) |
| Energia bateria laptopa | marnotrawi | oszczędza |
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.
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”.
function loop(){
if (game.running && !game.paused) game.tick();
game.draw();
requestAnimationFrame(loop);
}
Klasyczny układ:
tick);draw);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.
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”.
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.
ensureAudio() — utworzenie/odzyskanie kontekstu audio.jingleStart() — fanfary startu.display: none).game.reset() — pełen reset stanu gry.board.focus() z opóźnieniem 50 ms — przekierowanie fokusu na canvas, żeby klawiatura działała od razu.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”.
Krok wstecz. Zobaczmy grę jako całość — jakie wzorce architektoniczne zostały tu użyte, świadomie czy nie.
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:
Minusy:
Ani React, ani Vue, ani Phaser, ani PixiJS. Czyste HTML/CSS/JS. Why?
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.
Cały kod gry — HTML, CSS, JS, audio (syntezowane!) — w jednym pliku .html. Brak żadnego pliku zewnętrznego do pobrania.
Konsekwencje:
file:// (po lokalu, bez serwera);Gra ma trzy główne stany:
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ą.
| Zasób | Rozmiar | Komentarz |
|---|---|---|
| Tabela mapy (this.map) | ~3.5 KB | 440 liczb |
| 4 kasztanki | ~600 B | obiekty z 6 polami |
| 1 dyrektor | ~150 B | obiekt z 7 polami |
| Kontekst audio | ~16 KB | buforu wyjściowego |
| Bufor canvasa (640×704×4) | ~1.7 MB | RGBA |
Łącznie ~2 MB pamięci RAM. Niezauważalne na nowoczesnym PC, akceptowalne nawet na starym smartfonie.
Przy 60 fps mamy 16.67 ms na klatkę.
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.
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.
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:
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.
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.
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.
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.
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”.
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.
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.
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;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.
Krótka, alfabetyczna lista pojęć, których używałem w tym artykule. Dla łatwego powrotu, gdy coś zapomnisz.
if (frame % N !== 0) return — wykonanie kodu tylko co N klatek. Stosowane do regulacji szybkości.(function(){…})();|x1-x2| + |y1-y2|. Naturalna miara dla ruchu 4‑kierunkowego.A && B nie sprawdza B jeśli A jest false. A || B nie sprawdza B jeśli A jest true.{...obj, extra: 1} to nowy obiekt z polami z obj plus extra.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.