From 3d954efc035c6949d3ec4a3aad0b205fc91331b6 Mon Sep 17 00:00:00 2001 From: julio Date: Sun, 11 Oct 2026 19:53:48 -0300 Subject: [PATCH] =?UTF-8?q?feat(jogos):=20pedra,=20papel=20e=20tesoura,=20?= =?UTF-8?q?quantos=20dedos=20e=20achou!=20com=20a=20m=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- android/app/assets/carinha.html | 10 ++ carinha.html | 10 ++ kiosk-server.py | 17 ++ tests/test_servidor.py | 12 ++ web/src/lib/componentes/Jogos.svelte | 111 +++++++++++++ web/src/lib/guia.ts | 15 +- web/src/lib/jogos.svelte.ts | 233 +++++++++++++++++++++++++++ web/src/lib/maos.ts | 61 +++++++ web/src/lib/rastreamento.svelte.ts | 8 + web/src/telas/AoVivo.svelte | 3 + 10 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/componentes/Jogos.svelte create mode 100644 web/src/lib/jogos.svelte.ts create mode 100644 web/src/lib/maos.ts diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html index b878025..cc187ce 100644 --- a/android/app/assets/carinha.html +++ b/android/app/assets/carinha.html @@ -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)) }); 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 if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn(); setTimeout(fn, 150 + Math.random() * 450); @@ -858,6 +867,7 @@ function trata(ordem) { const bruta = (ordem || "").trim(); 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 (/^olhos /i.test(bruta)) { mostrarNosOlhos(bruta.slice(6).trim()); return; } // jogos: ✊ ✋ ✌️ e números ordem = bruta.toLowerCase(); if (!ordem) return; if(ordem==='recarregar') { diff --git a/carinha.html b/carinha.html index b878025..cc187ce 100644 --- a/carinha.html +++ b/carinha.html @@ -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)) }); 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 if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn(); setTimeout(fn, 150 + Math.random() * 450); @@ -858,6 +867,7 @@ function trata(ordem) { const bruta = (ordem || "").trim(); 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 (/^olhos /i.test(bruta)) { mostrarNosOlhos(bruta.slice(6).trim()); return; } // jogos: ✊ ✋ ✌️ e números ordem = bruta.toLowerCase(); if (!ordem) return; if(ordem==='recarregar') { diff --git a/kiosk-server.py b/kiosk-server.py index 9ee5a6c..071d6a1 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -66,6 +66,8 @@ ATIVIDADES = ["ler", "cantar", "bola", "videogame", "pintar", "malhar", "dormir" "violao", "dancar", "corda", "meditar", "telescopio", "pipa", "bolhas", "skate", "computador", "pescar"] # Ordens internas que o rosto entende mas não viram botão. 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 # arquivo ao lado do servidor, para sobreviver a reinicios. @@ -223,6 +225,21 @@ def ordem_de_comando(tipo, valor): if nome not in ROSTOS: raise ValueError("Rosto desconhecido: " + 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") diff --git a/tests/test_servidor.py b/tests/test_servidor.py index 99af1cd..c15079d 100644 --- a/tests/test_servidor.py +++ b/tests/test_servidor.py @@ -81,6 +81,18 @@ class ServidorTest(unittest.TestCase): self.assertEqual(self.pedir('GET', '/app/health', 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': ''}), ('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): # 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')): diff --git a/web/src/lib/componentes/Jogos.svelte b/web/src/lib/componentes/Jogos.svelte new file mode 100644 index 0000000..04d9fbf --- /dev/null +++ b/web/src/lib/componentes/Jogos.svelte @@ -0,0 +1,111 @@ + + + + {#if !pronto} +
+

Para jogar, a câmera precisa estar transmitindo e o rastreamento de mãos e de rosto ligado.

+ +
+ {:else if jogos.ativo} +
+
+ {atual?.emoji} {atual?.nome} + +
+

{jogos.fase || 'Começando…'}

+

A câmera vê: {jogos.vendo || '…'}

+ {#if jogos.ativo === 'ppt'} +

Você {jogos.placar.voce} × {jogos.placar.carinha} Carinha

+ {/if} +
+ {:else} +
+ {#each JOGOS as j (j.id)} + + {/each} +
+ {/if} +
+ + diff --git a/web/src/lib/guia.ts b/web/src/lib/guia.ts index 079f1ea..e90597c 100644 --- a/web/src/lib/guia.ts +++ b/web/src/lib/guia.ts @@ -256,6 +256,19 @@ export const TOPICOS: Record = { { 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: { titulo: 'Jeito de gente', emoji: '🧒', @@ -416,7 +429,7 @@ export const TOPICOS: Record = { /** Ordem do tutorial (página 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', 'reacoes', 'conversa', 'movimento', 'atalhos', ]; diff --git a/web/src/lib/jogos.svelte.ts b/web/src/lib/jogos.svelte.ts new file mode 100644 index 0000000..acef801 --- /dev/null +++ b/web/src/lib/jogos.svelte.ts @@ -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 = { pedra: '✊', papel: '✋', tesoura: '✌️' }; +const NOME: Record = { pedra: 'pedra', papel: 'papel', tesoura: 'tesoura' }; + +class Parou extends Error {} + +class Jogos { + ativo = $state(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(700, 0.7); + private dedosEstavel = new Estavel(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((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(); diff --git a/web/src/lib/maos.ts b/web/src/lib/maos.ts new file mode 100644 index 0000000..7d19bb4 --- /dev/null +++ b/web/src/lib/maos.ts @@ -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 { + 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(); + 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; + } +} diff --git a/web/src/lib/rastreamento.svelte.ts b/web/src/lib/rastreamento.svelte.ts index 273d9c7..bfce0dd 100644 --- a/web/src/lib/rastreamento.svelte.ts +++ b/web/src/lib/rastreamento.svelte.ts @@ -27,6 +27,8 @@ export class Rastreamento { private ativo = false; private config: AppConfig = {}; 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 alvoEm = 0; private em = 0; @@ -49,6 +51,11 @@ export class Rastreamento { addEventListener('resize', this.desenhar); } + aoResultado(fn: (r: { faces: Face[]; maos: Ponto[][] }) => void) { + this.ouvintes.add(fn); + return () => this.ouvintes.delete(fn); + } + private limpar() { this.ctx.clearRect(0, 0, this.tela.width, this.tela.height); } @@ -143,6 +150,7 @@ export class Rastreamento { } const r = data as Resultado; this.resultado = r; + this.ouvintes.forEach((fn) => fn({ faces: r.faces, maos: r.hands })); this.em = r.timestamp; const anterior = this.alvo; const alvo = escolherAlvo(r.faces, r.hands, anterior); diff --git a/web/src/telas/AoVivo.svelte b/web/src/telas/AoVivo.svelte index 1f31d24..d24287d 100644 --- a/web/src/telas/AoVivo.svelte +++ b/web/src/telas/AoVivo.svelte @@ -16,6 +16,7 @@ import RostoPrevia from '../lib/componentes/RostoPrevia.svelte'; import VidaCartao from '../lib/componentes/VidaCartao.svelte'; import QuemE from '../lib/componentes/QuemE.svelte'; + import Jogos from '../lib/componentes/Jogos.svelte'; import AprendizadoCartao from '../lib/componentes/AprendizadoCartao.svelte'; import { estado } from '../lib/estado.svelte'; import { Rastreamento } from '../lib/rastreamento.svelte'; @@ -167,6 +168,7 @@ const pirAtivo = $derived( +
@@ -402,6 +404,7 @@ const pirAtivo = $derived( .c-aprendizado { order: 2; } .c-celular { order: 3; } .c-camera { order: 4; } + .c-jogos { order: 5; } .c-gestos { order: 5; } .c-aparencia { order: 6; } .c-personalidade { order: 7; }