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:
@@ -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') {
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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')):
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user