AFU · Console'da kumanda, klavye, fare ve dokunmatik için tek girdi modeli
Konsol arayüzündeki ekranlar hangi aygıtın kullanıldığını bilmiyor; onlara yalnızca "yukarı", "onayla", "geri" gibi eylemler ulaşıyor. Dört girdiyi bu eylemlere nasıl çevirdiğimi, kumandada basılı tutunca tekrarın nasıl zamanlandığını, odak ızgarasını, sesleri osilatörle nasıl ürettiğimi ve bütün arayüzü tek bir CSS birimiyle nasıl ölçeklediğimi kodla anlatıyorum.
AFU · Console, bir oyun konsolunun ana ekranı gibi davranan bir Next.js uygulaması. İçinde açılış animasyonu, "Kumandayı kim kullanıyor?" ekranı, IGDB'den gelen gerçek PS5 oyunları, demo bir mağaza, kupalar ve ayarlar var. "İşe alım" kullanıcısıyla girersen CV'm ve GitHub projelerim açılıyor.
Beni en çok girdi tarafı uğraştırdı. Konsol arayüzleri kumanda için tasarlanır ama siteye gelenlerin çoğunun elinde kumanda yok; klavye, fare ya da telefonda parmak var. Dördünün de aynı arayüzde doğal hissettirmesini istedim ve sonunda hepsini lib/input.tsx adında 250 satırlık bir dosyada topladım. Ekranlar hangi aygıtın kullanıldığını bilmiyor, onlara yalnızca bir eylem geliyor.
Projenin çalışma zamanı bağımlılıkları next, react ve react-dom'dan ibaret. Girdi, ses ve ölçek için kütüphane kullanmadım; aşağıda göreceğin her şeyi tarayıcının kendi API'leriyle yazdım.
Dört kaynak, on üç eylem
İlk iş eylem listesini sabitlemek oldu: dört yön, onay, geri, ana menü (PS tuşu), Options, Create, L1, R1, üçgen ve bir de any. Klavye tarafı düz bir tablodan ibaret. Oklarla birlikte WASD de çalışıyor, x ve o kumandadaki ✕ ile ○'nin yerini tutuyor. Tabloda olmayan bir tuş any oluyor, açılış ekranındaki "bir tuşa bas" da bunu dinliyor.
Ctrl, Alt ya da Cmd basılıysa hiç karışmıyorum, Tab ve F tuşlarını da dışarıda bıraktım, çünkü tarayıcının kendi kısayollarını yutmak istemedim. preventDefault da yalnızca tablodaki tuşlar için çağrılıyor. lib/input.tsx içindeki klavye kısmı şöyle:
const KEYS: Record<string, Action> = {
ArrowUp: "up",
// ...
w: "up",
s: "down",
a: "left",
d: "right",
Enter: "confirm",
" ": "confirm",
x: "confirm",
Escape: "back",
// ...
};
const onKey = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
// ...
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
const action = KEYS[key] ?? (e.key === "Tab" || e.key.startsWith("F") ? null : "any");
if (!action) return;
setModality("keys");
if (action !== "any") e.preventDefault();
dispatchRef.current(action);
};Bu kuralın tek istisnası arama ekranı. Oradaki katman yazı da istiyor, o yüzden harfler ve Backspace oraya eylem yerine karakter olarak gidiyor. Aynı arama kutusuna kumandayla ekran klavyesinden de, fiziksel klavyeden de yazabilirsin.
Kumandayı her karede yoklamak
Gamepad API tuşlar için olay göndermiyor; bağlanma ve kopma dışında hiçbir şey bildirmiyor, durumu senin okuman gerekiyor. Ben bunu bir requestAnimationFrame döngüsünde yapıyorum: her karede ilk bağlı kumandayı alıyor, tuşları ve sol çubuğu eylem adlarına çeviriyorum. Tuş numaraları tarayıcıların "standard" eşlemesindeki sırayı izliyor: 0 çarpı, 1 daire, 3 üçgen, 4 ve 5 omuz tuşları, 8 Create, 9 Options, 12-15 yön tuşları, 16 ortadaki PS tuşu.
Ayrı bir ölü bölge (dead zone) kodu yazmadım. Çubuk bir eksende 0,6'yı geçerse yön tuşuna basılmış sayıyorum; bu tek eşik, çubuk boştayken gelen küçük değerleri de yutuyor. Bir yönü basılı tutarsan ilk eylem hemen gidiyor, 380 ms sonra tekrar başlıyor ve her 110 ms'de bir yineleniyor. Tekrarı yalnızca yönlere verdim; onay tuşunu basılı tutan biri aynı oyunu art arda açmasın diye. lib/input.tsx içindeki döngü şöyle:
const poll = () => {
raf = requestAnimationFrame(poll);
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
const pad = Array.from(pads).find(Boolean);
if (!pad) return;
const b = (i: number) => !!pad.buttons[i]?.pressed;
const ay = pad.axes[1] ?? 0;
const state: Record<string, boolean> = {
up: b(12) || ay < -0.6,
// ...
confirm: b(0),
// ...
};
const now = performance.now();
for (const [name, pressed] of Object.entries(state)) {
// ...
if (since === undefined) {
held.set(name, now + 380);
dispatchRef.current(name as Action);
} else if (repeats && now >= since) {
held.set(name, now + 110);
dispatchRef.current(name as Action);
}
}
};held haritası her tuş için bir sonraki tetiklenme zamanını tutuyor, bu yüzden ayrı bir zamanlayıcıya gerek kalmadı.
PS tuşu ayrı bir dert çıkardı. Bazı kumandalar (README'de örnek olarak Xbox kumandalarını verdim) ortadaki tuşu tarayıcıya hiç iletmiyor ve o kumandayla kontrol merkezine ulaşmanın yolu kalmıyordu. Bunu katmanlarla çözdüm.
En üstteki katman dinler
Her ekran ve her açılır pencere useLayer ile bir katman kaydediyor. Eylem, etkin katmanlardan en son bağlanana gidiyor. Bu yüzden bir pencere açıldığında alttaki ekranı ayrıca susturmam gerekmiyor: pencere üstte duruyor ve tuşları o alıyor. Pencere kapanınca katmanı listeden çıkıyor, ekran da kaldığı yerden devam ediyor. lib/input.tsx içindeki ilgili parçalar:
const top = () => {
for (let i = layers.current.length - 1; i >= 0; i--) if (layers.current[i].enabled) return layers.current[i];
return null;
};
export function useLayer(fn: Handler, enabled = true, opts: { options?: boolean; text?: (t: TextInput) => void } = {}) {
const ctx = useContext(Ctx);
const fnRef = useRef<Handler>(fn);
fnRef.current = (a) => {
// The Create button works the same everywhere, so the console handles it rather than each screen.
if (a === "create") return void window.dispatchEvent(new Event("console:create"));
fn(a === "options" && !opts.options ? "home" : a);
};
// ...
useEffect(() => {
if (!ctx) return;
const layer: Layer = { id: nextId++, enabled, fn: fnRef, text: textRef };
layerRef.current = layer;
return ctx.register(layer);
// ...
}, [ctx]);
// ...
}PS tuşu derdini "home" yazan satır çözüyor. Kendi menüsü olan bir ekran (mesela ana ekrandaki oyunun "…" menüsü) options: true diyerek tuşu kendine alıyor. Geri kalan ekranlarda Options, PS tuşu gibi davranıp kontrol merkezini açıyor. Create ise her ekranda aynı işi yaptığı için ekranlara hiç uğramıyor; window üzerinden bir olay olarak doğrudan konsola gidiyor.
Odak ızgarası
Mağaza, oyun sayfası, kupalar ve kütüphane, satır satır dizilmiş öğelerden oluşuyor. Bunlar için lib/nav.ts dosyasına useGrid adında bir kanca yazdım. Izgaraya yalnızca satırlardaki öğe sayılarını veriyorum, [1, 4, 0, 6] gibi. Boş satırlar atlanıyor ve her satır en son hangi sütunda kalındığını hatırlıyor. Bir rafta beşinci oyundayken aşağı inip geri dönersen yine beşinci oyuna gelirsin. Hareketin tamamı lib/nav.ts içindeki move fonksiyonunda:
const move = useCallback(
(a: Action): boolean => {
const rs = rowsRef.current;
const p = posRef.current;
let next: Pos | null = null;
if (a === "left" || a === "right") {
const nc = p.c + (a === "left" ? -1 : 1);
if (nc >= 0 && nc < (rs[p.r] ?? 0)) next = { r: p.r, c: nc };
} else if (a === "up" || a === "down") {
const dir = a === "up" ? -1 : 1;
let nr = p.r + dir;
while (nr >= 0 && nr < rs.length && rs[nr] === 0) nr += dir;
if (nr >= 0 && nr < rs.length) next = { r: nr, c: Math.min(lastCol.current[nr] ?? 0, rs[nr] - 1) };
}
if (!next) return false;
lastCol.current[next.r] = next.c;
set(next);
sound.move();
return true;
},
[set],
);Gezinme sesi de buradan çıkıyor. Kenara dayandıysan ya da eylem bir yön değilse move false dönüyor; ses çıkmıyor ve eylem sayfanın kendi koduna kalıyor. Onay tuşu da sayfaya bu yoldan ulaşıyor.
Arama ekranındaki ekran klavyesi bu ızgaraya uymadı, çünkü orada satır uzunlukları ve tuş genişlikleri farklı. Aşağı ya da yukarı geçerken tuşun satırdaki orantılı yatay konumunu hesaplıyorum ve öbür satırda o konuma denk gelen tuşa atlıyorum.
Fare gelince odak el değiştiriyor
Klavye ve kumandada odak tek bir yerde duruyor, fare ise her yerde olabilir. İkisini barıştırmak için son kullanılan girdi türünü <html> etiketine data-input olarak yazıyorum; değer keys, pointer ya da touch oluyor. CSS de buna bakıp dokunmatikte tuş ipuçlarını gizliyor.
Fare modunda imleci hangi öğenin üzerine getirirsen odak ona geçiyor. Klavye için yazdığım "odaklanan öğeyi görünür alana kaydır" kodu fareyle de çalışsaydı sayfa kendi kendine kayıp giderdi: kaydırma imlecin altına başka bir öğe getirir, odak ona geçer, sayfa yine kayar. Bu yüzden takibi yalnızca klavye ve kumandaya bıraktım ve sebebini lib/nav.ts içine yazdım:
export function useScrollToFocus(container: RefObject<HTMLElement | null>, pos: Pos) {
useEffect(() => {
// ...
// Only keys and pads follow the focus. A mouse focuses on hover, and scrolling the hovered item away would put
// another one under the cursor, which would scroll again: the page would run off by itself.
const m = document.documentElement.dataset.input;
if (m === "pointer" || m === "touch") return;
// ...
}, [container, pos.r, pos.c]);
}
export function navProps(pos: Pos, r: number, c: number, focus: (r: number, c: number) => void, onActivate: () => void) {
// ...
return {
// ...
onMouseEnter: () => {
if (document.documentElement.dataset.input === "pointer") focus(r, c);
},
// ...
};
}Dokunmatik taraf useSwipe'ta. 32 pikselden kısa hareketleri yok sayıyor, mesafeyi adım sayısına çeviriyor ve 220 ms'den kısa süren bir fiskeye bir adım daha ekliyor. Yani uzun ve hızlı kaydırırsan birkaç kutucuğu birden atlarsın. Ana ekranda fare tekerleğini de kullanabilirsin: dikey kaydırınca kutucuk satırıyla aşağıdaki kartlar arasında geçiyorsun, yatay kaydırınca (ya da Shift basılıyken tekerleği çevirince) kutucuklarda geziyorsun. Bir geçişten sonra yatayda 140 ms, dikeyde 450 ms boyunca yeni tekerlek olaylarını dinlemiyorum.
Ses dosyası yok, osilatör var
Depoda tek bir ses dosyası yok. Gezinme tıkını, seçim, geri, açılış ve kupa seslerini ve arka plandaki müziği lib/sound.ts içinde Web Audio ile üretiyorum. Tarayıcılar kullanıcı bir şeye dokunmadan AudioContext başlatmaya izin vermiyor, o yüzden bağlamı ilk dokunuşta kuruyorum. Açılış ekranında bastığın ilk tuş hem sesi açıyor hem de açılış akorunu çalıyor.
Her efekt küçük bir ses listesinden oluşuyor. Listedeki her sesin bir dalga tipi, frekansı, isteğe bağlı bir kayması, gecikmesi, süresi ve seviyesi var. Gezinme sesi 70 ms içinde 1320 Hz'den 1760 Hz'e kayan bir sinüs, altında da çok kısık bir üçgen dalga. lib/sound.ts içinde:
private play(voices: Voice[], opts: { wet?: number; force?: boolean } = {}) {
// ...
for (const v of voices) {
const start = now + (v.at ?? 0);
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = v.type;
osc.frequency.setValueAtTime(v.freq, start);
if (v.to) osc.frequency.exponentialRampToValueAtTime(v.to, start + (v.glide ?? v.dur * 0.5));
g.gain.setValueAtTime(0.0001, start);
g.gain.exponentialRampToValueAtTime(v.gain, start + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, start + v.dur);
// ...
}
}
move() {
this.play(
[
{ type: "sine", freq: 1320, to: 1760, dur: 0.07, gain: 0.05, glide: 0.03 },
{ type: "triangle", freq: 2640, dur: 0.04, gain: 0.012 },
],
{ wet: 0.25 },
);
}Zarf 0,0001'den başlıyor, çünkü üstel rampa sıfırdan başlayamıyor ve sıfıra inemiyor. Yankı için de dosya kullanmadım. ConvolverNode normalde bir odanın kaydedilmiş yanıtını ister; ben 2,6 saniyelik stereo bir gürültü tamponu üretip (1 - i/len)^3.2 ile söndürüyorum, sonuç da küçük bir oda gibi duyuluyor.
Müzik varsayılan olarak kapalı. Açarsan yavaş bir akor çalıyor, filtresi bir LFO ile gidip geliyor ve üstüne parçaya göre 2,6 ile 4,7 saniyede bir zil düşüyor. Ana ekranda bir oyunun kutucuğunda durursan o oyuna özel bir tema başlıyor. Temayı oyunun id'sinin FNV-1a hash'i seçiyor: on kök nota, beş akor şekli. Açıkçası projede en sevdiğim ayrıntılardan biri bu, çünkü her oyun her açılışta aynı tınlıyor ve hiçbir oyun için elle akor seçmem gerekmedi. Kutucuklar arasında hızlı gezdiğinde tema her adımda baştan başlamasın diye değişimi 420 ms geciktiriyorum.
Tek birim, --u
Bir TV arayüzü, ekran büyüdükçe olduğu gibi büyür. Ben bunu CSS'te tek bir değişkenle kurdum. app/globals.css dosyasının başı:
:root {
--zoom: 1;
--u: calc(min(1vw, 1.78vh) * var(--zoom));
/* ... */
--tile: calc(var(--u) * 6.4);
--tile-sel: calc(var(--u) * 9.6);
--gap: calc(var(--u) * 1.1);
--pad-x: calc(var(--u) * 6);
--radius: calc(var(--u) * 0.9);
}
/* ... */
:root[data-text="large"] {
--zoom: 1.12;
}
/* ... */
@media (max-aspect-ratio: 1/1), (max-width: 760px) {
:root {
--u: calc(min(2.35vw, 1.25vh) * var(--zoom));
--tile: calc(var(--u) * 8.4);
/* ... */
--pad-x: 20px;
}
}1,78 yaklaşık 16/9 demek. min(1vw, 1.78vh) 16:9 bir pencerede iki taraftan da aynı değeri veriyor. Pencere daha genişse yükseklik, daha darsa genişlik belirleyici oluyor; arayüz de her zaman pencerenin içine sığan 16:9'luk bir kutuda kalıyor. Kutucuklar, boşluklar ve yazı boyutları bu birimin katları. Ayarlardaki "Büyük yazı" yalnızca --zoom'u 1,12 yapıyor ve bütün arayüz birlikte büyüyor.
Telefonda bu formül işe yaramıyor, çünkü dikey ekranda 16:9'luk kutu ince bir şeride dönüşüyor. En-boy oranı 1'in altına düşünce ya da genişlik 760 pikselin altına inince birim başka bir formülle hesaplanıyor ve yerleşim değişiyor. Bir de her şeyin bu tek birimle ölçüldüğünü söyleyemem. Dosyada var(--u) 550 kez geçiyor ama 474 satırda hâlâ piksel değeri var; kenarlıklar, gölgeler, bulanıklıklar ve telefon düzeninin bir kısmı pikselle yazılı.
Eksikler
Girdi tarafında bildiğim birkaç açık var. Yalnızca ilk bağlı kumandayı okuyorum, ikinci bir kumanda hiçbir şey yapmıyor. Eşik eksen başına uygulandığı için çubuğu tam çapraz itersen iki eksen de 0,6'yı geçebiliyor ve iki yön aynı anda tetikleniyor. Tuş numaralarının standart eşlemeye uyduğunu varsayıyorum, pad.mapping'e hiç bakmıyorum. Bazı tuşların da gezinmede karşılığı yok: Ayarlar'daki canlı kumanda şemasında yanıyorlar ama bir eyleme bağlı değiller. components/screens/Settings.tsx içindeki tabloda bu görülüyor:
const padButtons: { id: string; label: string; action?: Action; index: number }[] = [
{ id: "cross", label: "✕", action: "confirm", index: 0 },
{ id: "circle", label: "○", action: "back", index: 1 },
{ id: "square", label: "□", index: 2 },
// ...
{ id: "l2", label: "L2", index: 6 },
{ id: "r2", label: "R2", index: 7 },
// ...
{ id: "l3", label: "", index: 10 },
{ id: "r3", label: "", index: 11 },
// ...
];Sağ çubuğu da hiç kullanmıyorum. Klavyede tuş tekrarını işletim sistemi belirliyor, kumandada ise 380/110 ms geçerli; bu yüzden basılı tutunca ikisi farklı hızda geziyor. Yoklama döngüsü kumanda bağlı değilken de her karede dönüyor. Sırada iki küçük iş var: döngüyü gamepadconnected olayından sonra başlatmak ve çaprazı çubuğun açısına bakarak ayırmak. Depoda test de yok, tek otomatik denetim npm run typecheck.
- Proje sayfası: /projects/afu-console
- Kaynak kod: github.com/MihrimatriX/ps5-showcase
- Aynı serideki diğer arayüzler: Tarayıcıda işletim sistemleri
