<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Games on Just another developer</title><link>https://xiang.es/games/</link><description>Recent content in Games on Just another developer</description><generator>Hugo -- 0.143.0</generator><language>en-us</language><lastBuildDate>Sat, 13 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://xiang.es/games/index.xml" rel="self" type="application/rss+xml"/><item><title>🏰 Fantasy Realms</title><link>https://xiang.es/games/fantasy-realms/</link><pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate><guid>https://xiang.es/games/fantasy-realms/</guid><description>&lt;p>Juego de cartas para 3–6 jugadores. Uno crea la sala y comparte el enlace. La partida vive en el navegador del anfitrión.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://wizkids.com/posters/repository/wizkids/FR_Rulebook-WEB.pdf">Reglamento&lt;/a>&lt;/li>
&lt;/ul>
&lt;style>
#fr-app{--bg:#1b1f27;--panel:#252b36;--text:#e8eaf0;--mut:#9aa3b2;--border:#3a4252;--blue:#3b82f6;
background:var(--bg);color:var(--text);border-radius:12px;padding:16px;
font-family:system-ui,sans-serif;font-size:15px;line-height:1.4}
#fr-app h3{margin:10px 0 6px;color:var(--text)}
#fr-app input{background:#11141a;color:var(--text);border:1px solid var(--border);
border-radius:8px;padding:8px 10px;font-size:15px;max-width:160px}
#fr-app button{background:var(--blue);color:#fff;border:0;border-radius:8px;
padding:8px 14px;font-size:14px;cursor:pointer;margin:2px}
#fr-app button:disabled{background:var(--border);color:var(--mut);cursor:not-allowed}
.fr-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}
.fr-panel{background:var(--panel);border-radius:10px;padding:10px 12px;margin:8px 0}
.fr-turn{box-shadow:0 0 0 3px #fbbf24 inset;border-radius:10px}
.fr-code{font-size:22px;font-weight:800;letter-spacing:4px;color:#fbbf24}
.fr-mut{color:var(--mut);font-size:13px;margin:4px 0}
.fr-name{font-weight:600;margin-bottom:6px;font-size:14px}
/* own hand: single scrollable row of full-size cards */
.fr-hand{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0;align-items:flex-start;
align-content:flex-start;padding-bottom:4px}
/* large card (own hand) */
.fr-card{width:145px;height:203px;border-radius:8px;border:2px solid rgba(0,0,0,.45);
flex:none;position:relative;user-select:none;background-color:#2a3040}
/* small card (opponent hands) */
.fr-card-opp{width:80px;height:112px;border-radius:6px;border:2px solid rgba(0,0,0,.45);
flex:none;position:relative;user-select:none;background-color:#2a3040}
/* small card (deck + discard pile) */
.fr-card-sm{width:65px;height:91px;border-radius:6px;border:2px solid rgba(0,0,0,.45);
flex:none;position:relative;user-select:none;background-color:#2a3040}
.fr-back{display:flex;align-items:center;justify-content:center}
.fr-back-count{font-size:42px;font-weight:800;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,.9);
position:absolute;bottom:10px;right:12px}
.fr-card-sm .fr-back-count{font-size:18px;bottom:4px;right:5px}
.fr-empty{display:flex;align-items:center;justify-content:center;font-size:28px;
border:2px dashed var(--border)!important;background:transparent!important;color:var(--mut)}
.fr-card-sm.fr-empty{font-size:18px}
.fr-clickable{cursor:pointer;transition:transform .12s}
.fr-card.fr-clickable:hover{transform:translateY(-6px)}
.fr-card-sm.fr-clickable:hover{transform:translateY(-4px)}
.fr-pickup-target{outline:4px solid #fbbf24;outline-offset:3px}
.fr-discard-target{outline:4px solid #f87171;outline-offset:3px}
.fr-zone{display:flex;flex-direction:column;align-items:center;gap:5px;flex:none}
.fr-zone-label{font-size:10px;color:var(--mut);letter-spacing:1px;font-weight:700;text-transform:uppercase}
.fr-board-row{display:flex;gap:14px;align-items:flex-start;flex-wrap:wrap;padding-bottom:4px}
.fr-discard-zone{flex:1;min-width:200px}
.fr-discard-fan{display:flex;flex-wrap:wrap;gap:5px;padding:2px}
.fr-log{max-height:140px;overflow-y:auto;font-size:13px;color:var(--mut)}
.fr-log div{padding:1px 0}
.fr-banner{background:#3b2f12;border:1px solid #fbbf24;border-radius:10px;
padding:10px 14px;margin:8px 0;font-weight:600}
.fr-action-hint{background:#1e3a5f;border:1px solid #3b82f6;border-radius:8px;
padding:8px 12px;margin:6px 0;font-size:14px}
.fr-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
background:#dc2626;color:#fff;padding:10px 18px;border-radius:10px;z-index:99;
box-shadow:0 4px 14px rgba(0,0,0,.4)}
#fr-tip{width:260px;height:364px;border-radius:10px;border:2px solid rgba(255,255,255,.22);
box-shadow:0 10px 40px rgba(0,0,0,.85);background-size:1000% 600%;
position:fixed;z-index:200;pointer-events:none;display:none}
@keyframes fr-fadein{from{opacity:0;transform:scale(.6)}to{opacity:1;transform:scale(1)}}
.fr-new{animation:fr-fadein .4s ease}
.fr-dragging{opacity:.25!important}
.fr-drop-line{width:5px;height:203px;background:#fbbf24;border-radius:3px;flex:none;
box-shadow:0 0 10px #fbbf24}
&lt;/style>
&lt;div id="fr-app">
&lt;div id="fr-setup">
&lt;div class="fr-row">&lt;label>Tu nombre: &lt;input id="fr-name" maxlength="14" placeholder="Xiang">&lt;/label>&lt;/div>
&lt;div class="fr-row">&lt;button id="fr-create">🏰 Crear sala&lt;/button>&lt;/div>
&lt;div class="fr-row">&lt;input id="fr-code" maxlength="4" placeholder="CÓDIGO" style="text-transform:uppercase;width:110px">&lt;button id="fr-join">Unirse&lt;/button>&lt;/div>
&lt;div id="fr-setupmsg" class="fr-mut">&lt;/div>
&lt;/div>
&lt;div id="fr-game" style="display:none">&lt;/div>
&lt;/div>
&lt;script src="https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js">&lt;/script>
&lt;script>
(function(){
'use strict';
var SPRITE='https://steamusercontent-a.akamaihd.net/ugc/1812114214287641959/29522BD4EC40B09E56541D728732C149E9819365/';
var BACK='https://steamusercontent-a.akamaihd.net/ugc/1786218626612222249/734934D88FAE55BC72C969D3B1E649A45015B405/';
var PREFIX='fantasy-realms-xiang-';
var MAXP=6,MINP=3,MAX_DISCARD=10,HAND_SIZE=7;
// [name, cardId, suit]
var CARDS_DEF=[
['Mountain',600,'Land'],['Cavern',601,'Land'],['Forest',603,'Land'],
['Earth Elemental',604,'Land'],['Swamp',606,'Land'],['Island',608,'Land'],
['Water Elemental',609,'Flood'],['Rainstorm',610,'Flood'],['Blizzard',611,'Flood'],
['Smoke',612,'Flood'],['Whirlwind',613,'Flood'],['Air Elemental',614,'Flood'],
['Wildfire',615,'Flame'],['Candle',616,'Flame'],['Forge',617,'Flame'],
['Lightning',618,'Flame'],['Fire Elemental',619,'Flame'],
['Knights',620,'Army'],['Elven Archers',621,'Army'],['Light Cavalry',622,'Army'],
['Dwarvish Infantry',623,'Army'],
['Collector',625,'Leader'],['Beastmaster',626,'Leader'],['Warlock Lord',628,'Leader'],
['Enchantress',629,'Leader'],['King',630,'Leader'],['Queen',631,'Leader'],
['Princess',632,'Leader'],['Warlord',633,'Leader'],['Empress',634,'Leader'],
['Unicorn',635,'Beast'],['Basilisk',636,'Beast'],['Warhorse',637,'Beast'],
['Dragon',638,'Beast'],['Hydra',639,'Beast'],
['Warship',640,'Artifact'],['Magic Wand',641,'Artifact'],['Sword of Keth',642,'Artifact'],
['Elven Longbow',643,'Artifact'],['War Dirigible',644,'Artifact'],
['Shield of Keth',645,'Artifact'],['Gem of Order',646,'Artifact'],
['Book of Changes',648,'Spell'],['Protection Rune',649,'Spell'],['Doppelganger',652,'Spell']
];
var SUIT_COLOR={
Land:'#65a30d',Flood:'#0284c7',Flame:'#ef4444',Army:'#92400e',
Leader:'#7c3aed',Beast:'#d97706',Artifact:'#6b7280',Spell:'#be185d'
};
// ---------- state ----------
var peer=null,isHost=false,hostConn=null,roomCode='';
var G=null; // full state (host)
var V=null; // view state (rendered)
var myName='';
function el(id){return document.getElementById(id);}
function esc(s){return String(s).replace(/[&amp;&lt;>"']/g,function(c){
return{'&amp;':'&amp;amp;','&lt;':'&amp;lt;','>':'&amp;gt;','"':'&amp;quot;',"'":'&amp;#39;'}[c];});}
function toast(msg){var t=document.createElement('div');t.className='fr-toast';
t.textContent=msg;el('fr-app').appendChild(t);setTimeout(function(){t.remove();},3500);}
// ---------- sprite ----------
function spriteStyle(cardId){
var idx=cardId-600,col=idx%10,row=Math.floor(idx/10);
var xp=(col/9*100).toFixed(2),yp=(row/5*100).toFixed(2);
return 'background-image:url('+SPRITE+');background-size:1000% 600%;background-position:'+xp+'% '+yp+'%';
}
function cardHtml(card,cls,act,extra){
var attrs='data-cid="'+card.id+'" title="'+esc(card.name)+' ('+card.suit+')"';
if(act)attrs+=' data-act="'+act+'"';
if(extra)attrs+=' '+extra;
return '&lt;div class="'+cls+'" style="'+spriteStyle(card.cardId)+'" '+attrs+'>&lt;/div>';
}
// ---------- hand order ----------
var myOrder=[];
var dragId=null,dragDropId=null,dragBefore=true,isDragging=false;
function reconcileOrder(hand){
var ids=hand.map(function(c){return c.id;});
myOrder=myOrder.filter(function(id){return ids.indexOf(id)!==-1;});
ids.forEach(function(id){if(myOrder.indexOf(id)===-1)myOrder.push(id);});
}
// ---------- game logic (host) ----------
function mkDeck(){
var d=CARDS_DEF.map(function(def,i){return{id:i,name:def[0],cardId:def[1],suit:def[2]};});
for(var i=d.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=d[i];d[i]=d[j];d[j]=t;}
return d;
}
function newGame(){
G={phase:'lobby',players:[],hands:[],deck:[],discard:[],turn:0,turnPhase:'pickup',log:[],result:null};
}
function startGame(){
G.deck=mkDeck();G.discard=[];G.result=null;
G.turn=Math.floor(Math.random()*G.players.length);
G.turnPhase='pickup';
G.log=['🏰 ¡Empieza la partida! Turno inicial: '+pname(G.turn)];
G.hands=G.players.map(function(){return[];});
for(var k=0;k&lt;HAND_SIZE;k++)G.players.forEach(function(_,i){G.hands[i].push(G.deck.pop());});
G.phase='playing';
broadcast();
}
function pname(i){return G.players[i].name;}
function glog(m){G.log.push(m);if(G.log.length>60)G.log.shift();}
function buildStateFor(me){
return{
me:me,
phase:G.phase,turn:G.turn,turnPhase:G.turnPhase,
deckCount:G.deck.length,
discard:G.discard.map(function(c){return{id:c.id,name:c.name,cardId:c.cardId,suit:c.suit};}),
hands:G.hands.map(function(h,i){return h.map(function(c){
if(i===me||G.phase==='ended')return{id:c.id,name:c.name,cardId:c.cardId,suit:c.suit};
return{id:c.id,hidden:true};
});}),
names:G.players.map(function(p){return p.name;}),
connected:G.players.map(function(p){return p.connected;}),
log:G.log.slice(-30),
result:G.result
};
}
function broadcast(){
G.players.forEach(function(p,i){
if(i===0)return;
if(p.conn&amp;&amp;p.connected)p.conn.send({t:'state',s:buildStateFor(i)});
});
V=buildStateFor(0);
render();
}
function sendErr(p,msg){
if(p===0)toast(msg);
else if(G.players[p].conn)G.players[p].conn.send({t:'error',msg:msg});
}
function applyAction(p,a){
if(!G||G.phase!=='playing')return;
if(G.turn!==p)return sendErr(p,'No es tu turno');
if(a.kind==='draw'){
if(G.turnPhase!=='pickup')return sendErr(p,'Ya has cogido carta, ahora descarta');
if(!G.deck.length)return sendErr(p,'El mazo está vacío');
G.hands[p].push(G.deck.pop());
glog('🂠 '+pname(p)+' roba una carta del mazo');
G.turnPhase='discard';
}else if(a.kind==='take'){
if(G.turnPhase!=='pickup')return sendErr(p,'Ya has cogido carta, ahora descarta');
if(!G.discard.length)return sendErr(p,'El descarte está vacío');
var dIdx=G.discard.findIndex(function(c){return c.id===a.cid;});
if(dIdx===-1)return sendErr(p,'Carta no encontrada en el descarte');
var taken=G.discard.splice(dIdx,1)[0];
G.hands[p].push(taken);
glog('✋ '+pname(p)+' toma '+taken.name+' del descarte');
G.turnPhase='discard';
}else if(a.kind==='discard'){
if(G.turnPhase!=='discard')return sendErr(p,'Primero roba o toma una carta');
var hIdx=G.hands[p].findIndex(function(c){return c.id===a.cid;});
if(hIdx===-1)return sendErr(p,'Carta no encontrada en tu mano');
var disc=G.hands[p].splice(hIdx,1)[0];
G.discard.push(disc);
glog('🗑 '+pname(p)+' descarta '+disc.name);
if(G.discard.length>=MAX_DISCARD){
G.phase='ended';G.result={};
glog('🏁 ¡El descarte tiene '+G.discard.length+' cartas — fin de la partida!');
broadcast();return;
}
G.turn=(G.turn+1)%G.players.length;
G.turnPhase='pickup';
glog('👉 Turno de '+pname(G.turn));
}else return;
broadcast();
}
// ---------- network ----------
function setupHostConn(c){
c.on('data',function(msg){
if(!msg||typeof msg!=='object')return;
if(msg.t==='join'){
var name=String(msg.name||'???').slice(0,14);
if(G.phase!=='lobby'){
var old=G.players.findIndex(function(p){return!p.connected&amp;&amp;p.name===name;});
if(old>=0){G.players[old].conn=c;G.players[old].connected=true;
c._hIdx=old;glog('🔌 '+name+' ha vuelto');broadcast();}
else c.send({t:'error',msg:'Partida en curso, no puedes entrar'});
return;
}
if(G.players.length>=MAXP)return c.send({t:'error',msg:'Sala llena (máx '+MAXP+')'});
while(G.players.some(function(p){return p.name===name;}))name+='2';
G.players.push({name:name,conn:c,connected:true});
c._hIdx=G.players.length-1;
broadcast();
}else if(msg.t==='action'&amp;&amp;typeof c._hIdx==='number'){
applyAction(c._hIdx,msg.a);
}
});
c.on('close',function(){
if(typeof c._hIdx!=='number')return;
if(G.phase==='lobby'){G.players.splice(c._hIdx,1);
G.players.forEach(function(p,i){if(p.conn)p.conn._hIdx=i;});}
else{G.players[c._hIdx].connected=false;
glog('🔌 '+pname(c._hIdx)+' se ha desconectado');}
broadcast();
});
}
function createRoom(){
myName=el('fr-name').value.trim();
if(!myName)return setupMsg('Pon tu nombre primero');
var alpha='ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
roomCode='';for(var i=0;i&lt;4;i++)roomCode+=alpha[Math.floor(Math.random()*alpha.length)];
setupMsg('Creando sala...');
peer=new Peer(PREFIX+roomCode);
peer.on('open',function(){
isHost=true;newGame();
G.players.push({name:myName,conn:null,connected:true});
el('fr-setup').style.display='none';el('fr-game').style.display='block';
broadcast();
});
peer.on('connection',setupHostConn);
peer.on('error',function(e){
if(e.type==='unavailable-id')setupMsg('Código ocupado, prueba otra vez');
else setupMsg('Error: '+e.type);
});
}
function joinRoom(){
myName=el('fr-name').value.trim();
roomCode=el('fr-code').value.trim().toUpperCase();
if(!myName)return setupMsg('Pon tu nombre primero');
if(roomCode.length!==4)return setupMsg('El código tiene 4 letras');
setupMsg('Conectando...');
peer=new Peer();
peer.on('open',function(){
hostConn=peer.connect(PREFIX+roomCode,{reliable:true});
hostConn.on('open',function(){hostConn.send({t:'join',name:myName});});
hostConn.on('data',function(msg){
if(!msg)return;
if(msg.t==='state'){
V=msg.s;
el('fr-setup').style.display='none';el('fr-game').style.display='block';
render();
}else if(msg.t==='error'){V?toast(msg.msg):setupMsg(msg.msg);}
});
hostConn.on('close',function(){toast('Se ha perdido la conexión con el anfitrión 😢');});
});
peer.on('error',function(e){
if(e.type==='peer-unavailable')setupMsg('No existe la sala '+roomCode);
else setupMsg('Error: '+e.type);
});
}
function sendAction(a){
if(isHost)applyAction(0,a);
else hostConn.send({t:'action',a:a});
}
function setupMsg(m){el('fr-setupmsg').textContent=m;}
// ---------- render ----------
function render(){
if(!V)return;
// reconcile local hand order with new state
if(V.hands&amp;&amp;V.hands[V.me])reconcileOrder(V.hands[V.me]);
// snapshot card positions and deck rect before DOM wipe
var prev={};
el('fr-game').querySelectorAll('[data-cid]').forEach(function(e){
prev[e.getAttribute('data-cid')]=e.getBoundingClientRect();
});
var deckEl=el('fr-game').querySelector('[data-zone="deck"]');
var deckRect=deckEl?deckEl.getBoundingClientRect():null;
var h='';
h+='&lt;div class="fr-row">Sala: &lt;span class="fr-code">'+roomCode+'&lt;/span> '+
'&lt;button data-act="copy">📋 Copiar enlace&lt;/button>&lt;/div>';
if(V.phase==='lobby'){
h+='&lt;div class="fr-panel">&lt;h3>Jugadores ('+V.names.length+'/'+MAXP+')&lt;/h3>';
V.names.forEach(function(n,i){
h+='&lt;div>'+esc(n)+(i===0?' 👑':'')+(i===V.me?' (tú)':'')+'&lt;/div>';});
h+='&lt;/div>';
if(V.me===0){
h+='&lt;button data-act="start"'+(V.names.length&lt;MINP?' disabled':'')+'>🚀 Empezar ('+
(V.names.length&lt;MINP?'mínimo '+MINP:V.names.length+' jugadores')+')&lt;/button>';
}else{
h+='&lt;div class="fr-mut">Esperando a que '+esc(V.names[0])+' empiece...&lt;/div>';
}
}else{
if(V.phase==='ended'){
h+='&lt;div class="fr-banner">🏁 ¡Fin de la partida! Contad las puntuaciones de vuestras manos.'+
(V.me===0?' &lt;button data-act="start">🔄 Otra partida&lt;/button>':'')+'&lt;/div>';
}
var myTurn=V.phase==='playing'&amp;&amp;V.turn===V.me;
var canPickup=myTurn&amp;&amp;V.turnPhase==='pickup';
var canDiscard=myTurn&amp;&amp;V.turnPhase==='discard';
if(V.phase==='playing'){
if(myTurn){
if(canPickup)h+='&lt;div class="fr-action-hint">✨ Tu turno: roba del mazo &lt;b>o&lt;/b> toma una carta del descarte&lt;/div>';
else h+='&lt;div class="fr-action-hint">🗑 Ahora elige una carta de tu mano para descartar&lt;/div>';
}else{
h+='&lt;div class="fr-mut">Turno de &lt;b>'+esc(V.names[V.turn])+'&lt;/b>'+
(V.turnPhase==='discard'?' (descartando...)':'')+'...&lt;/div>';
}
}
// Board: deck + discard
h+='&lt;div class="fr-panel">&lt;div class="fr-board-row">';
// Deck
h+='&lt;div class="fr-zone">&lt;div class="fr-zone-label">Mazo&lt;/div>';
if(V.deckCount>0){
var deckCls='fr-card-sm fr-back'+(canPickup?' fr-clickable fr-pickup-target':'');
h+='&lt;div class="'+deckCls+'" data-zone="deck"'+(canPickup?' data-act="draw"':'')+
' style="background-image:url('+BACK+');background-size:cover;background-position:center" title="Robar del mazo ('+V.deckCount+')">'+
'&lt;span class="fr-back-count">'+V.deckCount+'&lt;/span>&lt;/div>';
}else{
h+='&lt;div class="fr-card-sm fr-empty" title="Mazo vacío">∅&lt;/div>';
}
h+='&lt;/div>';
// Discard
h+='&lt;div class="fr-zone fr-discard-zone">&lt;div class="fr-zone-label">Descarte ('+V.discard.length+'/'+MAX_DISCARD+')&lt;/div>';
h+='&lt;div class="fr-discard-fan">';
if(!V.discard.length){
h+='&lt;div class="fr-card-sm fr-empty" title="Descarte vacío">🗑&lt;/div>';
}else{
V.discard.forEach(function(card){
var cls='fr-card-sm'+(canPickup?' fr-clickable fr-pickup-target':'');
var act=canPickup?'take':null;
h+=cardHtml(card,cls,act);
});
}
h+='&lt;/div>&lt;/div>';
h+='&lt;/div>&lt;/div>';
// Hands — current player always first
var nPlayers=V.names.length;
Array.from({length:nPlayers},function(_,k){return(V.me+k)%nPlayers;}).forEach(function(i){
var name=V.names[i];
var isTurn=V.phase==='playing'&amp;&amp;V.turn===i;
var isMe=i===V.me;
h+='&lt;div class="fr-panel'+(isTurn?' fr-turn':'')+'">'+
'&lt;div class="fr-name">'+(isTurn?'▶ ':'')+esc(name)+(isMe?' (tú)':'')+(V.connected[i]?'':' 🔌❌')+'&lt;/div>';
h+='&lt;div class="fr-hand" data-pl="'+i+'">';
if(isMe){
// sort by myOrder, show drop lines while dragging
var sorted=V.hands[i].slice().sort(function(a,b){
return myOrder.indexOf(a.id)-myOrder.indexOf(b.id);});
sorted.forEach(function(card){
var isDraggingThis=isDragging&amp;&amp;dragId===card.id;
var isDropTarget=isDragging&amp;&amp;dragDropId===card.id;
if(isDropTarget&amp;&amp;dragBefore)h+='&lt;div class="fr-drop-line">&lt;/div>';
var clickable=canDiscard&amp;&amp;!isDragging;
var cls='fr-card'+(clickable?' fr-clickable fr-discard-target':'')+(isDraggingThis?' fr-dragging':'');
var act=clickable?'discard':null;
h+=cardHtml(card,cls,act,'draggable="true" data-hand="1"');
if(isDropTarget&amp;&amp;!dragBefore)h+='&lt;div class="fr-drop-line">&lt;/div>';
});
}else{
V.hands[i].forEach(function(card){
if(card.hidden){
h+='&lt;div class="fr-card-opp fr-back" style="background-image:url('+BACK+');background-size:cover;background-position:center" title="Carta oculta">&lt;/div>';
}else{
h+=cardHtml(card,'fr-card-opp','',null);
}
});
}
h+='&lt;/div>';
h+='&lt;/div>';
});
// Log
h+='&lt;div class="fr-panel fr-log">'+V.log.slice().reverse().map(function(l){
return'&lt;div>'+esc(l)+'&lt;/div>';}).join('')+'&lt;/div>';
}
el('fr-game').innerHTML=h;
// FLIP animation (skip during drag to avoid jank)
if(isDragging)return;
el('fr-game').querySelectorAll('[data-cid]').forEach(function(e){
var cid=e.getAttribute('data-cid');
var r0=prev[cid];
var r1=e.getBoundingClientRect();
var dx,dy;
if(r0){
dx=r0.left-r1.left;dy=r0.top-r1.top;
if(!dx&amp;&amp;!dy)return;
}else{
// card is new to the DOM
var inMyHand=e.closest('[data-pl="'+V.me+'"]');
if(inMyHand&amp;&amp;deckRect){
dx=deckRect.left-r1.left;dy=deckRect.top-r1.top;
}else{
e.classList.add('fr-new');return;
}
}
e.style.zIndex='50';
e.style.transition='none';
e.style.transform='translate('+dx+'px,'+dy+'px)';
e.getBoundingClientRect(); // force reflow
e.style.transition='transform .45s ease';
e.style.transform='';
e.addEventListener('transitionend',function(){
e.style.transition='';e.style.zIndex='';
},{once:true});
});
}
// ---------- events ----------
el('fr-create').addEventListener('click',createRoom);
el('fr-join').addEventListener('click',joinRoom);
el('fr-game').addEventListener('click',function(e){
var b=e.target.closest('[data-act]');
if(!b)return;
var act=b.getAttribute('data-act');
var cid=b.getAttribute('data-cid');
if(act==='copy'){
navigator.clipboard.writeText(location.origin+location.pathname+'?sala='+roomCode)
.then(function(){toast('Enlace copiado 📋');});
}else if(act==='start'&amp;&amp;isHost){
startGame();
}else if(act==='draw'){
if(V&amp;&amp;V.turn===V.me&amp;&amp;V.turnPhase==='pickup'&amp;&amp;V.deckCount>0)
sendAction({kind:'draw'});
}else if(act==='take'){
if(V&amp;&amp;V.turn===V.me&amp;&amp;V.turnPhase==='pickup'&amp;&amp;cid!==null)
sendAction({kind:'take',cid:+cid});
}else if(act==='discard'){
if(V&amp;&amp;V.turn===V.me&amp;&amp;V.turnPhase==='discard'&amp;&amp;cid!==null)
sendAction({kind:'discard',cid:+cid});
}
});
// ---------- tooltip (discard only) ----------
var tip=document.createElement('div');tip.id='fr-tip';document.body.appendChild(tip);
el('fr-game').addEventListener('mouseover',function(e){
var card=e.target.closest('.fr-discard-fan [data-cid]');
if(!card){tip.style.display='none';return;}
tip.style.backgroundImage=card.style.backgroundImage;
tip.style.backgroundSize=card.style.backgroundSize;
tip.style.backgroundPosition=card.style.backgroundPosition;
tip.style.display='block';
});
el('fr-game').addEventListener('mousemove',function(e){
if(tip.style.display==='none')return;
var x=e.clientX+18,y=e.clientY-182;
if(x+260>window.innerWidth)x=e.clientX-278;
if(y&lt;8)y=8;
if(y+364>window.innerHeight)y=window.innerHeight-372;
tip.style.left=x+'px';tip.style.top=y+'px';
});
el('fr-game').addEventListener('mouseleave',function(){tip.style.display='none';});
el('fr-game').addEventListener('mouseout',function(e){
if(!e.target.closest('.fr-discard-fan'))tip.style.display='none';
});
// ---------- drag to reorder ----------
el('fr-game').addEventListener('dragstart',function(e){
var card=e.target.closest('[draggable="true"][data-cid]');
if(!card)return;
dragId=+card.getAttribute('data-cid');
isDragging=true;
e.dataTransfer.effectAllowed='move';
e.dataTransfer.setData('text/plain',String(dragId));
tip.style.display='none';
setTimeout(render,0);
});
el('fr-game').addEventListener('dragend',function(){
isDragging=false;dragId=null;dragDropId=null;
render();
});
el('fr-game').addEventListener('dragover',function(e){
var card=e.target.closest('[draggable="true"][data-cid]');
if(!card){e.preventDefault();return;}
e.preventDefault();
var overId=+card.getAttribute('data-cid');
var rect=card.getBoundingClientRect();
var before=e.clientX&lt;rect.left+rect.width/2;
if(overId!==dragDropId||before!==dragBefore){
dragDropId=overId;dragBefore=before;
render();
}
});
el('fr-game').addEventListener('drop',function(e){
e.preventDefault();
if(dragId===null||dragDropId===null||dragId===dragDropId){
isDragging=false;dragId=null;dragDropId=null;render();return;
}
var fromIdx=myOrder.indexOf(dragId);
myOrder.splice(fromIdx,1);
var toIdx=myOrder.indexOf(dragDropId);
if(!dragBefore)toIdx++;
myOrder.splice(toIdx,0,dragId);
isDragging=false;dragId=null;dragDropId=null;
render();
});
var m=location.search.match(/sala=([A-Za-z0-9]{4})/);
if(m)el('fr-code').value=m[1].toUpperCase();
})();
&lt;/script>
&lt;h2 id="cómo-se-juega">¿Cómo se juega?&lt;/h2>
&lt;p>Fantasy Realms es un juego de &lt;strong>colección de sets&lt;/strong>: cada jugador construye una mano de &lt;strong>7 cartas&lt;/strong> que combinen bien entre sí. Las cartas tienen efectos sinérgicos que aumentan o reducen la puntuación final.&lt;/p></description></item><item><title>💍 LotR Trick-Taking</title><link>https://xiang.es/games/lotr/</link><pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate><guid>https://xiang.es/games/lotr/</guid><description>&lt;style>
#lotr-app {
background: #0d1117;
border-radius: 12px;
padding: 24px;
font-family: system-ui, sans-serif;
color: #e6edf3;
}
#lotr-app h3 {
margin: 24px 0 10px;
color: #8b949e;
font-size: 13px;
letter-spacing: 1px;
text-transform: uppercase;
}
.l-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
}
/* ── sprite card ── */
.lcard {
width: 110px;
height: 154px;
border-radius: 10px;
background-image: url('https://steamusercontent-a.akamaihd.net/ugc/34440677816838739/C609A55EEEB4A08A3F74F90AA4AA90CF9DEC1EF4/');
background-size: 800% 500%;
border: 2px solid #30363d;
cursor: pointer;
position: relative;
flex: none;
transition: transform .12s, box-shadow .12s, border-color .12s;
}
.lcard:hover {
transform: translateY(-4px) scale(1.05);
box-shadow: 0 8px 20px rgba(0,0,0,.6);
z-index: 10;
border-color: #58a6ff;
}
/* reverso del mazo principal */
.lcard-back {
background-image: url('https://steamusercontent-a.akamaihd.net/ugc/34440677816838931/2DC6F20372D3BB0AA7A1ACB960616AE311F84ACD/');
background-size: 100% 100%;
}
/* personajes — anverso */
.lcard-char {
background-image: url('https://steamusercontent-a.akamaihd.net/ugc/34441311448452068/A45E85531AD7219FFD87731E1057D8628D2F0D7A/');
background-size: 500% 500%;
}
/* personajes — reverso */
.lcard-char-back {
background-image: url('https://steamusercontent-a.akamaihd.net/ugc/34441311448452379/E6CE202864F928F273B4926AAA1147A7FF5004AF/');
background-size: 500% 500%;
}
/* ── modal de preview ── */
#lcard-modal {
display: none;
position: fixed;
inset: 0;
z-index: 9999;
align-items: center;
justify-content: center;
background: rgba(0,0,0,.75);
backdrop-filter: blur(4px);
pointer-events: none; /* el ratón lo atraviesa → no rompe mouseleave */
}
#lcard-modal.open { display: flex; }
#lcard-modal-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
#lcard-modal-img {
width: 330px;
height: 462px;
border-radius: 16px;
border: 3px solid #58a6ff;
box-shadow: 0 20px 60px rgba(0,0,0,.9);
}
#lcard-modal-label {
font-family: system-ui, sans-serif;
font-size: 18px;
color: #e6edf3;
text-shadow: 0 2px 8px rgba(0,0,0,.8);
}
&lt;/style>
&lt;!-- modal overlay -->
&lt;div id="lcard-modal">
&lt;div id="lcard-modal-inner">
&lt;div id="lcard-modal-img">&lt;/div>
&lt;div id="lcard-modal-label">&lt;/div>
&lt;/div>
&lt;/div>
&lt;div id="lotr-app">
&lt;div id="lotr-grid">&lt;/div>
&lt;/div>
&lt;script>
(function(){
// ── Modal logic ──────────────────────────────────────────────────────────────
var modal = document.getElementById('lcard-modal');
var modalImg = document.getElementById('lcard-modal-img');
var modalLabel = document.getElementById('lcard-modal-label');
function showModal(bgImage, bgSize, bgPos, label) {
modalImg.style.backgroundImage = bgImage;
modalImg.style.backgroundSize = bgSize;
modalImg.style.backgroundPosition = bgPos;
modalLabel.textContent = label;
modal.classList.add('open');
}
function hideModal() { modal.classList.remove('open'); }
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') hideModal(); });
function attachHover(el, bgImage, bgSize, bgPos, label) {
el.addEventListener('mouseenter', function() { showModal(bgImage, bgSize, bgPos, label); });
el.addEventListener('mouseleave', hideModal);
}
// ── Card helpers ─────────────────────────────────────────────────────────────
var MAIN_URL = "url('https://steamusercontent-a.akamaihd.net/ugc/34440677816838739/C609A55EEEB4A08A3F74F90AA4AA90CF9DEC1EF4/')";
var BACK_URL = "url('https://steamusercontent-a.akamaihd.net/ugc/34440677816838931/2DC6F20372D3BB0AA7A1ACB960616AE311F84ACD/')";
var CHAR_URL = "url('https://steamusercontent-a.akamaihd.net/ugc/34441311448452068/A45E85531AD7219FFD87731E1057D8628D2F0D7A/')";
var CHARB_URL= "url('https://steamusercontent-a.akamaihd.net/ugc/34441311448452379/E6CE202864F928F273B4926AAA1147A7FF5004AF/')";
var COLS = 8, ROWS = 5;
var CHAR_COLS = 5, CHAR_ROWS = 5;
var SUITS = [
{ name: '⛰️ Mountain', start: 0, count: 8 },
{ name: '🏔️ Hill', start: 8, count: 8 },
{ name: '🌲 Forest', start: 16, count: 8 },
{ name: '🌑 Shadow', start: 24, count: 8 },
{ name: '💍 Ring', start: 32, count: 5 },
];
function suitOf(pos) {
for (var s of SUITS) if (pos >= s.start &amp;&amp; pos &lt; s.start + s.count) return s;
return null;
}
function valOf(pos) {
var s = suitOf(pos); return s ? (pos - s.start + 1) : '?';
}
function bpos(col, row, totalCols, totalRows) {
return (col / (totalCols - 1) * 100).toFixed(2) + '% ' +
(row / (totalRows - 1) * 100).toFixed(2) + '%';
}
// ── Build HTML ───────────────────────────────────────────────────────────────
var container = document.getElementById('lotr-grid');
var html = '';
// Main suits
SUITS.forEach(function(s) {
html += '&lt;h3>' + s.name + ' (1–' + s.count + ')&lt;/h3>&lt;div class="l-grid" data-section="main">';
for (var i = s.start; i &lt; s.start + s.count; i++) {
var col = i % COLS, row = Math.floor(i / COLS);
var bp = bpos(col, row, COLS, ROWS);
var sName = s.name.split(' ')[1];
html += '&lt;div class="lcard" style="background-position:' + bp + '"' +
' data-bg="main" data-bp="' + bp + '" data-label="' + sName + ' ' + valOf(i) + '">&lt;/div>';
}
html += '&lt;/div>';
});
// Back sample
html += '&lt;h3>Reverso (común a todas)&lt;/h3>&lt;div class="l-grid">' +
'&lt;div class="lcard lcard-back" data-bg="back" data-label="Reverso">&lt;/div>&lt;/div>';
// Characters
var CHARS = [
{pos:0, name:'Frodo'}, {pos:1, name:'Bilbo'},
{pos:2, name:'Merry'}, {pos:3, name:'Gildor Inglorien'},
{pos:4, name:'Fatty Bolger'}, {pos:5, name:'Gandalf'},
{pos:6, name:'Pippin'}, {pos:7, name:'Sam'},
{pos:8, name:'Farmer Maggot'}, {pos:9, name:'Tom Bombadil'},
{pos:10, name:'Strider'}, {pos:11, name:'Goldberry'},
{pos:12, name:'Barliman Butterbur'},{pos:13,name:'Mr. Underhill'},
{pos:14, name:'Glorfindel'}, {pos:15, name:'Bill the Pony'},
{pos:16, name:'Glóin'}, {pos:17, name:'Arwen'},
{pos:18, name:'Elrond'}, {pos:19, name:'Aragorn'},
{pos:20, name:'Bilbo Baggins'}, {pos:21, name:'Boromir'},
{pos:22, name:'Radagast'}, {pos:23, name:'Shadowfax'},
{pos:24, name:'Gwaihir'},
];
html += '&lt;h3>👤 Personajes&lt;/h3>&lt;div class="l-grid">';
CHARS.forEach(function(c) {
var col = c.pos % CHAR_COLS, row = Math.floor(c.pos / CHAR_COLS);
var bp = bpos(col, row, CHAR_COLS, CHAR_ROWS);
html += '&lt;div class="lcard lcard-char" style="background-position:' + bp + '"' +
' data-bg="char" data-bp="' + bp + '" data-label="' + c.name + '">&lt;/div>';
});
html += '&lt;/div>';
html += '&lt;h3>Reversos de personajes&lt;/h3>&lt;div class="l-grid">';
CHARS.forEach(function(c) {
var col = c.pos % CHAR_COLS, row = Math.floor(c.pos / CHAR_COLS);
var bp = bpos(col, row, CHAR_COLS, CHAR_ROWS);
html += '&lt;div class="lcard lcard-char-back" style="background-position:' + bp + '"' +
' data-bg="charback" data-bp="' + bp + '" data-label="' + c.name + ' (reverso)">&lt;/div>';
});
html += '&lt;/div>';
container.innerHTML = html;
// ── Attach modal events after render ─────────────────────────────────────────
var bgMap = {
main: { img: MAIN_URL, size: '800% 500%' },
back: { img: BACK_URL, size: '100% 100%' },
char: { img: CHAR_URL, size: '500% 500%' },
charback: { img: CHARB_URL, size: '500% 500%' },
};
container.querySelectorAll('.lcard').forEach(function(el) {
var bg = el.dataset.bg;
if (!bg || !bgMap[bg]) return;
var m = bgMap[bg];
var bp = el.dataset.bp || '0% 0%';
var label = el.dataset.label || '';
attachHover(el, m.img, m.size, bp, label);
});
})();
&lt;/script></description></item><item><title>💣 Time Bomb</title><link>https://xiang.es/games/timebomb/</link><pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate><guid>https://xiang.es/games/timebomb/</guid><description>&lt;p>Juego de deducción social para 4-8 jugadores.&lt;/p>
&lt;style>
#tb-app{--tbg:#0f1117;--tpanel:#1a1f2c;--ttext:#e8eaf0;--tmut:#9aa3b2;
background:var(--tbg);color:var(--ttext);border-radius:12px;padding:16px;
font-family:system-ui,sans-serif;font-size:15px;line-height:1.4}
#tb-app h3{margin:8px 0 6px;color:var(--ttext)}
#tb-app input{background:#11141a;color:var(--ttext);border:1px solid #3a4252;
border-radius:8px;padding:8px 10px;font-size:15px;max-width:160px}
#tb-app button{background:#3b82f6;color:#fff;border:0;border-radius:8px;
padding:8px 14px;font-size:14px;cursor:pointer;margin:2px}
#tb-app button:disabled{background:#3a4252;color:var(--tmut);cursor:not-allowed}
#tb-app .tb-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}
#tb-app .tb-panel{background:var(--tpanel);border-radius:10px;padding:10px 12px;margin:8px 0}
#tb-app .tb-turn{box-shadow:0 0 0 2px #fbbf24 inset;border-radius:10px}
#tb-app .tb-code{font-size:22px;font-weight:800;letter-spacing:4px;color:#fbbf24}
#tb-app .tb-mut{color:var(--tmut);font-size:13px;margin:4px 0}
#tb-app .tb-cable{width:42px;height:58px;border-radius:8px;display:inline-flex;
align-items:center;justify-content:center;font-size:20px;font-weight:700;
border:2px solid rgba(0,0,0,.35);user-select:none;flex:none;
text-shadow:0 1px 2px rgba(0,0,0,.5);margin:2px}
#tb-app .tb-cable-hidden{background:#2a3040;border:2px dashed #4a5366;color:#6a7488;font-size:16px}
#tb-app .tb-cable-safe{background:#64748b;color:#fff}
#tb-app .tb-cable-success{background:#16a34a;color:#fff}
#tb-app .tb-cable-exp{background:#dc2626;color:#fff}
#tb-app .tb-cables-row{display:flex;flex-wrap:wrap;gap:4px;margin:6px 0}
#tb-app .tb-pname{font-weight:600;margin-bottom:4px}
#tb-app .tb-identity{border-radius:10px;padding:10px 14px;margin:8px 0;font-weight:600;font-size:14px}
#tb-app .tb-agent{background:#1a2f4a;border:1px solid #3b82f6}
#tb-app .tb-terrorist{background:#3b1515;border:1px solid #dc2626}
#tb-app .tb-banner{background:#3b2f12;border:1px solid #fbbf24;border-radius:10px;
padding:10px 14px;margin:8px 0;font-weight:600}
#tb-app .tb-log{max-height:140px;overflow-y:auto;font-size:13px;color:var(--tmut)}
#tb-app .tb-log div{padding:1px 0}
#tb-app .tb-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
background:#dc2626;color:#fff;padding:10px 18px;border-radius:10px;z-index:99;
box-shadow:0 4px 14px rgba(0,0,0,.4)}
&lt;/style>
&lt;div id="tb-app">
&lt;div id="tb-setup">
&lt;div class="tb-row">&lt;label>Tu nombre: &lt;input id="tb-name" maxlength="14" placeholder="Nombre">&lt;/label>&lt;/div>
&lt;div class="tb-row">&lt;button id="tb-create">💣 Crear sala&lt;/button>&lt;/div>
&lt;div class="tb-row">&lt;input id="tb-code" maxlength="4" placeholder="CÓDIGO" style="text-transform:uppercase;width:110px">&lt;button id="tb-join">Unirse&lt;/button>&lt;/div>
&lt;div id="tb-setupmsg" class="tb-mut">&lt;/div>
&lt;/div>
&lt;div id="tb-game" style="display:none">&lt;/div>
&lt;/div>
&lt;script src="https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js">&lt;/script>
&lt;script>
(function(){
'use strict';
// ---------- constantes ----------
var CABLE_COUNTS={4:{safe:15,success:4,exp:1},5:{safe:19,success:5,exp:1},
6:{safe:23,success:6,exp:1},7:{safe:27,success:7,exp:1},8:{safe:31,success:8,exp:1}};
// pool de identidades (se barajan y se reparten np de ellas; la sobrante se retira sin revelar)
var ID_POOL={4:{a:3,t:2},5:{a:3,t:2},6:{a:4,t:2},7:{a:5,t:3},8:{a:5,t:3}};
var PREFIX='timebomb-xiang-';
var MINP=4,MAXP=8;
// ---------- estado ----------
var peer=null,isHost=false,hostConn=null,roomCode='';
var G=null,V=null,myName='';
function el(id){return document.getElementById(id);}
function esc(s){return String(s).replace(/[&amp;&lt;>"']/g,function(c){
return{'&amp;':'&amp;amp;','&lt;':'&amp;lt;','>':'&amp;gt;','"':'&amp;quot;',"'":'&amp;#39;'}[c];});}
function toast(msg){
var t=document.createElement('div');t.className='tb-toast';t.textContent=msg;
el('tb-app').appendChild(t);setTimeout(function(){t.remove();},3500);}
function setupMsg(m){el('tb-setupmsg').textContent=m;}
function shuffle(arr){
for(var i=arr.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));
var tmp=arr[i];arr[i]=arr[j];arr[j]=tmp;}
return arr;}
// ---------- lógica de juego (host) ----------
function newGame(){
G={phase:'lobby',players:[],cables:[],hands:[],round:0,cutsThisRound:0,
turn:0,log:[],result:null,cutSeq:0,lastCutType:null};}
function startGame(){
var np=G.players.length;
// Roles: se barajan todas las cartas del pool y se reparte una a cada jugador;
// la(s) sobrante(s) quedan fuera sin revelar, creando incertidumbre en mesa.
var pool=ID_POOL[np],roles=[];
for(var i=0;i&lt;pool.a;i++)roles.push('agent');
for(var i=0;i&lt;pool.t;i++)roles.push('terrorist');
shuffle(roles);
roles=roles.slice(0,np); // retira la(s) carta(s) sobrante(s) al azar
// Acorazado: con 50% de probabilidad sustituye un agente al azar
if(Math.random()&lt;0.5){var ai=roles.indexOf('agent');if(ai>=0)roles[ai]='armored';}
G.players.forEach(function(p,i){p.role=roles[i];});
// Cables
var cc=CABLE_COUNTS[np];
G.cables=[];var id=0;
for(var i=0;i&lt;cc.safe;i++)G.cables.push({id:id++,type:'safe',revealed:false});
for(var i=0;i&lt;cc.success;i++)G.cables.push({id:id++,type:'success',revealed:false});
G.cables.push({id:id++,type:'exp',revealed:false});
G.round=1;G.cutsThisRound=0;G.result=null;G.cutSeq=0;G.lastCutType=null;
G.turn=Math.floor(Math.random()*np);
G.log=['💣 ¡Empieza la partida! Ronda 1 · '+np+' jugadores'];
G.phase='playing';
dealRound();broadcast();}
function dealRound(){
var np=G.players.length;
var uncut=G.cables.filter(function(c){return !c.revealed;});
shuffle(uncut);
// uncut.length siempre es divisible por np: ronda 1→5, 2→4, 3→3, 4→2 cartas por jugador
var perP=uncut.length/np;
G.hands=[];
for(var i=0;i&lt;np;i++)
G.hands.push(uncut.slice(i*perP,(i+1)*perP).map(function(c){return c.id;}));}
function pname(i){return G.players[i].name;}
function glog(m){G.log.push(m);if(G.log.length>60)G.log.shift();}
function applyAction(p,a){
if(!G||G.phase!=='playing')return;
if(G.turn!==p)return sendErr(p,'No es tu turno');
if(a.kind==='cut'){
var tgt=a.target;
if(tgt===p)return sendErr(p,'No puedes cortar tus propios cables');
if(tgt&lt;0||tgt>=G.players.length)return;
var uncut=G.hands[tgt].filter(function(cid){return !G.cables[cid].revealed;});
if(!uncut.length)return sendErr(p,'Sin cables disponibles');
var cid=uncut[Math.floor(Math.random()*uncut.length)];
var cable=G.cables[cid];
cable.revealed=true;
G.cutSeq++;G.lastCutType=cable.type;G.cutsThisRound++;
G.turn=tgt; // el cortado es el siguiente en jugar
var label=cable.type==='safe'?'✂️ A salvo':cable.type==='success'?'⚡ ¡Éxito!':'💥 ¡EXPLOSIÓN!';
glog(pname(p)+' → '+pname(tgt)+': '+label);
// Comprobar victoria
if(cable.type==='exp'){
if(G.players[p].role==='armored'){
G.phase='ended';G.result={winner:'agents',reason:'armored'};
glog('🛡️ '+pname(p)+' es el Acorazado y cortó la bomba. ¡Los agentes ganan!');
}else{
G.phase='ended';G.result={winner:'terrorists',reason:'explosion'};
glog('💥 ¡La bomba explota! Los terroristas ganan.');}
return broadcast();
}
var sc=G.cables.filter(function(c){return c.revealed&amp;&amp;c.type==='success';}).length;
if(sc>=CABLE_COUNTS[G.players.length].success){
G.phase='ended';G.result={winner:'agents',reason:'success'};
glog('⚡ ¡Todos los cables de éxito cortados! Los agentes ganan.');
return broadcast();
}
// Fin de ronda
if(G.cutsThisRound>=G.players.length){
if(G.round>=4){
G.phase='ended';G.result={winner:'terrorists',reason:'timeout'};
glog('⏰ Fin de la última ronda. Los terroristas ganan.');
}else{
G.round++;G.cutsThisRound=0;
dealRound();
glog('🔄 Ronda '+G.round+' — los cables se barajan sin revelar.');}
}
broadcast();
}
}
function sendErr(p,msg){
if(p===0)toast(msg);
else if(G.players[p].conn)G.players[p].conn.send({t:'error',msg:msg});}
function viewFor(i){
var np=G.players.length;
var sc=G.phase!=='lobby'?G.cables.filter(function(c){return c.revealed&amp;&amp;c.type==='success';}).length:0;
var ts=np>=MINP?CABLE_COUNTS[np].success:0;
return{
phase:G.phase,me:i,myRole:G.players[i].role||null,
turn:G.turn,round:G.round,cutsThisRound:G.cutsThisRound,cutsPerRound:np,
result:G.result,
names:G.players.map(function(p){return p.name;}),
connected:G.players.map(function(p){return p.connected;}),
roles:G.result?G.players.map(function(p){return p.role;}):null,
hands:G.hands.length?G.hands.map(function(hand,j){
return hand.map(function(cid){
var c=G.cables[cid];
if(c.revealed)return{id:cid,revealed:true,type:c.type};
if(j===i)return{id:cid,revealed:false,type:c.type,own:true};
return{id:cid,revealed:false,type:null};});}):
G.players.map(function(){return[];}),
log:G.log.slice(-30),
successCut:sc,totalSuccess:ts,
cutSeq:G.cutSeq,lastCutType:G.lastCutType};}
function broadcast(){
G.players.forEach(function(p,i){
if(i===0)return;
if(p.conn&amp;&amp;p.connected)p.conn.send({t:'state',s:viewFor(i)});});
V=viewFor(0);render();}
// ---------- red ----------
function setupHostConn(c){
c.on('data',function(msg){
if(!msg||typeof msg!=='object')return;
if(msg.t==='join'){
var name=String(msg.name||'???').slice(0,14);
if(G.phase!=='lobby'){
var old=G.players.findIndex(function(p){return !p.connected&amp;&amp;p.name===name;});
if(old>=0){G.players[old].conn=c;G.players[old].connected=true;
c._hIdx=old;glog('🔌 '+name+' ha vuelto');broadcast();}
else c.send({t:'error',msg:'Partida en curso, no puedes entrar'});
return;}
if(G.players.length>=MAXP)return c.send({t:'error',msg:'Sala llena (máx 8)'});
while(G.players.some(function(p){return p.name===name;}))name+='2';
G.players.push({name:name,conn:c,connected:true,role:null});
c._hIdx=G.players.length-1;
broadcast();
}else if(msg.t==='action'&amp;&amp;typeof c._hIdx==='number'){
applyAction(c._hIdx,msg.a);}});
c.on('close',function(){
if(typeof c._hIdx!=='number')return;
if(G.phase==='lobby'){G.players.splice(c._hIdx,1);
G.players.forEach(function(p,i){if(p.conn)p.conn._hIdx=i;});}
else{G.players[c._hIdx].connected=false;glog('🔌 '+pname(c._hIdx)+' se ha desconectado');}
broadcast();});}
function createRoom(){
myName=el('tb-name').value.trim();
if(!myName)return setupMsg('Pon tu nombre primero');
var alpha='ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
roomCode='';for(var i=0;i&lt;4;i++)roomCode+=alpha[Math.floor(Math.random()*alpha.length)];
setupMsg('Creando sala...');
peer=new Peer(PREFIX+roomCode);
peer.on('open',function(){
isHost=true;newGame();
G.players.push({name:myName,conn:null,connected:true,role:null});
history.pushState(null,'',location.pathname+'?sala='+roomCode);
el('tb-setup').style.display='none';el('tb-game').style.display='block';
broadcast();});
peer.on('connection',setupHostConn);
peer.on('error',function(e){
if(e.type==='unavailable-id')setupMsg('Código ocupado, prueba otra vez');
else setupMsg('Error de conexión: '+e.type);});}
function joinRoom(){
myName=el('tb-name').value.trim();
roomCode=el('tb-code').value.trim().toUpperCase();
if(!myName)return setupMsg('Pon tu nombre primero');
if(roomCode.length!==4)return setupMsg('El código tiene 4 letras');
setupMsg('Conectando...');
peer=new Peer();
peer.on('open',function(){
hostConn=peer.connect(PREFIX+roomCode,{reliable:true});
hostConn.on('open',function(){hostConn.send({t:'join',name:myName});});
hostConn.on('data',function(msg){
if(!msg)return;
if(msg.t==='state'){
V=msg.s;
el('tb-setup').style.display='none';el('tb-game').style.display='block';
render();
}else if(msg.t==='error'){V?toast(msg.msg):setupMsg(msg.msg);}});
hostConn.on('close',function(){toast('Se ha perdido la conexión con el anfitrión 😢');});});
peer.on('error',function(e){
if(e.type==='peer-unavailable')setupMsg('No existe la sala '+roomCode);
else setupMsg('Error de conexión: '+e.type);});}
function sendAction(a){
if(isHost)applyAction(0,a);
else hostConn.send({t:'action',a:a});}
// ---------- sonidos ----------
var FAIL_SOUNDS=[
'https://www.myinstants.com/media/sounds/jixaw-metal-pipe-falling-sound.mp3',
'https://www.myinstants.com/media/sounds/fahhhh-6.mp3',
'https://www.myinstants.com/media/sounds/wrong_5.mp3',
'https://www.myinstants.com/media/sounds/oh-no.mp3',
'https://www.myinstants.com/media/sounds/movie_1.mp3',
'https://www.myinstants.com/media/sounds/el-diablo_MicOe0x.mp3'];
var SUCCESS_SOUNDS=[
'https://www.myinstants.com/media/sounds/answer-correct.mp3',
'https://www.myinstants.com/media/sounds/kids-saying-yay-sound-effect_3.mp3',
'https://www.myinstants.com/media/sounds/anime-wow-sound-effect.mp3',
'https://www.myinstants.com/media/sounds/yes-yes-yes-yes-yes.mp3',
'https://www.myinstants.com/media/sounds/yippeeeeeeeeeeeeee.mp3'];
function playAt(arr,idx){if(!arr.length)return;
try{var a=new Audio(arr[idx%arr.length]);a.volume=0.3;a.play().catch(function(){});}catch(e){}}
var lastCutSeq=-1;
function checkSounds(){
if(!V||typeof V.cutSeq!=='number')return;
if(lastCutSeq>=0&amp;&amp;V.cutSeq>lastCutSeq){
if(V.lastCutType==='exp')playAt(FAIL_SOUNDS,Math.floor(Math.random()*FAIL_SOUNDS.length));
else if(V.lastCutType==='success')playAt(SUCCESS_SOUNDS,Math.floor(Math.random()*SUCCESS_SOUNDS.length));
}
lastCutSeq=V.cutSeq;}
// ---------- render ----------
function cableHtml(c){
if(!c.revealed&amp;&amp;!c.own)return '&lt;div class="tb-cable tb-cable-hidden">?&lt;/div>';
var cls=c.type==='safe'?'tb-cable-safe':c.type==='success'?'tb-cable-success':'tb-cable-exp';
var lbl=c.type==='safe'?'✓':c.type==='success'?'⚡':'💥';
var title=c.type==='safe'?'A salvo':c.type==='success'?'Exito':'Explosion';
var extra=(!c.revealed&amp;&amp;c.own)?'opacity:.45':'';
return '&lt;div class="tb-cable '+cls+'" style="'+extra+'" title="'+title+'">'+lbl+'&lt;/div>';}
function render(){
if(!V)return;
var np=V.names.length;
var h='';
h+='&lt;div class="tb-row">Sala: &lt;span class="tb-code">'+roomCode+'&lt;/span> '+
'&lt;button data-act="copy">📋 Copiar enlace&lt;/button>&lt;/div>';
if(V.phase==='lobby'){
h+='&lt;div class="tb-panel">&lt;h3>Jugadores ('+np+'/8, mín 4)&lt;/h3>';
V.names.forEach(function(n,i){
h+='&lt;div>'+esc(n)+(i===0?' 👑':'')+(i===V.me?' (tú)':'')+'&lt;/div>';});
h+='&lt;/div>';
if(V.me===0)
h+='&lt;button data-act="start"'+(np&lt;MINP?' disabled':'')+'>💣 Empezar ('+
(np&lt;MINP?'mínimo 4':np+' jugadores')+')&lt;/button>';
else h+='&lt;div class="tb-mut">Esperando a que '+esc(V.names[0])+' empiece...&lt;/div>';
}else{
// Banner de identidad
if(V.myRole){
var rc=V.myRole==='terrorist'?'tb-terrorist':'tb-agent';
var re=V.myRole==='armored'?'🛡️':V.myRole==='agent'?'🕵️':'💣';
var rn=V.myRole==='armored'?'Acorazado':V.myRole==='agent'?'Agente':'Terrorista';
var hint=V.myRole==='armored'
?' — Si TÚ cortas la explosión 💥, ¡los agentes ganan!'
:V.myRole==='agent'
?' — Cortad todos los cables ⚡ antes de que acaben las rondas'
:' — Haz que corten la explosión 💥 o sobrevive hasta el final';
h+='&lt;div class="tb-identity '+rc+'">'+re+' Eres &lt;b>'+rn+'&lt;/b>'+hint+'&lt;/div>';
}
// Banner de fin
if(V.phase==='ended'){
var r=V.result;
var endMsg=r.reason==='armored'?'🛡️ ¡El Acorazado cortó la bomba! Los agentes ganan.':
r.winner==='agents'?'⚡ ¡Los agentes ganan! Todos los éxitos cortados.':
r.reason==='explosion'?'💥 ¡BOOM! Los terroristas ganan.':
'⏰ Se acaban las rondas. Los terroristas ganan.';
h+='&lt;div class="tb-banner">'+endMsg;
if(V.roles){
h+='&lt;div style="margin-top:8px;font-size:13px">';
V.names.forEach(function(n,i){
var badge=V.roles[i]==='armored'?'🛡️ Acorazado':V.roles[i]==='agent'?'🕵️ Agente':'💣 Terrorista';
h+='&lt;span style="margin-right:12px">'+esc(n)+': '+badge+'&lt;/span>';});
h+='&lt;/div>';}
if(V.me===0)h+=' &lt;button data-act="start" style="margin-top:6px">🔄 Otra partida&lt;/button>';
h+='&lt;/div>';
}
// Estado
h+='&lt;div class="tb-panel">&lt;div class="tb-row">⚡ Éxitos: &lt;b>'+V.successCut+'/'+V.totalSuccess+
'&lt;/b> &amp;nbsp; 🔄 Ronda: &lt;b>'+V.round+'/4&lt;/b>'+
' &amp;nbsp; ✂️ Cortes: &lt;b>'+V.cutsThisRound+'/'+V.cutsPerRound+'&lt;/b>&lt;/div>&lt;/div>';
// Manos de los jugadores
V.names.forEach(function(n,i){
var isTurn=V.phase==='playing'&amp;&amp;V.turn===i;
var canCut=V.phase==='playing'&amp;&amp;V.turn===V.me&amp;&amp;i!==V.me;
var hand=V.hands[i]||[];
var hasUncut=hand.some(function(c){return !c.revealed;});
h+='&lt;div class="tb-panel'+(isTurn?' tb-turn':'')+'">'+
'&lt;div class="tb-pname">'+(isTurn?'▶ ':'')+esc(n)+(i===V.me?' (tú)':'')+
(!V.connected[i]?' 🔌❌':'')+'&lt;/div>'+
'&lt;div class="tb-cables-row">';
hand.forEach(function(c){h+=cableHtml(c);});
if(!hand.length)h+='&lt;span class="tb-mut">Sin cables esta ronda&lt;/span>';
h+='&lt;/div>';
if(canCut&amp;&amp;hasUncut)
h+='&lt;button data-act="cut" data-target="'+i+'">✂️ Cortar cable aleatorio&lt;/button>';
h+='&lt;/div>';
});
// Indicación de turno
if(V.phase==='playing')
h+='&lt;div class="tb-mut">'+(V.turn===V.me
?'✂️ Tu turno: elige a quién cortarle un cable'
:'Turno de '+esc(V.names[V.turn])+'...')+'&lt;/div>';
// Log
h+='&lt;div class="tb-panel tb-log">'+V.log.slice().reverse().map(function(l){
return'&lt;div>'+esc(l)+'&lt;/div>';}).join('')+'&lt;/div>';
}
el('tb-game').innerHTML=h;
checkSounds();}
// ---------- eventos ----------
el('tb-create').addEventListener('click',createRoom);
el('tb-join').addEventListener('click',joinRoom);
el('tb-game').addEventListener('click',function(e){
var b=e.target.closest('[data-act]');if(!b)return;
var act=b.getAttribute('data-act');
if(act==='copy'){
navigator.clipboard.writeText(location.origin+location.pathname+'?sala='+roomCode)
.then(function(){toast('Enlace copiado 📋');});
}else if(act==='start'&amp;&amp;isHost){startGame();}
else if(act==='cut'){sendAction({kind:'cut',target:+b.getAttribute('data-target')});}
});
// Código por URL: ?sala=ABCD
var m=location.search.match(/sala=([A-Za-z0-9]{4})/);
if(m)el('tb-code').value=m[1].toUpperCase();
})();
&lt;/script>
&lt;h2 id="cómo-se-juega">¿Cómo se juega?&lt;/h2>
&lt;p>Time Bomb es un juego de &lt;strong>deducción social&lt;/strong>: los &lt;strong>agentes&lt;/strong> intentan cortar todos los cables de éxito, los &lt;strong>terroristas&lt;/strong> intentan sabotearlos.&lt;/p></description></item><item><title>🎆 Hanabi</title><link>https://xiang.es/games/hanabi/</link><pubDate>Thu, 11 Jun 2026 00:00:00 +0000</pubDate><guid>https://xiang.es/games/hanabi/</guid><description>&lt;p>Juego cooperativo de cartas para 2-5 jugadores. Uno crea la sala y comparte el código (o el enlace) con los demás. La partida vive en el navegador del anfitrión: si lo cierra, se acaba la fiesta.&lt;/p>
&lt;style>
#hanabi-app{--hbg:#1b1f27;--hpanel:#252b36;--htext:#e8eaf0;--hmut:#9aa3b2;
background:var(--hbg);color:var(--htext);border-radius:12px;padding:16px;
font-family:system-ui,sans-serif;font-size:15px;line-height:1.4}
#hanabi-app h3{margin:10px 0 6px;color:var(--htext)}
#hanabi-app input{background:#11141a;color:var(--htext);border:1px solid #3a4252;
border-radius:8px;padding:8px 10px;font-size:15px;max-width:160px}
#hanabi-app button{background:#3b82f6;color:#fff;border:0;border-radius:8px;
padding:8px 14px;font-size:14px;cursor:pointer;margin:2px}
#hanabi-app button:disabled{background:#3a4252;color:var(--hmut);cursor:not-allowed}
#hanabi-app button.h-red{background:#dc2626}
#hanabi-app .h-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}
#hanabi-app .h-panel{background:var(--hpanel);border-radius:10px;padding:10px 12px;margin:8px 0}
#hanabi-app .hcard{width:48px;height:66px;border-radius:8px;display:inline-flex;
align-items:center;justify-content:center;font-size:26px;font-weight:700;
cursor:pointer;border:2px solid rgba(0,0,0,.35);user-select:none;color:#fff;
text-shadow:0 1px 2px rgba(0,0,0,.5);position:relative;flex:none}
#hanabi-app .hcard.h-sel{outline:3px solid #fff;outline-offset:2px}
#hanabi-app .hcard.h-noclick{cursor:default}
#hanabi-app .h-cardwrap{display:flex;flex-direction:column;align-items:center;gap:3px}
#hanabi-app .h-new{animation:h-deal .45s ease}
@keyframes h-deal{from{transform:translateX(-50px) scale(.5);opacity:0}to{transform:none;opacity:1}}
#hanabi-app .h-hintglow{animation:h-halo 1.6s ease-out}
@keyframes h-halo{0%,100%{box-shadow:0 0 0 0 rgba(251,191,36,0)}
25%,75%{box-shadow:0 0 16px 7px rgba(251,191,36,.95)}
50%{box-shadow:0 0 6px 2px rgba(251,191,36,.4)}}
#hanabi-app .h-fan{display:flex;align-items:center;flex-wrap:wrap;margin-left:14px;
padding-left:10px;border-left:1px solid #3a4252;min-height:60px}
#hanabi-app .h-disc{width:32px;height:44px;border-radius:6px;display:inline-flex;
align-items:center;justify-content:center;font-size:16px;font-weight:700;color:#fff;
border:1.5px solid rgba(0,0,0,.35);margin-left:-13px;flex:none;
text-shadow:0 1px 2px rgba(0,0,0,.5)}
#hanabi-app .h-disc:first-child{margin-left:0}
#hanabi-app .h-discempty{background:transparent;border:1.5px dashed #4a5366;color:#6a7488;font-size:18px}
#hanabi-app .h-mini{font-size:12px;color:var(--hmut);letter-spacing:1px}
#hanabi-app .h-pile{width:44px;height:60px;border-radius:8px;display:inline-flex;
align-items:center;justify-content:center;font-size:22px;font-weight:700;color:#fff;
opacity:.95;border:2px dashed rgba(255,255,255,.25)}
#hanabi-app .h-turn{box-shadow:0 0 0 2px #fbbf24 inset;border-radius:10px}
#hanabi-app .h-name{font-weight:600;margin-bottom:4px}
#hanabi-app .h-log{max-height:140px;overflow-y:auto;font-size:13px;color:var(--hmut)}
#hanabi-app .h-log div{padding:1px 0}
#hanabi-app .h-banner{background:#3b2f12;border:1px solid #fbbf24;border-radius:10px;
padding:10px 14px;margin:8px 0;font-weight:600}
#hanabi-app .h-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
background:#dc2626;color:#fff;padding:10px 18px;border-radius:10px;z-index:99;
box-shadow:0 4px 14px rgba(0,0,0,.4)}
#hanabi-app .h-code{font-size:22px;font-weight:800;letter-spacing:4px;color:#fbbf24}
#hanabi-app .h-mut{color:var(--hmut);font-size:13px}
#hanabi-app .h-noob{display:flex;flex-direction:column;align-items:center;gap:2px;margin-top:3px}
#hanabi-app .h-noob-colors{display:flex;gap:3px}
#hanabi-app .h-noob-dot{width:9px;height:9px;border-radius:50%;flex:none}
#hanabi-app .h-noob-nums{font-size:11px;color:var(--hmut);letter-spacing:1px;line-height:1}
#hanabi-app button.h-noob-toggle{background:#2d3748;font-size:13px;padding:5px 10px}
#hanabi-app button.h-noob-toggle.h-active{background:#4a3728;border:1px solid #f59e0b}
#hanabi-app .h-hinthover{box-shadow:0 0 16px 6px rgba(251,191,36,.8);transition:box-shadow .15s}
#hanabi-app .h-intent{position:absolute;top:-9px;right:-7px;font-size:13px;line-height:1;pointer-events:none}
&lt;/style>
&lt;div id="hanabi-app">
&lt;div id="h-setup">
&lt;div class="h-row">&lt;label>Tu nombre: &lt;input id="h-name" maxlength="14" placeholder="Nombre">&lt;/label>&lt;/div>
&lt;div class="h-row">&lt;button id="h-create">🎇 Crear sala&lt;/button>&lt;/div>
&lt;div class="h-row">&lt;input id="h-code" maxlength="4" placeholder="CÓDIGO" style="text-transform:uppercase;width:110px">&lt;button id="h-join">Unirse&lt;/button>&lt;/div>
&lt;div id="h-setupmsg" class="h-mut">&lt;/div>
&lt;/div>
&lt;div id="h-game" style="display:none">&lt;/div>
&lt;/div>
&lt;script src="https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js">&lt;/script>
&lt;script>
(function(){
'use strict';
// ---------- constantes ----------
var COLORS=['R','G','B','Y','W'];
var CINFO={R:{name:'Rojo',bg:'#dc2626',em:'🔴'},G:{name:'Verde',bg:'#16a34a',em:'🟢'},
B:{name:'Azul',bg:'#2563eb',em:'🔵'},Y:{name:'Amarillo',bg:'#ca8a04',em:'🟡'},
W:{name:'Blanco',bg:'#64748b',em:'⚪'}};
var PREFIX='hanabi-xiang-';
var MAXP=5;
var TOTALS={1:3,2:2,3:2,4:2,5:1};
// ---------- estado ----------
var peer=null,isHost=false,hostConn=null,roomCode='';
var G=null; // estado completo (solo host)
var V=null; // vista personalizada (lo que se renderiza)
var sel=null; // carta seleccionada {pl, idx}
var myName='';
var noobMode=false;
function el(id){return document.getElementById(id);}
function esc(s){return String(s).replace(/[&amp;&lt;>"']/g,function(c){
return {'&amp;':'&amp;amp;','&lt;':'&amp;lt;','>':'&amp;gt;','"':'&amp;quot;',"'":'&amp;#39;'}[c];});}
function toast(msg){
var t=document.createElement('div');t.className='h-toast';t.textContent=msg;
el('hanabi-app').appendChild(t);setTimeout(function(){t.remove();},3500);
}
function cardTxt(c){return CINFO[c.c].em+c.n;}
// ---------- lógica de juego (host) ----------
function mkDeck(){
var d=[],counts=[[1,3],[2,2],[3,2],[4,2],[5,1]];
COLORS.forEach(function(c){counts.forEach(function(p){
for(var i=0;i&lt;p[1];i++)d.push({c:c,n:p[0],kc:false,kn:false,notC:[],notN:[]});});});
for(var i=d.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));
var t=d[i];d[i]=d[j];d[j]=t;}
// ids tras barajar: que el id no delate la carta
d.forEach(function(c,k){c.id=k;});
return d;
}
function newGame(){
G={phase:'lobby',players:[],hands:[],deck:[],piles:{},pileTops:{},discard:[],
hints:8,fuses:3,turn:0,turnsLeft:null,justEmptied:false,log:[],result:null,
failSeq:0,failIdx:0,successSeq:0,successIdx:0,hintSeq:0,hintCids:[],intentions:{}};
}
function startGame(){
G.deck=mkDeck();G.discard=[];G.hints=8;G.fuses=3;G.turnsLeft=null;
G.justEmptied=false;G.result=null;G.turn=0;G.log=['🎆 Empieza la partida'];
G.pileTops={};G.failSeq=0;G.failIdx=0;G.successSeq=0;G.successIdx=0;G.hintSeq=0;G.hintCids=[];G.intentions={};
COLORS.forEach(function(c){G.piles[c]=0;});
var hs=G.players.length&lt;=3?5:4;
G.hands=G.players.map(function(){return [];});
for(var k=0;k&lt;hs;k++)G.players.forEach(function(_,i){G.hands[i].push(G.deck.pop());});
G.phase='playing';
broadcast();
}
function pname(i){return G.players[i].name;}
function glog(m){G.log.push(m);if(G.log.length>60)G.log.shift();}
function checkLostForever(card){
if(G.phase!=='playing')return;
if(card.n&lt;=G.piles[card.c])return;
var disc=G.discard.filter(function(d){return d.c===card.c&amp;&amp;d.n===card.n;}).length;
if(disc>=TOTALS[card.n]){
G.failSeq++;G.failIdx=Math.floor(Math.random()*Math.max(1,FAIL_SOUNDS.length));
glog('🚨 ¡'+cardTxt(card)+' perdida para siempre!');
finish('lost');
}
}
function draw(p){
if(!G.deck.length)return;
G.hands[p].unshift(G.deck.pop()); // la carta nueva entra por la izquierda
if(!G.deck.length){G.turnsLeft=G.players.length;G.justEmptied=true;
glog('🂠 Se ha robado la última carta: una ronda final');}
}
function finish(reason){
G.phase='ended';
var score=COLORS.reduce(function(s,c){return s+G.piles[c];},0);
if(reason==='boom')score=0;
G.result={reason:reason,score:score};
glog(reason==='boom'?'💥 ¡Tercer fallo! Los fuegos artificiales explotan':
reason==='win'?'🎆 ¡Espectáculo perfecto!':
reason==='lost'?'💀 Carta irrecuperable — la partida es imposible':'🏁 Fin de la partida');
}
function sendErr(p,msg){
if(p===0)toast(msg);
else if(G.players[p].conn)G.players[p].conn.send({t:'error',msg:msg});
}
function endTurn(){
if(G.turnsLeft!==null&amp;&amp;!G.justEmptied){
G.turnsLeft--;
if(G.turnsLeft&lt;=0){finish('deck');return;}
}
G.justEmptied=false;
G.turn=(G.turn+1)%G.players.length;
}
function applyAction(p,a){
if(!G||G.phase!=='playing')return;
if(a.kind==='intent'){
if(!G.intentions[p])G.intentions[p]={};
if(a.value===null||a.value===undefined)delete G.intentions[p][a.cid];
else G.intentions[p][a.cid]=a.value;
broadcast();return;
}
if(G.turn!==p)return sendErr(p,'No es tu turno');
var hand=G.hands[p],card;
if(a.kind==='play'){
if(!hand[a.idx])return;
card=hand.splice(a.idx,1)[0];
if(G.intentions[p])delete G.intentions[p][card.id];
if(G.piles[card.c]===card.n-1){
G.piles[card.c]=card.n;G.pileTops[card.c]=card.id;
if(card.n===5){if(G.hints&lt;8)G.hints++;G.successSeq++;G.successIdx=Math.floor(Math.random()*Math.max(1,SUCCESS_SOUNDS.length));}
glog(pname(p)+' juega '+cardTxt(card)+' ✔');
if(COLORS.every(function(c){return G.piles[c]===5;})){finish('win');return broadcast();}
}else{
G.fuses--;G.discard.push(card);G.failSeq++;G.failIdx=Math.floor(Math.random()*Math.max(1,FAIL_SOUNDS.length));
glog('💥 '+pname(p)+' intenta jugar '+cardTxt(card)+' y falla ('+G.fuses+' mechas)');
checkLostForever(card);
if(G.phase!=='playing'){return broadcast();}
if(G.fuses===0){finish('boom');return broadcast();}
}
draw(p);
}else if(a.kind==='discard'){
if(G.hints>=8)return sendErr(p,'Ya tenéis las 8 fichas de pista');
if(!hand[a.idx])return;
card=hand.splice(a.idx,1)[0];
if(G.intentions[p])delete G.intentions[p][card.id];
G.discard.push(card);G.hints++;
if(G.piles[card.c]===card.n-1){G.failSeq++;G.failIdx=Math.floor(Math.random()*Math.max(1,FAIL_SOUNDS.length));}
glog(pname(p)+' descarta '+cardTxt(card));
checkLostForever(card);
if(G.phase!=='playing'){return broadcast();}
draw(p);
}else if(a.kind==='hint'){
if(G.hints&lt;=0)return sendErr(p,'No quedan fichas de pista');
if(a.target===p||!G.hands[a.target])return;
var matches=G.hands[a.target].filter(function(c){
return a.htype==='color'?c.c===a.value:c.n===a.value;});
if(!matches.length)return sendErr(p,'La pista debe señalar al menos una carta');
G.hands[a.target].forEach(function(c){
if(a.htype==='color'){
if(c.c===a.value)c.kc=true;
else if(c.notC.indexOf(a.value)===-1)c.notC.push(a.value);
}else{
if(c.n===a.value)c.kn=true;
else if(c.notN.indexOf(a.value)===-1)c.notN.push(a.value);
}
});
G.hints--;
G.hintSeq++;G.hintCids=matches.map(function(c){return c.id;});
var what=a.htype==='color'?CINFO[a.value].em+' '+CINFO[a.value].name:'número '+a.value;
glog(pname(p)+' → '+pname(a.target)+': '+what+' ('+matches.length+' carta'+(matches.length>1?'s':'')+')');
}else return;
endTurn();
broadcast();
}
function viewFor(i){
return {
phase:G.phase,me:i,turn:G.turn,hints:G.hints,fuses:G.fuses,
deckCount:G.deck.length,turnsLeft:G.turnsLeft,result:G.result,
failSeq:G.failSeq,failIdx:G.failIdx,successSeq:G.successSeq,successIdx:G.successIdx,hintSeq:G.hintSeq,hintCids:G.hintCids.slice(),
piles:JSON.parse(JSON.stringify(G.piles)),
pileTops:JSON.parse(JSON.stringify(G.pileTops)),
discard:G.discard.map(function(c){return {id:c.id,c:c.c,n:c.n};}),
log:G.log.slice(-30),
names:G.players.map(function(p){return p.name;}),
connected:G.players.map(function(p){return p.connected;}),
hands:G.hands.map(function(h,j){return h.map(function(c){
if(j===i)return {id:c.id,c:c.kc?c.c:null,n:c.kn?c.n:null,kc:c.kc,kn:c.kn,notC:c.notC,notN:c.notN};
return {id:c.id,c:c.c,n:c.n,kc:c.kc,kn:c.kn};}); }),
intentions:JSON.parse(JSON.stringify(G.intentions))
};
}
function broadcast(){
G.players.forEach(function(p,i){
if(i===0)return;
if(p.conn&amp;&amp;p.connected)p.conn.send({t:'state',s:viewFor(i)});
});
V=viewFor(0);sel=null;render();
}
// ---------- red ----------
function setupHostConn(c){
c.on('data',function(msg){
if(!msg||typeof msg!=='object')return;
if(msg.t==='join'){
var name=String(msg.name||'???').slice(0,14);
if(G.phase!=='lobby'){
var old=G.players.findIndex(function(p){return !p.connected&amp;&amp;p.name===name;});
if(old>=0){G.players[old].conn=c;G.players[old].connected=true;
c._hIdx=old;glog('🔌 '+name+' ha vuelto');broadcast();}
else c.send({t:'error',msg:'Partida en curso, no puedes entrar'});
return;
}
if(G.players.length>=MAXP)return c.send({t:'error',msg:'Sala llena (máx 5)'});
while(G.players.some(function(p){return p.name===name;}))name+='2';
G.players.push({name:name,conn:c,connected:true});
c._hIdx=G.players.length-1;
broadcast();
}else if(msg.t==='action'&amp;&amp;typeof c._hIdx==='number'){
applyAction(c._hIdx,msg.a);
}
});
c.on('close',function(){
if(typeof c._hIdx!=='number')return;
if(G.phase==='lobby'){G.players.splice(c._hIdx,1);
G.players.forEach(function(p,i){if(p.conn)p.conn._hIdx=i;});}
else{G.players[c._hIdx].connected=false;
glog('🔌 '+pname(c._hIdx)+' se ha desconectado');}
broadcast();
});
}
function createRoom(){
myName=el('h-name').value.trim();
if(!myName)return setupMsg('Pon tu nombre primero');
var alpha='ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
roomCode='';for(var i=0;i&lt;4;i++)roomCode+=alpha[Math.floor(Math.random()*alpha.length)];
setupMsg('Creando sala...');
peer=new Peer(PREFIX+roomCode);
peer.on('open',function(){
isHost=true;newGame();
G.players.push({name:myName,conn:null,connected:true});
history.pushState(null,'',location.pathname+'?sala='+roomCode);
el('h-setup').style.display='none';el('h-game').style.display='block';
broadcast();
});
peer.on('connection',setupHostConn);
peer.on('error',function(e){
if(e.type==='unavailable-id')setupMsg('Código ocupado, prueba otra vez');
else setupMsg('Error de conexión: '+e.type);
});
}
function joinRoom(){
myName=el('h-name').value.trim();
roomCode=el('h-code').value.trim().toUpperCase();
if(!myName)return setupMsg('Pon tu nombre primero');
if(roomCode.length!==4)return setupMsg('El código tiene 4 letras');
setupMsg('Conectando...');
peer=new Peer();
peer.on('open',function(){
hostConn=peer.connect(PREFIX+roomCode,{reliable:true});
hostConn.on('open',function(){hostConn.send({t:'join',name:myName});});
hostConn.on('data',function(msg){
if(!msg)return;
if(msg.t==='state'){
V=msg.s;sel=null;
el('h-setup').style.display='none';el('h-game').style.display='block';
render();
}else if(msg.t==='error'){V?toast(msg.msg):setupMsg(msg.msg);}
});
hostConn.on('close',function(){toast('Se ha perdido la conexión con el anfitrión 😢');});
});
peer.on('error',function(e){
if(e.type==='peer-unavailable')setupMsg('No existe la sala '+roomCode);
else setupMsg('Error de conexión: '+e.type);
});
}
function sendAction(a){
if(isHost)applyAction(0,a);
else hostConn.send({t:'action',a:a});
sel=null;render();
}
function sendIntent(idx,value){
var cid=V.hands[V.me][idx].id;
var a={kind:'intent',cid:cid,value:value};
if(isHost)applyAction(0,a);
else hostConn.send({t:'action',a:a});
sel=null;render();
}
function setupMsg(m){el('h-setupmsg').textContent=m;}
// ---------- render ----------
function cardHtml(card,pl,idx,clickable){
var bg=card.c?CINFO[card.c].bg:'#3a4252';
var label=card.n!==null&amp;&amp;card.n!==undefined?card.n:'?';
var cls='hcard'+(sel&amp;&amp;sel.pl===pl&amp;&amp;sel.idx===idx?' h-sel':'')+(clickable?'':' h-noclick');
var mini=(card.kc&amp;&amp;card.c?CINFO[card.c].em:'·')+' '+(card.kn?card.n:'·');
var isOwn=pl===V.me;
var extra='';
if(isOwn&amp;&amp;noobMode){
var nc=card.notC||[],nn=card.notN||[];
var posC=card.kc&amp;&amp;card.c?[card.c]:COLORS.filter(function(c){return nc.indexOf(c)===-1;});
var posN=card.kn&amp;&amp;card.n?[card.n]:[1,2,3,4,5].filter(function(n){return nn.indexOf(n)===-1;});
var dots=posC.map(function(c){return '&lt;div class="h-noob-dot" style="background:'+CINFO[c].bg+'" title="'+CINFO[c].name+'">&lt;/div>';}).join('');
extra='&lt;div class="h-noob">&lt;div class="h-noob-colors">'+dots+'&lt;/div>'+
'&lt;div class="h-noob-nums">'+posN.join('')+'&lt;/div>&lt;/div>';
}
var intent=V.intentions&amp;&amp;V.intentions[pl]&amp;&amp;V.intentions[pl][card.id];
var intentBadge=intent==='play'?'&lt;span class="h-intent">🎇&lt;/span>':intent==='discard'?'&lt;span class="h-intent">🗑️&lt;/span>':'';
return '&lt;div class="h-cardwrap" data-cid="'+card.id+'">&lt;div class="'+cls+'" data-pl="'+pl+'" data-idx="'+idx+
'" style="background:'+bg+'">'+label+intentBadge+'&lt;/div>'+
(!isOwn?'&lt;div class="h-mini" title="Lo que sabe">'+mini+'&lt;/div>':extra)+'&lt;/div>';
}
var FAIL_SOUNDS=[
'https://www.myinstants.com/media/sounds/jixaw-metal-pipe-falling-sound.mp3',
'https://www.myinstants.com/media/sounds/fahhhh-6.mp3',
'https://www.myinstants.com/media/sounds/wrong_5.mp3',
'https://www.myinstants.com/media/sounds/faaah.mp3',
'https://www.myinstants.com/media/sounds/oh-no.mp3',
'https://www.myinstants.com/media/sounds/movie_1.mp3',
'https://www.myinstants.com/media/sounds/el-diablo_MicOe0x.mp3',
'https://www.myinstants.com/media/sounds/lego-yoda-death-sound.mp3',
'https://www.myinstants.com/media/sounds/dio-wryyy.mp3',
'https://www.myinstants.com/media/sounds/shizaaaaaa.mp3',
'https://www.myinstants.com/media/sounds/jotaro-no.mp3',
'https://www.myinstants.com/media/sounds/preview_4.mp3',
'https://www.myinstants.com/media/sounds/sound-fail-fallo.mp3',
'https://www.myinstants.com/media/sounds/mission-failed-well-get-em-next-time.mp3',
'https://www.myinstants.com/media/sounds/defeat_90KWHvE.mp3',
'https://www.myinstants.com/media/sounds/ping_missing.mp3',
'https://www.myinstants.com/media/sounds/among-us-role-reveal-sound.mp3',
'https://www.myinstants.com/media/sounds/y-se-marcho_VhXz4Hd.mp3',
'https://www.myinstants.com/media/sounds/penalti-a-favor-del-real-madrid-jajaja.mp3',
'https://www.myinstants.com/media/sounds/no-no-no-la-policia.mp3',
];
var SUCCESS_SOUNDS=[
'https://www.myinstants.com/media/sounds/answer-correct.mp3',
'https://www.myinstants.com/media/sounds/click-nice.mp3',
'https://www.myinstants.com/media/sounds/kids-saying-yay-sound-effect_3.mp3',
'https://www.myinstants.com/media/sounds/anime-wow-sound-effect.mp3',
'https://www.myinstants.com/media/sounds/789-audio-extractor.mp3',
'https://www.myinstants.com/media/sounds/fairy-dust-sound-effect.mp3',
'https://www.myinstants.com/media/sounds/yes-yes-yes-yes-yes.mp3',
'https://www.myinstants.com/media/sounds/michael-jackson-hee-hee.mp3',
'https://www.myinstants.com/media/sounds/mercadona.mp3',
'https://www.myinstants.com/media/sounds/yippeeeeeeeeeeeeee.mp3',
'https://www.myinstants.com/media/sounds/tuturu_1.mp3',
'https://www.myinstants.com/media/sounds/e33-monoco-owowow.mp3',
'https://www.myinstants.com/media/sounds/rajoy-japones.mp3',
'https://www.myinstants.com/media/sounds/mission-success.mp3',
'https://www.myinstants.com/media/sounds/129-received-an-item.mp3',
'https://www.myinstants.com/media/sounds/reeeee-2.mp3',
'https://www.myinstants.com/media/sounds/es-la-hora-de-la-paja-video-original-audiotrimmer.mp3',
];
function playAt(arr,idx){
if(!arr.length)return;
try{var a=new Audio(arr[idx%arr.length]);a.volume=0.2;a.play().catch(function(){});}catch(e){}
}
var failHeard=-1,successHeard=-1;
function checkSounds(){
if(typeof V.failSeq==='number'){
if(failHeard>=0&amp;&amp;V.failSeq>failHeard)playAt(FAIL_SOUNDS,V.failIdx||0);
failHeard=V.failSeq;
}
if(typeof V.successSeq==='number'){
if(successHeard>=0&amp;&amp;V.successSeq>successHeard)playAt(SUCCESS_SOUNDS,V.successIdx||0);
successHeard=V.successSeq;
}
}
var hintSeen=-1;
function checkHintGlow(){
if(typeof V.hintSeq!=='number')return;
if(hintSeen>=0&amp;&amp;V.hintSeq>hintSeen&amp;&amp;V.hintCids){
V.hintCids.forEach(function(id){
var w=el('h-game').querySelector('[data-cid="'+id+'"]');
if(w&amp;&amp;w.firstChild&amp;&amp;w.firstChild.classList)w.firstChild.classList.add('h-hintglow');
});
}
hintSeen=V.hintSeq;
}
function animate(prev){
// FLIP: toda carta con data-cid (mano, pila, descartes) se desliza
// desde su posición del render anterior hasta la nueva
var els=el('h-game').querySelectorAll('[data-cid]');
for(var i=0;i&lt;els.length;i++){
var e=els[i],r0=prev[e.getAttribute('data-cid')];
if(!r0){
if(e.classList.contains('h-cardwrap'))e.firstChild.classList.add('h-new');
continue;
}
var r1=e.getBoundingClientRect(),dx=r0.left-r1.left,dy=r0.top-r1.top;
if(dx||dy){
var base=e.style.transform||''; // conserva la rotación del abanico
e.style.transition='none';e.style.transform='translate('+dx+'px,'+dy+'px) '+base;
e.getBoundingClientRect();
e.style.transition='transform .45s ease';e.style.transform=base;
}
}
}
function render(){
if(!V)return;
var prev={},olds=el('h-game').querySelectorAll('[data-cid]');
for(var i=0;i&lt;olds.length;i++)
prev[olds[i].getAttribute('data-cid')]=olds[i].getBoundingClientRect();
var h='';
var shareUrl=location.origin+location.pathname+'?sala='+roomCode;
h+='&lt;div class="h-row">Sala: &lt;span class="h-code">'+roomCode+'&lt;/span> '+
'&lt;button data-act="copy">📋 Copiar enlace&lt;/button>'+
'&lt;button data-act="noob" class="h-noob-toggle'+(noobMode?' h-active':'')+'">🔰 Modo noob'+(noobMode?' ✓':'')+'&lt;/button>&lt;/div>';
if(V.phase==='lobby'){
h+='&lt;div class="h-panel">&lt;h3>Jugadores ('+V.names.length+'/5)&lt;/h3>';
V.names.forEach(function(n,i){
h+='&lt;div>'+esc(n)+(i===0?' 👑':'')+(i===V.me?' (tú)':'')+'&lt;/div>';});
h+='&lt;/div>';
if(V.me===0)h+='&lt;button data-act="start" '+(V.names.length&lt;2?'disabled':'')+
'>🚀 Empezar ('+(V.names.length&lt;2?'mínimo 2':V.names.length+' jugadores')+')&lt;/button>';
else h+='&lt;div class="h-mut">Esperando a que '+esc(V.names[0])+' empiece...&lt;/div>';
}else{
if(V.phase==='ended'){
var r=V.result,msg;
if(r.reason==='boom')msg='💥 ¡Tres fallos! Puntuación: 0';
else if(r.reason==='lost')msg='💀 Carta perdida irrecuperablemente. Puntuación: '+r.score;
else{
var adj=r.score===25?'¡LEGENDARIA! 🎆':r.score>=21?'¡Memorable!':
r.score>=16?'¡Muy buena!':r.score>=11?'Honorable':r.score>=6?'Mediocre...':'Horrible 💀';
msg='🏁 Puntuación: '+r.score+'/25 — '+adj;
}
h+='&lt;div class="h-banner">'+msg+(V.me===0?' &lt;button data-act="start">🔄 Otra partida&lt;/button>':'')+'&lt;/div>';
}
// tablero central
h+='&lt;div class="h-panel">&lt;div class="h-row">';
COLORS.forEach(function(c){
h+='&lt;div class="h-pile"'+(V.piles[c]?' data-cid="'+V.pileTops[c]+'"':'')+
' style="background:'+CINFO[c].bg+(V.piles[c]===0?';opacity:.3':'')+
'">'+(V.piles[c]||'·')+'&lt;/div>';});
// abanico de descartes, agrupado por color
var ds=V.discard.slice().sort(function(a,b){
return COLORS.indexOf(a.c)-COLORS.indexOf(b.c)||a.n-b.n;});
h+='&lt;div class="h-fan" title="Descartes">&lt;div class="h-disc h-discempty">🗑&lt;/div>';
ds.forEach(function(d){
h+='&lt;div class="h-disc" data-cid="'+d.id+'" style="background:'+CINFO[d.c].bg+
';transform:rotate('+((d.id%5)-2)*4+'deg)">'+d.n+'&lt;/div>';});
h+='&lt;/div>';
h+='&lt;/div>&lt;div class="h-row">💡 Pistas: &lt;b>'+V.hints+'&lt;/b>/8 &amp;nbsp; 🧨 Mechas: &lt;b>'+
V.fuses+'&lt;/b>/3 &amp;nbsp; 🂠 Mazo: &lt;b>'+V.deckCount+'&lt;/b>'+
(V.turnsLeft!==null?' &amp;nbsp; ⏳ Turnos finales: &lt;b>'+V.turnsLeft+'&lt;/b>':'')+'&lt;/div>';
// en peligro: cartas aún necesarias con una sola copia viva (por descartes/fallos)
var danger=[],dead=[];
COLORS.forEach(function(c){
for(var n=V.piles[c]+1;n&lt;=5;n++){
var disc=V.discard.filter(function(d){return d.c===c&amp;&amp;d.n===n;}).length;
var rem=TOTALS[n]-disc;
if(rem===0){dead.push(CINFO[c].em+n+(n&lt;5?'↑':''));break;}
if(rem===1&amp;&amp;disc>0)danger.push(CINFO[c].em+n);
}
});
h+='&lt;div class="h-mut">⚠️ En peligro (última copia): '+(danger.join(' &amp;nbsp;')||'—')+
(dead.length?' &amp;nbsp;&amp;nbsp; 💀 Perdidas: '+dead.join(' &amp;nbsp;'):'')+'&lt;/div>&lt;/div>';
// manos
var playerOrder=[];
for(var oi=0;oi&lt;V.names.length;oi++)playerOrder.push((V.me+oi)%V.names.length);
var actionRendered=false;
playerOrder.forEach(function(i){
var n=V.names[i];
var turn=V.phase==='playing'&amp;&amp;V.turn===i;
h+='&lt;div class="h-panel'+(turn?' h-turn':'')+'">&lt;div class="h-name">'+
(turn?'▶ ':'')+esc(n)+(i===V.me?' (tú)':'')+(V.connected[i]?'':' 🔌❌')+'&lt;/div>&lt;div class="h-row">';
V.hands[i].forEach(function(c,j){h+=cardHtml(c,i,j,V.phase==='playing'&amp;&amp;(V.turn===V.me||i===V.me));});
h+='&lt;/div>&lt;/div>';
// intención: aparece debajo de tus propias cartas cuando no es tu turno
if(V.phase==='playing'&amp;&amp;V.turn!==V.me&amp;&amp;sel&amp;&amp;sel.pl===V.me&amp;&amp;i===V.me){
var intentCard=V.hands[V.me][sel.idx];
var curIntent=intentCard&amp;&amp;V.intentions&amp;&amp;V.intentions[V.me]&amp;&amp;V.intentions[V.me][intentCard.id];
h+='&lt;div class="h-panel">&lt;b>Carta '+(sel.idx+1)+' tuya — marcar intención:&lt;/b> '+
'&lt;button data-act="intent-play"'+(curIntent==='play'?' style="outline:2px solid #fbbf24;outline-offset:1px"':'')+'>🎇 Jugar&lt;/button>'+
'&lt;button data-act="intent-discard"'+(curIntent==='discard'?' style="outline:2px solid #fbbf24;outline-offset:1px"':'')+'>🗑️ Descartar&lt;/button>'+
'&lt;/div>';
actionRendered=true;
}
// barra de acciones: aparece justo debajo del jugador seleccionado
if(V.phase==='playing'&amp;&amp;V.turn===V.me&amp;&amp;sel&amp;&amp;sel.pl===i){
h+='&lt;div class="h-panel">&lt;b>Carta '+(sel.idx+1)+' de '+esc(V.names[sel.pl])+':&lt;/b> ';
if(sel.pl===V.me){
h+='&lt;button data-act="play">🎇 Jugar&lt;/button>'+
'&lt;button data-act="discard" class="h-red" '+(V.hints>=8?'disabled':'')+'>🗑 Descartar'+
(V.hints>=8?' (8 pistas)':'')+'&lt;/button>';
}else{
var sc=V.hands[sel.pl][sel.idx];
h+='&lt;button data-act="hintc" '+(V.hints&lt;1?'disabled':'')+'>Pista: '+
CINFO[sc.c].em+' '+CINFO[sc.c].name+'&lt;/button>'+
'&lt;button data-act="hintn" '+(V.hints&lt;1?'disabled':'')+'>Pista: número '+sc.n+'&lt;/button>';
}
h+='&lt;/div>';
actionRendered=true;
}
});
if(V.phase==='playing'&amp;&amp;!actionRendered){
h+='&lt;div class="h-mut">'+(V.turn===V.me?
'✨ Tu turno: toca una carta tuya (jugar/descartar) o una ajena (dar pista)':
'Turno de '+esc(V.names[V.turn])+'...')+'&lt;/div>';
}
h+='&lt;div class="h-panel h-log">'+V.log.slice().reverse().map(function(l){
return '&lt;div>'+esc(l)+'&lt;/div>';}).join('')+'&lt;/div>';
}
el('h-game').innerHTML=h;
if(V.phase==='playing'||V.phase==='ended')animate(prev);
checkSounds();
checkHintGlow();
}
// ---------- eventos ----------
el('h-create').addEventListener('click',createRoom);
el('h-join').addEventListener('click',joinRoom);
el('h-game').addEventListener('click',function(e){
var b=e.target.closest('[data-act]');
if(b){
var act=b.getAttribute('data-act');
if(act==='copy'){
navigator.clipboard.writeText(location.origin+location.pathname+'?sala='+roomCode)
.then(function(){toast('Enlace copiado 📋');});
}
else if(act==='noob'){noobMode=!noobMode;render();}
else if(act==='start'&amp;&amp;isHost)startGame();
else if(act==='play')sendAction({kind:'play',idx:sel.idx});
else if(act==='discard')sendAction({kind:'discard',idx:sel.idx});
else if(act==='hintc')sendAction({kind:'hint',target:sel.pl,htype:'color',value:V.hands[sel.pl][sel.idx].c});
else if(act==='hintn')sendAction({kind:'hint',target:sel.pl,htype:'number',value:V.hands[sel.pl][sel.idx].n});
else if(act==='intent-play'){var ci=V.hands[V.me][sel.idx].id;sendIntent(sel.idx,V.intentions&amp;&amp;V.intentions[V.me]&amp;&amp;V.intentions[V.me][ci]==='play'?null:'play');}
else if(act==='intent-discard'){var ci=V.hands[V.me][sel.idx].id;sendIntent(sel.idx,V.intentions&amp;&amp;V.intentions[V.me]&amp;&amp;V.intentions[V.me][ci]==='discard'?null:'discard');}
return;
}
var c=e.target.closest('[data-pl]');
if(c&amp;&amp;V&amp;&amp;V.phase==='playing'){
var pl=+c.getAttribute('data-pl'),idx=+c.getAttribute('data-idx');
if(V.turn===V.me||pl===V.me){
sel=(sel&amp;&amp;sel.pl===pl&amp;&amp;sel.idx===idx)?null:{pl:pl,idx:idx};
render();
}
}
});
// hover preview de pista
el('h-game').addEventListener('mouseover',function(e){
var b=e.target.closest('[data-act="hintc"],[data-act="hintn"]');
if(!b||!sel||sel.pl===V.me)return;
var act=b.getAttribute('data-act'),sc=V.hands[sel.pl][sel.idx];
var val=act==='hintc'?sc.c:sc.n;
V.hands[sel.pl].forEach(function(c,j){
if((act==='hintc'?c.c:c.n)===val){
var ce=el('h-game').querySelector('[data-pl="'+sel.pl+'"][data-idx="'+j+'"]');
if(ce)ce.classList.add('h-hinthover');
}
});
});
el('h-game').addEventListener('mouseout',function(e){
var b=e.target.closest('[data-act="hintc"],[data-act="hintn"]');
if(!b||b.contains(e.relatedTarget))return;
el('h-game').querySelectorAll('.h-hinthover').forEach(function(c){c.classList.remove('h-hinthover');});
});
// código por URL: ?sala=ABCD
var m=location.search.match(/sala=([A-Za-z0-9]{4})/);
if(m)el('h-code').value=m[1].toUpperCase();
})();
&lt;/script>
&lt;h2 id="cómo-se-juega">¿Cómo se juega?&lt;/h2>
&lt;p>Hanabi es &lt;strong>cooperativo&lt;/strong>: montáis juntos 5 fuegos artificiales (del 1 al 5 en cada color). El truco: &lt;strong>ves las cartas de los demás, pero no las tuyas&lt;/strong>.&lt;/p></description></item></channel></rss>