Jogo da Memória

0

O Jogo da Memória é o próximo passo natural após o 2048 — ele combina gerenciamento de estado com lógica por turnos e adiciona um elemento novo: o jogador precisa lembrar o que viu. É o primeiro jogo da série com interação exclusivamente por clique do mouse, o que abre um novo capítulo no design de interfaces de jogo.

📚 Curiosidade: o Jogo da Memória foi patenteado por Ravensburger na Alemanha em 1959, mas variações do conceito existem há séculos em culturas diferentes. A versão digital ganhou popularidade como exercício cognitivo e é um dos primeiros projetos ensinados em cursos de programação web — porque ele exige exatamente o raciocínio sobre estado, eventos e temporização que separa iniciantes de desenvolvedores experientes.

O que este projeto introduz?

Assim como o 2048 abandonou a física por lógica de matriz, o Jogo da Memória adiciona um conceito que ainda não apareceu na série: estado por objeto individual. Cada carta tem seu próprio estado e o jogo reage à combinação de estados de duas cartas simultâneas.

Projetos anterioresJogo da Memória
Input contínuo (teclado held)Input pontual (clique do mouse)
Lógica roda em update()Lógica roda em callbacks de evento
Objetos criados e destruídosObjetos mudam de estado visual
Ação imediata ao inputAção adiada com time.delayedCall
Estado global da partidaEstado individual por objeto

A série completa

Pong
física
Breakout
grupos
Shooter
spawn
Plataforma
câmera
2048
matriz
Memória
estado · clique

Objetivos do projeto

  • Grade 4×4 com 16 cartas (8 pares)
  • Cartas embaralhadas aleatoriamente a cada partida
  • Clique para revelar carta, comparar duas por vez
  • Par correto fica revelado; par errado vira de volta após pausa
  • Contador de jogadas e cronômetro
  • Tela de vitória ao encontrar todos os pares

Estados de uma carta

Cada carta pode estar em um de três estados. O jogo inteiro gira em torno de gerenciar esses estados:

 
hidden
🌻
revealed
🌻
matched
Se o par não combinar, a carta volta para hidden após uma pausa.

Fluxo do jogo

Boot Menu MemoryScene Todos os pares Vitória

Estrutura de arquivos

src/
 ├── main.jsx
 ├── PhaserGame.jsx
 ├── scenes/
 │   ├── BootScene.js
 │   ├── MenuScene.js
 │   └── MemoryScene.js
 ├── objects/
 │   └── Card.js          # carta com 3 estados visuais
 └── systems/
     └── CardManager.js   # embaralhamento e lógica de turnos
Separação de responsabilidades

O CardManager cuida de toda a lógica — embaralhar, registrar cliques, comparar pares e verificar vitória. O Card cuida apenas de como cada carta aparece na tela. A MemoryScene conecta os dois e gerencia o cronômetro e o placar. Nenhum dos três sabe mais do que precisa sobre os outros.


1
Card.js — a carta com três faces

A carta tem três representações visuais: verso (fundo colorido sem símbolo), frente (símbolo revelado) e par encontrado (destaque verde). O método setState troca entre elas sem recriar o objeto.

src/objects/Card.js
// Estados possíveis de uma carta
export const CARD_STATE = {
  HIDDEN:   'hidden',
  REVEALED: 'revealed',
  MATCHED:  'matched'
}

export default class Card {
  constructor(scene, x, y, symbol, id) {
    this.scene  = scene
    this.symbol = symbol   // emoji ou letra do par
    this.id     = id       // índice único na grade
    this.state  = CARD_STATE.HIDDEN
    this.locked = false   // bloqueia clique durante animação

    // ─── Elementos visuais ────────────────────────────────

    // Fundo da carta
    this.bg = scene.add
      .rectangle(x, y, 108, 108, 0x0F6E56)
      .setStrokeStyle(2, 0x085041)
      .setInteractive()      // habilita clique

    // Símbolo (fica invisível enquanto hidden)
    this.label = scene.add
      .text(x, y, symbol, {
        fontSize: '36px',
        fontFamily: 'monospace'
      })
      .setOrigin(0.5)
      .setAlpha(0)  // começa invisível

    // Ícone de verso (fica visível enquanto hidden)
    this.icon = scene.add
      .text(x, y, '?', {
        fontSize: '32px',
        fontFamily: 'monospace',
        color: '#9FE1CB'
      })
      .setOrigin(0.5)
  }

  // Registra o callback de clique vindo da cena
  onClick(callback) {
    this.bg.on('pointerdown', () => {
      if (this.locked || this.state !== CARD_STATE.HIDDEN) return
      callback(this)
    })

    // Feedback visual de hover
    this.bg.on('pointerover',  () => {
      if (this.state === CARD_STATE.HIDDEN) this.bg.setFillStyle(0x1D9E75)
    })
    this.bg.on('pointerout', () => {
      if (this.state === CARD_STATE.HIDDEN) this.bg.setFillStyle(0x0F6E56)
    })
  }

  setState(state) {
    this.state = state
    switch (state) {

      case CARD_STATE.HIDDEN:
        this.bg.setFillStyle(0x0F6E56)
        this.label.setAlpha(0)
        this.icon.setAlpha(1)
        break

      case CARD_STATE.REVEALED:
        this.bg.setFillStyle(0xE1F5EE)
        this.label.setAlpha(1)
        this.icon.setAlpha(0)
        break

      case CARD_STATE.MATCHED:
        this.bg.setFillStyle(0x9FE1CB)
        this.bg.setStrokeStyle(3, 0x1D9E75)
        this.label.setAlpha(1)
        this.icon.setAlpha(0)
        break
    }
  }

  destroy() {
    this.bg.destroy()
    this.label.destroy()
    this.icon.destroy()
  }
}
💡 setInteractive() habilita os eventos de ponteiro (clique, hover) em qualquer objeto gráfico do Phaser. Sem ele, on('pointerdown') simplesmente não dispara.
2
CardManager.js — embaralhamento e lógica de turnos novo

O CardManager é responsável por três coisas: embaralhar os símbolos (Fisher-Yates shuffle), registrar qual carta foi clicada e comparar dois pares quando o segundo clique acontece.

src/systems/CardManager.js
import { CARD_STATE } from '../objects/Card'

// 8 pares de símbolos — cada um aparece 2 vezes no deck
const SYMBOLS = ['🌻','🍎','🌟','🌎','🚀','⚽','🆕','☕']

export default class CardManager {
  constructor(onMatch, onMismatch, onVictory) {
    this.onMatch    = onMatch      // callback: par correto
    this.onMismatch = onMismatch   // callback: par errado
    this.onVictory  = onVictory    // callback: todos pares encontrados

    this.firstCard  = null  // primeira carta do turno
    this.locked     = false // bloqueia durante a comparação
    this.matchCount = 0    // pares encontrados
    this.moves      = 0    // jogadas realizadas

    // Cria e embaralha o deck: [🌻,🌻,🍎,🍎, ...]
    this.deck = shuffle([...SYMBOLS, ...SYMBOLS])
  }

  // Chamado pela cena quando uma carta é clicada
  handleClick(card, scene) {
    if (this.locked) return

    card.setState(CARD_STATE.REVEALED)

    // ── Primeiro clique do turno ──────────────────────────
    if (!this.firstCard) {
      this.firstCard = card
      return
    }

    // ── Segundo clique: comparar ──────────────────────────
    this.moves++
    const first  = this.firstCard
    const second = card
    this.firstCard = null
    this.locked    = true  // bloqueia novos cliques

    if (first.symbol === second.symbol) {
      // ✔ Par correto
      first.setState(CARD_STATE.MATCHED)
      second.setState(CARD_STATE.MATCHED)
      first.locked  = true
      second.locked = true
      this.matchCount++
      this.locked = false
      this.onMatch(this.moves)

      if (this.matchCount === SYMBOLS.length) {
        this.onVictory(this.moves)
      }
    } else {
      // ✘ Par errado: aguarda 900ms e vira de volta
      scene.time.delayedCall(900, () => {
        first.setState(CARD_STATE.HIDDEN)
        second.setState(CARD_STATE.HIDDEN)
        this.locked = false
        this.onMismatch(this.moves)
      })
    }
  }
}

// Fisher-Yates shuffle: embaralhamento imparcial
function shuffle(arr) {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[arr[i], arr[j]] = [arr[j], arr[i]]
  }
  return arr
}
💡 O Fisher-Yates shuffle é o algoritmo correto para embaralhar arrays. O método ingênuo (sort(() => Math.random() - 0.5)) produz distribuições estatisticamente enviesadas — alguns arranjos aparecem com mais frequência que outros. Fisher-Yates garante distribuição uniforme.
3
MemoryScene — montando o tabuleiro

A cena cria as 16 cartas em uma grade 4×4 e instancia o CardManager com os três callbacks. O update() desta cena é mínimo — quase toda a lógica vive nos callbacks de clique.

src/scenes/MemoryScene.js — create()
import Card        from '../objects/Card'
import CardManager from '../systems/CardManager'

export default class MemoryScene extends Phaser.Scene {
  constructor() { super('Game') }

  create() {
    const { width: W, height: H } = this.cameras.main

    // Fundo
    this.add.rectangle(W/2, H/2, W, H, 0x041a14)

    // ─── CardManager com callbacks ────────────────────────
    this.manager = new CardManager(
      (moves) => this.onMatch(moves),
      (moves) => this.onMismatch(moves),
      (moves) => this.showVictory(moves)
    )

    // ─── Grade 4x4 de cartas ─────────────────────────────
    this.cards = []
    const cols = 4, rows = 4
    const cardW = 112, cardH = 112
    const gapX  = 8,  gapY  = 8
    const totalW = cols * cardW + (cols - 1) * gapX
    const totalH = rows * cardH + (rows - 1) * gapY
    const startX = (W - totalW) / 2 + cardW / 2
    const startY = 100

    for (let row = 0; row < rows; row++) {
      for (let col = 0; col < cols; col++) {
        const id  = row * cols + col
        const x   = startX + col * (cardW + gapX)
        const y   = startY + row * (cardH + gapY)
        const sym = this.manager.deck[id]

        const card = new Card(this, x, y, sym, id)

        // Passa o clique ao CardManager
        card.onClick((c) => this.manager.handleClick(c, this))

        this.cards.push(card)
      }
    }

    // ─── HUD ─────────────────────────────────────────────
    this.add.text(W/2, 30, 'Jogo da Memória', {
      fontSize: '20px', fontFamily: 'monospace',
      fontStyle: 'bold', color: '#9FE1CB'
    }).setOrigin(0.5)

    this.movesText = this.add.text(16, H - 36, 'Jogadas: 0', {
      fontSize: '15px', fontFamily: 'monospace', color: '#9FE1CB'
    })

    this.timerText = this.add.text(W - 16, H - 36, '0s', {
      fontSize: '15px', fontFamily: 'monospace', color: '#9FE1CB'
    }).setOrigin(1, 0)

    // ─── Cronômetro ───────────────────────────────────────
    this.elapsed  = 0
    this.gameOver = false

    // Atualiza o cronômetro a cada segundo
    this.time.addEvent({
      delay: 1000,
      loop: true,
      callback: () => {
        if (this.gameOver) return
        this.elapsed++
        this.timerText.setText(this.elapsed + 's')
      }
    })
  }

  onMatch(moves) {
    this.movesText.setText('Jogadas: ' + moves)
  }

  onMismatch(moves) {
    this.movesText.setText('Jogadas: ' + moves)

    // Shake rápido na câmera como feedback de erro
    this.cameras.main.shake(120, 0.004)
  }

  showVictory(moves) {
    this.gameOver = true
    const { width: W, height: H } = this.cameras.main

    // Overlay escuro semitransparente
    this.add.rectangle(W/2, H/2, W, H, 0x000000, 0.65)

    this.add.text(W/2, H/2 - 50, 'Parabéns!', {
      fontSize: '44px', fontFamily: 'monospace',
      fontStyle: 'bold', color: '#9FE1CB'
    }).setOrigin(0.5)

    this.add.text(W/2, H/2 + 10,
      moves + ' jogadas  ·  ' + this.elapsed + 's', {
      fontSize: '20px', fontFamily: 'monospace', color: '#ffffff'
    }).setOrigin(0.5)

    this.add.text(W/2, H/2 + 65,
      'ESPAÇO = jogar de novo', {
      fontSize: '13px', fontFamily: 'monospace', color: '#5DCAA5'
    }).setOrigin(0.5)

    this.input.keyboard
      .addKey('SPACE').on('down', () => this.scene.restart())
  }

  update() {
    // Nada aqui — toda a lógica vive nos callbacks de clique
  }
}
⚠️ O locked do CardManager é essencial. Sem ele, o jogador pode clicar em uma terceira carta durante os 900ms de pausa, corrompendo o estado do turno. Sempre bloqueie a entrada durante animações e transições assíncronas.
4
O fluxo completo de um turno importante

Entender o fluxo de um turno é fundamental para não se perder nos callbacks. Cada jogada completa passa por estas etapas:

1º clique
carta → revealed
2º clique
locked = true
comparar
symbols iguais?
matched
locked = false
1º clique
2º clique
comparar
900ms delay
hidden, locked=false
// Resumo do fluxo no CardManager:

// Clique 1: apenas salva a referência
if (!this.firstCard) {
  this.firstCard = card
  return                    // espera o segundo clique
}

// Clique 2: compara e decide o destino
this.locked = true           // ninguém mais clica agora

if (first.symbol === second.symbol)  // ✔ par correto
  marcarComoMatched()
  this.locked = false       // libera imediatamente

else                               // ✘ par errado
  aguardar900ms()
  virarDeVolta()
  this.locked = false       // libera após a pausa
5
Registrando no PhaserGame.jsx

O Jogo da Memória também não usa física arcade. O config é simples — apenas adicione a cena à lista.

src/PhaserGame.jsx
import { useEffect, useRef } from 'react'
import Phaser from 'phaser'
import BootScene   from './scenes/BootScene'
import MenuScene   from './scenes/MenuScene'
import MemoryScene from './scenes/MemoryScene'

export default function PhaserGame() {
  const divRef = useRef(null)

  useEffect(() => {
    const config = {
      type: Phaser.AUTO,
      width: 560,
      height: 640,
      parent: divRef.current,
      backgroundColor: '#041a14',
      // Sem physics — o jogo é baseado em eventos de clique
      scene: [BootScene, MenuScene, MemoryScene]
    }

    const game = new Phaser.Game(config)
    return () => game.destroy(true)
  }, [])

  return <div ref={divRef} />
}

Conceitos fundamentais aprendidos

setInteractive()
habilita eventos

Ativa os eventos de ponteiro em qualquer sprite ou forma geométrica. Pré-requisito para qualquer clique.

on(‘pointerdown’)
clique do mouse

Substitui o teclado como forma de input. Essencial para puzzles, card games e interfaces de jogo.

Estado por objeto
hidden · revealed · matched

Cada carta tem seu próprio estado. Padrão de máquina de estados — base de animações e IA.

locked flag
bloqueio de input

Impede interações durante transições assíncronas. Obrigatório em qualquer lógica com atraso de tempo.

Fisher-Yates shuffle
embaralhamento justo

Algoritmo estatisticamente correto para embaralhar arrays. Usado em jogos de carta, loot e mapas gerados.

cameras.main.shake()
feedback de erro

Shake de câmera como resposta visual a um erro. Técnica de game feel usada em quase todo jogo moderno.


Desafios para evoluir o jogo

🎨 Animações
  • Virar a carta com tween de escaleX
  • Efeito de brilho ao encontrar par
  • Fade in na tela de vitória
🏆 Modos
  • Grade 6×6 (18 pares)
  • Modo contra o tempo
  • Dificuldades com tempo menor
📊 Recordes
  • Salvar melhor tempo no localStorage
  • Ranking de menor número de jogadas
  • Histórico de partidas
🎵 Som e visual
  • Som de virar carta
  • Som diferente para par correto/errado
  • Temas de símbolos (animais, países)
🚀 Próximo nível: implemente a animação de virar carta usando this.tweens.add({ targets: card.bg, scaleX: 0, duration: 150, onComplete: () => { ... } }). Escalar X até zero, trocar o conteúdo e escalar de volta cria a ilusão perfeita de uma carta virando — com apenas 10 linhas de código.