feat(vida): modo autônomo, o Carinha como bichinho virtual

Novo módulo vida.py: energia, alegria, companhia e diversão caem com o
tempo e sobem com presença (PIR), movimento, conversa e carinho. Delas sai
o humor e, do humor, os atos: gestos, cochilo quando cansado, dormir à
noite sem ninguém por perto, acenar pedindo atenção quando carente com
gente perto, tocar reações da biblioteca e inventar figurinhas novas com
o Gemini (no máximo 3 por dia, 90 min entre elas).

- O celular recebe "humor x" e escolhe os gestos soltos por ele.
- Toque na tela do celular conta como carinho/cutucada (/vida/cuidar).
- Respeita E-Stop e o modo presença (ninguém em casa = quieto).
- Painel: cartão Vida no Ao vivo com humor, barras, cuidados, ritmo e
  diário; liga por padrão.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-06 04:21:23 -03:00
co-authored by Claude Opus 5.5
parent 8d410dbf24
commit b456f80716
11 changed files with 1036 additions and 16 deletions
+283
View File
@@ -0,0 +1,283 @@
<script lang="ts">
import { Gauge, Moon, Sparkles } from '@lucide/svelte';
import { estado } from '../estado.svelte';
import { configurarVida, cuidar, CUIDADOS, NECESSIDADES } from '../vida.svelte';
import Grupo from './Grupo.svelte';
import Interruptor from './Interruptor.svelte';
import Linha from './Linha.svelte';
// Cartão do bichinho: humor, necessidades, botões de cuidado e o diário do que ele fez sozinho.
const v = $derived(estado.vida);
let diarioAberto = $state(false);
const RITMOS = [
{ valor: 'calmo', nome: 'Calmo' },
{ valor: 'normal', nome: 'Normal' },
{ valor: 'agitado', nome: 'Agitado' },
] as const;
const MOTIVOS: Record<string, string> = {
desligado: '',
'sem chave Gemini': 'precisa da chave Gemini (Ajustes)',
'limite do dia': 'limite de hoje atingido',
'descansando a criatividade': 'próxima ideia em breve',
'biblioteca indisponível': 'biblioteca indisponível',
};
function hora(s: number) {
return new Date(s * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function idade(d: number) {
return d === 0 ? 'nasceu hoje' : d === 1 ? '1 dia de vida' : `${d} dias de vida`;
}
const detalheFigurinhas = $derived(
v ? [`${v.emojis_hoje} de ${v.config.max_emojis_dia} hoje`, MOTIVOS[v.pode_criar] ?? v.pode_criar].filter(Boolean).join(' · ') : '',
);
</script>
<Grupo
titulo="Vida"
rodape={v?.config.ligado
? 'Ele decide sozinho o que fazer pelo humor. Tocar ou deslizar o dedo na tela do celular também conta como carinho.'
: 'Ligado, ele tem necessidades, humor, cochilos e inventa figurinhas, como um bichinho virtual.'}
>
{#if !v}
<p class="vazio">Carregando…</p>
{:else}
<div class="bicho" class:dormindo={v.dormindo}>
<span class="humor" aria-hidden="true">{v.config.ligado ? v.emoji : '🤖'}</span>
<div class="quem">
<strong>{v.config.ligado ? v.nome_humor : 'Modo autônomo desligado'}</strong>
<span>{idade(v.idade_dias)}{v.config.ligado && v.diario[0] ? ' · ' + v.diario[0].texto : ''}</span>
</div>
<Interruptor rotulo="Modo autônomo" checked={v.config.ligado} onchange={(l) => configurarVida({ ligado: l })} />
</div>
{#if v.config.ligado}
<div class="barras">
{#each NECESSIDADES as n (n.chave)}
{@const valor = v.necessidades[n.chave]}
<div class="barra" class:baixa={valor < 25}>
<span class="nome"><span aria-hidden="true">{n.emoji}</span> {n.nome}</span>
<span
class="trilho"
role="meter"
aria-label={n.nome}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={valor}
>
<span class="nivel" style="--v: {valor}%; --c: {n.cor}"></span>
</span>
<span class="num mono">{valor}</span>
</div>
{/each}
</div>
<div class="cuidados">
{#each CUIDADOS as c (c.acao)}
<button class="btn pequeno" onclick={() => cuidar(c.acao)}><span aria-hidden="true">{c.emoji}</span> {c.nome}</button>
{/each}
{#if v.dormindo}
<button class="btn pequeno" onclick={() => cuidar('acordar')}><span aria-hidden="true">☀️</span> Acordar</button>
{:else}
<button class="btn pequeno" onclick={() => cuidar('ninar')}><span aria-hidden="true">🌙</span> Ninar</button>
{/if}
</div>
<Linha icone={Sparkles} cor="#af52de" rotulo="Inventar figurinhas sozinho" detalhe={detalheFigurinhas}>
{#snippet acessorio()}
<Interruptor rotulo="Inventar figurinhas sozinho" checked={v.config.criar_emojis} onchange={(l) => configurarVida({ criar_emojis: l })} />
{/snippet}
</Linha>
<Linha icone={Gauge} cor="#ff9500" rotulo="Ritmo">
{#snippet acessorio()}
<span class="segmentos" role="radiogroup" aria-label="Ritmo">
{#each RITMOS as r (r.valor)}
<button
role="radio"
aria-checked={v.config.ritmo === r.valor}
class:ativo={v.config.ritmo === r.valor}
onclick={() => configurarVida({ ritmo: r.valor })}>{r.nome}</button
>
{/each}
</span>
{/snippet}
</Linha>
<Linha icone={Moon} cor="#5e5ce6" rotulo="Dormir à noite" detalhe="Das 23h às 7h, se não tiver ninguém por perto">
{#snippet acessorio()}
<Interruptor rotulo="Dormir à noite" checked={v.config.noite} onchange={(l) => configurarVida({ noite: l })} />
{/snippet}
</Linha>
{#if v.diario.length}
<ol class="diario">
{#each v.diario.slice(0, diarioAberto ? 30 : 5) as d (d.horario + d.texto)}
<li class={d.tipo}><time class="mono">{hora(d.horario)}</time> <span>{d.texto}</span></li>
{/each}
</ol>
{#if v.diario.length > 5}
<button class="btn fantasma pequeno mais" onclick={() => (diarioAberto = !diarioAberto)}>
{diarioAberto ? 'Mostrar menos' : 'Ver o diário inteiro'}
</button>
{/if}
{/if}
{/if}
{/if}
</Grupo>
<style>
.vazio {
padding: var(--e-3) var(--e-4);
color: var(--texto-3);
font-size: var(--t-sm);
}
.bicho {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-3) var(--e-4);
}
.humor {
display: grid;
place-items: center;
width: 52px;
height: 52px;
flex: none;
border-radius: 50%;
background: var(--superficie-2);
font-size: 30px;
line-height: 1;
animation: respirar 3.2s ease-in-out infinite;
}
.dormindo .humor {
animation-duration: 6s;
opacity: 0.8;
}
@keyframes respirar {
50% {
transform: scale(1.07);
}
}
.quem {
display: grid;
gap: 2px;
flex: 1;
min-width: 0;
}
.quem strong {
font-size: var(--t-md);
}
.quem span {
color: var(--texto-3);
font-size: var(--t-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.barras {
display: grid;
gap: var(--e-2);
padding: 0 var(--e-4) var(--e-3);
}
.barra {
display: grid;
grid-template-columns: 7.5em 1fr 2.2em;
align-items: center;
gap: var(--e-3);
font-size: var(--t-sm);
}
.nome {
color: var(--texto-2);
white-space: nowrap;
}
.trilho {
height: 8px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
overflow: hidden;
}
.nivel {
display: block;
width: var(--v);
height: 100%;
border-radius: inherit;
background: var(--c);
transition: width 600ms ease;
}
.num {
text-align: right;
color: var(--texto-3);
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
}
.baixa .num,
.baixa .nome {
color: var(--perigo);
}
.cuidados {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--e-2);
padding: 0 var(--e-4) var(--e-3);
}
.cuidados .btn {
gap: 4px;
padding: 0 var(--e-1);
}
.segmentos {
display: inline-flex;
padding: 2px;
border-radius: var(--raio-sm);
background: var(--superficie-3);
}
.segmentos button {
min-height: 28px;
padding: 0 var(--e-2);
border: 0;
border-radius: calc(var(--raio-sm) - 2px);
background: transparent;
color: var(--texto-2);
font: 500 var(--t-xs) var(--fonte);
cursor: pointer;
}
.segmentos button.ativo {
background: var(--superficie);
color: var(--texto);
box-shadow: var(--sombra);
}
.diario {
display: grid;
gap: 4px;
margin: 0;
padding: var(--e-3) var(--e-4);
border-top: 1px solid var(--borda);
list-style: none;
font-size: var(--t-sm);
}
.diario li {
display: flex;
gap: var(--e-2);
color: var(--texto-2);
}
.diario time {
color: var(--texto-3);
font-size: var(--t-xs);
padding-top: 2px;
}
.diario li.emoji {
color: #af52de;
}
.diario li.cuidado {
color: var(--texto);
}
.mais {
justify-self: start;
margin: 0 var(--e-3) var(--e-2);
}
@media (prefers-reduced-motion: reduce) {
.humor {
animation: none;
}
}
</style>