From 82f9673692b053beed27b6dbf348faf494284eb0 Mon Sep 17 00:00:00 2001 From: julio Date: Sun, 11 Oct 2026 22:33:09 -0300 Subject: [PATCH] =?UTF-8?q?feat(jogos):=20tel=C3=A3o=20com=20contagem=20re?= =?UTF-8?q?gressiva,=20jogadas=20lado=20a=20lado=20e=20resultado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - comando "telao" (validado: texto curto, tom, símbolos permitidos, até 2 lados) e letreiro por cima do rosto: título enorme que estoura e pisca, as duas jogadas (VOCÊ ✊ × ✋ CARINHA), cor por resultado; - pedra, papel e tesoura: 3, 2, 1 piscando, "JÁ!", o que ele leu da sua mão, "VENCI!!!" / "VOCÊ GANHOU!" / "EMPATE!" com placar e o campeão; quantos dedos e achou! também usam o telão; - o cartão do painel mostra o mesmo destaque. Co-Authored-By: Claude Opus 5.5 --- android/app/assets/carinha.html | 43 ++++++++++++++++++++++++ carinha.html | 43 ++++++++++++++++++++++++ kiosk-server.py | 29 ++++++++++++++++ tests/test_servidor.py | 12 +++++++ web/src/lib/componentes/Jogos.svelte | 49 ++++++++++++++++++++++++++++ web/src/lib/jogos.svelte.ts | 48 +++++++++++++++++++++++---- 6 files changed, 218 insertions(+), 6 deletions(-) diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html index cc187ce..2bb3469 100644 --- a/android/app/assets/carinha.html +++ b/android/app/assets/carinha.html @@ -20,6 +20,27 @@ @keyframes aparecerFigura{from{opacity:0}to{opacity:1}} @media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}} #fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none} + /* telão dos jogos: por cima do rosto, texto grande na cor dos olhos (o resultado tem cor própria) */ + #telao{position:fixed;inset:0;z-index:8;display:flex;flex-direction:column;align-items:center;justify-content:center; + gap:2vh;background:rgba(0,0,0,.9);font-family:system-ui,sans-serif;color:var(--cor);text-align:center;pointer-events:none} + #telao .titulo{font-weight:900;font-size:clamp(40px,24vh,240px);line-height:1;letter-spacing:.02em; + text-shadow:0 0 .25em currentColor} + #telao .sub{font-size:clamp(14px,6vh,48px);font-weight:600;opacity:.9} + #telao .lados{display:flex;align-items:center;gap:5vw} + #telao .lado{display:flex;flex-direction:column;align-items:center;gap:1vh;min-width:22vw} + #telao .rot{font-size:clamp(12px,5vh,36px);font-weight:700;letter-spacing:.1em;text-transform:uppercase;opacity:.85} + #telao .sim{font-size:clamp(40px,26vh,220px);line-height:1} + #telao .nome{font-size:clamp(12px,5vh,36px);font-weight:600} + #telao .x{font-size:clamp(24px,12vh,110px);font-weight:300;opacity:.7} + #telao.tom-eu .titulo{color:#ffd166} + #telao.tom-voce .titulo{color:#7ae582} + #telao.tom-empate .titulo{color:#cfe8ff} + #telao.tom-conta .titulo{font-size:clamp(60px,48vh,420px)} + #telao.entra .titulo,#telao.entra .lados{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1)} + #telao.piscar.entra .titulo{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1),telaoPisca .5s steps(2) .45s infinite} + @keyframes telaoEstoura{from{transform:scale(.3);opacity:0}to{transform:scale(1);opacity:1}} + @keyframes telaoPisca{50%{opacity:.25}} + @media(prefers-reduced-motion:reduce){#telao *{animation:none!important}} /* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */ #semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh); @@ -646,6 +667,27 @@ 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); } +/* Telão dos jogos: contagem regressiva piscando, as duas jogadas lado a lado e o resultado grande. */ +let telaoAte = null; +function mostrarTelao(json) { + let t; + try { t = JSON.parse(json); } catch (e) { return; } + let el = document.getElementById('telao'); + if (!el) { el = document.createElement('div'); el.id = 'telao'; document.body.appendChild(el); } + const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const lados = (t.lados || []).map((l) => + '
' + esc(l.rotulo) + '' + esc(l.simbolo) + '' + + '' + esc(l.nome) + '
').join('
×
'); + el.className = 'tom-' + (t.tom || 'neutro') + (t.piscar ? ' piscar' : ''); + el.style.setProperty('--cor', 'rgb(' + CIANO.map(Math.round).join(',') + ')'); + el.innerHTML = (lados ? '
' + lados + '
' : '') + + (t.titulo ? '
' + esc(t.titulo) + '
' : '') + (t.sub ? '
' + esc(t.sub) + '
' : ''); + el.hidden = false; + void el.offsetWidth; el.classList.add('entra'); // reinicia a animação a cada letreiro + if (r.dormindo) r.dormindo = false; + clearTimeout(telaoAte); + telaoAte = setTimeout(() => { el.hidden = true; }, (t.segundos || 2.5) * 1000); +} let olhosAte = null; function mostrarNosOlhos(texto) { // "✌️ 2.5": símbolo nos olhos; "- 0" apaga const i = texto.lastIndexOf(' '); @@ -868,6 +910,7 @@ function trata(ordem) { 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 + if (/^telao /i.test(bruta)) { mostrarTelao(bruta.slice(6)); return; } // letreiro grande dos jogos ordem = bruta.toLowerCase(); if (!ordem) return; if(ordem==='recarregar') { diff --git a/carinha.html b/carinha.html index cc187ce..2bb3469 100644 --- a/carinha.html +++ b/carinha.html @@ -20,6 +20,27 @@ @keyframes aparecerFigura{from{opacity:0}to{opacity:1}} @media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}} #fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none} + /* telão dos jogos: por cima do rosto, texto grande na cor dos olhos (o resultado tem cor própria) */ + #telao{position:fixed;inset:0;z-index:8;display:flex;flex-direction:column;align-items:center;justify-content:center; + gap:2vh;background:rgba(0,0,0,.9);font-family:system-ui,sans-serif;color:var(--cor);text-align:center;pointer-events:none} + #telao .titulo{font-weight:900;font-size:clamp(40px,24vh,240px);line-height:1;letter-spacing:.02em; + text-shadow:0 0 .25em currentColor} + #telao .sub{font-size:clamp(14px,6vh,48px);font-weight:600;opacity:.9} + #telao .lados{display:flex;align-items:center;gap:5vw} + #telao .lado{display:flex;flex-direction:column;align-items:center;gap:1vh;min-width:22vw} + #telao .rot{font-size:clamp(12px,5vh,36px);font-weight:700;letter-spacing:.1em;text-transform:uppercase;opacity:.85} + #telao .sim{font-size:clamp(40px,26vh,220px);line-height:1} + #telao .nome{font-size:clamp(12px,5vh,36px);font-weight:600} + #telao .x{font-size:clamp(24px,12vh,110px);font-weight:300;opacity:.7} + #telao.tom-eu .titulo{color:#ffd166} + #telao.tom-voce .titulo{color:#7ae582} + #telao.tom-empate .titulo{color:#cfe8ff} + #telao.tom-conta .titulo{font-size:clamp(60px,48vh,420px)} + #telao.entra .titulo,#telao.entra .lados{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1)} + #telao.piscar.entra .titulo{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1),telaoPisca .5s steps(2) .45s infinite} + @keyframes telaoEstoura{from{transform:scale(.3);opacity:0}to{transform:scale(1);opacity:1}} + @keyframes telaoPisca{50%{opacity:.25}} + @media(prefers-reduced-motion:reduce){#telao *{animation:none!important}} /* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */ #semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh); @@ -646,6 +667,27 @@ 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); } +/* Telão dos jogos: contagem regressiva piscando, as duas jogadas lado a lado e o resultado grande. */ +let telaoAte = null; +function mostrarTelao(json) { + let t; + try { t = JSON.parse(json); } catch (e) { return; } + let el = document.getElementById('telao'); + if (!el) { el = document.createElement('div'); el.id = 'telao'; document.body.appendChild(el); } + const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const lados = (t.lados || []).map((l) => + '
' + esc(l.rotulo) + '' + esc(l.simbolo) + '' + + '' + esc(l.nome) + '
').join('
×
'); + el.className = 'tom-' + (t.tom || 'neutro') + (t.piscar ? ' piscar' : ''); + el.style.setProperty('--cor', 'rgb(' + CIANO.map(Math.round).join(',') + ')'); + el.innerHTML = (lados ? '
' + lados + '
' : '') + + (t.titulo ? '
' + esc(t.titulo) + '
' : '') + (t.sub ? '
' + esc(t.sub) + '
' : ''); + el.hidden = false; + void el.offsetWidth; el.classList.add('entra'); // reinicia a animação a cada letreiro + if (r.dormindo) r.dormindo = false; + clearTimeout(telaoAte); + telaoAte = setTimeout(() => { el.hidden = true; }, (t.segundos || 2.5) * 1000); +} let olhosAte = null; function mostrarNosOlhos(texto) { // "✌️ 2.5": símbolo nos olhos; "- 0" apaga const i = texto.lastIndexOf(' '); @@ -868,6 +910,7 @@ function trata(ordem) { 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 + if (/^telao /i.test(bruta)) { mostrarTelao(bruta.slice(6)); return; } // letreiro grande dos jogos ordem = bruta.toLowerCase(); if (!ordem) return; if(ordem==='recarregar') { diff --git a/kiosk-server.py b/kiosk-server.py index 071d6a1..521afbb 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -240,9 +240,38 @@ def ordem_de_comando(tipo, valor): except (TypeError, ValueError): raise ValueError("Segundos inválidos") from None return "olhos %s %.1f" % (simbolo or "-", segundos) + if tipo == "telao": # letreiro grande por cima do rosto (jogos) + return "telao " + json.dumps(validar_telao(valor), ensure_ascii=False, separators=(",", ":")) raise ValueError("Tipo de comando inválido") +TONS_TELAO = ("neutro", "eu", "voce", "empate", "conta") + + +def validar_telao(v): + """{titulo, sub, tom, piscar, segundos, lados:[{rotulo, simbolo, nome}]} só com texto curto e símbolos permitidos.""" + if not isinstance(v, dict): + raise ValueError("Telão inválido") + texto = lambda x, n: " ".join(str(x or "").split())[:n] + t = dict(titulo=texto(v.get("titulo"), 24), sub=texto(v.get("sub"), 80), + tom=v.get("tom") if v.get("tom") in TONS_TELAO else "neutro", piscar=v.get("piscar") is True) + try: + t["segundos"] = max(0.3, min(10.0, float(v.get("segundos", 2.5)))) + except (TypeError, ValueError): + raise ValueError("Segundos inválidos") from None + lados = v.get("lados") or [] + if not isinstance(lados, list) or len(lados) > 2: + raise ValueError("Lados inválidos") + t["lados"] = [] + for l in lados: + if not isinstance(l, dict) or (l.get("simbolo") and l.get("simbolo") not in OLHOS_JOGO): + raise ValueError("Símbolo inválido no telão") + t["lados"].append(dict(rotulo=texto(l.get("rotulo"), 12), simbolo=l.get("simbolo") or "", nome=texto(l.get("nome"), 14))) + if not t["titulo"] and not t["lados"]: + raise ValueError("Telão vazio") + return t + + class Handler(SimpleHTTPRequestHandler): extensoes_map = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", diff --git a/tests/test_servidor.py b/tests/test_servidor.py index c15079d..3caf35e 100644 --- a/tests/test_servidor.py +++ b/tests/test_servidor.py @@ -93,6 +93,18 @@ class ServidorTest(unittest.TestCase): self.assertEqual(codigo, 200) self.assertIn('dizer Já!', kiosk.FILA.ordens()) + def test_telao_dos_jogos(self): + ordem = kiosk.ordem_de_comando('telao', {'titulo': 'VENCI!!!', 'tom': 'eu', 'sub': 'Você 0 × 1 Carinha', 'segundos': 3, + 'lados': [{'rotulo': 'Você', 'simbolo': '✊', 'nome': 'pedra'}, + {'rotulo': 'Carinha', 'simbolo': '✋', 'nome': 'papel'}]}) + self.assertTrue(ordem.startswith('telao {')) + t = json.loads(ordem[6:]) + self.assertEqual((t['titulo'], t['tom'], len(t['lados'])), ('VENCI!!!', 'eu', 2)) + self.assertEqual(json.loads(kiosk.ordem_de_comando('telao', {'titulo': 'x' * 99, 'tom': 'roxo'})[6:])['tom'], 'neutro') + for ruim in ({}, {'titulo': 'a', 'lados': [{'simbolo': ''}]}, {'titulo': 'a', 'lados': [{}, {}, {}]}, 'texto'): + with self.assertRaises(ValueError): + kiosk.ordem_de_comando('telao', ruim) + 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 index 04d9fbf..e98b736 100644 --- a/web/src/lib/componentes/Jogos.svelte +++ b/web/src/lib/componentes/Jogos.svelte @@ -32,6 +32,21 @@

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

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

+ {#if jogos.destaque} + {@const d = jogos.destaque} +
+ {#if d.lados?.length} +
+ {#each d.lados as l, i (i)} + {#if i}×{/if} + {l.rotulo}{l.simbolo}{l.nome} + {/each} +
+ {/if} + {#if d.titulo}{d.titulo}{/if} + {#if d.sub}{d.sub}{/if} +
+ {/if} {#if jogos.ativo === 'ppt'}

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

{/if} @@ -77,6 +92,40 @@ font-variant-numeric: tabular-nums; color: var(--texto) !important; } + .destaque { + display: grid; + justify-items: center; + gap: 4px; + padding: var(--e-3); + border-radius: var(--raio); + background: var(--superficie-3); + text-align: center; + } + .destaque .titulo { + font-size: var(--t-xl); + font-weight: 800; + } + .destaque.tom-eu .titulo { color: #d39b00; } + .destaque.tom-voce .titulo { color: var(--ok); } + .destaque .lados { + display: flex; + align-items: center; + gap: var(--e-4); + } + .destaque .lado { + display: grid; + justify-items: center; + } + .destaque .lado b { + font-size: 2.2rem; + line-height: 1.1; + } + .destaque .x { + color: var(--texto-3); + } + .destaque small { + color: var(--texto-2); + } .lista { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); diff --git a/web/src/lib/jogos.svelte.ts b/web/src/lib/jogos.svelte.ts index acef801..2132e79 100644 --- a/web/src/lib/jogos.svelte.ts +++ b/web/src/lib/jogos.svelte.ts @@ -21,11 +21,22 @@ const NOME: Record = { pedra: 'pedra', papel: 'papel', tesoura: class Parou extends Error {} +export interface Telao { + titulo?: string; + sub?: string; + tom?: 'neutro' | 'eu' | 'voce' | 'empate' | 'conta'; + piscar?: boolean; + segundos?: number; + lados?: { rotulo: string; simbolo: string; nome: string }[]; +} + 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 }); + /** O que o telão do Carinha está mostrando (o painel mostra igual). */ + destaque = $state(null); private rodada = 0; private maos: Ponto[][] = []; private faces = 0; @@ -54,6 +65,11 @@ class Jogos { 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 }); + /** Letreiro grande por cima do rosto (e no cartão do painel). */ + private telao(t: Telao) { + this.destaque = t; + return this.ordem('telao', t); + } private esperar(ms: number) { return new Promise((ok, nao) => { @@ -93,6 +109,7 @@ class Jogos { } finally { this.ativo = null; this.fase = ''; + this.destaque = null; this.olhos('', 0.5); } } @@ -121,31 +138,42 @@ class Jogos { } this.rodada++; this.fase = `Rodada ${this.rodada}: contando`; - for (const palavra of ['Pedra...', 'Papel...', 'Tesoura...']) { - await this.dizer(palavra); - await this.esperar(750); + const palavras = ['Pedra...', 'Papel...', 'Tesoura...']; + for (let i = 0; i < 3; i++) { + await this.telao({ titulo: String(3 - i), sub: palavras[i], tom: 'conta', piscar: true, segundos: 1.0 }); + await this.dizer(palavras[i]); + await this.esperar(850); } const minha = (['pedra', 'papel', 'tesoura'] as Jogada[])[Math.floor(Math.random() * 3)]; this.jogadaEstavel.limpar(); await this.olhos(SIMBOLO[minha], 3.5); + await this.telao({ titulo: 'JÁ!', sub: 'Mostra a mão parada!', tom: 'conta', segundos: 1.8 }); 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.telao({ titulo: '?', sub: 'Não vi sua mão... de novo!', segundos: 2.2 }); 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++; + else if (r < 0) this.placar.carinha++; + await this.telao({ + titulo: r > 0 ? 'VOCÊ GANHOU!' : r < 0 ? 'VENCI!!!' : 'EMPATE!', + tom: r > 0 ? 'voce' : r < 0 ? 'eu' : 'empate', + sub: `Você ${this.placar.voce} × ${this.placar.carinha} Carinha`, + segundos: 3.4, + lados: [{ rotulo: 'Você', simbolo: SIMBOLO[sua], nome: NOME[sua] }, { rotulo: 'Carinha', simbolo: SIMBOLO[minha], nome: NOME[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 { @@ -155,7 +183,10 @@ class Jogos { this.fase = `Você ${this.placar.voce} × ${this.placar.carinha} Carinha`; await this.esperar(3200); } - if (this.placar.voce > this.placar.carinha) { + const ganhou = this.placar.voce > this.placar.carinha; + await this.telao({ titulo: ganhou ? 'VOCÊ É O CAMPEÃO!' : 'EU SOU O CAMPEÃO!!!', tom: ganhou ? 'voce' : 'eu', piscar: true, + sub: `Você ${this.placar.voce} × ${this.placar.carinha} Carinha`, segundos: 4.5 }); + if (ganhou) { await this.dizer(`Você ganhou de ${this.placar.voce} a ${this.placar.carinha}! Revanche depois, hein?`); await this.gesto('rir'); } else { @@ -179,8 +210,12 @@ class Jogos { this.fase = `Rodada ${this.rodada} de 5: pediu ${alvo}`; this.dedosEstavel.limpar(); if (mostra) await this.olhos(String(alvo), 6); + await this.telao(mostra ? { titulo: String(alvo), sub: 'Mostra esse tanto de dedos!', tom: 'conta', segundos: 3 } + : { titulo: '?', sub: `Mostra ${alvo} ${alvo === 1 ? 'dedo' : 'dedos'}!`, segundos: 3 }); 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); + await this.telao(acertou ? { titulo: '✔ ACERTOU!', tom: 'voce', sub: `${alvo} ${alvo === 1 ? 'dedo' : 'dedos'}`, segundos: 2.6 } + : { titulo: `ERAM ${alvo}!`, tom: 'empate', sub: 'Vamos pro próximo', segundos: 2.6 }); if (acertou) { this.placar.voce++; await this.dizer(['Isso!', 'Acertou!', 'Muito bem!', 'Boa!'][this.rodada % 4]); @@ -218,6 +253,7 @@ class Jogos { 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.telao({ titulo: 'ACHOU!', tom: 'voce', piscar: true, segundos: 2.2 }); await this.gesto('rir'); await this.dizer('Achou!'); this.placar.voce++;