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)) });
|
||||
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') {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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': '<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):
|
||||
# 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')):
|
||||
|
||||
@@ -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.' },
|
||||
],
|
||||
},
|
||||
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<string, TopicoGuia> = {
|
||||
|
||||
/** 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',
|
||||
];
|
||||
|
||||
@@ -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 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);
|
||||
|
||||
@@ -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(
|
||||
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes/camera" />
|
||||
</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.">
|
||||
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
|
||||
<Gestos />
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user