Mobil Kontrolcü Geliştirme
RemoteNex'te her oyun için iki ayrı HTML kontrolcü dosyası hazırlamanız gerekir: master.html ve normal.html. Bu dosyalar, oyuncuların telefonlarında RemoteNex mobil uygulaması içindeki WebView'da çalışır.
| Dosya | Kime Gösterilir | Farkı |
|---|---|---|
master.html | Odayı kuran ilk oyuncu | Oyunu başlatma butonu içerir |
normal.html | Odaya sonradan katılanlar | Sadece kontrol butonlarını içerir |
HTML5 + Vanilla JavaScript. Harici framework gerekmez.
Mesaj Akışı
[HTML — postMessage("MOVE:CMD:START_GAME")]
↓
[Mobil Uygulama — "MOVE:" prefix'ini kaldırır]
↓
[Sunucu — PlayerId ekleyip iletir]
↓
[Unity — "PlayerId:MOVE:CMD:START_GAME"]
[Unity — RemoteNex.SendData("STATE:GAME_STARTED")]
↓
[Sunucu — odadaki herkese iletir]
↓
[Mobil Uygulama — WebView'a postMessage ile iletir]
↓
[HTML — handleServerMessage("STATE:GAME_STARTED")]
MOVE: Kuralı
HTML'den gönderilen tüm mesajlar MOVE: önekiyle başlamalıdır. Mobil uygulama yalnızca MOVE: ile başlayan mesajları sunucuya iletir; diğerleri yok sayılır.
// ✅ DOĞRU — sunucuya ulaşır
window.ReactNativeWebView.postMessage("MOVE:CMD:START_GAME");
window.ReactNativeWebView.postMessage("MOVE:UP:PRESS");
// ❌ YANLIŞ — sunucuya ulaşmaz
window.ReactNativeWebView.postMessage("CMD:START_GAME");
window.ReactNativeWebView.postMessage("UP:PRESS");
🔒 1. Temel Fonksiyonlar
Bu üç fonksiyon her kontrolcüde değişmeden bulunmalıdır.
Mesaj Gönderme
function sendInstant(msg) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(msg);
} else {
console.log("📤", msg); // Simülatörde test için
}
}
Sunucu Mesajlarını Dinleme
Sunucudan (Unity'den) gelen tüm durum değişiklikleri bu fonksiyona düşer.
window.handleServerMessage = function(rawData) {
if (!rawData) return;
// STATE: etiketinin başlangıcını bul
let msg = rawData.includes("STATE:")
? rawData.substring(rawData.indexOf("STATE:"))
: rawData;
let parts = msg.split(':');
let action = parts[1];
if (action === "LOBBY") showScreen('lobby-screen');
else if (action === "GAME_STARTED") showScreen('game-area');
else if (action === "GAME_OVER") showScreen('result-screen');
// 🎮 OYUNA ÖZEL: Ek durumlara buradan tepki verin
// else if (action === "SCORE") { updateScore(parts[2], parts[3]); }
// else if (action === "BUZZ_WINNER") { highlightWinner(parts[2]); }
};
// Her iki listener da olmalıdır — ikisi de gereklidir
document.addEventListener("message", function(e) { window.handleServerMessage(e.data); });
window.addEventListener("message", function(e) { window.handleServerMessage(e.data); });
Ekran Geçişi
function showScreen(id) {
['lobby-screen', 'game-area', 'result-screen'].forEach(function(sid) {
var el = document.getElementById(sid);
el.classList.remove('visible');
el.style.display = 'none';
});
var target = document.getElementById(id);
target.classList.add('visible');
target.style.display = 'flex';
}
🔒 2. InstantInput — Çift Basma Koruması
Telefon ekranlarında aynı butona birden fazla dokunma olayı tetiklenebilir. InstantInput nesnesi her butonu tek seferlik işler ve bırakma anını güvenilir biçimde takip eder.
const InstantInput = {
activeKeys: new Set(),
press(key) {
if (!this.activeKeys.has(key)) {
this.activeKeys.add(key);
return true; // İlk basma — işle
}
return false; // Zaten basılı — yoksay
},
release(key) {
if (this.activeKeys.has(key)) {
this.activeKeys.delete(key);
return true; // Bırakma — işle
}
return false; // Zaten bırakılmış — yoksay
},
reset() {
this.activeKeys.clear();
}
};
🔒 3. Buton Event Sistemi
Yön / Aksiyon Butonları (PRESS / RELEASE)
Sürekli basılı tutulabilen butonlar için:
function attachControlButton(btn, direction) {
btn.addEventListener('touchstart', function(e) {
e.preventDefault();
if (InstantInput.press(direction)) {
btn.classList.add('pressed');
triggerHaptic();
sendInstant('MOVE:' + direction + ':PRESS');
}
}, { passive: false });
btn.addEventListener('touchend', function(e) {
e.preventDefault();
if (InstantInput.release(direction)) {
btn.classList.remove('pressed');
sendInstant('MOVE:' + direction + ':RELEASE');
}
}, { passive: false });
btn.addEventListener('touchcancel', function(e) {
e.preventDefault();
if (InstantInput.release(direction)) {
btn.classList.remove('pressed');
sendInstant('MOVE:' + direction + ':RELEASE');
}
}, { passive: false });
// Masaüstü / simülatör desteği
btn.addEventListener('mousedown', function(e) {
e.preventDefault();
if (InstantInput.press(direction)) {
btn.classList.add('pressed');
sendInstant('MOVE:' + direction + ':PRESS');
}
});
btn.addEventListener('mouseup', function(e) {
e.preventDefault();
if (InstantInput.release(direction)) {
btn.classList.remove('pressed');
sendInstant('MOVE:' + direction + ':RELEASE');
}
});
btn.addEventListener('mouseleave', function(e) {
if (InstantInput.release(direction)) {
btn.classList.remove('pressed');
sendInstant('MOVE:' + direction + ':RELEASE');
}
});
}
Komut Butonları (Tek Seferlik)
Oyun başlatma, yeniden başlatma gibi bir kez tetiklenen butonlar için:
function attachCommandButton(btn, command) {
var fired = false;
btn.addEventListener('touchstart', function(e) {
e.preventDefault();
if (!fired) {
fired = true;
btn.classList.add('pressed');
triggerHaptic();
sendInstant(command);
}
}, { passive: false });
btn.addEventListener('touchend', function(e) {
e.preventDefault();
btn.classList.remove('pressed');
fired = false;
}, { passive: false });
// Masaüstü / simülatör desteği
btn.addEventListener('mousedown', function(e) {
if (!fired) {
fired = true;
btn.classList.add('pressed');
sendInstant(command);
}
});
btn.addEventListener('mouseup', function() {
btn.classList.remove('pressed');
fired = false;
});
}
🔒 4. Input Sıkışma Koruması
Kullanıcı uygulamayı arka plana aldığında veya tarayıcı sekmesi kapandığında basılı kalan tuşlar sıkışabilir. Bunu önlemek için:
// Uygulama arka plana alındığında
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
// Tüm basılı tuşlar için RELEASE gönder
['UP', 'DOWN', 'LEFT', 'RIGHT'].forEach(function(dir) {
if (InstantInput.release(dir)) {
sendInstant('MOVE:' + dir + ':RELEASE');
}
});
// Buton görselini sıfırla
document.querySelectorAll('.control-btn').forEach(function(btn) {
btn.classList.remove('pressed');
});
}
});
// Sayfa kapatılmadan önce
window.addEventListener('beforeunload', function() {
['UP', 'DOWN', 'LEFT', 'RIGHT'].forEach(function(dir) {
if (InstantInput.release(dir)) {
sendInstant('MOVE:' + dir + ':RELEASE');
}
});
});
🔒 5. Başlangıç Sinyalleri (window.onload)
Her kontrolcü yüklendiğinde mobil uygulamaya iki sinyal göndermelidir:
window.onload = function() {
SmartTranslate.init(); // Çeviri sistemi (aşağıda anlatılıyor)
if (window.ReactNativeWebView) {
// Ekran yönünü bildir
window.ReactNativeWebView.postMessage("CMD:ORIENT:PORTRAIT");
// veya yatay oyunlar için:
// window.ReactNativeWebView.postMessage("CMD:ORIENT:LANDSCAPE");
// 500ms sonra "Hazırım" sinyali gönder
setTimeout(function() {
window.ReactNativeWebView.postMessage("CMD:READY");
}, 500);
}
};
| Sinyal | Açıklama |
|---|---|
CMD:ORIENT:PORTRAIT | Telefonu dik konuma kilitler |
CMD:ORIENT:LANDSCAPE | Telefonu yatay konuma kilitler |
CMD:READY | Uygulamaya "HTML yüklendi, bağlanabilirsin" der. 500ms gecikme zorunludur. |
🌍 6. SmartTranslate — Otomatik Çeviri
Oyununuzu dünya genelindeki oyunculara ulaştırmak için SmartTranslate sistemi, metinleri cihaz diline göre otomatik olarak çevirir.
Kullanım
Çevrilmesini istediğiniz HTML elementlerine t-txt sınıfını ekleyin:
<button class="master-btn t-txt">OYUNU BAŞLAT</button>
<div class="info-text t-txt">OYUN BİTTİ</div>
JavaScript
const SmartTranslate = {
map: {},
init: function() {
var userLang = navigator.language || 'en';
// Cihaz dili Türkçeyse çeviri gerekmez
if (userLang.toLowerCase().startsWith('tr')) {
this.hideOverlay();
return;
}
// Çevrilecek metinleri topla
var texts = [];
document.querySelectorAll('.t-txt').forEach(function(el) {
var txt = el.innerText.trim();
if (txt && !texts.includes(txt)) texts.push(txt);
});
// Runtime'da gösterilecek dinamik metinleri ekle
var dynamicTerms = ["KAZANDIN", "KAYBETTİN", "OYUN BİTTİ", "MASTER BEKLENİYOR..."];
dynamicTerms.forEach(function(t) {
if (!texts.includes(t)) texts.push(t);
});
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage("REQ_TRANS:" + JSON.stringify(texts));
setTimeout(function() { SmartTranslate.hideOverlay(); }, 5000); // Fallback
} else {
this.hideOverlay();
}
},
apply: function(dictionary) {
this.map = dictionary;
document.querySelectorAll('.t-txt').forEach(function(el) {
if (!el.getAttribute('data-org'))
el.setAttribute('data-org', el.innerText.trim());
var key = el.getAttribute('data-org');
if (SmartTranslate.map[key])
el.innerText = SmartTranslate.map[key];
});
this.hideOverlay();
},
hideOverlay: function() {
var overlay = document.getElementById('translation-overlay');
if (overlay && overlay.style.display !== 'none') {
overlay.style.opacity = '0';
setTimeout(function() { overlay.style.display = 'none'; }, 500);
}
},
get: function(text) {
return this.map[text] || text;
}
};
// React Native bu fonksiyonu çağırarak çeviriler uygular
window.applyTranslations = function(jsonString) {
try {
var dict = JSON.parse(jsonString);
SmartTranslate.apply(dict);
} catch(e) {
SmartTranslate.hideOverlay();
}
};
Yükleme Ekranı (Translation Overlay)
Çeviri gelene kadar kullanıcının boş bir ekran görmesini engeller:
<div id="translation-overlay">
<div class="spinner"></div>
</div>
#translation-overlay {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: #000;
z-index: 9999999;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.5s;
}
.spinner {
border: 4px solid rgba(255,255,255,0.3);
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px; height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin { 100% { transform: rotate(360deg); } }
📐 7. Mobil Optimizasyonlar
Bu CSS ve JS blokları her kontrolcüde bulunmalıdır:
* {
box-sizing: border-box;
touch-action: none; /* Kaydırma ve zoom'u engelle */
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
body {
margin: 0;
height: 100vh;
overflow: hidden;
background-color: #000;
color: #fff;
}
<!-- Viewport — zoom ve kaydırmayı kilitle -->
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
// Sağ tık / uzun basma menüsünü engelle
window.oncontextmenu = function(e) {
e.preventDefault();
e.stopPropagation();
return false;
};
// Titreşim geri bildirimi
function triggerHaptic() {
if (navigator.vibrate) navigator.vibrate(30);
}
🎯 Tam Şablon
master.html
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8">
<title>Oyun Adı — Master</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0,
maximum-scale=1.0, user-scalable=no">
<style>
* { box-sizing: border-box; touch-action: none; user-select: none;
-webkit-tap-highlight-color: transparent; }
body { margin: 0; height: 100vh; overflow: hidden;
background: #000; color: #fff; }
.overlay-screen { position: absolute; top: 0; left: 0;
width: 100%; height: 100%; display: none;
flex-direction: column; justify-content: center;
align-items: center; background: #000; z-index: 100; }
.visible { display: flex !important; }
#game-area { display: none; width: 100%; height: 100%;
flex-direction: column; }
.control-btn { flex: 1; width: 100%; display: flex;
align-items: center; justify-content: center;
font-size: 15vmin; background: #000; color: #fff;
border: none; cursor: pointer; }
.control-btn.pressed { background: #444; }
.start-btn { background: #000; border: 3px solid #fff; color: #fff;
padding: 20px 0; width: 80%; font-size: 7vmin; cursor: pointer; }
#translation-overlay { position: fixed; top: 0; left: 0;
width: 100%; height: 100%; background: #000;
z-index: 9999999; display: flex;
align-items: center; justify-content: center; transition: opacity 0.5s; }
.spinner { border: 4px solid rgba(255,255,255,0.3);
border-top: 4px solid #3498db; border-radius: 50%;
width: 40px; height: 40px; animation: spin 1s linear infinite; }
@keyframes spin { 100% { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="translation-overlay"><div class="spinner"></div></div>
<!-- Lobi: Sadece master oyunu başlatabilir -->
<div id="lobby-screen" class="overlay-screen visible">
<button id="btn-start" class="start-btn t-txt">OYUNU BAŞLAT</button>
</div>
<!-- Oyun: Kontrol butonları -->
<div id="game-area">
<div id="btn-up" class="control-btn" data-dir="UP">▲</div>
<div id="btn-down" class="control-btn" data-dir="DOWN">▼</div>
</div>
<!-- Sonuç -->
<div id="result-screen" class="overlay-screen">
<div class="t-txt" style="font-size: 8vmin;">OYUN BİTTİ</div>
<button id="btn-restart" class="start-btn t-txt">TEKRAR OYNA</button>
</div>
<script>
// ── InstantInput ───────────────────────────────────────────
const InstantInput = {
activeKeys: new Set(),
press(key) { if (!this.activeKeys.has(key)) { this.activeKeys.add(key); return true; } return false; },
release(key) { if (this.activeKeys.has(key)) { this.activeKeys.delete(key); return true; } return false; },
reset() { this.activeKeys.clear(); }
};
// ── Temel Fonksiyonlar ─────────────────────────────────────
function sendInstant(msg) {
if (window.ReactNativeWebView) window.ReactNativeWebView.postMessage(msg);
else console.log("📤", msg);
}
function triggerHaptic() { if (navigator.vibrate) navigator.vibrate(30); }
function showScreen(id) {
['lobby-screen', 'game-area', 'result-screen'].forEach(function(sid) {
var el = document.getElementById(sid);
el.classList.remove('visible');
el.style.display = 'none';
});
var target = document.getElementById(id);
target.classList.add('visible');
target.style.display = 'flex';
}
// ── Sunucu Mesajları ───────────────────────────────────────
window.handleServerMessage = function(rawData) {
if (!rawData) return;
var msg = rawData.includes("STATE:") ? rawData.substring(rawData.indexOf("STATE:")) : rawData;
var parts = msg.split(':');
var action = parts[1];
if (action === "LOBBY") showScreen('lobby-screen');
else if (action === "GAME_STARTED") showScreen('game-area');
else if (action === "GAME_OVER") showScreen('result-screen');
};
document.addEventListener("message", function(e) { window.handleServerMessage(e.data); });
window.addEventListener("message", function(e) { window.handleServerMessage(e.data); });
// ── SmartTranslate (kısaltılmış — tam versiyonu yukarıda) ──
const SmartTranslate = { /* ... tam kod buraya */ };
window.applyTranslations = function(json) {
try { SmartTranslate.apply(JSON.parse(json)); } catch(e) { SmartTranslate.hideOverlay(); }
};
// ── Komut Butonları ────────────────────────────────────────
function attachCommandButton(btn, command) {
var fired = false;
btn.addEventListener('touchstart', function(e) {
e.preventDefault();
if (!fired) { fired = true; btn.classList.add('pressed'); triggerHaptic(); sendInstant(command); }
}, { passive: false });
btn.addEventListener('touchend', function(e) {
e.preventDefault(); btn.classList.remove('pressed'); fired = false;
}, { passive: false });
btn.addEventListener('mousedown', function() {
if (!fired) { fired = true; btn.classList.add('pressed'); sendInstant(command); }
});
btn.addEventListener('mouseup', function() { btn.classList.remove('pressed'); fired = false; });
}
// ── Kontrol Butonları ──────────────────────────────────────
function attachControlButton(btn, direction) {
btn.addEventListener('touchstart', function(e) {
e.preventDefault();
if (InstantInput.press(direction)) { btn.classList.add('pressed'); triggerHaptic(); sendInstant('MOVE:' + direction + ':PRESS'); }
}, { passive: false });
btn.addEventListener('touchend', function(e) {
e.preventDefault();
if (InstantInput.release(direction)) { btn.classList.remove('pressed'); sendInstant('MOVE:' + direction + ':RELEASE'); }
}, { passive: false });
btn.addEventListener('touchcancel', function(e) {
e.preventDefault();
if (InstantInput.release(direction)) { btn.classList.remove('pressed'); sendInstant('MOVE:' + direction + ':RELEASE'); }
}, { passive: false });
btn.addEventListener('mousedown', function(e) {
e.preventDefault();
if (InstantInput.press(direction)) { btn.classList.add('pressed'); sendInstant('MOVE:' + direction + ':PRESS'); }
});
btn.addEventListener('mouseup', function(e) {
e.preventDefault();
if (InstantInput.release(direction)) { btn.classList.remove('pressed'); sendInstant('MOVE:' + direction + ':RELEASE'); }
});
btn.addEventListener('mouseleave', function(e) {
if (InstantInput.release(direction)) { btn.classList.remove('pressed'); sendInstant('MOVE:' + direction + ':RELEASE'); }
});
}
// ── Input Sıkışma Koruması ─────────────────────────────────
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
['UP', 'DOWN'].forEach(function(dir) {
if (InstantInput.release(dir)) sendInstant('MOVE:' + dir + ':RELEASE');
});
document.querySelectorAll('.control-btn').forEach(function(b) { b.classList.remove('pressed'); });
}
});
window.addEventListener('beforeunload', function() {
['UP', 'DOWN'].forEach(function(dir) {
if (InstantInput.release(dir)) sendInstant('MOVE:' + dir + ':RELEASE');
});
});
// ── Bağlantı ──────────────────────────────────────────────
attachCommandButton(document.getElementById('btn-start'), 'MOVE:CMD:START_GAME');
attachCommandButton(document.getElementById('btn-restart'), 'MOVE:CMD:RESTART_GAME');
attachControlButton(document.getElementById('btn-up'), 'UP');
attachControlButton(document.getElementById('btn-down'), 'DOWN');
window.oncontextmenu = function(e) { e.preventDefault(); return false; };
window.onload = function() {
SmartTranslate.init();
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage("CMD:ORIENT:PORTRAIT");
setTimeout(function() { window.ReactNativeWebView.postMessage("CMD:READY"); }, 500);
}
};
</script>
</body>
</html>
normal.html
normal.html, master.html'in birebir aynısıdır. Tek farkı lobi ekranında oyun başlatma butonu yoktur:
<!-- master.html'deki lobby-screen yerine: -->
<div id="lobby-screen" class="overlay-screen visible">
<div class="t-txt" style="font-size: 6vmin; color: #aaa; text-align: center;">
MASTER BEKLENİYOR...
</div>
</div>
Ayrıca attachCommandButton(btn-start, ...) satırını ve btn-start elementini kaldırın.
📋 Kontrol Listesi
Her İki Dosyada Bulunması Zorunlular
-
sendInstant()fonksiyonu —MOVE:prefix'ini içeren mesaj gönderir -
window.handleServerMessage()fonksiyonu tanımlı -
document.addEventListener("message", ...)vewindow.addEventListener("message", ...)her ikisi de var -
showScreen()ile lobi / oyun / sonuç ekranları yönetiliyor -
InstantInputnesnesi mevcut - Kontrol butonlarında
touchcanceldinleniyor -
visibilitychangeile input sıkışma koruması var -
window.onloadiçindeCMD:ORIENT:PORTRAIT(veya LANDSCAPE) veCMD:READYgönderiliyor - CSS'te
touch-action: noneveuser-select: nonevar - Viewport meta etiketi
maximum-scale=1.0, user-scalable=noiçeriyor
master.html'ye Özgü
- Lobi ekranında oyun başlatma butonu var
-
MOVE:CMD:START_GAMEveMOVE:CMD:RESTART_GAMEgönderiliyor
normal.html'ye Özgü
- Lobi ekranında "master bekleniyor" metni var
- Oyun başlatma butonu yok
🐛 Sık Yapılan Hatalar
1. MOVE: prefix'ini unutmak
// ❌ YANLIŞ — sunucuya ulaşmaz
sendInstant("CMD:START_GAME");
sendInstant("UP:PRESS");
// ✅ DOĞRU
sendInstant("MOVE:CMD:START_GAME");
sendInstant("MOVE:UP:PRESS");
2. touchcancel'ı eklememek
// ❌ YANLIŞ — buton sıkışır (telefon bildirimi gelince vs.)
btn.addEventListener('touchstart', ...);
btn.addEventListener('touchend', ...);
// ✅ DOĞRU
btn.addEventListener('touchstart', ...);
btn.addEventListener('touchend', ...);
btn.addEventListener('touchcancel', ...); // Bu satır kritik
3. Yalnızca bir message listener eklemek
// ❌ YANLIŞ — bazı cihazlarda mesajlar gelmez
document.addEventListener("message", handler);
// ✅ DOĞRU — her ikisi de gerekli
document.addEventListener("message", function(e) { window.handleServerMessage(e.data); });
window.addEventListener("message", function(e) { window.handleServerMessage(e.data); });
4. CMD:READY'yi gecikmesiz göndermek
// ❌ YANLIŞ — WebView henüz hazır değilken sinyal gider
window.ReactNativeWebView.postMessage("CMD:READY");
// ✅ DOĞRU — 500ms bekle
setTimeout(function() {
window.ReactNativeWebView.postMessage("CMD:READY");
}, 500);