1 Commits

19 changed files with 78 additions and 775 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

View File

@@ -7,6 +7,8 @@
import React from "react";
import PropTypes from "prop-types";
import { getCategoryIcon } from "./editors/toolboxIcons"
import { useVolume } from "../../hooks/useVolume";
export default function GameFooter({
gameConfig,
@@ -17,6 +19,8 @@ export default function GameFooter({
const totalPhases = gameConfig.fases.length;
const displayPhase = currentPhase ?? currentPhase;
const { isMuted, toggleVolume } = useVolume();
const ajuda = () => {
if (onHelpClick) {
onHelpClick();
@@ -25,11 +29,12 @@ export default function GameFooter({
alert("Recurso de ajuda será implementado em breve!");
};
return (
<div className="bg-gray-900">
<div className="flex items-center justify-between px-6 py-3">
{/* Lado esquerdo - Botão de Ajuda (desativado temporariamente, tour será reimplementado) */}
<div className="flex items-center invisible">
{/*<div className="flex items-center invisible">
<button
onClick={ajuda}
data-tour="help-button"
@@ -38,7 +43,19 @@ export default function GameFooter({
>
Ajuda
</button>
</div>*/}
<div className="flex items-center">
<button
onClick={toggleVolume}
data-tour="volume-button"
title={isMuted ? "Ligar som" : "Desligar som"}
aria-label={isMuted ? "Ligar som" : "Desligar som"}
className="bg-green-100 text-black font-medium py-2 px-6 lg:py-3 lg:px-9 text-sm lg:text-base rounded-full transition-all duration-200 hover:bg-green-200 hover:scale-105 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-green-300"
>
<i className={isMuted ? getCategoryIcon("Volume Desligado") : getCategoryIcon("Volume Ligado")} />
</button>
</div>
{/* Centro - Indicador de Fase Atual/Total */}
<div className="flex items-center space-x-4">
<div className="phase-indicator">

View File

@@ -42,6 +42,10 @@ export function getCategoryIcon(name) {
return "fa fa-database";
case "Caneta":
return "fa fa-pencil-alt";
case "Volume Desligado":
return "fa-solid fa-volume-xmark";
case "Volume Ligado":
return "fa-solid fa-volume-high";
default:
return "fa fa-cube";
}

View File

@@ -0,0 +1,23 @@
// hooks/useVolume.js
import { useCallback, useEffect, useState } from "react";
import { gameEventBus } from "../utils/gameEvents";
const STORAGE_KEY = "decoda-volume-muted";
export function useVolume() {
const [isMuted, setIsMuted] = useState(() => localStorage.getItem(STORAGE_KEY) === "true");
useEffect(() => {
gameEventBus.setVolumeMuted(isMuted);
}, [isMuted]);
const toggleVolume = useCallback(() => {
setIsMuted((prev) => {
const next = !prev;
localStorage.setItem(STORAGE_KEY, String(next));
return next;
});
}, []);
return { isMuted, toggleVolume };
}

View File

@@ -4,8 +4,6 @@
* @module pages.HomePage.HomePage
*/
import { useEffect } from "react";
import capicodaUrl from "@/vendor/capicoda/capicoda.js?url";
import Navbar from "../../components/Navbar";
import PropTypes from "prop-types";
import Hero from "./Hero";
@@ -18,23 +16,6 @@ import StudentsMaterials from "./StudentsMaterials";
import TeachersMaterials from "./TeachersMaterials";
const HomePage = () => {
// Mascote Capicoda: injetado apenas na página inicial. O widget é um script
// autocontido que se anexa ao body; na saída da home removemos o script e o
// nó raiz (#dcs-root) para não vazar para outras rotas.
useEffect(() => {
// Estamos dentro do próprio DECODA: o widget esconde os links "Acessar o DECODA".
window.CAPICODA_ON_DECODA = true;
const script = document.createElement("script");
script.src = capicodaUrl;
script.defer = true;
document.body.appendChild(script);
return () => {
script.remove();
const root = document.getElementById("dcs-root");
if (root) root.remove();
};
}, []);
return (
<div className="min-h-screen">
{/* Navegação */}

View File

@@ -49,14 +49,26 @@ export function setupGameController(
}
};
const setVolumeMutedHandler = (event) => {
if (scene.game?.sound) {
scene.game.sound.mute = event.detail.muted;
}
};
gameEventBus.addEventListener("executeCode", executeCodeHandler);
gameEventBus.addEventListener("resetGame", resetGameHandler);
gameEventBus.addEventListener("stopExecution", stopExecutionHandler);
gameEventBus.addEventListener("setVolumeMuted", setVolumeMutedHandler);
if (scene.game?.sound) {
scene.game.sound.mute = localStorage.getItem("decoda-volume-muted") === "true";
}
const cleanup = () => {
gameEventBus.removeEventListener("executeCode", executeCodeHandler);
gameEventBus.removeEventListener("resetGame", resetGameHandler);
gameEventBus.removeEventListener("stopExecution", stopExecutionHandler);
gameEventBus.removeEventListener("setVolumeMuted", setVolumeMutedHandler);
};
scene._cleanupController = cleanup;

View File

@@ -86,6 +86,15 @@ describe("gameEventBus", () => {
});
});
describe("setVolumeMuted", () => {
it("dispatches setVolumeMuted with muted flag in detail", async () => {
const p = listenOnce("setVolumeMuted");
gameEventBus.setVolumeMuted(true);
const e = await p;
expect(e.detail).toEqual({ muted: true });
});
});
it("is an EventTarget", () => {
expect(gameEventBus).toBeInstanceOf(EventTarget);
});

View File

@@ -103,6 +103,18 @@ class GameEventBus extends EventTarget {
stopExecution() {
this.dispatchEvent(new CustomEvent("stopExecution"));
}
/**
* React define mute global do áudio no Phaser.
*
* @function setVolumeMuted
* @param {boolean} muted - `true` para silenciar, `false` para restaurar
* @returns {void}
* @fires setVolumeMuted - CustomEvent com detail: { muted }
*/
setVolumeMuted(muted) {
this.dispatchEvent(new CustomEvent("setVolumeMuted", { detail: { muted } }));
}
}
/**

View File

@@ -1,135 +0,0 @@
# Capicoda 🚩🦫
A **capivara comunista** do Núcleo de Tecnologia do MTST. Um widget de chat que
aparece na página do Núcleo, conversa com quem visita, identifica se a pessoa é
desenvolvedora e a direciona para **contribuir com o [DECODA](https://git.mtst.tec.br/educacao/decoda/)**.
Projeto do hackathon do Núcleo de Tecnologia do MTST.
---
## ✨ O que é
- Balão de chat no canto inferior direito de qualquer site.
- Conversa por **roteiro fixo** (botões) — sem IA, sem backend, sem build.
- Faz a triagem do visitante (**dev / aprendiz / curiosx**) e entrega o caminho
certo: repositório, como rodar localmente, como abrir um PR, ou como ajudar
sem programar.
- Um único arquivo: **`capicoda.js`** (CSS, SVG da capivara e diálogo embutidos).
---
## 🚀 Rodar o demo
Não precisa instalar nada. Na pasta do projeto:
```bash
python3 -m http.server 8000
```
Abra **http://localhost:8000/demo.html** e clique no balão da capivara.
> Também dá pra só abrir o `demo.html` direto no navegador (duplo clique).
---
## 🔌 Embutir em outro site
Copie `capicoda.js` para o site e adicione **uma linha** antes do `</body>`:
```html
<script src="capicoda.js" defer></script>
```
Pronto — o balão se injeta sozinho. Não conflita com o CSS da página (tudo é
prefixado com `dcs-` e isolado num `#dcs-root`).
---
## 💬 Editar a conversa
Toda a conversa vive no objeto **`TREE`** no topo do `capicoda.js`. Cada nó:
```js
nome_do_no: {
msg: "Texto do balão", // ou um array de balões em sequência
options: [
{ label: "Botão A", next: "outro_no" }, // navega para outro nó
{ label: "Abrir repo", url: URLS.repo }, // abre um link em nova aba
{ label: "Destaque", next: "x", primary: true } // botão em vermelho cheio
]
}
```
Os links reais ficam no objeto `URLS` (repo, página do DECODA, página do Núcleo).
### Mapa do roteiro
```
start ──┬─ Bora! ───────────────► qualifica
└─ O que é o DECODA? ───► oque_decoda ──► qualifica
qualifica ─┬─ Sou dev ──────► area ─┬─ Front/JS ──► cta_front
│ ├─ Back/DevOps ► cta_infra
│ └─ Full/outra ─► cta_geral
├─ Tô aprendendo ► aprendiz
└─ Só curiosx ───► curioso
cta_* ─┬─ Ver repositório (link)
├─ Como rodar localmente ► setup
├─ Como mandar um PR ─────► fluxo_pr
└─ Voltar ao início ──────► start
```
---
## 🦫 Poses da capivara (imagens)
O mascote é uma imagem grande (≈190px) que **substitui o botão** e **troca de pose**
conforme o estado da conversa. As 4 poses vivem embutidas como data URI (WebP) no
`capicoda.js`, então o widget continua autocontido (1 tag de script).
| Pose | Quando | Nós do diálogo |
|------|--------|----------------|
| `start` | acenando, no início | `start` |
| `prof` | explicando | `oque_decoda`, `setup`, `fluxo_pr` |
| `anot` | pedindo respostas | `qualifica`, `area` |
| `end` | encerrando | `cta_*`, `aprendiz`, `curioso` |
O mapa nó→pose é o objeto **`NODE_IMG`** no topo do `capicoda.js` (fácil de ajustar).
Todas as imagens têm o mesmo canvas (768×768), então a capivara não muda de tamanho
nem se desloca ao trocar de pose.
### Regenerar as imagens
As fontes são `app/src/assets/capicoda_{start,prof,anot,end}.jpeg`. Para remover o
fundo e reembutir no widget:
```bash
python3 scripts/build_images.py # requer Pillow
```
O script remove o fundo (flood-fill a partir das bordas), salva os PNGs transparentes
em `app/src/assets/capicoda_*.png` e reescreve o bloco `IMGS` entre os marcadores
`/* __IMAGES_START__ */ … /* __IMAGES_END__ */`. **Não edite o `IMGS` à mão.**
---
## 🎨 Identidade
Capivara marrom de boina verde com estrela vermelha. Paleta MTST (vermelho `#c1121f`).
Tom acolhedor e militante-bem-humorado. Fallback: SVG inline desenhado à mão, usado se
as imagens não carregarem.
---
## 🔮 Próximos passos (fora do escopo do hackathon)
- Modo IA opcional (Claude API) para conversa livre.
- Captura de contato de quem quer contribuir (onboarding → mutirão).
- Puxar "good first issues" dinamicamente da aba de Issues do repositório.
- Integração na página real do Núcleo e/ou sobre o app DECODA.
---
Desenvolvido com ✊ para o DECODA · Núcleo de Tecnologia do MTST

File diff suppressed because one or more lines are too long

View File

@@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Capicoda — demo · Núcleo de Tecnologia do MTST</title>
<style>
:root { --vermelho: #c1121f; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: #1d1d1f; line-height: 1.6; background: #faf6f1;
}
header {
background: var(--vermelho); color: #fff; padding: 18px 24px;
display: flex; align-items: center; gap: 12px;
}
header .star { font-size: 26px; }
header h1 { font-size: 19px; font-weight: 700; }
.hero {
max-width: 760px; margin: 0 auto; padding: 64px 24px 40px; text-align: center;
}
.hero h2 { font-size: 38px; line-height: 1.2; margin-bottom: 16px; }
.hero h2 span { color: var(--vermelho); }
.hero p { font-size: 18px; color: #4a4a4a; max-width: 560px; margin: 0 auto; }
.grid {
max-width: 760px; margin: 0 auto; padding: 24px; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px;
}
.card { background: #fff; border: 1px solid #eee; border-radius: 14px; padding: 20px; }
.card h3 { font-size: 16px; margin-bottom: 6px; }
.card p { font-size: 14px; color: #555; }
.hint {
max-width: 760px; margin: 12px auto 80px; padding: 16px 20px; border-radius: 12px;
background: #fff3cd; border: 1px solid #ffe69c; color: #664d03; font-size: 14px;
text-align: center;
}
footer { text-align: center; padding: 24px; color: #999; font-size: 13px; }
</style>
</head>
<body>
<header>
<span class="star">🚩</span>
<h1>Núcleo de Tecnologia · MTST</h1>
</header>
<section class="hero">
<h2>Aprenda a programar com o <span>DECODA</span></h2>
<p>Plataforma educacional do MTST: lógica de programação através de jogos
e blocos visuais. Tecnologia como ferramenta de transformação social.</p>
</section>
<section class="grid">
<div class="card"><h3>🎮 Jogos</h3><p>Atividades interativas para aprender programação na prática.</p></div>
<div class="card"><h3>🧩 Blocos visuais</h3><p>Programação arrastar-e-soltar com Blockly, sem decorar sintaxe.</p></div>
<div class="card"><h3>🆓 Livre e gratuito</h3><p>100% gratuito, sem cadastro, código aberto.</p></div>
</section>
<div class="hint">
👉 Página de demonstração. O balão da capivara <b>Capicoda</b> aparece no
canto inferior direito — clique pra conversar.
</div>
<footer>Demo do mascote Capicoda · hackathon do Núcleo de Tecnologia do MTST</footer>
<!-- É só isto que precisa ir na página real do Núcleo: -->
<script src="capicoda.js" defer></script>
</body>
</html>

View File

@@ -1,152 +0,0 @@
#!/usr/bin/env python3
"""
Capicoda — processamento das imagens do mascote.
Para cada pose (capicoda_<estado>.jpeg em app/src/assets):
1. Remove o fundo branco (flood-fill a partir das bordas sobre a máscara de
pixels claros — preserva os brancos INTERNOS, cercados pelo contorno escuro:
olhos, papel do caderno, brilho dos óculos).
2. Salva um PNG transparente em alta (canvas quadrado preservado — todas as
poses ficam do MESMO tamanho, então a capivara não "pula" ao trocar).
3. Gera uma versão reduzida (EMB x EMB), codifica em base64 (WebP se suportado,
senão PNG) e injeta o mapa `IMGS` no capicoda.js entre os marcadores
/* __IMAGES_START__ */ ... /* __IMAGES_END__ */.
Uso: python3 scripts/build_images.py
Requisitos: Pillow.
"""
import base64
import io
import os
import re
from collections import deque
from PIL import Image, features
HERE = os.path.dirname(os.path.abspath(__file__))
CAPICODA_DIR = os.path.normpath(os.path.join(HERE, ".."))
ASSETS = os.path.normpath(os.path.join(HERE, "..", "..", "..", "assets"))
CAPICODA_JS = os.path.join(CAPICODA_DIR, "capicoda.js")
STATES = ["start", "prof", "anot", "end"]
WORK = 768 # resolução de processamento/saída do PNG (quadrado, uniforme)
EMB = 384 # resolução embutida no JS
LIGHT = 190 # canal mínimo p/ considerar um pixel "claro" (fundo/sombra)
def remove_bg(img):
"""Torna transparente o fundo claro conectado às bordas."""
img = img.convert("RGBA").resize((WORK, WORK), Image.LANCZOS)
w, h = img.size
px = img.load()
# 1) máscara de pixels "claros" (fundo branco + sombra clara)
light = bytearray(w * h)
for y in range(h):
row = y * w
for x in range(w):
r, g, b, _ = px[x, y]
if r >= LIGHT and g >= LIGHT and b >= LIGHT:
light[row + x] = 1
# 2) BFS a partir dos pixels claros da borda -> só o fundo EXTERNO
visited = bytearray(w * h)
dq = deque()
def seed(x, y):
i = y * w + x
if light[i] and not visited[i]:
visited[i] = 1
dq.append((x, y))
for x in range(w):
seed(x, 0)
seed(x, h - 1)
for y in range(h):
seed(0, y)
seed(w - 1, y)
while dq:
x, y = dq.popleft()
if x > 0:
seed(x - 1, y)
if x < w - 1:
seed(x + 1, y)
if y > 0:
seed(x, y - 1)
if y < h - 1:
seed(x, y + 1)
# 3) zera o alpha do fundo externo
cleared = 0
for y in range(h):
row = y * w
for x in range(w):
if visited[row + x]:
r, g, b, _ = px[x, y]
px[x, y] = (r, g, b, 0)
cleared += 1
return img, cleared
def data_uri(img):
"""Reduz para EMB e devolve (data_uri, mime, n_bytes)."""
small = img.resize((EMB, EMB), Image.LANCZOS)
buf = io.BytesIO()
if features.check("webp"):
small.save(buf, format="WEBP", quality=90, method=6)
mime = "image/webp"
else:
small.save(buf, format="PNG", optimize=True)
mime = "image/png"
raw = buf.getvalue()
b64 = base64.b64encode(raw).decode("ascii")
return "data:%s;base64,%s" % (mime, b64), mime, len(raw)
def patch_js(uris):
with open(CAPICODA_JS, "r", encoding="utf-8") as f:
src = f.read()
lines = [' var IMGS = {']
for s in STATES:
lines.append(' %s: "%s",' % (s, uris[s]))
lines.append(' };')
block = "/* __IMAGES_START__ */\n" + "\n".join(lines) + "\n /* __IMAGES_END__ */"
new, n = re.subn(
r"/\* __IMAGES_START__ \*/.*?/\* __IMAGES_END__ \*/",
lambda _: block,
src,
flags=re.S,
)
if n != 1:
raise SystemExit("ERRO: marcadores __IMAGES_START/END__ não encontrados (n=%d)" % n)
with open(CAPICODA_JS, "w", encoding="utf-8") as f:
f.write(new)
def main():
uris = {}
total = 0
for s in STATES:
src = os.path.join(ASSETS, "capicoda_%s.jpeg" % s)
if not os.path.exists(src):
raise SystemExit("ERRO: não encontrei %s" % src)
img = Image.open(src)
out, cleared = remove_bg(img)
png_path = os.path.join(ASSETS, "capicoda_%s.png" % s)
out.save(png_path, format="PNG", optimize=True)
uri, mime, nbytes = data_uri(out)
uris[s] = uri
total += nbytes
print(" %-6s -> %s (fundo: %d px) | embutido %s %d KB"
% (s, os.path.basename(png_path), cleared, mime, nbytes // 1024))
patch_js(uris)
print("PNGs transparentes (%dx%d) em %s" % (WORK, WORK, ASSETS))
print("IMGS embutido no capicoda.js — total embutido: %d KB" % (total // 1024))
if __name__ == "__main__":
main()