Jedna jaskinia, każdy gracz
Jak TUNL buduje dzienny poziom z samej daty i dwa błędy, które po cichu psuły go przez miesiące.
TUNL to gra o lataniu po jaskini z ciągiem przy przytrzymaniu. Przytrzymujesz, żeby się wznosić, puszczasz, żeby opadać, i próbujesz dolecieć dalej w zwężającym się tunelu niż wczoraj. Każdy gracz leci przez tę samą jaskinię tego samego dnia, a na granicy północy UTC staje się ona inną.
Nie ma pliku poziomu. Żaden serwer nie rozdaje mapy, nic nie jest pobierane, a gra nie przechowuje tunelu, który właśnie wygenerowała. Jaskinia jest czystą funkcją daty, a cały "świat" to około czterdziestu linii arytmetyki.
Ten projekt kupuje zaskakująco dużo: uczciwy globalny ranking, powtórkę ducha, która kosztuje kilkaset bajtów, i obraz twojego przelotu, którym można się dzielić. Narzuca też ograniczenie o wiele surowsze, niż się początkowo wydaje, a ja złamałem je dwukrotnie, nie zauważając. Ten wpis dotyczy obu połówek.
Poziom, który jest funkcją, nie plikiem
Większość gier proceduralnych generuje świat na początku przelotu i trzyma go w pamięci. TUNL nie trzyma go wcale. Środek i półszerokość korytarza w dowolnej pozycji świata wx są obliczane na żądanie z dwóch nakładających się fal sinusoidalnych:
function boundsBase(wx) {
const p = Math.min(Math.sqrt(wx / 14000), 1); // wave frequency ramp, sqrt eased
const g = Math.min(Math.max(wx - 3000, 0) / 90000, 1); // corridor ramp, slow and linear
const wA1 = lerp(H * 0.07, H * 0.12, g); // slow wide arc
const wA2 = lerp(H * 0.035, H * 0.055, g); // faster shallow ripple
const wF1 = lerp(0.0025, 0.0048, p);
const wF2 = lerp(0.0060, 0.0115, p);
const hg = lerp(H * 0.34, H * 0.163, g); // half-gap: 68% -> 33% of the height
const raw = H / 2
+ wA1 * Math.sin(wx * wF1 + _wavePhase1)
+ wA2 * Math.sin(wx * wF2 + 1.57 + _wavePhase2);
const cy = clamp(raw, hg + WALL_PAD, H - hg - WALL_PAD);
return { top: cy - hg, bot: cy + hg };
}
To jest szkielet. Właściwa funkcja dodaje jeszcze kilka członów, takich jak opisana poniżej metamorfoza kształtu dla głębokich przelotów, ale żaden z nich nie przyjmuje niczego poza wx i dniem.
Dwie fale zamiast jednej, ponieważ pojedynczy sinus jest natychmiast czytelny jako rytm i przestaje być interesujący po około dziesięciu sekundach. Powolny szeroki łuk plus szybsza płytka fala przy niemal niewymiernym stosunku częstotliwości daje długie odcinki, które nigdy się dokładnie nie powtarzają. Półszczelina zwęża się z około 68% wysokości ekranu do 33% w miarę zagłębiania się i robi to powoli: nic w ogóle na pierwszym odcinku, który jest otwartą halą, potem prosta linia, która osiąga dno dopiero po około wyniku 1 500.
Nie zawsze tak to działało. Korytarz najpierw zwężał się po krzywej pierwiastka kwadratowego, która osiągała najwęższy punkt przy wyniku 233, co jest szybką rampą skoncentrowaną na początku, i przyszła informacja zwrotna, że gra jest o wiele za trudna. Więc korytarz i amplituda jego fali dostały własny powolny zegar, to g powyżej. Częstotliwości fal celowo pozostały na starej krzywej pierwiastkowej: sin(wx * f) przy dużym wx jest bardzo wrażliwy na f, więc zmiana tempa częstotliwości w środku przelotu miesza fazę wszystkiego, co już masz za sobą. Amplitudę i szerokość można bezpiecznie spowalniać, częstotliwości nie.
Ważna jest nie sama postać. Chodzi o to, że boundsBase(wx) przyjmuje tylko wx. Zapytaj ją o pozycję świata 40 000 w pierwszej klatce przelotu, a odpowie natychmiast, identycznie, na dowolnym urządzeniu, niczego po drodze nie generując.
Gdzie wchodzi dzień
Data wchodzi dokładnie przez dwie zmienne w tym fragmencie: _wavePhase1 i _wavePhase2. Data UTC staje się liczbą całkowitą, taką jak 20260915, która przechodzi przez łańcuch haszowania:
function seedDailyVariety(dayInt) {
let h = Math.imul(dayInt ^ 0x9e3779b9, 0x45d9f3b) >>> 0;
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
h = (h ^ (h >>> 16)) >>> 0;
_wavePhase1 = (h % 6283) / 1000; // 0 .. 2pi
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
_wavePhase2 = (h % 6283) / 1000;
_waveJitterA = 1 + (draw() * 2 - 1) * 0.08; // +/- 8% amplitude
_waveJitterF = 1 + (draw() * 2 - 1) * 0.08; // +/- 8% frequency
_dayArchetype = Math.floor(draw() * DAY_ARCHETYPES.length);
}
Kolejne daty różnią się o jeden, więc hasz musi mocno rozpraszać, inaczej dziś i jutro wyglądałyby jak bliscy sąsiedzi. Łańcuch multiply-xorshift to robi: 20260915 i 20260916 dają zupełnie niezwiązane fazy.
Wahania są celowo małe. Sama faza zmienia, gdzie wypadają zakręty, ale nie to, jak czuje się dzień, a ja chciałem, żeby każdy dzień miał trochę inny charakter, bez oddalania któregokolwiek dnia od bazy, której strojenie zajęło dużo czasu. Osiem procent wystarcza, by zauważyć w ciągu tygodnia, i nie wystarcza, by dzień wydawał się zły.
Cztery strumienie losowe, nie jeden
Przeszkody to osobny problem od kształtu korytarza. Stalaktyty, monety, miny i działa dostają każde własny niezależny generator, wszystkie wyprowadzone z tego samego dnia:
rngStal = makeRngStream(Math.imul(dayInt ^ 0x5741, 0x2545F491));
rngCoin = makeRngStream(Math.imul(dayInt ^ 0xC01D, 0x9E3779B1));
rngMine = makeRngStream(Math.imul(dayInt ^ 0x4D19, 0x85EBCA6B));
rngCannon = makeRngStream(Math.imul(dayInt ^ 0xCA77, 0xC2B2AE35));
Jeden wspólny strumień byłby prostszy i byłby błędem. Rozmieszczanie monet może odrzucić pozycję kandydata, na przykład gdy ląduje zbyt blisko stalaktytu. Na wspólnym strumieniu każde odrzucenie zużywa liczbę i przesuwa wszystko, co losowane potem, więc moneta, której rozmieszczenie się nie powiodło, po cichu przesunęłaby następną minę. Oddzielne sole czynią te cztery systemy naprawdę niezależnymi, zamiast przesuniętych w fazie kopii jednej sekwencji, i oznaczają, że mogę zmienić sposób rozmieszczania monet, nie ruszając ani jednej miny.
Archetypy dni
Faza i wahania zmieniają kształt dnia. Aby zmienić jego smak, ziarno wybiera także jeden z czterech archetypów, które skalują krzywe odstępów już obecne w grze:
| Archetyp | Stalaktyty | Monety | Miny | Zwężenia |
|---|---|---|---|---|
| Klasyczny | 1.00 | 1.00 | 1.00 | 1.00 |
| Dzień zwężeń | 0.85 | 1.00 | 1.00 | 1.35 |
| Rękawica min | 1.00 | 1.00 | 0.75 | 1.00 |
| Gorączka monet | 1.15 | 0.72 | 1.15 | 0.80 |
To są mnożniki odstępów, więc liczba poniżej jedności oznacza bliżej siebie. Reguła, którą ustaliłem dla tabeli, brzmiała: żaden wiersz nie może być jednolicie trudniejszy ani jednolicie łatwiejszy niż Klasyczny. Każdy archetyp pcha jedno pokrętło i cofa drugie. Gorączka monet daje ci znacznie więcej monet, które w TUNL poszerzają korytarz, ale płaci za to ciaśniejszymi stalaktytami i większą liczbą min. Dzień powinien czytać się jak inny dzień, a nie jak dobry lub zły rzut.
Ograniczenie pod tym wszystkim
Oto część, która okazała się ważniejsza niż sam generator.
Ponieważ jaskinia jest wspólna, a ranking globalny, wszystko, co decyduje o tym, gdzie obiekt zostaje umieszczony, musi być czystą funkcją pozycji świata i dnia. Nic innego nie może się wcisnąć. Nie rozmiar ekranu gracza, nie jego wynik, nie to, czy akurat ma aktywny bonus, nie to, jak długo gra.
Zapisane brzmi to oczywiście. W praktyce zakazuje długiej listy rzeczy, które wydają się całkowicie rozsądne podczas pisania, a naruszenia się nie ogłaszają. Nic się nie wywala. Gra wygląda dobrze. Jaskinia po prostu, po cichu, przestała być tą samą jaskinią.
Błąd pierwszy: sześć urządzeń, sześć jaskiń
Funkcje odstępów, które decydują, jak daleko od siebie stoją przeszkody, to krzywe trudności, więc naturalnie czytają bieżącą trudność. Oryginalne wersje czytały zmienną globalną _prog, która jest trudnością w bieżącej pozycji gracza.
Pętle spawnu wyglądają tak:
while (nextStalWx < scrollX + W + margin) { ... }
Umieszczają przeszkody nieco za prawą krawędzią ekranu. Więc moment, w którym dana pozycja świata ma losowany odstęp, zależy od W, szerokości ekranu. Szersze urządzenie sięga dalej do przodu, losuje tę pozycję wcześniej w przelocie i dlatego próbkuje krzywą trudności przy niższym _prog niż wąskie urządzenie dla dokładnie tego samego kawałka świata.
Inny odstęp. Inna jaskinia. Zmierzyłem to na sześciu rozmiarach urządzeń i dostałem sześć różnych jaskiń, rozbiegających się od pozycji świata 938, czyli mniej więcej wyniku 15. Wszystko po tym punkcie we wspólnym dziennym rankingu było porównywane między tunelami, które nie były tym samym tunelem.
Poprawką było przekazywanie umieszczanej pozycji do każdej krzywej, żeby odpowiadały na pytanie "jak trudno jest tam" zamiast "jak trudno jest tutaj, teraz":
function progAt(wx) { return Math.min(Math.sqrt(Math.max(wx, 0) / 14000), 1); }
function prog2At(wx) { return Math.max(wx - 14000, 0) / 40000; }
Był drugi, mniejszy przypadek tej samej klasy błędu, ukryty w ograniczeniu linii środkowej. WALL_PAD, margines trzymający korytarz z dala od krawędzi ekranu, wynosił stałe 8 pikseli, podczas gdy wszystko wokół skalowało się z wysokością ekranu. Na wyższym ekranie ten stały odstęp jest proporcjonalnie mniejszy, co lekko przesuwało punkt ograniczenia, co wystarczyło, by przewrócić jedno odrzucenie rozmieszczenia, co rozdzieliło jaskinię. Teraz jest to ułamek H, jak wszystko inne.
Oba błędy mają wspólny kształt, który warto nazwać: wartość, która jest prawie czystą funkcją pozycji świata, z jednym zanieczyszczeniem, które ma znaczenie tylko na sprzęcie, którego nie posiadasz. Żadnego nie dało się znaleźć grając. Oba są teraz zablokowane testami, które generują jaskinię przy kilku rozmiarach ekranu i twierdzą, że wyniki są identyczne co do bajta.
Błąd drugi: treść, której nikt nigdy nie widział
Druga porażka nie miała nic wspólnego z determinizmem i wszystko wspólnego z niesprawdzeniem moich założeń względem rzeczywistości.
Po przekroczeniu pewnej głębokości wszystkie pokrętła geometrii korytarza dobijają do swoich pułapów, bo dalsze ich pchanie czyni tunel naprawdę nieprzejezdnym. Poza tym punktem jedyną rzeczą, która wciąż się zmienia, jest prędkość przewijania, więc bardzo długi przelot był jedną zmienną, która powoli robiła się bardziej nerwowa na tle zamrożonego korytarza. Żeby to naprawić, zbudowałem zestaw funkcji dla głębokich przelotów: metamorfozę kształtu, która redystrybuuje amplitudy obu fal przez zasianą sekwencję charakterów (duże czyste zamachy, bijące siekanie, prawie prosto), puls prędkości, który sprawia, że tempo oddycha, szerokie komory, które przychodzą jako wytchnienie, głazy jako rzadkie efektowne sceny.
Włączyłem to wszystko na pozycji świata 54 000, co jest około wyniku 900. Ta liczba pochodziła z krzywej trudności, gdzie plateau matematycznie się zaczyna. Była właściwą liczbą dla geometrii i złą liczbą dla graczy.
Kiedy w końcu odtworzyłem prawdziwe dane rankingowe względem generatora, odpowiedź była dosadna: praktycznie nikt nawet nie zbliżał się do tego miejsca. Spędziłem tygodnie na budowaniu treści, których nie widział ani jeden gracz, łącznie ze mną.
Więc zbudowałem symulację red team: 2 400 przelotów bez interfejsu na czterech skalibrowanych poziomach umiejętności, mierzących, jak daleko każdy poziom faktycznie dociera. Poziom odpowiadający prawdziwym graczom dotarł do pozycji świata 30 000 w 0% z 600 przelotów. Nie "rzadko". Nigdy. Nawet po tym, jak już raz obniżyłem bramkę do 30 000, treść wciąż była nieosiągalna. To był ten sam błąd, o rząd wielkości mniejszy.
Bramka stoi teraz na 9 000, około wyniku 150, gdzie symulacja mówi, że prawdziwa część przelotów faktycznie dociera:
| Poziom umiejętności | Przeloty docierające do bramki |
|---|---|
| Średni | 3.3% |
| Dobry | 24.7% |
| Ekspert | 63.5% |
Można to było zrobić bezpiecznie tylko dlatego, że każda funkcja głębokich przelotów jest ograniczona względem tej samej pozycji bez metamorfozy. Podziały amplitudy metamorfozy są ograniczone względem prędkości korytarza, więc odcinek z metamorfozą nigdy nie niesie więcej niż około 3% więcej energii drgań niż odcinek bez niej, a komory tylko poszerzają korytarz. Nic z tego nie może uczynić danej pozycji trudniejszą niż ręcznie dostrojona baza, na żadnej głębokości, dlatego przesuwanie bramki jest decyzją o podaży, a nie zmianą trudności.
Jeden element celowo się nie ruszył. Miny z odchyleniem ku wierzchołkowi, które przyciągają się do linii środkowej na wierzchołku zakrętu, gdzie korytarz już cię do tego zmusza, niosą realne ryzyko odczytania jako oszustwo gry. Ta zostaje na starej głębokości, dopóki ktoś faktycznie nie przeleci przez nią na urządzeniu. Wyciąganie niesprawdzonego ryzyka sprawiedliwości 750 punktów wcześniej nie jest częścią poprawki tempa.
Co kupuje determinizm
Całe to ograniczenie płaci za trzy rzeczy, które inaczej byłyby o wiele trudniejsze.
Duch, który prawie nic nie kosztuje
Ściganie się z powtórką twojego najlepszego przelotu zwykle oznacza nagrywanie wejść lub stanu przeszkód. Tutaj jaskinia jest odtwarzalna z daty, więc powtórka nie potrzebuje niczego poza pionową pozycją statku w czasie. Jeden bajt na 60 pikseli świata, skwantowany względem wysokości ekranu, co czyni pełnego ducha kilkuset bajtami i pozwala poprawnie odtworzyć na tablecie ducha nagranego na telefonie.
Jest indeksowany przebytą odległością, a nie upływającym czasem, co ma znaczenie, ponieważ jeden z typów monet spowalnia przewijanie i przywraca je w ciągu kilku sekund. Duch indeksowany czasem rozsynchronizowałby się z tunelem w chwili, gdy ktoś by taką podniósł.
Karta do udostępniania, która jest obrazem przelotu
Ponieważ korytarz jest czystą funkcją pozycji, cały przebyty tunel można przerysować po fakcie, skompresowany do paska, z zaznaczonym punktem śmierci i rekordem osobistym. Karta jest obrazem przelotu, a nie odznaką wyniku, i jej wygenerowanie nie wymaga żadnego nagrania, tylko osiągniętej odległości.
Ranking, który coś znaczy
To, że wszyscy lecą przez identyczną jaskinię, jest całą podstawą porównywania wyników. To także powód, dla którego szerokość renderowania jest ograniczona zamiast wypełniać ekran: odstęp przeszkód jest stały w pozycji świata, więc znacznie szersze urządzenie widziałoby wspólną jaskinię przewijającą się proporcjonalnie szybciej i mierzyłoby się z tymi samymi przeszkodami z krótszym czasem reakcji. Pionowe odczucie jest normalizowane w ten sam sposób, z grawitacją i ciągiem skalowanymi wysokością ekranu względem wzorca, więc trajektoria pokrywa ten sam ułamek korytarza na sekundę na dowolnym urządzeniu.
Gdybym zaczynał od nowa
Generator był łatwą częścią. Dwie fale i hasz to jedno popołudnie. Wszystko, co było kosztowne, pochodziło z obietnicy owiniętej wokół niego, że jaskinia jest taka sama, co zamienia każdą przyszłą funkcję w pytanie, czy przecieka niedeterminizm do rozmieszczania.
Lekcja, którą bym zachował, to kształt testów. Błędy determinizmu są niewidoczne w grze i trywialne przy asercji. Wygenerowanie pierwszych kilku tysięcy pozycji przy sześciu rozmiarach ekranu i porównanie ich zajmuje milisekundy i wyłapałoby oba błędy urządzeń w dniu, w którym zostały napisane. Błąd bramki treści wymagał zupełnie innego narzędzia, symulacji tego, jak daleko gracze faktycznie docierają, a jedyną prawdziwą lekcją jest to, że liczba wyprowadzona z twojej krzywej trudności nie jest liczbą o twoich graczach.
Dzisiejsza jaskinia jest dostępna i resetuje się o północy UTC. Działa w przeglądarce, bez instalacji: