feat(rosto): motor de palpebras, 4 rostos novos, formato do olho e tela Rosto
- rosto-motor.js: emocoes como posicao de palpebra (cima, inclinacao, baixo) com transicao suave; respiracao, sacadas e piscada que achata o olho - rostos novos Carinha Viva, Bloco, Matriz (LEDs) e Lente (iris/pupila); os classicos continuam; emoji/figurinha nos olhos seguem no SVG - formato do olho (padrao, redondo, pilula, quadrado, largo) para Viva, Bloco e Matriz; salvo na aparencia e enviado como ordem "formato X" - toque na tela do S10: cutucar = susto e risada, deslizar = carinho, tocar dormindo acorda - painel: tela Rosto (celular em destaque, cartoes de rosto, formatos, cores, testar emocoes so na previa ou no celular, lista do que ja esta pronto); Ao vivo leva a ela; paleta ganha rostos e formatos - corrige /comando rosto que nao salvava a aparencia Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,22 @@ export const favoritos = new Favoritos();
|
||||
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
|
||||
export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome }));
|
||||
export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb }));
|
||||
export const formato = (nome: string) => tentar(() => postar('/comando', { tipo: 'formato', valor: nome }));
|
||||
|
||||
/** Rostos: os novos usam o motor de pálpebras (rosto-motor.js) e aceitam formato de olho. */
|
||||
export const ROSTOS_INFO: Record<string, { nome: string; descricao: string; novo: boolean; formato: boolean }> = {
|
||||
vivo: { nome: 'Carinha Viva', descricao: 'O rosto de sempre com pálpebras, brilho e micro-movimentos.', novo: true, formato: true },
|
||||
bloco: { nome: 'Bloco', descricao: 'Olhos quadrados que se recortam, estilo robô de mesa.', novo: true, formato: true },
|
||||
matriz: { nome: 'Matriz', descricao: 'Painel de LEDs retrô; cada olho é feito de pontos.', novo: true, formato: true },
|
||||
lente: { nome: 'Lente', descricao: 'Íris e pupila que dilata, pálpebras de metal.', novo: true, formato: false },
|
||||
carinha: { nome: 'Clássico', descricao: 'O desenho original, com boca.', novo: false, formato: false },
|
||||
miudo: { nome: 'Miúdo', descricao: 'Clássico com olhos menores.', novo: false, formato: false },
|
||||
eilik: { nome: 'Eilik', descricao: 'Clássico sem boca.', novo: false, formato: false },
|
||||
};
|
||||
export const FORMATOS_INFO: Record<string, string> = {
|
||||
padrao: 'Padrão', redondo: 'Redondo', pilula: 'Pílula', quadrado: 'Quadrado', largo: 'Largo',
|
||||
};
|
||||
export const nomeRosto = (id: string) => ROSTOS_INFO[id]?.nome ?? id;
|
||||
export const recarregarCelular = () =>
|
||||
tentar(async () => {
|
||||
const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
];
|
||||
onDestroy(() => cancelar.forEach((c) => c()));
|
||||
|
||||
/** Mostra uma ordem (gesto, rosto, formato) só na prévia, sem mandar ao celular. */
|
||||
export function ordem(texto: string) {
|
||||
enviar({ tipo: 'ordem', ordem: texto });
|
||||
}
|
||||
|
||||
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
|
||||
export function previa(receita: unknown) {
|
||||
enviar({ tipo: 'previa-reacao', receita });
|
||||
|
||||
@@ -19,7 +19,8 @@ class Estado {
|
||||
versao = $state('');
|
||||
autenticacao = $state(false);
|
||||
estop = $state({ ativo: false, desde: null as number | null });
|
||||
aparencia = $state({ rosto: 'carinha', cor: [92, 214, 255] as [number, number, number] });
|
||||
aparencia = $state<{ rosto: string; cor: [number, number, number]; formato?: string }>({ rosto: 'carinha', cor: [92, 214, 255], formato: 'padrao' });
|
||||
formatos = $state<string[]>([]);
|
||||
gestos = $state<string[]>([]);
|
||||
rostos = $state<string[]>([]);
|
||||
app = $state<Telemetria | null>(null);
|
||||
@@ -62,6 +63,7 @@ function aplicarFoto(f: EstadoServidor) {
|
||||
estado.aparencia = f.aparencia;
|
||||
estado.gestos = f.gestos;
|
||||
estado.rostos = f.rostos;
|
||||
estado.formatos = f.formatos ?? [];
|
||||
if (f.app) {
|
||||
estado.app = f.app;
|
||||
estado.appRecebidoEm = Date.now();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */
|
||||
import { cor, CORES, gesto, GESTOS_INFO, gestosDisponiveis, pararTudo, rearmar, recarregarCelular, rosto } from './comandos.svelte';
|
||||
import {
|
||||
cor, CORES, formato, FORMATOS_INFO, gesto, GESTOS_INFO, gestosDisponiveis, nomeRosto, pararTudo, rearmar, recarregarCelular, rosto,
|
||||
} from './comandos.svelte';
|
||||
import { estado } from './estado.svelte';
|
||||
import { nav, ROTAS } from './rotas.svelte';
|
||||
|
||||
@@ -33,6 +35,8 @@ export function itensDaPaleta(): ItemPaleta[] {
|
||||
// Itens base: navegação, ações gerais, gestos, rostos e cores.
|
||||
registrarNaPaleta(() => [
|
||||
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
|
||||
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
|
||||
...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
|
||||
estado.estop.ativo
|
||||
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
|
||||
: { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo },
|
||||
@@ -43,6 +47,6 @@ registrarNaPaleta(() => [
|
||||
titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`,
|
||||
fazer: () => gesto(g),
|
||||
})),
|
||||
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + r, fazer: () => rosto(r) })),
|
||||
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + nomeRosto(r), fazer: () => rosto(r) })),
|
||||
...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })),
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
|
||||
|
||||
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes';
|
||||
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
|
||||
|
||||
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
|
||||
@@ -10,9 +10,14 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||
{ id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' },
|
||||
];
|
||||
|
||||
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */
|
||||
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }];
|
||||
|
||||
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
|
||||
|
||||
function lerHash(): Rota {
|
||||
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
|
||||
return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo';
|
||||
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
|
||||
}
|
||||
|
||||
class Navegacao {
|
||||
|
||||
@@ -69,7 +69,8 @@ export interface EstadoServidor {
|
||||
seq: number;
|
||||
autenticacao: boolean;
|
||||
estop: { ativo: boolean; desde: number | null };
|
||||
aparencia: { rosto: string; cor: [number, number, number] };
|
||||
aparencia: { rosto: string; cor: [number, number, number]; formato?: string };
|
||||
formatos?: string[];
|
||||
gestos: string[];
|
||||
rostos: string[];
|
||||
app?: Telemetria;
|
||||
|
||||
Reference in New Issue
Block a user