diff --git a/src/app/routes.tsx b/src/app/routes.tsx index c0381af..295ff07 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -1,5 +1,5 @@ import type { RouteObject } from 'react-router-dom'; -import { ContatoPage, HabilidadesPage, InicioPage, PortifolioPage, SobrePage } from '../pages'; +import { ContatoPage, HabilidadesPage, InicioPage, JogoDaVida, PortifolioPage, SobrePage } from '../pages'; const rotas: RouteObject[] = [ { @@ -10,6 +10,7 @@ const rotas: RouteObject[] = [ { path: 'habilidades', element: }, { path: 'portifolio', element: }, { path: 'contato', element: }, + { path: 'jogo-da-vida', element: }, ], }, ]; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 578acb2..fd30b6a 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -3,3 +3,4 @@ export { SobrePage } from './sobre/sobre.page'; export { HabilidadesPage } from './habilidades/habilidades.page'; export { PortifolioPage } from './portifolio/portifolio.page'; export { ContatoPage } from './contato/contato.page'; +export { JogoDaVida } from './jogo-da-vida/jogo-da-vida'; diff --git a/src/pages/inicio/inicio.page.tsx b/src/pages/inicio/inicio.page.tsx index 9af0d83..03d21be 100644 --- a/src/pages/inicio/inicio.page.tsx +++ b/src/pages/inicio/inicio.page.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Link } from 'react-router-dom'; import { PaginaLayout } from '../../app/layouts'; import { Menu } from '../../shared/ui'; import './inicio.style.css' @@ -20,7 +21,8 @@ const InicioPage: React.FC = () => { -
+
+ @@ -29,6 +31,8 @@ const InicioPage: React.FC = () => { + Explorar Jogo da Vida +
diff --git a/src/pages/inicio/inicio.style.css b/src/pages/inicio/inicio.style.css index 10d9b9c..81b7aff 100644 --- a/src/pages/inicio/inicio.style.css +++ b/src/pages/inicio/inicio.style.css @@ -15,6 +15,34 @@ justify-content: center; } +.inicio-link-cubo { + position: relative; + display: grid; + place-items: center; + color: var(--cor-primaria); +} + +.inicio-dica-cubo { + position: absolute; + bottom: -1rem; + padding: .45rem .75rem; + border: 1px solid var(--cor-borda); + border-radius: 99px; + font-size: .68rem; + font-weight: 600; + opacity: 0; + pointer-events: none; + transform: translateY(-.35rem); + transition: opacity var(--transicao), transform var(--transicao); +} + +.inicio-link-cubo:hover .inicio-dica-cubo, .inicio-link-cubo:focus-visible .inicio-dica-cubo { + opacity: 1; + transform: translateY(0); +} + +.inicio-link-cubo:focus-visible { outline: 2px solid var(--cor-primaria); outline-offset: .5rem; } + .inicio--imagem-cubo-impossivel { animation: girar-cubo 20s linear infinite; } diff --git a/src/pages/jogo-da-vida/gameHelpers.ts b/src/pages/jogo-da-vida/gameHelpers.ts new file mode 100644 index 0000000..52a988a --- /dev/null +++ b/src/pages/jogo-da-vida/gameHelpers.ts @@ -0,0 +1,53 @@ +export type Grid = boolean[][]; + +export const GRID_LINHAS = 32; +export const GRID_COLUNAS = 64; + +export const criarGrid = (linhas = GRID_LINHAS, colunas = GRID_COLUNAS): Grid => ( + Array.from({ length: linhas }, () => Array.from({ length: colunas }, () => false)) +); + +export const contarCelulasVivas = (grid: Grid): number => ( + grid.reduce((total, linha) => total + linha.filter(Boolean).length, 0) +); + +export const proximaGeracao = (grid: Grid, bordasConectadas = true): Grid => { + const linhas = grid.length; + const colunas = grid[0]?.length ?? 0; + + return grid.map((linha, linhaAtual) => linha.map((viva, colunaAtual) => { + let vizinhasVivas = 0; + + for (let deslocamentoLinha = -1; deslocamentoLinha <= 1; deslocamentoLinha += 1) { + for (let deslocamentoColuna = -1; deslocamentoColuna <= 1; deslocamentoColuna += 1) { + if (deslocamentoLinha === 0 && deslocamentoColuna === 0) continue; + + let linhaVizinha = linhaAtual + deslocamentoLinha; + let colunaVizinha = colunaAtual + deslocamentoColuna; + + if (bordasConectadas) { + linhaVizinha = (linhaVizinha + linhas) % linhas; + colunaVizinha = (colunaVizinha + colunas) % colunas; + } + + if (linhaVizinha >= 0 && linhaVizinha < linhas && colunaVizinha >= 0 && colunaVizinha < colunas && grid[linhaVizinha][colunaVizinha]) { + vizinhasVivas += 1; + } + } + } + + return viva ? vizinhasVivas === 2 || vizinhasVivas === 3 : vizinhasVivas === 3; + })); +}; + +export const alternarCelula = (grid: Grid, linha: number, coluna: number, viva?: boolean): Grid => ( + grid.map((celulas, indiceLinha) => ( + indiceLinha === linha + ? celulas.map((celula, indiceColuna) => (indiceColuna === coluna ? viva ?? !celula : celula)) + : celulas + )) +); + +export const gerarGridAleatorio = (linhas = GRID_LINHAS, colunas = GRID_COLUNAS, chanceDeVida = 0.28): Grid => ( + Array.from({ length: linhas }, () => Array.from({ length: colunas }, () => Math.random() < chanceDeVida)) +); diff --git a/src/pages/jogo-da-vida/jogo-da-vida.style.css b/src/pages/jogo-da-vida/jogo-da-vida.style.css new file mode 100644 index 0000000..5b6479d --- /dev/null +++ b/src/pages/jogo-da-vida/jogo-da-vida.style.css @@ -0,0 +1,143 @@ +.jogo-da-vida-pagina { display: flex; min-height: 100vh; flex-direction: column; } + +.jogo-da-vida-topo { + position: relative; + display: flex; + height: 5vh; + align-items: center; + justify-content: space-between; + padding: 0 5vh; +} + +.jogo-da-vida-topo > p { font-size: clamp(.72rem, 1.1vw, .95rem); font-weight: 700; letter-spacing: -.03em; } +.jogo-da-vida-topo > p span { color: var(--cor-secundaria); font-weight: 500; } +.jogo-da-vida-marca { position: absolute; top: 50%; left: 50%; width: 1rem; height: 1rem; transform: translate(-50%, -50%) rotate(45deg); background: var(--cor-primaria); } +.jogo-da-vida-marca:focus-visible { outline: 2px solid var(--cor-primaria); outline-offset: .35rem; } + +.jogo-da-vida-janela { + display: flex; + width: 100%; + height: 90vh; + gap: clamp(.8rem, 2vw, 1.6rem); + padding: 0 5vh; +} + +.jogo-da-vida-menu, +.jogo-da-vida { + border: 4px solid var(--cor-borda); + border-radius: 1.1rem; + background: var(--cor-superficie); +} + +.jogo-da-vida-menu { + display: flex; + width: 4rem; + min-width: 4rem; + flex-direction: column; + align-items: center; + padding: 1rem 0; +} + +.jogo-da-vida-menu-grupo { display: grid; gap: .7rem; } +.jogo-da-vida-menu-rodape { margin-top: auto; } + +.jogo-da-vida-botao-icone { + display: grid; + width: 2.25rem; + height: 2.25rem; + place-items: center; + border: 2px solid transparent; + border-radius: .45rem; + color: var(--cor-secundaria); + font-size: 1.1rem; + transition: background-color var(--transicao), color var(--transicao), border-color var(--transicao), transform var(--transicao); +} + +.jogo-da-vida-botao-icone:hover, +.jogo-da-vida-botao-icone.ativo { border-color: var(--cor-borda); color: var(--cor-primaria); background: var(--cor-background); } +.jogo-da-vida-botao-icone:active { transform: scale(.9); } +.jogo-da-vida-botao-icone.destaque { color: var(--cor-texto-claro); background: var(--cor-primaria); } + +.jogo-da-vida { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; + overflow: hidden; +} + +.jogo-da-vida-estatisticas { display: flex; gap: clamp(1.2rem, 3vw, 3rem); } +.jogo-da-vida-estatisticas div { display: flex; align-items: baseline; gap: .42rem; } +.jogo-da-vida-estatisticas span { color: var(--cor-secundaria); font-size: .56rem; font-weight: 700; letter-spacing: .12em; } +.jogo-da-vida-estatisticas strong { font-size: .82rem; font-variant-numeric: tabular-nums; } + +.jogo-da-vida-painel { + width: 100%; + height: 100%; + overflow: hidden; + border-radius: inherit; +} + +.jogo-da-vida-tabuleiro-wrap { width: 100%; height: 100%; overflow: auto; } + +.jogo-da-vida-tabuleiro { + display: grid; + width: 100%; + min-width: 42rem; + height: 100%; + min-height: 27rem; + grid-template-columns: repeat(var(--colunas), 1fr); + grid-template-rows: repeat(var(--linhas), 1fr); + background-color: color-mix(in srgb, var(--cor-primaria) 30%, transparent); + gap: 1px; + user-select: none; + touch-action: none; +} + +.jogo-da-vida-celula { min-width: 0; border-radius: 0; background: var(--cor-superficie); } +.jogo-da-vida-celula:hover { background: var(--cor-secundaria); } +.jogo-da-vida-celula.viva { background: var(--cor-primaria); } +.jogo-da-vida-celula:focus-visible { z-index: 1; outline: 2px solid var(--cor-destaque); outline-offset: -2px; } + +.jogo-da-vida-ajuda { + position: fixed; + z-index: 2; + top: 50%; + left: 50%; + width: min(44rem, calc(100vw - 3rem)); + max-height: calc(100vh - 3rem); + overflow: auto; + padding: clamp(1.75rem, 4vw, 3.25rem); + border: 4px solid var(--cor-borda); + border-radius: 1.1rem; + box-shadow: .75rem .75rem 0 color-mix(in srgb, var(--cor-primaria) 16%, transparent); + background: var(--cor-superficie); + transform: translate(-50%, -50%); +} + +.jogo-da-vida-ajuda > button { position: absolute; top: .7rem; right: .9rem; color: var(--cor-secundaria); font-size: 1.35rem; } +.jogo-da-vida-ajuda h2 { max-width: 28rem; margin: .8rem 0 1rem; font-size: clamp(1.45rem, 3vw, 2.15rem); letter-spacing: -.055em; } +.jogo-da-vida-ajuda-introducao { max-width: 37rem; color: var(--cor-secundaria); font-size: .92rem; line-height: 1.7; } +.jogo-da-vida-ajuda ul { display: grid; gap: .75rem; margin-top: 1.5rem; padding-left: 1.25rem; color: var(--cor-secundaria); font-size: .9rem; line-height: 1.5; } +.jogo-da-vida-ajuda-interacao { margin-top: 2rem; padding-top: 1.5rem; border-top: 2px solid color-mix(in srgb, var(--cor-primaria) 22%, transparent); } +.jogo-da-vida-ajuda-interacao h3 { margin-bottom: .65rem; font-size: .8rem; letter-spacing: .08em; } +.jogo-da-vida-ajuda-interacao p { color: var(--cor-secundaria); font-size: .88rem; line-height: 1.7; } +.jogo-da-vida-ajuda kbd { padding: .14rem .3rem; border: 1px solid var(--cor-borda); border-radius: .25rem; color: var(--cor-primaria); font: 600 .72rem monospace; background: var(--cor-background); } +.jogo-da-vida-sobretitulo { color: var(--cor-secundaria); font-size: .65rem; font-weight: 700; letter-spacing: .12em; } + +@media (max-width: 700px) { + .jogo-da-vida-janela { height: 90vh; gap: .6rem; padding: 0 1rem; } + .jogo-da-vida-menu, + .jogo-da-vida { border-width: 3px; border-radius: .8rem; } + .jogo-da-vida-topo { padding: 0 1rem; } + .jogo-da-vida-topo > p { font-size: .65rem; } + .jogo-da-vida-marca { width: .75rem; height: .75rem; } + .jogo-da-vida-menu { width: 3.25rem; min-width: 3.25rem; padding: .8rem 0; } + .jogo-da-vida-botao-icone { width: 2rem; height: 2rem; font-size: .95rem; } + .jogo-da-vida-menu-grupo { gap: .45rem; } + .jogo-da-vida-estatisticas { gap: .65rem; } + .jogo-da-vida-estatisticas span { font-size: .48rem; } + .jogo-da-vida-estatisticas strong { font-size: .68rem; } + .jogo-da-vida-tabuleiro { min-width: 34rem; min-height: 25rem; } +} diff --git a/src/pages/jogo-da-vida/jogo-da-vida.tsx b/src/pages/jogo-da-vida/jogo-da-vida.tsx new file mode 100644 index 0000000..6d13625 --- /dev/null +++ b/src/pages/jogo-da-vida/jogo-da-vida.tsx @@ -0,0 +1,175 @@ +import React, { FC, PointerEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { RodapeComponent } from '../../app/layouts/rodape/rodape.component'; +import { + alternarCelula, + contarCelulasVivas, + criarGrid, + gerarGridAleatorio, + GRID_COLUNAS, + GRID_LINHAS, + proximaGeracao, + type Grid, +} from './gameHelpers'; +import './jogo-da-vida.style.css'; + +const VELOCIDADE_INICIAL = 180; + +const Icone: FC<{ nome: 'play' | 'pause' | 'step' | 'clear' | 'random' | 'info' | 'speed'; }> = ({ nome }) => { + const icones = { play: '▶', pause: 'Ⅱ', step: '▸|', clear: '×', random: '✦', info: '?', speed: '↯' }; + return ; +}; + +const JogoDaVida: FC = () => { + const [grid, definirGrid] = useState(() => criarGrid()); + const [emExecucao, definirEmExecucao] = useState(false); + const [velocidade, definirVelocidade] = useState(VELOCIDADE_INICIAL); + const [geracao, definirGeracao] = useState(0); + const [mostrarAjuda, definirMostrarAjuda] = useState(false); + const animacaoRef = useRef(); + const ultimaAtualizacaoRef = useRef(0); + const desenhandoRef = useRef(false); + const estadoDesenhoRef = useRef(true); + const ultimaCelulaRef = useRef(''); + + const avancarGeracao = useCallback(() => { + definirGrid((gridAtual) => proximaGeracao(gridAtual)); + definirGeracao((valor) => valor + 1); + }, []); + + useEffect(() => { + if (!emExecucao) return undefined; + + const animar = (tempo: number) => { + if (tempo - ultimaAtualizacaoRef.current >= velocidade) { + avancarGeracao(); + ultimaAtualizacaoRef.current = tempo; + } + animacaoRef.current = requestAnimationFrame(animar); + }; + + animacaoRef.current = requestAnimationFrame(animar); + return () => { + if (animacaoRef.current) cancelAnimationFrame(animacaoRef.current); + }; + }, [avancarGeracao, emExecucao, velocidade]); + + const limpar = useCallback(() => { + definirEmExecucao(false); + definirGrid(criarGrid()); + definirGeracao(0); + }, []); + + const aleatorizar = useCallback(() => { + definirEmExecucao(false); + definirGrid(gerarGridAleatorio()); + definirGeracao(0); + }, []); + + const alternarVelocidade = useCallback(() => { + const velocidades = [80, 180, 320, 520]; + definirVelocidade((valorAtual) => velocidades[(velocidades.indexOf(valorAtual) + 1) % velocidades.length]); + }, []); + + useEffect(() => { + const aoPressionarTecla = (evento: KeyboardEvent) => { + if ((evento.target as HTMLElement).tagName === 'INPUT') return; + if (evento.code === 'Space') { evento.preventDefault(); definirEmExecucao((ativo) => !ativo); } + if (evento.key.toLowerCase() === 'n') avancarGeracao(); + if (evento.key.toLowerCase() === 'c') limpar(); + if (evento.key.toLowerCase() === 'r') aleatorizar(); + if (evento.key === 'Escape') definirMostrarAjuda(false); + }; + + window.addEventListener('keydown', aoPressionarTecla); + return () => window.removeEventListener('keydown', aoPressionarTecla); + }, [aleatorizar, avancarGeracao, limpar]); + + const pintarCelula = useCallback((linha: number, coluna: number, viva: boolean) => { + definirGrid((gridAtual) => alternarCelula(gridAtual, linha, coluna, viva)); + }, []); + + const iniciarDesenho = (evento: PointerEvent, linha: number, coluna: number, viva: boolean) => { + evento.currentTarget.setPointerCapture(evento.pointerId); + desenhandoRef.current = true; + estadoDesenhoRef.current = !viva; + ultimaCelulaRef.current = `${linha}-${coluna}`; + pintarCelula(linha, coluna, !viva); + }; + + const continuarDesenho = (evento: PointerEvent) => { + if (!desenhandoRef.current) return; + + const celula = document.elementFromPoint(evento.clientX, evento.clientY) as HTMLElement | null; + const linha = Number(celula?.dataset.linha); + const coluna = Number(celula?.dataset.coluna); + const chave = `${linha}-${coluna}`; + + if (!Number.isNaN(linha) && !Number.isNaN(coluna) && ultimaCelulaRef.current !== chave) { + ultimaCelulaRef.current = chave; + pintarCelula(linha, coluna, estadoDesenhoRef.current); + } + }; + + const pararDesenho = () => { desenhandoRef.current = false; ultimaCelulaRef.current = ''; }; + + return ( +
+
+

Jogo da Vida · Conway

+ +
+
GERAÇÃO{geracao}
+
VIVAS{contarCelulasVivas(grid)}
+
+
+
+ + +
+
+
+
+ {grid.map((linha, indiceLinha) => linha.map((viva, indiceColuna) => ( +
+
+
+
+ + {mostrarAjuda && ( +
+ +

REGRAS

+

A vida se reorganiza a cada geração.

+

O Jogo da Vida é um autômato celular: não há jogador ou objetivo final. A evolução depende apenas do estado inicial criado no tabuleiro.

+
    +
  • Uma célula viva sobrevive com 2 ou 3 vizinhas.
  • +
  • Uma célula morta nasce com exatamente 3 vizinhas.
  • +
  • Nos demais casos, a célula morre ou permanece morta.
  • +
+
+

Como interagir

+

Clique e arraste sobre as células para desenhar ou apagar uma área. Use Espaço para reproduzir/pausar, N para avançar, R para aleatorizar e C para limpar.

+
+
+ )} +
+ +
+ ); +}; + +export { JogoDaVida };