BlogGames

Game Com TreeJs

Game Com TreeJs

 

Game Com TreeJs
Game Com TreeJs

 

Introdução

Game com Three.js, explorando um pouco desta lib que ajuda na criação de games e animações usando o Javascript e que roda no navegador.

Este post é apenas um pequeno exemplo de um script de um game 3D usando apenas Javascript com o aucilixo da lib do Three.js!
Com a biblioteca Three.js podemos transformar o navegador em um verdadeiro palco para experiências 3D incríveis.
Usar o Three.js para fazer Games é só um exemplo divertido de como a web pode ganhar vida com luzes, sombras e efeitos interativos, tudo em tempo real.

 

Você pode visualizar o jogo no link abaixo:

Game Exemplo com TreeJs 

Scripts utilizado (html):

<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Game Three Js - Testes</title>
    <link rel="stylesheet" href="/css/main.css">
</head>
<body>
    <script type="module" src="main.js"></script>
</body>
</html>

 

Scripts utilizado (Javascript):

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { FBXLoader } from "three/addons/loaders/FBXLoader.js";
import { GUI } from "three/addons/libs/lil-gui.module.min.js";
// ===================================
// CONFIGURAÇÕES GLOBAIS
// ===================================
const CONFIG = {
  // Movimento
  MOVEMENT_SPEED: 200,
  ROTATION_SPEED: 8,
  RUN_SPEED_MULTIPLIER: 1.5,
  // Física do pulo
  JUMP_VELOCITY: 70,
  GRAVITY: -180,
  MAX_FALL_SPEED: -300,
  GROUND_LEVEL: 0,
  COYOTE_TIME: 0.15,
  // Animação
  ANIMATION_FADE_DURATION: 0.15,
  JUMP_UP_THRESHOLD: 10,
  JUMP_DOWN_THRESHOLD: -10,
  // Câmera
  CAMERA_FOV: 65,
  CAMERA_DISTANCE: 280,
  CAMERA_HEIGHT: 140,
  CAMERA_FOLLOW_SMOOTHNESS: 0.08,
  // Visual
  MODEL_SCALE: 0.3,
  SHADOW_MAP_SIZE: 2048,
  // Sistema de Combate com Física
  ATTACK: {
    PUNCH: {
      COOLDOWN: 0.5,
      MIN_RANGE: 20, // Distância mínima
      MAX_RANGE: 100, // Distância máxima
      ANGLE: Math.PI / 3, // Ângulo do cone
      FORCE: 250, // Força do impulso
      KNOCKBACK: 0.8, // Multiplicador de knockback
      HIT_WINDOW: 0.25, // Quando o hit acontece
      DURATION: 0.6,
      DAMAGE: 15,
    },
    KICK: {
      COOLDOWN: 0.8,
      MIN_RANGE: 30,
      MAX_RANGE: 140,
      ANGLE: Math.PI / 2.5,
      FORCE: 450,
      KNOCKBACK: 1.5,
      HIT_WINDOW: 0.35,
      DURATION: 0.9,
      DAMAGE: 25,
    },
  },
  // Física dos objetos
  PHYSICS: {
    FRICTION: 0.92, // Atrito do chão
    AIR_RESISTANCE: 0.98, // Resistência do ar
    BOUNCE: 0.3, // Quão elástico é o objeto
    ROTATION_DAMPING: 0.95, // Amortecimento da rotação
    MIN_VELOCITY: 0.01, // Velocidade mínima antes de parar
  },
  // Colisão
  CHARACTER_RADIUS: 30, // Raio de colisão do personagem
  OBJECT_RADIUS: 35, // Raio de colisão dos cubos (metade da diagonal)
};
// ===================================
// MÓDULO: OBJETO COM FÍSICA
// ===================================
class PhysicsObject {
  constructor(mesh) {
    this.mesh = mesh;
    this.velocity = new THREE.Vector3(0, 0, 0);
    this.angularVelocity = new THREE.Vector3(0, 0, 0);
    this.mass = 1.0;
    this.isGrounded = false;
    this.health = 100;
    this.maxHealth = 100;
    // Cria barra de vida
    this.createHealthBar();
  }
  createHealthBar() {
    const barWidth = 60;
    const barHeight = 4;
    const canvas = document.createElement("canvas");
    canvas.width = 128;
    canvas.height = 32;
    const ctx = canvas.getContext("2d");
    const texture = new THREE.CanvasTexture(canvas);
    const material = new THREE.SpriteMaterial({ map: texture });
    this.healthBar = new THREE.Sprite(material);
    this.healthBar.scale.set(barWidth, barHeight, 1);
    this.mesh.add(this.healthBar);
    this.healthBar.position.set(0, 40, 0);
    this.healthBarCanvas = canvas;
    this.healthBarCtx = ctx;
    this.updateHealthBar();
  }
  updateHealthBar() {
    const ctx = this.healthBarCtx;
    const canvas = this.healthBarCanvas;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // Fundo
    ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    // Barra de vida
    const healthPercent = this.health / this.maxHealth;
    const barWidth = canvas.width * healthPercent;
    // Cor baseada na vida
    if (healthPercent > 0.6) {
      ctx.fillStyle = "#00ff00";
    } else if (healthPercent > 0.3) {
      ctx.fillStyle = "#ffff00";
    } else {
      ctx.fillStyle = "#ff0000";
    }
    ctx.fillRect(0, 0, barWidth, canvas.height);
    // Borda
    ctx.strokeStyle = "#ffffff";
    ctx.lineWidth = 2;
    ctx.strokeRect(1, 1, canvas.width - 2, canvas.height - 2);
    this.healthBar.material.map.needsUpdate = true;
  }
  applyForce(force, point) {
    // Aplica força linear
    this.velocity.add(force.clone().multiplyScalar(1 / this.mass));
    // Aplica torque (rotação) baseado no ponto de impacto
    const centerToPoint = point.clone().sub(this.mesh.position);
    const torque = new THREE.Vector3().crossVectors(centerToPoint, force);
    this.angularVelocity.add(torque.multiplyScalar(0.01 / this.mass));
  }
  takeDamage(amount) {
    this.health = Math.max(0, this.health - amount);
    this.updateHealthBar();
    if (this.health <= 0) {
      this.onDestroy();
    }
  }
  onDestroy() {
    // Efeito de destruição
    const material = this.mesh.material;
    const originalColor = material.color.clone();
    let opacity = 1;
    const fadeOut = () => {
      opacity -= 0.05;
      material.opacity = opacity;
      material.transparent = true;
      if (opacity > 0) {
        requestAnimationFrame(fadeOut);
      } else {
        this.mesh.visible = false;
        // Regenera após 5 segundos
        setTimeout(() => this.respawn(), 5000);
      }
    };
    fadeOut();
  }
  respawn() {
    this.health = this.maxHealth;
    this.velocity.set(0, 0, 0);
    this.angularVelocity.set(0, 0, 0);
    this.mesh.material.opacity = 1;
    this.mesh.material.transparent = false;
    this.mesh.visible = true;
    this.mesh.rotation.set(0, 0, 0);
    this.updateHealthBar();
  }
  update(delta) {
    // Aplica gravidade
    if (!this.isGrounded) {
      this.velocity.y += CONFIG.GRAVITY * delta;
    }
    // Aplica velocidade
    this.mesh.position.add(this.velocity.clone().multiplyScalar(delta));
    // Aplica rotação angular
    this.mesh.rotation.x += this.angularVelocity.x * delta;
    this.mesh.rotation.y += this.angularVelocity.y * delta;
    this.mesh.rotation.z += this.angularVelocity.z * delta;
    // Colisão com o chão
    const groundY = 25; // Altura do centro do cubo
    if (this.mesh.position.y <= groundY) {
      this.mesh.position.y = groundY;
      // Bounce
      if (Math.abs(this.velocity.y) > 5) {
        this.velocity.y *= -CONFIG.PHYSICS.BOUNCE;
      } else {
        this.velocity.y = 0;
        this.isGrounded = true;
      }
    } else {
      this.isGrounded = false;
    }
    // Aplicar atrito
    if (this.isGrounded) {
      this.velocity.x *= CONFIG.PHYSICS.FRICTION;
      this.velocity.z *= CONFIG.PHYSICS.FRICTION;
      this.angularVelocity.multiplyScalar(CONFIG.PHYSICS.ROTATION_DAMPING);
    } else {
      // Resistência do ar
      this.velocity.x *= CONFIG.PHYSICS.AIR_RESISTANCE;
      this.velocity.z *= CONFIG.PHYSICS.AIR_RESISTANCE;
    }
    // Para o objeto se estiver muito devagar
    if (this.velocity.length() < CONFIG.PHYSICS.MIN_VELOCITY) {
      this.velocity.set(0, this.velocity.y, 0);
    }
    if (this.angularVelocity.length() < 0.01) {
      this.angularVelocity.set(0, 0, 0);
    }
    // Mantém a barra de vida sempre virada para a câmera
    if (this.healthBar) {
      this.healthBar.quaternion.copy(this.mesh.quaternion);
    }
  }
}
// ===================================
// MÓDULO: GERENCIADOR DE UI
// ===================================
class UIManager {
  constructor() {
    this.loadingScreen = null;
    this.combatFeedback = null;
    this.hitIndicator = null;
  }
  createLoadingScreen() {
    const loader = document.createElement("div");
    loader.id = "loading-screen";
    loader.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      color: white;
      font-family: Arial, sans-serif;
      z-index: 1000;
      transition: opacity 0.5s;
    `;
    loader.innerHTML = `
      <div style="text-align: center;">
        <h1 style="font-size: 3em; margin: 0; text-shadow: 2px 2px 4px rgba(0,0,0,0.3);">⚔️</h1>
        <h2 style="margin: 20px 0;">Sistema de Combate</h2>
        <div id="loading-progress" style="
          width: 300px;
          height: 8px;
          background: rgba(255,255,255,0.2);
          border-radius: 10px;
          overflow: hidden;
          margin: 20px 0;
        ">
          <div id="loading-bar" style="
            width: 0%;
            height: 100%;
            background: white;
            transition: width 0.3s;
            border-radius: 10px;
          "></div>
        </div>
        <p id="loading-text" style="font-size: 0.9em; opacity: 0.8;">Preparando ambiente...</p>
      </div>
    `;
    document.body.appendChild(loader);
    this.loadingScreen = loader;
  }
  updateLoadingProgress(percent, text) {
    const bar = document.getElementById("loading-bar");
    const textEl = document.getElementById("loading-text");
    if (bar) bar.style.width = `${percent}%`;
    if (textEl) textEl.textContent = text;
  }
  hideLoadingScreen() {
    if (this.loadingScreen) {
      this.loadingScreen.style.opacity = "0";
      setTimeout(() => this.loadingScreen.remove(), 500);
    }
  }
  createInstructions() {
    const instructions = document.createElement("div");
    instructions.style.cssText = `
      position: fixed;
      bottom: 20px;
      left: 50%;
      transform: translateX(-50%);
      background: rgba(0,0,0,0.8);
      color: white;
      padding: 20px 30px;
      border-radius: 15px;
      font-family: Arial, sans-serif;
      font-size: 14px;
      text-align: center;
      pointer-events: none;
      z-index: 100;
      box-shadow: 0 4px 20px rgba(0,0,0,0.5);
    `;
    instructions.innerHTML = `
      <div style="margin-bottom: 10px; font-size: 18px; font-weight: bold;">⚔️ CONTROLES DE COMBATE</div>
      <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; text-align: left;">
        <div><strong>WASD/Setas:</strong> Mover</div>
        <div><strong>ESPAÇO:</strong> Pular</div>
        <div><strong>SHIFT:</strong> Correr</div>
        <div><strong>Mouse:</strong> Câmera</div>
        <div style="color: #ffdd57;"><strong>J:</strong> Soco 👊</div>
        <div style="color: #ff6b6b;"><strong>K:</strong> Chute 🦵</div>
      </div>
      <div style="margin-top: 15px; font-size: 12px; opacity: 0.7;">
        💡 Aproxime-se dos cubos para acertá-los!
      </div>
    `;
    document.body.appendChild(instructions);
    setTimeout(() => {
      instructions.style.transition = "opacity 1s";
      instructions.style.opacity = "0";
      setTimeout(() => instructions.remove(), 1000);
    }, 15000);
  }
  createCombatFeedback() {
    const feedback = document.createElement("div");
    feedback.id = "combat-feedback";
    feedback.style.cssText = `
      position: fixed;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      font-family: 'Arial Black', sans-serif;
      font-size: 64px;
      font-weight: bold;
      color: white;
      text-shadow:
        3px 3px 0px rgba(0,0,0,0.8),
        -1px -1px 0px rgba(0,0,0,0.8),
        1px -1px 0px rgba(0,0,0,0.8),
        -1px 1px 0px rgba(0,0,0,0.8);
      pointer-events: none;
      z-index: 200;
      opacity: 0;
      transition: all 0.1s;
    `;
    document.body.appendChild(feedback);
    this.combatFeedback = feedback;
  }
  createHitIndicator() {
    const indicator = document.createElement("div");
    indicator.id = "hit-indicator";
    indicator.style.cssText = `
      position: fixed;
      top: 20px;
      right: 20px;
      background: rgba(0,0,0,0.8);
      color: white;
      padding: 15px 25px;
      border-radius: 10px;
      font-family: Arial, sans-serif;
      font-size: 16px;
      pointer-events: none;
      z-index: 150;
      display: none;
    `;
    document.body.appendChild(indicator);
    this.hitIndicator = indicator;
  }
  showCombatHit(type, damage, distance, combo) {
    if (!this.combatFeedback) this.createCombatFeedback();
    const messages = {
      punch: ["💥 POW!", "👊 BAM!", "🔥 HIT!"],
      kick: ["💥 BOOM!", "🦵 CRASH!", "⚡ SMASH!"],
    };
    const msg =
      messages[type][Math.floor(Math.random() * messages[type].length)];
    this.combatFeedback.innerHTML = `
      ${msg}<br>
      <span style="font-size: 32px;">${damage} DMG</span>
      ${
        combo > 1
          ? `<br><span style="font-size: 28px; color: #ffdd57;">x${combo} COMBO!</span>`
          : ""
      }
    `;
    this.combatFeedback.style.color = type === "kick" ? "#ff6b6b" : "#ffdd57";
    this.combatFeedback.style.opacity = "1";
    this.combatFeedback.style.transform = "translate(-50%, -50%) scale(1.2)";
    setTimeout(() => {
      this.combatFeedback.style.opacity = "0";
      this.combatFeedback.style.transform = "translate(-50%, -50%) scale(0.8)";
    }, 600);
    // Mostra indicador de distância
    this.showHitInfo(type, distance, combo);
  }
  showCombatMiss(type, reason = "") {
    if (!this.combatFeedback) this.createCombatFeedback();
    const messages = {
      "too-far": "🎯 MUITO LONGE!",
      "too-close": "⚠️ MUITO PERTO!",
      "wrong-angle": "↔️ FORA DE ALCANCE!",
      default: type === "punch" ? "👊 ERROU..." : "🦵 ERROU...",
    };
    this.combatFeedback.textContent = messages[reason] || messages["default"];
    this.combatFeedback.style.color = "#888888";
    this.combatFeedback.style.fontSize = "48px";
    this.combatFeedback.style.opacity = "0.7";
    this.combatFeedback.style.transform = "translate(-50%, -50%) scale(1)";
    setTimeout(() => {
      this.combatFeedback.style.opacity = "0";
    }, 400);
  }
  showHitInfo(type, distance, combo) {
    if (!this.hitIndicator) this.createHitIndicator();
    this.hitIndicator.innerHTML = `
      <div style="font-weight: bold; margin-bottom: 5px;">${
        type === "punch" ? "👊 SOCO" : "🦵 CHUTE"
      }</div>
      <div>Distância: ${Math.round(distance)}u</div>
      ${combo > 1 ? `<div style="color: #ffdd57;">Combo: x${combo}</div>` : ""}
    `;
    this.hitIndicator.style.display = "block";
    setTimeout(() => {
      this.hitIndicator.style.display = "none";
    }, 2000);
  }
}
// ===================================
// MÓDULO: SISTEMA DE COMBATE
// ===================================
class CombatSystem {
  constructor(model, scene, uiManager) {
    this.model = model;
    this.scene = scene;
    this.ui = uiManager;
    this.attackState = {
      punch: { canAttack: true, cooldownTimer: 0 },
      kick: { canAttack: true, cooldownTimer: 0 },
    };
    this.physicsObjects = [];
    this.comboCounter = 0;
    this.lastAttackTime = 0;
    // Debug visual
    this.debugMesh = null;
    this.createDebugVisualization();
  }
  createDebugVisualization() {
    // Cria um cone para visualizar o alcance do ataque (debug)
    const geometry = new THREE.ConeGeometry(1, 1, 16);
    const material = new THREE.MeshBasicMaterial({
      color: 0x00ff00,
      transparent: true,
      opacity: 0.2,
      wireframe: true,
    });
    this.debugMesh = new THREE.Mesh(geometry, material);
    this.debugMesh.visible = false;
    this.scene.add(this.debugMesh);
  }
  addPhysicsObject(physicsObj) {
    this.physicsObjects.push(physicsObj);
  }
  tryAttack(type, isJumping) {
    if (!this.model || isJumping) return false;
    const state = this.attackState[type];
    if (!state || !state.canAttack) return false;
    const config = CONFIG.ATTACK[type.toUpperCase()];
    // Bloqueia novo ataque
    state.canAttack = false;
    state.cooldownTimer = config.COOLDOWN;
    // Atualiza combo
    const now = Date.now();
    if (now - this.lastAttackTime < 1500) {
      this.comboCounter++;
    } else {
      this.comboCounter = 1;
    }
    this.lastAttackTime = now;
    // Agenda o hit no momento apropriado da animação
    setTimeout(
      () => this.performAttack(type, config),
      config.HIT_WINDOW * 1000
    );
    return true;
  }
  performAttack(type, config) {
    if (!this.model) return;
    // Posição e direção do personagem
    const yaw = this.model.rotation.y;
    const forward = new THREE.Vector3(Math.sin(yaw), 0, Math.cos(yaw));
    const origin = this.model.position.clone();
    origin.y += 50; // Altura aproximada do soco/chute
    // Detecta hits
    const result = this.detectHits(origin, forward, config);
    if (result.hits.length > 0) {
      result.hits.forEach((hit) => {
        this.applyHitEffects(hit, config, type);
      });
      const firstHit = result.hits[0];
      this.ui.showCombatHit(
        type,
        config.DAMAGE * this.comboCounter,
        firstHit.distance,
        this.comboCounter
      );
    } else {
      this.ui.showCombatMiss(type, result.reason);
    }
    // Visualização debug (temporária)
    this.showDebugVisualization(
      origin,
      forward,
      config,
      result.hits.length > 0
    );
  }
  detectHits(origin, forward, config) {
    const hits = [];
    let closestDistance = Infinity;
    let reason = "default";
    this.physicsObjects.forEach((physObj) => {
      if (!physObj.mesh.visible) return;
      const toObj = physObj.mesh.position.clone().sub(origin);
      const horizontal = new THREE.Vector3(toObj.x, 0, toObj.z);
      const dist = horizontal.length();
      // Verifica distância mínima
      if (dist < config.MIN_RANGE) {
        if (dist < closestDistance) {
          closestDistance = dist;
          reason = "too-close";
        }
        return;
      }
      // Verifica distância máxima
      if (dist > config.MAX_RANGE) {
        if (dist < closestDistance) {
          closestDistance = dist;
          reason = "too-far";
        }
        return;
      }
      // Verifica ângulo
      const angle = Math.acos(
        Math.max(-1, Math.min(1, forward.dot(horizontal.normalize())))
      );
      if (angle > config.ANGLE / 2) {
        if (dist < closestDistance) {
          closestDistance = dist;
          reason = "wrong-angle";
        }
        return;
      }
      // Hit válido!
      hits.push({
        physicsObject: physObj,
        distance: dist,
        direction: horizontal.normalize(),
        angle: angle,
        impactPoint: physObj.mesh.position.clone(),
      });
    });
    // Ordena por distância (mais próximo primeiro)
    hits.sort((a, b) => a.distance - b.distance);
    return { hits, reason };
  }
  applyHitEffects(hit, config, type) {
    const { physicsObject, distance, direction, impactPoint } = hit;
    // Calcula força baseada na distância (mais perto = mais força)
    const distanceFactor =
      1 - (distance - config.MIN_RANGE) / (config.MAX_RANGE - config.MIN_RANGE);
    const comboMultiplier = 1.0 + (this.comboCounter - 1) * 0.3;
    const totalForce =
      config.FORCE * config.KNOCKBACK * distanceFactor * comboMultiplier;
    // Aplica força com componente vertical
    const forceVector = direction.clone().multiplyScalar(totalForce);
    forceVector.y = totalForce * 0.4; // Lança o objeto para cima
    physicsObject.applyForce(forceVector, impactPoint);
    // Aplica dano
    const damage = Math.round(config.DAMAGE * comboMultiplier);
    physicsObject.takeDamage(damage);
    // Efeito visual de impacto
    this.createImpactEffect(impactPoint, type);
    console.log(
      `💥 ${type.toUpperCase()} | Dist: ${Math.round(
        distance
      )}u | Força: ${Math.round(totalForce)} | DMG: ${damage} | Combo: x${
        this.comboCounter
      }`
    );
  }
  createImpactEffect(position, type) {
    // Cria partículas no ponto de impacto
    const particleCount = 15;
    const geometry = new THREE.SphereGeometry(2, 4, 4);
    const color = type === "kick" ? 0xff6b6b : 0xffdd57;
    const material = new THREE.MeshBasicMaterial({ color: color });
    for (let i = 0; i < particleCount; i++) {
      const particle = new THREE.Mesh(geometry, material.clone());
      particle.position.copy(position);
      const velocity = new THREE.Vector3(
        (Math.random() - 0.5) * 100,
        Math.random() * 80 + 40,
        (Math.random() - 0.5) * 100
      );
      this.scene.add(particle);
      // Anima partícula
      const startTime = Date.now();
      const animate = () => {
        const elapsed = (Date.now() - startTime) / 1000;
        if (elapsed < 0.5) {
          particle.position.add(velocity.clone().multiplyScalar(0.016));
          velocity.y -= 300 * 0.016; // Gravidade
          particle.scale.multiplyScalar(0.95);
          particle.material.opacity = 1 - elapsed * 2;
          particle.material.transparent = true;
          requestAnimationFrame(animate);
        } else {
          this.scene.remove(particle);
          particle.geometry.dispose();
          particle.material.dispose();
        }
      };
      animate();
    }
  }
  showDebugVisualization(origin, forward, config, hit) {
    if (!this.debugMesh) return;
    // Configura o cone de debug
    const radius = Math.tan(config.ANGLE / 2) * config.MAX_RANGE;
    this.debugMesh.scale.set(radius, config.MAX_RANGE, radius);
    this.debugMesh.position.copy(origin);
    this.debugMesh.rotation.x = Math.PI / 2;
    this.debugMesh.rotation.z = -Math.atan2(forward.x, forward.z);
    this.debugMesh.material.color.setHex(hit ? 0x00ff00 : 0xff0000);
    this.debugMesh.visible = true;
    // Esconde após 200ms
    setTimeout(() => {
      if (this.debugMesh) this.debugMesh.visible = false;
    }, 200);
  }
  update(delta) {
    // Atualiza cooldowns
    Object.keys(this.attackState).forEach((type) => {
      const state = this.attackState[type];
      if (state.cooldownTimer > 0) {
        state.cooldownTimer -= delta;
        if (state.cooldownTimer <= 0) {
          state.cooldownTimer = 0;
          state.canAttack = true;
        }
      }
    });
    // Reset combo
    if (Date.now() - this.lastAttackTime > 2000) {
      this.comboCounter = 0;
    }
    // Atualiza física dos objetos
    this.physicsObjects.forEach((physObj) => {
      physObj.update(delta);
    });
  }
}
// ===================================
// MÓDULO: GERENCIADOR DE ANIMAÇÕES
// ===================================
class AnimationManager {
  constructor(mixer) {
    this.mixer = mixer;
    this.actions = {};
    this.activeAction = null;
    this.currentActionName = "";
  }
  addAction(name, clip) {
    this.actions[name] = this.mixer.clipAction(clip);
  }
  play(name, loop = true) {
    if (!this.actions[name] || this.currentActionName === name) return;
    const newAction = this.actions[name];
    if (loop) {
      newAction.loop = THREE.LoopRepeat;
      newAction.clampWhenFinished = false;
    } else {
      newAction.loop = THREE.LoopOnce;
      newAction.clampWhenFinished = true;
    }
    if (this.activeAction && this.activeAction !== newAction) {
      this.activeAction.fadeOut(CONFIG.ANIMATION_FADE_DURATION);
    }
    newAction.reset().fadeIn(CONFIG.ANIMATION_FADE_DURATION).play();
    this.activeAction = newAction;
    this.currentActionName = name;
  }
  findAndPlay(partialName, loop = true) {
    const name = Object.keys(this.actions).find((k) =>
      k.toLowerCase().includes(partialName.toLowerCase())
    );
    if (name) this.play(name, loop);
  }
  update(delta) {
    if (this.mixer) {
      this.mixer.update(delta);
    }
  }
  getActionNames() {
    return Object.keys(this.actions);
  }
  setSpeed(speed) {
    if (this.mixer) {
      this.mixer.timeScale = speed;
    }
  }
}
// ===================================
// CONTROLADOR PRINCIPAL
// ===================================
class CharacterController {
  constructor() {
    this.ui = new UIManager();
    this.ui.createLoadingScreen();
    this.initScene();
    this.initLights();
    this.initGround();
    this.initControls();
    this.initInput();
    this.loader = new FBXLoader();
    this.clock = new THREE.Clock();
    // Estado do personagem
    this.model = null;
    this.mixer = null;
    this.animManager = null;
    this.combatSystem = null;
    // Física
    this.velocity = new THREE.Vector3(0, 0, 0);
    this.isOnGround = true;
    this.isJumping = false;
    this.coyoteTimer = 0;
    this.jumpPhase = "none";
    // Input
    this.keys = {
      forward: false,
      backward: false,
      left: false,
      right: false,
      jump: false,
      run: false,
    };
    // Câmera
    this.cameraOffset = new THREE.Vector3(
      0,
      CONFIG.CAMERA_HEIGHT,
      CONFIG.CAMERA_DISTANCE
    );
    // Colisão
    this.collidableObjects = [];
    this.gui = null;
  }
  initScene() {
    this.scene = new THREE.Scene();
    this.scene.background = new THREE.Color(0x87ceeb);
    this.scene.fog = new THREE.Fog(0x87ceeb, 600, 1200);
    this.camera = new THREE.PerspectiveCamera(
      CONFIG.CAMERA_FOV,
      window.innerWidth / window.innerHeight,
      0.1,
      2000
    );
    this.camera.position.set(0, CONFIG.CAMERA_HEIGHT, CONFIG.CAMERA_DISTANCE);
    this.renderer = new THREE.WebGLRenderer({
      antialias: true,
      powerPreference: "high-performance",
    });
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    this.renderer.outputColorSpace = THREE.SRGBColorSpace;
    document.body.appendChild(this.renderer.domElement);
  }
  initLights() {
    this.dirLight = new THREE.DirectionalLight(0xffffff, 3);
    this.dirLight.position.set(150, 250, 100);
    this.dirLight.castShadow = true;
    const shadowCam = this.dirLight.shadow.camera;
    shadowCam.top = shadowCam.right = 400;
    shadowCam.bottom = shadowCam.left = -400;
    shadowCam.near = 0.1;
    shadowCam.far = 600;
    this.dirLight.shadow.mapSize.set(
      CONFIG.SHADOW_MAP_SIZE,
      CONFIG.SHADOW_MAP_SIZE
    );
    this.dirLight.shadow.bias = -0.0001;
    this.dirLight.shadow.radius = 2;
    this.scene.add(this.dirLight);
    const ambient = new THREE.AmbientLight(0x666666, 2);
    this.scene.add(ambient);
    const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 1.5);
    hemiLight.position.set(0, 300, 0);
    this.scene.add(hemiLight);
  }
  initGround() {
    const groundGeometry = new THREE.PlaneGeometry(2000, 2000);
    const groundMaterial = new THREE.MeshStandardMaterial({
      color: 0x558855,
      roughness: 0.8,
      metalness: 0.2,
    });
    this.ground = new THREE.Mesh(groundGeometry, groundMaterial);
    this.ground.rotation.x = -Math.PI / 2;
    this.ground.receiveShadow = true;
    this.scene.add(this.ground);
    this.gridHelper = new THREE.GridHelper(2000, 100, 0x888888, 0x444444);
    this.gridHelper.position.y = 0.1;
    this.scene.add(this.gridHelper);
  }
  createEnvironmentObjects() {
    const cubeGeometry = new THREE.BoxGeometry(50, 50, 50);
    const positions = [
      [200, 25, 0],
      [-200, 25, 0],
      [0, 25, 200],
      [0, 25, -200],
      [200, 25, 200],
      [-200, 25, -200],
    ];
    positions.forEach((pos, i) => {
      const material = new THREE.MeshStandardMaterial({
        color: new THREE.Color().setHSL(i / positions.length, 0.7, 0.5),
        roughness: 0.5,
        metalness: 0.3,
      });
      const cube = new THREE.Mesh(cubeGeometry, material);
      cube.position.set(...pos);
      cube.castShadow = true;
      cube.receiveShadow = true;
      this.scene.add(cube);
      // Adiciona física ao cubo
      const physicsObj = new PhysicsObject(cube);
      this.combatSystem.addPhysicsObject(physicsObj);
      // Adiciona à lista de objetos colidíveis
      this.collidableObjects.push({
        mesh: cube,
        radius: CONFIG.OBJECT_RADIUS,
      });
    });
  }
  initControls() {
    this.controls = new OrbitControls(this.camera, this.renderer.domElement);
    this.controls.enableDamping = true;
    this.controls.dampingFactor = 0.05;
    this.controls.target.set(0, 100, 0);
    this.controls.minDistance = 150;
    this.controls.maxDistance = 600;
    this.controls.maxPolarAngle = Math.PI / 2 - 0.05;
    this.controls.enablePan = false;
  }
  initInput() {
    document.addEventListener("keydown", (e) => this.onKeyDown(e));
    document.addEventListener("keyup", (e) => this.onKeyUp(e));
    window.addEventListener("resize", () => this.onResize());
    this.ui.createInstructions();
  }
  onKeyDown(e) {
    if (e.repeat) return;
    switch (e.code) {
      case "KeyW":
      case "ArrowUp":
        this.keys.forward = true;
        break;
      case "KeyS":
      case "ArrowDown":
        this.keys.backward = true;
        break;
      case "KeyA":
      case "ArrowLeft":
        this.keys.left = true;
        break;
      case "KeyD":
      case "ArrowRight":
        this.keys.right = true;
        break;
      case "Space":
        e.preventDefault();
        this.keys.jump = true;
        break;
      case "ShiftLeft":
      case "ShiftRight":
        this.keys.run = true;
        break;
      case "KeyR":
        this.resetPosition();
        break;
      case "KeyJ":
        this.attack("punch");
        break;
      case "KeyK":
        this.attack("kick");
        break;
    }
  }
  onKeyUp(e) {
    switch (e.code) {
      case "KeyW":
      case "ArrowUp":
        this.keys.forward = false;
        break;
      case "KeyS":
      case "ArrowDown":
        this.keys.backward = false;
        break;
      case "KeyA":
      case "ArrowLeft":
        this.keys.left = false;
        break;
      case "KeyD":
      case "ArrowRight":
        this.keys.right = false;
        break;
      case "Space":
        this.keys.jump = false;
        break;
      case "ShiftLeft":
      case "ShiftRight":
        this.keys.run = false;
        break;
    }
  }
  onResize() {
    this.camera.aspect = window.innerWidth / window.innerHeight;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(window.innerWidth, window.innerHeight);
  }
  attack(type) {
    if (!this.combatSystem) return;
    const success = this.combatSystem.tryAttack(type, this.isJumping);
    if (success) {
      this.animManager.findAndPlay(type, false);
    }
  }
  async loadFBX(filename) {
    return new Promise((resolve, reject) => {
      this.loader.load(
        `./models/${filename}`,
        (obj) => resolve(obj),
        (xhr) => {
          const percent = (xhr.loaded / xhr.total) * 100;
          console.log(`📦 ${filename}: ${percent.toFixed(0)}%`);
        },
        (err) => reject(err)
      );
    });
  }
  async loadModelAndAnimations() {
    const animationFiles = [
      "Idle.fbx",
      "Jumping.fbx",
      "jumping-down.fbx",
      "running.fbx",
      "punch.fbx",
      "kick.fbx",
    ];
    try {
      this.ui.updateLoadingProgress(10, "Carregando modelo principal...");
      const mainModel = await this.loadFBX(animationFiles[0]);
      mainModel.scale.setScalar(CONFIG.MODEL_SCALE);
      mainModel.traverse((child) => {
        if (child.isMesh) {
          child.castShadow = true;
          child.receiveShadow = true;
          if (child.material) {
            child.material.needsUpdate = true;
            child.material.side = THREE.FrontSide;
          }
        }
      });
      this.scene.add(mainModel);
      this.model = mainModel;
      this.mixer = new THREE.AnimationMixer(this.model);
      this.animManager = new AnimationManager(this.mixer);
      this.combatSystem = new CombatSystem(this.model, this.scene, this.ui);
      this.ui.updateLoadingProgress(30, "Configurando animações...");
      if (mainModel.animations?.length > 0) {
        const clip = mainModel.animations[0];
        const clipName = this.getClipName(animationFiles[0], clip, 0);
        this.animManager.addAction(clipName, clip);
      }
      const progressStep = 50 / animationFiles.length;
      for (let i = 1; i < animationFiles.length; i++) {
        const filename = animationFiles[i];
        this.ui.updateLoadingProgress(
          30 + progressStep * i,
          `Carregando ${filename}...`
        );
        const obj = await this.loadFBX(filename);
        if (obj.animations?.length > 0) {
          const clip = obj.animations[0];
          const clipName = this.getClipName(filename, clip, i);
          this.animManager.addAction(clipName, clip);
        }
      }
      // Cria objetos com física DEPOIS do combatSystem estar pronto
      this.createEnvironmentObjects();
      this.ui.updateLoadingProgress(90, "Finalizando...");
      this.setupGUI();
      this.ui.updateLoadingProgress(100, "Pronto!");
      setTimeout(() => this.ui.hideLoadingScreen(), 500);
      console.log("🎉 Sistema de combate carregado!");
    } catch (err) {
      console.error("❌ Erro ao carregar assets:", err);
      this.ui.updateLoadingProgress(
        0,
        "Erro ao carregar! Verifique o console."
      );
    }
  }
  getClipName(filename, clip, index) {
    const baseName = filename.replace(".fbx", "");
    return `${baseName}_${clip.name || `Anim_${index}`}`;
  }
  updateJumpAnimation() {
    if (!this.model || !this.animManager) return;
    const velocityY = this.velocity.y;
    if (velocityY > CONFIG.JUMP_UP_THRESHOLD && this.jumpPhase !== "rising") {
      this.jumpPhase = "rising";
      this.animManager.findAndPlay("jumping", false);
    } else if (
      velocityY < CONFIG.JUMP_DOWN_THRESHOLD &&
      this.jumpPhase !== "falling"
    ) {
      this.jumpPhase = "falling";
      this.animManager.findAndPlay("jumping-down", false);
    }
  }
  tryJump() {
    const canJump =
      (this.isOnGround || this.coyoteTimer > 0) && !this.isJumping;
    if (canJump) {
      this.velocity.y = CONFIG.JUMP_VELOCITY;
      this.isOnGround = false;
      this.isJumping = true;
      this.jumpPhase = "rising";
      this.coyoteTimer = 0;
      this.animManager.findAndPlay("jumping", false);
    }
  }
  updateMovement(delta) {
    if (!this.model) return;
    if (!this.isOnGround && this.coyoteTimer > 0) {
      this.coyoteTimer -= delta;
    }
    // Aplicar gravidade
    this.velocity.y = Math.max(
      this.velocity.y + CONFIG.GRAVITY * delta,
      CONFIG.MAX_FALL_SPEED
    );
    // Processar pulo
    if (this.keys.jump && !this.isJumping) {
      this.tryJump();
      this.keys.jump = false;
    }
    // Atualiza animação de pulo
    this.updateJumpAnimation();
    // Movimento horizontal
    const speedMultiplier = this.keys.run ? CONFIG.RUN_SPEED_MULTIPLIER : 1;
    const moveSpeed = CONFIG.MOVEMENT_SPEED * speedMultiplier * delta;
    const moveDir = new THREE.Vector3(0, 0, 0);
    let isMoving = false;
    if (this.keys.forward) {
      moveDir.z -= 1;
      isMoving = true;
    }
    if (this.keys.backward) {
      moveDir.z += 1;
      isMoving = true;
    }
    if (this.keys.left) {
      moveDir.x -= 1;
      isMoving = true;
    }
    if (this.keys.right) {
      moveDir.x += 1;
      isMoving = true;
    }
    if (moveDir.lengthSq() > 0) {
      moveDir.normalize();
      // Rotação suave em direção ao movimento
      const targetAngle = Math.atan2(moveDir.x, moveDir.z);
      const currentAngle = this.model.rotation.y;
      let angleDiff = targetAngle - currentAngle;
      // Normaliza para o caminho mais curto
      while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
      while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
      // Aplica rotação suave
      this.model.rotation.y += angleDiff * CONFIG.ROTATION_SPEED * delta;
      // Calcula nova posição
      const moveDist = moveSpeed;
      const newX = this.model.position.x + Math.sin(targetAngle) * moveDist;
      const newZ = this.model.position.z + Math.cos(targetAngle) * moveDist;
      // Verifica colisão antes de mover
      const newPos = new THREE.Vector3(newX, this.model.position.y, newZ);
      if (!this.checkCollision(newPos)) {
        this.model.position.x = newX;
        this.model.position.z = newZ;
      } else {
        // Tenta deslizar ao longo da parede
        const slideX = new THREE.Vector3(
          newX,
          this.model.position.y,
          this.model.position.z
        );
        const slideZ = new THREE.Vector3(
          this.model.position.x,
          this.model.position.y,
          newZ
        );
        if (!this.checkCollision(slideX)) {
          this.model.position.x = newX;
        } else if (!this.checkCollision(slideZ)) {
          this.model.position.z = newZ;
        }
      }
    }
    // Trocar animação (exceto durante pulo ou ataque)
    if (
      !this.isJumping &&
      this.combatSystem.attackState.punch.canAttack &&
      this.combatSystem.attackState.kick.canAttack
    ) {
      if (isMoving) {
        this.animManager.findAndPlay("running");
      } else {
        this.animManager.findAndPlay("idle");
      }
    }
    // Aplicar velocidade vertical
    this.model.position.y += this.velocity.y * delta;
    // Colisão com chão
    if (this.model.position.y <= CONFIG.GROUND_LEVEL) {
      this.model.position.y = CONFIG.GROUND_LEVEL;
      this.velocity.y = 0;
      if (this.isJumping) {
        this.isJumping = false;
        this.jumpPhase = "none";
      }
      this.isOnGround = true;
      this.coyoteTimer = CONFIG.COYOTE_TIME;
    } else {
      if (this.isOnGround) {
        this.coyoteTimer = CONFIG.COYOTE_TIME;
      }
      this.isOnGround = false;
    }
  }
  checkCollision(newPosition) {
    // Verifica colisão com todos os objetos colidíveis
    for (let i = 0; i < this.collidableObjects.length; i++) {
      const obj = this.collidableObjects[i];
      // Ignora objetos invisíveis (destruídos)
      if (!obj.mesh.visible) continue;
      const objPos = obj.mesh.position;
      const distance = new THREE.Vector2(
        newPosition.x - objPos.x,
        newPosition.z - objPos.z
      ).length();
      const minDistance = CONFIG.CHARACTER_RADIUS + obj.radius;
      if (distance < minDistance) {
        return true; // Colisão detectada
      }
    }
    return false; // Sem colisão
  }
  updateCamera() {
    if (!this.model) return;
    const targetPos = this.model.position.clone();
    const targetLookAt = targetPos.clone();
    targetLookAt.y += 100;
    this.controls.target.lerp(targetLookAt, CONFIG.CAMERA_FOLLOW_SMOOTHNESS);
  }
  resetCamera() {
    this.camera.position.set(0, CONFIG.CAMERA_HEIGHT, CONFIG.CAMERA_DISTANCE);
    this.controls.target.set(0, 100, 0);
  }
  resetPosition() {
    if (this.model) {
      this.model.position.set(0, 0, 0);
      this.model.rotation.set(0, 0, 0);
      this.velocity.set(0, 0, 0);
      this.isJumping = false;
      this.jumpPhase = "none";
      this.animManager.findAndPlay("idle");
    }
    this.resetCamera();
  }
  setupGUI() {
    this.gui = new GUI();
    this.gui.title("⚔️ Controles de Combate");
    const animNames = this.animManager.getActionNames();
    if (animNames.length === 0) return;
    const guiControls = {
      animation: animNames[0],
      speed: 1.0,
      lightIntensity: 3,
      shadowsEnabled: true,
      showGrid: true,
      debugVisualization: false,
      showColliders: false,
      punchRange: CONFIG.ATTACK.PUNCH.MAX_RANGE,
      kickRange: CONFIG.ATTACK.KICK.MAX_RANGE,
      jump: () => this.tryJump(),
      punch: () => this.attack("punch"),
      kick: () => this.attack("kick"),
      resetCamera: () => this.resetCamera(),
      resetPosition: () => this.resetPosition(),
    };
    // Pasta de Animações
    const animFolder = this.gui.addFolder("🎬 Animações");
    animFolder
      .add(guiControls, "animation", animNames)
      .name("Animação")
      .onChange((n) => this.animManager.play(n));
    animFolder
      .add(guiControls, "speed", 0.1, 3, 0.1)
      .name("Velocidade")
      .onChange((v) => this.animManager.setSpeed(v));
    animFolder.add(guiControls, "jump").name("⬆️ Pular");
    // Pasta de Combate
    const combatFolder = this.gui.addFolder("⚔️ Combate");
    combatFolder.add(guiControls, "punch").name("👊 Soco (J)");
    combatFolder.add(guiControls, "kick").name("🦵 Chute (K)");
    combatFolder
      .add(guiControls, "punchRange", 50, 200, 10)
      .name("Alcance Soco")
      .onChange((v) => (CONFIG.ATTACK.PUNCH.MAX_RANGE = v));
    combatFolder
      .add(guiControls, "kickRange", 80, 250, 10)
      .name("Alcance Chute")
      .onChange((v) => (CONFIG.ATTACK.KICK.MAX_RANGE = v));
    combatFolder
      .add(guiControls, "debugVisualization")
      .name("Debug Visual")
      .onChange((v) => {
        if (this.combatSystem.debugMesh) {
          this.combatSystem.debugMesh.visible = v;
        }
      });
    combatFolder
      .add(guiControls, "showColliders")
      .name("Mostrar Colisões")
      .onChange((v) => this.toggleColliderVisualization(v));
    combatFolder.open();
    // Pasta de Visual
    const visualFolder = this.gui.addFolder("💡 Visual");
    visualFolder
      .add(guiControls, "lightIntensity", 0, 10, 0.5)
      .name("Intensidade da Luz")
      .onChange((v) => (this.dirLight.intensity = v));
    visualFolder
      .add(guiControls, "shadowsEnabled")
      .name("Sombras")
      .onChange((v) => {
        this.renderer.shadowMap.enabled = v;
        this.dirLight.castShadow = v;
      });
    visualFolder
      .add(guiControls, "showGrid")
      .name("Mostrar Grade")
      .onChange((v) => {
        this.gridHelper.visible = v;
      });
    // Pasta de Personagem
    const charFolder = this.gui.addFolder("🎮 Personagem");
    charFolder.add(guiControls, "resetCamera").name("🔄 Resetar Câmera");
    charFolder.add(guiControls, "resetPosition").name("🔄 Resetar Posição");
    // Inicia com a primeira animação
    this.animManager.play(animNames[0]);
  }
  toggleColliderVisualization(show) {
    // Remove visualizações anteriores
    if (this.colliderHelpers) {
      this.colliderHelpers.forEach((helper) => this.scene.remove(helper));
    }
    this.colliderHelpers = [];
    if (show) {
      // Adiciona círculo para o personagem
      const charGeometry = new THREE.CircleGeometry(
        CONFIG.CHARACTER_RADIUS,
        32
      );
      const charMaterial = new THREE.MeshBasicMaterial({
        color: 0x00ff00,
        transparent: true,
        opacity: 0.3,
        side: THREE.DoubleSide,
      });
      const charHelper = new THREE.Mesh(charGeometry, charMaterial);
      charHelper.rotation.x = -Math.PI / 2;
      charHelper.position.y = 1;
      this.model.add(charHelper);
      this.colliderHelpers.push(charHelper);
      // Adiciona círculos para os objetos
      this.collidableObjects.forEach((obj) => {
        const objGeometry = new THREE.CircleGeometry(obj.radius, 32);
        const objMaterial = new THREE.MeshBasicMaterial({
          color: 0xff0000,
          transparent: true,
          opacity: 0.3,
          side: THREE.DoubleSide,
        });
        const objHelper = new THREE.Mesh(objGeometry, objMaterial);
        objHelper.rotation.x = -Math.PI / 2;
        objHelper.position.y = 1;
        obj.mesh.add(objHelper);
        this.colliderHelpers.push(objHelper);
      });
    }
  }
  animate = () => {
    requestAnimationFrame(this.animate);
    const delta = Math.min(this.clock.getDelta(), 0.1);
    if (this.animManager) {
      this.animManager.update(delta);
    }
    if (this.combatSystem) {
      this.combatSystem.update(delta);
    }
    this.updateMovement(delta);
    this.updateCamera();
    this.controls.update();
    this.renderer.render(this.scene, this.camera);
  };
  async init() {
    await this.loadModelAndAnimations();
    this.animate();
  }
}
// ===================================
// EXECUTAR
// ===================================
const controller = new CharacterController();
controller.init().catch((err) => {
  console.error("❌ Erro :", err);
  const loader = document.getElementById("loading-screen");
  if (loader) {
    loader.querySelector("#loading-text").textContent =
      "Erro ao carregar! Verifique se os arquivos FBX estão na pasta ./models/";
  }
});

 

Você pode visualizar o jogo no link abaixo:

Game Exemplo com TreeJs 

 

Deixo aqui um vídeo curto do Jogo:

 

 

 

 

Conclusão

O Three.js mostra que criar jogos ou animações 3D com poucas linhas de código é mais simples e acessível do que parece.
Com um pouco de criatividade e curiosidade, qualquer pessoa pode transformar o seu site ou blog em um mundo cheio de luzes, cores e movimento.

 

Código fonte do jogo em Javascript:

Código Fonte

 

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *