— carregado do servidor multiplayer -->
◈ personagem ◈
Cabeça
Colar
Arma
Corpo
Escudo
Anel
Pernas
Botas
❤ HP185/185
💧 MP35/35
⚔ Ataque12-18
🛡 Defesa0
⭐ Nível1
🪙 Ouro150
✨ EXP Total0
💨 Speed140
💀 Abates0
⏱ Tempo0:00
◈ mochila (0/25) [B]
185 / 185
35 / 35
1⚔️
2🔥
3💚
4💨
5🧪
6💧
P🎣
Bem-vindo ao mundo de EmpiricRPG!
Clique no mapa para mover · WASD · Clique inimigos para atacar
Grupo: /convidar [nome] · /aceitar · /recusar · /sairgrupo
Alvo
◈ LOOT ◈
[ Fechar ]
✦ LEVEL UP! ✦
✦ EmpiricRPG ✦
SERVIDOR ONLINE
ENTRAR NA CONTA
🏆 RANKING EmpiricRPG 🏆 ✕ FECHAR
⭐ Nível
🏹 Distance
✨ Magic
⚔ Melee
· · ·
// ════════════════════════════════════════════════ // SISTEMA DE NAVE ESPACIAL — UFO EVENT // ════════════════════════════════════════════════ // MULTIPLAYER — Socket.io // ════════════════════════════════════════════════════════════════ const MP = { socket: null, connected: false, players: {}, enabled: false, lastStateSend: 0, party: { inParty: false, leaderId: null, members: {}, // { socketId: {name, level} } pending: null, // convite pendente recebido }, }; function mpConnect(){ if(typeof io === 'undefined'){ console.log('[MP] Socket.io não disponível — modo offline'); addMsg('','⚠️ Socket.io não carregou — modo offline.','s'); return; } MP.socket = io(location.origin, { transports: ['websocket','polling'], }); MP.enabled = true; console.log('[MP] Socket criado, aguardando connect...'); // Timeout: se não conectar em 5s, avisa e tenta reconectar const connectTimeout = setTimeout(()=>{ if(!MP.connected){ console.warn('[MP] Timeout de conexão — tentando reconectar...'); addMsg('','🔄 Reconectando ao servidor...','s'); MP.socket.connect(); } }, 5000); MP.socket.on('connect', ()=>{ clearTimeout(connectTimeout); MP.connected = true; console.log('[MP] Conectado! socket.id='+MP.socket.id+' gameStarted='+gameStarted+' token='+_sessionToken); addMsg('','🌐 Servidor multiplayer conectado!','s'); // Se jogo está ativo E já passou pelo claim_slot (token !== undefined), reenvia join if(gameStarted && _sessionToken !== undefined) mpJoin(); }); MP.socket.on('disconnect', ()=>{ MP.connected = false; // Não limpa MP.players — mantém até world_state atualizar na reconexão addMsg('','🔌 Desconectado do servidor.','s'); }); // Recebe lista de jogadores já no mundo MP.socket.on('world_state', (list)=>{ MP.players = {}; list.forEach(p=>{ MP.players[p.id]=p; }); console.log('[MP] world_state: '+list.length+' players: '+list.map(p=>p.name).join(', ')); addMsg('','👥 world_state: '+list.length+' online — '+list.map(p=>p.name).join(', '),'s'); }); // Novo jogador entrou MP.socket.on('player_joined', (p)=>{ MP.players[p.id] = p; console.log('[MP] player_joined: '+p.name+' id='+p.id.slice(0,6)); addMsg('','⚔ '+p.name+' entrou no mundo!','s'); }); // Atualização de estado de outro jogador MP.socket.on('player_state', (data)=>{ if(MP.players[data.id]) Object.assign(MP.players[data.id], data); else MP.players[data.id] = data; }); // Jogador saiu MP.socket.on('player_left', (data)=>{ console.log('[MP] player_left: '+data.name+' id='+data.id.slice(0,6)+' | players antes:', Object.values(MP.players).map(p=>p.name)); delete MP.players[data.id]; addMsg('','🚪 '+data.name+' saiu.','s'); }); // Chat de outro jogador MP.socket.on('chat', (data)=>{ if(data.id === MP.socket.id) return; // ignora eco addMsg(data.name, data.msg, 'n'); }); // ── MOB SYNC: sistema autoritativo ── // set_mob_authority: true = primeiro jogador (não precisa de full sync) // false = novo jogador (aguarda mob_full_sync para ter o mesmo mapa) MP.socket.on('set_mob_authority', (data)=>{ isMobAuthority = data.isAuthority; // todos rodam IA, isso só controla se precisa de sync if(!data.isAuthority){ _mobFullSyncReceived = false; setTimeout(()=>{ if(!_mobFullSyncReceived && MP.connected){ MP.socket.emit('request_mob_full_sync_retry'); } }, 2000); } }); // Mob raro nasceu para outro jogador — spawna localmente também MP.socket.on('mob_rare_spawn', (data)=>{ const base = ENTS.find(e=>e.id===data.baseId); if(!base) return; spawnRareMob(base, data.stars, true); // true = fromSync, sem reemitir }); MP.socket.on('request_mob_full_sync', (data)=>{ const mobs = ENTS.map(e=>({ id: e.id, type: e.type, name: e.name, x: Math.round(e.x*10)/10, y: Math.round(e.y*10)/10, hp: e.hp, maxHp: e.maxHp, active: e.active, dying: e.dying, fd: e.faceDir, wc: Math.round(e.walkCycle*10)/10, tier: e.tier, hostile: e.hostile, spd: e.spd, xp: e.xp, atkRange: e.atkRange, atkDmg: e.atkDmg, cd: e.cd, gold: e.gold, loot: e.loot, isBoss: e.isBoss||false, isRare: e.isRare||false, rareStars: e.rareStars||0, })); MP.socket.emit('mob_full_sync', { targetId: data.targetId, mobs }); }); // mob_full_sync: recebe lista completa de mobs do autoritativo e substitui ENTS locais MP.socket.on('mob_full_sync', (data)=>{ if(isMobAuthority) return; if(!data || !data.mobs) return; _mobFullSyncReceived = true; // Limpa ENTS local e repopula com os mobs do autoritativo ENTS.length = 0; for(const ms of data.mobs){ ENTS.push({ id: ms.id, type: ms.type, name: ms.name, x: ms.x, y: ms.y, hp: ms.hp, maxHp: ms.maxHp, active: ms.active, dying: ms.dying, faceDir: ms.fd||1, walkCycle: ms.wc||0, tier: ms.tier, hostile: ms.hostile, spd: ms.spd, xp: ms.xp, atkRange: ms.atkRange, atkDmg: ms.atkDmg, cd: ms.cd, gold: ms.gold, loot: ms.loot||[], isBoss: ms.isBoss||false, isRare: ms.isRare||false, rareStars: ms.rareStars||0, rareCol: ms.rareStars===2?'#ff9000':ms.rareStars===1?'#ffe040':null, // Campos de estado local (IA não roda, só render) vx:0, vy:0, _tvx:0, _tvy:0, hitFlash:0, lastAtk:0, atkAnim:0, deathTimer:0, wanderAngle:Math.random()*6.28, wanderTimer:0, _pauseTimer:0, }); } addMsg('','🗺 Mapa de monstros sincronizado! ('+data.mobs.length+' mobs)','s'); console.log('[mob] full_sync recebido: '+data.mobs.length+' mobs'); }); // mob_sync: recebe e aplica posição/HP dos mobs de outros jogadores MP.socket.on('mob_sync', (data)=>{ if(!data || !data.mobs) return; for(const ms of data.mobs){ const e = ENTS.find(e=>e.id===ms.id); if(!e) continue; if(!e.active || e.dying) continue; // Não sobrescreve mobs que este cliente está controlando const localDist = Math.hypot(PL.x-e.x, PL.y-e.y); let remoteCloser = false; for(const p of Object.values(MP.players)){ if(Math.hypot(p.x-e.x, p.y-e.y) < localDist){ remoteCloser=true; break; } } if(!remoteCloser) continue; // Guarda posição alvo para correção no update (IA local já move o mob) e._syncX = ms.x; e._syncY = ms.y; e.faceDir = ms.fd || 1; if(ms.hp < e.hp) e.hp = ms.hp; if((ms.dying || !ms.active) && !e.dying) killEnt(e, false); } }); // Co-op: dano em mob causado por outro jogador MP.socket.on('mob_hit', (data)=>{ const e = ENTS.find(e=>e.id===data.mobId && e.active && !e.dying); if(!e) return; e.hp = Math.max(0, e.hp - data.dmg); e.hitFlash = 1; const s = w2s(e.x, e.y); spawnFloat(s.x, s.y-48, '-'+data.dmg, '#ff6060'); if(e.hp<=0) killEnt(e, false); // sem loot/xp — quem matou já recebeu }); // Co-op: mob morto por outro jogador MP.socket.on('mob_kill', (data)=>{ const e = ENTS.find(e=>e.id===data.mobId && e.active); if(e && !e.dying) killEnt(e, false); // false = sem loot/xp para este cliente }); // PvP: fui atacado MP.socket.on('pvp_hit', (data)=>{ PL.hp = Math.max(0, data.yourHp); PL.hitFlash = 1; shake(4,200); const ps = w2s(PL.x,PL.y); spawnFloat(ps.x, ps.y-60, '-'+data.dmg, '#ff2020'); addMsg('','⚔ '+data.attackerName+' te atacou! -'+data.dmg+' HP','s'); document.getElementById('hp-f').style.width=(PL.hp/PL.maxHp*100)+'%'; if(PL.hp<=0) playerDie(); }); // PvP: morri por outro jogador MP.socket.on('pvp_killed', (data)=>{ addMsg('','💀 Você foi morto por '+data.killerName+'!','s'); }); // PvP: broadcast de morte MP.socket.on('pvp_death_broadcast', (data)=>{ addMsg('','💀 '+data.killerName+' matou '+data.victimName+'!','s'); }); // PvP: visual de hit em outro jogador (partículas) MP.socket.on('pvp_hit_visual', (data)=>{ const p = MP.players[data.targetId]; if(p){ const s = w2s(p.x,p.y); for(let i=0;i<8;i++) spawnPart(s.x+(Math.random()-.5)*24,s.y-30+(Math.random()-.5)*20,'#ff3030',true); spawnFloat(s.x,s.y-60,'-'+data.dmg,'#ff2020'); } }); // Curandeiro: recebi cura de um aliado MP.socket.on('recv_heal', (data)=>{ const healAmt = data.amount||0; PL.hp = Math.min(PL.maxHp, PL.hp + healAmt); const ps3 = w2s(PL.x, PL.y); spawnFloat(ps3.x, ps3.y-60, '+'+healAmt+' HP', '#40e060'); for(let i=0;i<16;i++) spawnPart(ps3.x+(Math.random()-.5)*34,ps3.y-24+(Math.random()-.5)*30,'#20e050',true); // Anel de cura menor ao redor do jogador curado for(let a=0;a<20;a++){const ang=a/20*6.28;spawnPart(ps3.x+Math.cos(ang)*36,ps3.y+Math.sin(ang)*18,'#40ff80',true);} document.getElementById('hp-f').style.width=(PL.hp/PL.maxHp*100)+'%'; document.getElementById('hpt').textContent=Math.ceil(PL.hp)+' / '+PL.maxHp; addMsg('','💚 '+data.healerName+' curou você! +'+healAmt+' HP','s'); }); // Curandeiro: visual de cura em outro jogador visível MP.socket.on('heal_visual', (data)=>{ const p = MP.players[data.targetId]; if(p){ const s = w2s(p.x, p.y); spawnFloat(s.x, s.y-56, '+'+data.amount+' HP', '#40e060'); for(let i=0;i<10;i++) spawnPart(s.x+(Math.random()-.5)*26,s.y-20+(Math.random()-.5)*22,'#20e050',true); } }); // ── PARTY EVENTS ── MP.socket.on('party_invite', (data)=>{ // data: { fromId, fromName } MP.party.pending = data; addMsg('','🤝 '+data.fromName+' te convidou para um grupo! Digite /aceitar ou /recusar.','s'); // HUD visual const el = document.getElementById('party-invite-hud'); if(el){ el.textContent = '🤝 Convite de '+data.fromName+' — /aceitar ou /recusar'; el.style.display='block'; } }); MP.socket.on('party_joined', (data)=>{ // data: { leaderId, members: [{id,name,level}] } MP.party.inParty = true; MP.party.leaderId = data.leaderId; MP.party.members = {}; data.members.forEach(m => MP.party.members[m.id] = m); addMsg('','🤝 Você entrou no grupo! Membros: '+data.members.map(m=>m.name).join(', '),'s'); renderPartyHUD(); }); MP.socket.on('party_member_joined', (data)=>{ MP.party.members[data.id] = data; addMsg('','🤝 '+data.name+' entrou no grupo.','s'); renderPartyHUD(); }); MP.socket.on('party_member_left', (data)=>{ const name = MP.party.members[data.id]?.name || '?'; delete MP.party.members[data.id]; addMsg('','🚪 '+name+' saiu do grupo.','s'); if(Object.keys(MP.party.members).length === 0){ MP.party.inParty = false; MP.party.leaderId = null; } renderPartyHUD(); }); MP.socket.on('party_disbanded', ()=>{ MP.party.inParty = false; MP.party.leaderId = null; MP.party.members = {}; addMsg('','🚪 O grupo foi desfeito.','s'); renderPartyHUD(); }); MP.socket.on('party_invite_refused', (data)=>{ addMsg('','❌ '+data.name+' recusou o convite.','s'); }); // Personagem já está online em outro navegador/aba MP.socket.on('char_already_online', (data)=>{ addMsg('','⚠️ '+data.name+' já está online em outro navegador!','s'); gameStarted = false; _sessionToken = undefined; MP.connected = false; // Destrói o socket atual completamente (não mexe em opts globais) MP.socket.removeAllListeners(); MP.socket.disconnect(); MP.socket = null; // Esconde HUD document.getElementById('game-hud').style.display = 'none'; ['login-screen','name-screen','vocation-screen'].forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display='none'; }); // Busca dados atualizados e mostra char select const _doReconnect = () => { // Reconecta o socket existente (não cria novo — evita sockets duplicados) _sessionToken = undefined; MP.connected = false; if(MP.socket){ MP.socket.connect(); } else { mpConnect(); // só cria novo se não existir } }; if(currentAccount){ fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({username:currentAccount.username,password:currentAccount.password})}) .then(r=>r.json()) .then(d=>{ if(d.ok && d.save) showCharSelect(d.save, (d.charsOnline||[]).filter(n=>n!==_deadCharName)); else if(_pendingSave) showCharSelect(_pendingSave); _doReconnect(); }) .catch(()=>{ if(_pendingSave) showCharSelect(_pendingSave); _doReconnect(); }); } else { if(_pendingSave) showCharSelect(_pendingSave); _doReconnect(); } }); // Recebe XP de kill compartilhado pelo líder do grupo MP.socket.on('party_xp', (data)=>{ gainXP(data.xp); const ps=w2s(PL.x,PL.y); spawnFloat(ps.x, ps.y-70, '+'+data.xp+' XP 🤝','#80e0ff'); addMsg('','🤝 +'+data.xp+' XP (grupo) de '+data.killerName,'s'); }); } // fim mpConnect // ── PARTY FUNCTIONS ── const MAX_PARTY_SIZE = 4; function mpPartyInvite(targetId){ if(!MP.enabled||!MP.connected) return; const currentSize = Object.keys(MP.party.members).length; if(MP.party.inParty && currentSize >= MAX_PARTY_SIZE){ addMsg('','❌ Grupo cheio! Máximo de '+MAX_PARTY_SIZE+' jogadores.','s'); return; } MP.socket.emit('party_invite',{ targetId, maxSize: MAX_PARTY_SIZE }); } function mpPartyAccept(){ if(!MP.party.pending||!MP.enabled||!MP.connected) return; MP.socket.emit('party_accept',{ fromId: MP.party.pending.fromId }); MP.party.pending = null; const el=document.getElementById('party-invite-hud'); if(el) el.style.display='none'; } function mpPartyRefuse(){ if(!MP.party.pending||!MP.enabled||!MP.connected) return; MP.socket.emit('party_refuse',{ fromId: MP.party.pending.fromId }); MP.party.pending = null; const el=document.getElementById('party-invite-hud'); if(el) el.style.display='none'; } function mpPartyLeave(){ if(!MP.enabled||!MP.connected) return; MP.socket.emit('party_leave',{}); MP.party.inParty=false; MP.party.leaderId=null; MP.party.members={}; addMsg('','🚪 Você saiu do grupo.','s'); renderPartyHUD(); } // Envia XP de kill para membros do grupo (chamado em rollDrop) // Regra: XP dividido por todos + 20% de bonus de party // Ex: 100 XP, 4 membros → 100/4=25, +20%=30 cada function mpPartyShareXP(xp){ if(!MP.enabled||!MP.connected||!MP.party.inParty) return; const memberCount = Object.keys(MP.party.members).length; if(memberCount < 2) return; // Calcula XP do matador já com bônus de party const PARTY_BONUS = 1.20; const sharedXp = Math.floor((xp / memberCount) * PARTY_BONUS); // Substitui o XP base do matador pelo valor dividido+bonus // (o gainXP original com xp inteiro já foi chamado antes — cancela e aplica o correto) // Nota: o matador recebe o mesmo valor que os membros via evento local MP.socket.emit('party_kill_xp',{ xp, killerName: PL.name, memberCount, partyBonus: PARTY_BONUS }); // Matador também recebe o XP dividido (não o total) // Subtrair o excedente que foi dado antes: gainXP retorna void, então ajustamos via diff const diff = sharedXp - xp; // normalmente negativo: desconta o excesso if(diff !== 0) gainXP(diff); } function renderPartyHUD(){ let hud = document.getElementById('party-hud'); if(!hud){ hud = document.createElement('div'); hud.id = 'party-hud'; hud.style.cssText='position:fixed;top:8px;left:50%;transform:translateX(-50%) translateX(0);margin-top:58px;background:rgba(3,7,3,.95);border:1px solid #1a3010;border-radius:3px;padding:5px 10px;z-index:10;pointer-events:auto;min-width:175px;display:none'; document.getElementById('game-hud').appendChild(hud); } if(!MP.party.inParty){ hud.style.display='none'; return; } const members = Object.values(MP.party.members); const count = members.length; const isFull = count >= MAX_PARTY_SIZE; hud.style.display = 'block'; hud.innerHTML = '
🤝 GRUPO (' + count + '/' + MAX_PARTY_SIZE + (isFull ? ' · CHEIO' : '') + '
' + '
⚡ +20% XP · dividido por todos
' + members.map(function(m){ const isMe = m.id === (MP.socket&&MP.socket.id); return '
' + m.name + ' Nv.' + (m.level||1) + '
'; }).join('') + '
Sair do grupo
'; } let _sessionToken = undefined; // undefined = não inicializado, null = modo offline, string = token válido let isMobAuthority = false; // true = este cliente roda a IA e sincroniza mobs let _mobSyncTimer = 0; // timer para enviar mob_sync a 10Hz let _mobFullSyncReceived = false; // flag para retry de full sync // Reserva slot no servidor e entra no multiplayer function mpClaimAndJoin(){ if(!MP.enabled) return; fetch('/api/claim_slot', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ charName: PL.name }) }) .then(r => r.json()) .then(d => { if(!d.ok){ addMsg('','⚠️ '+PL.name+' já está online em outro navegador!','s'); return; } _sessionToken = d.token || null; if(MP.connected) mpJoin(); else console.log('[MP] claim_slot ok, aguardando connect para mpJoin...'); }) .catch(() => { _sessionToken = null; if(MP.connected) mpJoin(); }); } function mpJoin(){ if(!MP.enabled||!MP.connected) return; console.log('[MP] mpJoin: name='+PL.name+' token='+_sessionToken+' socket='+MP.socket?.id); MP.socket.emit('join',{ name: PL.name, vocation: chosenVocation, x:PL.x, y:PL.y, hp:PL.hp, maxHp:PL.maxHp, mp:PL.mp, maxMp:PL.maxMp, level:PL.level, faceDir:PL.faceDir||1, token: _sessionToken, }); } // Envia estado do jogador ~10x/s function mpSendState(){ if(!MP.enabled||!MP.connected) return; const now = performance.now(); if(now - MP.lastStateSend < 100) return; MP.lastStateSend = now; // Serializa apenas os IDs dos itens equipados const eq = {}; for(const slot of ['head','neck','weapon','chest','shield','ring','legs','boots']){ eq[slot] = PL.equipped[slot] ? PL.equipped[slot].id : null; } MP.socket.emit('state',{ x:PL.x, y:PL.y, hp:PL.hp, maxHp:PL.maxHp, mp:PL.mp, maxMp:PL.maxMp, level:PL.level, faceDir:PL.faceDir||1, walkCycle:PL.walkCycle, isDead:playerDead, eq, vocation: chosenVocation, name: PL.name, chatBubble: (PL._chatBubbleTimer>0) ? PL._chatBubble : null, pzTimer: PL._pzTimer||0, }); } // Envia hit em monstro (co-op) function mpMobHit(mobId, dmg){ if(!MP.enabled||!MP.connected) return; MP.socket.emit('mob_hit',{ mobId, dmg }); } // Envia kill de monstro (co-op) function mpMobKill(mobId){ if(!MP.enabled||!MP.connected) return; MP.socket.emit('mob_kill',{ mobId }); } // Envia ataque PvP function mpPvpAttack(targetId, dmg){ if(!MP.enabled||!MP.connected) return; MP.socket.emit('pvp_attack',{ targetId, dmg }); } // Envia chat multiplayer function mpChat(msg){ if(!MP.enabled||!MP.connected) return; MP.socket.emit('chat', msg); } // Envia cura para aliado (curandeiro) function mpHealPlayer(targetId, amount){ if(!MP.enabled||!MP.connected) return; MP.socket.emit('heal_ally',{ targetId, amount, healerName:PL.name }); } // ── Desenha outros jogadores no mundo ─────────────────────────── function renderOtherPlayers(){ for(const p of Object.values(MP.players)){ if(p.isDead) continue; const s = w2s(p.x, p.y); const voc = p.vocation || 'guerreiro'; const wc = p.walkCycle || 0; const w = Math.sin(wc)*4.5, b = Math.sin(wc)*.65; // Reconstrói objeto de equipamento a partir dos IDs recebidos // Sempre inicializa todos os slots como null para não usar PL.equipped const remoteEq = {head:null,neck:null,weapon:null,chest:null,shield:null,ring:null,legs:null,boots:null}; if(p.eq){ for(const slot of ['head','neck','weapon','chest','shield','ring','legs','boots']){ remoteEq[slot] = p.eq[slot] ? (ITEMS[p.eq[slot]] || null) : null; } } ctx.save(); ctx.translate(s.x, s.y-6); if((p.faceDir||1) < 0) ctx.scale(-1,1); drawShadow(0,8); // Sempre usa drawPlayer com equipamentos remotos — reflete arma, armadura real drawPlayer(w, b, remoteEq); ctx.restore(); // Nome e HP bar (sempre em screen space, não flipado) ctx.save(); ctx.font='bold 10px monospace'; ctx.textAlign='center'; const nameColors = {guerreiro:'#ff8080', paladino:'#ffe080', mago:'#c080ff', curandeiro:'#80ff80', elite_guerreiro:'#ffaa40', royal_paladino:'#ffe844', master_mago:'#e0a0ff', elder_curandeiro:'#a0ffcc'}; const isPartyMember = MP.party.inParty && MP.party.members[p.id]; ctx.fillStyle = isPartyMember ? '#60ffb0' : (nameColors[voc]||'#80f0ff'); ctx.fillText(p.name, s.x, s.y-83); // 🏅 Badge de vocação elite nos outros jogadores if(VOCACOES[p.vocation]?.promoted){ const _pV = VOCACOES[p.vocation]; ctx.font = 'bold 7px monospace'; ctx.fillStyle = _pV.color || '#f0c040'; ctx.fillText(_pV.icon+' '+_pV.name, s.x, s.y-72); ctx.font = 'bold 10px monospace'; } if(isPartyMember){ ctx.font='7px monospace'; ctx.fillStyle='#30c070'; ctx.fillText('🤝 GRUPO', s.x, s.y-94); } // HP bar const bw=40, bx=s.x-bw/2, by=s.y-80; ctx.fillStyle='#300'; ctx.fillRect(bx,by,bw,5); ctx.fillStyle='#0f0'; ctx.fillRect(bx,by, bw*(p.hp/Math.max(1,p.maxHp)),5); // Nível ctx.font='8px monospace'; ctx.fillStyle='#a0e0ff'; ctx.fillText('Lv'+p.level, s.x, s.y-73); // Chat bubble if(p.chatBubble){ ctx.font='9px monospace'; const bw2=ctx.measureText(p.chatBubble).width+12; const by2=s.y-100; ctx.fillStyle='rgba(255,255,255,.92)'; ctx.beginPath();ctx.roundRect(s.x-bw2/2,by2-12,bw2,16,4);ctx.fill(); ctx.fillStyle='#222'; ctx.fillText(p.chatBubble,s.x,by2); ctx.fillStyle='rgba(255,255,255,.92)'; ctx.beginPath();ctx.moveTo(s.x-5,by2+4);ctx.lineTo(s.x+5,by2+4);ctx.lineTo(s.x,by2+10);ctx.closePath();ctx.fill(); } ctx.restore(); } } // ── PvP: clique em jogador inimigo ─────────────────────────────── function checkPvpClick(wx, wy){ for(const p of Object.values(MP.players)){ if(p.isDead) continue; if(Math.hypot(p.x-wx, p.y-wy)<1.2){ // Define alvo PvP persistente — auto-ataque começa no update loop MP.pvpTarget = p.id; addMsg('','⚔ Mirando em '+p.name+'! [clique no chão para cancelar]','s'); return true; } } return false; } // Inicializa conexão multiplayer mpConnect(); // ════════════════════════════════════════════════════════════════ // SISTEMA DE LOGIN / CONTA — salvo no servidor // ════════════════════════════════════════════════════════════════ let currentAccount = null; // { username, password } function showLogin(){ document.getElementById('login-box').style.display='flex'; document.getElementById('register-box').style.display='none'; } function showRegister(){ document.getElementById('login-box').style.display='none'; document.getElementById('register-box').style.display='flex'; } async function loginAccount(){ const user = document.getElementById('login-user').value.trim(); const pass = document.getElementById('login-pass').value; const msg = document.getElementById('login-msg'); if(!user||!pass){ msg.textContent='Preencha usuário e senha.'; return; } msg.textContent='Verificando...'; try{ const r = await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})}); const d = await r.json(); if(!d.ok){ msg.textContent=d.msg; return; } currentAccount = { username: d.username, password: pass }; msg.textContent=''; document.getElementById('login-screen').style.display='none'; if(d.save){ showCharSelect(d.save, d.charsOnline||[]); } else { document.getElementById('name-screen').style.display='flex'; } }catch(e){ msg.textContent='Erro de conexão com o servidor.'; } } async function registerAccount(){ const user = document.getElementById('reg-user').value.trim(); const pass = document.getElementById('reg-pass').value; const pass2 = document.getElementById('reg-pass2').value; const msg = document.getElementById('reg-msg'); if(user.length<3) { msg.textContent='Usuário: mínimo 3 caracteres.'; return; } if(pass.length<4) { msg.textContent='Senha: mínimo 4 caracteres.'; return; } if(pass!==pass2) { msg.textContent='Senhas não conferem.'; return; } msg.textContent='Criando conta...'; try{ const r = await fetch('/api/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})}); const d = await r.json(); if(!d.ok){ msg.textContent=d.msg; return; } currentAccount = { username: user, password: pass }; msg.textContent=''; document.getElementById('login-screen').style.display='none'; document.getElementById('name-screen').style.display='flex'; }catch(e){ msg.textContent='Erro de conexão com o servidor.'; } } let _pendingSave = null; // save completo aguardando seleção let currentCharsOnline = []; // personagens desta conta que estão online agora function showCharSelect(save, charsOnline){ _pendingSave = save; console.log('[showCharSelect] chars:', Object.keys(save.chars||{}), 'version:', save.version); currentCharsOnline = charsOnline || []; const vocIcons = {guerreiro:'⚔️', paladino:'🛡️', mago:'🔮', curandeiro:'💚'}; const vocNames = {guerreiro:'Guerreiro', paladino:'Paladino', mago:'Mago', curandeiro:'Curandeiro'}; document.getElementById('char-select-user').textContent = 'Conta: '+currentAccount.username; // Monta lista de personagens const chars = save.chars || {}; // Compatibilidade com save antigo (version<=2 sem .chars) if(!save.chars && save.charName){ chars[save.charName] = save; } const list = document.getElementById('char-cards-list'); list.innerHTML = ''; Object.entries(chars).forEach(([name, cd])=>{ const voc = cd.voc||'guerreiro'; const div = document.createElement('div'); div.style.cssText='width:100%;background:rgba(30,60,20,.6);border:1px solid #3a6020;border-radius:5px;padding:12px 16px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:12px'; div.onmouseover=()=>{div.style.borderColor='#60c030';div.style.background='rgba(40,80,25,.8)';}; div.onmouseout=()=>{div.style.borderColor='#3a6020';div.style.background='rgba(30,60,20,.6)';}; const isOnline = (currentCharsOnline||[]).includes(name); div.dataset.charname = name; if(isOnline){ div.style.borderColor='#803020'; div.style.opacity='0.65'; div.onclick=()=>{ addMsg('','⚠️ '+name+' já está online em outro navegador!','s'); }; } else { div.onclick=()=>enterWithChar(name); } div.innerHTML=`
${vocIcons[voc]||'⚔️'}
${name}${isOnline?' ● ONLINE':''}
${vocNames[voc]||voc} · Nível ${cd.level||1}
❤ ${(cd.hp||0)|0} · 🪙 ${cd.gold||0} · XP ${cd.xp||0}
${isOnline?'🔒':'▶'} 🗑 apagar
`; list.appendChild(div); }); if(Object.keys(chars).length===0){ list.innerHTML='
Nenhum personagem encontrado.
'; } // Só mostra botão de criar se ainda não tem nenhum personagem const createBtn = document.querySelector('#char-select-screen button[onclick="createNewChar()"]'); if(createBtn){ createBtn.style.display = Object.keys(chars).length === 0 ? 'block' : 'none'; } document.getElementById('char-select-screen').style.display='flex'; } function _hideAllMenuScreens(){ ['login-screen','char-select-screen','name-screen','vocation-screen'].forEach(function(id){ var el=document.getElementById(id); if(el) el.style.display='none'; }); } function enterWithChar(charName){ if(!_pendingSave) return; // Reserva slot no servidor — impede entrada duplicada de forma atômica fetch('/api/claim_slot', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ charName }) }) .then(r => r.json()) .then(d => { if(!d.ok){ // Personagem já está online — bloqueia addMsg('', '⚠️ ' + charName + ' já está online em outro navegador!', 's'); const cards = document.querySelectorAll('#char-cards-list [data-charname]'); cards.forEach(card => { if(card.dataset.charname === charName){ card.style.borderColor = '#803020'; card.style.opacity = '0.65'; card.onclick = () => addMsg('', '⚠️ ' + charName + ' já está online!', 's'); const arrow = card.querySelector('span[style*="font-size:16px"]'); if(arrow) arrow.textContent = '🔒'; } }); return; } // Slot reservado com sucesso — salva o token e entra _sessionToken = d.token || null; _doEnterWithChar(charName); }) .catch(() => { // Sem servidor (modo offline) — entra sem token _sessionToken = null; _doEnterWithChar(charName); }); } function _doEnterWithChar(charName){ if(!_pendingSave) { console.error('[enterWithChar] _pendingSave é null!'); return; } _hideAllMenuScreens(); try{ const chars = _pendingSave.chars || {}; const charSave = chars[charName] || (_pendingSave.charName===charName ? _pendingSave : null); if(!charSave){ console.error('[enterWithChar] personagem não encontrado:', charName, 'chars:', Object.keys(chars)); addMsg('','❌ Personagem não encontrado: '+charName,'s'); document.getElementById('char-select-screen').style.display='flex'; return; } _loadAccountSave(charSave); } catch(e){ console.error('[enterWithChar] erro ao carregar personagem:',e); addMsg('','❌ Erro ao carregar: '+e.message,'s'); document.getElementById('game-hud').style.display='block'; } _pendingSave = null; } function deleteChar(charName){ if(!_pendingSave||!confirm('Apagar '+charName+'? Isso não pode ser desfeito.')) return; if(!_pendingSave.chars) _pendingSave.chars={}; delete _pendingSave.chars[charName]; // Salva no servidor imediatamente fetch('/api/save',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({username:currentAccount.username,password:currentAccount.password,save:_pendingSave})}); showCharSelect(_pendingSave); } // Mantido para compatibilidade (char-select antigo com 1 char) function enterWithSavedChar(){ if(!_pendingSave) return; const save=_pendingSave; _pendingSave=null; document.getElementById('char-select-screen').style.display='none'; _loadAccountSave(save); addMsg('','👤 Bem-vindo de volta, '+PL.name+'! Nível '+PL.level,'s'); } function _loadAccountSave(save){ const V = VOCACOES[save.voc]; if(!V) throw new Error('Vocação inválida: '+save.voc); chosenVocation = save.voc; chosenCharName = save.charName; PL.name = save.charName || 'Aventureiro'; // Se saiu sem PZ ativo (logout normal/ctrl+q) → spawna no templo // Se fechou o browser com PZ → mantém posição (ghost mode) const hadPZ = save.pzAtLogout === true; if(!hadPZ){ PL.x = TEMPLE.x; PL.y = TEMPLE.y + 4; } else { PL.x = save.x || TEMPLE.x; PL.y = save.y || TEMPLE.y + 4; } PL.level = save.level || 1; PL.xp = save.xp || 0; PL.xpNext = save.xpNext || 100; PL.gold = save.gold || 0; PL.meleeSkill = save.meleeSkill || 10; PL.distSkill = save.distSkill || 10; PL.meleeXp = save.meleeXp || 0; PL.distXp = save.distXp || 0; PL.magicLevel = save.magicLevel || 0; PL.magicXp = save.magicXp || 0; PL.totalXp = save.totalXp || 0; // Se totalXp está zerado mas o level é > 1, estima o total acumulado if(!PL.totalXp && PL.level > 1){ let est = 0; for(let l=1; l{ if(!s||!s.id) return null; const item = ITEMS[s.id]; return item ? {item, qty:s.qty||1} : null; }); // Restaura equipamentos — ignora itens inválidos PL.equipped = {head:null,neck:null,weapon:null,chest:null,shield:null,ring:null,legs:null,boots:null}; if(save.equipped){ ['head','neck','weapon','chest','shield','ring','legs','boots'].forEach(sl=>{ const id = save.equipped[sl]; PL.equipped[sl] = (id && ITEMS[id]) ? ITEMS[id] : null; }); } // gameStarted=false durante recalc para evitar chamar getVocTree prematuramente gameStarted=false; recalc(); gameStarted=true; playerDead=false;PL.hitFlash=0;PL.atkAnim=0;PL._pzTimer=0;PL._pvpSkull=false; updatePZDisplay(); const _dov=document.getElementById('death-overlay'); if(_dov){_dov.style.opacity='0';_dov.style.display='none';} document.getElementById('game-hud').style.display='block'; // Remove skill display anterior (pode ser de vocação diferente) e recria const oldSd = document.getElementById('skill-display-row'); if(oldSd) oldSd.remove(); // Restaura HP/MP do save (limitado ao máximo calculado) PL.hp = Math.min(save.hp||PL.maxHp, PL.maxHp); PL.mp = Math.min(save.mp||PL.maxMp, PL.maxMp); // Atualiza UI refreshBag(); refreshEq(); try{ document.getElementById('hp-f').style.width=(PL.hp/PL.maxHp*100)+'%'; }catch(e){} try{ document.getElementById('hpt').textContent=`${PL.hp|0} / ${PL.maxHp}`; }catch(e){} try{ document.getElementById('mp-f').style.width=(PL.mp/PL.maxMp*100)+'%'; }catch(e){} try{ document.getElementById('mpt').textContent=`${PL.mp|0} / ${PL.maxMp}`; }catch(e){} try{ document.getElementById('s-level').textContent=PL.level; }catch(e){} try{ document.getElementById('s-gold').textContent=PL.gold; }catch(e){} try{ document.getElementById('xp-f').style.width=(PL.xp/PL.xpNext*100)+'%'; }catch(e){} try{ document.getElementById('s-totalxp').textContent=(PL.totalXp||0).toLocaleString('pt-BR'); }catch(e){} // Esconde todas as telas ['login-screen','char-select-screen','name-screen','vocation-screen'].forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display='none'; }); // Entra no multiplayer e inicializa setTimeout(()=>{ mpClaimAndJoin(); initQuests(); refreshSkillDisplay(); gc.focus(); }, 200); } async function saveToAccount(ghostTime, forceSave){ if(!currentAccount||(!gameStarted && !forceSave)) return; const charSave={ version:2, voc:chosenVocation, charName:chosenCharName, x:PL.x, y:PL.y, hp:PL.hp, mp:PL.mp, pzAtLogout: PL._pzTimer > 0, level:PL.level, xp:PL.xp, xpNext:PL.xpNext, gold:PL.gold, meleeSkill:PL.meleeSkill, distSkill:PL.distSkill, meleeXp:PL.meleeXp, distXp:PL.distXp, magicLevel:PL.magicLevel||0, magicXp:PL.magicXp||0, totalXp:PL.totalXp||0, skillPoints:PL.skillPoints||0, learnedSkills:[...(PL.learnedSkills||[])], mount:PL.mount||null, ownedMounts:PL.ownedMounts||[], outfit:PL.outfit||{skin:'#d0a878',hair:'#3a2010',cloth:'#3c3068'}, bag:PL.bag.map(s=>s&&s.item?{id:s.item.id,qty:s.qty}:null), equipped:Object.fromEntries( Object.entries(PL.equipped).map(([k,v])=>[k,v?v.id:null]) ), }; // Estrutura multi-personagem: {version:3, chars:{nome: saveData}} const existingChars = (_pendingSave&&_pendingSave.chars) ? {..._pendingSave.chars} : {}; existingChars[chosenCharName] = charSave; const save = {version:3, chars: existingChars}; try{ const r=await fetch('/api/save',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({username:currentAccount.username,password:currentAccount.password,save, ...(ghostTime!==undefined?{ghostTime}:{})})}); const d=await r.json(); if(d.ok) addMsg('','💾 '+PL.name+' salvo no servidor! Nível '+PL.level,'s'); else addMsg('','❌ Erro ao salvar: '+d.msg,'s'); }catch(e){ addMsg('','❌ Sem conexão com servidor.','s'); } } // Auto-save a cada 5 minutos setInterval(()=>{ if(gameStarted&¤tAccount) saveToAccount(); }, 300000); // ══════════════════════════════════════════════ // PZ SYSTEM + LOGOUT + CTRL+Q // ══════════════════════════════════════════════ function updatePZDisplay(){ const el = document.getElementById('pz-indicator'); const secsEl = document.getElementById('pz-secs'); if(!el) return; if(PL._pzTimer > 0){ el.style.display = 'inline-block'; if(secsEl) secsEl.textContent = Math.ceil(PL._pzTimer); } else { el.style.display = 'none'; } } function isInPZ(){ return PL._pzTimer > 0; } async function logoutChar(){ if(isInPZ()){ addMsg('','⚔️ Você está em PZ! Aguarde '+Math.ceil(PL._pzTimer)+'s para sair.','s'); return; } await saveToAccount(); const loggedOutCharName = PL.name; // guarda nome antes de limpar gameStarted = false; _sessionToken = undefined; isMobAuthority = false; // Notifica servidor para limpar o slot if(MP.socket && MP.connected) MP.socket.emit('leave'); document.getElementById('game-hud').style.display = 'none'; document.getElementById('save-btns').style.display = 'none'; // Aguarda 300ms para o servidor processar o leave antes de buscar charsOnline await new Promise(r => setTimeout(r, 300)); try{ const r = await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({username:currentAccount.username,password:currentAccount.password})}); const d = await r.json(); if(d.ok && d.save){ // Remove o personagem que acabou de sair da lista de online (por segurança) const charsOnline = (d.charsOnline||[]).filter(n => n !== loggedOutCharName); showCharSelect(d.save, charsOnline); return; } }catch(e){} const charSel = document.getElementById('char-select-screen'); if(charSel) charSel.style.display = 'flex'; else location.reload(); } // Ctrl+Q → logout completo para tela de LOGIN document.addEventListener('keydown', async function(e){ if(e.ctrlKey && e.key === 'q'){ e.preventDefault(); if(!gameStarted) return; if(isInPZ()){ addMsg('','⚔️ PZ ativo! Não pode sair agora. Aguarde '+Math.ceil(PL._pzTimer)+'s.','s'); return; } await saveToAccount(); gameStarted = false; currentAccount = null; _pendingSave = null; _sessionToken = undefined; isMobAuthority = false; if(MP.socket && MP.connected) MP.socket.emit('leave'); document.getElementById('game-hud').style.display = 'none'; document.getElementById('save-btns').style.display = 'none'; document.getElementById('login-screen').style.display = 'flex'; } }); // Ghost mode: keep character alive for 60s after tab close/refresh window.addEventListener('beforeunload', function(){ if(!gameStarted || !currentAccount) return; const charSave={ version:2, voc:chosenVocation, charName:chosenCharName, x:PL.x, y:PL.y, hp:PL.hp, mp:PL.mp, pzAtLogout: PL._pzTimer > 0, level:PL.level, xp:PL.xp, xpNext:PL.xpNext, gold:PL.gold, meleeSkill:PL.meleeSkill, distSkill:PL.distSkill, meleeXp:PL.meleeXp, distXp:PL.distXp, magicLevel:PL.magicLevel||0, magicXp:PL.magicXp||0, totalXp:PL.totalXp||0, skillPoints:PL.skillPoints||0, learnedSkills:[...(PL.learnedSkills||[])], mount:PL.mount||null, ownedMounts:PL.ownedMounts||[], outfit:PL.outfit||{skin:'#d0a878',hair:'#3a2010',cloth:'#3c3068'}, bag:PL.bag.map(s=>s&&s.item?{id:s.item.id,qty:s.qty}:null), equipped:Object.fromEntries(Object.entries(PL.equipped).map(([k,v])=>[k,v?v.id:null])), }; const existingChars = (_pendingSave&&_pendingSave.chars) ? {..._pendingSave.chars} : {}; existingChars[chosenCharName] = charSave; const save = {version:3, chars: existingChars}; navigator.sendBeacon('/api/save', new Blob([JSON.stringify({ username:currentAccount.username, password:currentAccount.password, save, ghostTime:60 })], {type:'application/json'})); if(MP.socket && MP.connected){ MP.socket.emit('ghost_mode', { charName: chosenCharName, ghostTime: 60 }); } }); // Enter nos campos de login document.getElementById('login-pass').addEventListener('keydown',e=>{if(e.key==='Enter')loginAccount();}); document.getElementById('login-user').addEventListener('keydown',e=>{if(e.key==='Enter')loginAccount();}); document.getElementById('reg-pass2').addEventListener('keydown',e=>{if(e.key==='Enter')registerAccount();}); // ════════════════════════════════════════════════ // SISTEMA UFO — Nave com ETs descendo por corda // ════════════════════════════════════════════════ // Cordas + mini-ETs descendo UFO.ropes.forEach(rope=>{ if(rope.landed) return; const gnd=w2s(rope.worldX,rope.worldY); const etX=sx+(gnd.x-sx)*rope.progress+Math.sin(rope.swing)*3*(1-rope.progress); const etY=sy+(gnd.y-sy)*rope.progress; ctx.save(); ctx.strokeStyle='rgba(120,220,80,.75)'; ctx.lineWidth=1.8; ctx.lineCap='round'; ctx.setLineDash([5,3]); const ropeStartX=sx+Math.sin(UFO.wobble)*2; // leve oscilação natural ctx.beginPath(); ctx.moveTo(ropeStartX,sy+11); ctx.lineTo(etX,etY-22); ctx.stroke(); ctx.setLineDash([]); ctx.translate(etX,etY); ctx.rotate(Math.sin(rope.swing)*0.2); ctx.scale(0.55,0.55); ctx.globalAlpha=0.88; ctx.strokeStyle='rgba(120,220,80,.8)'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(0,-52); ctx.lineTo(0,-44); ctx.stroke(); const hg=ctx.createRadialGradient(-3,-40,2,-3,-40,14); hg.addColorStop(0,'rgba(160,255,180,.95)'); hg.addColorStop(1,'rgba(15,90,35,.8)'); ctx.fillStyle=hg; ctx.strokeStyle='rgba(0,90,20,.8)'; ctx.lineWidth=2; ctx.beginPath(); ctx.ellipse(0,-40,14,10,0,0,6.28); ctx.fill(); ctx.stroke(); [[-5,-43],[5,-43]].forEach(([ex2,ey2])=>{ ctx.fillStyle='#050805'; ctx.beginPath(); ctx.ellipse(ex2,ey2,4.5,3.5,0,0,6.28); ctx.fill(); ctx.fillStyle='rgba(0,200,60,.5)'; ctx.beginPath(); ctx.ellipse(ex2,ey2,2,1.5,0,0,6.28); ctx.fill(); }); ctx.fillStyle='rgba(40,170,70,.9)'; ctx.strokeStyle='rgba(0,90,20,.8)'; ctx.lineWidth=2; ctx.beginPath(); ctx.ellipse(0,-16,8,11,0,0,6.28); ctx.fill(); ctx.stroke(); const ls=Math.sin(rope.swing*1.5)*12; ctx.strokeStyle='rgba(60,200,80,.9)'; ctx.lineWidth=3; ctx.beginPath(); ctx.moveTo(-3,0); ctx.lineTo(-5+ls*.3,11); ctx.stroke(); ctx.beginPath(); ctx.moveTo(3,0); ctx.lineTo(5-ls*.3,11); ctx.stroke(); ctx.globalAlpha=1; ctx.restore(); });