feat(jogos): pedra, papel e tesoura, quantos dedos e achou! com a mão

- lib/maos.ts: dedos esticados pelos 21 pontos do MediaPipe (vale em
  qualquer rotação), contagem, jogada e leitura estável (mão parada);
- lib/jogos.svelte.ts: os três jogos conversam com o Carinha por comandos
  (fala com legenda, gestos e símbolo nos olhos) e contam como brincadeira
  para o modo vida; cartão "Jogos com a mão" no Ao vivo liga a câmera e o
  rastreamento com um toque;
- servidor: comandos "dizer" (até 120 caracteres) e "olhos" (símbolos e
  números permitidos, 0,5 a 8 s); rosto mostra o símbolo nos olhos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-11 19:53:48 -03:00
co-authored by Claude Opus 5.5
parent 92701e8a16
commit 3d954efc03
10 changed files with 479 additions and 1 deletions
+10
View File
@@ -646,6 +646,15 @@ function falarFrase(texto, segundos) { // "dizer ..." do modo vida: le
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto, segundos: segundos || Math.max(4, Math.min(14, texto.length / 9)) }); if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto, segundos: segundos || Math.max(4, Math.min(14, texto.length / 9)) });
falar(texto); falar(texto);
} }
let olhosAte = null;
function mostrarNosOlhos(texto) { // "✌️ 2.5": símbolo nos olhos; "- 0" apaga
const i = texto.lastIndexOf(' ');
const simbolo = i > 0 ? texto.slice(0, i) : texto, seg = i > 0 ? parseFloat(texto.slice(i + 1)) : 2.5;
clearTimeout(olhosAte);
if (r.dormindo) { r.dormindo = false; }
window.carinhaOlhosIcone = simbolo && simbolo !== '-' ? simbolo : null;
if (window.carinhaOlhosIcone) olhosAte = setTimeout(() => { window.carinhaOlhosIcone = null; }, (seg || 2.5) * 1000);
}
function hesitar(fn, gesto) { // às vezes pensa um instante antes de agir function hesitar(fn, gesto) { // às vezes pensa um instante antes de agir
if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn(); if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn();
setTimeout(fn, 150 + Math.random() * 450); setTimeout(fn, 150 + Math.random() * 450);
@@ -858,6 +867,7 @@ function trata(ordem) {
const bruta = (ordem || "").trim(); const bruta = (ordem || "").trim();
if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase
if (/^dizer /i.test(bruta)) { falarFrase(bruta.slice(6).trim()); return; } if (/^dizer /i.test(bruta)) { falarFrase(bruta.slice(6).trim()); return; }
if (/^olhos /i.test(bruta)) { mostrarNosOlhos(bruta.slice(6).trim()); return; } // jogos: ✊ ✋ ✌️ e números
ordem = bruta.toLowerCase(); ordem = bruta.toLowerCase();
if (!ordem) return; if (!ordem) return;
if(ordem==='recarregar') { if(ordem==='recarregar') {
+10
View File
@@ -646,6 +646,15 @@ function falarFrase(texto, segundos) { // "dizer ..." do modo vida: le
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto, segundos: segundos || Math.max(4, Math.min(14, texto.length / 9)) }); if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto, segundos: segundos || Math.max(4, Math.min(14, texto.length / 9)) });
falar(texto); falar(texto);
} }
let olhosAte = null;
function mostrarNosOlhos(texto) { // "✌️ 2.5": símbolo nos olhos; "- 0" apaga
const i = texto.lastIndexOf(' ');
const simbolo = i > 0 ? texto.slice(0, i) : texto, seg = i > 0 ? parseFloat(texto.slice(i + 1)) : 2.5;
clearTimeout(olhosAte);
if (r.dormindo) { r.dormindo = false; }
window.carinhaOlhosIcone = simbolo && simbolo !== '-' ? simbolo : null;
if (window.carinhaOlhosIcone) olhosAte = setTimeout(() => { window.carinhaOlhosIcone = null; }, (seg || 2.5) * 1000);
}
function hesitar(fn, gesto) { // às vezes pensa um instante antes de agir function hesitar(fn, gesto) { // às vezes pensa um instante antes de agir
if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn(); if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn();
setTimeout(fn, 150 + Math.random() * 450); setTimeout(fn, 150 + Math.random() * 450);
@@ -858,6 +867,7 @@ function trata(ordem) {
const bruta = (ordem || "").trim(); const bruta = (ordem || "").trim();
if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase
if (/^dizer /i.test(bruta)) { falarFrase(bruta.slice(6).trim()); return; } if (/^dizer /i.test(bruta)) { falarFrase(bruta.slice(6).trim()); return; }
if (/^olhos /i.test(bruta)) { mostrarNosOlhos(bruta.slice(6).trim()); return; } // jogos: ✊ ✋ ✌️ e números
ordem = bruta.toLowerCase(); ordem = bruta.toLowerCase();
if (!ordem) return; if (!ordem) return;
if(ordem==='recarregar') { if(ordem==='recarregar') {
+17
View File
@@ -66,6 +66,8 @@ ATIVIDADES = ["ler", "cantar", "bola", "videogame", "pintar", "malhar", "dormir"
"violao", "dancar", "corda", "meditar", "telescopio", "pipa", "bolhas", "skate", "computador", "pescar"] "violao", "dancar", "corda", "meditar", "telescopio", "pipa", "bolhas", "skate", "computador", "pescar"]
# Ordens internas que o rosto entende mas não viram botão. # Ordens internas que o rosto entende mas não viram botão.
ORDENS_INTERNAS = {"calma"} ORDENS_INTERNAS = {"calma"}
# O que os jogos podem mostrar nos olhos (pedra, papel, tesoura, números...)
OLHOS_JOGO = {"✊", "✋", "✌️", "❓", "⭐", "❤️", "👀", "🙈"} | {str(n) for n in range(11)}
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num # Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
# arquivo ao lado do servidor, para sobreviver a reinicios. # arquivo ao lado do servidor, para sobreviver a reinicios.
@@ -223,6 +225,21 @@ def ordem_de_comando(tipo, valor):
if nome not in ROSTOS: if nome not in ROSTOS:
raise ValueError("Rosto desconhecido: " + nome) raise ValueError("Rosto desconhecido: " + nome)
return "rosto " + nome return "rosto " + nome
if tipo == "dizer": # fala curta com legenda (jogos, painel)
texto = " ".join(str(valor or "").split())[:120]
if not texto:
raise ValueError("Diga alguma coisa")
return "dizer " + texto
if tipo == "olhos": # símbolo nos olhos por alguns segundos
valor = valor if isinstance(valor, dict) else {}
simbolo = str(valor.get("simbolo") or "")
if simbolo and simbolo not in OLHOS_JOGO:
raise ValueError("Símbolo inválido para os olhos")
try:
segundos = max(0.5, min(8.0, float(valor.get("segundos", 2.5))))
except (TypeError, ValueError):
raise ValueError("Segundos inválidos") from None
return "olhos %s %.1f" % (simbolo or "-", segundos)
raise ValueError("Tipo de comando inválido") raise ValueError("Tipo de comando inválido")
+12
View File
@@ -81,6 +81,18 @@ class ServidorTest(unittest.TestCase):
self.assertEqual(self.pedir('GET', '/app/health', token=None)[0], 200) self.assertEqual(self.pedir('GET', '/app/health', token=None)[0], 200)
self.assertEqual(self.pedir('GET', '/estado?t=' + TOKEN, token=None)[0], 200) self.assertEqual(self.pedir('GET', '/estado?t=' + TOKEN, token=None)[0], 200)
def test_comandos_dos_jogos_falar_e_olhos(self):
self.assertEqual(kiosk.ordem_de_comando('dizer', ' Pedra... '), 'dizer Pedra...')
self.assertEqual(kiosk.ordem_de_comando('olhos', {'simbolo': '✌️', 'segundos': 3}), 'olhos ✌️ 3.0')
self.assertEqual(kiosk.ordem_de_comando('olhos', {'simbolo': '3', 'segundos': 99}), 'olhos 3 8.0')
self.assertEqual(kiosk.ordem_de_comando('olhos', {}), 'olhos - 2.5')
for tipo, valor in (('olhos', {'simbolo': '<b>'}), ('olhos', {'simbolo': '1', 'segundos': 'x'}), ('dizer', ' ')):
with self.assertRaises(ValueError):
kiosk.ordem_de_comando(tipo, valor)
codigo, _ = self.pedir('POST', '/comando', {'tipo': 'dizer', 'valor': 'Já!'})
self.assertEqual(codigo, 200)
self.assertIn('dizer Já!', kiosk.FILA.ordens())
def test_pagina_do_rosto_sempre_conferida_e_api_sem_cache(self): def test_pagina_do_rosto_sempre_conferida_e_api_sem_cache(self):
# o WebView do celular não pode seguir com uma carinha.html velha depois de um deploy # o WebView do celular não pode seguir com uma carinha.html velha depois de um deploy
for rota, esperado in (('/carinha.html', 'no-cache'), ('/gestos', 'no-store')): for rota, esperado in (('/carinha.html', 'no-cache'), ('/gestos', 'no-store')):
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { Square, Video } from '@lucide/svelte';
import { salvarApp } from '../ajustes.svelte';
import { estado } from '../estado.svelte';
import { JOGOS, jogos } from '../jogos.svelte';
import type { Rastreamento } from '../rastreamento.svelte';
import Grupo from './Grupo.svelte';
// Jogos com a mão no Ao vivo: precisam da câmera transmitindo e do rastreamento de mãos (e de rosto
// para o "Achou!"), que rodam aqui no navegador.
let { rastreio }: { rastreio?: Rastreamento } = $props();
$effect(() => rastreio?.aoResultado((r) => jogos.alimentar(r)));
const cfg = $derived(estado.appConfig);
const pronto = $derived(!!cfg?.preview && !!cfg?.rastrearMaos && !!cfg?.rastrearRosto);
const atual = $derived(JOGOS.find((j) => j.id === jogos.ativo));
</script>
<Grupo titulo="Jogos com a mão" ajuda="jogos" rodape={atual ? atual.como : 'A câmera do celular lê a sua mão aqui no navegador; nada é gravado.'}>
{#if !pronto}
<div class="aviso">
<p>Para jogar, a câmera precisa estar transmitindo e o rastreamento de mãos e de rosto ligado.</p>
<button class="btn pequeno primario" disabled={!cfg} onclick={() => salvarApp({ preview: true, rastrearMaos: true, rastrearRosto: true })}>
<Video size={14} /> Ligar câmera e rastreamento
</button>
</div>
{:else if jogos.ativo}
<div class="jogando">
<div class="cabeca">
<strong>{atual?.emoji} {atual?.nome}</strong>
<button class="btn pequeno" onclick={() => jogos.parar()}><Square size={12} /> Parar</button>
</div>
<p class="fase">{jogos.fase || 'Começando…'}</p>
<p class="vendo">A câmera vê: <strong>{jogos.vendo || '…'}</strong></p>
{#if jogos.ativo === 'ppt'}
<p class="placar"><span>Você {jogos.placar.voce}</span> × <span>{jogos.placar.carinha} Carinha</span></p>
{/if}
</div>
{:else}
<div class="lista">
{#each JOGOS as j (j.id)}
<button class="jogo" onclick={() => jogos.iniciar(j.id)}>
<span class="emoji" aria-hidden="true">{j.emoji}</span>
<span class="nome">{j.nome}</span>
</button>
{/each}
</div>
{/if}
</Grupo>
<style>
.aviso, .jogando {
display: grid;
gap: var(--e-2);
padding: var(--e-3) var(--e-4);
}
.aviso p, .jogando p {
margin: 0;
font-size: var(--t-sm);
color: var(--texto-2);
}
.aviso .btn {
justify-self: start;
}
.cabeca {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--e-2);
}
.fase {
font-weight: 600;
color: var(--texto) !important;
}
.placar {
font-size: var(--t-lg) !important;
font-variant-numeric: tabular-nums;
color: var(--texto) !important;
}
.lista {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--e-2);
padding: var(--e-3) var(--e-4);
}
.jogo {
display: grid;
justify-items: center;
gap: 4px;
padding: var(--e-3) var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--texto);
font: inherit;
cursor: pointer;
transition: border-color var(--transicao), background var(--transicao);
}
.jogo:hover {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.emoji {
font-size: 1.8rem;
line-height: 1.1;
}
.nome {
font-size: var(--t-sm);
text-align: center;
}
</style>
+14 -1
View File
@@ -256,6 +256,19 @@ export const TOPICOS: Record<string, TopicoGuia> = {
{ emoji: '🛡️', nome: 'Privacidade', texto: 'Rostos sem nome somem em 7 dias. Apagar uma pessoa apaga as fotos e as impressões dela. Dá para desligar o reconhecimento a qualquer hora.' }, { emoji: '🛡️', nome: 'Privacidade', texto: 'Rostos sem nome somem em 7 dias. Apagar uma pessoa apaga as fotos e as impressões dela. Dá para desligar o reconhecimento a qualquer hora.' },
], ],
}, },
jogos: {
titulo: 'Jogos com a mão',
emoji: '✌️',
onde: 'Ao vivo › Jogos com a mão',
resumo:
'A câmera do celular vem para o painel e o rastreador lê os 21 pontos de cada mão aqui no navegador. O Carinha fala com legenda, faz gestos e mostra a jogada dele nos olhos. Precisa do Ao vivo aberto com a câmera transmitindo e o rastreamento de mãos e de rosto ligado (o botão do cartão liga tudo). Jogar conta como brincadeira para o modo vida.',
itens: [
{ emoji: '✌️', nome: 'Pedra, papel e tesoura', texto: 'Melhor de três. Ele conta "Pedra... papel... tesoura... já!", mostra ✊ ✋ ou ✌️ nos olhos e lê a sua mão parada. Pedra: mão fechada; papel: mão aberta; tesoura: indicador e médio.' },
{ emoji: '🖐️', nome: 'Quantos dedos?', texto: 'Cinco rodadas: nas ímpares o número aparece nos olhos, nas pares ele só fala. Vale somar as duas mãos.' },
{ emoji: '🙈', nome: 'Achou!', texto: 'Esconda o rosto com as mãos (ele procura: "Cadê?") e apareça: ele ri. Três vezes.' },
{ emoji: '💡', nome: 'Dica', texto: 'Mão inteira na imagem, de frente para a câmera e com luz. Mexer a mão no "Já!" atrapalha: deixe parada um instante.' },
],
},
jeito: { jeito: {
titulo: 'Jeito de gente', titulo: 'Jeito de gente',
emoji: '🧒', emoji: '🧒',
@@ -416,7 +429,7 @@ export const TOPICOS: Record<string, TopicoGuia> = {
/** Ordem do tutorial (página Guia). */ /** Ordem do tutorial (página Guia). */
export const ORDEM_GUIA = [ export const ORDEM_GUIA = [
'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'jeito', 'console', 'pessoas', 'criar', 'ideias', 'aparencia', 'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'jeito', 'jogos', 'console', 'pessoas', 'criar', 'ideias', 'aparencia',
'personalidade', 'acontecimentos', 'ligacoes', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade', 'personalidade', 'acontecimentos', 'ligacoes', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
'reacoes', 'conversa', 'movimento', 'atalhos', 'reacoes', 'conversa', 'movimento', 'atalhos',
]; ];
+233
View File
@@ -0,0 +1,233 @@
/**
* Jogos com a mão: a câmera do celular vem para o painel (WebRTC), o rastreador acha os 21 pontos de
* cada mão e o jogo conversa com o Carinha por comandos (fala com legenda, gestos e símbolos nos olhos).
* - Pedra, papel e tesoura: melhor de três, ele mostra a jogada dele nos olhos.
* - Quantos dedos?: ele pede um número e festeja quando você mostra certo.
* - Achou!: você esconde o rosto com as mãos e aparece; ele procura e ri.
*/
import { postar } from './api';
import { avisos } from './avisos.svelte';
import { contarDedos, Estavel, jogada, vence, type Jogada, type Ponto } from './maos';
import { cuidar } from './vida.svelte';
export type IdJogo = 'ppt' | 'dedos' | 'achou';
export const JOGOS: { id: IdJogo; nome: string; emoji: string; como: string }[] = [
{ id: 'ppt', nome: 'Pedra, papel e tesoura', emoji: '✌️', como: 'Melhor de três. No "Já!", mostre a mão parada para a câmera.' },
{ id: 'dedos', nome: 'Quantos dedos?', emoji: '🖐️', como: 'Ele pede um número e você mostra com os dedos. Cinco rodadas.' },
{ id: 'achou', nome: 'Achou!', emoji: '🙈', como: 'Esconda o rosto com as mãos e depois apareça. Três vezes.' },
];
const SIMBOLO: Record<Jogada, string> = { pedra: '✊', papel: '✋', tesoura: '✌️' };
const NOME: Record<Jogada, string> = { pedra: 'pedra', papel: 'papel', tesoura: 'tesoura' };
class Parou extends Error {}
class Jogos {
ativo = $state<IdJogo | null>(null);
fase = $state(''); // o que está acontecendo agora (aparece no painel)
vendo = $state(''); // o que a câmera está lendo da sua mão
placar = $state({ voce: 0, carinha: 0 });
private rodada = 0;
private maos: Ponto[][] = [];
private faces = 0;
private visto = 0; // quando chegou a última leitura
private jogadaEstavel = new Estavel<Jogada>(700, 0.7);
private dedosEstavel = new Estavel<number>(800, 0.75);
/** Cada leitura do rastreador (chamado pelo Ao vivo). */
alimentar(r: { faces: unknown[]; maos: Ponto[][] }) {
this.maos = r.maos;
this.faces = r.faces.length;
this.visto = performance.now();
const j = this.maos.length ? jogada(this.maos[0]) : null;
const d = this.maos.reduce((s, m) => s + contarDedos(m), 0);
this.jogadaEstavel.ver(j);
this.dedosEstavel.ver(this.maos.length ? d : null);
if (!this.ativo) return;
this.vendo = !this.maos.length ? (this.faces ? 'rosto, sem mão' : 'ninguém') :
this.ativo === 'ppt' ? (j ? `${SIMBOLO[j]} ${NOME[j]}` : 'mão (não entendi a jogada)') : `${d} dedo${d === 1 ? '' : 's'}`;
}
// ------------------------------------------------------------ falar com o Carinha
private async ordem(tipo: string, valor: unknown) {
try { await postar('/comando', { tipo, valor }); } catch { /* um comando perdido não para o jogo */ }
}
private dizer = (texto: string) => this.ordem('dizer', texto);
private gesto = (nome: string) => this.ordem('gesto', nome);
private olhos = (simbolo: string, segundos = 2.5) => this.ordem('olhos', { simbolo, segundos });
private esperar(ms: number) {
return new Promise<void>((ok, nao) => {
const id = this.ativo;
setTimeout(() => (this.ativo === id ? ok() : nao(new Parou())), ms);
});
}
/** Espera até a condição valer (checando a cada 100 ms); false se passar do tempo. */
private async ate(cond: () => boolean, ms: number) {
const fim = performance.now() + ms;
while (performance.now() < fim) {
if (cond()) return true;
await this.esperar(100);
}
return false;
}
private semCamera = () => performance.now() - this.visto > 3000;
// ------------------------------------------------------------ começar e parar
async iniciar(id: IdJogo) {
if (this.ativo) return;
if (this.semCamera()) {
avisos.erro(new Error('Ligue a câmera e o rastreamento de mãos aqui no Ao vivo para jogar.'));
return;
}
this.ativo = id;
this.placar = { voce: 0, carinha: 0 };
this.rodada = 0;
await this.gesto('acordar');
try {
if (id === 'ppt') await this.pedraPapelTesoura();
else if (id === 'dedos') await this.quantosDedos();
else await this.achou();
cuidar('brincar'); // brincar conta para o modo vida
} catch (e) {
if (!(e instanceof Parou)) avisos.erro(e);
} finally {
this.ativo = null;
this.fase = '';
this.olhos('', 0.5);
}
}
parar() {
if (!this.ativo) return;
this.ativo = null;
this.dizer('Tá bom, depois a gente brinca!');
}
// ------------------------------------------------------------ pedra, papel e tesoura
private async pedraPapelTesoura() {
this.fase = 'Explicando';
await this.dizer('Vamos jogar pedra, papel e tesoura! Melhor de três.');
await this.gesto('alegre');
await this.esperar(2500);
while (this.placar.voce < 2 && this.placar.carinha < 2) {
if (!(await this.ate(() => this.maos.length > 0, 8000))) {
this.fase = 'Esperando a sua mão';
await this.dizer('Cadê sua mão? Mostra pra câmera!');
if (!(await this.ate(() => this.maos.length > 0, 12000))) {
await this.dizer('Tudo bem, a gente joga depois!');
await this.gesto('triste');
return;
}
}
this.rodada++;
this.fase = `Rodada ${this.rodada}: contando`;
for (const palavra of ['Pedra...', 'Papel...', 'Tesoura...']) {
await this.dizer(palavra);
await this.esperar(750);
}
const minha = (['pedra', 'papel', 'tesoura'] as Jogada[])[Math.floor(Math.random() * 3)];
this.jogadaEstavel.limpar();
await this.olhos(SIMBOLO[minha], 3.5);
await this.dizer('Já!');
this.fase = `Rodada ${this.rodada}: lendo a sua mão`;
const leitura = { v: null as Jogada | null };
await this.ate(() => (leitura.v = this.jogadaEstavel.valor()) !== null, 2200);
const sua = leitura.v;
if (!sua) {
await this.dizer('Não vi direito... de novo!');
await this.gesto('confuso');
await this.esperar(2200);
continue;
}
const r = vence(sua, minha);
if (r > 0) {
this.placar.voce++;
await this.dizer(`${NOME[sua]} ganha de ${NOME[minha]}... Você ganhou essa!`);
await this.gesto('triste');
} else if (r < 0) {
this.placar.carinha++;
await this.dizer(`${NOME[minha]} ganha de ${NOME[sua]}! Essa é minha!`);
await this.gesto('comemorar');
} else {
await this.dizer(`Os dois ${NOME[minha]}! Empate!`);
await this.gesto('surpreso');
}
this.fase = `Você ${this.placar.voce} × ${this.placar.carinha} Carinha`;
await this.esperar(3200);
}
if (this.placar.voce > this.placar.carinha) {
await this.dizer(`Você ganhou de ${this.placar.voce} a ${this.placar.carinha}! Revanche depois, hein?`);
await this.gesto('rir');
} else {
await this.dizer(`Ganhei de ${this.placar.carinha} a ${this.placar.voce}! Foi divertido!`);
await this.gesto('comemorar');
}
await this.esperar(2500);
}
// ------------------------------------------------------------ quantos dedos?
private async quantosDedos() {
await this.dizer('Vamos brincar de contar! Mostra os dedos que eu pedir.');
await this.gesto('feliz');
await this.esperar(2500);
let anterior = 0;
for (this.rodada = 1; this.rodada <= 5; this.rodada++) {
let alvo = 1 + Math.floor(Math.random() * 5);
if (alvo === anterior) alvo = (alvo % 5) + 1;
anterior = alvo;
const mostra = this.rodada % 2 === 1; // rodadas ímpares: o número aparece nos olhos
this.fase = `Rodada ${this.rodada} de 5: pediu ${alvo}`;
this.dedosEstavel.limpar();
if (mostra) await this.olhos(String(alvo), 6);
await this.dizer(mostra ? 'Mostra esse tanto de dedos!' : `Mostra ${alvo} ${alvo === 1 ? 'dedo' : 'dedos'}!`);
const acertou = await this.ate(() => this.dedosEstavel.valor() === alvo, 9000);
if (acertou) {
this.placar.voce++;
await this.dizer(['Isso!', 'Acertou!', 'Muito bem!', 'Boa!'][this.rodada % 4]);
await this.gesto('comemorar');
} else {
await this.dizer(`Eram ${alvo}! Vamos pro próximo.`);
await this.gesto('pensar');
}
this.fase = `Acertos: ${this.placar.voce} de ${this.rodada}`;
await this.esperar(2800);
}
await this.dizer(this.placar.voce >= 4 ? `Você acertou ${this.placar.voce} de 5! Craque!` : `Você acertou ${this.placar.voce} de 5. Vamos treinar mais!`);
await this.gesto(this.placar.voce >= 4 ? 'coracao' : 'feliz');
await this.esperar(2500);
}
// ------------------------------------------------------------ achou!
private async achou() {
await this.dizer('Esconde o rosto com as mãos e depois aparece!');
await this.gesto('alegre');
await this.esperar(2000);
for (this.rodada = 1; this.rodada <= 3; this.rodada++) {
this.fase = `Rodada ${this.rodada} de 3: esperando você se esconder`;
if (!(await this.ate(() => this.faces > 0, 10000))) {
await this.dizer('Cadê você? Fica na frente da câmera!');
if (!(await this.ate(() => this.faces > 0, 10000))) return;
}
// escondeu: rosto some e as mãos aparecem
if (!(await this.ate(() => this.faces === 0 && this.maos.length > 0, 15000))) {
await this.dizer('Esconde o rosto com as mãos!');
if (!(await this.ate(() => this.faces === 0 && this.maos.length > 0, 15000))) return;
}
this.fase = 'Você se escondeu';
await this.gesto('espiar');
await this.dizer(['Cadê? Cadê?', 'Sumiu!', 'Onde você foi?'][this.rodada - 1]);
// apareceu de novo
if (await this.ate(() => this.faces > 0, 15000)) {
await this.gesto('rir');
await this.dizer('Achou!');
this.placar.voce++;
}
await this.esperar(2500);
}
await this.dizer('Que divertido! Adoro brincar de achou!');
await this.gesto('coracao');
await this.esperar(2000);
}
}
export const jogos = new Jogos();
+61
View File
@@ -0,0 +1,61 @@
/**
* Leitura da mão a partir dos 21 pontos do MediaPipe (0 = pulso; 4, 8, 12, 16, 20 = pontas dos dedos).
* Um dedo está esticado quando a ponta fica bem mais longe do pulso que a junta do meio, o que vale
* em qualquer rotação da mão. O polegar é medido em relação à base do mindinho.
*/
export interface Ponto { x: number; y: number }
export type Jogada = 'pedra' | 'papel' | 'tesoura';
const dist = (a: Ponto, b: Ponto) => Math.hypot(a.x - b.x, a.y - b.y);
/** [polegar, indicador, médio, anelar, mindinho] */
export function dedosEsticados(m: Ponto[]): boolean[] {
if (!m || m.length < 21) return [false, false, false, false, false];
const pulso = m[0];
const dedo = (ponta: number, junta: number) => dist(m[ponta], pulso) > dist(m[junta], pulso) * 1.12;
const polegar = dist(m[4], m[17]) > dist(m[3], m[17]) * 1.08 && dist(m[4], m[5]) > dist(m[3], m[5]) * 1.15;
return [polegar, dedo(8, 6), dedo(12, 10), dedo(16, 14), dedo(20, 18)];
}
export function contarDedos(m: Ponto[]): number {
return dedosEsticados(m).filter(Boolean).length;
}
export function jogada(m: Ponto[]): Jogada | null {
const [, indicador, medio, anelar, mindinho] = dedosEsticados(m);
const n = [indicador, medio, anelar, mindinho].filter(Boolean).length;
if (n === 0) return 'pedra';
if (n >= 4) return 'papel';
if (indicador && medio && !anelar && !mindinho) return 'tesoura';
return null; // mão no meio do caminho: não conta
}
/** Quem ganha: 1 = a, -1 = b, 0 = empate. */
export function vence(a: Jogada, b: Jogada): number {
if (a === b) return 0;
return (a === 'pedra' && b === 'tesoura') || (a === 'tesoura' && b === 'papel') || (a === 'papel' && b === 'pedra') ? 1 : -1;
}
/**
* Leitura estável: guarda o que viu nos últimos instantes e só responde quando a maioria dos quadros
* concorda (mão parada), para não pegar a mão no meio do movimento.
*/
export class Estavel<T> {
private amostras: { t: number; v: T | null }[] = [];
constructor(private janela = 700, private minimo = 0.7) {}
ver(v: T | null, t = performance.now()) {
this.amostras.push({ t, v });
this.amostras = this.amostras.filter((a) => t - a.t <= this.janela);
}
limpar() {
this.amostras = [];
}
valor(): T | null {
if (this.amostras.length < 3) return null;
const conta = new Map<T, number>();
for (const a of this.amostras) if (a.v !== null) conta.set(a.v, (conta.get(a.v) ?? 0) + 1);
let melhor: T | null = null, n = 0;
for (const [v, c] of conta) if (c > n) { melhor = v; n = c; }
return melhor !== null && n / this.amostras.length >= this.minimo ? melhor : null;
}
}
+8
View File
@@ -27,6 +27,8 @@ export class Rastreamento {
private ativo = false; private ativo = false;
private config: AppConfig = {}; private config: AppConfig = {};
private resultado: Resultado | null = null; private resultado: Resultado | null = null;
/** Quem quer cada leitura (os jogos com a mão). */
private ouvintes = new Set<(r: { faces: Face[]; maos: Ponto[][] }) => void>();
private alvo: AlvoRastreio | null = null; private alvo: AlvoRastreio | null = null;
private alvoEm = 0; private alvoEm = 0;
private em = 0; private em = 0;
@@ -49,6 +51,11 @@ export class Rastreamento {
addEventListener('resize', this.desenhar); addEventListener('resize', this.desenhar);
} }
aoResultado(fn: (r: { faces: Face[]; maos: Ponto[][] }) => void) {
this.ouvintes.add(fn);
return () => this.ouvintes.delete(fn);
}
private limpar() { private limpar() {
this.ctx.clearRect(0, 0, this.tela.width, this.tela.height); this.ctx.clearRect(0, 0, this.tela.width, this.tela.height);
} }
@@ -143,6 +150,7 @@ export class Rastreamento {
} }
const r = data as Resultado; const r = data as Resultado;
this.resultado = r; this.resultado = r;
this.ouvintes.forEach((fn) => fn({ faces: r.faces, maos: r.hands }));
this.em = r.timestamp; this.em = r.timestamp;
const anterior = this.alvo; const anterior = this.alvo;
const alvo = escolherAlvo(r.faces, r.hands, anterior); const alvo = escolherAlvo(r.faces, r.hands, anterior);
+3
View File
@@ -16,6 +16,7 @@
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte'; import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import VidaCartao from '../lib/componentes/VidaCartao.svelte'; import VidaCartao from '../lib/componentes/VidaCartao.svelte';
import QuemE from '../lib/componentes/QuemE.svelte'; import QuemE from '../lib/componentes/QuemE.svelte';
import Jogos from '../lib/componentes/Jogos.svelte';
import AprendizadoCartao from '../lib/componentes/AprendizadoCartao.svelte'; import AprendizadoCartao from '../lib/componentes/AprendizadoCartao.svelte';
import { estado } from '../lib/estado.svelte'; import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte'; import { Rastreamento } from '../lib/rastreamento.svelte';
@@ -167,6 +168,7 @@ const pirAtivo = $derived(
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes/camera" /> <Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes/camera" />
</Grupo></div> </Grupo></div>
<div class="c-jogos"><Jogos {rastreio} /></div>
<div class="c-gestos"><Grupo titulo="Gestos" ajuda="gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto."> <div class="c-gestos"><Grupo titulo="Gestos" ajuda="gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos"> <Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos /> <Gestos />
@@ -402,6 +404,7 @@ const pirAtivo = $derived(
.c-aprendizado { order: 2; } .c-aprendizado { order: 2; }
.c-celular { order: 3; } .c-celular { order: 3; }
.c-camera { order: 4; } .c-camera { order: 4; }
.c-jogos { order: 5; }
.c-gestos { order: 5; } .c-gestos { order: 5; }
.c-aparencia { order: 6; } .c-aparencia { order: 6; }
.c-personalidade { order: 7; } .c-personalidade { order: 7; }