Ana içeriğe geç

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.

DosyaKime GösterilirFarkı
master.htmlOdayı kuran ilk oyuncuOyunu başlatma butonu içerir
normal.htmlOdaya sonradan katılanlarSadece kontrol butonlarını içerir
Teknoloji

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);
}
};
SinyalAçıklama
CMD:ORIENT:PORTRAITTelefonu dik konuma kilitler
CMD:ORIENT:LANDSCAPETelefonu yatay konuma kilitler
CMD:READYUygulamaya "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", ...) ve window.addEventListener("message", ...) her ikisi de var
  • showScreen() ile lobi / oyun / sonuç ekranları yönetiliyor
  • InstantInput nesnesi mevcut
  • Kontrol butonlarında touchcancel dinleniyor
  • visibilitychange ile input sıkışma koruması var
  • window.onload içinde CMD:ORIENT:PORTRAIT (veya LANDSCAPE) ve CMD:READY gönderiliyor
  • CSS'te touch-action: none ve user-select: none var
  • Viewport meta etiketi maximum-scale=1.0, user-scalable=no içeriyor

master.html'ye Özgü

  • Lobi ekranında oyun başlatma butonu var
  • MOVE:CMD:START_GAME ve MOVE:CMD:RESTART_GAME gö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);