/* ============ coop.js — partida cooperativa (hasta 4 jugadores) ============ * * Quién manda en qué: * ANFITRIÓN enemigos (aparición, IA, vida, muerte), objetos del suelo, * experiencia y nivel del equipo, reloj, fin de la partida. * CADA UNO su propio Pokémon: posición, vida, ataques y lo que le golpea. * Sus golpes a enemigos se mandan al anfitrión, que los aplica. * * Los invitados ven COPIAS de los enemigos del anfitrión (enemy.remote) y los * ataques de los compañeros como "fantasmas" que no hacen daño (ver combat.js). * * Quién está eligiendo (objeto o vínculo) y quién ha elegido carta de nivel va * además en CADA envío (no sólo en un aviso suelto): si un aviso se pierde * porque la conexión parpadea, el siguiente envío lo arregla y nadie se queda * esperando a otro para siempre (pasaba en 2026-10). * * Mensajes (por net.js, unas 15 veces por segundo; 5 si va por el relé): * invitado -> anfitrión { k:'f', p:[estado], ev:[eventos] } * anfitrión -> invitado { k:'f', t, lv, xp, xn, ki, bo, co, p:{uid:[estado]}, * e:[[id,x,y,vida,anim,dir,estados]], ev:[eventos], * ja:[x,y,anim,dir,daño] (Arceus, sólo en la Sentencia) } * anfitrión -> todos { k:'start', seed, host, members:[{uid,name,dex,shiny}] } */ G.Coop = (() => { const ANIMS = ['Idle', 'Walk', 'Attack', 'Shoot', 'Charge', 'Hurt', 'Faint']; const COLORS = ['#ffcb3d', '#5fc8ff', '#7ee07a', '#ff8ad8']; // Mensajes por segundo: 15 por la conexión directa y 3 por el relé de la // Realtime Database (apagado: 1 $ el GB). const RATE_FAST = 1 / 15, RATE_SLOW = 1 / 3; const rateFor = u => (G.Net.slow(u) ? RATE_SLOW : RATE_FAST); const NEAR = 950; // radio de enemigos que se mandan a cada uno let active = false, host = false, me = null, hostUid = null; let local = null; // tu Player const mates = new Map(); // uid -> { uid, name, color, pl, connected, q:[], flushT, xpMul } let outEv = []; // invitado: eventos para el anfitrión let flushT = 0; let hitFrom = null; // invitado cuyo golpe se está aplicando (no repetirle el número) let api = {}; // callbacks del juego (game.js) let waiting = new Set(); // subida de nivel: quién falta por elegir let holds = new Set(); // quién está eligiendo una fusión (la partida se para para todos) // El anfitrión mueve a los enemigos: si cambia de pestaña (o se le congela), // todo se para para todos. Antes los enemigos se quedaban quietos y los demás // seguían jugando con una ventaja enorme. let hostAway = false, stalled = false, lastHostAt = 0; // Cartas de nivel: número de la ronda (anfitrión), la última que te ha llegado // y la última que has elegido (invitado). Y el último "quién elige" aplicado. let levelSeq = 0, levelNow = 1, seenSeq = 0, doneSeq = 0, lastHo = ''; const STALL_MS = 2500; let members = []; // ---------------- escalado de dificultad ---------------- const scaleRate = n => 1 + 0.75 * Math.max(0, n - 1); const scaleHp = n => 1 + 0.3 * Math.max(0, n - 1); // ---------------- arranque / fin ---------------- /** * @param cfg { seed, host, members } * @param pl tu Player (ya creado) * @param cb { levelStart(lv), resume(), waiting(names), over(data), lost(msg), team(t), shiny(dex) } */ function begin(cfg, pl, cb) { active = true; me = G.Net.uid; hostUid = cfg.host; host = me === hostUid; local = pl; api = cb; members = cfg.members; mates.clear(); outEv = []; flushT = 0; waiting = new Set(); holds = new Set(); hostAway = false; stalled = false; lastHostAt = performance.now(); sums = {}; sumT = 1; myHold = false; levelSeq = 0; levelNow = 1; seenSeq = 0; doneSeq = 0; lastHo = ''; cfg.members.forEach((m, i) => { const x = (i - (cfg.members.length - 1) / 2) * 34, y = 0; if (m.uid === me) { pl.x = x; pl.y = y; pl.uid = me; pl.coopHost = host; pl.tagColor = COLORS[i]; return; } const mon = G.DEX_BY[m.dex] || G.DEX_BY[25]; const p = new G.Player(mon, {}, !!m.shiny); p.remote = true; p.uid = m.uid; p.tag = m.name; p.tagColor = COLORS[i]; p.revLabel = 'Quédate a su lado'; p.x = x; p.y = y; p.net = { x, y, ang: Math.PI / 2, walk: 0, hp: p.maxHp, maxHp: p.maxHp, dead: 0, mv: G.baseStartOf(mon.dex), ml: 1, mag: 78, aim: Math.PI / 2, rev: 0 }; G.Sprites.preload([mon.dex], 8000, p.shiny); mates.set(m.uid, { uid: m.uid, name: m.name, color: COLORS[i], pl: p, connected: true, q: [], flushT: 0, xpMul: 1, lastAt: performance.now() }); }); } function end() { for (const u of [...graceT.keys()]) stopGrace(u); active = false; host = false; local = null; mates.clear(); outEv = []; waiting = new Set(); holds = new Set(); } /** Te vas de la partida (los demás siguen). */ function leave() { if (!active) return; if (host) { // Si se va el anfitrión, la partida termina para todos. emit(['ov', Object.assign(api.summary ? api.summary() : {}, { why: 'host', sums: teamSums() })]); flushAll(); } else { outEv.push(['bye']); flushClient(true); } end(); } // ---------------- lista de jugadores ---------------- function players() { const out = [local]; for (const m of mates.values()) if (m.connected) out.push(m.pl); return out; } function puppets() { const out = []; for (const m of mates.values()) if (m.connected) out.push(m.pl); return out; } /** * Multiplicador de la experiencia del equipo: la media de los bonus de * cada uno, y repartida entre los que sois (en grupo cae mucho más, y si no * se subiría de nivel el triple de rápido que en solitario). */ function xpFactor() { let s = local.xpMul, n = 1; for (const m of mates.values()) if (m.connected) { s += m.xpMul || 1; n++; } return (s / n) / local.xpMul / (1 + 0.6 * (n - 1)); } // ---------------- estado de un jugador ---------------- const r1 = v => Math.round(v), r2 = v => Math.round(v * 100) / 100; function myState() { // Con un vínculo se manda la órbita si la hay (es lo único que los demás no ven por los 'c'). const g = local.activeGroup(); const m = g.find(x => x.kind === 'orbit') || g[0]; return [r1(local.x), r1(local.y), r2(local.moveAngle), local.walking ? 1 : 0, r1(local.hp), r1(local.maxHp), local.dead ? 1 : 0, m ? m.id : '', m ? m.lvl : 1, r1(local.magnet), r2(local.aim), r2(local.xpMul), r2(local.reviveT || 0), api.choosing && api.choosing() ? 1 : 0, doneSeq, api.watching ? api.watching() : '', r2((G.Camera && G.Camera.zoomTo) || 1)]; } /** Número que llega por la red: si no es un número de verdad vale `def`, y si se pasa se queda en el tope. */ const num = (v, def, lo, hi) => typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : def; /* * Resumen de cada uno para el final de la partida (25-10-2026): su Pokémon, sus * ataques con el daño que han hecho y su objeto. Cada invitado manda el suyo al * anfitrión cada SUM_EVERY s ('sm') y el anfitrión, los de todos a todos ('sms'); * al acabar van también en 'ov'. Así cada uno ve el de todos aunque alguien se * haya ido antes. Lo que llega se limpia (sólo números, ataques y objetos que * existen): nada de texto de otros jugadores. */ const SUM_EVERY = 5; let sums = {}, sumT = 0; const mySum = () => (api && api.mySum ? api.mySum() : null); function cleanSum(s) { if (!s || typeof s !== 'object') return null; const dex = Math.floor(num(s.dex, 0, 0, 5000)); if (!G.DEX_BY[dex]) return null; const mv = (Array.isArray(s.mv) ? s.mv : []).slice(0, 8) .filter(m => Array.isArray(m) && typeof m[0] === 'string' && G.Moves.BY_ID[m[0]]) .map(m => [m[0], Math.floor(num(m[1], 1, 1, 99)), Math.round(num(m[2], 0, 0, 1e13)), Math.round(num(m[3], 0, 0, 86400)), m[4] ? 1 : 0]); return { dex, sh: s.sh === true, lv: Math.floor(num(s.lv, 1, 1, 9999)), t: Math.round(num(s.t, 0, 0, 86400)), it: typeof s.it === 'string' && G.Items.BY[s.it] ? s.it : null, mv }; } /** Los resúmenes de todos (el tuyo, recién hecho). Siguen ahí al acabar la partida. */ function teamSums() { const out = Object.assign({}, sums), m = mySum(); if (m && me) out[me] = m; return out; } function takeSums(all) { if (!all || typeof all !== 'object') return; for (const u of Object.keys(all)) { if (u === me || !members.some(x => x.uid === u)) continue; const c = cleanSum(all[u]); if (c) sums[u] = c; } } // Lo que manda otro jugador no se cree a ciegas: un xpMul de 1e309 colgaba al anfitrión al subir de nivel. function readState(a) { if (!Array.isArray(a)) a = []; return { x: num(a[0], 0, -1e6, 1e6), y: num(a[1], 0, -1e6, 1e6), ang: num(a[2], 0, -10, 10), walk: a[3] ? 1 : 0, hp: num(a[4], 0, 0, 1e6), maxHp: num(a[5], 1, 1, 1e6), dead: a[6] ? 1 : 0, mv: typeof a[7] === 'string' ? a[7].slice(0, 40) : '', ml: num(a[8], 1, 1, 99), mag: num(a[9], 0, 0, 5000), aim: num(a[10], 0, -10, 10), xpMul: num(a[11], 1, 0.1, 20), rev: num(a[12], 0, 0, 60), ch: a[13] === undefined ? undefined : a[13] ? 1 : 0, ld: num(a[14], 0, 0, 1e9), wa: typeof a[15] === 'string' ? a[15].slice(0, 64) : '', zm: num(a[16], 1, 1, 1.5) }; } /** Anfitrión: dónde mira un invitado (él mismo, o el compañero vivo que mira estando caído). */ function viewOf(m) { const w = m.pl.dead && m.pl.net && m.pl.net.wa; if (!w) return m.pl; if (w === me) return local.dead ? m.pl : local; const o = mates.get(w); return o && o.connected && !o.pl.dead ? o.pl : m.pl; } // ---------------- envío ---------------- /** Anfitrión: evento para todos los invitados (menos `except`). */ function emit(ev, except) { if (!host) return; for (const m of mates.values()) if (m.connected && m.uid !== except) m.q.push(ev); } /** Anfitrión: evento sólo para los que están cerca (números de daño). */ function emitNear(ev, x, y, except) { for (const m of mates.values()) { if (!m.connected || m.uid === except) continue; const v = viewOf(m); if (G.U.dist2(v.x, v.y, x, y) > 700 * 700) continue; if (m.q.length > 400) continue; m.q.push(ev); } } function enemyList(near) { const out = []; const r2n = NEAR * NEAR; for (const e of G.EnemyMgr.all()) { if (e.dead || G.U.dist2(e.x, e.y, near.x, near.y) > r2n) continue; const a = ANIMS.indexOf(e.anim.cur); out.push([e.id, r1(e.x), r1(e.y), Math.ceil(e.hp), a < 0 ? 0 : a, e.anim.dir, (e.burn > 0 ? 1 : 0) | (e.poison > 0 ? 2 : 0) | (e.shield > 0 ? 4 : 0) | (e.slow > 0 ? 8 : 0) | (e.sleepT > 0 ? 16 : 0)]); } return out; } function hostFrame(m) { const st = api.team(); const p = { [me]: myState() }; for (const o of mates.values()) { if (o.connected && o.uid !== m.uid && o.state) p[o.uid] = o.state; } const ev = m.q; m.q = []; const f = { k: 'f', t: r2(st.time), lv: st.level, xp: r2(st.xp), xn: st.xpNext, ki: st.kills, bo: st.bosses, co: st.coins, tk: [st.t1, st.t10], p, e: enemyList(viewOf(m)), ev }; const ja = G.Judgment.netState(); // Arceus en la Sentencia if (ja) f.ja = ja; // Siempre: quién está eligiendo y, si hay cartas de nivel, cuáles (ver arriba). f.ho = holdNames(); if (waiting.size) f.lu = [levelSeq, levelNow]; return f; } function flushAll() { for (const m of mates.values()) if (m.connected) G.Net.send(m.uid, hostFrame(m)); } /* * Cada mensaje del invitado lleva también si está eligiendo fusión u objeto (h) * y hasta qué ronda de cartas de nivel ha elegido (w), y el anfitrión lo coge de * ahí (onClientFrame). Antes sólo iban en avisos sueltos ('hs'/'he'/'lp'): si uno * se perdía en un corte de la conexión, el anfitrión esperaba para siempre y la * partida se quedaba parada para todos (25-10-2026). */ let myHold = false; function flushClient(force) { if (!force && !outEv.length && flushT > 0) return; G.Net.toHost({ k: 'f', p: myState(), ev: outEv, h: myHold ? 1 : 0, w: doneSeq }); outEv = []; } /** Cada fotograma (también en la pantalla de subida de nivel). */ function tick(dt) { if (!active) return; if ((sumT -= dt) <= 0) { sumT = SUM_EVERY; if (host) emit(['sms', teamSums()]); else { const m = mySum(); if (m) outEv.push(['sm', m]); } } if (host) { for (const m of mates.values()) { if (!m.connected) continue; // Un compañero que no manda nada (el móvil apagado o en segundo plano) se // queda quieto y nunca muere: si los enemigos y Arceus le seguían // persiguiendo, el anfitrión aguantaba para siempre (25-10-2026). m.pl.silent = performance.now() - m.lastAt > SILENT_MS; m.flushT -= dt; if (m.flushT > 0) continue; m.flushT = rateFor(m.uid); G.Net.send(m.uid, hostFrame(m)); } } else { if (!stalled && performance.now() - lastHostAt > STALL_MS) { stalled = true; updateAway(); } flushT -= dt; if (flushT <= 0) { flushT = rateFor(hostUid); G.Net.toHost({ k: 'f', p: myState(), ev: outEv }); outEv = []; } } } // ---------------- recepción ---------------- G.Net.on((from, msg) => { if (!msg) return; if (msg.k === 'start') { if (G.Flow.startCoop) G.Flow.startCoop(msg); return; } if (!active || msg.k !== 'f') return; if (host) onClientFrame(from, msg); else if (from === hostUid) onHostFrame(msg); }); /* * Cortes de la conexión directa en plena partida (22-10-2026). Antes, al * primer fallo del canal (un bache de la wifi o de los datos), al invitado se * le acababa la partida al momento, aunque el anfitrión ya lo estuviera * reintentando. Ahora se espera GRACE_MS a que vuelva: el invitado se queda * en pausa («esperando al anfitrión») y el anfitrión sigue jugando. */ const GRACE_MS = 45000; const SILENT_MS = 3000; // sin noticias de un compañero: los enemigos le dejan (ver tick) const graceT = new Map(); // uid -> temporizador de la espera function stopGrace(u) { clearTimeout(graceT.get(u)); graceT.delete(u); } G.Net.onPeer((u, mode) => { if (!active) return; if (mode === 'direct') { if (!graceT.has(u)) return; stopGrace(u); const m = mates.get(u); if (host && m && m.connected) G.Spawner.say(m.name + ' ha vuelto', 2); return; } if (mode !== 'closed' && mode !== 'failed') return; const ours = host ? !!(mates.get(u) && mates.get(u).connected) : u === hostUid; if (mode === 'failed' && ours) { if (graceT.has(u)) return; // ya se le está esperando if (host) G.Spawner.say(nameOf(u) + ': reconectando…', 3); graceT.set(u, setTimeout(() => { graceT.delete(u); if (!active || G.Net.mode(u) === 'direct' || (host && !keeps(u))) return; // volvió, o se fue ('bye') if (host) G.Social.dropMember(u); // no volvió: fuera de la sala gone(u); }, GRACE_MS)); return; } gone(u); }); function gone(u) { stopGrace(u); if (host) mateGone(u); else if (u === hostUid) { const cb = api.lost; end(); if (cb) cb('Se ha perdido la conexión con el anfitrión.'); } } /** Anfitrión: ¿`u` sigue en esta partida (aunque se esté reconectando)? */ function keeps(u) { const m = active && host && mates.get(u); return !!(m && m.connected); } function mateGone(u) { const m = mates.get(u); if (!m || !m.connected) return; m.connected = false; G.Projectiles.clearOrbs(m.pl); G.Spawner.say(m.name + ' ha dejado la partida', 2.6); if (waiting.has(u)) picked(u); if (holds.has(u)) holdOff(u); if (api.mateLeft) api.mateLeft(m); } // --- en el anfitrión --- function onClientFrame(from, msg) { const m = mates.get(from); if (!m || !m.connected) return; m.lastAt = performance.now(); m.pl.silent = false; // Lo que dice su juego manda sobre lo que se apuntó con los avisos (ver flushClient). if (msg.h === 0 && holds.has(from)) holdOff(from); else if (msg.h === 1 && !holds.has(from)) holdOn(from); if (typeof msg.w === 'number' && msg.w >= levelSeq && waiting.has(from)) picked(from); if (msg.p) { m.state = msg.p; m.pl.net = readState(msg.p); m.xpMul = m.pl.net.xpMul || 1; reconcile(from, m.pl.net); } for (const ev of msg.ev || []) { switch (ev[0]) { case 'h': { const e = G.EnemyMgr.get(ev[1]); if (!e || e.dead || e.remote) break; hitFrom = from; e.hurt(ev[2], ev[3], ev[4], ev[5], ev[6]); hitFrom = null; break; } case 's': { const e = G.EnemyMgr.get(ev[1]); if (!e || e.dead) break; if (ev[2] === 'b') e.applyBurn(ev[3]); else if (ev[2] === 'p') e.applyPoison(ev[3]); else if (ev[2] === 's') e.applySlow(ev[3]); else if (ev[2] === 't') e.applyStun(ev[3]); break; } case 'c': G.Combat.ghostCast(m.pl, ev[1], ev[2], ev[3], ev[4]); emit(['c', from, ev[1], ev[2], ev[3], ev[4]], from); break; case 'pb': G.World.breakAt(ev[1], ev[2]); emit(['pb', ev[1], ev[2]], from); break; case 'ou': { // Esfera de un invitado: se aplica aquí (los enemigos son del anfitrión) y se avisa a los demás. const x = num(ev[2], 0, -1e6, 1e6), y = num(ev[3], 0, -1e6, 1e6), z = num(ev[4], 1, 1, 1.5); if (typeof ev[1] === 'string' && G.Orbs.onMateUse(m.pl, ev[1], x, y, z)) emit(['ob', ev[1], x, y, z], from); break; } case 'lp': picked(from); break; case 'hs': holdOn(from); break; case 'he': holdOff(from); break; case 're': G.Rift.enterRequest(); break; case 'bye': mateGone(from); break; case 'sm': { const c = cleanSum(ev[1]); if (c) sums[from] = c; break; } // Un invitado con Sequía, Llovizna...: su clima, si no hay otro (como mucho uno por minuto). case 'wxq': if (typeof ev[1] === 'string' && G.Weather.KINDS[ev[1]] && !G.Weather.current && performance.now() - (m.wxAt || -1e9) > 60000) { m.wxAt = performance.now(); G.Weather.debugStart(ev[1], 60); } break; } } } /** Anfitrión: lo que dice cada envío del invitado manda sobre avisos que se hayan perdido. */ function reconcile(from, n) { if (n.ch === undefined) return; // versión vieja del juego if (n.ch && !holds.has(from)) holdOn(from); else if (!n.ch && holds.has(from)) holdOff(from); if (waiting.has(from) && n.ld >= levelSeq) picked(from); } // --- en el invitado --- /** Invitado: se para (o sigue) según si el anfitrión está. */ function updateAway() { if (api.away) api.away(hostAway || stalled ? nameOf(hostUid) : null); } // Anfitrión: al irse de la pestaña avisa a todos al momento (después ya no // podrá mandar nada) y al volver, que sigue. document.addEventListener('visibilitychange', () => { if (!active || !host) return; emit(['ha', document.hidden ? 1 : 0]); flushAll(); }); function onHostFrame(msg) { lastHostAt = performance.now(); if (stalled) { stalled = false; updateAway(); } for (const ev of msg.ev || []) applyHostEvent(ev); if (msg.ja) G.Judgment.netApply(msg.ja); // Quién elige y las cartas de nivel, aunque se haya perdido algún aviso. if (msg.ho) { const k = msg.ho.join('|'); if (k !== lastHo) { lastHo = k; api.hold && api.hold(msg.ho); } // Hay cartas abiertas que aún no has elegido y no las tienes delante (eran // nuevas, o algo te las cerró al elegir muy deprisa): se abren otra vez. // Antes sólo se miraba si eran nuevas y te podías quedar fuera de la ronda // con los demás esperándote. const showing = api.inLevelUp && api.inLevelUp(); if (msg.lu && msg.lu[0] > doneSeq && (msg.lu[0] > seenSeq || !showing)) { seenSeq = msg.lu[0]; api.levelStart(msg.lu[1]); } else if (!msg.lu && showing) api.resume(); } api.syncTeam({ time: msg.t, level: msg.lv, xp: msg.xp, xpNext: msg.xn, kills: msg.ki, bosses: msg.bo, coins: msg.co, t1: (msg.tk || [])[0], t10: (msg.tk || [])[1] }); for (const u in msg.p || {}) { const m = mates.get(u); if (m) m.pl.net = readState(msg.p[u]); } const now = performance.now(); for (const a of msg.e || []) { const e = G.EnemyMgr.get(a[0]); if (!e || !e.remote || e.dead) continue; const n = e.net; const dts = (now - (n.last || now)) / 1000; if (dts > 0.02 && dts < 1) { n.vx = G.U.clamp((a[1] - n.x) / dts, -600, 600); n.vy = G.U.clamp((a[2] - n.y) / dts, -600, 600); } else { n.vx = 0; n.vy = 0; } n.x = a[1]; n.y = a[2]; n.age = 0; n.last = now; e.hp = Math.min(e.maxHp, a[3]); n.anim = a[4]; n.dir = a[5]; n.st = a[6]; } } function applyHostEvent(ev) { switch (ev[0]) { case 'ne': G.EnemyMgr.addRemote({ id: ev[1], dex: ev[2], x: ev[3], y: ev[4], boss: !!ev[5], shiny: !!ev[6], hp: ev[7], dmg: ev[8], beh: ev[9] }); break; case 'ed': G.EnemyMgr.removeRemote(ev[1], true); break; case 'eg': G.EnemyMgr.removeRemote(ev[1], false); break; case 'hn': { const e = G.EnemyMgr.get(ev[1]); if (e && !e.dead) e.showHurt(ev[2], ev[3]); break; } case 'es': G.Projectiles.spawn({ x: ev[1], y: ev[2], vx: ev[3], vy: ev[4], dmg: ev[5], r: ev[6], life: ev[7], friendly: false, color: '#ff7a9e', vis: G.VFX.forType(ev[8]), mtype: ev[8], cat: ev[9] }); break; case 'c': { const m = mates.get(ev[1]); if (m) G.Combat.ghostCast(m.pl, ev[2], ev[3], ev[4], ev[5]); break; } case 'item': api.item(ev[1]); break; case 'orb': if (typeof ev[1] === 'string') api.orb(ev[1]); break; case 'ob': if (typeof ev[1] === 'string') G.Orbs.nearBuff(ev[1], num(ev[2], 0, -1e6, 1e6), num(ev[3], 0, -1e6, 1e6)); break; case 'pk': G.Pickups.addRemote(ev[1], ev[2], ev[3], ev[4], ev[5]); break; case 'pc': G.Pickups.removeId(ev[1]); break; case 'mag': G.Pickups.pullAll(); G.FX.ring(local.x, local.y, 10, 420, '#ff9ed8', 0.6, 4); break; case 'bomb': G.FX.ring(ev[1], ev[2], 20, G.Camera.outerRadius(), '#ff7b3d', 0.5, 6); if (G.U.dist2(ev[1], ev[2], local.x, local.y) < 500 * 500) G.Camera.kick(0.8); G.Audio.sfx('break'); break; case 'pb': G.World.breakAt(ev[1], ev[2]); break; case 'say': G.Spawner.say(ev[1], ev[2]); break; case 'heal': if (!local.dead) { local.heal(local.maxHp * ev[1]); G.FX.ring(local.x, local.y, 6, 60, '#5fe08a', 0.4, 3); } break; case 'lu': if (!ev[2] || ev[2] > seenSeq) { seenSeq = ev[2] || seenSeq; api.levelStart(ev[1]); } break; case 'lw': api.waiting(ev[1]); break; case 'lr': api.resume(); break; case 'hp': lastHo = (ev[1] || []).join('|'); api.hold && api.hold(ev[1]); break; case 'ha': hostAway = !!ev[1]; updateAway(); break; case 'sh': api.shiny(ev[1]); break; case 'ov': { const cb = api.over; const d = ev[1]; if (d) takeSums(d.sums); end(); cb(d); break; } case 'sms': takeSums(ev[1]); break; case 'rift': case 'riftx': case 'arena': case 'aw': case 'al': G.Rift.onEvent(ev); break; case 'jd': case 'jk': case 'jb': G.Judgment.onEvent(ev); break; case 'wx': G.Weather.onEvent(ev); break; case 'ev': G.Events.onNet(ev); break; case 'hz': G.Hazards.add(ev[1], true); break; case 'fxr': if (typeof ev[4] === 'string') G.FX.ring(num(ev[1], 0, -1e6, 1e6), num(ev[2], 0, -1e6, 1e6), 8, num(ev[3], 8, 0, 2000), ev[4], 0.5, 3); break; } } // ---------------- ganchos que llaman los módulos del juego ---------------- /** Tu ataque: los demás lo repiten como fantasma. */ function cast(pl, m) { if (!active || pl !== local) return; if (host) emit(['c', me, m.id, m.lvl, r2(pl.aim), m.evolved ? 1 : 0]); else outEv.push(['c', m.id, m.lvl, r2(pl.aim), m.evolved ? 1 : 0]); } // invitado function hit(e, d, color, dx, dy, knock) { outEv.push(['h', e.id, d, color, r1(dx), r1(dy), knock || 0]); } function status(e, kind, val) { outEv.push(['s', e.id, kind, val]); } // anfitrión function hurtShown(e, d, color) { emitNear(['hn', e.id, d, color], e.x, e.y, hitFrom); } function enemyAdded(e) { emit(['ne', e.id, e.dex, r1(e.x), r1(e.y), e.boss ? 1 : 0, e.shiny ? 1 : 0, Math.ceil(e.maxHp), r2(e.dmg), e.behavior]); } function enemyGone(e, died) { emit([died ? 'ed' : 'eg', e.id]); } function enemyShot(p, type) { emit(['es', r1(p.x), r1(p.y), r1(p.vx), r1(p.vy), r2(p.dmg), p.r, p.life, type, p.cat]); } function pickupAdded(o) { emit(['pk', o.id, o.kind, r1(o.x), r1(o.y), o.value]); } function pickupGone(id) { emit(['pc', id]); } function say(text, secs) { emit(['say', text, secs]); } function propBroken(p) { if (host) emit(['pb', p.tx, p.ty]); else outEv.push(['pb', p.tx, p.ty]); } /** Anfitrión: alguien ha cogido un objeto. Devuelve true si es para ti. */ function collected(p, who) { emit(['pc', p.id]); if (p.kind === 'magnet') emit(['mag']); if (p.kind === 'bomb') emit(['bomb', r1(who.x), r1(who.y)]); if ((p.kind === 'heal' || p.kind === 'item' || p.kind === 'sphere') && who !== local) { const m = mates.get(who.uid); if (m) m.q.push(p.kind === 'heal' ? ['heal', 0.3] : [p.kind === 'item' ? 'item' : 'orb', p.value]); } } function shinyCaught(e) { emit(['sh', e.dex]); } /** Has usado una esfera: el anfitrión avisa a todos; un invitado se lo pide al anfitrión. */ function orbUsed(id, x, y, z) { if (!active) return; if (host) emit(['ob', id, r1(x), r1(y), r2(z)]); else outEv.push(['ou', id, r1(x), r1(y), r2(z)]); } // ---------------- subida de nivel compartida ---------------- function levelStart(level) { waiting = new Set([me]); for (const m of mates.values()) if (m.connected) waiting.add(m.uid); levelSeq++; levelNow = level; emit(['lu', level, levelSeq]); flushAll(); api.levelStart(level); sendWaiting(); } function nameOf(u) { return u === me ? (G.Social.me ? G.Social.me.name : 'Tú') : (mates.get(u) || {}).name || '?'; } function sendWaiting() { const names = [...waiting].map(nameOf); emit(['lw', names]); api.waiting(names); } /** Alguien ha elegido carta (anfitrión). */ function picked(u) { if (!waiting.delete(u)) return; if (waiting.size) { sendWaiting(); return; } emit(['lr']); flushAll(); api.resume(); } // ---------------- fusiones: pausa para todos ---------------- // Mientras alguien elige qué movimientos fusiona (vínculo de un legendario), // la partida se para para todos, como con las cartas de nivel. function holdNames() { return [...holds].map(nameOf); } /** Anfitrión: `u` empieza a elegir. */ function holdOn(u) { holds.add(u); emit(['hp', holdNames()]); flushAll(); api.hold && api.hold(holdNames()); } /** Anfitrión: `u` ha terminado (o se ha ido). */ function holdOff(u) { if (!holds.delete(u)) return; emit(['hp', holdNames()]); flushAll(); api.hold && api.hold(holdNames()); } /** Tú empiezas / terminas de elegir fusión. */ function holdStart() { if (host) holdOn(me); else { myHold = true; outEv.push(['hs']); flushClient(true); } } function holdEnd() { if (host) holdOff(me); else { myHold = false; outEv.push(['he']); flushClient(true); } } /** Has elegido tu carta. */ function localPicked() { if (host) picked(me); else { doneSeq = seenSeq; outEv.push(['lp']); flushClient(true); } } /** Anfitrión: se acabó para todos. */ function over(data) { emit(['ov', Object.assign({}, data, { sums: teamSums() })]); flushAll(); end(); } return { ANIMS, COLORS, begin, end, leave, keeps, tick, players, puppets, xpFactor, scaleRate, scaleHp, cast, hit, status, hurtShown, enemyAdded, enemyGone, enemyShot, pickupAdded, pickupGone, say, propBroken, collected, shinyCaught, orbUsed, levelStart, localPicked, over, holdStart, holdEnd, /** Anfitrión: evento para todos. Invitado: evento para el anfitrión. */ broadcast(ev) { emit(ev); }, toHost(ev) { if (active && !host) { outEv.push(ev); flushClient(true); } }, mate(u) { return mates.get(u); }, get mates() { return [...mates.values()]; }, get members() { return members; }, /** uid -> resumen de cada jugador (ver cleanSum), también al acabar. */ sums: teamSums, get active() { return active; }, get isHost() { return active && host; }, get local() { return local; } }; })();