じゃんけん
ニックネームメニューからほかの人にじゃんけんを申し込み、双方が送った値を確認して結果を知らせるサンプルです。
最終更新 2026年10月17日
ニックネームメニューから相手にじゃんけんを申し込み、結果をチャットルームの全員の画面に表示します。挑戦ごとに固有のIDと有効期限を持たせ、結果はそれぞれの画面で、2人が自分で送った値から計算します。
流れ
- 申し込む人が相手のニックネームメニューでじゃんけんをクリックし、出す手を選びます。出した手はすぐには公開せず、ハッシュ(SHA-256)だけを全員に送ります(
start)。 - 相手が受けて手を選ぶと、全員に送ります(
play)。断った場合は申し込んだ人にだけ知らせます(decline、sendTo)。 - 申し込んだ人の画面が相手の値を受け取ると、最初に選んだ手と秘密の文字列を公開します(
reveal)。 - 全員の画面が、公開された値が最初のハッシュと一致するかを確認し、結果を表示します。
コード
<script>U=window.U=window.U||{},U.events=U.events||[],U.chat=function(n){return{on:function(e,t){U.events.push([n,e,t])},off:function(e){for(var t=U.events.length;t>0;)U.events[--t][0]==n&&U.events[t][1]==e&&U.events.splice(t,1)}}};</script>
<script async src="https://client.uchat.io/uchat.js"></script>
<u-chat room="my-room" style="display:inline-block;width:100%;max-width:400px;height:500px"></u-chat>
<script>
U.chat('my-room').on('after.create#rps-demo', function (room) {
var ui = room.skin;
var channel = room.plugin.add('rps-demo');
var TO_ALL = room.plugin.TO_ALL;
var ONLY_ME = room.plugin.ONLY_ME;
var HANDS = { r: '✊ グー', p: '✋ パー', s: '✌️ チョキ' };
var LIMIT = 60000; // 挑戦の有効時間(ミリ秒)
var games = {}; // 挑戦 ID → 進行状態(観戦している人もそれぞれ記録)
var secrets = {}; // 自分が申し込んだ挑戦 ID → { hand, salt }
function esc(value) {
return String(value).replace(/[&<>"']/g, function (c) {
return '&#' + c.charCodeAt(0) + ';';
});
}
function hex(bytes) {
return Array.from(bytes, function (b) {
return b.toString(16).padStart(2, '0');
}).join('');
}
function random(size) {
return hex(crypto.getRandomValues(new Uint8Array(size)));
}
function commitOf(id, hand, salt) {
var text = new TextEncoder().encode(id + ':' + hand + ':' + salt);
return crypto.subtle.digest('SHA-256', text).then(function (buffer) {
return hex(new Uint8Array(buffer));
});
}
function winner(a, b) {
if (a === b) return 0;
return (a === 'r' && b === 's') || (a === 's' && b === 'p') || (a === 'p' && b === 'r') ? 1 : 2;
}
function alive(game) {
return !!game && !game.done && room.now() <= game.expires;
}
function pick(title) {
return ui.dialog.select(title, '出す手を選んでください。', HANDS, 'r');
}
// 1. 申し込み: 出した手は隠し、ハッシュだけを全員に
ui.userMenu.add({
id: 'rps-demo.challenge',
label: 'じゃんけん',
condition: function (ctx) {
return !!ctx.target && !!ctx.room.my.nick && ctx.target.nick !== ctx.room.my.nick;
},
onSelect: function (ctx) {
var target = ctx.target.nick;
pick(target + 'さんに挑戦').then(function (hand) {
if (!HANDS[hand]) return;
var id = random(8);
var salt = random(16);
secrets[id] = { hand: hand, salt: salt };
return commitOf(id, hand, salt).then(function (commit) {
channel.send(['start', id, target, String(room.now() + LIMIT), commit]);
});
});
},
});
// 2. 相手: 受けるなら出した手を全員に、断るなら申し込んだ人にだけ
function answer(game) {
ui.dialog.confirm('じゃんけん', game.a + 'さんがじゃんけんを申し込みました。受けますか?').then(function (ok) {
if (!ok) return decline(game);
return pick(game.a + 'さんと対戦').then(function (hand) {
if (!HANDS[hand]) return decline(game);
if (!alive(game)) return room.print('時間切れのため、じゃんけんはキャンセルされました。');
channel.send(['play', game.id, hand]);
});
});
}
function decline(game) {
game.done = true;
if (room.now() <= game.expires) channel.sendTo(game.a, ['decline', game.id]);
}
function showResult(game, handA) {
var w = winner(handA, game.handB);
var text =
esc(game.a) + ' ' + HANDS[handA] + ' vs ' + HANDS[game.handB] + ' ' + esc(game.b) + ' → ';
room.print(text + (w === 0 ? 'あいこです' : esc(w === 1 ? game.a : game.b) + 'さんの勝ち!'));
}
channel.onReceived(function (room, data) {
// 送信者のニックネームはサーバーが付けてくれるので、データ内の名前ではなくこちらを信頼する
var from = data.user && data.user.nick;
var msg = data.data;
if (!from || !Array.isArray(msg)) return;
var game = games[msg[1]];
if (msg[0] === 'start' && data.type === TO_ALL) {
var now = room.now();
var expires = Number(msg[3]);
if (game || !/^[0-9a-f]{16}$/.test(msg[1])) return; // 同じ ID は一度だけ
if (typeof msg[2] !== 'string' || msg[2] === from) return;
if (!(expires > now && expires <= now + LIMIT + 5000)) return; // 期限切れ、または長すぎる挑戦
if (!/^[0-9a-f]{64}$/.test(msg[4])) return;
game = games[msg[1]] = { id: msg[1], a: from, b: msg[2], expires: expires, commit: msg[4] };
if (game.a === room.my.nick) {
room.print(esc(game.b) + 'さんにじゃんけんを申し込みました。');
setTimeout(function () {
if (game.done) return;
game.done = true;
room.print('応答がないため、じゃんけんはキャンセルされました。');
}, expires - now + 1000);
}
if (game.b === room.my.nick) answer(game);
return;
}
// 相手本人が送った値だけを、一度だけ受け取る
if (msg[0] === 'play' && data.type === TO_ALL) {
if (!alive(game) || from !== game.b || game.handB || !HANDS[msg[2]]) return;
game.handB = msg[2];
var secret = secrets[game.id];
if (game.a === room.my.nick && secret) channel.send(['reveal', game.id, secret.hand, secret.salt]);
return;
}
// 申し込んだ本人が公開した値が最初のハッシュと一致するときだけ結果を出す
if (msg[0] === 'reveal' && data.type === TO_ALL) {
if (!alive(game) || from !== game.a || !game.handB || !HANDS[msg[2]]) return;
if (typeof msg[3] !== 'string') return;
commitOf(game.id, msg[2], msg[3]).then(function (commit) {
if (commit !== game.commit || game.done) return;
game.done = true;
showResult(game, msg[2]);
});
return;
}
if (msg[0] === 'decline' && data.type === ONLY_ME) {
if (!game || game.done || from !== game.b || game.a !== room.my.nick) return;
game.done = true;
room.print(esc(from) + 'さんに断られました。');
}
});
});
</script>
何を確認するか
| 確認 | 防ぐこと |
|---|---|
| 挑戦ごとにランダムなID(16桁の16進数)、同じIDは一度だけ登録 | 過去の挑戦を送り直したり、別の挑戦と混ぜたりすること |
有効期限が現在より後で、LIMIT に5秒の余裕を足した時間より先ではないこと |
終わらない挑戦、古いメッセージ |
play は相手(game.b)本人が送ったものだけ、最初の1回だけ |
観戦者が代わりに出したり、相手が値を変えたりすること |
reveal は申し込んだ人(game.a)本人が送ったものだけ、ハッシュが一致するときだけ |
申し込んだ人が相手の手を見てから自分の手を変えること |
decline は自分にだけ届いたもの(data.type === ONLY_ME)で、相手が送ったものだけ |
ほかの人が断りを偽装すること |
data.user はサーバーが送信者の情報で埋めてくれるため、改ざんできません。逆に data.data の中の値は誰でも自由に作れるので、ニックネームのような値をそこから読んで信頼してはいけません。
注意点
- 時刻は
room.now()(サーバー基準のミリ秒)で比較するので、人によってパソコンの時計がずれていても同じ基準を使えます。 crypto.subtleはHTTPSのページでしか使えません。- 申し込んだ人が結果が不利だからと公開しなければ、挑戦は期限切れで終わります。結果を強制する必要があるゲームなら、サーバーで判定してサーバーREST APIで結果を送ってください。
- 1回の対戦で全体送信が3回(
start、play、reveal)発生します。無料チャットルームでは全体送信のたびに接続者数の分だけ送信上限が差し引かれるため、人の多いルームではチャンネルAPIと上限を確認してください。 - 相手が
playの前に、申し込んだ人がrevealの前にニックネームを変えると、その挑戦は期限切れで終わります。それより後に変えた場合、結果は以前のニックネームで表示されます。 - ダイアログ(
dialog.confirm、dialog.select)はキャンセルするとそれぞれfalse、nullを返します。詳しくはスキンUI APIをご覧ください。
読んでもよくわからない場合は お問い合わせ掲示板に質問を書き込んでください。